diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8abdd59 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY server ./server +COPY web ./web +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/game ./server + +FROM gcr.io/distroless/static +COPY --from=build /out/game /game +EXPOSE 8080 +ENTRYPOINT ["/game"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..58c5898 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + game: + build: . + ports: + - "8080:8080" + restart: unless-stopped + healthcheck: + # distroless has no shell or curl, so the binary probes itself + test: ["CMD", "/game", "-healthcheck"] + interval: 30s + timeout: 3s + retries: 3 diff --git a/docs/CARD-WRITING.md b/docs/CARD-WRITING.md new file mode 100644 index 0000000..6ea9fd1 --- /dev/null +++ b/docs/CARD-WRITING.md @@ -0,0 +1,116 @@ +# Writing decision cards + +Guide for anyone (human or model) adding cards to `web/data/cards/`. Every card is one weekly yes/no decision +for the ruler of the fictional Republic of Valdoria. A run has 260 of them, in English and French. + +## Workflow + +1. Read the existing file for your category (`web/data/cards/.json`) to catch the voice and format, + and `web/data/characters.json` for the senders. +2. Write new cards in **batches of at most 25** as a JSON array in a scratch file outside the repo. +3. Merge the batch: `node tools/add-cards.mjs `. It rejects duplicate ids and wrong + categories, appends the cards, and reformats the file. +4. Validate: `go test ./server`. It checks every field, both languages, effects, conditions, senders, flags and + follow-up links, and says which card is wrong. +5. Review the balance: `node tools/card-stats.mjs `. +6. Edit the category file directly to fix whatever the tests or the stats show. + +Only edit your own category files. Never touch `balance.json`, the code or the tests while adding content. + +## The card + +```json +{ + "id": "eco_example_01", + "category": "economy", + "doc": "memo", + "sender": "min_finance", + "weight": 1, + "conditions": { "min": { "debt": 70 }, "max": {}, "flags": [], "notFlags": [], "dayMin": 90, "dayMax": 1500 }, + "title": { "en": "…", "fr": "…" }, + "body": { "en": "…", "fr": "…" }, + "yes": { "label": { "en": "…", "fr": "…" }, "effects": [ … ], "setFlags": [], "followUp": { "id": "eco_next_01", "inDays": 45 } }, + "no": { "label": { "en": "…", "fr": "…" }, "effects": [ … ] } +} +``` + +- `doc`: `memo`, `letter`, `petition`, `report` or `telegram`. Telegrams are for urgent news only. +- `sender`: an id from `characters.json`. Pick the one who would really write it. +- `once` is true by default. `once: false` plus `cooldownDays` makes a card repeatable (only for crises and + recurring situations). `followUpOnly: true` keeps a card out of random draws; it appears only through a `followUp`. +- `weight` defaults to 1. Use 0.5 for niche cards and 1.5 for central ones, sparingly. +- Ids: `__`, unique across all files. Prefixes: `eco soc hlt edu sec def for ind trn hou env jus cul cri`. + +### Effects (each option has a list) + +| Shape | Meaning | +|---|---| +| `{ "stat": "opinion", "delta": 4 }` | instant shift | +| `{ "stat": "opinion", "delta": 4, "days": 30 }` | spread over the days, then fades back | +| `{ "stat": "economy", "delta": -2, "bias": true, "days": 365 }` | moves the stat's target for that many days (omit `days` for permanent) | +| `{ "sector": "health", "delta": 5, "days": 120 }` | sector quality (also instant or `bias`) | +| `{ "oneOff": 0.5 }` | one-time cost in % of GDP (negative = windfall) | +| `{ "extraSpending": 0.3 }` | permanent extra spending in % of GDP (negative = savings) | +| `{ "revenue": "income", "delta": 3 }` | tax rate in points (`income`, `corporate`, `vat`) | +| `{ "revenue": "other", "delta": 0.3 }` | permanent other revenue in % of GDP | + +Stats: `order stability civilRights opinion economy hardPower softPower unrest armyLoyalty` (0 to 100), plus +`growth unemployment inflation` (percent). Sectors: `education health social police army justice industry +transport infrastructure housing research culture foreignAffairs environment administration`. + +**Size guide.** Instant or spread shifts 1 to 6 points. Lasting `bias` effects 1 to 5 points for 180 to 730 days. +Sector quality 2 to 6 over 60 to 365 days. `oneOff` 0.1 to 1.5. `extraSpending` 0.1 to 1.0. Tax rates 1 to 4 points. +`growth` bias 0.1 to 0.6, `unemployment` 0.1 to 0.5, `inflation` 0.2 to 1.5. Use 2 to 4 effects per option, and mix +instant, spread, bias, sector and budget effects. The effects must fit the story: repression raises order and +lowers civil rights. + +**Every option has a real cost and a real benefit.** Saying no is never a free "nothing happens": the status quo +has consequences too. + +### Conditions and scheduling + +Spread the cards over the whole term so each year brings new material: + +- about 30 % available from the start (no `dayMin`), 25 % from day 90 to 400, 25 % from day 400 to 900, + 20 % from day 900 to 1500; +- 30 to 40 % gated on the state of the country: `min`/`max` on any stat, on `debt` or `deficit` (% of GDP), or on + `q:` (sector quality, for "this sector is in trouble" cards); +- a few early-only cards with `dayMax`. + +Flags: `setFlags` on an option records the decision; `conditions.flags` / `notFlags` read it. Every flag a card +reads must be set by some card (the tests check). Use flags prefixed with your category. The flags that already +exist are `wealth_tax austerity airline_sold crackdown energy_privatised trade_pact emergency_austerity concessions`. + +### Follow-up chains + +About one chain per eight cards: an option has `"followUp": { "id": "...", "inDays": 30 }` pointing at a +`followUpOnly` card that continues the story. Branch the story: set a flag in one option and let the follow-up +(or a second follow-up card) differ depending on the flag. Chains are 2 to 3 cards long. + +## Voice + +- **Tone**: dry, specific, politically grounded, a little satirical, like a briefing that has seen everything. + Concrete places, numbers and institutions in Valdoria. Both sides of every dilemma have a respectable argument. +- **Fiction only**: no real countries, people, parties or brands. No slurs. No cartoon villains. +- **Length**: body of 2 to 3 sentences (English up to 300 characters, French up to 340): the situation first, then + the tension. Title up to 50 characters. Option labels up to 32 characters, led by a verb, clearly different from + each other (never "Yes" / "No", never two "Approve"). +- **No pronouns for people**: name ministries and organisations, not individuals, and never use a pronoun for the + player or anyone else. Address the player as "you" / "vous" sparingly, and in French never with an adjective + that agrees with the player. +- **Variety**: vary senders, document types and sentence shapes. Do not start every card with "The ministry + proposes". No two cards about the same dilemma. +- **French**: natural, idiomatic French rather than a translation word for word. Correct accents, « guillemets » + with spaces, a space before `? ! : ;`, typographic apostrophe `'` as in the existing cards. Telegram bodies are + UPPERCASE without accents and end with `STOP.` in both languages. Keep institution names consistent with the + existing cards ("ministère des Finances", "Banque centrale"). + +## Balance checks before you finish + +`node tools/card-stats.mjs ` prints, per answer and for a coin flip (50/50): + +- one-off cost and extra spending: the coin-flip rows should stay close to zero (about as many windfalls and savings + as costs), because a player who answers at random should not bankrupt or enrich the country; +- opinion, stability, economy: the coin-flip rows should stay within ±0.5 per card; +- the spread of availability and the share of gated cards, per the targets above; +- warnings for long bodies, duplicate titles and identical labels. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..b183981 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,247 @@ +# 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 ` +

+

Final state per strategy

+
+

Timeline

+
+ + + diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..abdce80 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +// Package web embeds the static frontend so the server ships as one binary. +// It lives next to the files because go:embed cannot reference parent directories. +package web + +import "embed" + +//go:embed index.html css js data dev fonts img +var Files embed.FS diff --git a/web/fonts/CourierPrime-Bold.ttf b/web/fonts/CourierPrime-Bold.ttf new file mode 100644 index 0000000..d27ce71 Binary files /dev/null and b/web/fonts/CourierPrime-Bold.ttf differ diff --git a/web/fonts/CourierPrime-Regular.ttf b/web/fonts/CourierPrime-Regular.ttf new file mode 100644 index 0000000..2a0f8bd Binary files /dev/null and b/web/fonts/CourierPrime-Regular.ttf differ diff --git a/web/fonts/IMFeENit28P.ttf b/web/fonts/IMFeENit28P.ttf new file mode 100644 index 0000000..d9afa7e Binary files /dev/null and b/web/fonts/IMFeENit28P.ttf differ diff --git a/web/fonts/IMFeENrm28P.ttf b/web/fonts/IMFeENrm28P.ttf new file mode 100644 index 0000000..275d754 Binary files /dev/null and b/web/fonts/IMFeENrm28P.ttf differ diff --git a/web/fonts/LICENSE-SpecialElite.txt b/web/fonts/LICENSE-SpecialElite.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/web/fonts/LICENSE-SpecialElite.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/web/fonts/OFL-CourierPrime.txt b/web/fonts/OFL-CourierPrime.txt new file mode 100644 index 0000000..7cdc333 --- /dev/null +++ b/web/fonts/OFL-CourierPrime.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Courier Prime Project Authors (https://github.com/quoteunquoteapps/CourierPrime). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/web/fonts/OFL-IMFellEnglish.txt b/web/fonts/OFL-IMFellEnglish.txt new file mode 100644 index 0000000..1bf2ab0 --- /dev/null +++ b/web/fonts/OFL-IMFellEnglish.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Igino Marini (mail@iginomarini.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/fonts/SpecialElite-Regular.ttf b/web/fonts/SpecialElite-Regular.ttf new file mode 100644 index 0000000..6654876 Binary files /dev/null and b/web/fonts/SpecialElite-Regular.ttf differ diff --git a/web/img/README.txt b/web/img/README.txt new file mode 100644 index 0000000..fd476c0 --- /dev/null +++ b/web/img/README.txt @@ -0,0 +1 @@ +Placeholder so go:embed accepts this folder. Delete once real files exist. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..020bed4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,32 @@ + + + + + + The Desk + + + + + + + + + +
+
+
+
+ + + + +
+
+
+
+
+
+ + + diff --git a/web/js/engine/cards.js b/web/js/engine/cards.js new file mode 100644 index 0000000..c40fa38 --- /dev/null +++ b/web/js/engine/cards.js @@ -0,0 +1,104 @@ +// Weekly decision cards: drawing, eligibility and applying the player's answer. +// +// Card: { id, category, doc, sender, weight?, once?, cooldownDays?, followUpOnly?, +// conditions?: { min:{stat:n}, max:{stat:n}, flags:[], notFlags:[], dayMin, dayMax }, +// title, body, yes: Option, no: Option } +// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } } +// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP), +// "deficit" (% of GDP) and "q:" (sector quality). + +import { IND_IDS, VAR_IDS, clamp } from "./state.js"; +import { computeFiscal } from "./economy.js"; +import { applyEffect } from "./modifiers.js"; +import { pickWeighted } from "./rng.js"; + +export const indexCards = (cards) => new Map(cards.map((c) => [c.id, c])); + +function statValue(state, key, fiscal) { + if (IND_IDS.includes(key)) return state.indicators[key]; + if (VAR_IDS.includes(key)) return state.economy[key]; + if (key === "debt") return fiscal.debtRatio; + if (key === "deficit") return fiscal.deficit; + if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN; + return NaN; // unknown key: never eligible (content tests catch typos) +} + +function meetsConditions(card, state, fiscal) { + const c = card.conditions; + if (!c) return true; + if (c.dayMin !== undefined && state.day < c.dayMin) return false; + if (c.dayMax !== undefined && state.day > c.dayMax) return false; + for (const [k, v] of Object.entries(c.min ?? {})) if (!(statValue(state, k, fiscal) >= v)) return false; + for (const [k, v] of Object.entries(c.max ?? {})) if (!(statValue(state, k, fiscal) <= v)) return false; + for (const f of c.flags ?? []) if (!state.flags[f]) return false; + for (const f of c.notFlags ?? []) if (state.flags[f]) return false; + return true; +} + +// A sector (or indicator) in trouble makes cards about it more likely. +function boostFor(card, state, bal) { + const b = bal.cards.boost[card.category]; + if (!b) return 1; + const v = b.sector ? state.sectors[b.sector].quality : state.indicators[b.indicator]; + const below = bal.cards.boostBelow; + return 1 + bal.cards.boostMax * clamp((below - v) / below, 0, 1); +} + +export function drawCard(state, bal, cards) { + const fiscal = computeFiscal(state, bal); + const lastAnswered = new Map(); + 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 pool = []; + const weights = []; + for (const card of cards) { + if (card.followUpOnly) continue; + const last = lastAnswered.get(card.id); + if (last !== undefined) { + if (card.once !== false) continue; + if (state.day - last < (card.cooldownDays ?? bal.cards.defaultCooldown)) continue; + } + if (!meetsConditions(card, state, fiscal)) continue; + const seen = recent.filter((c) => c === card.category).length; + pool.push(card); + weights.push((card.weight ?? 1) * boostFor(card, state, bal) * Math.pow(bal.cards.recentPenalty, seen)); + } + return pickWeighted(state, pool, weights); +} + +// Called once a weekly pause is pending: puts the card to answer in state.pendingCard. +// A due follow-up always comes first. With no eligible card the week is simply empty. +export function ensureCard(state, bal, cards) { + if (!state.pending.weeklyCard || state.pendingCard) return null; + const byId = indexCards(cards); + + let card = null; + const due = state.followUps.findIndex((f) => f.due <= state.day); + if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null; + card ??= drawCard(state, bal, cards); + + if (!card) { + state.pending.weeklyCard = false; + return null; + } + state.pendingCard = { id: card.id, day: state.day }; + return card; +} + +export function answerCard(state, bal, cards, choice) { + if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false }; + const card = indexCards(cards).get(state.pendingCard.id); + if (!card) return { ok: false }; + + const option = card[choice]; + for (const effect of option.effects ?? []) applyEffect(state, bal, effect); + for (const f of option.setFlags ?? []) state.flags[f] = state.day; + for (const f of option.clearFlags ?? []) delete state.flags[f]; + 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.pendingCard = null; + state.pending.weeklyCard = false; + return { ok: true, card }; +} diff --git a/web/js/engine/economy.js b/web/js/engine/economy.js new file mode 100644 index 0000000..34b0741 --- /dev/null +++ b/web/js/engine/economy.js @@ -0,0 +1,88 @@ +// Public finances: revenue, spending, interest, debt and GDP. All flows are in +// % of GDP per year; debt and gdp are absolute (billions of the scenario currency). + +import { TAX_IDS, clamp } from "./state.js"; + +// Laffer-style curve: revenue peaks at rate == peak, then falls. +export function taxRevenue(id, rate, bal) { + const t = bal.tax[id]; + return t.coef * rate * (1 - rate / (2 * t.peak)); +} + +export function interestRate(debtRatio, bal) { + const d = bal.debt; + const excess = Math.max(0, debtRatio - d.premiumFrom); + return d.baseRate + d.premiumCoef * excess * excess; +} + +export function computeFiscal(state, bal) { + const e = state.economy; + let revenue = e.otherRevenue; + for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal); + + const debtRatio = (e.debt / e.gdp) * 100; + const rate = interestRate(debtRatio, bal); + const interest = (debtRatio * rate) / 100; + + let allocTotal = 0; + for (const s of Object.values(state.sectors)) allocTotal += s.alloc; + const spending = allocTotal + interest + e.extraSpending; + + return { revenue, allocTotal, interest, spending, deficit: spending - revenue, debtRatio, rate }; +} + +// Derived quantities that indicator/variable targets are written against. +// ref values are captured at game creation so the start is an equilibrium. +export function computeDevs(state, bal, fiscal) { + const e = state.economy; + let taxPressure = 0; + let taxGrowthDrag = 0; + for (const id of TAX_IDS) { + const d = e.taxes[id] - state.ref.taxes[id]; + taxPressure += bal.tax[id].opinion * d; + taxGrowthDrag += bal.tax[id].growth * d; + } + const start = bal.crackdown.unrestStart; + return { + growth: e.growth, + unemployment: e.unemployment, + inflation: e.inflation, + debt: Math.max(0, fiscal.debtRatio - bal.debt.freeBelow), + deficit: fiscal.deficit, + taxPressure, + taxGrowthDrag, + // police sent in when unrest is high: 0..1 + crackdown: clamp((state.indicators.unrest - start) / (100 - start), 0, 1), + }; +} + +export function advanceEconomy(state, fiscal) { + const e = state.economy; + e.debt = Math.max(0, e.debt + ((fiscal.deficit / 100) * e.gdp) / 365); + e.gdp *= Math.pow(1 + e.growth / 100, 1 / 365); +} + +// Player action, only allowed while the quarterly review is open. +export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) { + if (!state.pending.review) return { ok: false, errors: ["budget_locked"] }; + + const errors = []; + for (const [id, v] of Object.entries(alloc)) { + const s = state.sectors[id]; + if (!s) errors.push(`unknown_sector:${id}`); + else if (!(v >= 0 && v <= s.need * bal.limits.allocMaxRatio)) errors.push(`alloc_out_of_range:${id}`); + } + for (const [id, v] of Object.entries(taxes)) { + if (!TAX_IDS.includes(id)) errors.push(`unknown_tax:${id}`); + else if (!(v >= 0 && v <= bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak)) errors.push(`tax_out_of_range:${id}`); + } + if (errors.length) return { ok: false, errors }; + + for (const [id, v] of Object.entries(alloc)) state.sectors[id].alloc = v; + for (const [id, v] of Object.entries(taxes)) state.economy.taxes[id] = v; + return { ok: true, errors: [] }; +} + +export function confirmBudget(state) { + state.pending.review = false; +} diff --git a/web/js/engine/game.js b/web/js/engine/game.js new file mode 100644 index 0000000..a42b9a4 --- /dev/null +++ b/web/js/engine/game.js @@ -0,0 +1,161 @@ +// Game creation and the daily tick. Pure: data (balance, scenario) is passed in. + +import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js"; +import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js"; +import { applyShifts, ageBias } from "./modifiers.js"; +import { stepSectors } from "./sectors.js"; +import { stepIndicators, stepVars } from "./indicators.js"; +import { checkGameOver } from "./gameover.js"; + +export const PRONOUNS = ["he", "she", "they"]; + +// pronouns: omit it and the game opens with the "who are you" question (pending.profile). +export function createGame({ scenario, balance, seed = Date.now(), countryName, lang = "en", pronouns }) { + const se = scenario.economy; + const gdp = se.gdp; + + const sectors = {}; + for (const [id, s] of Object.entries(scenario.sectors)) { + sectors[id] = { need: s.need, alloc: s.alloc, quality: qualityFromRatio(s.alloc / s.need, balance) }; + } + + const state = { + version: STATE_VERSION, + seed: seed >>> 0, + rngState: seed >>> 0, + day: 0, + speed: 1, // 0 = paused, otherwise one of balance.time.speeds + startDate: scenario.startDate, + lang, + pronouns: PRONOUNS.includes(pronouns) ? pronouns : "they", + countryName: countryName ?? scenario.name[lang] ?? scenario.name.en, + scenarioId: scenario.id, + economy: { + gdp, + debt: (gdp * se.debtRatio) / 100, + growth: se.growth, + unemployment: se.unemployment, + inflation: se.inflation, + taxes: { ...se.taxes }, + otherRevenue: se.otherRevenue, + extraSpending: 0, + }, + sectors, + indicators: { ...scenario.indicators }, + modifiers: [], + flags: {}, + cardHistory: [], + pendingCard: null, + followUps: [], + // Day 0 opens with the pronoun question, the initial budget review and a first card. + // Any true flag here pauses time (see advanceDay). + pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false }, + over: null, + risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone + alerts: [], // urgent telegrams waiting to be read, oldest first + history: { t: [], series: {} }, + log: [], + ref: null, + }; + + state.ref = { + taxes: { ...se.taxes }, + ind: { ...scenario.indicators }, + vars: { growth: se.growth, unemployment: se.unemployment, inflation: se.inflation }, + sectors: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.quality])), + ratio: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.alloc / s.need])), + dev: null, + }; + state.ref.dev = computeDevs(state, balance, computeFiscal(state, balance)); + + recordHistory(state, balance); + return state; +} + +function recordHistory(state, bal) { + const f = computeFiscal(state, bal); + const h = state.history; + h.t.push(state.day); + const sample = { debtRatio: f.debtRatio, deficit: f.deficit }; + for (const id of IND_IDS) sample[id] = state.indicators[id]; + for (const id of VAR_IDS) sample[id] = state.economy[id]; + for (const [k, v] of Object.entries(sample)) (h.series[k] ??= []).push(round(v)); +} + +const round = (v) => Math.round(v * 100) / 100; + +export function setSpeed(state, bal, speed) { + if (speed !== 0 && !bal.time.speeds.includes(speed)) return false; + state.speed = speed; + return true; +} + +// Clears a pause condition once the player has dealt with it. +export function clearPending(state, kind) { + state.pending[kind] = false; +} + +export function setPronouns(state, pronouns) { + if (!PRONOUNS.includes(pronouns)) return false; + state.pronouns = pronouns; + state.pending.profile = false; + return true; +} + +// True while the game is waiting on the player (profile, budget review, weekly card, alert). +export const isWaiting = (state) => Object.values(state.pending).some(Boolean); + +// Advances one in-game day unless the game is over or waiting on the player. +export function advanceDay(state, bal) { + if (state.over) return { advanced: false, blocked: "over", events: null }; + if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null }; + + applyShifts(state, bal); + const fiscal = computeFiscal(state, bal); + const devs = computeDevs(state, bal, fiscal); + + advanceEconomy(state, fiscal); + stepVars(state, bal, devs); + stepIndicators(state, bal, devs); + stepSectors(state, bal, fiscal); + ageBias(state); + + state.day += 1; + + const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null }; + const fallen = checkGameOver(state, bal); + if (fallen) { + state.over = { type: fallen, day: state.day }; + events.gameOver = fallen; + recordHistory(state, bal); + } else if (state.day >= bal.time.termDays) { + state.over = { type: "term", day: state.day }; + events.termEnd = true; + recordHistory(state, bal); + } else { + if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal); + if (state.day % bal.time.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true; + if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true; + } + return { advanced: true, blocked: null, events }; +} + +// Read-only snapshot for the UI and the sim. +export function summarize(state, bal) { + const f = computeFiscal(state, bal); + return { + day: state.day, + gdp: state.economy.gdp, + debt: state.economy.debt, + debtRatio: f.debtRatio, + interestRate: f.rate, + revenue: f.revenue, + spending: f.spending, + interest: f.interest, + deficit: f.deficit, + growth: state.economy.growth, + unemployment: state.economy.unemployment, + inflation: state.economy.inflation, + indicators: { ...state.indicators }, + }; +} diff --git a/web/js/engine/gameover.js b/web/js/engine/gameover.js new file mode 100644 index 0000000..3d61869 --- /dev/null +++ b/web/js/engine/gameover.js @@ -0,0 +1,63 @@ +// Ways the term can end early. Checked once per day after the economy has moved. +// +// default debt above the limit: immediate +// impeachment civil rights and opinion both very low for N days in a row: certain +// coup army loyalty and stability both very low: a daily chance that grows each day in the zone +// revolution unrest very high and opinion very low: same growing daily chance +// +// Entering a danger zone queues an urgent telegram (state.alerts) and pauses the game +// until the player acknowledges it. state.risk counts the days spent in each zone. + +import { nextFloat } from "./rng.js"; + +function alert(state, kind) { + state.alerts.push({ kind, day: state.day }); + state.pending.alert = true; +} + +// Returns the game-over type or null. Mutates state.risk, state.alerts and the RNG. +export function checkGameOver(state, bal) { + const g = bal.gameOver; + const ind = state.indicators; + const risk = state.risk; + const debtRatio = (state.economy.debt / state.economy.gdp) * 100; + + // Zone bookkeeping: count consecutive days inside, fade (or reset) outside. + const track = (name, inZone, fade) => { + if (inZone) { + risk[name] += 1; + if (risk[name] === 1) alert(state, name); + } else { + risk[name] = fade === "reset" ? 0 : Math.max(0, risk[name] - fade); + } + }; + const chance = (name, perDay) => risk[name] > 0 && nextFloat(state) < Math.min(1, perDay * risk[name]); + + if (debtRatio >= g.default.warnAbove) { + if (!risk.defaultZone) { + risk.defaultZone = true; + alert(state, "default"); + } + } else if (debtRatio < g.default.warnAbove - 10) { + risk.defaultZone = false; + } + + const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow; + const inCoup = ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow; + const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow; + track("impeachment", inImpeachment, "reset"); + track("coup", inCoup, 1); + track("revolution", inRevolution, 1); + + if (debtRatio > g.default.debtAbove) return "default"; + if (risk.impeachment >= g.impeachment.days) return "impeachment"; + if (inCoup && chance("coup", g.coup.chancePerDay)) return "coup"; + if (inRevolution && chance("revolution", g.revolution.chancePerDay)) return "revolution"; + return null; +} + +// The player has read the telegram at the front of the queue. +export function dismissAlert(state) { + state.alerts.shift(); + state.pending.alert = state.alerts.length > 0; +} diff --git a/web/js/engine/index.js b/web/js/engine/index.js new file mode 100644 index 0000000..6d74450 --- /dev/null +++ b/web/js/engine/index.js @@ -0,0 +1,14 @@ +// Public engine API. The UI and tools import from here only. + +export { createGame, advanceDay, clearPending, setSpeed, setPronouns, isWaiting, summarize, PRONOUNS } from "./game.js"; +export { dismissAlert } from "./gameover.js"; +export { buildReview, optionImpact, METRIC_IDS } from "./score.js"; +export { restoreState } from "./validate.js"; +export { setBudget, confirmBudget, computeFiscal, taxRevenue } from "./economy.js"; +export { previewBudget } from "./projection.js"; +export { qualityFromRatio } from "./state.js"; +export { applyEffect } from "./modifiers.js"; +export { ensureCard, answerCard, drawCard, indexCards } from "./cards.js"; +export { createClock, consumeClock, dateOfDay, weekOfDay, quarterOfDay } from "./time.js"; +export { nextFloat, nextInt, pickWeighted } from "./rng.js"; +export { IND_IDS, VAR_IDS, TAX_IDS } from "./state.js"; diff --git a/web/js/engine/indicators.js b/web/js/engine/indicators.js new file mode 100644 index 0000000..909f93e --- /dev/null +++ b/web/js/engine/indicators.js @@ -0,0 +1,47 @@ +// Indicators and economy variables move toward a target each day: +// target = start value + sum of weighted deviations from the start state + active biases +// Because every term is a deviation from the scenario start, the start is an equilibrium. + +import { IND_IDS, VAR_IDS, clamp } from "./state.js"; +import { biasFor } from "./modifiers.js"; +import { nextNoise } from "./rng.js"; + +function computeTarget(def, base, group, key, state, devs) { + let t = base; + for (const [id, w] of Object.entries(def.sectors ?? {})) { + t += w * (state.sectors[id].quality - state.ref.sectors[id]); + } + for (const [id, w] of Object.entries(def.alloc ?? {})) { + const s = state.sectors[id]; + t += w * (s.alloc / s.need - state.ref.ratio[id]); + } + for (const [k, w] of Object.entries(def.econ ?? {})) { + t += w * (devs[k] - state.ref.dev[k]); + } + for (const [k, w] of Object.entries(def.ind ?? {})) { + t += w * (state.indicators[k] - state.ref.ind[k]); + } + return t + biasFor(state, group, key); +} + +export function stepVars(state, bal, devs) { + const next = {}; + for (const id of VAR_IDS) { + const def = bal.vars[id]; + const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs); + const v = state.economy[id]; + next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max); + } + Object.assign(state.economy, next); +} + +export function stepIndicators(state, bal, devs) { + const next = {}; + for (const id of IND_IDS) { + const def = bal.indicators[id]; + const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs); + const v = state.indicators[id]; + next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100); + } + Object.assign(state.indicators, next); +} diff --git a/web/js/engine/modifiers.js b/web/js/engine/modifiers.js new file mode 100644 index 0000000..46ef48b --- /dev/null +++ b/web/js/engine/modifiers.js @@ -0,0 +1,84 @@ +// Effects and timed modifiers. Card choices end up here. +// +// Effect shapes: +// { stat, delta } instant shift of an indicator or economy variable +// { stat, delta, days } shift spread evenly over `days` (then the value mean-reverts) +// { stat, delta, bias: true, days? } offset on the stat's *target* for `days` (permanent if omitted) +// { sector, delta[, days][, bias] } same, on a sector's quality +// { revenue: "income"|"corporate"|"vat", delta } change tax rate (rate points), permanent +// { revenue: "other", delta } change other revenue (% of GDP), permanent +// { oneOff: pct } one-time cost in % of GDP (negative = windfall) +// { extraSpending: delta } permanent extra spending in % of GDP + +import { IND_IDS, VAR_IDS, TAX_IDS, clamp } from "./state.js"; + +function groupOf(effect, state) { + if (effect.sector !== undefined) return state.sectors[effect.sector] ? ["sector", effect.sector] : null; + if (IND_IDS.includes(effect.stat)) return ["ind", effect.stat]; + if (VAR_IDS.includes(effect.stat)) return ["var", effect.stat]; + return null; +} + +function shift(state, bal, group, key, delta) { + if (group === "ind") state.indicators[key] = clamp(state.indicators[key] + delta, 0, 100); + else if (group === "sector") state.sectors[key].quality = clamp(state.sectors[key].quality + delta, 0, 100); + else { + const v = bal.vars[key]; + state.economy[key] = clamp(state.economy[key] + delta, v.min, v.max); + } +} + +export function applyEffect(state, bal, effect) { + if (effect.stat !== undefined || effect.sector !== undefined) { + const found = groupOf(effect, state); + if (!found) return false; + const [group, key] = found; + if (effect.bias) { + state.modifiers.push({ type: "bias", group, key, value: effect.delta, remaining: effect.days ?? null }); + } else if (effect.days > 0) { + state.modifiers.push({ type: "shift", group, key, perDay: effect.delta / effect.days, remaining: effect.days }); + } else { + shift(state, bal, group, key, effect.delta); + } + return true; + } + const e = state.economy; + if (effect.revenue !== undefined) { + if (TAX_IDS.includes(effect.revenue)) e.taxes[effect.revenue] = Math.max(0, e.taxes[effect.revenue] + effect.delta); + else if (effect.revenue === "other") e.otherRevenue = Math.max(0, e.otherRevenue + effect.delta); + else return false; + return true; + } + if (effect.oneOff !== undefined) { + e.debt = Math.max(0, e.debt + (effect.oneOff / 100) * e.gdp); + return true; + } + if (effect.extraSpending !== undefined) { + e.extraSpending += effect.extraSpending; + return true; + } + return false; +} + +// Start of each day: apply the spread-out shifts. +export function applyShifts(state, bal) { + for (const m of state.modifiers) { + if (m.type !== "shift") continue; + shift(state, bal, m.group, m.key, m.perDay); + m.remaining -= 1; + } + state.modifiers = state.modifiers.filter((m) => m.type !== "shift" || m.remaining > 0); +} + +// Sum of active target offsets for one stat. +export function biasFor(state, group, key) { + let sum = 0; + for (const m of state.modifiers) if (m.type === "bias" && m.group === group && m.key === key) sum += m.value; + return sum; +} + +// End of each day: age timed biases. +export function ageBias(state) { + for (const m of state.modifiers) if (m.type === "bias" && m.remaining !== null) m.remaining -= 1; + state.modifiers = state.modifiers.filter((m) => m.type !== "bias" || m.remaining === null || m.remaining > 0); +} diff --git a/web/js/engine/projection.js b/web/js/engine/projection.js new file mode 100644 index 0000000..59178b0 --- /dev/null +++ b/web/js/engine/projection.js @@ -0,0 +1,44 @@ +// Budget preview for the quarterly ledger: applies a draft budget to a copy of +// the game and runs the real daily tick forward, without noise, so the player +// sees where debt, deficit and the main indicators are heading. +// It assumes no new policies (future cards are unknown). + +import { advanceDay, summarize } from "./game.js"; +import { computeFiscal, setBudget } from "./economy.js"; + +const quietCache = new WeakMap(); + +// Same balance with all daily noise switched off. +function quietBalance(bal) { + let q = quietCache.get(bal); + if (!q) { + q = structuredClone(bal); + q.noise.indicator = 0; + for (const def of Object.values(q.vars)) def.noise = 0; + for (const def of Object.values(q.indicators)) if ("noise" in def) def.noise = 0; + quietCache.set(bal, q); + } + return q; +} + +// Returns { ok, errors, fiscal, points }. points[0] is "now with the draft applied"; +// points[i] is the state i quarters later (fewer if the term ends first). +export function previewBudget(state, bal, draft, { quarters = 4 } = {}) { + const sim = structuredClone(state); + sim.pending.review = true; // the preview may edit the budget even outside a review + const res = setBudget(sim, bal, draft); + if (!res.ok) return { ok: false, errors: res.errors, fiscal: null, points: [] }; + + const quiet = quietBalance(bal); + const fiscal = computeFiscal(sim, quiet); + const points = [summarize(sim, quiet)]; + + for (let q = 0; q < quarters && !sim.over; q++) { + for (let d = 0; d < bal.time.daysPerQuarter && !sim.over; d++) { + for (const k of Object.keys(sim.pending)) sim.pending[k] = false; + advanceDay(sim, quiet); + } + points.push(summarize(sim, quiet)); + } + return { ok: true, errors: [], fiscal, points }; +} diff --git a/web/js/engine/rng.js b/web/js/engine/rng.js new file mode 100644 index 0000000..4ab1f97 --- /dev/null +++ b/web/js/engine/rng.js @@ -0,0 +1,31 @@ +// mulberry32 PRNG. The generator state lives in state.rngState (a uint32) so a +// saved game resumes with the exact same sequence. + +export function nextFloat(state) { + state.rngState = (state.rngState + 0x6d2b79f5) >>> 0; + let t = state.rngState; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +// Uniform in [-amp, amp]. +export function nextNoise(state, amp) { + return (nextFloat(state) * 2 - 1) * amp; +} + +export function nextInt(state, maxExclusive) { + return Math.floor(nextFloat(state) * maxExclusive); +} + +export function pickWeighted(state, items, weights) { + let total = 0; + for (const w of weights) total += w; + if (total <= 0) return null; + let r = nextFloat(state) * total; + for (let i = 0; i < items.length; i++) { + r -= weights[i]; + if (r < 0) return items[i]; + } + return items[items.length - 1]; +} diff --git a/web/js/engine/score.js b/web/js/engine/score.js new file mode 100644 index 0000000..c710665 --- /dev/null +++ b/web/js/engine/score.js @@ -0,0 +1,93 @@ +// End-of-term review: a score from the whole term (not just the last day), a letter +// grade, the historian's title and the decisions that mattered most. +// Pure: works for a finished term and for a game that ended early. + +import { clamp } from "./state.js"; +import { indexCards } from "./cards.js"; + +// Names usable in the `min` / `max` conditions of endgame.json titles. +export const METRIC_IDS = [ + "order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", + "growth", "unemployment", "inflation", "debt", "score", "years", +]; + +const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0); + +// Averages over the whole term, plus the final debt ratio. +function termAverages(state) { + const s = state.history.series; + const m = {}; + for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) { + m[id] = mean(s[id] ?? []); + } + m.debt = s.debtRatio?.at(-1) ?? 0; + m.years = state.day / 365; + return m; +} + +function computeScore(m, bal) { + const sc = bal.score; + let base = 0; + for (const [id, w] of Object.entries(sc.weights)) base += w * (id === "calm" ? 100 - m.unrest : m[id]); + const bonus = (cfg, value) => clamp((cfg.ref - value) * cfg.per, cfg.min, cfg.max); + const total = base + + bonus(sc.debt, m.debt) + + clamp((m.growth - sc.growth.ref) * sc.growth.per, sc.growth.min, sc.growth.max) + + bonus(sc.unemployment, m.unemployment); + return clamp(total, 0, 100); +} + +const gradeOf = (score, bal) => bal.score.grades.find(([min]) => score >= min)?.[1] ?? "F"; + +// How strongly one effect moves the country, in rough "points". Used to rank decisions. +export function effectImpact(e) { + const d = Math.abs(e.delta ?? 0); + if (e.stat !== undefined || e.sector !== undefined) { + if (e.bias) return d * clamp((e.days ?? 365) / 90, 1, 8); + return d; + } + if (e.revenue !== undefined) return d * (e.revenue === "other" ? 8 : 2); + if (e.oneOff !== undefined) return Math.abs(e.oneOff) * 5; + if (e.extraSpending !== undefined) return Math.abs(e.extraSpending) * 8; + return 0; +} + +export const optionImpact = (option) => (option.effects ?? []).reduce((sum, e) => sum + effectImpact(e), 0); + +function topDecisions(state, cards, count) { + const byId = indexCards(cards); + return state.cardHistory + .map((h) => { + const card = byId.get(h.id); + return card ? { id: h.id, day: h.day, choice: h.choice, impact: optionImpact(card[h.choice]) } : null; + }) + .filter(Boolean) + .sort((a, b) => b.impact - a.impact || a.day - b.day) + .slice(0, count); +} + +function meets(entry, m) { + const c = entry.conditions ?? {}; + for (const [k, v] of Object.entries(c.min ?? {})) if (!(m[k] >= v)) return false; + for (const [k, v] of Object.entries(c.max ?? {})) if (!(m[k] <= v)) return false; + return true; +} + +// endgame: the parsed data/endgame.json (its `titles` list is in priority order). +export function buildReview(state, bal, cards, endgame) { + const metrics = termAverages(state); + let score = computeScore(metrics, bal); + if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap); + metrics.score = score; + + const earlyEnd = state.over && state.over.type !== "term" ? state.over.type : null; + const title = endgame.titles.find((t) => (t.over ?? null) === earlyEnd && (earlyEnd || meets(t, metrics))); + return { + over: state.over, + metrics, + score, + grade: earlyEnd ? "F" : gradeOf(score, bal), + titleId: title?.id ?? null, + topDecisions: topDecisions(state, cards, bal.score.topDecisions), + }; +} diff --git a/web/js/engine/sectors.js b/web/js/engine/sectors.js new file mode 100644 index 0000000..be7f5a8 --- /dev/null +++ b/web/js/engine/sectors.js @@ -0,0 +1,16 @@ +// Sector quality drifts toward a target set by the budget ratio (alloc / need). +// Each sector has its own time constant: police reacts in weeks, infrastructure in years. + +import { clamp, qualityFromRatio } from "./state.js"; +import { biasFor } from "./modifiers.js"; + +export function stepSectors(state, bal, fiscal) { + const s = bal.sector; + for (const [id, sec] of Object.entries(state.sectors)) { + let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id); + if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) { + target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom); + } + sec.quality = clamp(sec.quality + (clamp(target, 0, 100) - sec.quality) / bal.sectorTau[id], 0, 100); + } +} diff --git a/web/js/engine/state.js b/web/js/engine/state.js new file mode 100644 index 0000000..c47a382 --- /dev/null +++ b/web/js/engine/state.js @@ -0,0 +1,17 @@ +// Shared constants and tiny helpers. No game logic here. + +export const STATE_VERSION = 1; + +export const IND_IDS = [ + "order", "stability", "civilRights", "opinion", "economy", + "hardPower", "softPower", "unrest", "armyLoyalty", +]; +export const VAR_IDS = ["growth", "unemployment", "inflation"]; +export const TAX_IDS = ["income", "corporate", "vat"]; + +export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +// Saturating curve: overspending has diminishing returns, ratio 0 gives 0. +export function qualityFromRatio(ratio, bal) { + return 100 * (1 - Math.exp(-bal.sector.qualityCurve * Math.max(0, ratio))); +} diff --git a/web/js/engine/time.js b/web/js/engine/time.js new file mode 100644 index 0000000..6616a38 --- /dev/null +++ b/web/js/engine/time.js @@ -0,0 +1,31 @@ +// Real-time clock accumulator and in-game calendar helpers. + +export function createClock() { + return { acc: 0 }; +} + +// 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 +// floored at 0: the first animation frame can carry a timestamp slightly before +// the one the caller recorded, and a negative step would pre-load a whole day. +export function consumeClock(clock, dtMs, msPerDay, speed) { + clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed; + const days = Math.floor(clock.acc / msPerDay); + clock.acc -= days * msPerDay; + return days; +} + +export function dateOfDay(startDate, day) { + const [y, m, d] = startDate.split("-").map(Number); + const date = new Date(Date.UTC(y, m - 1, d + day)); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + weekday: date.getUTCDay(), // 0 = Sunday + iso: date.toISOString().slice(0, 10), + }; +} + +export const weekOfDay = (day, bal) => Math.floor(day / bal.time.daysPerWeek); +export const quarterOfDay = (day, bal) => Math.floor(day / bal.time.daysPerQuarter); diff --git a/web/js/engine/validate.js b/web/js/engine/validate.js new file mode 100644 index 0000000..7549130 --- /dev/null +++ b/web/js/engine/validate.js @@ -0,0 +1,96 @@ +// Checks a game loaded from a save (autosave, slot or imported string) before the game +// resumes from it, and repairs what can be repaired. A hostile or damaged save must never +// get as far as the UI: every number is finite and in range, every id is known. + +import { STATE_VERSION, IND_IDS, VAR_IDS, TAX_IDS } from "./state.js"; +import { PRONOUNS } from "./game.js"; + +const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"]; +const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS]; +const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"]; +const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"]; +const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"]; + +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 isInt = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi; +const sameKeys = (obj, keys) => isObj(obj) && Object.keys(obj).length === keys.length && keys.every((k) => k in obj); +const allNums = (obj, keys, lo, hi) => sameKeys(obj, keys) && keys.every((k) => isNum(obj[k], lo, hi)); + +// Returns { ok: true, state } (repaired in place) or { ok: false, error: "invalid" | "scenario" | "version" }. +export function restoreState(s, { scenarios, balance, cards }) { + const fail = (error = "invalid") => ({ ok: false, error }); + if (!isObj(s) || s.version !== STATE_VERSION) return fail("version"); + const scenario = scenarios[s.scenarioId]; + if (!scenario) return fail("scenario"); + + const lim = balance.limits; + const cardIds = new Set(cards.map((c) => c.id)); + const sectorIds = Object.keys(scenario.sectors); + const modGroups = { ind: IND_IDS, var: VAR_IDS, sector: sectorIds }; + + // Scalars + if (!isInt(s.day, 0, balance.time.termDays)) return fail(); + if (!isInt(s.rngState, 0, 0xffffffff) || !isInt(s.seed, 0, 0xffffffff)) return fail(); + if (s.speed !== 0 && !balance.time.speeds.includes(s.speed)) return fail(); + if (s.startDate !== scenario.startDate) return fail(); + if (!PRONOUNS.includes(s.pronouns)) return fail(); + if (typeof s.countryName !== "string" || s.countryName.length < 1 || s.countryName.length > 60) return fail(); + if (s.lang !== "en" && s.lang !== "fr") s.lang = "en"; + + // Economy, sectors, indicators + const e = s.economy; + if (!isObj(e) || !isNum(e.gdp, 1e-6) || !isNum(e.debt, 0) || !isNum(e.otherRevenue) || !isNum(e.extraSpending)) return fail(); + if (!isNum(e.growth, -100, 100) || !isNum(e.unemployment, 0, 100) || !isNum(e.inflation, -100, 1000)) return fail(); + if (!allNums(e.taxes, TAX_IDS, 0, 500)) return fail(); + + if (!sameKeys(s.sectors, sectorIds)) return fail(); + for (const id of sectorIds) { + const x = s.sectors[id]; + if (!isObj(x) || !isNum(x.need, 1e-6) || !isNum(x.alloc, 0, x.need * lim.allocMaxRatio + 1e-9) || !isNum(x.quality, 0, 100)) return fail(); + } + if (!allNums(s.indicators, IND_IDS, 0, 100)) return fail(); + + // Reference values captured at the start of the game + const r = s.ref; + if (!isObj(r) || !allNums(r.taxes, TAX_IDS, 0, 500) || !allNums(r.ind, IND_IDS, 0, 100)) return fail(); + if (!allNums(r.vars, VAR_IDS) || !allNums(r.sectors, sectorIds) || !allNums(r.ratio, sectorIds) || !allNums(r.dev, DEV_KEYS)) return fail(); + + // Timed modifiers + if (!Array.isArray(s.modifiers)) return fail(); + for (const m of s.modifiers) { + if (!isObj(m) || !(m.group in modGroups) || !modGroups[m.group].includes(m.key)) return fail(); + if (m.type === "shift" ? !(isNum(m.perDay) && isNum(m.remaining, 1)) : !(m.type === "bias" && isNum(m.value) && (m.remaining === null || isNum(m.remaining, 1)))) return fail(); + } + + // Decisions and flags + if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail(); + if (!Array.isArray(s.cardHistory)) return fail(); + for (const h of s.cardHistory) { + 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 (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail(); + + // Pauses, alerts and endings + if (!isObj(s.pending)) return fail(); + for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true; + if (!Array.isArray(s.alerts) || !s.alerts.every((a) => isObj(a) && ALERT_KINDS.includes(a.kind) && isInt(a.day, 0, balance.time.termDays))) return fail(); + if (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail(); + if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) return fail(); + + // History for the charts: one equal-length series per tracked value + const h = s.history; + if (!isObj(h) || !Array.isArray(h.t) || !isObj(h.series) || !h.t.every((d) => isInt(d, 0, balance.time.termDays))) return fail(); + for (const id of HISTORY_SERIES) { + const series = h.series[id]; + if (!Array.isArray(series) || series.length !== h.t.length || !series.every((v) => isNum(v))) return fail(); + } + if (!Array.isArray(s.log)) return fail(); + + // Repairs: content that changed since the save was made + s.followUps = s.followUps.filter((f) => cardIds.has(f.id)); + if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null; + s.pending.alert = s.alerts.length > 0; + return { ok: true, state: s }; +} diff --git a/web/js/i18n/en.json b/web/js/i18n/en.json new file mode 100644 index 0000000..59b18d7 --- /dev/null +++ b/web/js/i18n/en.json @@ -0,0 +1,189 @@ +{ + "app.title": "The Desk", + "dash.title": "Ministerial Briefing", + "dash.confidential": "Strictly confidential", + "dash.econ": "Economic figures", + "dash.intel": "Intelligence summary", + "dash.classified": "Classified", + "ind.order": "Order", + "ind.stability": "Stability", + "ind.civilRights": "Civil rights", + "ind.opinion": "Public opinion", + "ind.economy": "Economy", + "ind.hardPower": "Hard power", + "ind.softPower": "Soft power", + "ind.unrest": "Unrest", + "ind.armyLoyalty": "Army loyalty", + "opinion.0": "Angry", + "opinion.1": "Hostile", + "opinion.2": "Uneasy", + "opinion.3": "Neutral", + "opinion.4": "Content", + "opinion.5": "Happy", + "opinion.6": "Ecstatic", + "unrest.0": "Calm", + "unrest.1": "Restless", + "unrest.2": "Tense", + "unrest.3": "Volatile", + "unrest.4": "Explosive", + "army.0": "Mutinous", + "army.1": "Wavering", + "army.2": "Steady", + "army.3": "Loyal", + "econ.gdp": "GDP", + "econ.growth": "Growth", + "econ.unemployment": "Unemployment", + "econ.inflation": "Inflation", + "econ.debt": "Debt / GDP", + "econ.deficit": "Deficit / GDP", + "econ.rate": "Interest rate", + "econ.gdpUnit": "bn {currency}", + "cal.dayCount": "Day {day} of {total}", + "cal.briefing": "Next briefing", + "cal.review": "Budget review", + "cal.inDays": "in {n} days", + "cal.today": "today", + "cal.tomorrow": "tomorrow", + "cal.pause": "Pause", + "cal.speed": "Speed {n}×", + "inbox.title": "In-tray", + "inbox.idle": "Nothing awaits your signature. The country carries on.", + "inbox.overTitle": "End of term", + "inbox.overBody": "Your term has ended. (The final review arrives in a later build.)", + "news.edition": "Edition of {date}", + "sector.education": "Education", + "sector.health": "Health", + "sector.social": "Social & pensions", + "sector.police": "Police", + "sector.army": "Army", + "sector.justice": "Justice", + "sector.industry": "Industry & energy", + "sector.transport": "Public transport", + "sector.infrastructure": "Infrastructure", + "sector.housing": "Housing", + "sector.research": "Research", + "sector.culture": "Culture & media", + "sector.foreignAffairs": "Foreign affairs", + "sector.environment": "Environment", + "sector.administration": "Administration", + "tax.income": "Income tax", + "tax.corporate": "Corporate tax", + "tax.vat": "VAT", + "ledger.title": "Treasury ledger", + "ledger.spending": "Spending, % of GDP", + "ledger.spendingHint": "The tick on each line marks what the sector needs.", + "ledger.totalAlloc": "Allocated to sectors", + "ledger.revenue": "Revenue: tax rates", + "ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.", + "ledger.yield": "{n} % of GDP", + "ledger.quality": "Quality {now} → {target}", + "ledger.summary": "Treasury summary", + "ledger.sumRevenue": "Revenue", + "ledger.sumSectors": "Sector spending", + "ledger.sumInterest": "Debt interest", + "ledger.sumSpending": "Total spending", + "ledger.sumSurplus": "Surplus", + "ledger.sumDeficit": "Deficit", + "ledger.perYear": "{n} bn {currency} per year", + "ledger.projection": "Next four quarters, if nothing else changes", + "ledger.now": "Now", + "ledger.months": "+{n} mo", + "ledger.reset": "Reset", + "ledger.confirm": "Sign and file", + "ledger.error": "The Treasury rejected this budget.", + "kind.memo": "Memorandum", + "kind.telegram": "Telegram", + "kind.petition": "Petition", + "kind.report": "Report", + "kind.letter": "Letter", + "card.approved": "Approved", + "card.rejected": "Rejected", + "card.keys": "Keys: Y approve · N reject", + "card.from": "From", + "profile.title": "Sworn in", + "profile.lead": "Before you take office, the Press Office needs one thing for the official records.", + "profile.body": "How should the press refer to you?", + "profile.he": "He / Him", + "profile.she": "She / Her", + "profile.they": "They / Them", + "profile.note": "This only changes how you are named in the history books.", + "alert.urgent": "Urgent telegram", + "alert.ack": "Understood", + "ind.regime": "Hold on power", + "regime.0": "Secure", + "regime.1": "Shaky", + "regime.2": "Critical", + "end.verdict": "The historians' verdict", + "end.score": "Score {n} / 100", + "end.grade": "Grade", + "end.served": "Served {days} days", + "end.decisions": "Decisions that defined the term", + "end.noDecisions": "No decision was signed.", + "end.chose": "You chose: {label}", + "end.again": "Play again", + "end.averages": "avg", + "end.final": "end", + "cal.menu": "Menu", + "menu.secret": "Top secret", + "menu.tagline": "A country. Five years. Every decision carries your signature.", + "menu.paused": "Game paused", + "menu.continue": "Continue", + "menu.resume": "Resume", + "menu.new": "New game", + "menu.load": "Load game", + "menu.save": "Save game", + "menu.export": "Export", + "menu.import": "Import", + "menu.settings": "Settings", + "menu.credits": "Credits", + "menu.quit": "Main menu", + "menu.back": "Back", + "menu.info": "{country} · day {day}", + "new.title": "New game", + "new.name": "Name of the country", + "new.scenario": "Scenario", + "new.pronouns": "How should the press refer to you?", + "new.start": "Take office", + "load.title": "Load game", + "load.auto": "Autosave", + "load.slot": "Slot {n}", + "load.empty": "Empty", + "load.info": "{country} · day {day}{date}", + "load.finished": "finished", + "load.unreadable": "Unreadable save", + "load.load": "Load", + "load.delete": "Delete", + "load.sure": "Sure?", + "load.replace": "Loading replaces the game in progress.", + "save.title": "Save game", + "save.here": "Save here", + "save.overwrite": "Overwrite", + "save.done": "Saved to slot {n}.", + "save.failed": "The browser refused the save. Use Export to keep your game.", + "export.title": "Export", + "export.hint": "Copy this text or download the file. You can import it later, on any device.", + "export.copy": "Copy", + "export.copied": "Copied.", + "export.copyFailed": "Select the text and copy it by hand.", + "export.download": "Download .sav", + "import.title": "Import", + "import.hint": "Paste a save string, or choose a .sav file.", + "import.file": "Choose a file", + "import.go": "Import", + "import.error.format": "That does not look like a save string.", + "import.error.checksum": "The save is damaged or incomplete (checksum mismatch).", + "import.error.tooNew": "This save comes from a newer version of the game.", + "import.error.version": "This save is from a version that cannot be converted.", + "import.error.invalid": "The contents of this save are not valid.", + "import.error.scenario": "This save uses a scenario that is not installed.", + "import.error.empty": "Nothing to import yet.", + "settings.title": "Settings", + "settings.language": "Language", + "settings.slow": "Slow time", + "settings.slowHint": "One in-game day takes 60 seconds instead of 10.", + "settings.storage": "This browser is not keeping saves between visits (private window or blocked storage). Use Export to keep your game.", + "credits.title": "Credits", + "credits.fiction": "Valdoria and everyone in it are fictional.", + "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." +} diff --git a/web/js/i18n/fr.json b/web/js/i18n/fr.json new file mode 100644 index 0000000..bff4dab --- /dev/null +++ b/web/js/i18n/fr.json @@ -0,0 +1,189 @@ +{ + "app.title": "Le Bureau", + "dash.title": "Note ministérielle", + "dash.confidential": "Strictement confidentiel", + "dash.econ": "Chiffres économiques", + "dash.intel": "Synthèse du renseignement", + "dash.classified": "Classifié", + "ind.order": "Ordre public", + "ind.stability": "Stabilité", + "ind.civilRights": "Droits civiques", + "ind.opinion": "Opinion publique", + "ind.economy": "Économie", + "ind.hardPower": "Puissance militaire", + "ind.softPower": "Influence", + "ind.unrest": "Agitation", + "ind.armyLoyalty": "Loyauté de l'armée", + "opinion.0": "En colère", + "opinion.1": "Hostile", + "opinion.2": "Inquiète", + "opinion.3": "Neutre", + "opinion.4": "Satisfaite", + "opinion.5": "Heureuse", + "opinion.6": "Extatique", + "unrest.0": "Calme", + "unrest.1": "Agité", + "unrest.2": "Tendu", + "unrest.3": "Volatil", + "unrest.4": "Explosif", + "army.0": "Mutinée", + "army.1": "Hésitante", + "army.2": "Stable", + "army.3": "Loyale", + "econ.gdp": "PIB", + "econ.growth": "Croissance", + "econ.unemployment": "Chômage", + "econ.inflation": "Inflation", + "econ.debt": "Dette / PIB", + "econ.deficit": "Déficit / PIB", + "econ.rate": "Taux d'intérêt", + "econ.gdpUnit": "Md {currency}", + "cal.dayCount": "Jour {day} sur {total}", + "cal.briefing": "Prochain dossier", + "cal.review": "Revue budgétaire", + "cal.inDays": "dans {n} jours", + "cal.today": "aujourd'hui", + "cal.tomorrow": "demain", + "cal.pause": "Pause", + "cal.speed": "Vitesse {n}×", + "inbox.title": "Corbeille d'arrivée", + "inbox.idle": "Rien n'attend votre signature. Le pays poursuit sa route.", + "inbox.overTitle": "Fin de mandat", + "inbox.overBody": "Votre mandat est terminé. (Le bilan final arrive dans une prochaine version.)", + "news.edition": "Édition du {date}", + "sector.education": "Éducation", + "sector.health": "Santé", + "sector.social": "Social, retraites", + "sector.police": "Police", + "sector.army": "Armée", + "sector.justice": "Justice", + "sector.industry": "Industrie, énergie", + "sector.transport": "Transports publics", + "sector.infrastructure": "Infrastructures", + "sector.housing": "Logement", + "sector.research": "Recherche", + "sector.culture": "Culture, médias", + "sector.foreignAffairs": "Diplomatie", + "sector.environment": "Environnement", + "sector.administration": "Administration", + "tax.income": "Impôt revenu", + "tax.corporate": "Impôt sociétés", + "tax.vat": "TVA", + "ledger.title": "Registre du Trésor", + "ledger.spending": "Dépenses, % du PIB", + "ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.", + "ledger.totalAlloc": "Alloué aux secteurs", + "ledger.revenue": "Recettes : taux d'imposition", + "ledger.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.", + "ledger.yield": "{n} % du PIB", + "ledger.quality": "Qualité {now} → {target}", + "ledger.summary": "Synthèse du Trésor", + "ledger.sumRevenue": "Recettes", + "ledger.sumSectors": "Dépenses sectorielles", + "ledger.sumInterest": "Intérêts de la dette", + "ledger.sumSpending": "Dépenses totales", + "ledger.sumSurplus": "Excédent", + "ledger.sumDeficit": "Déficit", + "ledger.perYear": "{n} Md {currency} par an", + "ledger.projection": "Quatre prochains trimestres, sans autre changement", + "ledger.now": "Auj.", + "ledger.months": "+{n} m", + "ledger.reset": "Réinitialiser", + "ledger.confirm": "Signer et classer", + "ledger.error": "Le Trésor a rejeté ce budget.", + "kind.memo": "Mémorandum", + "kind.telegram": "Télégramme", + "kind.petition": "Pétition", + "kind.report": "Rapport", + "kind.letter": "Lettre", + "card.approved": "Approuvé", + "card.rejected": "Rejeté", + "card.keys": "Touches : Y approuver · N rejeter", + "card.from": "De la part de", + "profile.title": "Prestation de serment", + "profile.lead": "Avant votre entrée en fonction, le service de presse a besoin d'une précision pour les registres officiels.", + "profile.body": "Comment la presse doit-elle s'adresser à vous ?", + "profile.he": "Il / Lui", + "profile.she": "Elle / Elle", + "profile.they": "Iel / Ielle", + "profile.note": "Cela ne change que la manière dont vous serez nommé dans les livres d'histoire.", + "alert.urgent": "Télégramme urgent", + "alert.ack": "Compris", + "ind.regime": "Emprise sur le pouvoir", + "regime.0": "Solide", + "regime.1": "Fragile", + "regime.2": "Critique", + "end.verdict": "Le verdict des historiens", + "end.score": "Score {n} / 100", + "end.grade": "Note", + "end.served": "{days} jours au pouvoir", + "end.decisions": "Les décisions qui ont marqué le mandat", + "end.noDecisions": "Aucune décision n'a été signée.", + "end.chose": "Votre choix : {label}", + "end.again": "Rejouer", + "end.averages": "moy.", + "end.final": "fin", + "cal.menu": "Menu", + "menu.secret": "Secret défense", + "menu.tagline": "Un pays. Cinq ans. Chaque décision porte votre signature.", + "menu.paused": "Partie en pause", + "menu.continue": "Continuer", + "menu.resume": "Reprendre", + "menu.new": "Nouvelle partie", + "menu.load": "Charger une partie", + "menu.save": "Sauvegarder", + "menu.export": "Exporter", + "menu.import": "Importer", + "menu.settings": "Réglages", + "menu.credits": "Crédits", + "menu.quit": "Menu principal", + "menu.back": "Retour", + "menu.info": "{country} · jour {day}", + "new.title": "Nouvelle partie", + "new.name": "Nom du pays", + "new.scenario": "Scénario", + "new.pronouns": "Comment la presse doit-elle s'adresser à vous ?", + "new.start": "Prendre ses fonctions", + "load.title": "Charger une partie", + "load.auto": "Sauvegarde auto", + "load.slot": "Emplacement {n}", + "load.empty": "Vide", + "load.info": "{country} · jour {day}{date}", + "load.finished": "terminée", + "load.unreadable": "Sauvegarde illisible", + "load.load": "Charger", + "load.delete": "Supprimer", + "load.sure": "Sûr ?", + "load.replace": "Charger remplace la partie en cours.", + "save.title": "Sauvegarder", + "save.here": "Sauvegarder ici", + "save.overwrite": "Écraser", + "save.done": "Sauvegardé dans l'emplacement {n}.", + "save.failed": "Le navigateur a refusé la sauvegarde. Utilisez Exporter pour garder votre partie.", + "export.title": "Exporter", + "export.hint": "Copiez ce texte ou téléchargez le fichier. Vous pourrez l'importer plus tard, sur n'importe quel appareil.", + "export.copy": "Copier", + "export.copied": "Copié.", + "export.copyFailed": "Sélectionnez le texte et copiez-le à la main.", + "export.download": "Télécharger le .sav", + "import.title": "Importer", + "import.hint": "Collez une chaîne de sauvegarde, ou choisissez un fichier .sav.", + "import.file": "Choisir un fichier", + "import.go": "Importer", + "import.error.format": "Cela ne ressemble pas à une chaîne de sauvegarde.", + "import.error.checksum": "La sauvegarde est endommagée ou incomplète (somme de contrôle invalide).", + "import.error.tooNew": "Cette sauvegarde vient d'une version plus récente du jeu.", + "import.error.version": "Cette sauvegarde vient d'une version qui ne peut pas être convertie.", + "import.error.invalid": "Le contenu de cette sauvegarde n'est pas valide.", + "import.error.scenario": "Cette sauvegarde utilise un scénario qui n'est pas installé.", + "import.error.empty": "Rien à importer pour l'instant.", + "settings.title": "Réglages", + "settings.language": "Langue", + "settings.slow": "Temps ralenti", + "settings.slowHint": "Un jour en jeu dure 60 secondes au lieu de 10.", + "settings.storage": "Ce navigateur ne conserve pas les sauvegardes entre deux visites (fenêtre privée ou stockage bloqué). Utilisez Exporter pour garder votre partie.", + "credits.title": "Crédits", + "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.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." +} diff --git a/web/js/i18n/i18n.js b/web/js/i18n/i18n.js new file mode 100644 index 0000000..ac2c8b0 --- /dev/null +++ b/web/js/i18n/i18n.js @@ -0,0 +1,72 @@ +// UI strings live in en.json / fr.json. Content fields (cards, newspaper) are +// { en, fr } objects read through tx(). + +const dict = {}; +let lang = "en"; + +export async function initI18n(initial = "en") { + for (const l of ["en", "fr"]) { + dict[l] = await (await fetch(`js/i18n/${l}.json`)).json(); + } + setLang(initial); +} + +export function setLang(l) { + lang = dict[l] ? l : "en"; + document.documentElement.lang = lang; +} + +export const getLang = () => lang; + +export function t(key, vars) { + let s = dict[lang]?.[key] ?? dict.en?.[key] ?? key; + if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v); + return s; +} + +// Pick the current language from a { en, fr } content field. +export const tx = (obj) => obj?.[lang] ?? obj?.en ?? ""; + +// Pronoun tokens for text that refers to the player: {sub} {Sub} {obj} {pos} {is} {has} {was} +// in English, {sub} {Sub} {tonic} {e} (agreement ending) in French. Text that needs none of +// them should avoid pronouns altogether. +const PRONOUN_FORMS = { + en: { + he: { sub: "he", Sub: "He", obj: "him", pos: "his", is: "is", has: "has", was: "was" }, + she: { sub: "she", Sub: "She", obj: "her", pos: "her", is: "is", has: "has", was: "was" }, + they: { sub: "they", Sub: "They", obj: "them", pos: "their", is: "are", has: "have", was: "were" }, + }, + fr: { + he: { sub: "il", Sub: "Il", tonic: "lui", e: "" }, + she: { sub: "elle", Sub: "Elle", tonic: "elle", e: "e" }, + they: { sub: "iel", Sub: "Iel", tonic: "ielle", e: "·e" }, + }, +}; + +export function tp(text, pronouns = "they") { + const forms = PRONOUN_FORMS[lang]?.[pronouns] ?? PRONOUN_FORMS[lang]?.they ?? {}; + return text.replace(/\{(\w+)\}/g, (match, key) => forms[key] ?? match); +} + +// A historian's title: English is one string, French has a form per pronoun set. +export function tt(title, pronouns = "they") { + const v = title?.[lang] ?? title?.en ?? ""; + return typeof v === "string" ? v : (v[pronouns] ?? v.they); +} + +export function fmt(n, digits = 1) { + return new Intl.NumberFormat(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n); +} + +// ISO date (YYYY-MM-DD, treated as UTC) → long localized parts. +export function dateParts(iso) { + const d = new Date(`${iso}T00:00:00Z`); + const f = (opts) => new Intl.DateTimeFormat(lang, { timeZone: "UTC", ...opts }).format(d); + return { + weekday: f({ weekday: "long" }), + day: f({ day: "numeric" }), + month: f({ month: "long" }), + year: f({ year: "numeric" }), + full: f({ dateStyle: "full" }), + }; +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..91c0521 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,282 @@ +// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop. +// +// 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) +// ?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) + +import { + createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting, + createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat, +} from "./engine/index.js"; +import { initI18n, setLang, getLang, t } from "./i18n/i18n.js"; +import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js"; +import { tilt } from "./ui/desk.js"; +import { createDashboard } from "./ui/dashboard.js"; +import { createCalendar } from "./ui/calendar.js"; +import { createNewspaper } from "./ui/newspaper.js"; +import { createDecision } from "./ui/decision.js"; +import { createLedger } from "./ui/ledger.js"; +import { createTelegram } from "./ui/telegram.js"; +import { createEndgame } from "./ui/endgame.js"; +import { createMenu } from "./ui/menu.js"; + +const params = new URLSearchParams(location.search); +const getJson = (url) => fetch(url).then((r) => r.json()); + +// ---- Content ---- + +const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([ + getJson("data/balance.json"), + getJson("data/newspaper.json"), + getJson("data/endgame.json"), + getJson("data/characters.json"), + getJson("data/cards/index.json"), + getJson("data/scenarios/index.json"), +]); +const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat(); +const scenarios = Object.fromEntries( + await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])), +); +const cardsById = indexCards(cards); +const content = { scenarios, balance, cards }; + +// ---- Settings and storage ---- + +const storage = createStorage(); +let settings = loadSettings(storage); +const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en"; +await initI18n(params.get("lang") ?? settings.lang ?? browserLang); + +const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"]; +const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu"); + +// ---- Game and screen state ---- + +let state = null; // the running game, or null in the main menu +let scenario = null; +let panels = null; +let menuOpen = true; +let lastSpeed = 1; + +const el = { + dash: document.getElementById("dashboard"), + folder: document.getElementById("folder"), + ledger: document.getElementById("ledger"), + alerts: document.getElementById("alerts"), + endgame: document.getElementById("endgame"), + cal: document.getElementById("calendar"), + news: document.getElementById("newspaper"), + menu: document.getElementById("menu"), +}; +const pad = document.getElementById("pad"); +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. +function prepareWeek() { + ensureCard(state, balance, cards); +} + +function autosave() { + if (!state || devRun) return; + if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue + else writeSave(storage, KEYS.auto, state); +} + +// ---- Panels ---- + +function build() { + panels?.decision.destroy(); + document.title = t("app.title"); + state.lang = getLang(); + panels = { + dash: createDashboard(el.dash, scenario), + decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }), + ledger: createLedger(el.ledger, scenario, { onConfirm: render }), + cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }), + news: createNewspaper(el.news, newsTpl), + alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }), + endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }), + }; + render(); +} + +function render() { + if (!state) return; + 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. + pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over)); + pad.classList.toggle("is-endgame", Boolean(state.over)); + autosave(); +} + +// ---- Game lifecycle ---- + +function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) { + return createGame({ + scenario: scenarios[scenarioId], + balance, + seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0], + lang: getLang(), + countryName, + pronouns, + }); +} + +function enterGame(next) { + state = next; + scenario = scenarios[state.scenarioId]; + if (params.has("dev")) window.__game = state; + lastSpeed = state.speed || 1; + prepareWeek(); + closeMenu(); + build(); +} + +function openMenu() { + if (!state) return; + menuOpen = true; + pad.classList.add("is-menu"); + menu.show("game"); +} + +function closeMenu() { + menuOpen = false; + pad.classList.remove("is-menu"); + menu.hide(); + clock.acc = 0; +} + +function quitToMenu() { + autosave(); + panels?.decision.destroy(); + state = null; + panels = null; + for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = ""; + el.ledger.hidden = true; + el.alerts.hidden = true; + el.endgame.hidden = true; + pad.classList.remove("is-ledger", "is-endgame"); + pad.classList.add("is-menu"); + menuOpen = true; + menu.show("main"); +} + +function applySettings(patch) { + const langChanged = patch.lang && patch.lang !== getLang(); + settings = { ...settings, ...patch }; + saveSettings(storage, settings); + if (!langChanged) return; + setLang(patch.lang); + document.title = t("app.title"); + if (state) build(); + menu.refresh(); +} + +const menu = createMenu(el.menu, { + content, + storage, + getState: () => state, + getSettings: () => settings, + handlers: { + onStart: (options) => enterGame(newGame(options)), + onLoadState: enterGame, + onResume: closeMenu, + onQuit: quitToMenu, + onSettings: applySettings, + }, +}); + +// ---- Player actions ---- + +function onSpeed(speed) { + if (speed > 0) lastSpeed = speed; + setSpeed(state, balance, speed); + render(); +} + +function onAnswer(choice) { + answerCard(state, balance, cards, choice); + render(); +} + +function onProfile(pronouns) { + setPronouns(state, pronouns); + render(); +} + +function onDismiss() { + dismissAlert(state); + render(); +} + +document.addEventListener("keydown", (e) => { + if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return; + if (menuOpen) return; // the menu handles its own keys + if (e.key === "Escape" && !state.over) { + openMenu(); + } else if (e.code === "Space") { + e.preventDefault(); + onSpeed(state.speed > 0 ? 0 : lastSpeed); + } else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) { + onSpeed(balance.time.speeds[Number(e.key) - 1]); + } +}); + +addEventListener("pagehide", autosave); +document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); }); + +// ---- Dev shortcuts ---- + +function devGame() { + const s = newGame({ pronouns: params.get("pronouns") ?? undefined }); + state = s; // the helpers below act on `state` + if (params.has("alloc")) { + const k = Number(params.get("alloc")); + setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) }); + } + for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) { + if (s.pending.profile) setPronouns(s, "they"); + s.pending.review = false; + while (s.pending.alert) dismissAlert(s); + prepareWeek(); + if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no"); + advanceDay(s, balance); + } + if (cardsById.has(params.get("card"))) { + s.pendingCard = { id: params.get("card"), day: s.day }; + s.pending.weeklyCard = true; + } + return s; +} + +// ---- Start ---- + +const clock = createClock(); +let last = performance.now(); + +if (devRun) enterGame(devGame()); +else quitToMenu(); + +function frame(now) { + const dt = now - last; + last = now; + if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) { + const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay; + let days = consumeClock(clock, dt, msPerDay, state.speed); + while (days-- > 0) { + const r = advanceDay(state, balance); + if (!r.advanced) break; + if (r.events.weeklyCard) prepareWeek(); + render(); + if (isWaiting(state) || state.over) { + clock.acc = 0; + break; + } + } + } else { + clock.acc = 0; + } + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); diff --git a/web/js/save/codec.js b/web/js/save/codec.js new file mode 100644 index 0000000..a427241 --- /dev/null +++ b/web/js/save/codec.js @@ -0,0 +1,81 @@ +// Save strings for export and import: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON). +// The checksum only catches corrupted or truncated strings. It is not protection against editing: +// anyone can decode, change and re-encode a save, and a single-player game does not need more. + +import { STATE_VERSION } from "../engine/state.js"; + +const PREFIX = "CG1:"; +const FORMAT = /^CG1:([0-9a-f]{8}):([A-Za-z0-9+/]+={0,2})$/; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(bytes) { + let c = 0xffffffff; + for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function toBase64(bytes) { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); +} + +function fromBase64(text) { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function encodeSave(state) { + const bytes = new TextEncoder().encode(JSON.stringify(state)); + return `${PREFIX}${crc32(bytes).toString(16).padStart(8, "0")}:${toBase64(bytes)}`; +} + +// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION +// changes: MIGRATIONS[n] turns a version n state into a version n+1 state. +export const MIGRATIONS = {}; + +export function migrate(state) { + if (!Number.isInteger(state?.version)) return { ok: false, error: "format" }; + if (state.version > STATE_VERSION) return { ok: false, error: "tooNew" }; + let s = state; + while (s.version < STATE_VERSION) { + const step = MIGRATIONS[s.version]; + if (!step) return { ok: false, error: "version" }; + s = step(s); + } + return { ok: true, state: s }; +} + +// Returns { ok: true, state } or { ok: false, error: "format" | "checksum" | "tooNew" | "version" }. +// Whitespace is ignored, so strings that were wrapped when pasted still work. +export function decodeSave(text) { + const match = FORMAT.exec(String(text).replace(/\s+/g, "")); + if (!match) return { ok: false, error: "format" }; + + let bytes; + try { + bytes = fromBase64(match[2]); + } catch { + return { ok: false, error: "format" }; + } + if (crc32(bytes).toString(16).padStart(8, "0") !== match[1]) return { ok: false, error: "checksum" }; + + let state; + try { + state = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return { ok: false, error: "format" }; + } + return migrate(state); +} diff --git a/web/js/save/storage.js b/web/js/save/storage.js new file mode 100644 index 0000000..74e9f9f --- /dev/null +++ b/web/js/save/storage.js @@ -0,0 +1,107 @@ +// Browser storage for the autosave, the manual slots and the settings. +// Every access is wrapped: if localStorage is missing or throws (private window, blocked +// site data, full quota) the game keeps working and uses memory for the rest of the session. + +export const SLOT_COUNT = 3; +export const KEYS = { + auto: "cg.autosave", + slot: (n) => `cg.slot.${n}`, + settings: "cg.settings", +}; + +function browserStorage() { + try { + const probe = "cg.probe"; + globalThis.localStorage.setItem(probe, "1"); + globalThis.localStorage.removeItem(probe); + return globalThis.localStorage; + } catch { + return null; + } +} + +// backend: anything with getItem/setItem/removeItem, for tests. Default: localStorage. +export function createStorage(backend = browserStorage()) { + const memory = new Map(); + const store = { persistent: backend !== null }; + + store.get = (key) => { + try { + if (backend) return backend.getItem(key); + } catch { + store.persistent = false; + } + return memory.get(key) ?? null; + }; + + // Returns false when the write could not be made durable. + store.set = (key, value) => { + try { + if (backend) { + backend.setItem(key, value); + return true; + } + } catch { + store.persistent = false; + } + memory.set(key, value); + return false; + }; + + store.remove = (key) => { + memory.delete(key); + try { + backend?.removeItem(key); + } catch { + store.persistent = false; + } + }; + + return store; +} + +// ---- Saved games: { meta, state } under one key ---- + +export function writeSave(store, key, state) { + const meta = { + savedAt: Date.now(), + day: state.day, + countryName: state.countryName, + pronouns: state.pronouns, + scenarioId: state.scenarioId, + over: state.over?.type ?? null, + }; + return store.set(key, JSON.stringify({ meta, state })); +} + +// Returns { meta, state } or null when the slot is empty or unreadable. +export function readSave(store, key) { + const raw = store.get(key); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed?.state && parsed?.meta ? parsed : null; + } catch { + return null; + } +} + +// ---- Settings ---- + +const DEFAULT_SETTINGS = { lang: null, slow: false }; + +export function loadSettings(store) { + try { + const s = JSON.parse(store.get(KEYS.settings) ?? "{}"); + return { + lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang, + slow: s.slow === true, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveSettings(store, settings) { + return store.set(KEYS.settings, JSON.stringify(settings)); +} diff --git a/web/js/ui/calendar.js b/web/js/ui/calendar.js new file mode 100644 index 0000000..f033d87 --- /dev/null +++ b/web/js/ui/calendar.js @@ -0,0 +1,60 @@ +// Desk calendar: in-game date, term progress, next decision dates, the brass speed +// controls and the way into the menu. + +import { t, dateParts } from "../i18n/i18n.js"; +import { dateOfDay } from "../engine/index.js"; +import { $ } from "./desk.js"; + +export function createCalendar(root, bal, { onSpeed, onMenu }) { + const speeds = bal.time.speeds; + root.innerHTML = ` +
+
+
+
+
+
+
+
${t("cal.briefing")}
+
${t("cal.review")}
+
+
+ + ${speeds.map((s) => ``).join("")} +
+ +
`; + + root.addEventListener("click", (e) => { + const b = e.target.closest("button"); + if (!b) return; + if (b.dataset.menu !== undefined) onMenu(); + else onSpeed(Number(b.dataset.speed)); + }); + + const until = (day, every) => (every - (day % every)) % every; + const when = (n) => { + if (n === 0) return t("cal.today"); + return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n }); + }; + + function update(state) { + const p = dateParts(dateOfDay(state.startDate, state.day).iso); + $(root, ".cal__weekday").textContent = p.weekday; + $(root, ".cal__day").textContent = p.day; + $(root, ".cal__month").textContent = `${p.month} ${p.year}`; + 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__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`; + + 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="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter)); + + for (const b of root.querySelectorAll("button[data-speed]")) { + b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === state.speed)); + } + } + + return { update }; +} diff --git a/web/js/ui/charts.js b/web/js/ui/charts.js new file mode 100644 index 0000000..ffe6571 --- /dev/null +++ b/web/js/ui/charts.js @@ -0,0 +1,45 @@ +// Hand-drawn SVG helpers. Output is built from numbers only, so it is safe as innerHTML. + +// Bigger chart for the end-of-term review: weekly samples over the whole term, +// a gridline per year and per quarter of the value range. +export function lineChart(values, { w = 260, h = 96, min = 0, max = 100, yearEvery = 52 } = {}) { + const pad = 3; + const n = values.length; + const span = max - min || 1; + const x = (i) => pad + (n > 1 ? (i / (n - 1)) * (w - 2 * pad) : 0); + const y = (v) => h - pad - Math.min(1, Math.max(0, (v - min) / span)) * (h - 2 * pad); + + const grid = []; + for (let i = yearEvery; i < n; i += yearEvery) grid.push(``); + for (const f of [0.25, 0.5, 0.75]) grid.push(``); + + const pts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ``; +} + +// Small line chart on graph paper. `values` are plotted evenly left to right. +export function sparkline(values, { w = 84, h = 24, min = 0, max = 100 } = {}) { + const grid = []; + for (let x = 12; x < w; x += 12) grid.push(``); + for (const f of [0.25, 0.5, 0.75]) grid.push(``); + + let line = ""; + const n = values.length; + if (n > 1) { + const span = max - min || 1; + const pts = values.map((v, i) => { + const x = 2 + (i / (n - 1)) * (w - 4); + const y = h - 2 - Math.min(1, Math.max(0, (v - min) / span)) * (h - 4); + return [x.toFixed(1), y.toFixed(1)]; + }); + const last = pts[n - 1]; + line = + `` + + ``; + } + return ``; +} diff --git a/web/js/ui/dashboard.js b/web/js/ui/dashboard.js new file mode 100644 index 0000000..8d113a1 --- /dev/null +++ b/web/js/ui/dashboard.js @@ -0,0 +1,127 @@ +// Ministerial briefing: seven indicator gauges with sparklines, economic figures +// with trend arrows, and a classified intelligence summary for the hidden gauges. + +import { t, fmt } from "../i18n/i18n.js"; +import { sparkline } from "./charts.js"; +import { $ } from "./desk.js"; +import { summarize } from "../engine/index.js"; + +const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"]; + +// key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising +const ECON = [ + ["gdp", "econ.gdp", 0, "", "gdp", null, 0, true], + ["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true], + ["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false], + ["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false], + ["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false], + ["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false], + ["rate", "econ.rate", 2, " %", "interestRate", null, 0, false], +]; + +const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length; +const OPINION_CUTS = [15, 30, 45, 60, 75, 90]; +const UNREST_CUTS = [20, 35, 50, 65]; +const ARMY_CUTS = [25, 45, 65]; + +const SPARK_WEEKS = 26; +const SPARK_W = 76; +const MIN_SPAN = 16; + +export function createDashboard(root, scenario) { + root.innerHTML = ` +
+

