# Country Game: "The Desk" — Plan > Shared plan for both of us. The first coding step copies this file to `docs/PLAN.md` in the repo. From then on, that copy is the one to follow: tick the checkboxes, edit the numbers, and add entries to the decisions log at the bottom. ## Context The goal is a single-player browser game in the style of Suzerain, delivered as a docker-compose service. You rule a fictional country for 5 in-game years. You set the sector budgets (in % of GDP) every quarter and answer a yes/no policy card every week. The country's indicators change a little every in-game day. Debt, unrest and repression feed into each other and can end the run early (revolution, coup or default). If you survive, the game ends on a 5-year review ("bilan"). Everything on screen is shown as paper documents on a wooden desk with a leather pad. The project folder is empty, so everything below is new code. ## Decisions taken | Topic | Decision | |---|---| | Backend | **Go, standard library only** (`net/http` + `embed`). It serves static files and has a `/healthz` endpoint. No game logic runs on the server. | | Frontend | **Vanilla JS ES modules + CSS, no bundler and no npm.** The only runtime dependency is zero: no framework, no chart library (charts are hand-drawn SVG). | | Fonts | Special Elite (typewriter text, **Apache-2.0**, not OFL), IM Fell English (headings, OFL) and Courier Prime (figures, OFL), stored with their licence files in `web/fonts/` so the game works offline. | | Time | **1 day = 10 s at 1× speed** (a full run is about 5 h). Speed controls: pause, 1×, 2×, 4×, plus a "slow" setting (60 s/day) in Settings. **The game pauses automatically** when the weekly card arrives, at the quarterly budget review, and on crisis telegrams. | | Saves | Auto-save to `localStorage` every in-game day. 3 manual save slots. Export/import as a **plain base64 string** (and as a downloadable `.sav` file). | | Language | **English and French.** Every text field is `{ "en": "...", "fr": "..." }`, and there's a language switch in the menu. | | Randomness | A seeded PRNG (mulberry32) whose state is stored in the save, so reloading a save doesn't re-roll the cards. | About "AppData": a web page can't write to AppData or the macOS/Linux equivalents directly. However, `localStorage` is already stored on disk inside the browser profile (which on Windows lives under AppData). The `.sav` file export covers the case of keeping a save file you can see. ## Repository layout ``` country_game/ ├─ docker-compose.yml # one service "game", port 8080 ├─ Dockerfile # golang:1.23-alpine build stage → gcr.io/distroless/static ├─ go.mod # module countrygame, no require block ├─ server/ │ ├─ main.go # embed ../web, FileServer, cache headers, /healthz, PORT env │ └─ content_test.go # `go test` validates every card/scenario JSON (schema, ids, en+fr present, refs exist) ├─ web/ │ ├─ index.html │ ├─ css/ desk.css papers.css ledger.css ui.css │ ├─ fonts/ img/ (small noise/stamp SVGs only) │ ├─ data/ │ │ ├─ balance.json # every tunable coefficient (no magic numbers in code) │ │ ├─ scenarios/default.json (+ index.json listing the scenarios) │ │ ├─ newspaper.json # headline templates (EN/FR), done in M2 │ │ ├─ endgame.json # game-over banners, urgent telegrams, historian titles and verdicts, done in M5 │ │ ├─ characters.json # ministers / senders (name, seal initials, role), done in M4 │ │ └─ cards/.json # card pools, one file per category, plus cards/index.json (the manifest) │ └─ js/ │ ├─ main.js # boot, load data, main loop (requestAnimationFrame + accumulator) │ ├─ engine/ # PURE: no DOM, no fetch, so it can run headless │ │ rng.js state.js time.js economy.js sectors.js indicators.js modifiers.js game.js index.js (done, M1) │ │ projection.js (done, M3) │ │ validate.js (done, M6) │ │ cards.js (done, M4) │ │ gameover.js score.js (done, M5) │ ├─ ui/ # DOM only, reads state, dispatches actions │ │ desk.js dashboard.js newspaper.js calendar.js charts.js inbox.js (stub) (done, M2) │ │ ledger.js (done, M3) │ │ decision.js (done, M4; replaces the inbox stub) │ │ telegram.js endgame.js (done, M5) │ │ menu.js (done, M6) │ ├─ save/ storage.js codec.js (done, M6) │ └─ i18n/ i18n.js en.json fr.json # UI strings only; card text lives in cards (done, M2) ├─ tools/ │ └─ engine-check.mjs # `node tools/engine-check.mjs`: engine regression checks (determinism, pause gating, effects) └─ docs/PLAN.md ``` `web/dev/sim.html` + `sim-core.js` (the balance runner) live under `web/` instead of `tools/`, because the page needs the Go server to load the engine as ES modules (`file://` blocks them). Open http://localhost:8080/dev/sim.html. Rule: `engine/` never touches the DOM. `ui/` never changes the state directly; it calls `engine` actions (`answerCard`, `setBudget`, `tick`). This keeps the balance tools and any later server-side validation possible. ## Simulation model ### State (one JSON object, this is also the save) `version, seed, rngState, day (0…1825), speed, lang, countryName, scenarioId, economy{}, sectors{}, indicators{}, modifiers[], flags{}, cardHistory[], pendingCard, followUps[], history{t[], series{}} (one sample per week), pending{weeklyCard, review}, over, ref{}, log[]` `pending` holds the pauses waiting on the player; `advanceDay` refuses to run while either is set, so a reload mid-decision comes back to the same decision. `ref` freezes the scenario's start values: every indicator target is `start value + weighted deviations from the start state`, so the starting country is an exact equilibrium and a hands-off run stays flat apart from noise. ### Economy (in % of GDP unless stated) - `gdp` (absolute value, in a fictional currency) and `growth` (annual %, applied daily). - **Revenue**: three tax levers, adjustable at the quarterly review: income tax, corporate tax and VAT. Each one turns into revenue in % of GDP, with diminishing returns, and pushes growth or opinion in the opposite direction. - **Spending** = the sum of sector allocations + debt interest. - `deficit = spending − revenue`, which is added to `debt` every day (1/365 per day). - **Interest rate** = base rate + a risk premium that rises with debt/GDP (convex curve). - Debt bands (thresholds tunable in `balance.json`): - < 60 %: no effect. - 60–90 %: small stability drain, interest starts to climb. - 90–150 %: industry quality decays, growth −, unrest +. - > 150 %: daily chance of a "bond market panic" event. - > 200 %: game over by sovereign default. - Also tracked: `unemployment` and `inflation` (simple functions of growth, industry and deficit). ### Sectors (budget % of GDP, set every quarter) Education, Health, Social welfare & pensions, Police, Army, Justice, Industry & energy, Public transport, Infrastructure, Housing, Research, Culture & media, Foreign affairs, Environment, Administration (15 sectors). Each sector has: - `alloc`: the % of GDP the player gave it. - `need`: the baseline % from the scenario. - `quality` (0–100), which moves toward a target every day: `target = f(alloc/need)`, a saturating curve, so overspending gives diminishing returns. The **time constant differs per sector**. Police reacts in about 2 weeks, Health in about 3 months, Education in about 1 year, Infrastructure in about 2 years. This is what makes the game strategic: you pay now and see the effect later. ### Indicators (0–100) Order, Stability, Civil Rights, Public Opinion (0 = angry, 100 = ecstatic), Economy, Hard Power, Soft Power. Two hidden or semi-visible gauges also exist: **Unrest** and **Army Loyalty**. Every day, each indicator moves toward `target = Σ(weight × sector quality) + economy terms + active modifiers`. It moves by a fraction of the gap, plus small noise. The weight matrix lives in `balance.json`. Some examples: - Police raises Order and lowers Civil Rights (strongly if over-funded). - Army raises Hard Power and Army Loyalty. - Culture and Foreign affairs raise Soft Power. - Social welfare, Health and Housing raise Opinion and Stability. - High unemployment or inflation lowers Opinion. **Unrest** goes up when Opinion and Stability are low, when unemployment is high and when debt is above 90 %. It goes down with Order, but heavy police use lowers Civil Rights and Soft Power and feeds future unrest. This is the debt → unrest → police → instability spiral you described. ### Card effects (seen after each day) An effect can be: - **instant**: `{stat, delta}` (stat is an indicator or `growth`/`unemployment`/`inflation`); - **spread over time**: `{stat, delta, days}`, applied as delta/days every day. The value then drifts back toward its target, so this is a temporary bump; - **bias**: `{stat, delta, bias: true, days?}` offsets the stat's *target* for `days` days (permanent if omitted). Use it for lasting policy effects; - **budget**: `{revenue: "income"|"corporate"|"vat", delta}` changes a tax rate in rate points; `{revenue: "other", delta}` and `{extraSpending: delta}` are permanent changes in % of GDP; `{oneOff: pct}` is a one-time cost in % of GDP (negative = windfall); - **sector quality**: `{sector, delta[, days][, bias]}`; - **flags** set or cleared; - **follow-up**: `{cardId, inDays}`. The daily tick applies every active modifier, so the consequences of a choice unfold day after day on the dashboard. ### Game over (checked daily, risk-based rather than hard cut-offs) - **Revolution**: Unrest > 70 and Opinion < 20 → a daily chance of 2 % × days spent in the zone (the days fade by 1 per day outside it). - **Military coup**: Army Loyalty < 35 and Stability < 35 → a daily chance of 1.5 % × days in the zone. The army only turns on a failing government: neglecting it alone is not enough. - **Sovereign default**: debt > 200 % of GDP, immediately. - **Impeachment / no-confidence**: Civil Rights < 10 and Opinion < 25 for 60 days in a row. - **Warnings**: entering any of these zones (or debt reaching 160 %) queues an urgent telegram that stops time until the player acknowledges it. Thresholds live in `balance.json` under `gameOver`; the first four were lowered from the original draft (85/15 and 20/30) because the model never got close to them. - The dashboard's "Hold on power" line shows Secure, Shaky (close to a zone) or Critical (inside one). Each game over has its own illustrated final document, for example a newspaper front page announcing "GOVERNMENT FALLS". ### End-of-term review (day 1826) A weighted score over the indicators averaged across the whole term (not just final values), plus bonuses or penalties for final debt, average growth and average unemployment (`balance.score`). Grades: S 75, A 66, B 58, C 50, D 40, F below. A game that ends early scores at most 35 and always gets an F. The historian's title is the first entry of `endgame.json` whose conditions match the term's averages (order of the list is the priority; the last entry has no conditions), or the one title tied to the way the player lost. The final screen is a newspaper front page with the grade, title and verdict, charts of opinion, stability, economy and debt over the term, and the five decisions with the greatest effect (`optionImpact` in `engine/score.js`). A hands-off term scores 57 (C, "The Caretaker"). **Pronouns.** The game opens by asking how the press should refer to the player: He/Him, She/Her, They/Them (Il/Lui, Elle/Elle, Iel/Ielle in French). It only changes text that refers to the player, which is the historian's verdict and title. Texts use tokens: `{sub} {Sub} {obj} {pos} {is} {has} {was}` in English, `{sub} {Sub} {tonic} {e}` in French (`{e}` is the agreement ending: nothing, e, ·e). French titles come in three forms (masculine, feminine, inclusive with a middle dot). Everywhere else (cards, newspaper, telegrams) text avoids pronouns for the player. `tp()` and `tt()` in `i18n.js` fill the tokens, and `server/endgame_test.go` rejects unknown tokens. ## Time and the quarterly cycle - The main loop uses `requestAnimationFrame` with a time accumulator. A new day starts when the accumulator reaches `msPerDay / speed`. - **Every Monday** a card is drawn and the game pauses. You can't resume until you answer it (stamp APPROVED or REJECTED). - **Every 13 weeks** the budget ledger opens and the game pauses. You can change the sector allocations and the three tax levers, with a live projection of the deficit and of debt over the next 4 quarters. The rest of the time the ledger is read-only. - Crisis events can arrive in the middle of a week as telegrams. They either need an answer or are information only. ## Card system - **How many**: 5 years × 52 weeks = 260 weekly draws. The goal is a pool of **at least 800 base cards**, plus follow-up and crisis cards, so that each run sees less than a third of the pool. - **Schema** (`web/data/cards/.json`): ```json { "id": "eco_tax_wealthy_01", "category": "economy", "doc": "memo", // memo | telegram | petition | report | letter "sender": "min_finance", "weight": 1.0, "once": true, "conditions": { "min": {"debt": 60}, "max": {}, "flags": [], "notFlags": [], "dayMin": 0, "dayMax": 1825 }, "title": { "en": "Tax the wealthy?", "fr": "Taxer les plus riches ?" }, "body": { "en": "...", "fr": "..." }, "yes": { "label": {"en":"Sign the decree","fr":"Signer le décret"}, "effects": [ {"revenue":"income","delta":3}, {"stat":"opinion","delta":4,"days":30}, {"stat":"economy","delta":-3,"days":90} ], "setFlags": ["wealth_tax"], "followUp": {"id":"eco_capital_flight_01","inDays":45} }, "no": { "label": {"en":"Shelve it","fr":"Classer le dossier"}, "effects": [ ... ] } } ``` - **How a card is drawn** (`engine/cards.js`): start from all cards whose conditions are met and that haven't been used. Weight each one by `weight` × a category boost (a sector below quality 40 makes its category up to 2× likelier) × a novelty factor (0.4 per card of the same category among the last 3). Follow-ups that are due take priority over random draws. With no eligible card the week is simply empty and the game is not blocked. - **Fields beyond the schema above**: `once` defaults to true; `once: false` makes a card repeatable with `cooldownDays` (default 180); `followUpOnly: true` keeps a card out of random draws so it only appears through a `followUp`. Condition stats are any indicator, `growth`/`unemployment`/`inflation`, `debt` and `deficit` (% of GDP) and `q:` (sector quality). Options can also `clearFlags`. Every pending card is saved in `state.pendingCard`, so a reload shows the same card. - **Effect size guide** used for the first 64 cards: instant or spread shifts of 1–6 points on indicators, lasting `bias` effects of 1–5 over 180–730 days, sector quality shifts of 2–6 over 60–365 days, one-off costs of 0.1–1.5 % of GDP (negative = windfall), `extraSpending` of 0.1–1.0 % of GDP, tax rate changes of 2–4 points. Every option has a cost; there is no free choice. - **Target count per category** (about 920 in total): economy 100, social 70, health 60, education 60, police/security 70, army/defense 60, foreign/diplomacy 70, industry/energy 70, transport/infrastructure 60, housing 40, environment 50, justice/civil rights 60, culture/media 40, crises 50. - **How the content gets written**: Sonnet writes it in batches of about 40 cards per category file, in English and French. After each batch, `go test ./server` checks the schema, that ids are unique, that both languages are present, that follow-up ids exist, and that effect stats are valid. ## Desk UI (Suzerain-like) - **Background**: a wood texture made in CSS (layered `repeating-linear-gradient` plus an inline SVG `feTurbulence` grain), with a darker vignette. - **Leather pad**: a rounded rectangle with a stitched border (`dashed` outline), its own grain and an inner shadow. - **Everything is a paper on the desk**, each with a slight random rotation, a drop shadow and a paper texture: - *Ministerial dashboard* (top left): a typed report with the 7 indicators as ink gauges and SVG sparklines drawn on graph paper. - *Calendar / clock* (top right): a desk calendar with the date and the speed buttons as brass toggles. - *Daily newspaper* (side): 1–3 generated headlines driven by state changes and events. - *Decision folder* (centre): the weekly card as a memo, telegram or petition with a sender seal. You answer with two rubber stamps (APPROVED / REJECTED) and a stamp animation. - *Budget ledger*: a green-ruled accounting book with one row per sector, a slider and an ink figure. It opens as a two-page spread. - *Telegrams*: crisis alerts that slide in on yellow strips. - **Main menu**: a closed folder labelled "TOP SECRET". It has New game (country name + scenario), Continue, Load slot, Import, Settings (language, slow mode, sound off) and Credits. - **Accessibility**: real `