diff --git a/README.md b/README.md index 861e802..8970bcc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ -# Country-Game---The-Desk +# The Desk +A single-player browser game. You rule the fictional Republic of Valdoria for five in-game years from a wooden desk: a yes/no proposal lands in your tray every week, you set the budget and taxes every quarter, and the country answers. Debt, unrest and repression can end your term early. English and French. + +The rules are in [web/data/rules/rules.en.md](web/data/rules/rules.en.md) and [rules.fr.md](web/data/rules/rules.fr.md). They are plain Markdown, and the game shows the same files under **Menu → Rules**. + +## Run it + +You need Docker with Compose. + +```bash +docker compose up --build -d # build and start +# open http://localhost:8080 +docker compose down # stop +``` + +The container listens on port 8080 (change the left side of `ports` in `docker-compose.yml` to use another host port). It restarts unless stopped and has a health check (`/healthz`, probed by the binary itself because the image has no shell). + +Without Docker (Go 1.23+): + +```bash +go run ./server # PORT=3000 go run ./server to pick a port +``` + +There is nothing to configure: no database, no environment variables except the optional `PORT`. Saves live in the player's browser (`localStorage`), with three manual slots and export/import as text. + +## Stack + +- **Server:** Go, standard library only. It serves the files under `web/` (embedded in the binary) and `/healthz`. No game logic runs on the server. +- **Game:** vanilla JavaScript ES modules and CSS. No framework, no bundler, no npm, no runtime dependency. Charts are hand-drawn SVG, sounds are synthesised with the Web Audio API, fonts are bundled (OFL). +- **Image:** multi-stage build into `gcr.io/distroless/static` (a few MB). + +## Folders + +``` +server/ Go entry point (main.go) and the content tests (*_test.go) +web/ + embed.go go:embed of everything below + index.html the page + css/ desk, papers, ledger, decision, menu, endgame, anim (motion), mobile (phone layout) + fonts/ img/ bundled fonts and small images + js/ + main.js boot, lifecycle, the real-time loop (frames + a worker heartbeat for background play) + timer-worker.js heartbeat that keeps the game running in a hidden tab + engine/ the game rules: pure functions, no DOM, no network (state, economy, sectors, + indicators, cards, gameover, score, world, projection, validate...) + ui/ DOM panels: dashboard, decision (the tray), calendar (dial clock), ledger, newspaper, + telegram, endgame, menu, shelf (phone), audio, markdown + save/ save codec (CG1::) with migrations, and storage with fallbacks + i18n/ interface strings, en.json and fr.json + data/ + balance.json every tunable number + scenarios/ the country at the start (default.json, index.json) + cards/ the proposal pool, one file per category (900+ cards, EN and FR) + characters.json newspaper.json endgame.json rules/ + dev/ headless simulation page (sim.html) +tools/ Node scripts: tests, balance, card and scenario helpers (below) +docs/ PLAN.md (plan and decisions log), BALANCE.md, CARD-WRITING.md, SCENARIO-WRITING.md +``` + +The engine never touches the DOM and the UI never changes the state directly: it calls engine actions. That keeps the simulation testable headless. + +## Test and tune + +```bash +go test ./server # validates every card, scenario, balance section, rules file and string +node tools/engine-check.mjs # engine rules, saves, tray, clock, game overs +node tools/balance-check.mjs 24 --check # plays full terms with bots and checks the difficulty bands +node tools/card-stats.mjs # overview of the card pool +node tools/add-cards.mjs # merge a batch of new cards +node tools/scenario-check.mjs [id] # start-state report for a scenario +node tools/bal-edit.mjs path=value # edit balance.json keeping its layout +``` + +Guides for contributors (and agents): [docs/BALANCE.md](docs/BALANCE.md) for difficulty, [docs/CARD-WRITING.md](docs/CARD-WRITING.md) for new proposals, [docs/SCENARIO-WRITING.md](docs/SCENARIO-WRITING.md) for new countries. + +Handy URL parameters for development (they skip the menu and never touch the real autosave): `?dev` (exposes `window.__game`), `?seed=N`, `?lang=fr`, `?pronouns=she`, `?skip=DAYS`, `?alloc=K`, `?card=ID`, `?msPerDay=N`, `?menu`. + +## Licence + +Public domain, see [LICENSE](LICENSE). The fictional countries, people and events are invented. diff --git a/docs/PLAN.md b/docs/PLAN.md index 53f4f7d..377b2a5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -15,7 +15,7 @@ The project folder is empty, so everything below is new code. | 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. | +| 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. | @@ -140,7 +140,7 @@ A weighted score over the indicators averaged across the whole term (not just fi ## 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 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. @@ -207,7 +207,7 @@ A weighted score over the indicators averaged across the whole term (not just fi - [x] **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). - [x] **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`. - [x] **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 (optional)**: animations, sounds (typewriter, stamp, telegraph; off by default), a second scenario, a repo README. +- [x] **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. @@ -254,3 +254,9 @@ A weighted score over the indicators averaged across the whole term (not just fi - 2026-10-25 (M8): Cards react to the player's choices. New condition keys `ratio:` and `tax:` (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 ` with an optional `.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. diff --git a/docs/SCENARIO-WRITING.md b/docs/SCENARIO-WRITING.md index 169ca18..b0c8501 100644 --- a/docs/SCENARIO-WRITING.md +++ b/docs/SCENARIO-WRITING.md @@ -145,8 +145,8 @@ Most of the pool is shared. A scenario can add cards visible only in that scenar |---|---| | `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 ` (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 ` (new) | plays the scenario with the standard player types and compares them to the bands in `balance-check.mjs` (about 30 s) | +| `node tools/scenario-check.mjs ` | 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 ` | 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. @@ -190,7 +190,7 @@ near +0.5); run `scenario-check` on the real values. ## 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 `scenarios` card field, `scenario-check.mjs` and `--scenario` exist and were exercised on the default scenario only; a second scenario has not been played yet. - 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. diff --git a/server/rules_test.go b/server/rules_test.go new file mode 100644 index 0000000..dd8332a --- /dev/null +++ b/server/rules_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "regexp" + "strings" + "testing" + + "countrygame/web" +) + +// Markdown the in-game renderer (web/js/ui/markdown.js) does not support. +var unsupportedMarkdown = []*regexp.Regexp{ + regexp.MustCompile("(?m)^```"), // code blocks + regexp.MustCompile(`!\[`), // images + regexp.MustCompile(`\]\(`), // links + regexp.MustCompile(`]*>`), // HTML + regexp.MustCompile(`(?m)^[ \t]+[-*] `), // nested lists +} + +// rulesShape counts the building blocks of a rules file so the two languages can be compared. +func rulesShape(src string) map[string]int { + counts := map[string]int{} + inTable := false + for _, line := range strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") { + if strings.HasPrefix(line, "|") { + if !inTable { + counts["tables"]++ + } + inTable = true + continue + } + inTable = false + switch { + case strings.HasPrefix(line, "### "): + counts["h3"]++ + case strings.HasPrefix(line, "## "): + counts["h2"]++ + case strings.HasPrefix(line, "# "): + counts["h1"]++ + case strings.HasPrefix(line, "- "): + counts["bullets"]++ + case strings.HasPrefix(line, ">"): + counts["quotes"]++ + case strings.TrimSpace(line) == "---": + counts["rules"]++ + } + } + return counts +} + +// The rules are shown in the game by a tiny Markdown renderer, so the files may only use what it supports, +// and the English and French versions must have the same shape. +func TestRulesFiles(t *testing.T) { + files := map[string]string{} + for _, lang := range []string{"en", "fr"} { + raw, err := web.Files.ReadFile("data/rules/rules." + lang + ".md") + if err != nil { + t.Fatalf("rules.%s.md: %v", lang, err) + } + files[lang] = string(raw) + for _, bad := range unsupportedMarkdown { + if bad.MatchString(files[lang]) { + t.Errorf("rules.%s.md uses Markdown the in-game renderer does not support: %s", lang, bad) + } + } + } + + en, fr := rulesShape(files["en"]), rulesShape(files["fr"]) + if en["h1"] != 1 { + t.Errorf("the rules need exactly one # title, found %d", en["h1"]) + } + for _, k := range []string{"h1", "h2", "h3", "tables", "bullets", "quotes", "rules"} { + if en[k] != fr[k] { + t.Errorf("rules structure differs between languages: %s en=%d fr=%d", k, en[k], fr[k]) + } + } + if en["h2"] < 8 { + t.Errorf("the rules look too short: only %d sections", en["h2"]) + } +} diff --git a/tools/engine-check.mjs b/tools/engine-check.mjs index 796f214..634eda4 100644 --- a/tools/engine-check.mjs +++ b/tools/engine-check.mjs @@ -479,6 +479,23 @@ assert.equal(E.advanceDay(s, bal).blocked, "over"); assert.equal(up.state.pendingCard, undefined); } +// 13. scenario-only cards and the clock's catch-up cap +{ + const mk = (id, extra = {}) => ({ id, category: "economy", doc: "memo", sender: "min_finance", title: { en: id, fr: id }, body: { en: "b", fr: "b" }, yes: { label: { en: "y", fr: "o" } }, no: { label: { en: "n", fr: "n" } }, ...extra }); + const g = fresh(9); open(g); + assert.equal(E.drawCard(g, bal, [mk("elsewhere", { scenarios: ["other"] })]), null, "a card for another scenario is never drawn"); + assert.equal(E.drawCard(g, bal, [mk("here", { scenarios: ["default"] })])?.id, "here"); + assert.equal(E.drawCard(g, bal, [mk("everywhere")])?.id, "everywhere", "no scenarios field: every scenario"); + + const clock = E.createClock(); + assert.equal(E.consumeClock(clock, 5000, 10000, 1), 0, "by default one step is capped at a second"); + const wake = E.createClock(); + assert.equal(E.consumeClock(wake, 45000, 10000, 1, 60000), 4, "a frozen tab catches up within the given cap"); + const capped = E.createClock(); + assert.equal(E.consumeClock(capped, 900000, 10000, 1, 60000), 6, "and no further than the cap"); + assert.equal(E.consumeClock(E.createClock(), -50, 10000, 1, 60000), 0); +} + // 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 diff --git a/web/data/README.txt b/web/data/README.txt deleted file mode 100644 index fd476c0..0000000 --- a/web/data/README.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder so go:embed accepts this folder. Delete once real files exist.