${t("dash.title")}

+ ${t("dash.confidential")} +
+
+ ${MAIN.map((id) => ` +
+ ${t("ind." + id)} + + + +
`).join("")} +
+

${t("dash.econ")}

+ + ${ECON.map(([key, label]) => ` + `).join("")} +
${t(label)}
+

${t("dash.intel")} ${t("dash.classified")}

+
    +
  • ${t("ind.unrest")}
  • +
  • ${t("ind.armyLoyalty")}
  • +
  • ${t("ind.regime")}
  • +
`; + + const gauges = Object.fromEntries(MAIN.map((id) => [id, { + el: $(root, `.gauge[data-ind="${id}"]`), + fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`), + value: $(root, `.gauge[data-ind="${id}"] .gauge__value`), + band: $(root, `.gauge[data-ind="${id}"] .gauge__band`), + spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`), + }])); + const sparkLen = {}; + + function trend(series, eps, goodUp) { + if (!series || series.length < 2) return ["", ""]; + const cur = series[series.length - 1]; + const prev = series[Math.max(0, series.length - 5)]; + const d = cur - prev; + if (Math.abs(d) < eps) return ["▬", "flat"]; + return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"]; + } + + function update(state, bal) { + const sum = summarize(state, bal); + + for (const id of MAIN) { + const g = gauges[id]; + const v = state.indicators[id]; + g.fill.style.width = `${v}%`; + g.el.classList.toggle("is-low", v < 30); + g.el.classList.toggle("is-high", v >= 70); + g.value.textContent = Math.round(v); + if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS)); + + const series = state.history.series[id] ?? []; + if (sparkLen[id] !== series.length) { + sparkLen[id] = series.length; + const win = series.slice(-SPARK_WEEKS); + // Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible. + const mid = (Math.min(...win) + Math.max(...win)) / 2; + const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN); + g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 }); + } + } + + for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) { + const row = $(root, `[data-econ="${key}"]`); + const val = sum[field]; + $(row, ".econ__val").textContent = key === "gdp" + ? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}` + : `${fmt(val, digits)}${suffix}`; + const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp); + const cell = $(row, ".econ__trend"); + cell.textContent = arrow; + cell.className = `econ__trend ${cls}`; + } + + $(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS)); + $(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS)); + // Hold on power: Critical once any danger zone has been entered, Shaky when close to one. + const i = state.indicators; + const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone; + const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40 + || i.civilRights <= 20 || sum.debtRatio >= 130; + const regime = inZone ? 2 : close ? 1 : 0; + $(root, '[data-intel="regime"] b').textContent = t("regime." + regime); + $(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime]; + + $(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS); + $(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS); + } + + return { update }; +} diff --git a/web/js/ui/decision.js b/web/js/ui/decision.js new file mode 100644 index 0000000..2a48432 --- /dev/null +++ b/web/js/ui/decision.js @@ -0,0 +1,113 @@ +// Centre folder: the weekly decision card as a paper document with two rubber +// stamps. Also shows the idle and end-of-term messages. + +import { t, tx, dateParts } from "../i18n/i18n.js"; +import { dateOfDay } from "../engine/index.js"; +import { $, esc } from "./desk.js"; + +const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied + +export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) { + root.innerHTML = ` +

