Files

43 KiB
Raw Permalink Blame History

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. Since M8b the weekly proposals pile up in a tray and the game only pauses when 10 are waiting, at the quarterly budget review (and the emergency session), and on danger telegrams. The game keeps running in a background tab.
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/<category>.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 proposal is drawn and put in the in-tray; the game does not stop. You answer the one on top (stamp APPROVED or REJECTED). The game pauses when 10 proposals are waiting (not 10 weeks), until one is answered.
  • 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/<category>.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> (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 860 in total; the figures below add up to 860, not 920): 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 <button> elements, keyboard shortcuts (Space = pause, Y/N = stamp), and a minimum font size on the papers.

Save system

  • Where: localStorage keys cg.autosave, cg.slot.1 to cg.slot.3 and cg.settings. Each save holds { meta, state }; meta (country, day, scenario, saved-at, how it ended) lets the menu list saves without reading the whole state. Every access is wrapped in try/catch, with an in-memory fallback: the game keeps running if storage is missing, blocked or full, and the Settings screen then warns that saves will not survive the visit.
  • Autosave: written after every in-game day and every player action, and when the tab is hidden or closed, so a reload comes back to the same decision. A finished game (any ending) deletes the autosave, so "Continue" only appears for games still in progress. Games started with dev URL parameters never write the autosave.
  • Slots and menu: the folder-style menu has Continue, New game (country name, scenario if there is more than one, pronouns), Load game, Import, Settings (language, slow time) and Credits. While playing, the Menu button on the calendar (or Escape) opens a pause menu with Resume, Save game, Load game, Export, Import, Settings and Main menu. Overwriting a slot, deleting one and loading over a running game all need a second click ("Sure?").
  • Export string: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON), about 40,000 characters for a full term, so no compression. The CRC only detects corrupted or truncated strings; it is not protection against editing, as you chose. Whitespace is ignored so wrapped strings still import. The menu offers Copy (with a fallback if the clipboard is blocked), Download .sav, and Import by paste or file.
  • Loading is checked: codec.js verifies the format and checksum and migrates old versions (MIGRATIONS[n] turns version n into n+1; saves from a newer version are refused). Then engine/validate.js (restoreState) checks every number is finite and in range, every id is known (sectors, indicators, taxes, modifiers, history series), and repairs references to cards that no longer exist (an unknown pending card is dropped, unknown follow-ups removed). A damaged or tampered save is refused with a message; it never reaches the UI.
  • Settings: language (also switchable in-game from the pause menu) and slow time (1 day = 60 s instead of 10). They are stored separately from games.

Docker

  • Dockerfile: a multi-stage build. The Go build stage runs CGO_ENABLED=0 go build, and the web/ files are embedded into the binary. The final image is distroless/static, about 10 MB.
  • docker-compose.yml: the game service, with ports: ["8080:8080"], restart: unless-stopped and a healthcheck on /healthz.

Milestones (tick as we go)

  • M0 Skeleton: go.mod, server/main.go, Dockerfile, compose, empty desk page served; copy this plan to docs/PLAN.md. (Verified as a local binary; docker compose up --build still to be run once Docker Desktop is started.)
  • M1 Engine core: rng, state, time loop, sectors, indicators, economy/debt, modifiers; balance.json; web/dev/sim.html runs headless years per strategy; server/content_test.go validates balance.json and the scenario.
  • M2 Desk UI: wood and leather, papers, dashboard with sparklines, calendar and speed controls, newspaper. The centre folder is a stub (see decisions log) until M3/M4.
  • M3 Budget ledger: quarterly pause, sliders, tax levers, deficit and debt projection.
  • M4 Cards: draw engine, decision folder and stamps, follow-ups; first 60 cards (EN/FR) across all categories; content_test.go. 64 cards written (62 draw at random, 2 follow-up only), 14 categories, 20 senders.
  • M5 Crises and endings: unrest and army loyalty, crisis telegrams, the 4 game overs, the 5-year review with historian titles. Also the pronoun question at the start of a game.
  • M6 Saves and menu: auto-save, slots, export/import string and file, main menu, settings, language switch. Sound is not in the settings yet because the game has no sounds (M8, optional).
  • M7 Content production: grow the card pool to at least 800 cards in batches by category, plus 50 crisis cards and the follow-up chains. Done: 864 cards, 117 of them follow-up-only, about 100 chains. Tools: tools/add-cards.mjs, tools/card-stats.mjs, tools/cardfmt.mjs, guide in docs/CARD-WRITING.md.
  • M8 Balance: the difficulty is now measured and gated. Passive play is risky, a balanced plan wins, bold play pays up to a point and extremes end in a fall. Tools tools/balance-check.mjs (with --check), balance-search.mjs, bal-edit.mjs; guide in docs/BALANCE.md.
  • M8b Polish: animations, synthesised sounds, a dial clock that turns once per day, background play, a tray of up to 10 waiting proposals, a one-column phone layout with a shelf, the rules in EN and FR (also shown in the game), a README and a scenario authoring guide. A second scenario itself is not written: docs/SCENARIO-WRITING.md is the brief for agents to create them.

