feat: Doing more polishing (M8b)
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
# Writing scenarios
|
||||||
|
|
||||||
|
Guide for anyone (human or model) adding a new fictional country or situation to "The Desk". A scenario is one JSON file
|
||||||
|
that sets the start state; the rules, the card pool, the newspaper and the endings are shared. Read `docs/BALANCE.md` and
|
||||||
|
`docs/CARD-WRITING.md` first. Reference scenario: `web/data/scenarios/default.json` (Valdoria).
|
||||||
|
|
||||||
|
## 1. What a scenario can change
|
||||||
|
|
||||||
|
`createGame` (`web/js/engine/game.js`) reads these fields, and nothing else:
|
||||||
|
|
||||||
|
| Field | What it does | Where it shows |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | key of the scenario, saved in every save | file name, `index.json`, saves |
|
||||||
|
| `name` `{en, fr}` | default country name (the player may type another) | menu list, save slots, newspaper `{country}`, export file name |
|
||||||
|
| `currency` | text only | "bn VLD" on the dashboard and the ledger |
|
||||||
|
| `startDate` | `YYYY-MM-DD`, day 0 | calendar, card dates, end screen |
|
||||||
|
| `economy` | gdp, debtRatio, growth, unemployment, inflation, otherRevenue, the three start tax rates | everything fiscal |
|
||||||
|
| `indicators` | the 9 start values (0 to 100) | dashboard, and the equilibrium (section 3) |
|
||||||
|
| `sectors` | `need` and `alloc` (% of GDP) for the 15 sectors | ledger, quality, cost of every lever |
|
||||||
|
|
||||||
|
**What a scenario cannot change without engine work.** All of it lives in shared files:
|
||||||
|
|
||||||
|
- Tax structure: the three taxes, their yield `coef`, Laffer `peak`, opinion and growth effects (`balance.json` `tax`). A scenario only picks start rates and `otherRevenue`.
|
||||||
|
- The 15 sectors (all must be present, and `balance.json` `sectorTau` / `needGrowth` must match), the 9 indicators, the 3 economy variables.
|
||||||
|
- How things react: indicator weights, drift, gain, world cycle, game-over thresholds, score formula, `needGrowth`.
|
||||||
|
- Characters (`characters.json`: fixed names and roles), the newspaper (`newspaper.json`, called "The Daily Herald"), endings and historian titles (`endgame.json`), the length of the term (1826 days).
|
||||||
|
- **Card text.** About 140 mentions of "Valdoria / Valdorian" (both languages) sit in card text, written literally; card bodies are not run through `{country}` (only the newspaper intro is). A new country therefore plays with Valdorian cards. Write the premise so this is tolerable (a sister state, a Valdorian region or era), or ask for `{country}` substitution in cards as engine work. Scenario-specific cards (section 5) may name the country directly.
|
||||||
|
- A scenario `description` or premise: the menu only reads `name`.
|
||||||
|
|
||||||
|
What really makes a scenario feel different, given shared rules:
|
||||||
|
|
||||||
|
1. **Where the start sits.** Indicators move toward `start value + weighted deviations`, so a country starting at opinion 35 stays near 35 until the player acts.
|
||||||
|
2. **Need versus alloc.** Quality is `100 * (1 - exp(-0.9 * alloc/need))`; the price of one more point of ratio is `need` % of GDP, so a sector with a small need is a cheap lever and a big need is an expensive one.
|
||||||
|
3. **The fiscal squeeze:** debt interest and taxes already close to their Laffer peak leave little room to move.
|
||||||
|
4. **Scenario cards** that stage the central tension.
|
||||||
|
|
||||||
|
## 2. Design brief (fill in before writing numbers)
|
||||||
|
|
||||||
|
Write this in the scenario's pull request or notes; an agent that cannot fill every line is not ready to write JSON.
|
||||||
|
|
||||||
|
| Question | Example answer |
|
||||||
|
|---|---|
|
||||||
|
| Identity: country, era, one-sentence premise | "A sugar island that borrowed to survive a hurricane." |
|
||||||
|
| Central tension: the two things the player cannot have together | "pay the interest or keep the clinics open" |
|
||||||
|
| Temptation: the easy move that backfires | "print growth with debt, or sell the ports" |
|
||||||
|
| First-year crisis the player will feel by week 8 | "bond auction in month 3" |
|
||||||
|
| Difficulty profile | tighter / same as default / easier, and which of the standard player types should change band (section 6) |
|
||||||
|
| Levers that are cheap and expensive (needs) | health need 9 (expensive), police need 0.8 (cheap) |
|
||||||
|
| Which indicators start high, which low, which near a danger zone | "unrest 44, but opinion 55: no zone" |
|
||||||
|
| Cards: which situations need a scenario-only card | 15 to 40 cards, 3 to 5 chains |
|
||||||
|
|
||||||
|
Use fiction only: no real country, person, party or brand (same rule as the cards). Prefer neighbours from the existing pool (Torvania, Marenia, Belmora, Kessaly, Draveth).
|
||||||
|
|
||||||
|
## 3. The scenario file
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "saltmere",
|
||||||
|
"name": { "en": "Free State of Saltmere", "fr": "État libre de Saltmère" },
|
||||||
|
"startDate": "2028-01-03",
|
||||||
|
"currency": "SLM",
|
||||||
|
"economy": { "gdp": 22, "debtRatio": 100, "growth": 1.5, "unemployment": 9, "inflation": 3,
|
||||||
|
"otherRevenue": 7, "taxes": { "income": 38, "corporate": 28, "vat": 26 } },
|
||||||
|
"indicators": { "order": 60, "stability": 50, "civilRights": 66, "opinion": 48, "economy": 45,
|
||||||
|
"hardPower": 20, "softPower": 40, "unrest": 28, "armyLoyalty": 62 },
|
||||||
|
"sectors": { "education": { "need": 4.5, "alloc": 4.0 }, "...": "all 15 sector ids, see default.json" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Unit | Sane range | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | lowercase letters and digits | | must equal the file name; avoid `_` (it is the card id prefix); never change it after release |
|
||||||
|
| `startDate` | ISO date | any | pick a **Monday**: cards arrive every 7th day from day 0 and the calendar shows weekdays. Never change after release (saves refuse a different date) |
|
||||||
|
| `gdp` | bn of currency | 15 to 2000 | display and absolute debt only; must be above 0 |
|
||||||
|
| `debtRatio` | % of GDP | 15 to 105 | see the interest table below; the engine warns at 135 and defaults at 165 |
|
||||||
|
| `growth` | % per year | -1 to 5 | clamped to -15..12 by the engine |
|
||||||
|
| `unemployment` / `inflation` | % | 2 to 15 / 0 to 12 | clamped 1..40 / -2..40 |
|
||||||
|
| `otherRevenue` | % of GDP | 3 to 24 | royalties, fees, aid; the main "resource state" knob |
|
||||||
|
| `taxes.income / corporate / vat` | rate in % | 15 to 45 / 10 to 40 / 10 to 30 | stay under about 75 % of the peaks 60 / 50 / 40, or the player cannot raise them |
|
||||||
|
| `indicators.*` | 0 to 100 | 15 to 85 | all 9 keys required; keep `unrest` 10 to 45, `armyLoyalty` 40 to 85 |
|
||||||
|
| `sectors.*.need` | % of GDP | 0.3 to 14 | greater than 0; the 15 ids as in `default.json` |
|
||||||
|
| `sectors.*.alloc` | % of GDP | 0.5 to 1.5 times need | engine accepts 0 to 3 times need |
|
||||||
|
|
||||||
|
### The equilibrium rule
|
||||||
|
|
||||||
|
`createGame` stores the start in `state.ref` (taxes, indicators, economy variables, sector quality, budget ratios, and
|
||||||
|
the fiscal deviations). Every target is `ref + gain * weight * (change since the start)`, so **the start is an exact
|
||||||
|
equilibrium whatever numbers you choose**: the start deficit is not punished, and a high start inflation does not hurt by
|
||||||
|
itself. A hands-off year still slides (default: order -5, stability -6, opinion -5, economy -6, unrest +8, army loyalty -5)
|
||||||
|
because of the yearly `drift` and growing needs; that slide is the intended pressure.
|
||||||
|
|
||||||
|
Some rules read **absolute** levels, so the start must respect them:
|
||||||
|
|
||||||
|
- **Interest and default.** Rate = 3 + 0.0008 * (debtRatio - 40)^2 %, interest = ratio * rate. Default at 165, warning at 135, and the budget must pay the interest every year:
|
||||||
|
|
||||||
|
| debt / GDP | 40 | 58 | 70 | 80 | 90 | 100 | 110 | 120 | 135 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| interest, % of GDP | 1.2 | 1.9 | 2.6 | 3.4 | 4.5 | 5.9 | 7.6 | 9.7 | 13.8 |
|
||||||
|
|
||||||
|
Industry quality also loses 0.25 per point of debt above 90.
|
||||||
|
- **Game-over zones**: revolution (unrest above 62 with opinion below 28), coup (army loyalty under 35 with stability under 50, or loyalty under 18), impeachment (civil rights under 10 with opinion under 25, 60 days). Start at least 15 points away from each.
|
||||||
|
- **Quality shaping.** Excess terms start at sector quality 70 (ratio 1.34) and short terms at 40 (ratio 0.57): outside that band the target is offset from the start. Keep ratios in 0.6 to 1.3 for health, social, housing, industry, infrastructure, education, research, police, justice, culture, foreign affairs.
|
||||||
|
- **Score**: debt (ref 60), average growth (ref 2) and unemployment (ref 6.5) are absolute, so a high-debt or slow start scores lower (debt 100 costs 8 points). This is why a scenario may need its own bands (section 6).
|
||||||
|
- Cards gated on `debt` (for example bond panic at 100, rating warning at 62, lenders at 105) fire from day 0 if the start is above them.
|
||||||
|
|
||||||
|
### The start fiscal picture
|
||||||
|
|
||||||
|
All flows are % of GDP per year (`economy.js`):
|
||||||
|
|
||||||
|
- Tax revenue = `coef * rate * (1 - rate / (2 * peak))`, with `coef` 0.5 / 0.25 / 0.55 and `peak` 60 / 50 / 40 for income / corporate / VAT. Revenue peaks at the peak rate (15 / 6.25 / 11 % of GDP); at the default rates (half the peak) one extra point yields 0.25 / 0.125 / 0.275.
|
||||||
|
- Raising a rate by d points costs opinion (`0.5 / 0.15 / 0.6` times d, then times -1.2 on the opinion target) and growth (`0.03 / 0.06 / 0.025` times d).
|
||||||
|
- `revenue = otherRevenue + sum of the three taxes`; `spending = sum of sector alloc + interest`; `deficit = spending - revenue`.
|
||||||
|
- Default: revenue 35.69, sectors 34.30, interest 1.89, deficit 0.50.
|
||||||
|
- **Aim for a start deficit between -1 and +1.5.** The debt ratio holds when deficit is about `growth * debtRatio / 100` (1.2 for the default), so a start above 2 spirals. Fix a gap by moving `otherRevenue`, tax rates, or sector `alloc`, never by changing a `need` (needs set prices, not budgets).
|
||||||
|
- Order of work: pick debt and taxes, compute revenue and interest, then split the remaining budget over the 15 sectors.
|
||||||
|
|
||||||
|
## 4. Registering the scenario
|
||||||
|
|
||||||
|
1. Save `web/data/scenarios/<id>.json`.
|
||||||
|
2. Add the id to `web/data/scenarios/index.json` (`"scenarios": ["default", "<id>"]`). Keep `default` first: it is the pre-selected scenario, the name field's placeholder, and the fallback.
|
||||||
|
3. Both `name.en` and `name.fr` are required (`TestScenarioIndex`). The menu shows the name only: the New game screen has a scenario `<select>` once there are two or more, and a blank country name field falls back to the chosen scenario's name in the current language. Names must be 40 characters or fewer (the field is `maxlength 40`).
|
||||||
|
4. `web/embed.go` embeds the `data` folder, so no code change is needed; rebuild the Docker image (`docker compose up --build`) to ship it.
|
||||||
|
5. Do not edit `balance.json`, the engine or other scenarios to make a scenario work. Changing numbers of a released scenario is safe (saves keep their own copy); changing `id`, `startDate` or the sector list breaks saves.
|
||||||
|
|
||||||
|
## 5. Scenario-specific cards (new feature)
|
||||||
|
|
||||||
|
Most of the pool is shared. A scenario can add cards visible only in that scenario through an optional card field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "saltmere_eco_hurricane_bonds_01", "category": "economy", "scenarios": ["saltmere"], "...": "same schema as CARD-WRITING.md" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `"scenarios": ["id", ...]` is a **whitelist**. A card without the field is available in every scenario. There is no blacklist, so shared cards stay shared.
|
||||||
|
- `go test ./server` validates it: a non-empty array of strings, each listed in `scenarios/index.json`.
|
||||||
|
- `node tools/add-cards.mjs <category> <batch.json>` keeps the field when it merges a batch.
|
||||||
|
- **Id prefix** `<scenarioId>_`, then the usual `<cat>_<slug>_<NN>`, for example `saltmere_eco_hurricane_bonds_01`. Ids stay unique across all files. Same category files and same schema, effects, size guide and voice as `docs/CARD-WRITING.md`.
|
||||||
|
- Keep chains closed: a card and its follow-ups carry the same whitelist, and a shared card never follows up to a scenario card. Flags read by a scenario card must be set by a card available in that scenario.
|
||||||
|
- Use conditions on the situation (`min: {"debt": 90}`, `ratio:army`, `dayMin`) so the cards appear where the story says. The scenario's tension cards may weigh 1.5.
|
||||||
|
- Naming the country and its places is fine here (shared cards cannot do it). Both languages, no pronouns for people.
|
||||||
|
- Budget 15 to 40 cards: about 5 available from the start, the rest spread over the term, and 3 to 5 chains. Run `node tools/card-stats.mjs` on the files you touched.
|
||||||
|
|
||||||
|
## 6. Validation
|
||||||
|
|
||||||
|
| Command | Checks |
|
||||||
|
|---|---|
|
||||||
|
| `go test ./server` | scenario index (id equals the file name, localized name, listed and present), card schema including `scenarios`, i18n. **The economy, sectors, indicators and taxes of a scenario other than `default` are not covered by Go tests**: use the tools below. |
|
||||||
|
| `node tools/engine-check.mjs` | engine rules (uses `default.json`) |
|
||||||
|
| `node tools/scenario-check.mjs <id>` (new) | prints the start fiscal picture (revenue, sector spending, interest, deficit, debt) and a one-year hands-off drift; warns when the start is far from balanced. The draft warns for a deficit above 2, debt above 110, sector ratios outside 0.5 to 1.5, indicators outside 5 to 95, and exits with 1 on any warning. |
|
||||||
|
| `node tools/balance-check.mjs 24 --check --scenario <id>` (new) | plays the scenario with the standard player types and compares them to the bands in `balance-check.mjs` (about 30 s) |
|
||||||
|
|
||||||
|
Compare the hands-off drift with the default (about -5 on opinion, stability and economy, +8 unrest in a year). A scenario that
|
||||||
|
collapses or ignores a year of inaction was built on the wrong numbers.
|
||||||
|
|
||||||
|
**Own bands.** A scenario that is deliberately harder or easier ships `web/data/scenarios/<id>.targets.json`, same shape as
|
||||||
|
`TARGETS` and merged over the defaults:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "notes": "Debt 100 costs 8 score points and interest eats 6 % of GDP, so scores sit about 8 lower and spendMild is fatal.",
|
||||||
|
"targets": { "plannerHuman": { "survive": [85, 100], "score": [48, 60], "top": [0, 40] }, "spendMild": { "survive": [0, 60] } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`notes` must say why the bands differ. Move only the bands that the premise explains; the extremes (`ruin`, `starve`,
|
||||||
|
`tyrant`) must still end in a fall, and `plannerHuman` must still survive. Do not retune `balance.json` to rescue a scenario.
|
||||||
|
|
||||||
|
## 7. Scenario ideas
|
||||||
|
|
||||||
|
Ranges are starting points. The fiscal columns were checked by hand against the formulas (a midpoint gives a deficit
|
||||||
|
near +0.5); run `scenario-check` on the real values.
|
||||||
|
|
||||||
|
| Idea | Premise | Start ranges | What makes it different |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Debt-ridden island (`saltmere`) | A tourism and sugar island borrowed to rebuild after a hurricane; lenders now watch every ledger. | debt 95-105, gdp 15-40, growth 1-2, otherRevenue 6-9, taxes near 65 % of peak (38 / 28 / 26), sectors total 28-30, interest 5.5-6.5 | Interest takes 6 % of GDP, so every service is squeezed and tax hikes hit the Laffer wall early; the pull is to borrow once more. Bands: lower scores, survival of `spendMild` near 0. |
|
||||||
|
| Resource-rich, weak army (`karhaven`) | A gas exporter whose royalties fund the state while the army is a parade force next to hostile neighbours. | otherRevenue 18-24, income 12-18, corporate 15-22, vat 10-14, debt 25-40, growth 3-5, sectors total 33-38; army need 3-4 with alloc 1.2-1.6, armyLoyalty 40-50, hardPower 20-30 | Cheap taxes and a large windfall make spending easy; the danger is the army and a royalty crash (scenario cards with `revenue: other` cuts). Coup risk is the real threat. |
|
||||||
|
| Post-war republic (`ashford`) | A republic after a short civil war, with an army that is loyal but restless about pay and demobilisation. | debt 70-90, growth 3-4, unemployment 10-13, armyLoyalty 70-80, stability 40-48, order 40-50, unrest 33-42, taxes 24 / 20 / 18, otherRevenue 7-9, sectors total 26-29; army need 4 alloc 3.5-4; housing and justice need high, alloc 60-70 % | Loyalty is high but erodes 1.1 points a year (drift), cutting the army is the shortcut to a coup, and reconstruction wants housing and justice first. Check revolution distance (unrest below 47). |
|
||||||
|
| Rich small state under a big neighbour (`lysmark`) | A prosperous harbour state whose neighbour decides its trade and defence; it has to look independent without a real army. | debt 15-30, growth 1-2, unemployment 3-4.5, taxes 22 / 15 / 14, otherRevenue 9-12, sectors total 26-30, deficit -1 to +0.5; softPower 60-70, hardPower 10-20, opinion 65-72, economy 65-72 | Everything starts high so the drift hurts; foreign affairs and culture are the real levers, defence is nearly impossible. Scenario cards carry the neighbour's pressure. Bands: `top` grades rarer. |
|
||||||
|
| Ageing welfare state (`aldermoor`) | A cold, orderly republic whose pensions and clinics grow faster than its economy. | debt 70-85, growth 0.5-1.5, unemployment 4-5.5, social need 13-15, health need 8-10, taxes 36 / 24 / 24, otherRevenue 8-10, sectors total 32-35 | Social and health needs are expensive levers and grow every year (`needGrowth`); growth sectors are starved. Tension is pensions versus investment; a slow growth start lowers the score floor (note it). |
|
||||||
|
|
||||||
|
## 8. Checklist for the agent
|
||||||
|
|
||||||
|
- [ ] Design brief filled in (section 2), fiction only, no real names.
|
||||||
|
- [ ] `web/data/scenarios/<id>.json` has `id`, `name.en`, `name.fr`, `startDate` (a Monday), `currency`, `economy` (with the 3 taxes), 9 indicators, 15 sectors.
|
||||||
|
- [ ] Start deficit between -1 and +1.5; debt within the interest table; unrest, army loyalty and opinion 15 points away from the danger zones; sector ratios 0.6 to 1.3 unless the premise says otherwise.
|
||||||
|
- [ ] Added to `index.json`, `default` still first; names fit 40 characters.
|
||||||
|
- [ ] Scenario cards (if any): `scenarios` whitelist, `<id>_` prefix, closed chains, both languages, merged with `add-cards.mjs`.
|
||||||
|
- [ ] `node tools/scenario-check.mjs <id>` clean, and the one-year drift looks like the default's.
|
||||||
|
- [ ] `node tools/balance-check.mjs 24 --check --scenario <id>` passes, or a `<id>.targets.json` with a `notes` explaining each moved band.
|
||||||
|
- [ ] `go test ./server` and `node tools/engine-check.mjs` pass.
|
||||||
|
- [ ] Nothing outside `web/data/scenarios/` and your own card files was edited (no `balance.json`, no code, no tests).
|
||||||
|
- [ ] Played the first quarter in the browser: menu entry, first review, a week of cards, no immediate warning telegram.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The `scenarios` card field, `scenario-check.mjs` and `--scenario` behave as specified; the draft code in `tools/` was read for thresholds and may change.
|
||||||
|
- The targets file is read as `{ "notes": "...", "targets": { player: {...} } }` (what `balance-check.mjs` reads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep.
|
||||||
|
- Go tests currently load only `default.json` for economy, sector and card-condition checks.
|
||||||
|
- Shared card text names Valdoria; the guide treats this as a known limit, not something a scenario can fix.
|
||||||
|
- Suggested ranges come from reading `balance.json` and the formulas, not from playtesting the ideas.
|
||||||
+16
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
docTypes = set([]string{"memo", "telegram", "petition", "report", "letter"})
|
docTypes = set([]string{"memo", "telegram", "petition", "report", "letter"})
|
||||||
cardKeys = set([]string{"id", "category", "doc", "sender", "weight", "once", "cooldownDays", "followUpOnly", "conditions", "title", "body", "yes", "no"})
|
cardKeys = set([]string{"id", "category", "doc", "sender", "weight", "once", "cooldownDays", "followUpOnly", "conditions", "scenarios", "title", "body", "yes", "no"})
|
||||||
optionKeys = set([]string{"label", "effects", "setFlags", "clearFlags", "followUp"})
|
optionKeys = set([]string{"label", "effects", "setFlags", "clearFlags", "followUp"})
|
||||||
condKeys = set([]string{"min", "max", "flags", "notFlags", "dayMin", "dayMax"})
|
condKeys = set([]string{"min", "max", "flags", "notFlags", "dayMin", "dayMax"})
|
||||||
effectKeys = set([]string{"stat", "sector", "revenue", "oneOff", "extraSpending", "delta", "days", "bias"})
|
effectKeys = set([]string{"stat", "sector", "revenue", "oneOff", "extraSpending", "delta", "days", "bias"})
|
||||||
@@ -201,6 +201,10 @@ func TestCards(t *testing.T) {
|
|||||||
t.Errorf("cards/index.json lists %s but the file does not exist", f)
|
t.Errorf("cards/index.json lists %s but the file does not exist", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scenarioIDs := map[string]bool{}
|
||||||
|
for _, sid := range load(t, "data/scenarios/index.json")["scenarios"].([]any) {
|
||||||
|
scenarioIDs[sid.(string)] = true
|
||||||
|
}
|
||||||
followOnly := map[string]bool{}
|
followOnly := map[string]bool{}
|
||||||
total := 0
|
total := 0
|
||||||
perCategory := map[string]int{}
|
perCategory := map[string]int{}
|
||||||
@@ -249,6 +253,17 @@ func TestCards(t *testing.T) {
|
|||||||
if !isLocalized(card["title"]) || !isLocalized(card["body"]) {
|
if !isLocalized(card["title"]) || !isLocalized(card["body"]) {
|
||||||
t.Errorf("%s: title and body need non-empty en and fr", id)
|
t.Errorf("%s: title and body need non-empty en and fr", id)
|
||||||
}
|
}
|
||||||
|
if list, ok := card["scenarios"]; ok {
|
||||||
|
ids, _ := list.([]any)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
t.Errorf("%s: scenarios must be a non-empty list of scenario ids", id)
|
||||||
|
}
|
||||||
|
for _, raw := range ids {
|
||||||
|
if sid, _ := raw.(string); !scenarioIDs[sid] {
|
||||||
|
t.Errorf("%s: unknown scenario %q", id, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if w, ok := card["weight"]; ok {
|
if w, ok := card["weight"]; ok {
|
||||||
if f, isNum := w.(float64); !isNum || f <= 0 {
|
if f, isNum := w.(float64); !isNum || f <= 0 {
|
||||||
t.Errorf("%s: weight must be positive", id)
|
t.Errorf("%s: weight must be positive", id)
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { STRATEGIES } from "../web/dev/sim-core.js";
|
|||||||
const root = new URL("../web/data/", import.meta.url);
|
const root = new URL("../web/data/", import.meta.url);
|
||||||
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
|
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
|
||||||
export const bal = read("balance.json");
|
export const bal = read("balance.json");
|
||||||
export const scn = read("scenarios/default.json");
|
// --scenario <id> plays another scenario (default: "default")
|
||||||
|
const scenarioAt = process.argv.indexOf("--scenario");
|
||||||
|
export const scenarioId = scenarioAt > 0 ? process.argv[scenarioAt + 1] : "default";
|
||||||
|
export const scn = read(`scenarios/${scenarioId}.json`);
|
||||||
export const endgame = read("endgame.json");
|
export const endgame = read("endgame.json");
|
||||||
|
|
||||||
// --set path=value (repeatable) overrides balance.json in memory, e.g. --set gain=2 --set indicators.opinion.drift=-1
|
// --set path=value (repeatable) overrides balance.json in memory, e.g. --set gain=2 --set indicators.opinion.drift=-1
|
||||||
@@ -323,6 +326,8 @@ const TARGETS = {
|
|||||||
|
|
||||||
function check(seeds) {
|
function check(seeds) {
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
// a scenario may ship its own bands (same shape as TARGETS), merged over the defaults
|
||||||
|
try { Object.assign(TARGETS, read(`scenarios/${scenarioId}.targets.json`).targets ?? {}); } catch { /* none */ }
|
||||||
for (const [name, want] of Object.entries(TARGETS)) {
|
for (const [name, want] of Object.entries(TARGETS)) {
|
||||||
const [b, p] = PLAYERS[name];
|
const [b, p] = PLAYERS[name];
|
||||||
const runs = [];
|
const runs = [];
|
||||||
@@ -343,7 +348,7 @@ function check(seeds) {
|
|||||||
|
|
||||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/").split("/").pop())) {
|
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/").split("/").pop())) {
|
||||||
const raw = process.argv.slice(2);
|
const raw = process.argv.slice(2);
|
||||||
const args = raw.filter((a, i) => !a.startsWith("--") && raw[i - 1] !== "--set");
|
const args = raw.filter((a, i) => !a.startsWith("--") && raw[i - 1] !== "--set" && raw[i - 1] !== "--scenario");
|
||||||
const seeds = Number(args[0] ?? 12);
|
const seeds = Number(args[0] ?? 12);
|
||||||
const names = args[1] ? args[1].split(",") : Object.keys(PLAYERS);
|
const names = args[1] ? args[1].split(",") : Object.keys(PLAYERS);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
|||||||
+58
-12
@@ -147,8 +147,8 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
];
|
];
|
||||||
g = fresh(3); E.confirmBudget(g);
|
g = fresh(3); E.confirmBudget(g);
|
||||||
const op = g.indicators.opinion;
|
const op = g.indicators.opinion;
|
||||||
for (let i = 0; i < 10 && g.pendingCard?.id !== "start"; i++) { if (g.pending.weeklyCard && !g.pendingCard) E.ensureCard(g, bal, [cards[0]]); if (!g.pendingCard) E.advanceDay(g, bal); }
|
for (let i = 0; i < 10 && g.inbox[0]?.id !== "start"; i++) { if (g.pending.weeklyCard) E.ensureCard(g, bal, [cards[0]]); if (!g.inbox.length) E.advanceDay(g, bal); }
|
||||||
assert.equal(g.pendingCard.id, "start");
|
assert.equal(g.inbox[0].id, "start");
|
||||||
E.answerCard(g, bal, cards, "yes");
|
E.answerCard(g, bal, cards, "yes");
|
||||||
assert.equal(g.indicators.opinion, op + 5);
|
assert.equal(g.indicators.opinion, op + 5);
|
||||||
assert.equal(g.economy.taxes.income, 32);
|
assert.equal(g.economy.taxes.income, 32);
|
||||||
@@ -379,22 +379,24 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
|
|
||||||
// content that changed since the save is repaired, not fatal
|
// content that changed since the save is repaired, not fatal
|
||||||
const stale = clone(g);
|
const stale = clone(g);
|
||||||
stale.pendingCard = { id: "deleted_card", day: stale.day }; stale.pending.weeklyCard = true;
|
stale.inbox = [{ id: "deleted_card", day: stale.day }]; stale.pending.inboxFull = true;
|
||||||
stale.followUps.push({ id: "deleted_card", due: 5 }, { id: cardsAll[0].id, due: 9 });
|
stale.followUps.push({ id: "deleted_card", due: 5 }, { id: cardsAll[0].id, due: 9 });
|
||||||
const fixed = E.restoreState(stale, content);
|
const fixed = E.restoreState(stale, content);
|
||||||
assert.equal(fixed.ok, true);
|
assert.equal(fixed.ok, true);
|
||||||
assert.equal(fixed.state.pendingCard, null);
|
assert.deepEqual(fixed.state.inbox, []);
|
||||||
|
assert.equal(fixed.state.pending.inboxFull, false);
|
||||||
assert.deepEqual(fixed.state.followUps.map((f) => f.id).filter((id) => id === "deleted_card"), []);
|
assert.deepEqual(fixed.state.followUps.map((f) => f.id).filter((id) => id === "deleted_card"), []);
|
||||||
assert.ok(fixed.state.followUps.some((f) => f.id === cardsAll[0].id));
|
assert.ok(fixed.state.followUps.some((f) => f.id === cardsAll[0].id));
|
||||||
// an alert queue and its pause flag stay consistent
|
// an alert queue and its pause flag stay consistent
|
||||||
const al = clone(g); al.alerts = [{ kind: "coup", day: 10 }]; al.pending.alert = false;
|
const al = clone(g); al.alerts = [{ kind: "coup", day: 10 }]; al.pending.alert = false;
|
||||||
assert.equal(E.restoreState(al, content).state.pending.alert, true);
|
assert.equal(E.restoreState(al, content).state.pending.alert, true);
|
||||||
|
|
||||||
// a game saved while waiting on a decision comes back waiting on the same decision
|
// a game saved with a proposal in the tray comes back with the same tray, and keeps running
|
||||||
const waiting = fresh(32); E.confirmBudget(waiting); E.ensureCard(waiting, bal, cardsAll);
|
const waiting = fresh(32); E.confirmBudget(waiting); E.ensureCard(waiting, bal, cardsAll);
|
||||||
const w = E.restoreState(decodeSave(encodeSave(waiting)).state, content).state;
|
const w = E.restoreState(decodeSave(encodeSave(waiting)).state, content).state;
|
||||||
assert.equal(w.pendingCard.id, waiting.pendingCard.id);
|
assert.deepEqual(w.inbox, waiting.inbox);
|
||||||
assert.equal(E.advanceDay(w, bal).blocked, "pending");
|
assert.equal(w.inbox.length, 1);
|
||||||
|
assert.equal(E.advanceDay(w, bal).advanced, true);
|
||||||
|
|
||||||
// storage: slots, autosave, settings, and the fallbacks
|
// storage: slots, autosave, settings, and the fallbacks
|
||||||
const mem = new Map();
|
const mem = new Map();
|
||||||
@@ -412,11 +414,13 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty");
|
mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty");
|
||||||
mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null);
|
mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null);
|
||||||
|
|
||||||
assert.deepEqual(loadSettings(store), { lang: null, slow: false });
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 });
|
||||||
saveSettings(store, { lang: "fr", slow: true });
|
saveSettings(store, { lang: "fr", slow: true, sound: false, volume: 0.25 });
|
||||||
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true });
|
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true, sound: false, volume: 0.25 });
|
||||||
mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" }));
|
mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" }));
|
||||||
assert.deepEqual(loadSettings(store), { lang: null, slow: false }, "unknown values are ignored");
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 }, "unknown values are ignored");
|
||||||
|
mem.set(KEYS.settings, JSON.stringify({ volume: 7, sound: "no" }));
|
||||||
|
assert.equal(loadSettings(store).volume, 0.6, "an out-of-range volume falls back to the default");
|
||||||
|
|
||||||
// no storage at all, a throwing storage, and a full storage: the game keeps going in memory
|
// no storage at all, a throwing storage, and a full storage: the game keeps going in memory
|
||||||
const none = createStorage(null);
|
const none = createStorage(null);
|
||||||
@@ -433,6 +437,48 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12. the in-tray: proposals pile up without stopping the clock; the game waits at the limit
|
||||||
|
{
|
||||||
|
const real = JSON.parse(fs.readFileSync(root + "cards/index.json")).files.flatMap((f) => JSON.parse(fs.readFileSync(root + "cards/" + f)));
|
||||||
|
const max = bal.cards.inboxMax;
|
||||||
|
const g = fresh(5); E.confirmBudget(g);
|
||||||
|
let blockedDay = null;
|
||||||
|
for (let i = 0; i < 200 && blockedDay === null; i++) {
|
||||||
|
if (g.pending.review) E.confirmBudget(g);
|
||||||
|
while (g.pending.alert) E.dismissAlert(g);
|
||||||
|
if (g.pending.weeklyCard) E.ensureCard(g, bal, real);
|
||||||
|
if (g.pending.inboxFull) { blockedDay = g.day; break; }
|
||||||
|
E.advanceDay(g, bal);
|
||||||
|
}
|
||||||
|
assert.equal(g.inbox.length, max, "the game waits when the tray holds " + max);
|
||||||
|
assert.equal(blockedDay, (max - 1) * bal.time.daysPerWeek, "the " + max + "th proposal arrives on week " + max);
|
||||||
|
assert.equal(E.advanceDay(g, bal).blocked, "pending");
|
||||||
|
assert.equal(new Set(g.inbox.map((e) => e.id)).size, max, "no proposal twice in the tray");
|
||||||
|
assert.deepEqual(g.inbox.map((e) => e.day), [...g.inbox.map((e) => e.day)].sort((a, b) => a - b), "oldest first");
|
||||||
|
|
||||||
|
// answering the top one frees the clock; answering a chosen one works too
|
||||||
|
const first = g.inbox[0].id, third = g.inbox[2].id;
|
||||||
|
assert.equal(E.answerCard(g, bal, real, "yes").card.id, first);
|
||||||
|
assert.equal(g.pending.inboxFull, false);
|
||||||
|
assert.equal(E.advanceDay(g, bal).advanced, true);
|
||||||
|
assert.equal(E.answerCard(g, bal, real, "no", third).card.id, third);
|
||||||
|
assert.equal(g.inbox.length, max - 2);
|
||||||
|
assert.equal(E.answerCard(g, bal, real, "no", "not_in_tray").ok, false);
|
||||||
|
assert.equal(E.answerCard(g, bal, real, "maybe").ok, false);
|
||||||
|
|
||||||
|
// a card waiting in the tray is not drawn again, and a saved tray survives a round trip
|
||||||
|
const copy = E.restoreState(decodeSave(encodeSave(g)).state, { scenarios: { default: scn }, balance: bal, cards: real });
|
||||||
|
assert.equal(copy.ok, true);
|
||||||
|
assert.deepEqual(copy.state.inbox, g.inbox);
|
||||||
|
// a version-2 save with a pending card becomes a one-card tray
|
||||||
|
const v2 = JSON.parse(JSON.stringify(fresh(6))); v2.version = 2; v2.pendingCard = { id: real[0].id, day: 0 }; v2.pending.weeklyCard = true; delete v2.inbox; delete v2.pending.inboxFull;
|
||||||
|
const up = decodeSave(encodeSave(v2));
|
||||||
|
assert.equal(up.ok, true);
|
||||||
|
assert.deepEqual(up.state.inbox, [{ id: real[0].id, day: 0 }]);
|
||||||
|
assert.equal(up.state.pending.weeklyCard, false);
|
||||||
|
assert.equal(up.state.pendingCard, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
|
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
|
||||||
{
|
{
|
||||||
// the world cycle moves, stays within bounds and is part of the saved state
|
// the world cycle moves, stays within bounds and is part of the saved state
|
||||||
@@ -488,7 +534,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
const content = { scenarios: { default: scn }, balance: bal, cards: [] };
|
const content = { scenarios: { default: scn }, balance: bal, cards: [] };
|
||||||
const v1 = clone(fresh()); delete v1.world; v1.version = 1;
|
const v1 = clone(fresh()); delete v1.world; v1.version = 1;
|
||||||
const up = decodeSave(encodeSave(v1));
|
const up = decodeSave(encodeSave(v1));
|
||||||
assert.equal(up.ok, true); assert.equal(up.state.version, 2); assert.deepEqual(up.state.world, { cycle: 0 });
|
assert.equal(up.ok, true); assert.equal(up.state.version, 3); assert.deepEqual(up.state.world, { cycle: 0 }); assert.deepEqual(up.state.inbox, []);
|
||||||
assert.equal(E.restoreState(up.state, content).ok, true);
|
assert.equal(E.restoreState(up.state, content).ok, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Start-state report for a scenario (M8b): is the start close to balance, and what does a year of
|
||||||
|
// doing nothing do? Usage: node tools/scenario-check.mjs [scenarioId]
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as E from "../web/js/engine/index.js";
|
||||||
|
|
||||||
|
const root = new URL("../web/data/", import.meta.url);
|
||||||
|
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
|
||||||
|
const id = process.argv[2] ?? "default";
|
||||||
|
const bal = read("balance.json");
|
||||||
|
const scn = read(`scenarios/${id}.json`);
|
||||||
|
const state = E.createGame({ scenario: scn, balance: bal, seed: 1, pronouns: "they" });
|
||||||
|
|
||||||
|
const f = E.computeFiscal(state, bal);
|
||||||
|
const warn = [];
|
||||||
|
const line = (k, v) => console.log(k.padEnd(28), v);
|
||||||
|
console.log(`Scenario ${id}: ${scn.name.en} (${scn.currency}), starts ${scn.startDate}`);
|
||||||
|
line("GDP", `${scn.economy.gdp} bn ${scn.currency}`);
|
||||||
|
line("Debt / GDP", `${f.debtRatio.toFixed(1)} % (interest rate ${f.rate.toFixed(2)} %)`);
|
||||||
|
line("Revenue", `${f.revenue.toFixed(2)} % of GDP`);
|
||||||
|
line("Sector spending", `${f.allocTotal.toFixed(2)} % of GDP`);
|
||||||
|
line("Debt interest", `${f.interest.toFixed(2)} % of GDP`);
|
||||||
|
line("Deficit (+) / surplus (-)", `${f.deficit.toFixed(2)} % of GDP`);
|
||||||
|
if (Math.abs(f.deficit) > 2) warn.push(`the start deficit is ${f.deficit.toFixed(1)} % of GDP: the start should be close to balance (within 2 points)`);
|
||||||
|
if (f.debtRatio > bal.gameOver.default.warnAbove - 25) warn.push("the start debt is close to the default warning");
|
||||||
|
for (const [k, s] of Object.entries(state.sectors)) {
|
||||||
|
const ratio = s.alloc / s.need;
|
||||||
|
if (ratio < 0.5 || ratio > 1.5) warn.push(`sector ${k} starts at ${(ratio * 100).toFixed(0)} % of its need`);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(state.indicators)) if (v < 5 || v > 95) warn.push(`indicator ${k} starts at ${v}`);
|
||||||
|
for (const k of E.IND_IDS) if (!(k in state.indicators)) warn.push(`indicator ${k} is missing`);
|
||||||
|
|
||||||
|
// A year of doing nothing: no card answered, the budget untouched, the same noise as the game.
|
||||||
|
const x = structuredClone(state);
|
||||||
|
const start = { ...x.indicators, debt: f.debtRatio, growth: x.economy.growth };
|
||||||
|
for (let d = 0; d < 365 && !x.over; d++) {
|
||||||
|
for (const k of Object.keys(x.pending)) x.pending[k] = false;
|
||||||
|
x.alerts.length = 0;
|
||||||
|
E.advanceDay(x, bal);
|
||||||
|
}
|
||||||
|
const end = E.summarize(x, bal);
|
||||||
|
console.log("\nOne year with no decisions:");
|
||||||
|
for (const k of E.IND_IDS) line(` ${k}`, `${start[k].toFixed(0)} -> ${x.indicators[k].toFixed(0)}`);
|
||||||
|
line(" debt / GDP", `${start.debt.toFixed(0)} -> ${end.debtRatio.toFixed(0)}`);
|
||||||
|
line(" growth", `${start.growth.toFixed(1)} -> ${end.growth.toFixed(1)}`);
|
||||||
|
if (x.over) warn.push(`the game ends within a year of doing nothing (${x.over.type})`);
|
||||||
|
if (end.indicators.opinion < start.opinion - 15) warn.push("opinion falls by more than 15 points in a year of inaction");
|
||||||
|
|
||||||
|
console.log(warn.length ? `\nWarnings:\n- ${warn.join("\n- ")}` : "\nNo warnings.");
|
||||||
|
process.exitCode = warn.length ? 1 : 0;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* Motion: papers landing on the desk, stamps, the dial, page turns. All of it is skipped for people
|
||||||
|
who ask for reduced motion (see the media query at the bottom). */
|
||||||
|
|
||||||
|
/* Papers are dealt onto the desk when a game starts */
|
||||||
|
@keyframes deal {
|
||||||
|
from { opacity: 0; transform: translateY(-28px) rotate(calc(var(--rot, 0deg) - 3deg)); }
|
||||||
|
to { opacity: 1; transform: translateY(0) rotate(var(--rot, 0deg)); }
|
||||||
|
}
|
||||||
|
#pad.is-dealing #dashboard { animation: deal 0.55s ease-out both; }
|
||||||
|
#pad.is-dealing #folder { animation: deal 0.55s 0.12s ease-out both; }
|
||||||
|
#pad.is-dealing #calendar { animation: deal 0.55s 0.24s ease-out both; }
|
||||||
|
#pad.is-dealing #newspaper { animation: deal 0.55s 0.36s ease-out both; }
|
||||||
|
|
||||||
|
/* A proposal arrives in the tray, is stamped, and slides away */
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: translateY(22px) rotate(0.8deg); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
@keyframes card-out {
|
||||||
|
from { opacity: 1; transform: none; }
|
||||||
|
to { opacity: 0; transform: translateX(70%) rotate(7deg); }
|
||||||
|
}
|
||||||
|
@keyframes bump {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
40% { transform: scale(1.35); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
.card--enter { animation: card-in 0.38s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
|
||||||
|
.card--leave { animation: card-out 0.32s ease-in forwards; }
|
||||||
|
.inbox__count.is-bump { animation: bump 0.45s ease-out; }
|
||||||
|
|
||||||
|
/* The calendar page flips when the day changes; the dial hand turns smoothly (set per frame) */
|
||||||
|
@keyframes day-flip {
|
||||||
|
from { opacity: 0.2; transform: perspective(200px) rotateX(-65deg); }
|
||||||
|
to { opacity: 1; transform: perspective(200px) rotateX(0); }
|
||||||
|
}
|
||||||
|
.cal__day.is-flip { animation: day-flip 0.35s ease-out; transform-origin: 50% 0; }
|
||||||
|
|
||||||
|
/* Indicator values flash when they move */
|
||||||
|
@keyframes flash-up { 0% { color: var(--ink-green); } 100% { color: inherit; } }
|
||||||
|
@keyframes flash-down { 0% { color: var(--ink-red); } 100% { color: inherit; } }
|
||||||
|
.gauge__value.is-up { animation: flash-up 1.4s ease-out; }
|
||||||
|
.gauge__value.is-down { animation: flash-down 1.4s ease-out; }
|
||||||
|
|
||||||
|
/* Newspaper: fresh headlines fade up */
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.paper--news.is-fresh .news__lead { animation: fade-up 0.5s ease-out both; }
|
||||||
|
.paper--news.is-fresh .news__side article { animation: fade-up 0.5s 0.15s ease-out both; }
|
||||||
|
|
||||||
|
/* The ledger opens like a book; the menu folder and the end front page rise into place */
|
||||||
|
@keyframes book-open {
|
||||||
|
from { opacity: 0; transform: perspective(900px) translateY(24px) rotateX(10deg); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.ledger__book { animation: book-open 0.45s ease-out both; transform-origin: 50% 100%; }
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(18px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.menu__folder { animation: rise 0.4s ease-out both; }
|
||||||
|
.end__page, #endgame > * { animation: rise 0.6s ease-out both; }
|
||||||
|
|
||||||
|
/* Brass buttons and stamp buttons answer the finger */
|
||||||
|
.brass { transition: transform 0.08s, filter 0.15s, box-shadow 0.15s; }
|
||||||
|
.stamp-btn { transition: background 0.15s, transform 0.08s; }
|
||||||
|
.stamp-btn:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
|
||||||
|
|
||||||
|
/* The danger "Hold on power" line pulses when it is critical */
|
||||||
|
@keyframes pulse { 50% { opacity: 0.45; } }
|
||||||
|
.intel li[data-level="3"] b, .intel li[data-level="4"] b { animation: pulse 1.6s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#pad.is-dealing .paper,
|
||||||
|
.card--enter, .card--leave, .inbox__count.is-bump, .cal__day.is-flip,
|
||||||
|
.gauge__value.is-up, .gauge__value.is-down,
|
||||||
|
.paper--news.is-fresh .news__lead, .paper--news.is-fresh .news__side article,
|
||||||
|
.ledger__book, .menu__folder, .end__page, #endgame > *,
|
||||||
|
.intel li b { animation: none !important; }
|
||||||
|
.card--leave { opacity: 0; }
|
||||||
|
}
|
||||||
@@ -145,3 +145,53 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card__stamp { animation: none; opacity: 0.9; }
|
.card__stamp { animation: none; opacity: 0.9; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The tray: counter on the tab, "full" banner, and the pile of waiting sheets ---- */
|
||||||
|
.inbox__count {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 2.7em;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: #f6ead0;
|
||||||
|
font: 700 12px/18px var(--font-num);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
.inbox__count:empty { display: none; }
|
||||||
|
.inbox__count.is-warn { background: var(--ink-red); }
|
||||||
|
|
||||||
|
.inbox__full {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 2px solid var(--ink-red);
|
||||||
|
color: var(--ink-red);
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transform: rotate(-0.8deg);
|
||||||
|
}
|
||||||
|
.inbox__full[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Sheets under the top paper: stacked box-shadows that offset a little more for each waiting proposal */
|
||||||
|
.card[data-pile] { transition: box-shadow 0.35s ease; margin: 0 14px 14px 0; }
|
||||||
|
.card[data-pile="0"] { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
|
||||||
|
.card[data-pile="1"] {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
|
||||||
|
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45);
|
||||||
|
}
|
||||||
|
.card[data-pile="2"] {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
|
||||||
|
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45),
|
||||||
|
8px 8px 0 0 #e0d2ab, 8px 8px 0 1px rgba(80, 55, 20, 0.45);
|
||||||
|
}
|
||||||
|
.card[data-pile="3"] {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35),
|
||||||
|
4px 4px 0 0 #e8dcb8, 4px 4px 0 1px rgba(80, 55, 20, 0.45),
|
||||||
|
8px 8px 0 0 #e0d2ab, 8px 8px 0 1px rgba(80, 55, 20, 0.45),
|
||||||
|
12px 12px 0 0 #d8c9a2, 12px 12px 0 1px rgba(80, 55, 20, 0.45);
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,3 +124,26 @@
|
|||||||
to { opacity: 1; transform: translateY(0) rotate(-0.4deg); }
|
to { opacity: 1; transform: translateY(0) rotate(-0.4deg); }
|
||||||
}
|
}
|
||||||
.menu__stamp { max-width: none; white-space: nowrap; }
|
.menu__stamp { max-width: none; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- The rules, rendered from Markdown ---- */
|
||||||
|
.rules {
|
||||||
|
max-height: 56vh;
|
||||||
|
padding: 2px 10px 6px 2px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.rules:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
.rules h1 { margin: 0 0 8px; font-size: 25px; }
|
||||||
|
.rules h2 { margin: 18px 0 6px; padding-bottom: 2px; border-bottom: 2px solid var(--ink); font-size: 18px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.rules h3 { margin: 12px 0 4px; font-size: 16px; font-style: italic; }
|
||||||
|
.rules p { margin: 0 0 8px; }
|
||||||
|
.rules ul, .rules ol { margin: 0 0 8px; padding-left: 20px; }
|
||||||
|
.rules li { margin-bottom: 2px; }
|
||||||
|
.rules code { font-family: var(--font-num); font-size: 0.9em; background: rgba(28, 42, 68, 0.1); padding: 0 3px; }
|
||||||
|
.rules table { width: 100%; margin: 6px 0 12px; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.rules th, .rules td { padding: 3px 6px; border: 1px solid rgba(43, 36, 24, 0.35); text-align: left; vertical-align: top; }
|
||||||
|
.rules th { background: rgba(28, 42, 68, 0.1); font-weight: 700; }
|
||||||
|
.rules blockquote { margin: 8px 0; padding: 4px 10px; border-left: 4px solid var(--ink-red); background: rgba(255, 255, 255, 0.3); font-style: italic; }
|
||||||
|
.rules hr { border: 0; border-top: 1px dashed rgba(43, 36, 24, 0.5); margin: 14px 0; }
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/* Phones and small tablets: one column, one paper at a time, a shelf along the top to pick it.
|
||||||
|
Loaded last so it can override the desktop layout. */
|
||||||
|
|
||||||
|
#shelf { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#desk {
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 6px;
|
||||||
|
place-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pad {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100dvh - 12px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px 10px 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
#pad::before { inset: 5px; border-radius: 8px; }
|
||||||
|
#pad > * { grid-column: 1; }
|
||||||
|
|
||||||
|
/* Papers lie flat on a phone: a rotated sheet wastes the little width there is. */
|
||||||
|
.paper { transform: none; }
|
||||||
|
|
||||||
|
/* One paper at a time */
|
||||||
|
#dashboard, #folder, #ledger, #newspaper, #calendar { grid-row: 2; display: none; overflow-y: auto; }
|
||||||
|
#side { display: contents; }
|
||||||
|
#pad[data-view="tray"] #folder,
|
||||||
|
#pad[data-view="brief"] #dashboard,
|
||||||
|
#pad[data-view="news"] #newspaper,
|
||||||
|
#pad[data-view="clock"] #calendar { display: block; }
|
||||||
|
#calendar { align-self: start; }
|
||||||
|
|
||||||
|
/* The ledger is a view of its own while a review is open; the tabs still reach the other papers */
|
||||||
|
#pad.is-ledger #folder { display: none; }
|
||||||
|
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
|
||||||
|
#pad.is-ledger #side { display: contents; }
|
||||||
|
#ledger { grid-column: 1; }
|
||||||
|
|
||||||
|
/* Menu and end screens cover the shelf too */
|
||||||
|
#menu, #endgame { grid-row: 1 / -1; }
|
||||||
|
|
||||||
|
/* ---- The shelf ---- */
|
||||||
|
#shelf {
|
||||||
|
display: grid;
|
||||||
|
grid-row: 1;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 5;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.shelf__time {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background-color: var(--paper);
|
||||||
|
background-image: var(--paper-grain);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: inset 0 0 14px rgba(120, 90, 40, 0.22), 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.shelf__dial { width: 40px; height: 40px; }
|
||||||
|
.shelf__date b { display: block; font-family: var(--font-head); font-weight: 400; font-size: 17px; line-height: 1.1; text-transform: capitalize; }
|
||||||
|
.shelf__date span { font-size: 12px; opacity: 0.7; }
|
||||||
|
.shelf__speed { min-width: 56px; min-height: 40px; font-size: 16px; }
|
||||||
|
.shelf__tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.shelf__tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.shelf__tab {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #c6a566;
|
||||||
|
border: 1px solid rgba(90, 60, 20, 0.55);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shelf__tab[aria-pressed="true"] {
|
||||||
|
background-color: var(--paper);
|
||||||
|
box-shadow: inset 0 4px 0 var(--ink-red);
|
||||||
|
}
|
||||||
|
.shelf__tab--ledger { background: #cfe0c8; }
|
||||||
|
.shelf__tab--menu { flex: 0 0 auto; background: linear-gradient(#e7c872, #b98e32); font-weight: 700; }
|
||||||
|
.shelf__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; }
|
||||||
|
.shelf__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -3px;
|
||||||
|
min-width: 19px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: #f6ead0;
|
||||||
|
font: 700 12px/19px var(--font-num);
|
||||||
|
font-style: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.shelf__badge[hidden] { display: none; }
|
||||||
|
.shelf__badge.is-warn { background: var(--ink-red); }
|
||||||
|
|
||||||
|
/* ---- Papers, sized for a narrow screen ---- */
|
||||||
|
.paper { padding: 12px 12px; }
|
||||||
|
.paper--folder { padding-top: 38px; }
|
||||||
|
.gauge { grid-template-columns: 104px minmax(40px, 1fr) 28px 62px; gap: 6px; }
|
||||||
|
.gauge__spark { width: 62px; }
|
||||||
|
.dash__head h2 { font-size: 19px; }
|
||||||
|
.card { padding: 14px 14px 10px; }
|
||||||
|
.card__title { font-size: 22px; }
|
||||||
|
.card__choices { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.stamp-btn { min-height: 62px; flex-direction: row; justify-content: space-between; text-align: left; }
|
||||||
|
.card__keys { display: none; }
|
||||||
|
.card__stamp { font-size: 34px; }
|
||||||
|
.cal__day { font-size: 46px; }
|
||||||
|
|
||||||
|
.ledger__book { height: auto; min-height: 100%; grid-template-columns: 1fr; overflow-y: visible; }
|
||||||
|
.ledger__page { overflow: visible; padding: 12px 12px 12px 24px; }
|
||||||
|
.ledger__page--left, .ledger__page--right { border-radius: 4px; box-shadow: none; }
|
||||||
|
.ledger__page--right { margin-top: 8px; }
|
||||||
|
|
||||||
|
.menu__folder { width: 100%; padding: 40px 14px 16px; }
|
||||||
|
.rules { max-height: calc(100dvh - 250px); }
|
||||||
|
.alerts { top: 8px; width: calc(100% - 16px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.gauge { grid-template-columns: 92px minmax(30px, 1fr) 26px; }
|
||||||
|
.gauge__spark { display: none; }
|
||||||
|
.gauge__label { font-size: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape phones: the shelf shrinks to one row so the paper keeps its height */
|
||||||
|
@media (max-width: 900px) and (max-height: 480px) {
|
||||||
|
#pad { grid-template-rows: auto minmax(0, 1fr); padding: 8px; }
|
||||||
|
.shelf__time { display: none; }
|
||||||
|
}
|
||||||
@@ -255,3 +255,12 @@
|
|||||||
.news__side article + article { border-top: 1px solid rgba(43, 36, 24, 0.35); padding-top: 6px; }
|
.news__side article + article { border-top: 1px solid rgba(43, 36, 24, 0.35); padding-top: 6px; }
|
||||||
.news__side h4 { font-size: 16px; line-height: 1.15; }
|
.news__side h4 { font-size: 16px; line-height: 1.15; }
|
||||||
.news__side p { margin: 2px 0 0; font-size: 13px; }
|
.news__side p { margin: 2px 0 0; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Dial clock: one full turn of the hand is one in-game day */
|
||||||
|
.cal__top { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||||
|
.cal__date { text-align: left; }
|
||||||
|
.cal__dial { width: 76px; height: 76px; flex: none; }
|
||||||
|
.dial__face { fill: #f6efd8; stroke: var(--ink); stroke-width: 3; }
|
||||||
|
.dial__ticks line { stroke: var(--ink); stroke-width: 3; stroke-linecap: round; }
|
||||||
|
.dial__hand { stroke: var(--ink-red); stroke-width: 4; stroke-linecap: round; }
|
||||||
|
.dial__pin { fill: var(--ink); }
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"msPerDay": 10000,
|
"msPerDay": 10000,
|
||||||
"slowMsPerDay": 60000,
|
"slowMsPerDay": 60000,
|
||||||
"speeds": [1, 2, 4],
|
"speeds": [1, 2, 4],
|
||||||
"historyEvery": 7
|
"historyEvery": 7,
|
||||||
|
"catchUpMs": 60000
|
||||||
},
|
},
|
||||||
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
||||||
"noise": { "indicator": 0.35 },
|
"noise": { "indicator": 0.35 },
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"environment": { "sector": "environment" },
|
"environment": { "sector": "environment" },
|
||||||
"justice": { "sector": "justice" },
|
"justice": { "sector": "justice" },
|
||||||
"culture": { "sector": "culture" }
|
"culture": { "sector": "culture" }
|
||||||
}
|
},
|
||||||
|
"inboxMax": 10
|
||||||
},
|
},
|
||||||
"sector": { "qualityCurve": 0.9, "industryDebtPenaltyFrom": 90, "industryDebtPenaltyPerPoint": 0.25 },
|
"sector": { "qualityCurve": 0.9, "industryDebtPenaltyFrom": 90, "industryDebtPenaltyPerPoint": 0.25 },
|
||||||
"sectorTau": {
|
"sectorTau": {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# The Desk: Rules
|
||||||
|
|
||||||
|
You rule the Republic of Valdoria for five years, from a wooden desk. Papers arrive, you sign or refuse them, you set the budget, and the country answers. This sheet explains what is on the desk and how the machinery behind it behaves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Your task
|
||||||
|
|
||||||
|
Rule for the full term, **1826 days**, without being thrown out of office. Every decision carries your signature, and most of them have a price that arrives later.
|
||||||
|
|
||||||
|
When the term ends, a final review is drawn up: a grade from **S** to **F**, a title from the historians, and charts of the period. The **last year counts for half of the score**, so a boom bought on credit does not pay. You are judged on the country you leave behind.
|
||||||
|
|
||||||
|
> The game never shows you its formulas. Read the papers, watch the gauges, and learn from what happens.
|
||||||
|
|
||||||
|
## The desk
|
||||||
|
|
||||||
|
Four things sit in front of you. On a phone they share the screen, and a shelf at the top switches between Tray, Briefing, News and Calendar.
|
||||||
|
|
||||||
|
### The briefing
|
||||||
|
|
||||||
|
The *Ministerial Briefing* is your dashboard. It has three parts.
|
||||||
|
|
||||||
|
- Seven **indicators**, each with a gauge and a small sparkline of its recent history.
|
||||||
|
- The **economic figures**: GDP, growth, unemployment, inflation, debt and deficit as a share of GDP, and the interest rate, each with a trend arrow.
|
||||||
|
- The **intelligence summary**, which covers what the gauges cannot show you (see below).
|
||||||
|
|
||||||
|
### The in-tray
|
||||||
|
|
||||||
|
The *In-tray* holds the proposals waiting for your signature. The one on top is the one you answer. When it is empty, the country simply carries on.
|
||||||
|
|
||||||
|
### The calendar
|
||||||
|
|
||||||
|
The calendar shows the day count, the next briefing and the next budget review. A clock dial turns once per in-game day, and turns faster at higher speeds. The speed buttons, the pause and the Menu are here too.
|
||||||
|
|
||||||
|
### The newspaper
|
||||||
|
|
||||||
|
The press reports what moved: indicators, growth, jobs, prices, debt, and the state of the world economy. It is often the first place you learn that a slump or a boom has started.
|
||||||
|
|
||||||
|
## The seven indicators
|
||||||
|
|
||||||
|
Each runs from 0 to 100. They move slowly, they pull on one another, and they all drift downhill if you leave them alone.
|
||||||
|
|
||||||
|
| Indicator | What it measures | What drives it |
|
||||||
|
|---|---|---|
|
||||||
|
| Order | Calm streets, a working state | Police, justice, administration; falls with unemployment and unrest |
|
||||||
|
| Stability | Solidity of institutions | Social spending, health, housing, justice; falls with debt, unemployment, inflation |
|
||||||
|
| Civil rights | Freedom and fair treatment | Justice; falls with heavy policing, a large army and crackdowns |
|
||||||
|
| Public opinion | Mood of the people | Health, social, housing, jobs, growth; falls with high prices and heavy taxes |
|
||||||
|
| Economy | Strength of the productive base | Industry, infrastructure, research, education; growth and jobs |
|
||||||
|
| Hard power | Military weight | Army above all, then research and industry |
|
||||||
|
| Soft power | Influence abroad | Culture, foreign affairs, civil rights |
|
||||||
|
|
||||||
|
### The intelligence summary
|
||||||
|
|
||||||
|
Two gauges are hidden from you, and the summary describes them in words. A third line tells you how safe your seat is.
|
||||||
|
|
||||||
|
- **Unrest** reads Calm, Restless, Tense, Volatile or Explosive. It climbs with unemployment, inflation and a hostile public.
|
||||||
|
- **Army loyalty** reads Mutinous, Wavering, Steady or Loyal. It follows the army's budget, but also debt, inflation and the general mood.
|
||||||
|
- **Hold on power** reads Secure, Shaky or Critical. Shaky means you are close to a danger zone. Critical means you are in one.
|
||||||
|
|
||||||
|
## Time
|
||||||
|
|
||||||
|
One in-game day lasts **10 seconds** at speed 1. In Settings, *Slow time* stretches a day to 60 seconds.
|
||||||
|
|
||||||
|
- **Speeds**: 1x, 2x and 4x, plus pause. Space pauses and resumes; the keys 1, 2 and 3 pick the speed.
|
||||||
|
- The game keeps running if the browser tab is in the background. The tab title shows how many proposals are waiting for you.
|
||||||
|
- The game stops by itself for the budget review, for urgent telegrams, and when the in-tray is full (see below).
|
||||||
|
|
||||||
|
## Proposals
|
||||||
|
|
||||||
|
Every **Monday** a new proposal lands in the in-tray. It comes as a memorandum, a report, a letter, a petition or a telegram, and it names its sender.
|
||||||
|
|
||||||
|
Proposals **pile up**. The game does not stop for each one, so time keeps moving while the stack grows. You answer the top one whenever you choose.
|
||||||
|
|
||||||
|
### Answering
|
||||||
|
|
||||||
|
Stamp it **Approved** or **Rejected**, with the buttons or with the keys **Y** and **N**. Both answers cost something: there is no free choice, and refusing is a decision like any other.
|
||||||
|
|
||||||
|
The game **pauses only when 10 proposals are waiting**, not after 10 weeks, and stays paused until you answer one.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
Answers have lasting effects, and many arrive late. Some set off a follow-up proposal weeks later: a promise comes due, a refusal turns into a petition, a gamble goes wrong.
|
||||||
|
|
||||||
|
A run draws roughly 260 proposals from a pool of more than 900, so no two terms read the same. Crisis telegrams are different: they react to the state of the country and to extreme choices. Starve the army, squeeze the taxpayers or arm the police to the teeth, and the telegrams will notice.
|
||||||
|
|
||||||
|
## The budget
|
||||||
|
|
||||||
|
On day 0 and then **every quarter (13 weeks)**, the *Treasury ledger* opens and the game pauses. Between reviews the ledger is closed: the budget you filed is the budget you live with. Nothing takes effect until you press *Sign and file*, and *Reset* returns to the figures the ledger opened with.
|
||||||
|
|
||||||
|
### Sectors
|
||||||
|
|
||||||
|
Fifteen sectors share the spending: education, health, social and pensions, police, army, justice, industry and energy, public transport, infrastructure, housing, research, culture and media, foreign affairs, environment, administration.
|
||||||
|
|
||||||
|
Each is set as a **share of GDP**. A tick on each line marks what the sector **needs**, and needs grow a little every year, so a frozen budget slowly buys less.
|
||||||
|
|
||||||
|
Each sector has a **quality** from 0 to 100 that follows the budget slowly. Police reacts within weeks, health within months, education and infrastructure only over years. Above about **70**, a sector brings extra benefits; below about **40**, it starts to hurt. Overspending gives diminishing returns, so the last coins buy the least.
|
||||||
|
|
||||||
|
### Taxes
|
||||||
|
|
||||||
|
Three taxes can be set: **income tax**, **corporate tax** and **VAT**. For each, revenue rises with the rate up to a peak, marked by a tick, and then **falls**. Beyond the peak you collect less and still pay in opinion and growth. Each tax hurts in its own way: VAT is the most felt by the public, corporate tax weighs most on growth.
|
||||||
|
|
||||||
|
### The projection
|
||||||
|
|
||||||
|
The ledger shows a Treasury summary (revenue, sector spending, debt interest, surplus or deficit) and a **four-quarter projection** of the deficit and debt. It assumes nothing else changes, so it cannot foresee the world cycle or the proposals to come.
|
||||||
|
|
||||||
|
## Debt
|
||||||
|
|
||||||
|
Each day the deficit is added to the debt, and a surplus pays it down. Debt is measured against GDP.
|
||||||
|
|
||||||
|
- **Below 60%**, debt is free: it costs nothing extra to carry.
|
||||||
|
- **Above 60%**, an interest premium appears and climbs steeply. From about **100%** it is dangerous, and bond panics and rating downgrades can arrive as proposals.
|
||||||
|
- At **135%**, a warning telegram arrives.
|
||||||
|
- **Above 165%**, the country defaults and your term ends at once.
|
||||||
|
|
||||||
|
Interest eats into the money you would rather spend on sectors, so debt feeds on itself.
|
||||||
|
|
||||||
|
## Ways to lose power
|
||||||
|
|
||||||
|
Four things end the game at once, each with its own front page. The chance grows with every day spent in the danger zone, so leaving it quickly matters more than leaving it perfectly.
|
||||||
|
|
||||||
|
| Ending | Trigger | Warning |
|
||||||
|
|---|---|---|
|
||||||
|
| Revolution | Unrest above 62 and public opinion below 28 | Urgent telegram: crowds at the gates |
|
||||||
|
| Military coup | Army loyalty below 35 with stability below 50, or army loyalty below 18 alone | Urgent telegram: the generals are restless |
|
||||||
|
| Impeachment | Civil rights below 10 and public opinion below 25, for 60 days in a row | Urgent telegram: a motion of no confidence |
|
||||||
|
| Default | Debt above 165% of GDP | Urgent telegram at 135%: lenders pull back |
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
|
||||||
|
Entering a danger zone sends an **urgent telegram**, and the game pauses until you acknowledge it. An **Emergency session** then opens the ledger at once, outside the quarterly schedule, so you can change course without waiting. It happens at most once every 45 days. A revolution or a coup is not certain on the first day, but it only gets likelier.
|
||||||
|
|
||||||
|
## The world
|
||||||
|
|
||||||
|
Outside your borders, a **global business cycle** swings between slumps and booms. It moves growth, inflation and revenue whatever you do, and a slump can turn a balanced budget into a deficit. The newspaper reports it.
|
||||||
|
|
||||||
|
Everything also **drifts**. Public opinion fades, order and stability erode, unrest creeps up, and needs grow. Standing still loses ground, so a hands-off term is a slow slide, not a safe harbour.
|
||||||
|
|
||||||
|
## The final review
|
||||||
|
|
||||||
|
The term ends with a review, drawn from the average of the whole term and not only its last day.
|
||||||
|
|
||||||
|
- A score out of 100, weighted toward opinion, stability and economy, then adjusted for final debt, average growth and unemployment.
|
||||||
|
- A grade: **S**, **A**, **B**, **C**, **D** or **F**. S is rare by design. A term that ends early is always an F.
|
||||||
|
- A title from the historians, and a verdict that depends on how you ruled.
|
||||||
|
- Charts of opinion, stability, economy and debt, and the decisions that defined the term.
|
||||||
|
|
||||||
|
## Saves
|
||||||
|
|
||||||
|
The game **autosaves** in your browser, and the autosave is removed when a game ends. The pause menu offers **three manual slots** (Save game).
|
||||||
|
|
||||||
|
- To keep a game safely, use **Export**: it gives a text string starting with `CG1:`, or a `.sav` file. **Import** takes either back, on any device.
|
||||||
|
- If your browser blocks storage (a private window, for instance), Settings warns you. Export instead.
|
||||||
|
- At the start you are asked how the press should refer to you: **He / Him**, **She / Her** or **They / Them**. This only changes how the historian writes about you.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Control | Effect |
|
||||||
|
|---|---|
|
||||||
|
| Space | Pause or resume |
|
||||||
|
| 1, 2, 3 | Speed 1x, 2x, 4x |
|
||||||
|
| Y / N | Approve or reject the top proposal |
|
||||||
|
| Esc | Open the menu (time stops while it is open) |
|
||||||
|
|
||||||
|
## Advice
|
||||||
|
|
||||||
|
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
|
||||||
|
- Do not ignore the army. A neglected army is a coup waiting for a date.
|
||||||
|
- Police is cheap and quick, but it costs civil rights.
|
||||||
|
- Every tax has a ceiling. Push past it and you earn less while angering everyone.
|
||||||
|
- Keep an eye on the intelligence summary: it speaks of what the gauges hide.
|
||||||
|
- Answer telegrams first.
|
||||||
|
- Read the projection in the ledger before you sign.
|
||||||
|
- Slow sectors need an early start: education funded in year four helps no one.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Le Bureau : Règles
|
||||||
|
|
||||||
|
Vous gouvernez la République de Valdorie pendant cinq ans, depuis un bureau en bois. Des dossiers arrivent, vous les signez ou vous les refusez, vous fixez le budget, et le pays réagit. Cette fiche explique ce qui se trouve sur le bureau et comment fonctionne la mécanique qui se cache derrière.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Votre mission
|
||||||
|
|
||||||
|
Gouvernez pendant tout le mandat, soit **1826 jours**, sans être chassé du pouvoir. Chaque décision porte votre signature, et la plupart ont un prix qui n'arrive que plus tard.
|
||||||
|
|
||||||
|
À la fin du mandat, un bilan est établi : une note de **S** à **F**, un titre décerné par les historiens et des graphiques de la période. **La dernière année compte pour moitié dans le score** : un essor acheté à crédit ne rapporte donc rien. On vous juge sur le pays que vous laissez.
|
||||||
|
|
||||||
|
> Le jeu ne dévoile jamais ses formules. Lisez les dossiers, surveillez les jauges, et tirez les leçons de ce qui arrive.
|
||||||
|
|
||||||
|
## Le bureau
|
||||||
|
|
||||||
|
Quatre éléments sont posés devant vous. Sur téléphone, ils se partagent l'écran, et une étagère en haut permet de passer de la Corbeille à la Note, au Journal et au Calendrier.
|
||||||
|
|
||||||
|
### La note ministérielle
|
||||||
|
|
||||||
|
La *Note ministérielle* est votre tableau de bord. Elle comprend trois parties.
|
||||||
|
|
||||||
|
- Sept **indicateurs**, chacun avec une jauge et une petite courbe de son évolution récente.
|
||||||
|
- Les **chiffres économiques** : PIB, croissance, chômage, inflation, dette et déficit en part du PIB, taux d'intérêt, chacun avec une flèche de tendance.
|
||||||
|
- La **synthèse du renseignement**, qui couvre ce que les jauges ne peuvent pas vous montrer (voir plus bas).
|
||||||
|
|
||||||
|
### La corbeille d'arrivée
|
||||||
|
|
||||||
|
La *Corbeille d'arrivée* contient les propositions qui attendent votre signature. Celle du dessus est celle à laquelle vous répondez. Quand elle est vide, le pays poursuit simplement sa route.
|
||||||
|
|
||||||
|
### Le calendrier
|
||||||
|
|
||||||
|
Le calendrier affiche le jour en cours, le prochain dossier et la prochaine revue budgétaire. Le cadran d'horloge fait un tour par jour de jeu, et tourne plus vite aux vitesses supérieures. Les boutons de vitesse, la pause et le Menu s'y trouvent aussi.
|
||||||
|
|
||||||
|
### Le journal
|
||||||
|
|
||||||
|
La presse raconte ce qui a bougé : indicateurs, croissance, emploi, prix, dette, et état de l'économie mondiale. C'est souvent là que l'on apprend qu'un creux ou un essor vient de commencer.
|
||||||
|
|
||||||
|
## Les sept indicateurs
|
||||||
|
|
||||||
|
Chacun va de 0 à 100. Ils évoluent lentement, s'influencent mutuellement, et tous glissent vers le bas si vous les laissez faire.
|
||||||
|
|
||||||
|
| Indicateur | Ce qu'il mesure | Ce qui l'influence |
|
||||||
|
|---|---|---|
|
||||||
|
| Ordre public | Rues calmes, État qui fonctionne | Police, justice, administration ; baisse avec le chômage et l'agitation |
|
||||||
|
| Stabilité | Solidité des institutions | Social, santé, logement, justice ; baisse avec la dette, le chômage, l'inflation |
|
||||||
|
| Droits civiques | Libertés et traitement équitable | Justice ; baisse avec une police lourde, une grosse armée et les coups de force |
|
||||||
|
| Opinion publique | Humeur de la population | Santé, social, logement, emploi, croissance ; baisse avec la hausse des prix et des impôts lourds |
|
||||||
|
| Économie | Vigueur de l'appareil productif | Industrie, infrastructures, recherche, éducation ; croissance et emploi |
|
||||||
|
| Puissance militaire | Poids des armes | L'armée avant tout, puis la recherche et l'industrie |
|
||||||
|
| Influence | Rayonnement à l'étranger | Culture, diplomatie, droits civiques |
|
||||||
|
|
||||||
|
### La synthèse du renseignement
|
||||||
|
|
||||||
|
Deux jauges vous sont cachées, et la synthèse les décrit avec des mots. Une troisième ligne dit si votre fauteuil est solide.
|
||||||
|
|
||||||
|
- **L'agitation** se lit Calme, Agité, Tendu, Volatil ou Explosif. Elle monte avec le chômage, l'inflation et une opinion hostile.
|
||||||
|
- **La loyauté de l'armée** se lit Mutinée, Hésitante, Stable ou Loyale. Elle suit le budget de l'armée, mais aussi la dette, l'inflation et l'humeur générale.
|
||||||
|
- **L'emprise sur le pouvoir** se lit Solide, Fragile ou Critique. Fragile signifie que vous êtes près d'une zone de danger. Critique, que vous y êtes.
|
||||||
|
|
||||||
|
## Le temps
|
||||||
|
|
||||||
|
Un jour de jeu dure **10 secondes** à la vitesse 1. Dans les Réglages, *Temps ralenti* porte le jour à 60 secondes.
|
||||||
|
|
||||||
|
- **Vitesses** : 1x, 2x et 4x, plus la pause. Espace met en pause et reprend ; les touches 1, 2 et 3 choisissent la vitesse.
|
||||||
|
- Le jeu continue de tourner si l'onglet du navigateur passe en arrière-plan. Le titre de l'onglet indique combien de propositions vous attendent.
|
||||||
|
- Le jeu s'arrête de lui-même pour la revue budgétaire, pour les télégrammes urgents, et quand la corbeille est pleine (voir plus bas).
|
||||||
|
|
||||||
|
## Les propositions
|
||||||
|
|
||||||
|
Chaque **lundi**, une nouvelle proposition arrive dans la corbeille. Elle prend la forme d'un mémorandum, d'un rapport, d'une lettre, d'une pétition ou d'un télégramme, et elle nomme son expéditeur.
|
||||||
|
|
||||||
|
Les propositions **s'empilent**. Le jeu ne s'arrête pas pour chacune : le temps continue de couler pendant que la pile grossit. Vous répondez à celle du dessus quand vous le décidez.
|
||||||
|
|
||||||
|
### Répondre
|
||||||
|
|
||||||
|
Apposez le tampon **Approuvé** ou **Rejeté**, avec les boutons ou avec les touches **Y** et **N**. Les deux réponses ont un coût : il n'existe pas de choix gratuit, et refuser est une décision comme une autre.
|
||||||
|
|
||||||
|
Le jeu **ne se met en pause que lorsque 10 propositions attendent**, et non au bout de 10 semaines, et il y reste jusqu'à ce que vous en traitiez une.
|
||||||
|
|
||||||
|
### Conséquences
|
||||||
|
|
||||||
|
Les réponses ont des effets durables, et beaucoup arrivent en retard. Certaines déclenchent une proposition de suivi quelques semaines plus tard : une promesse arrive à échéance, un refus devient une pétition, un pari tourne mal.
|
||||||
|
|
||||||
|
Une partie tire environ 260 propositions dans un fonds de plus de 900 : deux mandats ne se ressemblent jamais. Les télégrammes de crise sont à part : ils réagissent à l'état du pays et aux choix extrêmes. Affamez l'armée, pressez les contribuables ou armez la police jusqu'aux dents, et les télégrammes s'en apercevront.
|
||||||
|
|
||||||
|
## Le budget
|
||||||
|
|
||||||
|
Au jour 0, puis **chaque trimestre (13 semaines)**, le *Registre du Trésor* s'ouvre et le jeu se met en pause. Entre deux revues, le registre est fermé : le budget que vous avez classé est celui avec lequel vous vivez. Rien ne prend effet avant d'appuyer sur *Signer et classer*, et *Réinitialiser* revient aux chiffres d'ouverture du registre.
|
||||||
|
|
||||||
|
### Les secteurs
|
||||||
|
|
||||||
|
Quinze secteurs se partagent les dépenses : éducation, santé, social et retraites, police, armée, justice, industrie et énergie, transports publics, infrastructures, logement, recherche, culture et médias, diplomatie, environnement, administration.
|
||||||
|
|
||||||
|
Chacun se règle en **part du PIB**. Un repère sur chaque ligne marque ce dont le secteur a **besoin**, et les besoins augmentent un peu chaque année : un budget figé achète donc un peu moins chaque fois.
|
||||||
|
|
||||||
|
Chaque secteur a une **qualité** de 0 à 100 qui suit le budget avec retard. La police réagit en quelques semaines, la santé en quelques mois, l'éducation et les infrastructures seulement en plusieurs années. Au-dessus d'environ **70**, un secteur apporte des bénéfices supplémentaires ; en dessous d'environ **40**, il commence à faire mal. Les rendements sont décroissants : les dernières pièces sont celles qui rapportent le moins.
|
||||||
|
|
||||||
|
### Les impôts
|
||||||
|
|
||||||
|
Trois impôts se règlent : l'**impôt sur le revenu**, l'**impôt sur les sociétés** et la **TVA**. Pour chacun, les recettes montent avec le taux jusqu'à un sommet, marqué par un repère, puis **retombent**. Au-delà, vous encaissez moins et vous payez encore en opinion et en croissance. Chaque impôt blesse à sa façon : la TVA est la plus ressentie par la population, l'impôt sur les sociétés pèse le plus sur la croissance.
|
||||||
|
|
||||||
|
### La projection
|
||||||
|
|
||||||
|
Le registre affiche une synthèse du Trésor (recettes, dépenses sectorielles, intérêts de la dette, excédent ou déficit) et une **projection sur quatre trimestres** du déficit et de la dette. Elle suppose que rien d'autre ne change : elle ne peut donc pas prévoir le cycle mondial ni les propositions à venir.
|
||||||
|
|
||||||
|
## La dette
|
||||||
|
|
||||||
|
Chaque jour, le déficit s'ajoute à la dette, et un excédent la réduit. La dette se mesure par rapport au PIB.
|
||||||
|
|
||||||
|
- **Sous 60 %**, la dette est libre : la porter ne coûte rien de plus.
|
||||||
|
- **Au-dessus de 60 %**, une prime d'intérêt apparaît et grimpe rapidement. À partir d'environ **100 %**, c'est dangereux, et des paniques obligataires ou des dégradations de note peuvent arriver sous forme de propositions.
|
||||||
|
- À **135 %**, un télégramme d'avertissement arrive.
|
||||||
|
- **Au-dessus de 165 %**, le pays fait défaut et votre mandat s'arrête aussitôt.
|
||||||
|
|
||||||
|
Les intérêts rognent l'argent que vous préféreriez consacrer aux secteurs : la dette se nourrit d'elle-même.
|
||||||
|
|
||||||
|
## Les façons de perdre le pouvoir
|
||||||
|
|
||||||
|
Quatre événements mettent fin à la partie sur-le-champ, chacun avec sa propre une de journal. Le risque augmente chaque jour passé dans la zone de danger : en sortir vite compte plus que d'en sortir parfaitement.
|
||||||
|
|
||||||
|
| Fin | Déclencheur | Avertissement |
|
||||||
|
|---|---|---|
|
||||||
|
| Révolution | Agitation au-dessus de 62 et opinion publique sous 28 | Télégramme urgent : la foule aux portes |
|
||||||
|
| Coup d'État militaire | Loyauté de l'armée sous 35 avec une stabilité sous 50, ou loyauté sous 18 à elle seule | Télégramme urgent : les généraux s'impatientent |
|
||||||
|
| Destitution | Droits civiques sous 10 et opinion publique sous 25, pendant 60 jours d'affilée | Télégramme urgent : une motion de défiance |
|
||||||
|
| Défaut de paiement | Dette au-dessus de 165 % du PIB | Télégramme urgent à 135 % : les prêteurs se retirent |
|
||||||
|
|
||||||
|
### Les alertes
|
||||||
|
|
||||||
|
Entrer dans une zone de danger envoie un **télégramme urgent**, et le jeu se met en pause jusqu'à ce que vous l'ayez lu. Une **Séance d'urgence** ouvre alors aussitôt le registre, hors du calendrier trimestriel, pour que vous puissiez corriger la trajectoire sans attendre. Elle a lieu au plus une fois tous les 45 jours. Une révolution ou un coup d'État n'est pas certain dès le premier jour, mais il ne fait que se rapprocher.
|
||||||
|
|
||||||
|
## Le monde
|
||||||
|
|
||||||
|
Hors de vos frontières, un **cycle économique mondial** alterne creux et essors. Il pèse sur la croissance, l'inflation et les recettes quoi que vous fassiez, et un creux peut transformer un budget équilibré en déficit. Le journal en rend compte.
|
||||||
|
|
||||||
|
Tout **dérive** aussi. L'opinion s'érode, l'ordre et la stabilité s'effritent, l'agitation gagne du terrain, et les besoins croissent. Rester immobile fait perdre du terrain : un mandat sans action est une lente glissade, pas un port tranquille.
|
||||||
|
|
||||||
|
## Le bilan final
|
||||||
|
|
||||||
|
Le mandat se termine par un bilan, établi sur la moyenne de tout le mandat et pas seulement sur son dernier jour.
|
||||||
|
|
||||||
|
- Un score sur 100, pondéré surtout par l'opinion, la stabilité et l'économie, puis ajusté selon la dette finale, la croissance moyenne et le chômage.
|
||||||
|
- Une note : **S**, **A**, **B**, **C**, **D** ou **F**. Le S est rare par construction. Un mandat interrompu en cours de route vaut toujours un F.
|
||||||
|
- Un titre décerné par les historiens, et un verdict qui dépend de votre façon de gouverner.
|
||||||
|
- Des graphiques de l'opinion, de la stabilité, de l'économie et de la dette, et les décisions qui ont marqué le mandat.
|
||||||
|
|
||||||
|
## Sauvegardes
|
||||||
|
|
||||||
|
Le jeu fait une **sauvegarde auto** dans votre navigateur, supprimée quand une partie se termine. Le menu pause propose **trois emplacements manuels** (Sauvegarder).
|
||||||
|
|
||||||
|
- Pour conserver une partie en sûreté, utilisez **Exporter** : vous obtenez une chaîne de texte commençant par `CG1:`, ou un fichier `.sav`. **Importer** reprend l'un ou l'autre, sur n'importe quel appareil.
|
||||||
|
- Si votre navigateur bloque le stockage (fenêtre privée, par exemple), les Réglages vous en avertissent. Exportez alors.
|
||||||
|
- Au début, on vous demande comment la presse doit s'adresser à vous : **Il / Lui**, **Elle / Elle** ou **Iel / Ielle**. Cela ne change que la manière dont l'historien parle de vous.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
| Commande | Effet |
|
||||||
|
|---|---|
|
||||||
|
| Espace | Pause ou reprise |
|
||||||
|
| 1, 2, 3 | Vitesse 1x, 2x, 4x |
|
||||||
|
| Y / N | Approuver ou rejeter la proposition du dessus |
|
||||||
|
| Échap | Ouvrir le menu (le temps s'arrête tant qu'il est ouvert) |
|
||||||
|
|
||||||
|
## Conseils
|
||||||
|
|
||||||
|
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
|
||||||
|
- N'oubliez pas l'armée. Une armée négligée est un coup d'État qui attend sa date.
|
||||||
|
- La police est peu chère et rapide, mais elle coûte des droits civiques.
|
||||||
|
- Chaque impôt a un plafond. Le dépasser rapporte moins et irrite tout le monde.
|
||||||
|
- Gardez un œil sur la synthèse du renseignement : elle dit ce que les jauges cachent.
|
||||||
|
- Répondez d'abord aux télégrammes.
|
||||||
|
- Lisez la projection du registre avant de signer.
|
||||||
|
- Les secteurs lents demandent de l'avance : l'éducation financée en quatrième année ne profite à personne.
|
||||||
+5
-1
@@ -2,7 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#26322c">
|
||||||
<title>The Desk</title>
|
<title>The Desk</title>
|
||||||
<link rel="stylesheet" href="css/desk.css">
|
<link rel="stylesheet" href="css/desk.css">
|
||||||
<link rel="stylesheet" href="css/papers.css">
|
<link rel="stylesheet" href="css/papers.css">
|
||||||
@@ -11,10 +12,13 @@
|
|||||||
<link rel="stylesheet" href="css/decision.css">
|
<link rel="stylesheet" href="css/decision.css">
|
||||||
<link rel="stylesheet" href="css/endgame.css">
|
<link rel="stylesheet" href="css/endgame.css">
|
||||||
<link rel="stylesheet" href="css/menu.css">
|
<link rel="stylesheet" href="css/menu.css">
|
||||||
|
<link rel="stylesheet" href="css/anim.css">
|
||||||
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="desk">
|
<main id="desk">
|
||||||
<div id="pad">
|
<div id="pad">
|
||||||
|
<nav id="shelf" class="shelf" aria-label="Display"></nav>
|
||||||
<section id="dashboard" class="paper paper--graph" aria-label="Ministerial briefing"></section>
|
<section id="dashboard" class="paper paper--graph" aria-label="Ministerial briefing"></section>
|
||||||
<section id="folder" class="paper paper--folder"></section>
|
<section id="folder" class="paper paper--folder"></section>
|
||||||
<section id="ledger" class="ledger" hidden aria-label="Treasury ledger"></section>
|
<section id="ledger" class="ledger" hidden aria-label="Treasury ledger"></section>
|
||||||
|
|||||||
+25
-16
@@ -51,12 +51,15 @@ export function drawCard(state, bal, cards) {
|
|||||||
const fiscal = computeFiscal(state, bal);
|
const fiscal = computeFiscal(state, bal);
|
||||||
const lastAnswered = new Map();
|
const lastAnswered = new Map();
|
||||||
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
|
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
|
||||||
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
|
const waiting = new Set(state.inbox.map((e) => e.id));
|
||||||
|
// the last few categories seen, answered or still in the tray
|
||||||
|
const recent = [...state.cardHistory, ...state.inbox].slice(-bal.cards.recentWindow).map((h) => h.category);
|
||||||
|
|
||||||
const pool = [];
|
const pool = [];
|
||||||
const weights = [];
|
const weights = [];
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
if (card.followUpOnly) continue;
|
if (card.followUpOnly || waiting.has(card.id)) continue;
|
||||||
|
if (card.scenarios && !card.scenarios.includes(state.scenarioId)) continue;
|
||||||
const last = lastAnswered.get(card.id);
|
const last = lastAnswered.get(card.id);
|
||||||
if (last !== undefined) {
|
if (last !== undefined) {
|
||||||
if (card.once !== false) continue;
|
if (card.once !== false) continue;
|
||||||
@@ -70,28 +73,34 @@ export function drawCard(state, bal, cards) {
|
|||||||
return pickWeighted(state, pool, weights);
|
return pickWeighted(state, pool, weights);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
|
// Called when a new week starts (state.pending.weeklyCard): draws the week's proposal and puts it
|
||||||
// A due follow-up always comes first. With no eligible card the week is simply empty.
|
// at the bottom of the in-tray (state.inbox). A due follow-up always comes first. With no eligible
|
||||||
|
// card the week is simply empty. Proposals pile up without stopping the clock; the game only waits
|
||||||
|
// once the tray holds bal.cards.inboxMax of them (pending.inboxFull), until one is answered.
|
||||||
export function ensureCard(state, bal, cards) {
|
export function ensureCard(state, bal, cards) {
|
||||||
if (!state.pending.weeklyCard || state.pendingCard) return null;
|
if (!state.pending.weeklyCard) return null;
|
||||||
|
state.pending.weeklyCard = false;
|
||||||
const byId = indexCards(cards);
|
const byId = indexCards(cards);
|
||||||
|
const waiting = new Set(state.inbox.map((e) => e.id));
|
||||||
|
|
||||||
let card = null;
|
let card = null;
|
||||||
const due = state.followUps.findIndex((f) => f.due <= state.day);
|
const due = state.followUps.findIndex((f) => f.due <= state.day && !waiting.has(f.id));
|
||||||
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
|
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
|
||||||
card ??= drawCard(state, bal, cards);
|
card ??= drawCard(state, bal, cards);
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
if (!card) {
|
state.inbox.push({ id: card.id, day: state.day, category: card.category });
|
||||||
state.pending.weeklyCard = false;
|
if (state.inbox.length >= bal.cards.inboxMax) state.pending.inboxFull = true;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
state.pendingCard = { id: card.id, day: state.day };
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function answerCard(state, bal, cards, choice) {
|
// Answers the proposal `id` (default: the one on top of the tray, the oldest).
|
||||||
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
|
export function answerCard(state, bal, cards, choice, id) {
|
||||||
const card = indexCards(cards).get(state.pendingCard.id);
|
if (choice !== "yes" && choice !== "no") return { ok: false };
|
||||||
|
const at = id === undefined ? 0 : state.inbox.findIndex((e) => e.id === id);
|
||||||
|
const entry = state.inbox[at];
|
||||||
|
if (!entry) return { ok: false };
|
||||||
|
const card = indexCards(cards).get(entry.id);
|
||||||
if (!card) return { ok: false };
|
if (!card) return { ok: false };
|
||||||
|
|
||||||
const option = card[choice];
|
const option = card[choice];
|
||||||
@@ -101,7 +110,7 @@ export function answerCard(state, bal, cards, choice) {
|
|||||||
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
|
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
|
||||||
|
|
||||||
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
|
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
|
||||||
state.pendingCard = null;
|
state.inbox.splice(at, 1);
|
||||||
state.pending.weeklyCard = false;
|
state.pending.inboxFull = state.inbox.length >= bal.cards.inboxMax;
|
||||||
return { ok: true, card };
|
return { ok: true, card };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
|
|||||||
world: { cycle: 0 }, // global business cycle, see world.js
|
world: { cycle: 0 }, // global business cycle, see world.js
|
||||||
flags: {},
|
flags: {},
|
||||||
cardHistory: [],
|
cardHistory: [],
|
||||||
pendingCard: null,
|
inbox: [], // proposals waiting in the tray, oldest first: { id, day, category }
|
||||||
followUps: [],
|
followUps: [],
|
||||||
// Day 0 opens with the pronoun question, the initial budget review and a first card.
|
// Day 0 opens with the pronoun question, the initial budget review and a first card.
|
||||||
// Any true flag here pauses time (see advanceDay).
|
// Any true flag here pauses time (see advanceDay). weeklyCard is momentary: the caller answers it
|
||||||
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
|
// at once with ensureCard(); inboxFull stays until the tray is below its limit.
|
||||||
|
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false, inboxFull: false },
|
||||||
over: null,
|
over: null,
|
||||||
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
|
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
|
||||||
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
|
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Shared constants and tiny helpers. No game logic here.
|
// Shared constants and tiny helpers. No game logic here.
|
||||||
|
|
||||||
export const STATE_VERSION = 2;
|
export const STATE_VERSION = 3;
|
||||||
|
|
||||||
export const IND_IDS = [
|
export const IND_IDS = [
|
||||||
"order", "stability", "civilRights", "opinion", "economy",
|
"order", "stability", "civilRights", "opinion", "economy",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ export function createClock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Feed it the real elapsed ms; it returns how many in-game days are due.
|
// Feed it the real elapsed ms; it returns how many in-game days are due.
|
||||||
// dt is capped so a background tab doesn't fast-forward the game on return, and
|
// dt is capped (capMs) so a frozen tab doesn't fast-forward the game without limit when it wakes
|
||||||
// floored at 0: the first animation frame can carry a timestamp slightly before
|
// up, and floored at 0: the first animation frame can carry a timestamp slightly before the one the
|
||||||
// the one the caller recorded, and a negative step would pre-load a whole day.
|
// caller recorded, and a negative step would pre-load a whole day.
|
||||||
export function consumeClock(clock, dtMs, msPerDay, speed) {
|
export function consumeClock(clock, dtMs, msPerDay, speed, capMs = 1000) {
|
||||||
clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed;
|
clock.acc += Math.max(0, Math.min(dtMs, capMs)) * speed;
|
||||||
const days = Math.floor(clock.acc / msPerDay);
|
const days = Math.floor(clock.acc / msPerDay);
|
||||||
clock.acc -= days * msPerDay;
|
clock.acc -= days * msPerDay;
|
||||||
return days;
|
return days;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "tax
|
|||||||
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
|
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
|
||||||
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
|
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
|
||||||
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
|
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
|
||||||
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
|
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert", "inboxFull"];
|
||||||
|
|
||||||
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
||||||
@@ -73,7 +73,11 @@ export function restoreState(s, { scenarios, balance, cards }) {
|
|||||||
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
|
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
|
||||||
}
|
}
|
||||||
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
|
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
|
||||||
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
|
if (!Array.isArray(s.inbox) || s.inbox.length > balance.cards.inboxMax) return fail();
|
||||||
|
for (const e of s.inbox) {
|
||||||
|
if (!isObj(e) || typeof e.id !== "string" || !isInt(e.day, 0, balance.time.termDays)) return fail();
|
||||||
|
if (e.category !== undefined && typeof e.category !== "string") return fail();
|
||||||
|
}
|
||||||
|
|
||||||
// Pauses, alerts and endings
|
// Pauses, alerts and endings
|
||||||
if (!isObj(s.pending)) return fail();
|
if (!isObj(s.pending)) return fail();
|
||||||
@@ -95,7 +99,8 @@ export function restoreState(s, { scenarios, balance, cards }) {
|
|||||||
|
|
||||||
// Repairs: content that changed since the save was made
|
// Repairs: content that changed since the save was made
|
||||||
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
|
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
|
||||||
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
|
s.inbox = s.inbox.filter((e) => cardIds.has(e.id));
|
||||||
|
s.pending.inboxFull = s.inbox.length >= balance.cards.inboxMax;
|
||||||
s.pending.alert = s.alerts.length > 0;
|
s.pending.alert = s.alerts.length > 0;
|
||||||
return { ok: true, state: s };
|
return { ok: true, state: s };
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -186,5 +186,21 @@
|
|||||||
"credits.title": "Credits",
|
"credits.title": "Credits",
|
||||||
"credits.fiction": "Valdoria and everyone in it are fictional.",
|
"credits.fiction": "Valdoria and everyone in it are fictional.",
|
||||||
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
|
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
|
||||||
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game."
|
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game.",
|
||||||
|
"inbox.full": "The tray is full. Time is stopped until you answer one.",
|
||||||
|
"shelf.nav": "Choose a display",
|
||||||
|
"shelf.tray": "Tray",
|
||||||
|
"shelf.brief": "Briefing",
|
||||||
|
"shelf.news": "News",
|
||||||
|
"shelf.clock": "Calendar",
|
||||||
|
"shelf.ledger": "Ledger",
|
||||||
|
"shelf.menu": "Menu",
|
||||||
|
"shelf.waiting": "{n} waiting",
|
||||||
|
"menu.rules": "Rules",
|
||||||
|
"rules.title": "Rules of the desk",
|
||||||
|
"rules.loading": "Opening the rule book…",
|
||||||
|
"rules.error": "The rule book could not be opened.",
|
||||||
|
"settings.sound": "Sounds",
|
||||||
|
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
|
||||||
|
"settings.volume": "Volume"
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -186,5 +186,21 @@
|
|||||||
"credits.title": "Crédits",
|
"credits.title": "Crédits",
|
||||||
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
|
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
|
||||||
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
|
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
|
||||||
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu."
|
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu.",
|
||||||
|
"inbox.full": "La corbeille est pleine. Le temps est arrêté tant que vous n'en traitez pas une.",
|
||||||
|
"shelf.nav": "Choisir l'affichage",
|
||||||
|
"shelf.tray": "Corbeille",
|
||||||
|
"shelf.brief": "Note",
|
||||||
|
"shelf.news": "Journal",
|
||||||
|
"shelf.clock": "Calendrier",
|
||||||
|
"shelf.ledger": "Registre",
|
||||||
|
"shelf.menu": "Menu",
|
||||||
|
"shelf.waiting": "{n} en attente",
|
||||||
|
"menu.rules": "Règles",
|
||||||
|
"rules.title": "Règlement du bureau",
|
||||||
|
"rules.loading": "Ouverture du règlement…",
|
||||||
|
"rules.error": "Le règlement n'a pas pu être ouvert.",
|
||||||
|
"settings.sound": "Sons",
|
||||||
|
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
|
||||||
|
"settings.volume": "Volume"
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-24
@@ -1,9 +1,13 @@
|
|||||||
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
|
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
|
||||||
|
// The loop is driven by animation frames while the tab is visible and by a worker heartbeat while it
|
||||||
|
// is hidden, so the game keeps running in the background (it still stops at a budget review, a danger
|
||||||
|
// warning, or when the in-tray holds its maximum of proposals).
|
||||||
//
|
//
|
||||||
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
|
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
|
||||||
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
|
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
|
||||||
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
|
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
|
||||||
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
|
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
|
||||||
|
// ?msPerDay=N (real milliseconds per in-game day at speed 1, for tests) ?untilAlert (stop ?skip at the first warning)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
|
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
|
||||||
@@ -20,6 +24,8 @@ import { createLedger } from "./ui/ledger.js";
|
|||||||
import { createTelegram } from "./ui/telegram.js";
|
import { createTelegram } from "./ui/telegram.js";
|
||||||
import { createEndgame } from "./ui/endgame.js";
|
import { createEndgame } from "./ui/endgame.js";
|
||||||
import { createMenu } from "./ui/menu.js";
|
import { createMenu } from "./ui/menu.js";
|
||||||
|
import { createShelf } from "./ui/shelf.js";
|
||||||
|
import { createAudio } from "./ui/audio.js";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const getJson = (url) => fetch(url).then((r) => r.json());
|
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||||
@@ -48,6 +54,8 @@ let settings = loadSettings(storage);
|
|||||||
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
||||||
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
||||||
|
|
||||||
|
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume });
|
||||||
|
|
||||||
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||||
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||||
|
|
||||||
@@ -58,6 +66,8 @@ let scenario = null;
|
|||||||
let panels = null;
|
let panels = null;
|
||||||
let menuOpen = true;
|
let menuOpen = true;
|
||||||
let lastSpeed = 1;
|
let lastSpeed = 1;
|
||||||
|
let view = "tray"; // phone layout: which paper is on the desk (tray, brief, news, clock, ledger)
|
||||||
|
let seen = null; // pending flags and tray size at the last change, to play sounds on transitions
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
dash: document.getElementById("dashboard"),
|
dash: document.getElementById("dashboard"),
|
||||||
@@ -68,13 +78,39 @@ const el = {
|
|||||||
cal: document.getElementById("calendar"),
|
cal: document.getElementById("calendar"),
|
||||||
news: document.getElementById("newspaper"),
|
news: document.getElementById("newspaper"),
|
||||||
menu: document.getElementById("menu"),
|
menu: document.getElementById("menu"),
|
||||||
|
shelf: document.getElementById("shelf"),
|
||||||
};
|
};
|
||||||
const pad = document.getElementById("pad");
|
const pad = document.getElementById("pad");
|
||||||
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||||
|
|
||||||
// A weekly pause needs a card; with none eligible the week is simply empty.
|
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
|
||||||
function prepareWeek() {
|
function prepareWeek() {
|
||||||
ensureCard(state, balance, cards);
|
if (state.pending.weeklyCard) ensureCard(state, balance, cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTitle() {
|
||||||
|
const base = t("app.title");
|
||||||
|
if (!state || state.over) {
|
||||||
|
document.title = base;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const waiting = document.hidden && isWaiting(state) && !state.pending.profile;
|
||||||
|
const n = state.inbox.length;
|
||||||
|
document.title = `${waiting ? "● " : ""}${n ? `(${n}) ` : ""}${base}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sounds for what just changed: a new proposal, a budget review, a warning, a full tray, the end.
|
||||||
|
function notify() {
|
||||||
|
const p = state.pending;
|
||||||
|
const now = { review: p.review, alert: p.alert, inboxFull: p.inboxFull, over: Boolean(state.over), inbox: state.inbox.length };
|
||||||
|
if (seen) {
|
||||||
|
if (now.over && !seen.over) audio.play(state.over.type === "term" ? "fanfare" : "fall");
|
||||||
|
else if (now.alert && !seen.alert) audio.play("alert");
|
||||||
|
else if (now.review && !seen.review) audio.play("page");
|
||||||
|
else if (now.inboxFull && !seen.inboxFull) audio.play("chime");
|
||||||
|
else if (now.inbox > seen.inbox) audio.play("paper");
|
||||||
|
}
|
||||||
|
seen = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
function autosave() {
|
function autosave() {
|
||||||
@@ -87,13 +123,19 @@ function autosave() {
|
|||||||
|
|
||||||
function build() {
|
function build() {
|
||||||
panels?.decision.destroy();
|
panels?.decision.destroy();
|
||||||
document.title = t("app.title");
|
|
||||||
state.lang = getLang();
|
state.lang = getLang();
|
||||||
|
pad.classList.add("is-dealing"); // the papers land on the desk
|
||||||
|
setTimeout(() => pad.classList.remove("is-dealing"), 1100);
|
||||||
panels = {
|
panels = {
|
||||||
dash: createDashboard(el.dash, scenario),
|
dash: createDashboard(el.dash, scenario),
|
||||||
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
|
decision: createDecision(el.folder, {
|
||||||
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
|
cardsById, characters, inboxMax: balance.cards.inboxMax, onAnswer, onProfile,
|
||||||
|
onStamp: (choice) => audio.play(choice === "yes" ? "stampYes" : "stampNo"),
|
||||||
|
keysEnabled: () => !menuOpen,
|
||||||
|
}),
|
||||||
|
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
|
||||||
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
||||||
|
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, onSpeed, onMenu: openMenu }),
|
||||||
news: createNewspaper(el.news, newsTpl),
|
news: createNewspaper(el.news, newsTpl),
|
||||||
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||||
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||||
@@ -101,12 +143,34 @@ function build() {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phone layout: the ledger takes the screen when a review opens and hands it back to the tray afterwards.
|
||||||
|
function syncView(ledgerOpen) {
|
||||||
|
if (ledgerOpen && !seen?.review) view = "ledger";
|
||||||
|
else if (!ledgerOpen && view === "ledger") view = "tray";
|
||||||
|
if (state.pending.inboxFull && !seen?.inboxFull && !ledgerOpen) view = "tray";
|
||||||
|
pad.dataset.view = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(next) {
|
||||||
|
view = next;
|
||||||
|
pad.dataset.view = view;
|
||||||
|
panels?.shelf.update(state, balance);
|
||||||
|
audio.play("click");
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
const ledgerOpen = Boolean(state.pending.review && !state.pending.profile && !state.over);
|
||||||
|
syncView(ledgerOpen);
|
||||||
|
notify();
|
||||||
|
// A hidden tab keeps playing but skips the drawing; it redraws everything when it comes back.
|
||||||
|
if (!document.hidden) {
|
||||||
for (const p of Object.values(panels)) p.update(state, balance);
|
for (const p of Object.values(panels)) p.update(state, balance);
|
||||||
// The ledger covers the folder and the side column; the end screen covers everything.
|
// The ledger covers the folder and the side column; the end screen covers everything.
|
||||||
pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over));
|
pad.classList.toggle("is-ledger", ledgerOpen);
|
||||||
pad.classList.toggle("is-endgame", Boolean(state.over));
|
pad.classList.toggle("is-endgame", Boolean(state.over));
|
||||||
|
}
|
||||||
|
updateTitle();
|
||||||
autosave();
|
autosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +192,9 @@ function enterGame(next) {
|
|||||||
scenario = scenarios[state.scenarioId];
|
scenario = scenarios[state.scenarioId];
|
||||||
if (params.has("dev")) window.__game = state;
|
if (params.has("dev")) window.__game = state;
|
||||||
lastSpeed = state.speed || 1;
|
lastSpeed = state.speed || 1;
|
||||||
|
clock.acc = 0;
|
||||||
|
view = "tray";
|
||||||
|
seen = null;
|
||||||
prepareWeek();
|
prepareWeek();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
build();
|
build();
|
||||||
@@ -144,7 +211,6 @@ function closeMenu() {
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
pad.classList.remove("is-menu");
|
pad.classList.remove("is-menu");
|
||||||
menu.hide();
|
menu.hide();
|
||||||
clock.acc = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function quitToMenu() {
|
function quitToMenu() {
|
||||||
@@ -152,7 +218,8 @@ function quitToMenu() {
|
|||||||
panels?.decision.destroy();
|
panels?.decision.destroy();
|
||||||
state = null;
|
state = null;
|
||||||
panels = null;
|
panels = null;
|
||||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
|
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf"]) el[id].innerHTML = "";
|
||||||
|
document.title = t("app.title");
|
||||||
el.ledger.hidden = true;
|
el.ledger.hidden = true;
|
||||||
el.alerts.hidden = true;
|
el.alerts.hidden = true;
|
||||||
el.endgame.hidden = true;
|
el.endgame.hidden = true;
|
||||||
@@ -166,6 +233,7 @@ function applySettings(patch) {
|
|||||||
const langChanged = patch.lang && patch.lang !== getLang();
|
const langChanged = patch.lang && patch.lang !== getLang();
|
||||||
settings = { ...settings, ...patch };
|
settings = { ...settings, ...patch };
|
||||||
saveSettings(storage, settings);
|
saveSettings(storage, settings);
|
||||||
|
if (patch.sound === true || patch.volume !== undefined) audio.play("click");
|
||||||
if (!langChanged) return;
|
if (!langChanged) return;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
document.title = t("app.title");
|
document.title = t("app.title");
|
||||||
@@ -192,11 +260,12 @@ const menu = createMenu(el.menu, {
|
|||||||
function onSpeed(speed) {
|
function onSpeed(speed) {
|
||||||
if (speed > 0) lastSpeed = speed;
|
if (speed > 0) lastSpeed = speed;
|
||||||
setSpeed(state, balance, speed);
|
setSpeed(state, balance, speed);
|
||||||
|
audio.play("click");
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAnswer(choice) {
|
function onAnswer(choice, id) {
|
||||||
answerCard(state, balance, cards, choice);
|
answerCard(state, balance, cards, choice, id);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +276,7 @@ function onProfile(pronouns) {
|
|||||||
|
|
||||||
function onDismiss() {
|
function onDismiss() {
|
||||||
dismissAlert(state);
|
dismissAlert(state);
|
||||||
|
audio.play("paper");
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +294,11 @@ document.addEventListener("keydown", (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addEventListener("pagehide", autosave);
|
addEventListener("pagehide", autosave);
|
||||||
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) autosave();
|
||||||
|
else if (state) render(); // redraw what was skipped while hidden
|
||||||
|
updateTitle();
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Dev shortcuts ----
|
// ---- Dev shortcuts ----
|
||||||
|
|
||||||
@@ -240,13 +314,12 @@ function devGame() {
|
|||||||
s.pending.review = false;
|
s.pending.review = false;
|
||||||
while (s.pending.alert) dismissAlert(s);
|
while (s.pending.alert) dismissAlert(s);
|
||||||
prepareWeek();
|
prepareWeek();
|
||||||
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
while (s.inbox.length) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||||
advanceDay(s, balance);
|
advanceDay(s, balance);
|
||||||
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
|
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
|
||||||
}
|
}
|
||||||
if (cardsById.has(params.get("card"))) {
|
if (cardsById.has(params.get("card"))) {
|
||||||
s.pendingCard = { id: params.get("card"), day: s.day };
|
s.inbox.unshift({ id: params.get("card"), day: s.day, category: cardsById.get(params.get("card")).category });
|
||||||
s.pending.weeklyCard = true;
|
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -259,25 +332,47 @@ let last = performance.now();
|
|||||||
if (devRun) enterGame(devGame());
|
if (devRun) enterGame(devGame());
|
||||||
else quitToMenu();
|
else quitToMenu();
|
||||||
|
|
||||||
function frame(now) {
|
// One step of the game: turns elapsed real time into in-game days. Returns true when a day passed.
|
||||||
|
// A long gap since the last step (a frozen background tab waking up) is caught up within a limit.
|
||||||
|
function step(now) {
|
||||||
const dt = now - last;
|
const dt = now - last;
|
||||||
last = now;
|
last = now;
|
||||||
if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) {
|
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state)) return false;
|
||||||
const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay;
|
let days = consumeClock(clock, dt, msPerDay(), state.speed, balance.time.catchUpMs);
|
||||||
let days = consumeClock(clock, dt, msPerDay, state.speed);
|
let advanced = false;
|
||||||
while (days-- > 0) {
|
while (days-- > 0) {
|
||||||
const r = advanceDay(state, balance);
|
const r = advanceDay(state, balance);
|
||||||
if (!r.advanced) break;
|
if (!r.advanced) break;
|
||||||
|
advanced = true;
|
||||||
if (r.events.weeklyCard) prepareWeek();
|
if (r.events.weeklyCard) prepareWeek();
|
||||||
render();
|
if (!document.hidden && state.speed <= 2) audio.play(state.day % 2 ? "tick" : "tock");
|
||||||
if (isWaiting(state) || state.over) {
|
if (isWaiting(state) || state.over) break;
|
||||||
clock.acc = 0;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (advanced) render();
|
||||||
|
return advanced;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
clock.acc = 0;
|
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
|
||||||
|
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
step(now);
|
||||||
|
if (panels) {
|
||||||
|
const p = Math.min(1, clock.acc / msPerDay());
|
||||||
|
panels.cal.clockHand(p);
|
||||||
|
panels.shelf.clockHand(p);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// Background play: animation frames stop in a hidden tab, so a worker's timer keeps the game going.
|
||||||
|
function startHeartbeat() {
|
||||||
|
const beat = () => { if (document.hidden) step(performance.now()); };
|
||||||
|
try {
|
||||||
|
new Worker(new URL("./timer-worker.js", import.meta.url)).onmessage = beat;
|
||||||
|
} catch {
|
||||||
|
setInterval(beat, 1000); // no worker support: the slower main-thread timer still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startHeartbeat();
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export function encodeSave(state) {
|
|||||||
export const MIGRATIONS = {
|
export const MIGRATIONS = {
|
||||||
// v2 added the world cycle: older saves start it at neutral.
|
// v2 added the world cycle: older saves start it at neutral.
|
||||||
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
|
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
|
||||||
|
// v3 replaced the single pending card by the in-tray queue.
|
||||||
|
2: ({ pendingCard, ...s }) => ({
|
||||||
|
...s,
|
||||||
|
version: 3,
|
||||||
|
inbox: pendingCard ? [{ id: pendingCard.id, day: pendingCard.day }] : [],
|
||||||
|
pending: { ...s.pending, weeklyCard: Boolean(s.pending?.weeklyCard) && !pendingCard, inboxFull: false },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function migrate(state) {
|
export function migrate(state) {
|
||||||
@@ -55,7 +62,11 @@ export function migrate(state) {
|
|||||||
while (s.version < STATE_VERSION) {
|
while (s.version < STATE_VERSION) {
|
||||||
const step = MIGRATIONS[s.version];
|
const step = MIGRATIONS[s.version];
|
||||||
if (!step) return { ok: false, error: "version" };
|
if (!step) return { ok: false, error: "version" };
|
||||||
|
try {
|
||||||
s = step(s);
|
s = step(s);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "format" }; // a damaged or hostile save must never throw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, state: s };
|
return { ok: true, state: s };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function readSave(store, key) {
|
|||||||
|
|
||||||
// ---- Settings ----
|
// ---- Settings ----
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = { lang: null, slow: false };
|
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6 };
|
||||||
|
|
||||||
export function loadSettings(store) {
|
export function loadSettings(store) {
|
||||||
try {
|
try {
|
||||||
@@ -96,6 +96,8 @@ export function loadSettings(store) {
|
|||||||
return {
|
return {
|
||||||
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||||
slow: s.slow === true,
|
slow: s.slow === true,
|
||||||
|
sound: s.sound !== false,
|
||||||
|
volume: typeof s.volume === "number" && s.volume >= 0 && s.volume <= 1 ? s.volume : DEFAULT_SETTINGS.volume,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// A heartbeat for the game loop. Browsers stop animation frames in a background tab and slow
|
||||||
|
// down its timers, but a worker's timer keeps going, so the game can keep running while hidden.
|
||||||
|
setInterval(() => postMessage(0), 100);
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
|
||||||
|
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
|
||||||
|
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
|
||||||
|
|
||||||
|
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||||
|
let ctx = null;
|
||||||
|
let master = null;
|
||||||
|
let noiseBuf = null;
|
||||||
|
|
||||||
|
function context() {
|
||||||
|
if (!ctx) {
|
||||||
|
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||||
|
if (!AC) return null;
|
||||||
|
try {
|
||||||
|
ctx = new AC();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
master = ctx.createGain();
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
const c = context();
|
||||||
|
if (c && c.state === "suspended") c.resume?.().catch?.(() => {});
|
||||||
|
}
|
||||||
|
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
|
||||||
|
|
||||||
|
// One note: a sine (or other) wave with an attack and an exponential fade.
|
||||||
|
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
|
||||||
|
const o = c.createOscillator();
|
||||||
|
const g = c.createGain();
|
||||||
|
o.type = type;
|
||||||
|
o.frequency.setValueAtTime(freq, at);
|
||||||
|
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
|
||||||
|
g.gain.setValueAtTime(0.0001, at);
|
||||||
|
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
|
o.connect(g).connect(master);
|
||||||
|
o.start(at);
|
||||||
|
o.stop(at + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered noise: paper, pages, thumps and clicks.
|
||||||
|
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
|
||||||
|
noiseBuf ??= (() => {
|
||||||
|
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
|
const d = b.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
return b;
|
||||||
|
})();
|
||||||
|
const src = c.createBufferSource();
|
||||||
|
src.buffer = noiseBuf;
|
||||||
|
src.loop = true;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = type;
|
||||||
|
f.Q.value = q;
|
||||||
|
f.frequency.setValueAtTime(freq, at);
|
||||||
|
if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur);
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, at);
|
||||||
|
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
|
src.connect(f).connect(g).connect(master);
|
||||||
|
src.start(at);
|
||||||
|
src.stop(at + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOUNDS = {
|
||||||
|
stampYes: (c, t) => {
|
||||||
|
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
|
||||||
|
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
|
||||||
|
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||||
|
},
|
||||||
|
stampNo: (c, t) => {
|
||||||
|
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
|
||||||
|
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
|
||||||
|
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||||
|
},
|
||||||
|
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
|
||||||
|
page: (c, t) => {
|
||||||
|
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
|
||||||
|
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
|
||||||
|
},
|
||||||
|
sign: (c, t) => {
|
||||||
|
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
|
||||||
|
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
|
||||||
|
},
|
||||||
|
telegram: (c, t) => {
|
||||||
|
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
|
||||||
|
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
|
||||||
|
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
|
||||||
|
},
|
||||||
|
alert: (c, t) => {
|
||||||
|
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||||
|
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||||
|
},
|
||||||
|
chime: (c, t) => {
|
||||||
|
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
|
||||||
|
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
|
||||||
|
},
|
||||||
|
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||||
|
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||||
|
click: (c, t) => {
|
||||||
|
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
|
||||||
|
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
|
||||||
|
},
|
||||||
|
fall: (c, t) => {
|
||||||
|
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
|
||||||
|
},
|
||||||
|
fanfare: (c, t) => {
|
||||||
|
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function play(name) {
|
||||||
|
if (!enabled()) return;
|
||||||
|
const make = SOUNDS[name];
|
||||||
|
const c = context();
|
||||||
|
if (!make || !c) return;
|
||||||
|
if (c.state === "suspended") {
|
||||||
|
c.resume?.().catch?.(() => {});
|
||||||
|
return; // still locked: nothing to hear yet
|
||||||
|
}
|
||||||
|
master.gain.value = Math.max(0, Math.min(1, volume()));
|
||||||
|
try {
|
||||||
|
make(c, c.currentTime + 0.005);
|
||||||
|
} catch {
|
||||||
|
/* a failing sound never stops the game */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { play, unlock, names: Object.keys(SOUNDS) };
|
||||||
|
}
|
||||||
+21
-6
@@ -1,17 +1,22 @@
|
|||||||
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
|
// Desk calendar: a dial clock that turns once per in-game day, the date, term progress, next
|
||||||
// controls and the way into the menu.
|
// decision dates, the brass speed controls and the way into the menu.
|
||||||
|
|
||||||
import { t, dateParts } from "../i18n/i18n.js";
|
import { t, dateParts } from "../i18n/i18n.js";
|
||||||
import { dateOfDay } from "../engine/index.js";
|
import { dateOfDay } from "../engine/index.js";
|
||||||
import { $ } from "./desk.js";
|
import { $, dialSvg, setDial } from "./desk.js";
|
||||||
|
|
||||||
export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||||
const speeds = bal.time.speeds;
|
const speeds = bal.time.speeds;
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="cal__head"><span class="cal__weekday"></span></div>
|
<div class="cal__head"><span class="cal__weekday"></span></div>
|
||||||
<div class="cal__body">
|
<div class="cal__body">
|
||||||
|
<div class="cal__top">
|
||||||
|
${dialSvg("cal__dial")}
|
||||||
|
<div class="cal__date">
|
||||||
<div class="cal__day"></div>
|
<div class="cal__day"></div>
|
||||||
<div class="cal__month"></div>
|
<div class="cal__month"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="cal__count"></div>
|
<div class="cal__count"></div>
|
||||||
<div class="cal__term"><span></span></div>
|
<div class="cal__term"><span></span></div>
|
||||||
<dl class="cal__next">
|
<dl class="cal__next">
|
||||||
@@ -38,17 +43,27 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
|||||||
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
|
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dial = $(root, ".cal__dial");
|
||||||
|
let lastDay = -1;
|
||||||
|
|
||||||
function update(state) {
|
function update(state) {
|
||||||
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
|
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||||
$(root, ".cal__weekday").textContent = p.weekday;
|
$(root, ".cal__weekday").textContent = p.weekday;
|
||||||
$(root, ".cal__day").textContent = p.day;
|
const dayEl = $(root, ".cal__day");
|
||||||
|
dayEl.textContent = p.day;
|
||||||
|
if (lastDay >= 0 && state.day !== lastDay && state.speed > 0 && state.speed <= 2) {
|
||||||
|
dayEl.classList.remove("is-flip");
|
||||||
|
void dayEl.offsetWidth; // restart the animation
|
||||||
|
dayEl.classList.add("is-flip");
|
||||||
|
}
|
||||||
|
lastDay = state.day;
|
||||||
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
|
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
|
||||||
const shown = Math.min(state.day + 1, bal.time.termDays);
|
const shown = Math.min(state.day + 1, bal.time.termDays);
|
||||||
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
|
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
|
||||||
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
|
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
|
||||||
|
|
||||||
const over = Boolean(state.over);
|
const over = Boolean(state.over);
|
||||||
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
|
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(until(state.day, bal.time.daysPerWeek));
|
||||||
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
|
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
|
||||||
|
|
||||||
for (const b of root.querySelectorAll("button[data-speed]")) {
|
for (const b of root.querySelectorAll("button[data-speed]")) {
|
||||||
@@ -56,5 +71,5 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update };
|
return { update, clockHand: (progress) => setDial(dial, progress) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function createDashboard(root, scenario) {
|
|||||||
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
|
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
|
||||||
}]));
|
}]));
|
||||||
const sparkLen = {};
|
const sparkLen = {};
|
||||||
|
const shownValue = {};
|
||||||
|
|
||||||
function trend(series, eps, goodUp) {
|
function trend(series, eps, goodUp) {
|
||||||
if (!series || series.length < 2) return ["", ""];
|
if (!series || series.length < 2) return ["", ""];
|
||||||
@@ -82,7 +83,14 @@ export function createDashboard(root, scenario) {
|
|||||||
g.fill.style.width = `${v}%`;
|
g.fill.style.width = `${v}%`;
|
||||||
g.el.classList.toggle("is-low", v < 30);
|
g.el.classList.toggle("is-low", v < 30);
|
||||||
g.el.classList.toggle("is-high", v >= 70);
|
g.el.classList.toggle("is-high", v >= 70);
|
||||||
g.value.textContent = Math.round(v);
|
const r = Math.round(v);
|
||||||
|
g.value.textContent = r;
|
||||||
|
if (shownValue[id] !== undefined && shownValue[id] !== r) {
|
||||||
|
g.value.classList.remove("is-up", "is-down");
|
||||||
|
void g.value.offsetWidth; // restart the animation
|
||||||
|
g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down");
|
||||||
|
}
|
||||||
|
shownValue[id] = r;
|
||||||
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
|
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
|
||||||
|
|
||||||
const series = state.history.series[id] ?? [];
|
const series = state.history.series[id] ?? [];
|
||||||
|
|||||||
+48
-13
@@ -1,26 +1,34 @@
|
|||||||
// Centre folder: the weekly decision card as a paper document with two rubber
|
// Centre folder: the in-tray. The oldest waiting proposal is the document on top, answered with
|
||||||
// stamps. Also shows the idle and end-of-term messages.
|
// two rubber stamps; the others show as a pile of sheets under it. Also shows the idle and
|
||||||
|
// end-of-term messages.
|
||||||
|
|
||||||
import { t, tx, dateParts } from "../i18n/i18n.js";
|
import { t, tx, dateParts } from "../i18n/i18n.js";
|
||||||
import { dateOfDay } from "../engine/index.js";
|
import { dateOfDay } from "../engine/index.js";
|
||||||
import { $, esc } from "./desk.js";
|
import { $, esc } from "./desk.js";
|
||||||
|
|
||||||
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
|
const STAMP_MS = 600; // how long the stamp animation plays before the paper leaves the tray
|
||||||
|
const LEAVE_MS = 320; // and how long the paper takes to slide away
|
||||||
|
|
||||||
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
|
export function createDecision(root, { cardsById, characters, inboxMax = 10, onAnswer, onStamp = () => {}, onProfile, keysEnabled = () => true }) {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<h2 class="inbox__tab">${t("inbox.title")}</h2>
|
<h2 class="inbox__tab">${t("inbox.title")} <span class="inbox__count" data-count></span></h2>
|
||||||
|
<p class="inbox__full" role="status" hidden>${t("inbox.full")}</p>
|
||||||
<div class="inbox__body"></div>`;
|
<div class="inbox__body"></div>`;
|
||||||
const body = $(root, ".inbox__body");
|
const body = $(root, ".inbox__body");
|
||||||
|
const countEl = $(root, "[data-count]");
|
||||||
|
const fullEl = $(root, ".inbox__full");
|
||||||
|
|
||||||
let shown = "";
|
let shown = "";
|
||||||
let locked = false;
|
let locked = false;
|
||||||
let latest = null;
|
let latest = null;
|
||||||
|
let lastCount = 0;
|
||||||
|
let timers = [];
|
||||||
|
|
||||||
function choose(choice) {
|
function choose(choice) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const card = $(body, ".card");
|
const card = $(body, ".card");
|
||||||
if (!card) return;
|
const id = latest?.inbox[0]?.id;
|
||||||
|
if (!card || !id) return;
|
||||||
locked = true;
|
locked = true;
|
||||||
card.dataset.stamped = choice;
|
card.dataset.stamped = choice;
|
||||||
for (const b of card.querySelectorAll("button")) b.disabled = true;
|
for (const b of card.querySelectorAll("button")) b.disabled = true;
|
||||||
@@ -28,7 +36,9 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
|||||||
stamp.className = `card__stamp card__stamp--${choice}`;
|
stamp.className = `card__stamp card__stamp--${choice}`;
|
||||||
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
|
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
|
||||||
card.append(stamp);
|
card.append(stamp);
|
||||||
setTimeout(() => onAnswer(choice), STAMP_MS);
|
onStamp(choice);
|
||||||
|
timers.push(setTimeout(() => card.classList.add("card--leave"), STAMP_MS));
|
||||||
|
timers.push(setTimeout(() => onAnswer(choice, id), STAMP_MS + LEAVE_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
body.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
@@ -40,7 +50,7 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
|||||||
|
|
||||||
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
|
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (!keysEnabled() || !latest?.pendingCard) return;
|
if (!keysEnabled() || !latest?.inbox.length) return;
|
||||||
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
|
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
if (e.target.closest?.("input, textarea, select")) return;
|
if (e.target.closest?.("input, textarea, select")) return;
|
||||||
@@ -61,10 +71,10 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
|||||||
|
|
||||||
function cardHtml(card, state) {
|
function cardHtml(card, state) {
|
||||||
const from = characters[card.sender];
|
const from = characters[card.sender];
|
||||||
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
|
const date = dateParts(dateOfDay(state.startDate, state.inbox[0].day).iso);
|
||||||
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
|
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
|
||||||
return `
|
return `
|
||||||
<article class="card card--${esc(card.doc)}">
|
<article class="card card--enter card--${esc(card.doc)}" data-pile="${Math.min(3, state.inbox.length - 1)}">
|
||||||
<header class="card__head">
|
<header class="card__head">
|
||||||
<span class="card__kind">${t("kind." + card.doc)}</span>
|
<span class="card__kind">${t("kind." + card.doc)}</span>
|
||||||
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||||
@@ -80,15 +90,34 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
|||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tab counts the proposals waiting; the pile under the top paper grows with them.
|
||||||
|
function updateTray(state) {
|
||||||
|
const n = state.inbox.length;
|
||||||
|
countEl.textContent = n ? `${n}/${inboxMax}` : "";
|
||||||
|
countEl.classList.toggle("is-warn", n >= inboxMax - 2);
|
||||||
|
if (n > lastCount) {
|
||||||
|
countEl.classList.remove("is-bump");
|
||||||
|
void countEl.offsetWidth; // restart the animation
|
||||||
|
countEl.classList.add("is-bump");
|
||||||
|
}
|
||||||
|
lastCount = n;
|
||||||
|
fullEl.hidden = !(state.pending.inboxFull && !state.over);
|
||||||
|
const card = $(body, ".card");
|
||||||
|
if (card) card.dataset.pile = Math.min(3, Math.max(0, n - 1));
|
||||||
|
}
|
||||||
|
|
||||||
function update(state) {
|
function update(state) {
|
||||||
latest = state;
|
latest = state;
|
||||||
|
updateTray(state);
|
||||||
let kind = "idle";
|
let kind = "idle";
|
||||||
if (state.over) kind = "over";
|
if (state.over) kind = "over";
|
||||||
else if (state.pending.profile) kind = "profile";
|
else if (state.pending.profile) kind = "profile";
|
||||||
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
|
else if (state.inbox.length) kind = `card:${state.inbox[0].id}`;
|
||||||
if (kind === shown) return;
|
if (kind === shown) return;
|
||||||
shown = kind;
|
shown = kind;
|
||||||
locked = false;
|
locked = false;
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
timers = [];
|
||||||
body.classList.toggle("is-card", kind.startsWith("card:"));
|
body.classList.toggle("is-card", kind.startsWith("card:"));
|
||||||
|
|
||||||
if (kind === "profile") {
|
if (kind === "profile") {
|
||||||
@@ -105,9 +134,15 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
|||||||
} else if (kind === "over") {
|
} else if (kind === "over") {
|
||||||
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
|
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
|
body.innerHTML = cardHtml(cardsById.get(state.inbox[0].id), state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
|
return {
|
||||||
|
update,
|
||||||
|
destroy: () => {
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,22 @@ export const $ = (root, sel) => root.querySelector(sel);
|
|||||||
|
|
||||||
// Escape text for use inside innerHTML templates.
|
// Escape text for use inside innerHTML templates.
|
||||||
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||||
|
|
||||||
|
// A 12-hour dial whose single hand makes one full turn per in-game day. progress is 0..1.
|
||||||
|
export function dialSvg(className = "") {
|
||||||
|
const ticks = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const long = i % 3 === 0;
|
||||||
|
return `<line x1="50" y1="${long ? 8 : 10}" x2="50" y2="${long ? 18 : 15}" transform="rotate(${i * 30} 50 50)"/>`;
|
||||||
|
}).join("");
|
||||||
|
return `
|
||||||
|
<svg class="dial ${className}" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
|
||||||
|
<circle class="dial__face" cx="50" cy="50" r="46"/>
|
||||||
|
<g class="dial__ticks">${ticks}</g>
|
||||||
|
<line class="dial__hand" x1="50" y1="54" x2="50" y2="16" transform="rotate(0 50 50)"/>
|
||||||
|
<circle class="dial__pin" cx="50" cy="50" r="4"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDial(svg, progress) {
|
||||||
|
svg?.querySelector(".dial__hand")?.setAttribute("transform", `rotate(${(progress * 360).toFixed(1)} 50 50)`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// A tiny Markdown renderer for the rules. It supports exactly what the rules use: #, ##, ### headings,
|
||||||
|
// paragraphs, - and 1. lists (no nesting), **bold**, *italic*, `code`, pipe tables, --- and > quotes.
|
||||||
|
// Everything is escaped first, so a rules file can never inject markup.
|
||||||
|
|
||||||
|
import { esc } from "./desk.js";
|
||||||
|
|
||||||
|
function inline(text) {
|
||||||
|
return esc(text)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?![*\w])/g, "$1<em>$2</em>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
||||||
|
|
||||||
|
export function renderMarkdown(src) {
|
||||||
|
const lines = src.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim()) { i++; continue; }
|
||||||
|
|
||||||
|
const h = /^(#{1,3})\s+(.*)$/.exec(line);
|
||||||
|
if (h) {
|
||||||
|
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^-{3,}\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
|
||||||
|
|
||||||
|
if (/^\|.*\|\s*$/.test(line) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
|
||||||
|
const head = cells(line);
|
||||||
|
i += 2;
|
||||||
|
const rows = [];
|
||||||
|
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
|
||||||
|
out.push(
|
||||||
|
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
|
||||||
|
`<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^>\s?/.test(line)) {
|
||||||
|
const quote = [];
|
||||||
|
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ""));
|
||||||
|
out.push(`<blockquote>${quote.map(inline).join(" ")}</blockquote>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = /^(-|\d+\.)\s+/.exec(line);
|
||||||
|
if (list) {
|
||||||
|
const tag = list[1] === "-" ? "ul" : "ol";
|
||||||
|
const re = tag === "ul" ? /^-\s+(.*)$/ : /^\d+\.\s+(.*)$/;
|
||||||
|
const items = [];
|
||||||
|
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])[1]);
|
||||||
|
out.push(`<${tag}>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</${tag}>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const para = [];
|
||||||
|
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|-{3,}\s*$|>|\||(-|\d+\.)\s)/.test(lines[i])) para.push(lines[i++]);
|
||||||
|
out.push(`<p>${inline(para.join(" "))}</p>`);
|
||||||
|
}
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
+31
-1
@@ -7,6 +7,7 @@ import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
|
|||||||
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
||||||
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||||
import { $, esc } from "./desk.js";
|
import { $, esc } from "./desk.js";
|
||||||
|
import { renderMarkdown } from "./markdown.js";
|
||||||
|
|
||||||
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
let armed = null; // "action:arg" waiting for a second click
|
let armed = null; // "action:arg" waiting for a second click
|
||||||
let armTimer = 0;
|
let armTimer = 0;
|
||||||
let exportText = "";
|
let exportText = "";
|
||||||
|
const rulesCache = {}; // language -> rendered html, or "error"
|
||||||
|
|
||||||
const say = (text, kind = "ok") => { message = { text, kind }; };
|
const say = (text, kind = "ok") => { message = { text, kind }; };
|
||||||
const go = (next) => { screen = next; message = null; disarm(); render(); };
|
const go = (next) => { screen = next; message = null; disarm(); render(); };
|
||||||
@@ -85,6 +87,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
${button("load", t("menu.load"))}
|
${button("load", t("menu.load"))}
|
||||||
${button("export", t("menu.export"))}
|
${button("export", t("menu.export"))}
|
||||||
${button("import", t("menu.import"))}
|
${button("import", t("menu.import"))}
|
||||||
|
${button("rules", t("menu.rules"))}
|
||||||
${button("settings", t("menu.settings"))}
|
${button("settings", t("menu.settings"))}
|
||||||
${button("quit", t("menu.quit"))}
|
${button("quit", t("menu.quit"))}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -100,6 +103,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
${button("new", t("menu.new"))}
|
${button("new", t("menu.new"))}
|
||||||
${button("load", t("menu.load"))}
|
${button("load", t("menu.load"))}
|
||||||
${button("import", t("menu.import"))}
|
${button("import", t("menu.import"))}
|
||||||
|
${button("rules", t("menu.rules"))}
|
||||||
${button("settings", t("menu.settings"))}
|
${button("settings", t("menu.settings"))}
|
||||||
${button("credits", t("menu.credits"))}
|
${button("credits", t("menu.credits"))}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -213,6 +217,13 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
|
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
|
||||||
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
|
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="menu__field menu__check">
|
||||||
|
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
|
||||||
|
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="menu__field">${t("settings.volume")}
|
||||||
|
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
|
||||||
|
</label>
|
||||||
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||||
${back()}`;
|
${back()}`;
|
||||||
}
|
}
|
||||||
@@ -226,7 +237,23 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
${back()}`;
|
${back()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
|
// The rules are plain Markdown files (data/rules/rules.<lang>.md), fetched on first use.
|
||||||
|
function rules() {
|
||||||
|
const lang = getLang();
|
||||||
|
const body = rulesCache[lang];
|
||||||
|
if (body === undefined) {
|
||||||
|
rulesCache[lang] = null; // loading
|
||||||
|
fetch(`data/rules/rules.${lang}.md`)
|
||||||
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||||
|
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
|
||||||
|
.catch(() => { rulesCache[lang] = "error"; })
|
||||||
|
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
|
||||||
|
}
|
||||||
|
const html = body === "error" ? `<p class="menu__msg menu__msg--error">${t("rules.error")}</p>` : body ?? `<p class="menu__sub">${t("rules.loading")}</p>`;
|
||||||
|
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -296,6 +323,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
export: () => { exportText = encodeSave(getState()); go("export"); },
|
export: () => { exportText = encodeSave(getState()); go("export"); },
|
||||||
import: () => go("import"),
|
import: () => go("import"),
|
||||||
settings: () => go("settings"),
|
settings: () => go("settings"),
|
||||||
|
rules: () => go("rules"),
|
||||||
credits: () => go("credits"),
|
credits: () => go("credits"),
|
||||||
back: () => go("home"),
|
back: () => go("home"),
|
||||||
resume: () => handlers.onResume(),
|
resume: () => handlers.onResume(),
|
||||||
@@ -336,6 +364,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
|
|
||||||
root.addEventListener("change", async (e) => {
|
root.addEventListener("change", async (e) => {
|
||||||
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||||
|
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
|
||||||
|
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
|
||||||
if (e.target.id === "import-file" && e.target.files[0]) {
|
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||||
$(root, "#import-text").value = await e.target.files[0].text();
|
$(root, "#import-text").value = await e.target.files[0].text();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function createNewspaper(root, tpl) {
|
|||||||
if (key === shown) return;
|
if (key === shown) return;
|
||||||
shown = key;
|
shown = key;
|
||||||
|
|
||||||
|
root.classList.remove("is-fresh");
|
||||||
|
void root.offsetWidth; // restart the fade
|
||||||
|
root.classList.add("is-fresh");
|
||||||
const fill = (s) => s.replaceAll("{country}", state.countryName);
|
const fill = (s) => s.replaceAll("{country}", state.countryName);
|
||||||
const [lead, ...rest] = computeHeadlines(state, tpl);
|
const [lead, ...rest] = computeHeadlines(state, tpl);
|
||||||
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
|
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Phone layout only: the shelf along the top of the desk. It shows the date and a small dial,
|
||||||
|
// cycles the speed, and picks which single paper is on the desk (tray, briefing, news,
|
||||||
|
// calendar, and the ledger while a budget review is open).
|
||||||
|
|
||||||
|
import { t, dateParts } from "../i18n/i18n.js";
|
||||||
|
import { dateOfDay } from "../engine/index.js";
|
||||||
|
import { $, dialSvg, setDial } from "./desk.js";
|
||||||
|
|
||||||
|
const VIEWS = ["tray", "brief", "news", "clock"];
|
||||||
|
|
||||||
|
export function createShelf(root, bal, { getView, onView, onSpeed, onMenu }) {
|
||||||
|
const speeds = [0, ...bal.time.speeds];
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="shelf__time">
|
||||||
|
${dialSvg("shelf__dial")}
|
||||||
|
<div class="shelf__date"><b></b><span></span></div>
|
||||||
|
<button class="brass shelf__speed" data-speed-cycle></button>
|
||||||
|
</div>
|
||||||
|
<div class="shelf__tabs" role="group" aria-label="${t("shelf.nav")}">
|
||||||
|
${VIEWS.map((v) => `<button class="shelf__tab" data-view="${v}"><span>${t("shelf." + v)}</span>${v === "tray" ? `<i class="shelf__badge" data-badge></i>` : ""}</button>`).join("")}
|
||||||
|
<button class="shelf__tab shelf__tab--ledger" data-view="ledger" hidden><span>${t("shelf.ledger")}</span></button>
|
||||||
|
<button class="shelf__tab shelf__tab--menu" data-menu><span>${t("shelf.menu")}</span></button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const dial = $(root, ".shelf__dial");
|
||||||
|
let speed = 0;
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button");
|
||||||
|
if (!b) return;
|
||||||
|
if (b.dataset.menu !== undefined) onMenu();
|
||||||
|
else if (b.dataset.speedCycle !== undefined) onSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length]);
|
||||||
|
else if (b.dataset.view) onView(b.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(state) {
|
||||||
|
const d = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||||
|
$(root, ".shelf__date b").textContent = `${d.weekday} ${d.day} ${d.month}`;
|
||||||
|
$(root, ".shelf__date span").textContent = d.year;
|
||||||
|
speed = state.speed;
|
||||||
|
const sp = $(root, ".shelf__speed");
|
||||||
|
sp.textContent = speed === 0 ? "II" : `${speed}×`;
|
||||||
|
sp.setAttribute("aria-label", speed === 0 ? t("cal.pause") : t("cal.speed", { n: speed }));
|
||||||
|
|
||||||
|
const view = getView();
|
||||||
|
const ledger = Boolean(state.pending.review && !state.pending.profile && !state.over);
|
||||||
|
$(root, '[data-view="ledger"]').hidden = !ledger;
|
||||||
|
for (const b of root.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === view));
|
||||||
|
const badge = $(root, "[data-badge]");
|
||||||
|
badge.textContent = state.inbox.length || "";
|
||||||
|
badge.hidden = state.inbox.length === 0;
|
||||||
|
badge.classList.toggle("is-warn", state.inbox.length >= bal.cards.inboxMax - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, clockHand: (p) => setDial(dial, p) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user