${t("inbox.title")}

+
`; + const body = $(root, ".inbox__body"); + + let shown = ""; + let locked = false; + let latest = null; + + function choose(choice) { + if (locked) return; + const card = $(body, ".card"); + if (!card) return; + locked = true; + card.dataset.stamped = choice; + for (const b of card.querySelectorAll("button")) b.disabled = true; + const stamp = document.createElement("div"); + stamp.className = `card__stamp card__stamp--${choice}`; + stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected"); + card.append(stamp); + setTimeout(() => onAnswer(choice), STAMP_MS); + } + + body.addEventListener("click", (e) => { + const b = e.target.closest("button[data-choice]"); + if (b) choose(b.dataset.choice); + const p = e.target.closest("button[data-pronouns]"); + if (p) onProfile(p.dataset.pronouns); + }); + + // The panel is rebuilt on language changes and new games, so its key listener must be removable. + const onKey = (e) => { + if (!keysEnabled() || !latest?.pendingCard) return; + if (latest.pending.review || latest.pending.profile || latest.pending.alert) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.target.closest?.("input, textarea, select")) return; + const k = e.key.toLowerCase(); + if (k === "y") choose("yes"); + else if (k === "n") choose("no"); + }; + document.addEventListener("keydown", onKey); + + function option(card, side) { + const label = side === "yes" ? t("card.approved") : t("card.rejected"); + return ` + `; + } + + function cardHtml(card, state) { + const from = characters[card.sender]; + const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso); + const paragraphs = tx(card.body).split("\n\n").map((p) => `

