feat: Doing more polishing (M8b)

This commit is contained in:
gauvainboiche
2026-10-25 18:50:17 +01:00
parent c2103c32e7
commit c55e5a2484
34 changed files with 1605 additions and 106 deletions
+197
View File
@@ -0,0 +1,197 @@
# Writing scenarios
Guide for anyone (human or model) adding a new fictional country or situation to "The Desk". A scenario is one JSON file
that sets the start state; the rules, the card pool, the newspaper and the endings are shared. Read `docs/BALANCE.md` and
`docs/CARD-WRITING.md` first. Reference scenario: `web/data/scenarios/default.json` (Valdoria).
## 1. What a scenario can change
`createGame` (`web/js/engine/game.js`) reads these fields, and nothing else:
| Field | What it does | Where it shows |
|---|---|---|
| `id` | key of the scenario, saved in every save | file name, `index.json`, saves |
| `name` `{en, fr}` | default country name (the player may type another) | menu list, save slots, newspaper `{country}`, export file name |
| `currency` | text only | "bn VLD" on the dashboard and the ledger |
| `startDate` | `YYYY-MM-DD`, day 0 | calendar, card dates, end screen |
| `economy` | gdp, debtRatio, growth, unemployment, inflation, otherRevenue, the three start tax rates | everything fiscal |
| `indicators` | the 9 start values (0 to 100) | dashboard, and the equilibrium (section 3) |
| `sectors` | `need` and `alloc` (% of GDP) for the 15 sectors | ledger, quality, cost of every lever |
**What a scenario cannot change without engine work.** All of it lives in shared files:
- Tax structure: the three taxes, their yield `coef`, Laffer `peak`, opinion and growth effects (`balance.json` `tax`). A scenario only picks start rates and `otherRevenue`.
- The 15 sectors (all must be present, and `balance.json` `sectorTau` / `needGrowth` must match), the 9 indicators, the 3 economy variables.
- How things react: indicator weights, drift, gain, world cycle, game-over thresholds, score formula, `needGrowth`.
- Characters (`characters.json`: fixed names and roles), the newspaper (`newspaper.json`, called "The Daily Herald"), endings and historian titles (`endgame.json`), the length of the term (1826 days).
- **Card text.** About 140 mentions of "Valdoria / Valdorian" (both languages) sit in card text, written literally; card bodies are not run through `{country}` (only the newspaper intro is). A new country therefore plays with Valdorian cards. Write the premise so this is tolerable (a sister state, a Valdorian region or era), or ask for `{country}` substitution in cards as engine work. Scenario-specific cards (section 5) may name the country directly.
- A scenario `description` or premise: the menu only reads `name`.
What really makes a scenario feel different, given shared rules:
1. **Where the start sits.** Indicators move toward `start value + weighted deviations`, so a country starting at opinion 35 stays near 35 until the player acts.
2. **Need versus alloc.** Quality is `100 * (1 - exp(-0.9 * alloc/need))`; the price of one more point of ratio is `need` % of GDP, so a sector with a small need is a cheap lever and a big need is an expensive one.
3. **The fiscal squeeze:** debt interest and taxes already close to their Laffer peak leave little room to move.
4. **Scenario cards** that stage the central tension.
## 2. Design brief (fill in before writing numbers)
Write this in the scenario's pull request or notes; an agent that cannot fill every line is not ready to write JSON.
| Question | Example answer |
|---|---|
| Identity: country, era, one-sentence premise | "A sugar island that borrowed to survive a hurricane." |
| Central tension: the two things the player cannot have together | "pay the interest or keep the clinics open" |
| Temptation: the easy move that backfires | "print growth with debt, or sell the ports" |
| First-year crisis the player will feel by week 8 | "bond auction in month 3" |
| Difficulty profile | tighter / same as default / easier, and which of the standard player types should change band (section 6) |
| Levers that are cheap and expensive (needs) | health need 9 (expensive), police need 0.8 (cheap) |
| Which indicators start high, which low, which near a danger zone | "unrest 44, but opinion 55: no zone" |
| Cards: which situations need a scenario-only card | 15 to 40 cards, 3 to 5 chains |
Use fiction only: no real country, person, party or brand (same rule as the cards). Prefer neighbours from the existing pool (Torvania, Marenia, Belmora, Kessaly, Draveth).
## 3. The scenario file
```json
{
"id": "saltmere",
"name": { "en": "Free State of Saltmere", "fr": "État libre de Saltmère" },
"startDate": "2028-01-03",
"currency": "SLM",
"economy": { "gdp": 22, "debtRatio": 100, "growth": 1.5, "unemployment": 9, "inflation": 3,
"otherRevenue": 7, "taxes": { "income": 38, "corporate": 28, "vat": 26 } },
"indicators": { "order": 60, "stability": 50, "civilRights": 66, "opinion": 48, "economy": 45,
"hardPower": 20, "softPower": 40, "unrest": 28, "armyLoyalty": 62 },
"sectors": { "education": { "need": 4.5, "alloc": 4.0 }, "...": "all 15 sector ids, see default.json" }
}
```
| Field | Unit | Sane range | Notes |
|---|---|---|---|
| `id` | lowercase letters and digits | | must equal the file name; avoid `_` (it is the card id prefix); never change it after release |
| `startDate` | ISO date | any | pick a **Monday**: cards arrive every 7th day from day 0 and the calendar shows weekdays. Never change after release (saves refuse a different date) |
| `gdp` | bn of currency | 15 to 2000 | display and absolute debt only; must be above 0 |
| `debtRatio` | % of GDP | 15 to 105 | see the interest table below; the engine warns at 135 and defaults at 165 |
| `growth` | % per year | -1 to 5 | clamped to -15..12 by the engine |
| `unemployment` / `inflation` | % | 2 to 15 / 0 to 12 | clamped 1..40 / -2..40 |
| `otherRevenue` | % of GDP | 3 to 24 | royalties, fees, aid; the main "resource state" knob |
| `taxes.income / corporate / vat` | rate in % | 15 to 45 / 10 to 40 / 10 to 30 | stay under about 75 % of the peaks 60 / 50 / 40, or the player cannot raise them |
| `indicators.*` | 0 to 100 | 15 to 85 | all 9 keys required; keep `unrest` 10 to 45, `armyLoyalty` 40 to 85 |
| `sectors.*.need` | % of GDP | 0.3 to 14 | greater than 0; the 15 ids as in `default.json` |
| `sectors.*.alloc` | % of GDP | 0.5 to 1.5 times need | engine accepts 0 to 3 times need |
### The equilibrium rule
`createGame` stores the start in `state.ref` (taxes, indicators, economy variables, sector quality, budget ratios, and
the fiscal deviations). Every target is `ref + gain * weight * (change since the start)`, so **the start is an exact
equilibrium whatever numbers you choose**: the start deficit is not punished, and a high start inflation does not hurt by
itself. A hands-off year still slides (default: order -5, stability -6, opinion -5, economy -6, unrest +8, army loyalty -5)
because of the yearly `drift` and growing needs; that slide is the intended pressure.
Some rules read **absolute** levels, so the start must respect them:
- **Interest and default.** Rate = 3 + 0.0008 * (debtRatio - 40)^2 %, interest = ratio * rate. Default at 165, warning at 135, and the budget must pay the interest every year:
| debt / GDP | 40 | 58 | 70 | 80 | 90 | 100 | 110 | 120 | 135 |
|---|---|---|---|---|---|---|---|---|---|
| interest, % of GDP | 1.2 | 1.9 | 2.6 | 3.4 | 4.5 | 5.9 | 7.6 | 9.7 | 13.8 |
Industry quality also loses 0.25 per point of debt above 90.
- **Game-over zones**: revolution (unrest above 62 with opinion below 28), coup (army loyalty under 35 with stability under 50, or loyalty under 18), impeachment (civil rights under 10 with opinion under 25, 60 days). Start at least 15 points away from each.
- **Quality shaping.** Excess terms start at sector quality 70 (ratio 1.34) and short terms at 40 (ratio 0.57): outside that band the target is offset from the start. Keep ratios in 0.6 to 1.3 for health, social, housing, industry, infrastructure, education, research, police, justice, culture, foreign affairs.
- **Score**: debt (ref 60), average growth (ref 2) and unemployment (ref 6.5) are absolute, so a high-debt or slow start scores lower (debt 100 costs 8 points). This is why a scenario may need its own bands (section 6).
- Cards gated on `debt` (for example bond panic at 100, rating warning at 62, lenders at 105) fire from day 0 if the start is above them.
### The start fiscal picture
All flows are % of GDP per year (`economy.js`):
- Tax revenue = `coef * rate * (1 - rate / (2 * peak))`, with `coef` 0.5 / 0.25 / 0.55 and `peak` 60 / 50 / 40 for income / corporate / VAT. Revenue peaks at the peak rate (15 / 6.25 / 11 % of GDP); at the default rates (half the peak) one extra point yields 0.25 / 0.125 / 0.275.
- Raising a rate by d points costs opinion (`0.5 / 0.15 / 0.6` times d, then times -1.2 on the opinion target) and growth (`0.03 / 0.06 / 0.025` times d).
- `revenue = otherRevenue + sum of the three taxes`; `spending = sum of sector alloc + interest`; `deficit = spending - revenue`.
- Default: revenue 35.69, sectors 34.30, interest 1.89, deficit 0.50.
- **Aim for a start deficit between -1 and +1.5.** The debt ratio holds when deficit is about `growth * debtRatio / 100` (1.2 for the default), so a start above 2 spirals. Fix a gap by moving `otherRevenue`, tax rates, or sector `alloc`, never by changing a `need` (needs set prices, not budgets).
- Order of work: pick debt and taxes, compute revenue and interest, then split the remaining budget over the 15 sectors.
## 4. Registering the scenario
1. Save `web/data/scenarios/<id>.json`.
2. Add the id to `web/data/scenarios/index.json` (`"scenarios": ["default", "<id>"]`). Keep `default` first: it is the pre-selected scenario, the name field's placeholder, and the fallback.
3. Both `name.en` and `name.fr` are required (`TestScenarioIndex`). The menu shows the name only: the New game screen has a scenario `<select>` once there are two or more, and a blank country name field falls back to the chosen scenario's name in the current language. Names must be 40 characters or fewer (the field is `maxlength 40`).
4. `web/embed.go` embeds the `data` folder, so no code change is needed; rebuild the Docker image (`docker compose up --build`) to ship it.
5. Do not edit `balance.json`, the engine or other scenarios to make a scenario work. Changing numbers of a released scenario is safe (saves keep their own copy); changing `id`, `startDate` or the sector list breaks saves.
## 5. Scenario-specific cards (new feature)
Most of the pool is shared. A scenario can add cards visible only in that scenario through an optional card field:
```json
{ "id": "saltmere_eco_hurricane_bonds_01", "category": "economy", "scenarios": ["saltmere"], "...": "same schema as CARD-WRITING.md" }
```
- `"scenarios": ["id", ...]` is a **whitelist**. A card without the field is available in every scenario. There is no blacklist, so shared cards stay shared.
- `go test ./server` validates it: a non-empty array of strings, each listed in `scenarios/index.json`.
- `node tools/add-cards.mjs <category> <batch.json>` keeps the field when it merges a batch.
- **Id prefix** `<scenarioId>_`, then the usual `<cat>_<slug>_<NN>`, for example `saltmere_eco_hurricane_bonds_01`. Ids stay unique across all files. Same category files and same schema, effects, size guide and voice as `docs/CARD-WRITING.md`.
- Keep chains closed: a card and its follow-ups carry the same whitelist, and a shared card never follows up to a scenario card. Flags read by a scenario card must be set by a card available in that scenario.
- Use conditions on the situation (`min: {"debt": 90}`, `ratio:army`, `dayMin`) so the cards appear where the story says. The scenario's tension cards may weigh 1.5.
- Naming the country and its places is fine here (shared cards cannot do it). Both languages, no pronouns for people.
- Budget 15 to 40 cards: about 5 available from the start, the rest spread over the term, and 3 to 5 chains. Run `node tools/card-stats.mjs` on the files you touched.
## 6. Validation
| Command | Checks |
|---|---|
| `go test ./server` | scenario index (id equals the file name, localized name, listed and present), card schema including `scenarios`, i18n. **The economy, sectors, indicators and taxes of a scenario other than `default` are not covered by Go tests**: use the tools below. |
| `node tools/engine-check.mjs` | engine rules (uses `default.json`) |
| `node tools/scenario-check.mjs <id>` (new) | prints the start fiscal picture (revenue, sector spending, interest, deficit, debt) and a one-year hands-off drift; warns when the start is far from balanced. The draft warns for a deficit above 2, debt above 110, sector ratios outside 0.5 to 1.5, indicators outside 5 to 95, and exits with 1 on any warning. |
| `node tools/balance-check.mjs 24 --check --scenario <id>` (new) | plays the scenario with the standard player types and compares them to the bands in `balance-check.mjs` (about 30 s) |
Compare the hands-off drift with the default (about -5 on opinion, stability and economy, +8 unrest in a year). A scenario that
collapses or ignores a year of inaction was built on the wrong numbers.
**Own bands.** A scenario that is deliberately harder or easier ships `web/data/scenarios/<id>.targets.json`, same shape as
`TARGETS` and merged over the defaults:
```json
{ "notes": "Debt 100 costs 8 score points and interest eats 6 % of GDP, so scores sit about 8 lower and spendMild is fatal.",
"targets": { "plannerHuman": { "survive": [85, 100], "score": [48, 60], "top": [0, 40] }, "spendMild": { "survive": [0, 60] } } }
```
`notes` must say why the bands differ. Move only the bands that the premise explains; the extremes (`ruin`, `starve`,
`tyrant`) must still end in a fall, and `plannerHuman` must still survive. Do not retune `balance.json` to rescue a scenario.
## 7. Scenario ideas
Ranges are starting points. The fiscal columns were checked by hand against the formulas (a midpoint gives a deficit
near +0.5); run `scenario-check` on the real values.
| Idea | Premise | Start ranges | What makes it different |
|---|---|---|---|
| Debt-ridden island (`saltmere`) | A tourism and sugar island borrowed to rebuild after a hurricane; lenders now watch every ledger. | debt 95-105, gdp 15-40, growth 1-2, otherRevenue 6-9, taxes near 65 % of peak (38 / 28 / 26), sectors total 28-30, interest 5.5-6.5 | Interest takes 6 % of GDP, so every service is squeezed and tax hikes hit the Laffer wall early; the pull is to borrow once more. Bands: lower scores, survival of `spendMild` near 0. |
| Resource-rich, weak army (`karhaven`) | A gas exporter whose royalties fund the state while the army is a parade force next to hostile neighbours. | otherRevenue 18-24, income 12-18, corporate 15-22, vat 10-14, debt 25-40, growth 3-5, sectors total 33-38; army need 3-4 with alloc 1.2-1.6, armyLoyalty 40-50, hardPower 20-30 | Cheap taxes and a large windfall make spending easy; the danger is the army and a royalty crash (scenario cards with `revenue: other` cuts). Coup risk is the real threat. |
| Post-war republic (`ashford`) | A republic after a short civil war, with an army that is loyal but restless about pay and demobilisation. | debt 70-90, growth 3-4, unemployment 10-13, armyLoyalty 70-80, stability 40-48, order 40-50, unrest 33-42, taxes 24 / 20 / 18, otherRevenue 7-9, sectors total 26-29; army need 4 alloc 3.5-4; housing and justice need high, alloc 60-70 % | Loyalty is high but erodes 1.1 points a year (drift), cutting the army is the shortcut to a coup, and reconstruction wants housing and justice first. Check revolution distance (unrest below 47). |
| Rich small state under a big neighbour (`lysmark`) | A prosperous harbour state whose neighbour decides its trade and defence; it has to look independent without a real army. | debt 15-30, growth 1-2, unemployment 3-4.5, taxes 22 / 15 / 14, otherRevenue 9-12, sectors total 26-30, deficit -1 to +0.5; softPower 60-70, hardPower 10-20, opinion 65-72, economy 65-72 | Everything starts high so the drift hurts; foreign affairs and culture are the real levers, defence is nearly impossible. Scenario cards carry the neighbour's pressure. Bands: `top` grades rarer. |
| Ageing welfare state (`aldermoor`) | A cold, orderly republic whose pensions and clinics grow faster than its economy. | debt 70-85, growth 0.5-1.5, unemployment 4-5.5, social need 13-15, health need 8-10, taxes 36 / 24 / 24, otherRevenue 8-10, sectors total 32-35 | Social and health needs are expensive levers and grow every year (`needGrowth`); growth sectors are starved. Tension is pensions versus investment; a slow growth start lowers the score floor (note it). |
## 8. Checklist for the agent
- [ ] Design brief filled in (section 2), fiction only, no real names.
- [ ] `web/data/scenarios/<id>.json` has `id`, `name.en`, `name.fr`, `startDate` (a Monday), `currency`, `economy` (with the 3 taxes), 9 indicators, 15 sectors.
- [ ] Start deficit between -1 and +1.5; debt within the interest table; unrest, army loyalty and opinion 15 points away from the danger zones; sector ratios 0.6 to 1.3 unless the premise says otherwise.
- [ ] Added to `index.json`, `default` still first; names fit 40 characters.
- [ ] Scenario cards (if any): `scenarios` whitelist, `<id>_` prefix, closed chains, both languages, merged with `add-cards.mjs`.
- [ ] `node tools/scenario-check.mjs <id>` clean, and the one-year drift looks like the default's.
- [ ] `node tools/balance-check.mjs 24 --check --scenario <id>` passes, or a `<id>.targets.json` with a `notes` explaining each moved band.
- [ ] `go test ./server` and `node tools/engine-check.mjs` pass.
- [ ] Nothing outside `web/data/scenarios/` and your own card files was edited (no `balance.json`, no code, no tests).
- [ ] Played the first quarter in the browser: menu entry, first review, a week of cards, no immediate warning telegram.
## Assumptions
- The `scenarios` card field, `scenario-check.mjs` and `--scenario` behave as specified; the draft code in `tools/` was read for thresholds and may change.
- The targets file is read as `{ "notes": "...", "targets": { player: {...} } }` (what `balance-check.mjs` reads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep.
- Go tests currently load only `default.json` for economy, sector and card-condition checks.
- Shared card text names Valdoria; the guide treats this as a known limit, not something a scenario can fix.
- Suggested ranges come from reading `balance.json` and the formulas, not from playtesting the ideas.