Verification

  • docker compose up --build → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
  • go test ./server passes (content validation).
  • web/dev/sim.html: 200 runs per strategy finish without exceptions, and the distribution of outcomes is printed. Check that debt > 90 % visibly increases unrest, and that unrest leads to a game over in the spendthrift strategy.
  • Manual: answer a card and watch the effects build up over the following days. Open the quarterly ledger on day 91. Export a string, change one character and check that the import is rejected; import an untouched string and check it gives the same state. Switch to French in the middle of a game.
  • Check in Chrome and Firefox; the layout must work at a 1280×720 minimum.

Open points (to settle as we go)

  • Starting scenario figures: a proposal is in web/data/scenarios/default.json ("Republic of Valdoria": GDP 480, debt 58 %, growth 2 %, unemployment 6.5 %, inflation 2.5 %, spending 34.3 % of GDP, revenue about 35.7 %). Waiting for your review.
  • Whether elections happen during the term (for example a mid-term vote in year 3 that can end the run). Not included yet.
  • Sound effects (typewriter, stamp, telegraph). Optional, in M8.

Decisions log

  • 2026-10-25: Go stdlib + vanilla JS; 10 s/day default with pause/speeds; plain base64 export; EN+FR.
  • 2026-10-25: go:embed can't reference ../web, so the embed lives in web/embed.go (package web, exported Files) and server/main.go imports countrygame/web. web/data, web/fonts and web/img hold a README.txt placeholder because embed rejects empty folders; delete them once real files exist.
  • 2026-10-25: The binary has a -healthcheck flag (the distroless image has no curl), used by the compose healthcheck.
  • 2026-10-25 (M1): The sim page moved to web/dev/ (see layout note). History is sampled weekly, not daily, to keep saves small. Modifiers come in two kinds, shift (moves the current value) and bias (moves the target); see Card effects. Tax-rate effects are in rate points, so the schema example changed from 0.6 to 3.
  • 2026-10-25 (M1 balance, from web/dev/sim.html, seed 1, 5 years): baseline stays flat. Spendthrift (all budgets x1.4) ends with debt 132 %, stability 44, unrest 49 but no game over yet. Austerity (x0.75) reaches debt 11 % with opinion 41 and unrest 35, which looks too safe. Police state: civil rights 23, order 72, soft power 33. Game-over thresholds (M5) and tuning (M8) still to do; the austerity result is the first thing to revisit.
  • 2026-10-25 (M2): The desk is three papers on the leather pad (dashboard, centre folder, calendar + newspaper). Layout checked at 1280×720 in Edge, English and French. A language button (EN/FR) sat on the calendar so the translations could be checked early; in M6 it moved into Settings.
  • 2026-10-25 (M2): The centre folder is a stub. It only shows the pending pause with an "Acknowledge" button; M3 puts the budget ledger there and M4 the decision cards. At the start of a game the first pause is the day-0 budget review.
  • 2026-10-25 (M2): The game starts a new run on every page load (autosave comes in M6) at speed 1×, and resumes at that speed after each acknowledged pause. Keys: Space = pause/resume, 1/2/3 = 1×/2×/4×. Special Elite is Apache-2.0 rather than OFL, so its licence file ships in web/fonts/ with the others.
  • 2026-10-25 (M2): The newspaper picks headlines from the 4-week movement of each indicator plus a few "level" rules (unrest, opinion, debt, stability, civil rights, army loyalty, inflation, deficit). It uses a hash of the week, never the game RNG. Dev URL params in main.js: ?lang=fr, ?seed=N, ?alloc=K (scale all budgets), ?skip=DAYS (fast-forward), ?dev (exposes window.__game).
  • 2026-10-25 (M3): The ledger is a two-page book that covers the centre and right columns while a review is pending; the dashboard stays visible on the left. The left page sets the 15 sector budgets, the right page the three tax rates, the Treasury summary and a four-quarter projection. Nothing is applied until "Sign and file"; "Reset" restores the values the review opened with. The weekly briefing comes after the review when both fall on the same day (day 91, 182, ...).
  • 2026-10-25 (M3): engine/projection.js (previewBudget) copies the game, applies the draft and runs the real daily tick 4 quarters ahead with noise switched off, so the projection uses the same rules as the game (about 7 ms per call). It assumes no new policies, so cards will move the real numbers. The ledger sliders stop at 2x a sector's need (limits.sliderMaxRatio); the engine still accepts up to 3x (limits.allocMaxRatio). A red tick on each slider marks the sector's need (budgets) or the revenue-maximising rate (taxes), and the ▲/▼ next to each budget compares the sector's quality now with the quality it is heading to at that budget.
  • 2026-10-25 (M3): UI tested through the browser's DevTools protocol (slider input, live preview, reset, sign, day-91 reopening); no repo script for it because it depends on a local Edge/Chrome. tools/engine-check.mjs now also covers previewBudget.
  • 2026-10-25 (M4): The centre folder now shows the weekly card as a paper document (memo, telegram, petition, report or letter, each with its own look), the sender's seal, and two stamp buttons showing the option text. Pressing one (or Y / N) plays a 0.75 s stamp animation and then applies the answer. The old "Acknowledge" stub is gone. Cards are drawn when the weekly pause starts, not when the folder is shown.
  • 2026-10-25 (M4): Bug fixed, present since M2: the first animation frame could carry a timestamp earlier than the clock's start, giving a negative elapsed time that pre-loaded a whole day, so every game began one day early. consumeClock now ignores negative steps (regression test in tools/engine-check.mjs).
  • 2026-10-25 (M4): 64 cards: economy 8, social 5, health 4, education 4, security 5, defense 5, foreign 5, industry 5, transport 4, housing 3, environment 4, justice 4, culture 3, crises 5 (bond panic at debt >= 100 %, outbreak, earthquake, general strike at unrest >= 50, coup plot at army loyalty <= 45; all repeatable with cooldowns). Three follow-up chains: wealth tax -> capital flight (45 days), crackdown -> rights report (30 days), energy privatisation -> energy strike (60 days). A random run answers about 62 cards, then the pool is empty and the remaining weeks pass with no card; M7 fixes that by growing the pool to 800+. Card bodies avoid pronouns for people and name ministries instead, so French needs no gender agreement.
  • 2026-10-25 (M4): server/cards_test.go validates every card file against the manifest, scenario sectors, characters.json and balance.json: unknown or misspelled fields, stats, sectors and flags, missing English or French text, dangling or unreachable follow-ups, flags used in conditions but never set, and a minimum of 60 cards. Checked by deliberately breaking cards. sim-core.js now draws and answers cards (random, always-yes and always-no strategies added). With cards, 5-year averages over 6 seeds: baseline debt 58 %, spendthrift 140 % with unrest 55, austerity 15 % with opinion 39. Dev URL option ?card=ID shows a given card first.
  • 2026-10-25 (M5): Pronoun question added at the start of every game (see the Pronouns paragraph above). It is a pending pause like the others (pending.profile), shown first in the folder; the budget ledger waits for it. Dev URL option ?pronouns=he|she|they skips it. The M6 new-game screen can take over the question; the engine side (setPronouns, state.pronouns) stays.
  • 2026-10-25 (M5): pending now has four flags (profile, weeklyCard, review, alert) and advanceDay refuses to run while any is true (isWaiting). The previews in the ledger clear all of them. Game over is checked every day after the economy moves, before the weekly and quarterly pauses.
  • 2026-10-25 (M5): Reachability, from web/dev/sim.html over 12 seeds: do-nothing, tax hikes, austerity, spendthrift and police-state runs all reach the end of the term. ruin (budgets x2, taxes cut) ends in default around day 1050, tyrant (police x3, army x2, justice and social x0.3) in impeachment around day 860, starve (budgets x0.25, taxes up) in revolution or coup around day 300. Army neglect alone survives. The thresholds are a first pass; tune them in M8 together with the rest of the balance (austerity is still too safe).
  • 2026-10-25 (M5): The "Play again" button reloads the page, since there is no menu yet; M6 replaces it. Dev tool: ?alloc=K&skip=DAYS fast-forwards a game at a budget multiple, which is the quickest way to see each end screen.
  • 2026-10-25 (M6): The game now opens on the main menu. The New game screen asks for the country name and the pronouns (He/Him, She/Her, They/Them; Il/Lui, Elle/Elle, Iel/Ielle), so the in-folder pronoun question from M5 only appears for games started with dev parameters. "Play again" on the end screen returns to the menu. Dev URL options still start a game at once; ?menu forces the menu even with them.
  • 2026-10-25 (M6): The language button left the calendar (replaced by a Menu button); language lives in Settings, in the main menu and the pause menu, and is remembered. With no saved choice the browser language decides (French if it starts with fr). Time does not run while the menu is open. Space, 1-3 and Escape work in game; the menu has its own Escape (back, or resume).
  • 2026-10-25 (M6): The decision panel's keyboard listener leaked every time the panels were rebuilt (language change since M4); it is now removed when the panels are rebuilt. On load, restoreState also keeps the alert pause flag consistent with the alert queue.
  • 2026-10-25 (M6): New tests. tools/engine-check.mjs covers the codec (round trip, whitespace, CRC reference value, UTF-8, every kind of damage, versions and migrations), 23 kinds of invalid or tampered saves, repair of stale references, a restored game continuing identically, and storage (slots, settings, missing, throwing and full storage). server/ui_content_test.go checks that en.json and fr.json have identical keys and placeholders and that the scenario index matches the files. The menu was exercised in a real browser: new game, continue after reload with the same waiting card, slot save and overwrite, export, import (valid, tampered, garbage, empty), settings, load and delete with confirmation, and the game-over cleanup.
  • 2026-10-25 (M7): Pool is 864 cards: economy 100, social 73, health 60, education 60, security 70, defense 60, foreign 70, industry 70, transport 60, housing 41, environment 50, justice 60, culture 40, crises 50. 117 cards are follow-up-only (119 links, about one chain per 7 cards, several branch on yes/no), 29 are repeatable (mostly crises), 353 have a stat or quality condition. Availability by year: start 286, year 1 202, year 2 189, year 3 127, year 4+ 60. The plan's old "about 920" was an addition error; the per-category targets add up to 860. Seven writers worked in parallel from docs/CARD-WRITING.md; defense and foreign cards only use invented countries (Torvania, Marenia, Belmora, Kessaly, Draveth). Coin-flip averages (yes/no averaged) are near zero for opinion, stability, economy and cost; unrest runs about +0.24 because "no" answers lean toward unrest, which is fine for now (M8). Seven duplicate English titles were retitled.
  • 2026-10-25 (M7): Pool check, 120 random-answer runs (20 seeds x 6 strategies): no empty week, a run sees 205 distinct cards on average (min 39 on the early-ending starve runs, max 261), draws are spread over categories and years, and the endings match M5 (baseline, spendthrift, austerity and police-state random play reach the end; ruin ends in default; starve in revolution or coup). 9 cards were never drawn because of narrow conditions (coup attempt and its two follow-ups, police strike, strike wave, regional fund loan, industry rescue, drought pipeline, guildhall roof); they are rare-situation cards and are kept. server/cards_test.go now requires at least 800 cards and a minimum per category.
  • 2026-10-25 (M8): Starting point, measured with the new harness: every strategy except ruin, starve and tyrant survived 100 % of runs, random play scored 58 and a card-reading bot only 61, so decisions barely mattered. Causes: the start state was an equilibrium with nothing pushing back, sector effects were weak, and debt cost only about 10 score points at 150 % of GDP.
  • 2026-10-25 (M8): Engine changes (all tunable in balance.json). Indicator drift (opinion fades, order and stability erode, unrest creeps up, scaled by driftScale 1.1); sector needs grow yearly (needGrowth); a world business cycle with shocks (world, state version 2 with a migration that adds world: {cycle: 0}), receipts follow growth (fiscal.growthSens); gain 3.0 multiplies how much sector quality moves indicators; excess and short terms reward quality above 70 to 75 and punish neglect; interest premium coefficient 0.0008 (was 0.0003); default at 165 % (was 200, warning at 135); the score leans 50 % on the last year (score.lastYearShare); grades S 73, A 64, B 56, C 47, D 38; revolution zone is unrest above 62 and opinion below 28; coup also triggers from army loyalty under 18 alone, and from loyalty under 35 with stability under 50; zone chances grow 0.004 per day (was 0.015 and 0.02), so a warning leaves about a month.
  • 2026-10-25 (M8): Emergency session. A new danger warning (revolution, coup, impeachment, default) opens the budget ledger at once, at most once per gameOver.emergencyCooldown (45) days, with a stamped banner. Passive play with a sensible reaction survives about 95 % of runs against 79 % without it, and extremes still fall. state.risk gained emergency and lastEmergency; restoreState repairs saves without them.
  • 2026-10-25 (M8): Cards react to the player's choices. New condition keys ratio:<sector> and tax:<id> (engine, Go validator, guide). 50 new crisis cards: 34 backlash (cri_bk_*: police state, army excess or neglect, high and low taxes, debt and rating, starved services, waste) and 16 windfall (cri_wf_*: sustained high sector quality). The pool is 914 cards. Windfalls fire about 1.1 times per run for the balanced plan and 2.6 for the 1.5x plan; backlash fires 2 to 3 times per run for police state, tax hike and spendthrift and almost never for the milder versions.
  • 2026-10-25 (M8): Result, 24 seeds each, human-like card answers: random 79 % survive (grade D); planner 100 % and score 61 (A in 17 % of runs); the same plan at 1.5x boldness 100 % with A in 29 %; at 2x 63 % survive; at 3x default every time; ruin, starve, deep cuts, tyrant 0 %; spendthrift 33 %; mild versions of each idea 75 to 100 %. The best budget the search found scores about 63 to 65. balance-check --check encodes these bands. Soft spots: S is rare, unrest runs slightly positive in the card pool, blanket austerity is fatal.
  • 2026-10-25 (M8): Newspaper headlines for a global slump or boom. The ledger hint says needs grow yearly. Dev parameter ?untilAlert stops a fast-forward at the first danger warning. Engine checks cover the world cycle, growing needs, the new condition keys, coup from neglect, the emergency session with its cooldown and the version 1 migration.
  • 2026-10-25 (M8b): The in-tray. state.pendingCard became state.inbox (oldest first, entries {id, day, category}) and pending.inboxFull was added (state version 3, migration from version 2 turns a pending card into a one-card tray). advanceDay still raises pending.weeklyCard on a Monday, but it is momentary: the caller answers it at once with ensureCard(), which queues the proposal and sets inboxFull when the tray holds cards.inboxMax (10). answerCard(state, bal, cards, choice, id?) answers the top one or a chosen one. A card in the tray is not drawn twice and counts for the "same category recently" penalty. The balance harness is unchanged: its bots still answer at once.
  • 2026-10-25 (M8b): Time and background play. The dial on the calendar and the small one on the phone shelf turn once per in-game day; the hand reads the clock accumulator, so it follows the speed. The accumulator is kept across pauses (it used to be reset). The loop is step(now), called by animation frames while visible and by a worker heartbeat (js/timer-worker.js, every 100 ms) while hidden, so the game runs in a background tab; a long gap (a frozen tab waking up) is caught up within time.catchUpMs (60 s). A hidden tab skips the drawing, still autosaves, and shows "● (n) The Desk" in the title when it is waiting for the player. A new danger alert, review, full tray or new proposal plays a sound.
  • 2026-10-25 (M8b): Sounds are synthesised with the Web Audio API (no files): stamp, paper, page, telegram, alert, chime, tick, click, fall, fanfare. Settings gained sound (default on) and volume (default 0.6). Motion lives in css/anim.css and is switched off by prefers-reduced-motion.
  • 2026-10-25 (M8b): Phone layout (css/mobile.css, up to 900 px wide): one paper at a time, picked with a shelf along the top (date and dial, a speed button that cycles pause, 1x, 2x, 4x, then Tray with a count badge, Briefing, News, Calendar, Menu, and a Ledger tab while a review is open). The ledger takes the screen when a review opens and returns to the tray after signing. A full tray switches to the tray view.
  • 2026-10-25 (M8b): Rules. web/data/rules/rules.en.md and rules.fr.md are plain Markdown, shown in the game (Menu, Rules) by a small renderer, js/ui/markdown.js, which escapes everything and supports headings, paragraphs, lists, bold, italic, code, tables, quotes and rules. go test checks that both files use only that subset and have the same structure. Scenario-only cards: optional card field scenarios (a whitelist of scenario ids, checked by the Go tests, honoured by drawCard). New tools: scenario-check.mjs and balance-check.mjs --scenario <id> with an optional <id>.targets.json.
  • 2026-10-25 (M8b): Browser checks (headless Edge, desktop and a 390x844 phone): the tray fills to 10 on day 63 and stops, answering one resumes the clock; with animation frames disabled and the page marked hidden, the worker alone advanced the game to day 63 and stopped at the full tray; the phone shows exactly one paper per tab with no horizontal overflow; the ledger, menu, rules and telegram fit; all 13 sounds play without errors.
  • 2026-10-25 (M8c, audio pass): Sounds were softened: slower attacks, longer fades, no energy below about 140 Hz (the stamp used to thump at 55 Hz), a master chain of high-pass, low-pass and a gentle limiter, and a short synthetic room reverb under everything so sounds melt away. Settings gained a mixer: one level per sound group (stamps, paper, page turns, signing, telegrams, danger warnings, full-tray chime, daily tick, countdown clock, buttons, end of term) plus the overall volume and the music, all stored in settings.mix (0 to 1) with a reset; releasing a slider plays that sound. The checkbox "Sounds" mutes the effects only; the music has its own on/off key.
  • 2026-10-25 (M8c): Countdown. On the day before a new proposal (the day that precedes each Monday, which is also the day before each budget review) a small wooden clock ticks while that day runs, 20 ticks per day, 10 at 4x, following the dial. The ordinary daily tick is skipped that day. Nothing ticks while the game waits, the menu is open or the tab is hidden.
  • 2026-10-25 (M8c): Music. The server lists MUSIC_DIR (default music, mounted from ./music by docker-compose) at /music/index.json (non-empty .mp3 files, sorted, never an error) and serves the files with byte ranges, with no directory listing, dot files or way out of the folder (Go tests). js/ui/music.js plays a shuffled bag with no immediate repeat, previous and next, skips unreadable files, and starts on the first click or key press if the music was left on (settings.musicOn; stop and pause turn it off, play and next turn it on). js/ui/walkman.js is the player on the desk: a silver cassette with two reels that turn while playing, the title on the label (it scrolls when long), a red LED and five keys. On the phone it sits under the calendar in the Calendar view. With no tracks it reads "No tape". music/placeholder.mp3 is an empty placeholder.