${esc(p)}

`).join(""); + return ` +
+
+ ${t("kind." + card.doc)} + ${esc(date.day)} ${esc(date.month)} ${esc(date.year)} +
+
+ ${esc(from.seal)} + ${esc(tx(from.name))}${esc(tx(from.role))} +
+

${esc(tx(card.title))}

+
${paragraphs}
+
${option(card, "yes")}${option(card, "no")}
+

${t("card.keys")}

+
`; + } + + function update(state) { + latest = state; + let kind = "idle"; + if (state.over) kind = "over"; + else if (state.pending.profile) kind = "profile"; + else if (state.pendingCard) kind = `card:${state.pendingCard.id}`; + if (kind === shown) return; + shown = kind; + locked = false; + body.classList.toggle("is-card", kind.startsWith("card:")); + + if (kind === "profile") { + body.innerHTML = ` +

${t("profile.title")}

+

${t("profile.lead")}

+

${t("profile.body")}

+
+ ${["he", "she", "they"].map((p) => ``).join("")} +
+

${t("profile.note")}

`; + } else if (kind === "idle") { + body.innerHTML = `

${t("inbox.idle")}

`; + } else if (kind === "over") { + body.innerHTML = `

${t("inbox.overTitle")}

${t("inbox.overBody")}

`; + } else { + body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state); + } + } + + return { update, destroy: () => document.removeEventListener("keydown", onKey) }; +} diff --git a/web/js/ui/desk.js b/web/js/ui/desk.js new file mode 100644 index 0000000..147aef1 --- /dev/null +++ b/web/js/ui/desk.js @@ -0,0 +1,18 @@ +// Desk-level helpers shared by the paper panels. + +export function hash(str) { + let h = 2166136261; + for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 16777619); + return h >>> 0; +} + +// Give a paper a stable slight rotation derived from its id. +export function tilt(el, id, amp = 0.8) { + const r = ((hash(id) % 1000) / 1000 - 0.5) * 2 * amp; + el.style.setProperty("--rot", `${r.toFixed(2)}deg`); +} + +export const $ = (root, sel) => root.querySelector(sel); + +// Escape text for use inside innerHTML templates. +export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); diff --git a/web/js/ui/endgame.js b/web/js/ui/endgame.js new file mode 100644 index 0000000..1c6166c --- /dev/null +++ b/web/js/ui/endgame.js @@ -0,0 +1,118 @@ +// End of the game: a retrospective front page. It covers both the normal end of +// the term and each way of losing power early. + +import { t, tx, tp, tt, getLang, dateParts, fmt } from "../i18n/i18n.js"; +import { buildReview, dateOfDay, indexCards } from "../engine/index.js"; +import { lineChart } from "./charts.js"; +import { $, esc } from "./desk.js"; + +const MIN_SPAN = 30; + +// series id in state.history, label key, show the term average or the final value +const CHARTS = [ + ["opinion", "ind.opinion", "avg"], + ["stability", "ind.stability", "avg"], + ["economy", "ind.economy", "avg"], + ["debtRatio", "econ.debt", "final"], +]; + +export function createEndgame(root, { newsName, endgame, cards, onAgain }) { + const cardsById = indexCards(cards); + + root.addEventListener("click", (e) => { + if (e.target.closest("button[data-again]")) onAgain(); + }); + + let shown = ""; + + function chart(state, [id, label, mode], review) { + const series = state.history.series[id] ?? []; + const value = mode === "avg" ? review.metrics[id] : series.at(-1); + const tag = mode === "avg" ? t("end.averages") : t("end.final"); + + // Zoom on the data (at least MIN_SPAN points tall) so trends are visible; stay within 0-100 + // for indicators, and above 0 for debt. + const lo = Math.min(...series); + const hi = Math.max(...series); + const span = Math.max(hi - lo, MIN_SPAN); + let min = Math.max(0, (lo + hi) / 2 - span / 2); + let max = min + span; + if (id !== "debtRatio" && max > 100) { + max = 100; + min = 100 - span; + } + return ` +
+
${t(label)}${esc(fmt(value, 0))}${tag}
+ ${lineChart(series, { min, max })} +
`; + } + + function decision(d) { + const card = cardsById.get(d.id); + const date = dateParts(dateOfDay(root.dataset.startDate, d.day).iso); + return ` +
  • + ${esc(date.day)} ${esc(date.month)} ${esc(date.year)} + ${esc(tx(card.title))} + ${esc(t("end.chose", { label: tx(card[d.choice].label) }))} +
  • `; + } + + function update(state, bal) { + if (!state.over) { + if (shown) { + root.hidden = true; + root.innerHTML = ""; + shown = ""; + } + return; + } + const key = `${state.over.type}:${state.over.day}:${getLang()}`; + if (key === shown) return; + shown = key; + root.dataset.startDate = state.startDate; + + const review = buildReview(state, bal, cards, endgame); + const banner = endgame.over[state.over.type]; + const entry = endgame.titles.find((x) => x.id === review.titleId); + const date = dateParts(dateOfDay(state.startDate, state.over.day).iso); + const lost = state.over.type !== "term"; + + root.innerHTML = ` +
    +
    +

    ${esc(tx(newsName))}

    +
    ${esc(t("news.edition", { date: date.full }))}
    +
    +
    +

    ${esc(tx(banner.h))}

    +

    ${esc(tx(banner.d))}

    +
    +
    +
    +

    ${t("end.verdict")}

    +
    + ${esc(review.grade)} + ${t("end.grade")} +
    +

    ${entry ? esc(tt(entry.title, state.pronouns)) : ""}

    +

    ${entry ? esc(tp(tx(entry.verdict), state.pronouns)) : ""}

    +

    ${t("end.score", { n: Math.round(review.score) })}
    ${t("end.served", { days: state.over.day })}

    +
    +
    ${CHARTS.map((c) => chart(state, c, review)).join("")}
    +
    +

    ${t("end.decisions")}

    + ${review.topDecisions.length + ? `
      ${review.topDecisions.map(decision).join("")}
    ` + : `

    ${t("end.noDecisions")}

    `} +
    +
    +
    +
    `; + root.hidden = false; + $(root, "button[data-again]").focus(); + } + + return { update }; +} diff --git a/web/js/ui/ledger.js b/web/js/ui/ledger.js new file mode 100644 index 0000000..e13d2c9 --- /dev/null +++ b/web/js/ui/ledger.js @@ -0,0 +1,213 @@ +// Quarterly budget ledger: a two-page accounting book. The left page sets sector +// budgets, the right page sets tax rates and shows the Treasury summary and a +// four-quarter projection. Nothing is applied until the player signs. + +import { t, fmt, getLang } from "../i18n/i18n.js"; +import { + previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS, +} from "../engine/index.js"; +import { $ } from "./desk.js"; + +const ALLOC_STEP = 0.05; +const TAX_STEP = 0.5; + +// Projection rows: label key, field on the summary, digits, suffix +const PROJ_ROWS = [ + ["econ.debt", (p) => p.debtRatio, 1, ""], + ["econ.deficit", (p) => p.deficit, 1, ""], + ["econ.growth", (p) => p.growth, 1, ""], + ["econ.unemployment", (p) => p.unemployment, 1, ""], + ["ind.opinion", (p) => p.indicators.opinion, 0, ""], + ["ind.stability", (p) => p.indicators.stability, 0, ""], + ["ind.economy", (p) => p.indicators.economy, 0, ""], +]; + +export function createLedger(root, scenario, { onConfirm }) { + let openKey = null; + let ctx = null; // { state, bal } + let draft = null; + let base = null; + let queued = false; + + const sectorIds = (state) => Object.keys(state.sectors); + + function open(state, bal) { + ctx = { state, bal }; + base = { + alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])), + taxes: { ...state.economy.taxes }, + }; + draft = structuredClone(base); + + const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio); + const sectorRows = sectorIds(state).map((id) => { + const s = state.sectors[id]; + const max = Math.max(s.need * allocMax, s.alloc); + return ` +
    + + + + + + + +
    `; + }).join(""); + + const taxRows = TAX_IDS.map((id) => { + const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak; + return ` +
    + + + + + + + +
    `; + }).join(""); + + root.innerHTML = ` +
    +
    +

    ${t("ledger.title")}

    +

    ${t("ledger.spending")}. ${t("ledger.spendingHint")}

    +
    ${sectorRows}
    +
    ${t("ledger.totalAlloc")}
    +
    +
    +

    ${t("ledger.revenue")}

    +

    ${t("ledger.revenueHint")}

    +
    ${taxRows}
    +

    ${t("ledger.summary")}

    +
    +

    ${t("ledger.projection")}

    +
    +
    + + +
    + +
    +
    `; + root.hidden = false; + refresh(); + } + + function close() { + root.hidden = true; + root.innerHTML = ""; + openKey = null; + ctx = null; + } + + // Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame. + function refresh() { + const { state, bal } = ctx; + let total = 0; + for (const id of sectorIds(state)) { + const s = state.sectors[id]; + const v = draft.alloc[id]; + total += v; + const row = $(root, `[data-sector="${id}"]`); + $(row, ".lrow__val").textContent = fmt(v, 2); + const target = qualityFromRatio(v / s.need, bal); + const diff = target - s.quality; + const q = $(row, ".lrow__q"); + q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "="; + q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`; + q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) }); + row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9); + } + $(root, "[data-total]").textContent = `${fmt(total, 2)} %`; + + for (const id of TAX_IDS) { + const row = $(root, `[data-tax="${id}"]`); + $(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`; + $(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) }); + row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9); + } + + if (!queued) { + queued = true; + requestAnimationFrame(() => { + queued = false; + if (ctx) renderPreview(); + }); + } + } + + function renderPreview() { + const { state, bal } = ctx; + const pv = previewBudget(state, bal, draft); + if (!pv.ok) return; + const f = pv.fiscal; + const gdp = state.economy.gdp; + const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1); + const balance = -f.deficit; // positive = surplus + + $(root, "[data-summary]").innerHTML = ` +
    ${t("ledger.sumRevenue")}
    ${fmt(f.revenue, 1)} %
    +
    ${t("ledger.sumSectors")}
    ${fmt(f.allocTotal, 1)} %
    +
    ${t("ledger.sumInterest")}
    ${fmt(f.interest, 1)} %
    +
    ${t("ledger.sumSpending")}
    ${fmt(f.spending, 1)} %
    +
    ${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}
    +
    ${fmt(Math.abs(balance), 1)} % + ${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}
    `; + + const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 })); + $(root, ".proj thead").innerHTML = `${pv.points.map((_, i) => `${months(i)}`).join("")}`; + $(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) => + `${t(label)}${pv.points.map((p) => `${fmt(get(p), digits)}${suffix}`).join("")}`).join(""); + } + + function syncSliders() { + for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id]; + for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id]; + } + + root.addEventListener("input", (e) => { + const input = e.target; + if (!ctx || input.type !== "range") return; + const v = Number(input.value); + if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v; + else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v; + refresh(); + }); + + root.addEventListener("click", (e) => { + const b = e.target.closest("button[data-act]"); + if (!b || !ctx) return; + if (b.dataset.act === "reset") { + draft = structuredClone(base); + syncSliders(); + refresh(); + } else { + const { state, bal } = ctx; + const res = setBudget(state, bal, draft); + if (!res.ok) { + $(root, "[data-error]").hidden = false; + return; + } + confirmBudget(state); + close(); + onConfirm(); + } + }); + + function update(state, bal) { + if (!(state.pending.review && !state.pending.profile && !state.over)) { + if (openKey !== null) close(); + return; + } + const key = `${state.day}:${getLang()}`; + if (key !== openKey) { + openKey = key; + open(state, bal); + } + } + + return { update }; +} diff --git a/web/js/ui/menu.js b/web/js/ui/menu.js new file mode 100644 index 0000000..53ffbaf --- /dev/null +++ b/web/js/ui/menu.js @@ -0,0 +1,369 @@ +// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time. +// Modes: "main" (before a game) and "game" (pause menu while a game is running). +// The menu never changes the game itself; it hands results to main.js through `handlers`. + +import { t, tx, getLang, dateParts } from "../i18n/i18n.js"; +import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js"; +import { encodeSave, decodeSave, migrate } from "../save/codec.js"; +import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js"; +import { $, esc } from "./desk.js"; + +const ARM_MS = 4000; // how long a "Sure?" button stays armed + +export function createMenu(root, { content, storage, getState, getSettings, handlers }) { + let mode = "main"; + let screen = "home"; + let message = null; // { text, kind: "ok" | "error" } + let armed = null; // "action:arg" waiting for a second click + let armTimer = 0; + let exportText = ""; + + const say = (text, kind = "ok") => { message = { text, kind }; }; + const go = (next) => { screen = next; message = null; disarm(); render(); }; + + function disarm() { + armed = null; + clearTimeout(armTimer); + } + + function arm(key) { + armed = key; + clearTimeout(armTimer); + armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS); + render(); + } + + // ---- Reading saves ---- + + const autosave = () => readSave(storage, KEYS.auto); + + function describe(entry) { + const scenario = content.scenarios[entry.meta.scenarioId]; + let date = ""; + if (scenario) { + const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso); + date = ` · ${p.day} ${p.month} ${p.year}`; + } + const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt)); + const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date }); + return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}${esc(saved)}`; + } + + // Turns a stored or imported state into a playable one, or explains why not. + function restore(rawState) { + const migrated = migrate(rawState); + if (!migrated.ok) return migrated; + return restoreState(migrated.state, content); + } + + function loadEntry(entry) { + const r = entry ? restore(entry.state) : { ok: false, error: "invalid" }; + if (!r.ok) { + say(t("load.unreadable"), "error"); + render(); + return; + } + handlers.onLoadState(r.state); + } + + // ---- Screens ---- + + const button = (action, label, extra = "", cls = "") => + ``; + const back = () => `
    ${button("back", t("menu.back"))}
    `; + const notice = () => (message ? `` : ""); + + function home() { + const state = getState(); + if (mode === "game") { + return ` +

    ${t("menu.paused")}

    + + `; + } + const auto = autosave(); + const canContinue = auto && !auto.meta.over; + return ` +

    ${t("app.title")}

    + + ${notice()} + `; + } + + function newGame() { + const ids = Object.keys(content.scenarios); + const first = content.scenarios[ids[0]]; + return ` +

    ${t("new.title")}

    + + ${ids.length > 1 + ? `` + : ``} + + + ${back()}`; + } + + function slotRow(label, entry, actions) { + return ` + `; + } + + const confirmable = (action, arg, label, enabled = true) => { + const isArmed = armed === `${action}:${arg}`; + return ``; + }; + + function load() { + const rows = []; + const auto = autosave(); + rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : "")); + for (let n = 1; n <= SLOT_COUNT; n++) { + const entry = readSave(storage, KEYS.slot(n)); + rows.push(slotRow(t("load.slot", { n }), entry, + entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : "")); + } + return ` +

    ${t("load.title")}

    + ${mode === "game" ? `` : ""} + ${notice()} + + ${back()}`; + } + + function save() { + const rows = []; + for (let n = 1; n <= SLOT_COUNT; n++) { + const entry = readSave(storage, KEYS.slot(n)); + rows.push(slotRow(t("load.slot", { n }), entry, + confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here")))); + } + return ` +

    ${t("save.title")}

    + ${notice()} + + ${back()}`; + } + + function exportScreen() { + return ` +

    ${t("export.title")}

    + + + ${notice()} + + ${back()}`; + } + + function importScreen() { + return ` +

    ${t("import.title")}

    + + + ${notice()} + + ${back()}`; + } + + function settings() { + const s = getSettings(); + const lang = getLang(); + return ` +

    ${t("settings.title")}

    + + + ${storage.persistent ? "" : ``} + ${back()}`; + } + + function credits() { + return ` +

    ${t("credits.title")}

    +

    ${t("credits.fiction")}

    +

    ${t("credits.tech")}

    +

    ${t("credits.fonts")}

    + ${back()}`; + } + + const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits }; + + function render() { + root.innerHTML = ` + `; + } + + // ---- Actions ---- + + async function copyExport() { + const box = $(root, "#export-text"); + try { + await navigator.clipboard.writeText(exportText); + say(t("export.copied")); + } catch { + box.select(); + say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok"); + } + render(); + } + + function download() { + const state = getState(); + const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game"; + const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = `${slug}-day${state.day}.sav`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function doImport() { + const text = $(root, "#import-text").value; + if (!text.trim()) { + say(t("import.error.empty"), "error"); + render(); + return; + } + const decoded = decodeSave(text); + const r = decoded.ok ? restoreState(decoded.state, content) : decoded; + if (!r.ok) { + say(t("import.error." + r.error), "error"); + const keep = text; + render(); + $(root, "#import-text").value = keep; + return; + } + handlers.onLoadState(r.state); + } + + function start() { + const id = $(root, "#new-scenario").value; + const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they"; + const name = $(root, "#new-name").value.trim(); + handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns }); + } + + const ACTIONS = { + continue: () => loadEntry(autosave()), + new: () => go("new"), + load: () => go("load"), + save: () => go("save"), + export: () => { exportText = encodeSave(getState()); go("export"); }, + import: () => go("import"), + settings: () => go("settings"), + credits: () => go("credits"), + back: () => go("home"), + resume: () => handlers.onResume(), + quit: () => handlers.onQuit(), + start, + copy: copyExport, + download, + doImport, + lang: (arg) => { handlers.onSettings({ lang: arg }); render(); }, + loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))), + deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); }, + saveSlot: (arg) => { + const ok = writeSave(storage, KEYS.slot(arg), getState()); + disarm(); + say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error"); + render(); + }, + }; + // Destructive actions need a second click on the same button. + const NEEDS_CONFIRM = { + loadEntry: () => mode === "game", + deleteSlot: () => true, + saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null, + }; + + root.addEventListener("click", (e) => { + const el = e.target.closest("[data-action]"); + if (!el || el.disabled) return; + const { action, arg } = el.dataset; + const key = `${action}:${arg}`; + if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) { + arm(key); + return; + } + disarm(); + ACTIONS[action](arg); + }); + + root.addEventListener("change", async (e) => { + if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked }); + if (e.target.id === "import-file" && e.target.files[0]) { + $(root, "#import-text").value = await e.target.files[0].text(); + } + }); + + document.addEventListener("keydown", (e) => { + if (root.hidden || e.key !== "Escape") return; + if (screen !== "home") go("home"); + else if (mode === "game") handlers.onResume(); + }); + + return { + show(nextMode, nextScreen = "home") { + mode = nextMode; + screen = nextScreen; + message = null; + disarm(); + render(); + root.hidden = false; + }, + hide() { + root.hidden = true; + root.innerHTML = ""; + disarm(); + }, + // Re-render after the language changed. + refresh() { + if (!root.hidden) render(); + }, + }; +} diff --git a/web/js/ui/newspaper.js b/web/js/ui/newspaper.js new file mode 100644 index 0000000..ec4af6c --- /dev/null +++ b/web/js/ui/newspaper.js @@ -0,0 +1,96 @@ +// Daily newspaper: headlines picked from templates by how much the indicators +// moved over the last few weeks. Selection is deterministic (hash of week + key) +// and never touches the game RNG, so reading the paper can't change the game. + +import { t, tx, getLang, dateParts } from "../i18n/i18n.js"; +import { IND_IDS, dateOfDay } from "../engine/index.js"; +import { hash, $ } from "./desk.js"; + +const IND_THRESHOLD = 4; // points moved over the window to make news +const VAR_THRESHOLD = { growth: 0.4, unemployment: 0.4, inflation: 0.5, debtRatio: 3 }; + +// Each entry: [template key, test on the live state, severity score] +const LEVELS = [ + ["unrest.high", (s, f) => s.indicators.unrest >= 50, 1.7], + ["opinion.low", (s, f) => s.indicators.opinion <= 30, 1.6], + ["debt.high", (s, f) => f.debtRatio >= 100, 1.6], + ["stability.low", (s, f) => s.indicators.stability <= 35, 1.6], + ["civilRights.low", (s, f) => s.indicators.civilRights <= 25, 1.5], + ["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7], + ["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5], + ["deficit.high", (s, f) => f.deficit >= 8, 1.5], +]; + +function candidates(state, tpl) { + const out = []; + const h = state.history; + const n = h.t.length; + if (n >= 2) { + const back = Math.min(4, n - 1); + const consider = (group, id, threshold) => { + const s = h.series[id]; + const d = s[n - 1] - s[n - 1 - back]; + const dir = d > 0 ? "up" : "down"; + const tpls = tpl[group][id]?.[dir]; + if (tpls && Math.abs(d) / threshold >= 1) out.push({ score: Math.abs(d) / threshold, key: `${group}.${id}.${dir}`, tpls }); + }; + for (const id of IND_IDS) consider("ind", id, IND_THRESHOLD); + for (const [id, thr] of Object.entries(VAR_THRESHOLD)) consider("var", id, thr); + } + const debtRatio = h.series.debtRatio?.[n - 1] ?? 0; + const deficit = h.series.deficit?.[n - 1] ?? 0; + for (const [key, test, score] of LEVELS) { + if (tpl.level[key] && test(state, { debtRatio, deficit })) out.push({ score, key: `level.${key}`, tpls: tpl.level[key] }); + } + return out.sort((a, b) => b.score - a.score); +} + +export function computeHeadlines(state, tpl) { + const week = Math.floor(state.day / 7); + const pick = (tpls, key) => tpls[hash(`${key}:${week}`) % tpls.length]; + + if (state.history.t.length < 2 && state.day === 0) return [pick(tpl.intro, "intro")]; + const found = candidates(state, tpl); + if (!found.length) return [pick(tpl.quiet, "quiet")]; + return found.slice(0, 3).map((c) => pick(c.tpls, c.key)); +} + +export function createNewspaper(root, tpl) { + root.innerHTML = ` +
    +

    ${tx(tpl.name)}

    +
    +
    +

    +
    `; + + let shown = ""; + + function update(state) { + // The date line follows the calendar; headlines only change with the weekly history sample. + $(root, ".news__edition").textContent = t("news.edition", { date: dateParts(dateOfDay(state.startDate, state.day).iso).full }); + + const week = Math.floor(state.day / 7); + const key = `${week}|${getLang()}|${state.history.t.length}`; + if (key === shown) return; + shown = key; + + const fill = (s) => s.replaceAll("{country}", state.countryName); + const [lead, ...rest] = computeHeadlines(state, tpl); + $(root, ".news__lead h3").textContent = fill(tx(lead.h)); + $(root, ".news__lead p").textContent = fill(tx(lead.d)); + + const side = $(root, ".news__side"); + side.replaceChildren(...rest.map((item) => { + const a = document.createElement("article"); + const h = document.createElement("h4"); + const p = document.createElement("p"); + h.textContent = fill(tx(item.h)); + p.textContent = fill(tx(item.d)); + a.append(h, p); + return a; + })); + } + + return { update }; +} diff --git a/web/js/ui/telegram.js b/web/js/ui/telegram.js new file mode 100644 index 0000000..1bb6e35 --- /dev/null +++ b/web/js/ui/telegram.js @@ -0,0 +1,41 @@ +// Urgent telegrams: the game warns that a way of losing power is getting close. +// They slide in over the desk and time stays stopped until the player acknowledges. + +import { t, tx } from "../i18n/i18n.js"; +import { $, esc } from "./desk.js"; + +export function createTelegram(root, { alerts, onDismiss }) { + root.addEventListener("click", (e) => { + if (e.target.closest("button[data-dismiss]")) onDismiss(); + }); + + let shown = ""; + + function update(state) { + const alert = state.pending.alert && !state.over ? state.alerts[0] : null; + const key = alert ? `${alert.kind}:${alert.day}` : ""; + if (key === shown) return; + shown = key; + + if (!alert) { + root.hidden = true; + root.innerHTML = ""; + return; + } + const text = alerts[alert.kind]; + root.innerHTML = ` +
    +
    + ${t("alert.urgent")} + ${state.alerts.length > 1 ? `+${state.alerts.length - 1}` : ""} +
    +

    ${esc(tx(text.title))}

    +

    ${esc(tx(text.body))}

    + +
    `; + root.hidden = false; + $(root, "button").focus(); + } + + return { update }; +}