feat: Cards writings and balancing (M8)
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
# Balance guide (M8)
|
||||||
|
|
||||||
|
How hard the game is, what makes it hard, and how to retune it without breaking the shape.
|
||||||
|
|
||||||
|
## Intended difficulty
|
||||||
|
|
||||||
|
Hard, but a competent player wins; bold play is rewarded up to a point; extremes end in a fall.
|
||||||
|
|
||||||
|
| Player | What it does | Expected result |
|
||||||
|
|---|---|---|
|
||||||
|
| `random` | never touches the budget, coin-flips every card | about 80 % reach the end, grade D |
|
||||||
|
| `randomRescue` | same, but reacts when a danger warning opens the emergency session | about 95 % survive |
|
||||||
|
| `plannerHuman` | balanced plan: growth sectors up, order up late, taxes up a few points; answers cards with 30 % mistakes | survives, grade B (A in about 1 run in 6) |
|
||||||
|
| `plannerOracle` | same plan, reads the real card effects | survives, grade A, S about 1 run in 12 |
|
||||||
|
| `planner05` / `15` / `2` / `3` | the same plan, 0.5x / 1.5x / 2x / 3x as bold | 100 % / 100 % (more A grades) / about 60 % / default every time |
|
||||||
|
| `spendthrift`, `ruin`, `welfare` | borrow to spend | default in most runs |
|
||||||
|
| `starve`, `deepCuts`, `austerity` | cut everything | revolution |
|
||||||
|
| `tyrant`, `policeState`, `militarist` | repression and arms | impeachment, revolution or a bad grade |
|
||||||
|
| `neglectArmy` | army at 10 % | coup |
|
||||||
|
| `austerityMild`, `taxMild`, `policeMild`, `spendMild`, `welfareMild` | milder versions of the above | playable (75 to 100 % survive) |
|
||||||
|
|
||||||
|
The same table, with numeric bands, lives in `TARGETS` in `tools/balance-check.mjs`.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- `node tools/balance-check.mjs [seeds] [player,...]` plays full terms and prints survival, score, grades, endings and final
|
||||||
|
averages. `--check` runs every band in `TARGETS` and exits with an error when one is out of band (24 seeds, about 30 s).
|
||||||
|
`--set path=value` overrides a `balance.json` value in memory: `--set gain=3.4 --set debt.premiumCoef=0.0006`.
|
||||||
|
- `node tools/balance-search.mjs [iterations] [seeds]` hill-climbs the best two-phase budget (early and late multipliers per
|
||||||
|
sector group and per tax). It shows the ceiling: about 63 to 65 with human-like card answers. Its output was the basis for the
|
||||||
|
`planner` player.
|
||||||
|
- `node tools/bal-edit.mjs path=value ...` edits `balance.json` and keeps its compact layout.
|
||||||
|
- `node tools/card-stats.mjs` for the card pool; `node tools/engine-check.mjs` for the engine rules.
|
||||||
|
|
||||||
|
Workflow for a change: edit with `--set` first, read the table, commit the value with `bal-edit`, run `--check`, then
|
||||||
|
`go test ./server` and `node tools/engine-check.mjs`.
|
||||||
|
|
||||||
|
## What makes the game hard (all values in `balance.json`)
|
||||||
|
|
||||||
|
- **Drift** (`indicators.*.drift`, scaled by `driftScale`): each indicator's target slides every year (opinion fades, order and
|
||||||
|
stability erode, unrest creeps up). Standing still loses ground, so passive play slides toward a revolution.
|
||||||
|
- **Needs grow** (`needGrowth`, per sector per year): the tick in the ledger moves; a frozen budget buys less quality.
|
||||||
|
- **World cycle** (`world`): a slow global business cycle plus shocks. It moves growth and inflation, and
|
||||||
|
`fiscal.growthSens` makes receipts follow growth, so a slump becomes a deficit. Newspaper headlines announce it.
|
||||||
|
- **Debt** (`debt.premiumCoef`, `gameOver.default`): the interest premium is steep above 100 % of GDP. Default at 165 %, warning
|
||||||
|
at 135 %. Bond-panic and rating cards fire on the way.
|
||||||
|
- **Gain** (`gain`): how strongly sector quality moves the indicators. High enough that the budget visibly matters.
|
||||||
|
- **Score** (`score.lastYearShare`): the last year counts for half of the average, so a boom bought on credit does not pay.
|
||||||
|
|
||||||
|
## What rewards audacity
|
||||||
|
|
||||||
|
- **Excellence terms** (`indicators.*.excess`, `vars.growth.excess`): extra payoff above a sector quality of 70 to 75 (research,
|
||||||
|
education, infrastructure, industry for growth; health and social for opinion; culture and foreign affairs for soft power).
|
||||||
|
Quality 75 needs a budget of about 1.6x the need, so it is a real commitment. Neglect terms (`short`) punish the opposite.
|
||||||
|
- **Windfall cards** (`cri_wf_*`): fire only when a sector quality is high for long enough.
|
||||||
|
|
||||||
|
## What punishes extremes
|
||||||
|
|
||||||
|
- **Backlash cards** (`cri_bk_*`): fire on the player's own choices, through the condition keys `ratio:<sector>` (budget over need)
|
||||||
|
and `tax:<id>` (rate): police state, arms race, tax strikes, capital flight, rating downgrades, hospital wards closing.
|
||||||
|
- **Game overs**: revolution (unrest above 62 and opinion below 28), coup (army loyalty under 35 with stability under 50, or
|
||||||
|
under 18 alone), impeachment, default. The chance grows each day in the zone (0.004 per day of presence).
|
||||||
|
- **Emergency session**: a new danger warning opens the budget ledger at once (at most once per 45 days) so the player can react
|
||||||
|
before the next quarter.
|
||||||
|
|
||||||
|
## Known soft spots
|
||||||
|
|
||||||
|
- `S` is rare by design (score 73). The best human-like runs reach about 71.
|
||||||
|
- Unrest runs about +0.25 in the card pool because "no" answers lean toward unrest.
|
||||||
|
- Blanket austerity (x0.75 everywhere) is fatal; targeted austerity (cut culture, foreign affairs, administration, research
|
||||||
|
and raise VAT) is survivable but scores poorly (grade D or F). That is deliberate: austerity costs political capital.
|
||||||
@@ -75,6 +75,10 @@ Spread the cards over the whole term so each year brings new material:
|
|||||||
20 % from day 900 to 1500;
|
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
|
- 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>` (sector quality, for "this sector is in trouble" cards);
|
`q:<sector>` (sector quality, for "this sector is in trouble" cards);
|
||||||
|
- two more keys read the player's choices rather than the results: `ratio:<sector>` (the budget divided by the
|
||||||
|
sector's need, 1 = as needed, 2 = double) and `tax:income` / `tax:corporate` / `tax:vat` (the rate; the start values are 30, 25
|
||||||
|
and 20). They are for cards that answer extreme choices (`ratio:police` >= 2, `tax:income` >= 42) in the "backlash"
|
||||||
|
or "windfall" families of M8;
|
||||||
- a few early-only cards with `dayMax`.
|
- a few early-only cards with `dayMax`.
|
||||||
|
|
||||||
Flags: `setFlags` on an option records the decision; `conditions.flags` / `notFlags` read it. Every flag a card
|
Flags: `setFlags` on an option records the decision; `conditions.flags` / `notFlags` read it. Every flag a card
|
||||||
|
|||||||
+12
-3
@@ -169,7 +169,7 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
- **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.
|
- **How a card is drawn** (`engine/cards.js`): start from all cards whose conditions are met and that haven't been used. Weight each one by `weight` × a category boost (a sector below quality 40 makes its category up to 2× likelier) × a novelty factor (0.4 per card of the same category among the last 3). Follow-ups that are due take priority over random draws. With no eligible card the week is simply empty and the game is not blocked.
|
||||||
- **Fields beyond the schema above**: `once` defaults to true; `once: false` makes a card repeatable with `cooldownDays` (default 180); `followUpOnly: true` keeps a card out of random draws so it only appears through a `followUp`. Condition stats are any indicator, `growth`/`unemployment`/`inflation`, `debt` and `deficit` (% of GDP) and `q:<sector>` (sector quality). Options can also `clearFlags`. Every pending card is saved in `state.pendingCard`, so a reload shows the same card.
|
- **Fields beyond the schema above**: `once` defaults to true; `once: false` makes a card repeatable with `cooldownDays` (default 180); `followUpOnly: true` keeps a card out of random draws so it only appears through a `followUp`. Condition stats are any indicator, `growth`/`unemployment`/`inflation`, `debt` and `deficit` (% of GDP) and `q:<sector>` (sector quality). Options can also `clearFlags`. Every pending card is saved in `state.pendingCard`, so a reload shows the same card.
|
||||||
- **Effect size guide** used for the first 64 cards: instant or spread shifts of 1–6 points on indicators, lasting `bias` effects of 1–5 over 180–730 days, sector quality shifts of 2–6 over 60–365 days, one-off costs of 0.1–1.5 % of GDP (negative = windfall), `extraSpending` of 0.1–1.0 % of GDP, tax rate changes of 2–4 points. Every option has a cost; there is no free choice.
|
- **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.
|
- **Target count per category** (about 860 in total; the figures below add up to 860, not 920): economy 100, social 70, health 60, education 60, police/security 70, army/defense 60, foreign/diplomacy 70, industry/energy 70, transport/infrastructure 60, housing 40, environment 50, justice/civil rights 60, culture/media 40, crises 50.
|
||||||
- **How the content gets written**: Sonnet writes it in batches of about 40 cards per category file, in English and French. After each batch, `go test ./server` checks the schema, that ids are unique, that both languages are present, that follow-up ids exist, and that effect stats are valid.
|
- **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)
|
## Desk UI (Suzerain-like)
|
||||||
@@ -205,8 +205,9 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
- [x] **M4 Cards**: draw engine, decision folder and stamps, follow-ups; first 60 cards (EN/FR) across all categories; `content_test.go`. 64 cards written (62 draw at random, 2 follow-up only), 14 categories, 20 senders.
|
- [x] **M4 Cards**: draw engine, decision folder and stamps, follow-ups; first 60 cards (EN/FR) across all categories; `content_test.go`. 64 cards written (62 draw at random, 2 follow-up only), 14 categories, 20 senders.
|
||||||
- [x] **M5 Crises and endings**: unrest and army loyalty, crisis telegrams, the 4 game overs, the 5-year review with historian titles. Also the pronoun question at the start of a game.
|
- [x] **M5 Crises and endings**: unrest and army loyalty, crisis telegrams, the 4 game overs, the 5-year review with historian titles. Also the pronoun question at the start of a game.
|
||||||
- [x] **M6 Saves and menu**: auto-save, slots, export/import string and file, main menu, settings, language switch. Sound is not in the settings yet because the game has no sounds (M8, optional).
|
- [x] **M6 Saves and menu**: auto-save, slots, export/import string and file, main menu, settings, language switch. Sound is not in the settings yet because the game has no sounds (M8, optional).
|
||||||
- [ ] **M7 Content production**: grow the card pool to at least 800 cards in batches by category, plus 50 crisis cards and the follow-up chains.
|
- [x] **M7 Content production**: grow the card pool to at least 800 cards in batches by category, plus 50 crisis cards and the follow-up chains. Done: 864 cards, 117 of them follow-up-only, about 100 chains. Tools: `tools/add-cards.mjs`, `tools/card-stats.mjs`, `tools/cardfmt.mjs`, guide in `docs/CARD-WRITING.md`.
|
||||||
- [ ] **M8 Balance and polish**: sim.html runs 200 games per strategy (random, austerity, spendthrift, police state, balanced). Targets: random play fails about 40–60 % of runs, balanced play survives. Then animations, optional sounds, and a second scenario.
|
- [x] **M8 Balance**: the difficulty is now measured and gated. Passive play is risky, a balanced plan wins, bold play pays up to a point and extremes end in a fall. Tools `tools/balance-check.mjs` (with `--check`), `balance-search.mjs`, `bal-edit.mjs`; guide in `docs/BALANCE.md`.
|
||||||
|
- [ ] **M8b Polish (optional)**: animations, sounds (typewriter, stamp, telegraph; off by default), a second scenario, a repo README.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- `docker compose up --build` → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
|
- `docker compose up --build` → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
|
||||||
@@ -245,3 +246,11 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
- 2026-10-25 (M6): The language button left the calendar (replaced by a Menu button); language lives in Settings, in the main menu and the pause menu, and is remembered. With no saved choice the browser language decides (French if it starts with fr). Time does not run while the menu is open. Space, 1-3 and Escape work in game; the menu has its own Escape (back, or resume).
|
- 2026-10-25 (M6): The language button left the calendar (replaced by a Menu button); language lives in Settings, in the main menu and the pause menu, and is remembered. With no saved choice the browser language decides (French if it starts with fr). Time does not run while the menu is open. Space, 1-3 and Escape work in game; the menu has its own Escape (back, or resume).
|
||||||
- 2026-10-25 (M6): The decision panel's keyboard listener leaked every time the panels were rebuilt (language change since M4); it is now removed when the panels are rebuilt. On load, `restoreState` also keeps the alert pause flag consistent with the alert queue.
|
- 2026-10-25 (M6): The decision panel's keyboard listener leaked every time the panels were rebuilt (language change since M4); it is now removed when the panels are rebuilt. On load, `restoreState` also keeps the alert pause flag consistent with the alert queue.
|
||||||
- 2026-10-25 (M6): New tests. `tools/engine-check.mjs` covers the codec (round trip, whitespace, CRC reference value, UTF-8, every kind of damage, versions and migrations), 23 kinds of invalid or tampered saves, repair of stale references, a restored game continuing identically, and storage (slots, settings, missing, throwing and full storage). `server/ui_content_test.go` checks that en.json and fr.json have identical keys and placeholders and that the scenario index matches the files. The menu was exercised in a real browser: new game, continue after reload with the same waiting card, slot save and overwrite, export, import (valid, tampered, garbage, empty), settings, load and delete with confirmation, and the game-over cleanup.
|
- 2026-10-25 (M6): New tests. `tools/engine-check.mjs` covers the codec (round trip, whitespace, CRC reference value, UTF-8, every kind of damage, versions and migrations), 23 kinds of invalid or tampered saves, repair of stale references, a restored game continuing identically, and storage (slots, settings, missing, throwing and full storage). `server/ui_content_test.go` checks that en.json and fr.json have identical keys and placeholders and that the scenario index matches the files. The menu was exercised in a real browser: new game, continue after reload with the same waiting card, slot save and overwrite, export, import (valid, tampered, garbage, empty), settings, load and delete with confirmation, and the game-over cleanup.
|
||||||
|
- 2026-10-25 (M7): Pool is 864 cards: economy 100, social 73, health 60, education 60, security 70, defense 60, foreign 70, industry 70, transport 60, housing 41, environment 50, justice 60, culture 40, crises 50. 117 cards are follow-up-only (119 links, about one chain per 7 cards, several branch on yes/no), 29 are repeatable (mostly crises), 353 have a stat or quality condition. Availability by year: start 286, year 1 202, year 2 189, year 3 127, year 4+ 60. The plan's old "about 920" was an addition error; the per-category targets add up to 860. Seven writers worked in parallel from `docs/CARD-WRITING.md`; defense and foreign cards only use invented countries (Torvania, Marenia, Belmora, Kessaly, Draveth). Coin-flip averages (yes/no averaged) are near zero for opinion, stability, economy and cost; unrest runs about +0.24 because "no" answers lean toward unrest, which is fine for now (M8). Seven duplicate English titles were retitled.
|
||||||
|
- 2026-10-25 (M7): Pool check, 120 random-answer runs (20 seeds x 6 strategies): no empty week, a run sees 205 distinct cards on average (min 39 on the early-ending starve runs, max 261), draws are spread over categories and years, and the endings match M5 (baseline, spendthrift, austerity and police-state random play reach the end; ruin ends in default; starve in revolution or coup). 9 cards were never drawn because of narrow conditions (coup attempt and its two follow-ups, police strike, strike wave, regional fund loan, industry rescue, drought pipeline, guildhall roof); they are rare-situation cards and are kept. `server/cards_test.go` now requires at least 800 cards and a minimum per category.
|
||||||
|
- 2026-10-25 (M8): Starting point, measured with the new harness: every strategy except ruin, starve and tyrant survived 100 % of runs, random play scored 58 and a card-reading bot only 61, so decisions barely mattered. Causes: the start state was an equilibrium with nothing pushing back, sector effects were weak, and debt cost only about 10 score points at 150 % of GDP.
|
||||||
|
- 2026-10-25 (M8): Engine changes (all tunable in balance.json). Indicator drift (opinion fades, order and stability erode, unrest creeps up, scaled by `driftScale` 1.1); sector needs grow yearly (`needGrowth`); a world business cycle with shocks (`world`, state version 2 with a migration that adds `world: {cycle: 0}`), receipts follow growth (`fiscal.growthSens`); `gain` 3.0 multiplies how much sector quality moves indicators; `excess` and `short` terms reward quality above 70 to 75 and punish neglect; interest premium coefficient 0.0008 (was 0.0003); default at 165 % (was 200, warning at 135); the score leans 50 % on the last year (`score.lastYearShare`); grades S 73, A 64, B 56, C 47, D 38; revolution zone is unrest above 62 and opinion below 28; coup also triggers from army loyalty under 18 alone, and from loyalty under 35 with stability under 50; zone chances grow 0.004 per day (was 0.015 and 0.02), so a warning leaves about a month.
|
||||||
|
- 2026-10-25 (M8): Emergency session. A new danger warning (revolution, coup, impeachment, default) opens the budget ledger at once, at most once per `gameOver.emergencyCooldown` (45) days, with a stamped banner. Passive play with a sensible reaction survives about 95 % of runs against 79 % without it, and extremes still fall. `state.risk` gained `emergency` and `lastEmergency`; `restoreState` repairs saves without them.
|
||||||
|
- 2026-10-25 (M8): Cards react to the player's choices. New condition keys `ratio:<sector>` and `tax:<id>` (engine, Go validator, guide). 50 new crisis cards: 34 backlash (`cri_bk_*`: police state, army excess or neglect, high and low taxes, debt and rating, starved services, waste) and 16 windfall (`cri_wf_*`: sustained high sector quality). The pool is 914 cards. Windfalls fire about 1.1 times per run for the balanced plan and 2.6 for the 1.5x plan; backlash fires 2 to 3 times per run for police state, tax hike and spendthrift and almost never for the milder versions.
|
||||||
|
- 2026-10-25 (M8): Result, 24 seeds each, human-like card answers: random 79 % survive (grade D); planner 100 % and score 61 (A in 17 % of runs); the same plan at 1.5x boldness 100 % with A in 29 %; at 2x 63 % survive; at 3x default every time; ruin, starve, deep cuts, tyrant 0 %; spendthrift 33 %; mild versions of each idea 75 to 100 %. The best budget the search found scores about 63 to 65. `balance-check --check` encodes these bands. Soft spots: S is rare, unrest runs slightly positive in the card pool, blanket austerity is fatal.
|
||||||
|
- 2026-10-25 (M8): Newspaper headlines for a global slump or boom. The ledger hint says needs grow yearly. Dev parameter `?untilAlert` stops a fast-forward at the first danger warning. Engine checks cover the world cycle, growing needs, the new condition keys, coup from neglect, the emergency session with its cooldown and the version 1 migration.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The M8 balance sections are read by the engine without defensive checks: a typo in a sector id
|
||||||
|
// would crash the game at runtime, so every reference is checked here.
|
||||||
|
func TestBalanceSections(t *testing.T) {
|
||||||
|
bal := load(t, "data/balance.json")
|
||||||
|
scn := load(t, "data/scenarios/default.json")
|
||||||
|
sectors := set(keys(scn["sectors"].(obj)))
|
||||||
|
|
||||||
|
for id, v := range bal["needGrowth"].(obj) {
|
||||||
|
if !sectors[id] {
|
||||||
|
t.Errorf("needGrowth: unknown sector %q", id)
|
||||||
|
}
|
||||||
|
if g, ok := v.(float64); !ok || g < 0 || g > 0.2 {
|
||||||
|
t.Errorf("needGrowth.%s must be a yearly rate between 0 and 0.2", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range sectors {
|
||||||
|
if _, ok := bal["needGrowth"].(obj)[id]; !ok {
|
||||||
|
t.Errorf("needGrowth: missing sector %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := bal["world"].(obj)
|
||||||
|
sameKeys(t, "balance.world", w, []string{"tau", "noise", "max", "shockChancePerDay", "shock", "boomShare"})
|
||||||
|
for k, v := range w {
|
||||||
|
if f, ok := v.(float64); !ok || f <= 0 {
|
||||||
|
t.Errorf("world.%s must be a positive number", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, ok := bal["gain"].(float64); !ok || g <= 0 {
|
||||||
|
t.Error("gain must be positive")
|
||||||
|
}
|
||||||
|
if g, ok := bal["driftScale"].(float64); !ok || g < 0 {
|
||||||
|
t.Error("driftScale must be a number >= 0")
|
||||||
|
}
|
||||||
|
if f, ok := bal["fiscal"].(obj)["growthSens"].(float64); !ok || f < 0 {
|
||||||
|
t.Error("fiscal.growthSens must be a number >= 0")
|
||||||
|
}
|
||||||
|
if c, ok := bal["gameOver"].(obj)["emergencyCooldown"].(float64); !ok || c < 1 {
|
||||||
|
t.Error("gameOver.emergencyCooldown must be at least 1 day")
|
||||||
|
}
|
||||||
|
|
||||||
|
// every indicator and economy variable definition
|
||||||
|
known := set([]string{"tau", "noise", "min", "max", "sectors", "alloc", "econ", "ind", "world", "excess", "short", "drift"})
|
||||||
|
check := func(group string, defs obj) {
|
||||||
|
for id, raw := range defs {
|
||||||
|
def := raw.(obj)
|
||||||
|
for k := range def {
|
||||||
|
if !known[k] {
|
||||||
|
t.Errorf("%s.%s: unknown field %q", group, id, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"sectors", "alloc"} {
|
||||||
|
if m, ok := def[field].(obj); ok {
|
||||||
|
for sid := range m {
|
||||||
|
if !sectors[sid] {
|
||||||
|
t.Errorf("%s.%s.%s: unknown sector %q", group, id, field, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"excess", "short"} {
|
||||||
|
m, ok := def[field].(obj)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for sid, pair := range m {
|
||||||
|
if !sectors[sid] {
|
||||||
|
t.Errorf("%s.%s.%s: unknown sector %q", group, id, field, sid)
|
||||||
|
}
|
||||||
|
p, ok := pair.([]any)
|
||||||
|
if !ok || len(p) != 2 {
|
||||||
|
t.Errorf("%s.%s.%s.%s must be [from, weight]", group, id, field, sid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from, ok1 := p[0].(float64)
|
||||||
|
weight, ok2 := p[1].(float64)
|
||||||
|
if !ok1 || !ok2 || from < 0 || from > 100 || weight < 0 {
|
||||||
|
t.Errorf("%s.%s.%s.%s: from must be 0..100 and weight >= 0", group, id, field, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, ok := def["world"].(obj); ok {
|
||||||
|
for k := range m {
|
||||||
|
if k != "cycle" {
|
||||||
|
t.Errorf("%s.%s.world: unknown key %q", group, id, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := def["drift"].(float64); def["drift"] != nil && !ok {
|
||||||
|
t.Errorf("%s.%s.drift must be a number", group, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("indicators", bal["indicators"].(obj))
|
||||||
|
check("vars", bal["vars"].(obj))
|
||||||
|
}
|
||||||
+18
-3
@@ -114,7 +114,10 @@ func (c *cardChecker) conditions(id string, cond obj) {
|
|||||||
for _, bound := range []string{"min", "max"} {
|
for _, bound := range []string{"min", "max"} {
|
||||||
m, _ := cond[bound].(obj)
|
m, _ := cond[bound].(obj)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
okKey := c.condStats[k] || (strings.HasPrefix(k, "q:") && c.sectors[strings.TrimPrefix(k, "q:")])
|
okKey := c.condStats[k] ||
|
||||||
|
(strings.HasPrefix(k, "q:") && c.sectors[strings.TrimPrefix(k, "q:")]) ||
|
||||||
|
(strings.HasPrefix(k, "ratio:") && c.sectors[strings.TrimPrefix(k, "ratio:")]) ||
|
||||||
|
(strings.HasPrefix(k, "tax:") && c.revenues[strings.TrimPrefix(k, "tax:")] && k != "tax:other")
|
||||||
if !okKey {
|
if !okKey {
|
||||||
t.Errorf("%s.conditions.%s: unknown stat %q", id, bound, k)
|
t.Errorf("%s.conditions.%s: unknown stat %q", id, bound, k)
|
||||||
}
|
}
|
||||||
@@ -200,6 +203,7 @@ func TestCards(t *testing.T) {
|
|||||||
|
|
||||||
followOnly := map[string]bool{}
|
followOnly := map[string]bool{}
|
||||||
total := 0
|
total := 0
|
||||||
|
perCategory := map[string]int{}
|
||||||
for _, f := range index["files"].([]any) {
|
for _, f := range index["files"].([]any) {
|
||||||
file := f.(string)
|
file := f.(string)
|
||||||
raw, err := web.Files.ReadFile("data/cards/" + file)
|
raw, err := web.Files.ReadFile("data/cards/" + file)
|
||||||
@@ -220,6 +224,7 @@ func TestCards(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total++
|
total++
|
||||||
|
perCategory[stem]++
|
||||||
if c.ids[id] {
|
if c.ids[id] {
|
||||||
t.Errorf("duplicate card id %s", id)
|
t.Errorf("duplicate card id %s", id)
|
||||||
}
|
}
|
||||||
@@ -281,7 +286,17 @@ func TestCards(t *testing.T) {
|
|||||||
t.Errorf("%s: flag %q is used in conditions but never set by any card", by, fl)
|
t.Errorf("%s: flag %q is used in conditions but never set by any card", by, fl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if total < 60 {
|
if total < 800 {
|
||||||
t.Errorf("only %d cards; M4 expects at least 60", total)
|
t.Errorf("only %d cards; M7 expects at least 800", total)
|
||||||
|
}
|
||||||
|
minPerCategory := map[string]int{
|
||||||
|
"economy": 90, "social": 60, "health": 55, "education": 55, "security": 60, "defense": 55,
|
||||||
|
"foreign": 60, "industry": 60, "transport": 55, "housing": 35, "environment": 45,
|
||||||
|
"justice": 55, "culture": 35, "crises": 45,
|
||||||
|
}
|
||||||
|
for cat, min := range minPerCategory {
|
||||||
|
if perCategory[cat] < min {
|
||||||
|
t.Errorf("category %s has %d cards; M7 expects at least %d", cat, perCategory[cat], min)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func TestEndgame(t *testing.T) {
|
|||||||
|
|
||||||
// Balance sections the review and the game-over rules rely on.
|
// Balance sections the review and the game-over rules rely on.
|
||||||
g := bal["gameOver"].(obj)
|
g := bal["gameOver"].(obj)
|
||||||
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution"})
|
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution", "emergencyCooldown"})
|
||||||
sc := bal["score"].(obj)
|
sc := bal["score"].(obj)
|
||||||
valid := set(append(append([]string{}, indIDs...), "calm"))
|
valid := set(append(append([]string{}, indIDs...), "calm"))
|
||||||
for k := range sc["weights"].(obj) {
|
for k := range sc["weights"].(obj) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Edits balance.json from the command line and keeps its compact layout.
|
||||||
|
// node tools/bal-edit.mjs path=value [path=value ...] e.g. gain=2.8 debt.premiumCoef=0.0008
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { format } from "./cardfmt.mjs";
|
||||||
|
|
||||||
|
const p = new URL("../web/data/balance.json", import.meta.url);
|
||||||
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||||
|
for (const a of process.argv.slice(2)) {
|
||||||
|
const [path, val] = a.split("=");
|
||||||
|
const keys = path.split(".");
|
||||||
|
let o = j;
|
||||||
|
for (const k of keys.slice(0, -1)) o = o[k] ??= {};
|
||||||
|
o[keys.at(-1)] = JSON.parse(val);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(p, `${format(j)}\n`);
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
// Balance harness (M8): plays full terms with different "players" and reports how hard the game is.
|
||||||
|
// node tools/balance-check.mjs [seeds] [players] [--json]
|
||||||
|
// A player = a budget policy (decided at each quarterly review) + a card policy (yes/no each week).
|
||||||
|
// Card policies: random (coin flip), oracle (reads the real effects), intuitive (oracle that gets
|
||||||
|
// 30 % of the answers wrong, closer to a human reading the text).
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as E from "../web/js/engine/index.js";
|
||||||
|
import { STRATEGIES } from "../web/dev/sim-core.js";
|
||||||
|
|
||||||
|
const root = new URL("../web/data/", import.meta.url);
|
||||||
|
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
|
||||||
|
export const bal = read("balance.json");
|
||||||
|
export const scn = read("scenarios/default.json");
|
||||||
|
export const endgame = read("endgame.json");
|
||||||
|
|
||||||
|
// --set path=value (repeatable) overrides balance.json in memory, e.g. --set gain=2 --set indicators.opinion.drift=-1
|
||||||
|
for (let i = 2; i < process.argv.length; i++) {
|
||||||
|
if (process.argv[i] !== "--set") continue;
|
||||||
|
const [path, val] = process.argv[i + 1].split("=");
|
||||||
|
const keys = path.split(".");
|
||||||
|
let o = bal;
|
||||||
|
for (const k of keys.slice(0, -1)) o = o[k];
|
||||||
|
o[keys.at(-1)] = JSON.parse(val);
|
||||||
|
}
|
||||||
|
export const cards = read("cards/index.json").files.flatMap((f) => read("cards/" + f));
|
||||||
|
|
||||||
|
// ---------- card policies ----------
|
||||||
|
|
||||||
|
// Rough worth of an option in "points". Stats the player cares about weigh more when they are low.
|
||||||
|
function optionValue(state, opt, fiscal) {
|
||||||
|
const ind = state.indicators;
|
||||||
|
const need = (v) => 1 + Math.max(0, (50 - v) / 25); // low stat => more valuable
|
||||||
|
let v = 0;
|
||||||
|
for (const e of opt.effects ?? []) {
|
||||||
|
const d = e.delta ?? 0;
|
||||||
|
const horizon = e.bias ? Math.min(6, (e.days ?? 540) / 90) : 1; // permanent offsets count more
|
||||||
|
if (e.stat !== undefined) {
|
||||||
|
const w = { opinion: 1.0, stability: 1.0, economy: 1.0, order: 0.6, civilRights: 0.6, hardPower: 0.5, softPower: 0.5 }[e.stat];
|
||||||
|
if (w !== undefined) v += w * need(ind[e.stat]) * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
else if (e.stat === "unrest") v -= 1.0 * (1 + Math.max(0, (ind.unrest - 30) / 20)) * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
else if (e.stat === "armyLoyalty") v += 0.5 * need(ind.armyLoyalty) * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
else if (e.stat === "growth") v += 3 * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
else if (e.stat === "unemployment" || e.stat === "inflation") v -= 1.2 * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
} else if (e.sector !== undefined) {
|
||||||
|
v += 0.35 * d * horizon * (e.bias ? 0.4 : 1);
|
||||||
|
} else if (e.revenue !== undefined) {
|
||||||
|
v += (e.revenue === "other" ? 8 : e.revenue === "corporate" ? 2 : 2.5) * d * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40));
|
||||||
|
} else if (e.oneOff !== undefined) {
|
||||||
|
v -= 4 * e.oneOff * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40));
|
||||||
|
} else if (e.extraSpending !== undefined) {
|
||||||
|
v -= 14 * e.extraSpending * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLICIES = {
|
||||||
|
random: () => (state) => (E.nextFloat(state) < 0.5 ? "yes" : "no"),
|
||||||
|
oracle: () => (state, card) => {
|
||||||
|
const f = E.computeFiscal(state, bal);
|
||||||
|
return optionValue(state, card.yes, f) >= optionValue(state, card.no, f) ? "yes" : "no";
|
||||||
|
},
|
||||||
|
intuitive: () => (state, card) => {
|
||||||
|
const f = E.computeFiscal(state, bal);
|
||||||
|
const best = optionValue(state, card.yes, f) >= optionValue(state, card.no, f) ? "yes" : "no";
|
||||||
|
return E.nextFloat(state) < 0.3 ? (best === "yes" ? "no" : "yes") : best;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- budget policies ----------
|
||||||
|
|
||||||
|
const scaled = (state, k) => {
|
||||||
|
const out = {};
|
||||||
|
for (const [id, s] of Object.entries(state.sectors)) out[id] = s.need * state.ref.ratio[id] * k;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-sector multipliers on the scenario's starting ratio. `by` overrides single sectors.
|
||||||
|
const withMult = (state, base, by = {}) => {
|
||||||
|
const out = {};
|
||||||
|
for (const [id, s] of Object.entries(state.sectors)) {
|
||||||
|
const m = by[id] ?? base;
|
||||||
|
out[id] = Math.min(s.need * bal.limits.allocMaxRatio, s.need * state.ref.ratio[id] * m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROWTH = ["education", "research", "infrastructure", "industry", "transport"];
|
||||||
|
|
||||||
|
// A prudent manager: keeps the core services at their starting level, nudges taxes to hold the debt
|
||||||
|
// ratio near a target and puts a little extra into the growth sectors when the books allow it.
|
||||||
|
function steward({ invest = 1.1, target = 65, tight = 0.9 } = {}) {
|
||||||
|
return (state) => {
|
||||||
|
const f = E.computeFiscal(state, bal);
|
||||||
|
const taxes = {};
|
||||||
|
const by = {};
|
||||||
|
if (f.debtRatio > target + 15 || f.deficit > 3) {
|
||||||
|
for (const id of GROWTH) by[id] = 1.0;
|
||||||
|
for (const id of ["culture", "foreignAffairs", "administration"]) by[id] = tight;
|
||||||
|
taxes.vat = Math.min(bal.tax.vat.peak, state.economy.taxes.vat + 1);
|
||||||
|
taxes.income = Math.min(bal.tax.income.peak, state.economy.taxes.income + 0.5);
|
||||||
|
} else if (f.debtRatio < target - 10 && f.deficit < 0.5) {
|
||||||
|
for (const id of GROWTH) by[id] = invest;
|
||||||
|
taxes.vat = Math.max(state.ref.taxes.vat, state.economy.taxes.vat - 0.5);
|
||||||
|
taxes.income = Math.max(state.ref.taxes.income, state.economy.taxes.income - 0.5);
|
||||||
|
} else {
|
||||||
|
for (const id of GROWTH) by[id] = 1.0;
|
||||||
|
}
|
||||||
|
return { alloc: withMult(state, 1.0, by), taxes };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold: borrow heavily at the start to lift education, research, infrastructure and industry,
|
||||||
|
// then pull the brake in the last year.
|
||||||
|
function investor({ mult = 1.8, until = 1100, brake = 0.85 } = {}) {
|
||||||
|
return (state) => {
|
||||||
|
const by = {};
|
||||||
|
const late = state.day >= until;
|
||||||
|
for (const id of GROWTH) by[id] = late ? 1.0 : mult;
|
||||||
|
const rest = late ? brake : 1.0;
|
||||||
|
return { alloc: withMult(state, rest, by) };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the hill-climbing search found (tools/balance-search.mjs): invest hard in growth, cut corporate tax.
|
||||||
|
const grower = () => (state) => {
|
||||||
|
const by = { education: 1.7, research: 1.7, infrastructure: 1.7, industry: 1.7, transport: 1.7, health: 0.9, social: 0.9, housing: 0.9, army: 1.2 };
|
||||||
|
return { alloc: withMult(state, 1.0, by), taxes: { corporate: state.ref.taxes.corporate - 7 } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best policy of the two-phase search (balance-search.mjs, 2nd run): steady core services, more for growth,
|
||||||
|
// soft power and (late) order, financed by income tax and VAT in the second half.
|
||||||
|
// k scales every deviation from "normal" (k=1 is the searched policy, 0 = baseline, 2 = twice as bold).
|
||||||
|
const planner = (k = 1) => (state) => {
|
||||||
|
const late = state.day >= 730;
|
||||||
|
const by = late
|
||||||
|
? { police: 1.7, justice: 1.7, administration: 1.7, army: 1.17, education: 1.7, research: 1.7, infrastructure: 1.7, industry: 1.7, transport: 1.7, culture: 1.3, foreignAffairs: 1.3, environment: 1.3 }
|
||||||
|
: { education: 1.3, research: 1.3, infrastructure: 1.3, industry: 1.3, transport: 1.3, culture: 1.38, foreignAffairs: 1.38, environment: 1.38 };
|
||||||
|
const t = state.ref.taxes;
|
||||||
|
for (const id of Object.keys(by)) by[id] = 1 + (by[id] - 1) * k;
|
||||||
|
return { alloc: withMult(state, 1.0, by), taxes: late ? { income: t.income + 4.3 * k, vat: t.vat + 2.5 * k } : { income: t.income + 1.5 * k } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a sensible player does in an emergency session (opened by a danger alert).
|
||||||
|
function emergencyBudget(state) {
|
||||||
|
const kind = state.alerts[0]?.kind;
|
||||||
|
const t = state.economy.taxes;
|
||||||
|
if (kind === "default") return { alloc: withMult(state, 0.8), taxes: { vat: t.vat + 3, income: t.income + 3 } };
|
||||||
|
if (kind === "coup") return { alloc: withMult(state, 1.0, { army: 1.8 }) };
|
||||||
|
return { alloc: withMult(state, 1.0, { social: 1.4, housing: 1.5, health: 1.3, justice: 1.4, police: 1.3 }), taxes: { income: Math.max(state.ref.taxes.income - 2, t.income - 2), vat: Math.max(state.ref.taxes.vat - 2, t.vat - 2) } };
|
||||||
|
}
|
||||||
|
const rescued = (make) => (o) => { const f = make(o); return (state) => (state.risk.emergency ? emergencyBudget(state) : f(state)); };
|
||||||
|
|
||||||
|
const BUDGETS = {
|
||||||
|
starveRescue: rescued(() => BUDGETS.starve()),
|
||||||
|
austerityRescue: rescued(() => BUDGETS.austerity()),
|
||||||
|
tyrantRescue: rescued(() => BUDGETS.tyrant()),
|
||||||
|
neglectArmyRescue: rescued(() => BUDGETS.neglectArmy()),
|
||||||
|
spendthriftRescue: rescued(() => BUDGETS.spendthrift()),
|
||||||
|
baselineRescue: rescued(() => BUDGETS.baseline()),
|
||||||
|
plannerRescue: rescued(() => planner(1)),
|
||||||
|
planner: () => planner(1),
|
||||||
|
planner05: () => planner(0.5),
|
||||||
|
planner15: () => planner(1.5),
|
||||||
|
planner2: () => planner(2),
|
||||||
|
planner3: () => planner(3),
|
||||||
|
grower,
|
||||||
|
baseline: () => () => ({}),
|
||||||
|
steward: (o) => steward(o),
|
||||||
|
investor: (o) => investor(o),
|
||||||
|
bold: () => investor({ mult: 2.2, until: 1000, brake: 0.8 }),
|
||||||
|
spendthrift: () => (s) => ({ alloc: scaled(s, 1.4) }),
|
||||||
|
austerity: () => (s) => ({ alloc: scaled(s, 0.75) }),
|
||||||
|
austerityMild: () => (s) => ({ alloc: withMult(s, 0.92, { culture: 0.6, foreignAffairs: 0.6, administration: 0.7, research: 0.8, environment: 0.7 }), taxes: { vat: s.ref.taxes.vat + 1.5 } }),
|
||||||
|
taxMild: () => (s) => ({ taxes: { income: s.ref.taxes.income + 3, vat: s.ref.taxes.vat + 2 } }),
|
||||||
|
policeMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), police: s.sectors.police.need * 1.6 } }),
|
||||||
|
armyMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), army: s.sectors.army.need * 1.5 } }),
|
||||||
|
spendMild: () => (s) => ({ alloc: scaled(s, 1.15) }),
|
||||||
|
welfareMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), social: s.sectors.social.need * 1.2, health: s.sectors.health.need * 1.2, housing: s.sectors.housing.need * 1.3 }, taxes: { income: s.ref.taxes.income + 2, vat: s.ref.taxes.vat + 1.5 } }),
|
||||||
|
deepCuts: () => (s) => ({ alloc: scaled(s, 0.5), taxes: { income: s.ref.taxes.income + 5, vat: s.ref.taxes.vat + 3 } }),
|
||||||
|
taxHike: () => (s) => ({ taxes: { income: s.ref.taxes.income + 12, corporate: s.ref.taxes.corporate + 10, vat: s.ref.taxes.vat + 8 } }),
|
||||||
|
taxCut: () => (s) => ({ taxes: { income: s.ref.taxes.income - 12, corporate: s.ref.taxes.corporate - 10, vat: s.ref.taxes.vat - 6 } }),
|
||||||
|
policeState: () => (s) => ({ alloc: { ...withMult(s, 1.0), police: s.sectors.police.need * 3, army: s.sectors.army.need * 1.5 } }),
|
||||||
|
tyrant: () => (s) => ({ alloc: { ...withMult(s, 0.7), police: s.sectors.police.need * 3, army: s.sectors.army.need * 2, justice: s.sectors.justice.need * 0.3, social: s.sectors.social.need * 0.3 } }),
|
||||||
|
ruin: () => (s) => ({ alloc: scaled(s, 2), taxes: { income: 15, corporate: 12, vat: 10 } }),
|
||||||
|
starve: () => (s) => ({ alloc: scaled(s, 0.25), taxes: { income: s.ref.taxes.income + 15, corporate: s.ref.taxes.corporate + 10, vat: s.ref.taxes.vat + 10 } }),
|
||||||
|
neglectArmy: () => (s) => ({ alloc: { ...withMult(s, 1.1), army: s.sectors.army.need * 0.1 } }),
|
||||||
|
militarist: () => (s) => ({ alloc: { ...withMult(s, 0.9), army: s.sectors.army.need * 3 } }),
|
||||||
|
welfare: () => (s) => ({ alloc: { ...withMult(s, 1.0), social: s.sectors.social.need * 1.6, health: s.sectors.health.need * 1.5, housing: s.sectors.housing.need * 1.8 } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// name -> [budget, cards]
|
||||||
|
export const PLAYERS = {
|
||||||
|
random: ["baseline", "random"],
|
||||||
|
randomSteward: ["steward", "random"],
|
||||||
|
plannerHuman: ["planner", "intuitive"],
|
||||||
|
starveRescue: ["starveRescue", "intuitive"],
|
||||||
|
austerityRescue: ["austerityRescue", "intuitive"],
|
||||||
|
tyrantRescue: ["tyrantRescue", "intuitive"],
|
||||||
|
neglectArmyRescue: ["neglectArmyRescue", "intuitive"],
|
||||||
|
spendthriftRescue: ["spendthriftRescue", "intuitive"],
|
||||||
|
randomRescue: ["baselineRescue", "random"],
|
||||||
|
plannerRescue: ["plannerRescue", "intuitive"],
|
||||||
|
planner05: ["planner05", "intuitive"],
|
||||||
|
planner15: ["planner15", "intuitive"],
|
||||||
|
planner2: ["planner2", "intuitive"],
|
||||||
|
planner3: ["planner3", "intuitive"],
|
||||||
|
plannerOracle: ["planner", "oracle"],
|
||||||
|
plannerRandom: ["planner", "random"],
|
||||||
|
growerHuman: ["grower", "intuitive"],
|
||||||
|
growerOracle: ["grower", "oracle"],
|
||||||
|
stewardOracle: ["steward", "oracle"],
|
||||||
|
stewardHuman: ["steward", "intuitive"],
|
||||||
|
investorOracle: ["investor", "oracle"],
|
||||||
|
investorHuman: ["investor", "intuitive"],
|
||||||
|
boldOracle: ["bold", "oracle"],
|
||||||
|
boldHuman: ["bold", "intuitive"],
|
||||||
|
baselineOracle: ["baseline", "oracle"],
|
||||||
|
spendthrift: ["spendthrift", "intuitive"],
|
||||||
|
austerity: ["austerity", "intuitive"],
|
||||||
|
deepCuts: ["deepCuts", "intuitive"],
|
||||||
|
austerityMild: ["austerityMild", "intuitive"],
|
||||||
|
taxMild: ["taxMild", "intuitive"],
|
||||||
|
policeMild: ["policeMild", "intuitive"],
|
||||||
|
armyMild: ["armyMild", "intuitive"],
|
||||||
|
spendMild: ["spendMild", "intuitive"],
|
||||||
|
welfareMild: ["welfareMild", "intuitive"],
|
||||||
|
taxHike: ["taxHike", "intuitive"],
|
||||||
|
taxCut: ["taxCut", "intuitive"],
|
||||||
|
policeState: ["policeState", "intuitive"],
|
||||||
|
tyrant: ["tyrant", "intuitive"],
|
||||||
|
ruin: ["ruin", "intuitive"],
|
||||||
|
starve: ["starve", "intuitive"],
|
||||||
|
neglectArmy: ["neglectArmy", "intuitive"],
|
||||||
|
militarist: ["militarist", "intuitive"],
|
||||||
|
welfare: ["welfare", "intuitive"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playOne = (budgetName, policyName, seed, opts = {}) => playWith(BUDGETS[budgetName](opts.budget), policyName, seed);
|
||||||
|
|
||||||
|
// budget: (state) => { alloc?, taxes? } called at every quarterly review.
|
||||||
|
export function playWith(budget, policyName, seed) {
|
||||||
|
const state = E.createGame({ scenario: scn, balance: bal, seed, pronouns: "they" });
|
||||||
|
const answer = POLICIES[policyName]();
|
||||||
|
const alerts = {};
|
||||||
|
for (let guard = 0; guard < 9000 && !state.over; guard++) {
|
||||||
|
if (state.pending.review) {
|
||||||
|
const res = E.setBudget(state, bal, budget(state));
|
||||||
|
if (!res.ok) throw new Error(`${budgetName}: ${res.errors.join(",")}`);
|
||||||
|
E.confirmBudget(state);
|
||||||
|
}
|
||||||
|
while (state.pending.alert) { alerts[state.alerts[0].kind] = (alerts[state.alerts[0].kind] ?? 0) + 1; E.dismissAlert(state); }
|
||||||
|
if (state.pending.weeklyCard) {
|
||||||
|
const card = E.ensureCard(state, bal, cards);
|
||||||
|
if (card) E.answerCard(state, bal, cards, answer(state, card));
|
||||||
|
}
|
||||||
|
E.advanceDay(state, bal);
|
||||||
|
}
|
||||||
|
const review = E.buildReview(state, bal, cards, endgame);
|
||||||
|
const f = E.summarize(state, bal);
|
||||||
|
return {
|
||||||
|
seed, end: state.over?.type ?? "term", day: state.day, score: review.score, grade: review.grade, title: review.titleId,
|
||||||
|
debt: f.debtRatio, ind: f.indicators, alerts, crises: state.cardHistory.filter((h) => h.category === "crises").length,
|
||||||
|
metrics: review.metrics, cardIds: state.cardHistory.map((h) => h.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
||||||
|
|
||||||
|
function report(name, runs) {
|
||||||
|
const n = runs.length;
|
||||||
|
const ends = {};
|
||||||
|
for (const r of runs) ends[r.end] = (ends[r.end] ?? 0) + 1;
|
||||||
|
const survived = runs.filter((r) => r.end === "term");
|
||||||
|
const grades = {};
|
||||||
|
for (const r of survived) grades[r.grade] = (grades[r.grade] ?? 0) + 1;
|
||||||
|
const scoreAll = mean(runs.map((r) => r.score));
|
||||||
|
const scoreSurv = mean(survived.map((r) => r.score));
|
||||||
|
const dayFail = mean(runs.filter((r) => r.end !== "term").map((r) => r.day));
|
||||||
|
const endTxt = Object.entries(ends).map(([k, v]) => `${k} ${v}`).join(" ");
|
||||||
|
const gradeTxt = ["S", "A", "B", "C", "D", "F"].filter((g) => grades[g]).map((g) => `${g}${grades[g]}`).join(" ");
|
||||||
|
const last = (k) => mean(runs.map((r) => r.ind[k]));
|
||||||
|
console.log(
|
||||||
|
`${name.padEnd(15)} survive ${String(Math.round((100 * survived.length) / n)).padStart(3)}% score ${scoreSurv.toFixed(1).padStart(5)} (all ${scoreAll.toFixed(1)}) ` +
|
||||||
|
`grades ${gradeTxt.padEnd(14)} fall@day ${Number.isFinite(dayFail) ? dayFail.toFixed(0).padStart(4) : " -"} debt ${mean(runs.map((r) => r.debt)).toFixed(0).padStart(3)} ` +
|
||||||
|
`opin ${last("opinion").toFixed(0)} stab ${last("stability").toFixed(0)} econ ${last("economy").toFixed(0)} unrest ${last("unrest").toFixed(0)} crises ${mean(runs.map((r) => r.crises)).toFixed(1)} | ${endTxt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intended shape of the difficulty (M8). `node tools/balance-check.mjs --check` fails when a player type leaves its band.
|
||||||
|
// survive: % of runs that reach the end of the term; score: mean score of the survivors; top: % of survivors graded A or S.
|
||||||
|
const TARGETS = {
|
||||||
|
// hard for passive play, forgiving once the cabinet reacts to a warning
|
||||||
|
random: { survive: [55, 92], score: [36, 52] },
|
||||||
|
randomRescue: { survive: [85, 100] },
|
||||||
|
stewardHuman: { survive: [80, 100], score: [38, 54] },
|
||||||
|
// a competent, balanced plan works; audacity pays up to a point, then it is punished
|
||||||
|
planner05: { survive: [96, 100], score: [50, 62] },
|
||||||
|
plannerHuman: { survive: [96, 100], score: [57, 67], top: [5, 60] },
|
||||||
|
plannerOracle: { survive: [100, 100], score: [62, 72], top: [30, 100] },
|
||||||
|
planner15: { survive: [85, 100], score: [54, 67], top: [10, 70] },
|
||||||
|
planner2: { survive: [40, 90] },
|
||||||
|
planner3: { survive: [0, 8] },
|
||||||
|
// extremes end in a fall
|
||||||
|
ruin: { survive: [0, 0] },
|
||||||
|
starve: { survive: [0, 0] },
|
||||||
|
deepCuts: { survive: [0, 0] },
|
||||||
|
tyrant: { survive: [0, 0] },
|
||||||
|
neglectArmy: { survive: [0, 15] },
|
||||||
|
militarist: { survive: [0, 25] },
|
||||||
|
austerity: { survive: [0, 20] },
|
||||||
|
spendthrift: { survive: [0, 55] },
|
||||||
|
taxHike: { survive: [40, 100], score: [0, 48] },
|
||||||
|
policeState: { score: [0, 46] },
|
||||||
|
// milder versions of the same ideas stay playable
|
||||||
|
austerityMild: { survive: [75, 100] },
|
||||||
|
taxMild: { survive: [85, 100] },
|
||||||
|
policeMild: { survive: [90, 100] },
|
||||||
|
spendMild: { survive: [95, 100] },
|
||||||
|
welfareMild: { survive: [95, 100] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function check(seeds) {
|
||||||
|
let failures = 0;
|
||||||
|
for (const [name, want] of Object.entries(TARGETS)) {
|
||||||
|
const [b, p] = PLAYERS[name];
|
||||||
|
const runs = [];
|
||||||
|
for (let s = 1; s <= seeds; s++) runs.push(playOne(b, p, s));
|
||||||
|
const surv = runs.filter((r) => r.end === "term");
|
||||||
|
const got = {
|
||||||
|
survive: (100 * surv.length) / runs.length,
|
||||||
|
score: mean(surv.map((r) => r.score)),
|
||||||
|
top: surv.length ? (100 * surv.filter((r) => r.grade === "A" || r.grade === "S").length) / surv.length : 0,
|
||||||
|
};
|
||||||
|
const bad = Object.entries(want).filter(([k, [lo, hi]]) => !(got[k] >= lo && got[k] <= hi) && !(k !== "survive" && !surv.length && lo === 0));
|
||||||
|
if (bad.length) failures++;
|
||||||
|
console.log(`${bad.length ? "FAIL" : "ok "} ${name.padEnd(15)} ` + Object.entries(want).map(([k, [lo, hi]]) => `${k} ${got[k].toFixed(0)} (want ${lo}-${hi})`).join(" "));
|
||||||
|
}
|
||||||
|
console.log(failures ? `${failures} player type(s) out of band` : "balance targets met");
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/").split("/").pop())) {
|
||||||
|
const raw = process.argv.slice(2);
|
||||||
|
const args = raw.filter((a, i) => !a.startsWith("--") && raw[i - 1] !== "--set");
|
||||||
|
const seeds = Number(args[0] ?? 12);
|
||||||
|
const names = args[1] ? args[1].split(",") : Object.keys(PLAYERS);
|
||||||
|
const t0 = Date.now();
|
||||||
|
if (raw.includes("--check")) { check(Number(args[0] ?? 24)); process.exit(); }
|
||||||
|
for (const name of names) {
|
||||||
|
const [b, p] = PLAYERS[name];
|
||||||
|
const runs = [];
|
||||||
|
for (let s = 1; s <= seeds; s++) runs.push(playOne(b, p, s));
|
||||||
|
report(name, runs);
|
||||||
|
}
|
||||||
|
console.log(`(${((Date.now() - t0) / 1000).toFixed(0)} s, ${seeds} seeds each)`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Hill-climbing search for the best budget policy (M8): shows what the ceiling of the game is and
|
||||||
|
// how a strong player would play, so the balance can be tuned against it.
|
||||||
|
// node tools/balance-search.mjs [iterations] [seeds] [cardPolicy]
|
||||||
|
// Prints the best parameters found and re-checks them on fresh seeds.
|
||||||
|
|
||||||
|
import * as E from "../web/js/engine/index.js";
|
||||||
|
import { bal, playWith } from "./balance-check.mjs";
|
||||||
|
|
||||||
|
const GROUPS = {
|
||||||
|
core: ["health", "social", "housing"],
|
||||||
|
order: ["police", "justice", "administration"],
|
||||||
|
army: ["army"],
|
||||||
|
growth: ["education", "research", "infrastructure", "industry", "transport"],
|
||||||
|
soft: ["culture", "foreignAffairs", "environment"],
|
||||||
|
};
|
||||||
|
const GKEYS = Object.keys(GROUPS);
|
||||||
|
const TAXES = ["income", "corporate", "vat"];
|
||||||
|
// every group multiplier and every tax shift has an early value (first two years) and a late one
|
||||||
|
const START = { brake: 0 };
|
||||||
|
const RANGE = { brake: [0, 1] };
|
||||||
|
for (const g of GKEYS) for (const ph of ["E", "L"]) { START[g + ph] = 1; RANGE[g + ph] = g === "army" ? [0.3, 2.5] : [0.4, 2.6]; }
|
||||||
|
for (const t of TAXES) for (const ph of ["E", "L"]) { START[t + ph] = 0; RANGE[t + ph] = [-10, 12]; }
|
||||||
|
|
||||||
|
export function policyFrom(p) {
|
||||||
|
return (state) => {
|
||||||
|
const ph = state.day < 730 ? "E" : "L";
|
||||||
|
const alloc = {};
|
||||||
|
for (const [g, ids] of Object.entries(GROUPS)) {
|
||||||
|
for (const id of ids) {
|
||||||
|
const s = state.sectors[id];
|
||||||
|
alloc[id] = Math.min(s.need * bal.limits.allocMaxRatio, s.need * state.ref.ratio[id] * p[g + ph]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const taxes = {};
|
||||||
|
for (const id of TAXES) taxes[id] = Math.max(0, state.ref.taxes[id] + p[id + ph]);
|
||||||
|
// debt brake: above 90 % of GDP, trim every sector toward the baseline and add a point of VAT
|
||||||
|
if (p.brake > 0) {
|
||||||
|
const f = E.computeFiscal(state, bal);
|
||||||
|
if (f.debtRatio > 90) {
|
||||||
|
const k = 1 - 0.25 * p.brake;
|
||||||
|
for (const id of Object.keys(alloc)) alloc[id] *= k;
|
||||||
|
taxes.vat += 2 * p.brake;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { alloc, taxes };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(p, seeds, policy) {
|
||||||
|
let total = 0;
|
||||||
|
let survived = 0;
|
||||||
|
for (const seed of seeds) {
|
||||||
|
const r = playWith(policyFrom(p), policy, seed);
|
||||||
|
total += r.end === "term" ? r.score : r.score * 0.5;
|
||||||
|
if (r.end === "term") survived++;
|
||||||
|
}
|
||||||
|
return { mean: total / seeds.length, survival: survived / seeds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterations = Number(process.argv[2] ?? 120);
|
||||||
|
const nSeeds = Number(process.argv[3] ?? 8);
|
||||||
|
const policy = process.argv[4] ?? "intuitive";
|
||||||
|
const seeds = Array.from({ length: nSeeds }, (_, i) => 100 + i);
|
||||||
|
|
||||||
|
let best = { ...START };
|
||||||
|
let bestScore = score(best, seeds, policy);
|
||||||
|
console.log("start", JSON.stringify(best), bestScore);
|
||||||
|
let seedState = 12345;
|
||||||
|
const rnd = () => (seedState = (seedState * 1664525 + 1013904223) % 4294967296) / 4294967296;
|
||||||
|
|
||||||
|
for (let it = 0; it < iterations; it++) {
|
||||||
|
const cand = { ...best };
|
||||||
|
const keys = Object.keys(RANGE);
|
||||||
|
const nChange = 1 + Math.floor(rnd() * 3);
|
||||||
|
for (let c = 0; c < nChange; c++) {
|
||||||
|
const k = keys[Math.floor(rnd() * keys.length)];
|
||||||
|
const [lo, hi] = RANGE[k];
|
||||||
|
const step = (hi - lo) * 0.18 * (rnd() * 2 - 1);
|
||||||
|
cand[k] = Math.min(hi, Math.max(lo, cand[k] + step));
|
||||||
|
}
|
||||||
|
const sc = score(cand, seeds, policy);
|
||||||
|
if (sc.mean > bestScore.mean) {
|
||||||
|
best = cand;
|
||||||
|
bestScore = sc;
|
||||||
|
console.log(`it ${it}`, JSON.stringify(Object.fromEntries(Object.entries(best).map(([k, v]) => [k, +v.toFixed(2)]))), JSON.stringify({ mean: +sc.mean.toFixed(1), survival: sc.survival }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = Array.from({ length: 24 }, (_, i) => 500 + i);
|
||||||
|
const check = score(best, fresh, policy);
|
||||||
|
console.log("fresh seeds:", JSON.stringify({ mean: +check.mean.toFixed(1), survival: check.survival }));
|
||||||
+62
-2
@@ -170,6 +170,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
const drawn = new Set();
|
const drawn = new Set();
|
||||||
for (let guard = 0; guard < 6000 && !gg.over; guard++) {
|
for (let guard = 0; guard < 6000 && !gg.over; guard++) {
|
||||||
if (gg.pending.review) E.confirmBudget(gg);
|
if (gg.pending.review) E.confirmBudget(gg);
|
||||||
|
while (gg.pending.alert) E.dismissAlert(gg);
|
||||||
if (gg.pending.weeklyCard) {
|
if (gg.pending.weeklyCard) {
|
||||||
const card = E.ensureCard(gg, bal, real);
|
const card = E.ensureCard(gg, bal, real);
|
||||||
if (card) { drawn.add(card.id); E.answerCard(gg, bal, real, E.nextFloat(gg) < 0.5 ? "yes" : "no"); }
|
if (card) { drawn.add(card.id); E.answerCard(gg, bal, real, E.nextFloat(gg) < 0.5 ? "yes" : "no"); }
|
||||||
@@ -179,7 +180,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
return { gg, drawn };
|
return { gg, drawn };
|
||||||
};
|
};
|
||||||
const a = play(11), b = play(11);
|
const a = play(11), b = play(11);
|
||||||
assert.ok(a.gg.over && a.gg.over.type === "term", "random play reaches the end of the term");
|
assert.ok(a.gg.over, "random play ends one way or another (the term, or a fall: passive play is risky since M8)");
|
||||||
assert.deepEqual(a.gg, b.gg, "same seed and answers give the same game");
|
assert.deepEqual(a.gg, b.gg, "same seed and answers give the same game");
|
||||||
assert.ok(a.drawn.size >= 40, "most of the pool is seen in a run: " + a.drawn.size);
|
assert.ok(a.drawn.size >= 40, "most of the pool is seen in a run: " + a.drawn.size);
|
||||||
assert.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length);
|
assert.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length);
|
||||||
@@ -274,7 +275,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
const calm = run(21);
|
const calm = run(21);
|
||||||
assert.equal(calm.over.type, "term");
|
assert.equal(calm.over.type, "term");
|
||||||
const rv = E.buildReview(calm, bal, real, endgame);
|
const rv = E.buildReview(calm, bal, real, endgame);
|
||||||
assert.ok(rv.score > 40 && rv.score < 80, "a hands-off term scores mid-range: " + rv.score);
|
assert.ok(rv.score > 20 && rv.score < 80, "a hands-off term scores in range (low since M8): " + rv.score);
|
||||||
assert.ok(["S", "A", "B", "C", "D", "F"].includes(rv.grade));
|
assert.ok(["S", "A", "B", "C", "D", "F"].includes(rv.grade));
|
||||||
assert.ok(endgame.titles.some((x) => x.id === rv.titleId && !x.over), "a term title");
|
assert.ok(endgame.titles.some((x) => x.id === rv.titleId && !x.over), "a term title");
|
||||||
assert.ok(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5);
|
assert.ok(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5);
|
||||||
@@ -432,4 +433,63 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
|
||||||
|
{
|
||||||
|
// the world cycle moves, stays within bounds and is part of the saved state
|
||||||
|
let w = fresh(); open(w);
|
||||||
|
let lo = 0, hi = 0;
|
||||||
|
for (let i = 0; i < 1500; i++) { run(w, 1); lo = Math.min(lo, w.world.cycle); hi = Math.max(hi, w.world.cycle); }
|
||||||
|
assert.ok(hi - lo > 1, "the cycle moves: " + (hi - lo));
|
||||||
|
assert.ok(lo >= -bal.world.max && hi <= bal.world.max);
|
||||||
|
|
||||||
|
// needs grow, so a frozen budget buys less quality
|
||||||
|
const n0 = fresh().sectors.health.need;
|
||||||
|
const g1 = fresh(); run(g1, 365);
|
||||||
|
assert.ok(Math.abs(g1.sectors.health.need / n0 - (1 + bal.needGrowth.health)) < 1e-6, "health need grows by its yearly rate");
|
||||||
|
|
||||||
|
// conditions can read the player's choices: budget ratio and tax rate
|
||||||
|
const c = fresh(); open(c);
|
||||||
|
const cond = (conditions) => E.drawCard(c, bal, [{ id: "x", category: "crises", conditions, yes: {}, no: {} }]);
|
||||||
|
assert.equal(cond({ min: { "ratio:police": 1.5 } }), null, "police at its starting ratio is below 1.5");
|
||||||
|
assert.equal(cond({ max: { "ratio:police": 1.5 } })?.id, "x");
|
||||||
|
c.sectors.police.alloc = c.sectors.police.need * 2;
|
||||||
|
assert.equal(cond({ min: { "ratio:police": 1.5 } })?.id, "x");
|
||||||
|
assert.equal(cond({ min: { "tax:income": 31 } }), null);
|
||||||
|
c.economy.taxes.income = 45;
|
||||||
|
assert.equal(cond({ min: { "tax:income": 31 } })?.id, "x");
|
||||||
|
assert.equal(cond({ min: { "ratio:nope": 0 } }), null, "unknown sector is never eligible");
|
||||||
|
|
||||||
|
// severe army neglect alone is enough for a coup (stability fine)
|
||||||
|
const k = fresh(); open(k);
|
||||||
|
for (let i = 0; i < 400 && !k.over; i++) { k.indicators.armyLoyalty = bal.gameOver.coup.armyAlone - 1; k.indicators.stability = 90; while (k.pending.alert) E.dismissAlert(k); run(k, 1); }
|
||||||
|
assert.equal(k.over?.type, "coup");
|
||||||
|
|
||||||
|
// a new danger warning opens an emergency budget session, at most once per cooldown
|
||||||
|
const em = fresh(); open(em);
|
||||||
|
em.indicators.unrest = 95; em.indicators.opinion = 2;
|
||||||
|
E.advanceDay(em, bal);
|
||||||
|
assert.equal(em.alerts.length, 1);
|
||||||
|
assert.equal(em.pending.review, true, "the warning opens the budget");
|
||||||
|
assert.equal(em.risk.emergency, true);
|
||||||
|
E.confirmBudget(em);
|
||||||
|
assert.equal(em.risk.emergency, false, "signing closes the emergency session");
|
||||||
|
while (em.pending.alert) E.dismissAlert(em);
|
||||||
|
// leave the zone, re-enter it inside the cooldown: a second alert but no second session
|
||||||
|
em.indicators.unrest = 20; em.indicators.opinion = 60;
|
||||||
|
for (let i = 0; i < 3; i++) { if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard"); E.advanceDay(em, bal); }
|
||||||
|
em.indicators.unrest = 95; em.indicators.opinion = 2;
|
||||||
|
if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard");
|
||||||
|
E.advanceDay(em, bal);
|
||||||
|
assert.equal(em.alerts.length, 1, "a second warning");
|
||||||
|
assert.equal(em.pending.review, false, "no second session inside the cooldown");
|
||||||
|
|
||||||
|
// a version-1 save is upgraded with a neutral world and then resumes
|
||||||
|
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||||
|
const content = { scenarios: { default: scn }, balance: bal, cards: [] };
|
||||||
|
const v1 = clone(fresh()); delete v1.world; v1.version = 1;
|
||||||
|
const up = decodeSave(encodeSave(v1));
|
||||||
|
assert.equal(up.ok, true); assert.equal(up.state.version, 2); assert.deepEqual(up.state.world, { cycle: 0 });
|
||||||
|
assert.equal(E.restoreState(up.state, content).ok, true);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("all engine checks passed");
|
console.log("all engine checks passed");
|
||||||
|
|||||||
@@ -143,3 +143,16 @@
|
|||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
.ledger input[type="range"]:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
.ledger input[type="range"]:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
||||||
|
|
||||||
|
.ledger__emergency {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #8a1c14;
|
||||||
|
border: 1.5px solid #8a1c14;
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: rotate(-0.6deg);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|||||||
+112
-67
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
||||||
"time": {
|
"time": {
|
||||||
"daysPerWeek": 7,
|
"daysPerWeek": 7,
|
||||||
"daysPerQuarter": 91,
|
"daysPerQuarter": 91,
|
||||||
@@ -10,16 +9,33 @@
|
|||||||
"speeds": [1, 2, 4],
|
"speeds": [1, 2, 4],
|
||||||
"historyEvery": 7
|
"historyEvery": 7
|
||||||
},
|
},
|
||||||
|
|
||||||
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
||||||
|
|
||||||
"noise": { "indicator": 0.35 },
|
"noise": { "indicator": 0.35 },
|
||||||
|
"gain": 3,
|
||||||
|
"world": { "tau": 220, "noise": 0.22, "max": 4, "shockChancePerDay": 0.0012, "shock": 2.5, "boomShare": 0.3 },
|
||||||
|
"fiscal": { "growthSens": 0.25 },
|
||||||
|
"needGrowth": {
|
||||||
|
"education": 0.015,
|
||||||
|
"health": 0.035,
|
||||||
|
"social": 0.025,
|
||||||
|
"police": 0.02,
|
||||||
|
"army": 0.015,
|
||||||
|
"justice": 0.02,
|
||||||
|
"industry": 0.01,
|
||||||
|
"transport": 0.02,
|
||||||
|
"infrastructure": 0.03,
|
||||||
|
"housing": 0.025,
|
||||||
|
"research": 0.01,
|
||||||
|
"culture": 0.01,
|
||||||
|
"foreignAffairs": 0.01,
|
||||||
|
"environment": 0.02,
|
||||||
|
"administration": 0.015
|
||||||
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"recentWindow": 3,
|
"recentWindow": 3,
|
||||||
"recentPenalty": 0.4,
|
"recentPenalty": 0.4,
|
||||||
"boostBelow": 40,
|
"boostBelow": 40,
|
||||||
"boostMax": 1.0,
|
"boostMax": 1,
|
||||||
"defaultCooldown": 180,
|
"defaultCooldown": 180,
|
||||||
"boost": {
|
"boost": {
|
||||||
"economy": { "indicator": "economy" },
|
"economy": { "indicator": "economy" },
|
||||||
@@ -37,13 +53,7 @@
|
|||||||
"culture": { "sector": "culture" }
|
"culture": { "sector": "culture" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sector": { "qualityCurve": 0.9, "industryDebtPenaltyFrom": 90, "industryDebtPenaltyPerPoint": 0.25 },
|
||||||
"sector": {
|
|
||||||
"qualityCurve": 0.9,
|
|
||||||
"industryDebtPenaltyFrom": 90,
|
|
||||||
"industryDebtPenaltyPerPoint": 0.25
|
|
||||||
},
|
|
||||||
|
|
||||||
"sectorTau": {
|
"sectorTau": {
|
||||||
"education": 365,
|
"education": 365,
|
||||||
"health": 90,
|
"health": 90,
|
||||||
@@ -61,117 +71,152 @@
|
|||||||
"environment": 365,
|
"environment": 365,
|
||||||
"administration": 90
|
"administration": 90
|
||||||
},
|
},
|
||||||
|
"debt": { "freeBelow": 60, "baseRate": 3, "premiumFrom": 40, "premiumCoef": 0.0008 },
|
||||||
"debt": {
|
|
||||||
"freeBelow": 60,
|
|
||||||
"baseRate": 3.0,
|
|
||||||
"premiumFrom": 40,
|
|
||||||
"premiumCoef": 0.0003
|
|
||||||
},
|
|
||||||
|
|
||||||
"crackdown": { "unrestStart": 45 },
|
"crackdown": { "unrestStart": 45 },
|
||||||
|
|
||||||
"gameOver": {
|
"gameOver": {
|
||||||
"default": { "debtAbove": 200, "warnAbove": 160 },
|
"default": { "debtAbove": 165, "warnAbove": 135 },
|
||||||
"impeachment": { "civilRightsBelow": 10, "opinionBelow": 25, "days": 60 },
|
"impeachment": { "civilRightsBelow": 10, "opinionBelow": 25, "days": 60 },
|
||||||
"coup": { "armyBelow": 35, "stabilityBelow": 35, "chancePerDay": 0.015 },
|
"coup": { "armyBelow": 35, "stabilityBelow": 50, "armyAlone": 18, "chancePerDay": 0.004 },
|
||||||
"revolution": { "unrestAbove": 70, "opinionBelow": 20, "chancePerDay": 0.02 }
|
"revolution": { "unrestAbove": 62, "opinionBelow": 28, "chancePerDay": 0.004 },
|
||||||
|
"emergencyCooldown": 45
|
||||||
},
|
},
|
||||||
|
|
||||||
"score": {
|
"score": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"opinion": 0.20, "stability": 0.15, "economy": 0.15, "order": 0.10,
|
"opinion": 0.2,
|
||||||
"civilRights": 0.10, "softPower": 0.10, "hardPower": 0.10, "calm": 0.10
|
"stability": 0.15,
|
||||||
|
"economy": 0.15,
|
||||||
|
"order": 0.1,
|
||||||
|
"civilRights": 0.1,
|
||||||
|
"softPower": 0.1,
|
||||||
|
"hardPower": 0.1,
|
||||||
|
"calm": 0.1
|
||||||
},
|
},
|
||||||
"debt": { "ref": 60, "per": 0.12, "min": -10, "max": 6 },
|
"debt": { "ref": 60, "per": 0.2, "min": -16, "max": 6 },
|
||||||
"growth": { "ref": 2, "per": 2.5, "min": -5, "max": 7.5 },
|
"growth": { "ref": 2, "per": 2.5, "min": -5, "max": 7.5 },
|
||||||
"unemployment": { "ref": 6.5, "per": 1, "min": -5, "max": 3 },
|
"unemployment": { "ref": 6.5, "per": 1, "min": -5, "max": 3 },
|
||||||
"overCap": 35,
|
"overCap": 35,
|
||||||
"grades": [[75, "S"], [66, "A"], [58, "B"], [50, "C"], [40, "D"], [0, "F"]],
|
"grades": [[73, "S"], [64, "A"], [56, "B"], [47, "C"], [38, "D"], [0, "F"]],
|
||||||
"topDecisions": 5
|
"topDecisions": 5,
|
||||||
|
"lastYearShare": 0.5
|
||||||
},
|
},
|
||||||
|
|
||||||
"tax": {
|
"tax": {
|
||||||
"income": { "coef": 0.50, "peak": 60, "opinion": 0.50, "growth": 0.030 },
|
"income": { "coef": 0.5, "peak": 60, "opinion": 0.5, "growth": 0.03 },
|
||||||
"corporate": { "coef": 0.25, "peak": 50, "opinion": 0.15, "growth": 0.060 },
|
"corporate": { "coef": 0.25, "peak": 50, "opinion": 0.15, "growth": 0.06 },
|
||||||
"vat": { "coef": 0.55, "peak": 40, "opinion": 0.60, "growth": 0.025 }
|
"vat": { "coef": 0.55, "peak": 40, "opinion": 0.6, "growth": 0.025 }
|
||||||
},
|
},
|
||||||
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"growth": {
|
"growth": {
|
||||||
"tau": 60, "min": -15, "max": 12, "noise": 0.02,
|
"tau": 60,
|
||||||
"sectors": { "industry": 0.02, "infrastructure": 0.012, "research": 0.015, "education": 0.010, "transport": 0.006 },
|
"min": -15,
|
||||||
|
"max": 12,
|
||||||
|
"noise": 0.02,
|
||||||
|
"sectors": { "industry": 0.02, "infrastructure": 0.012, "research": 0.015, "education": 0.01, "transport": 0.006 },
|
||||||
"econ": { "debt": -0.03, "taxGrowthDrag": -1 },
|
"econ": { "debt": -0.03, "taxGrowthDrag": -1 },
|
||||||
"ind": { "unrest": -0.02 }
|
"world": { "cycle": 0.8 },
|
||||||
|
"ind": { "unrest": -0.02 },
|
||||||
|
"excess": { "research": [70, 0.012], "education": [70, 0.01], "infrastructure": [70, 0.012], "industry": [70, 0.012] },
|
||||||
|
"short": { "infrastructure": [40, 0.01], "industry": [40, 0.012] }
|
||||||
},
|
},
|
||||||
"unemployment": {
|
"unemployment": {
|
||||||
"tau": 120, "min": 1, "max": 40, "noise": 0.01,
|
"tau": 120,
|
||||||
|
"min": 1,
|
||||||
|
"max": 40,
|
||||||
|
"noise": 0.01,
|
||||||
"sectors": { "industry": -0.03, "education": -0.015, "infrastructure": -0.01 },
|
"sectors": { "industry": -0.03, "education": -0.015, "infrastructure": -0.01 },
|
||||||
"econ": { "growth": -0.6 }
|
"econ": { "growth": -0.6 }
|
||||||
},
|
},
|
||||||
"inflation": {
|
"inflation": {
|
||||||
"tau": 90, "min": -2, "max": 40, "noise": 0.015,
|
"tau": 90,
|
||||||
"econ": { "deficit": 0.35, "growth": 0.15, "debt": 0.02 }
|
"min": -2,
|
||||||
|
"max": 40,
|
||||||
|
"noise": 0.015,
|
||||||
|
"econ": { "deficit": 0.35, "growth": 0.15, "debt": 0.02 },
|
||||||
|
"world": { "cycle": 0.3 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"indicators": {
|
"indicators": {
|
||||||
"order": {
|
"order": {
|
||||||
"tau": 30,
|
"tau": 30,
|
||||||
"sectors": { "police": 0.35, "justice": 0.20, "administration": 0.08 },
|
"sectors": { "police": 0.35, "justice": 0.2, "administration": 0.08 },
|
||||||
"econ": { "unemployment": -0.6, "crackdown": 25 },
|
"econ": { "unemployment": -0.6, "crackdown": 25 },
|
||||||
"ind": { "unrest": -0.30 }
|
"ind": { "unrest": -0.3 },
|
||||||
|
"drift": -0.8,
|
||||||
|
"excess": { "police": [75, 0.15], "justice": [70, 0.2] }
|
||||||
},
|
},
|
||||||
"stability": {
|
"stability": {
|
||||||
"tau": 45,
|
"tau": 45,
|
||||||
"sectors": { "social": 0.15, "health": 0.08, "housing": 0.08, "justice": 0.08, "administration": 0.10 },
|
"sectors": { "social": 0.15, "health": 0.08, "housing": 0.08, "justice": 0.08, "administration": 0.1 },
|
||||||
"econ": { "debt": -0.15, "unemployment": -0.7, "inflation": -0.4 },
|
"econ": { "debt": -0.15, "unemployment": -0.7, "inflation": -0.4 },
|
||||||
"ind": { "unrest": -0.25, "opinion": 0.15, "order": 0.10, "economy": 0.08 }
|
"ind": { "unrest": -0.25, "opinion": 0.15, "order": 0.1, "economy": 0.08 },
|
||||||
},
|
"drift": -0.8
|
||||||
"civilRights": {
|
|
||||||
"tau": 40,
|
|
||||||
"sectors": { "justice": 0.30 },
|
|
||||||
"alloc": { "police": -20, "army": -4 },
|
|
||||||
"econ": { "crackdown": -35 }
|
|
||||||
},
|
},
|
||||||
|
"civilRights": { "tau": 40, "sectors": { "justice": 0.3 }, "alloc": { "police": -20, "army": -4 }, "econ": { "crackdown": -35 } },
|
||||||
"opinion": {
|
"opinion": {
|
||||||
"tau": 30,
|
"tau": 30,
|
||||||
"sectors": { "health": 0.22, "education": 0.08, "social": 0.28, "housing": 0.18, "transport": 0.10, "infrastructure": 0.08, "culture": 0.05, "environment": 0.04 },
|
"sectors": {
|
||||||
|
"health": 0.22,
|
||||||
|
"education": 0.08,
|
||||||
|
"social": 0.28,
|
||||||
|
"housing": 0.18,
|
||||||
|
"transport": 0.1,
|
||||||
|
"infrastructure": 0.08,
|
||||||
|
"culture": 0.05,
|
||||||
|
"environment": 0.04
|
||||||
|
},
|
||||||
"econ": { "unemployment": -1.4, "inflation": -1.1, "growth": 1.8, "taxPressure": -1.2, "crackdown": -4 },
|
"econ": { "unemployment": -1.4, "inflation": -1.1, "growth": 1.8, "taxPressure": -1.2, "crackdown": -4 },
|
||||||
"ind": { "civilRights": 0.08, "order": 0.08, "stability": 0.10 }
|
"ind": { "civilRights": 0.08, "order": 0.08, "stability": 0.1 },
|
||||||
|
"drift": -1.4,
|
||||||
|
"excess": { "health": [70, 0.25], "social": [70, 0.2], "housing": [70, 0.15] },
|
||||||
|
"short": { "health": [40, 0.25], "social": [40, 0.2] }
|
||||||
},
|
},
|
||||||
"economy": {
|
"economy": {
|
||||||
"tau": 30,
|
"tau": 30,
|
||||||
"sectors": { "industry": 0.30, "infrastructure": 0.15, "research": 0.12, "transport": 0.10, "education": 0.10, "administration": 0.05 },
|
"sectors": {
|
||||||
|
"industry": 0.3,
|
||||||
|
"infrastructure": 0.15,
|
||||||
|
"research": 0.12,
|
||||||
|
"transport": 0.1,
|
||||||
|
"education": 0.1,
|
||||||
|
"administration": 0.05
|
||||||
|
},
|
||||||
"econ": { "growth": 6, "unemployment": -1.2, "inflation": -0.8, "debt": -0.12 },
|
"econ": { "growth": 6, "unemployment": -1.2, "inflation": -0.8, "debt": -0.12 },
|
||||||
"ind": { "stability": 0.10 }
|
"ind": { "stability": 0.1 },
|
||||||
|
"excess": { "education": [70, 0.2], "research": [70, 0.25], "infrastructure": [70, 0.2], "industry": [70, 0.25] },
|
||||||
|
"short": { "infrastructure": [40, 0.2], "industry": [40, 0.2] }
|
||||||
},
|
},
|
||||||
"hardPower": {
|
"hardPower": {
|
||||||
"tau": 60,
|
"tau": 60,
|
||||||
"sectors": { "army": 0.50, "research": 0.10, "industry": 0.08 },
|
"sectors": { "army": 0.5, "research": 0.1, "industry": 0.08 },
|
||||||
"alloc": { "army": 8 },
|
"alloc": { "army": 8 },
|
||||||
"econ": { "debt": -0.05 },
|
"econ": { "debt": -0.05 },
|
||||||
"ind": { "economy": 0.08 }
|
"ind": { "economy": 0.08 },
|
||||||
|
"drift": -0.8,
|
||||||
|
"excess": { "army": [75, 0.3], "research": [70, 0.15] }
|
||||||
},
|
},
|
||||||
"softPower": {
|
"softPower": {
|
||||||
"tau": 60,
|
"tau": 60,
|
||||||
"sectors": { "culture": 0.30, "foreignAffairs": 0.35, "education": 0.08, "research": 0.08, "environment": 0.05 },
|
"sectors": { "culture": 0.3, "foreignAffairs": 0.35, "education": 0.08, "research": 0.08, "environment": 0.05 },
|
||||||
"alloc": { "police": -4 },
|
"alloc": { "police": -4 },
|
||||||
"econ": { "crackdown": -15 },
|
"econ": { "crackdown": -15 },
|
||||||
"ind": { "civilRights": 0.15, "economy": 0.05 }
|
"ind": { "civilRights": 0.15, "economy": 0.05 },
|
||||||
|
"drift": -0.55,
|
||||||
|
"excess": { "culture": [70, 0.3], "foreignAffairs": [70, 0.3] }
|
||||||
},
|
},
|
||||||
"unrest": {
|
"unrest": {
|
||||||
"tau": 20,
|
"tau": 20,
|
||||||
"sectors": { "social": -0.15, "housing": -0.10 },
|
"sectors": { "social": -0.15, "housing": -0.1 },
|
||||||
"econ": { "unemployment": 2.5, "inflation": 1.0, "debt": 0.20, "crackdown": -10 },
|
"econ": { "unemployment": 2.5, "inflation": 1, "debt": 0.2, "crackdown": -10 },
|
||||||
"ind": { "opinion": -0.50, "stability": -0.25, "order": -0.15, "civilRights": -0.12 }
|
"ind": { "opinion": -0.5, "stability": -0.25, "order": -0.15, "civilRights": -0.12 },
|
||||||
|
"drift": 1.1
|
||||||
},
|
},
|
||||||
"armyLoyalty": {
|
"armyLoyalty": {
|
||||||
"tau": 60,
|
"tau": 60,
|
||||||
"sectors": { "army": 0.40 },
|
"sectors": { "army": 0.4 },
|
||||||
"alloc": { "army": 15 },
|
"alloc": { "army": 15 },
|
||||||
"econ": { "debt": -0.10, "inflation": -0.3 },
|
"econ": { "debt": -0.1, "inflation": -0.3 },
|
||||||
"ind": { "opinion": 0.05, "stability": 0.10 }
|
"ind": { "opinion": 0.05, "stability": 0.1 },
|
||||||
|
"drift": -1.1
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"driftScale": 1.1
|
||||||
}
|
}
|
||||||
|
|||||||
+2987
-13
File diff suppressed because it is too large
Load Diff
+1040
-12
File diff suppressed because it is too large
Load Diff
+1629
-16
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1493
-13
File diff suppressed because it is too large
Load Diff
+1377
-17
File diff suppressed because it is too large
Load Diff
+1927
-21
File diff suppressed because it is too large
Load Diff
+1528
-14
File diff suppressed because it is too large
Load Diff
+1158
-11
File diff suppressed because it is too large
Load Diff
+1864
-14
File diff suppressed because it is too large
Load Diff
+1620
-8
File diff suppressed because it is too large
Load Diff
+1920
-18
File diff suppressed because it is too large
Load Diff
+2038
-26
File diff suppressed because it is too large
Load Diff
+1594
-14
File diff suppressed because it is too large
Load Diff
+296
-107
@@ -1,214 +1,403 @@
|
|||||||
{
|
{
|
||||||
"name": { "en": "The Daily Herald", "fr": "Le Courrier du Jour" },
|
"name": { "en": "The Daily Herald", "fr": "Le Courrier du Jour" },
|
||||||
|
|
||||||
"intro": [
|
"intro": [
|
||||||
{ "h": { "en": "A new government takes office", "fr": "Un nouveau gouvernement prend ses fonctions" },
|
{
|
||||||
"d": { "en": "{country} awaits its first decisions.", "fr": "{country} attend ses premières décisions." } }
|
"h": { "en": "A new government takes office", "fr": "Un nouveau gouvernement prend ses fonctions" },
|
||||||
|
"d": { "en": "{country} awaits its first decisions.", "fr": "{country} attend ses premières décisions." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"quiet": [
|
"quiet": [
|
||||||
{ "h": { "en": "A quiet week in the capital", "fr": "Semaine calme dans la capitale" },
|
{
|
||||||
"d": { "en": "Ministers report nothing of note.", "fr": "Les ministres n'ont rien à signaler." } },
|
"h": { "en": "A quiet week in the capital", "fr": "Semaine calme dans la capitale" },
|
||||||
{ "h": { "en": "Markets and streets stay calm", "fr": "Les marchés et les rues restent calmes" },
|
"d": { "en": "Ministers report nothing of note.", "fr": "Les ministres n'ont rien à signaler." }
|
||||||
"d": { "en": "Analysts expect no change in the weeks ahead.", "fr": "Les analystes n'attendent pas de changement dans les semaines à venir." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Markets and streets stay calm", "fr": "Les marchés et les rues restent calmes" },
|
||||||
|
"d": {
|
||||||
|
"en": "Analysts expect no change in the weeks ahead.",
|
||||||
|
"fr": "Les analystes n'attendent pas de changement dans les semaines à venir."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"ind": {
|
"ind": {
|
||||||
"order": {
|
"order": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Streets calmer as order improves", "fr": "Les rues s'apaisent, l'ordre se rétablit" },
|
{
|
||||||
"d": { "en": "Police report fewer incidents across the country.", "fr": "La police signale moins d'incidents dans tout le pays." } },
|
"h": { "en": "Streets calmer as order improves", "fr": "Les rues s'apaisent, l'ordre se rétablit" },
|
||||||
{ "h": { "en": "Crime figures fall for a month", "fr": "La criminalité recule depuis un mois" },
|
"d": {
|
||||||
"d": { "en": "The interior ministry credits tighter patrols.", "fr": "Le ministère de l'Intérieur salue des patrouilles renforcées." } }
|
"en": "Police report fewer incidents across the country.",
|
||||||
|
"fr": "La police signale moins d'incidents dans tout le pays."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Crime figures fall for a month", "fr": "La criminalité recule depuis un mois" },
|
||||||
|
"d": {
|
||||||
|
"en": "The interior ministry credits tighter patrols.",
|
||||||
|
"fr": "Le ministère de l'Intérieur salue des patrouilles renforcées."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Disorder spreads in the cities", "fr": "Le désordre gagne les villes" },
|
{
|
||||||
"d": { "en": "Officials admit control is slipping.", "fr": "Les autorités admettent perdre le contrôle." } },
|
"h": { "en": "Disorder spreads in the cities", "fr": "Le désordre gagne les villes" },
|
||||||
{ "h": { "en": "Lawlessness on the rise", "fr": "L'insécurité progresse" },
|
"d": { "en": "Officials admit control is slipping.", "fr": "Les autorités admettent perdre le contrôle." }
|
||||||
"d": { "en": "Shopkeepers demand protection.", "fr": "Les commerçants réclament une protection." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Lawlessness on the rise", "fr": "L'insécurité progresse" },
|
||||||
|
"d": { "en": "Shopkeepers demand protection.", "fr": "Les commerçants réclament une protection." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"stability": {
|
"stability": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Government steadies after turbulent weeks", "fr": "Le gouvernement se reprend après des semaines agitées" },
|
{
|
||||||
"d": { "en": "Coalition partners close ranks.", "fr": "Les partenaires de la coalition resserrent les rangs." } },
|
"h": {
|
||||||
{ "h": { "en": "Political climate cools", "fr": "Le climat politique se détend" },
|
"en": "Government steadies after turbulent weeks",
|
||||||
"d": { "en": "Analysts note fewer cabinet rifts.", "fr": "Les analystes notent moins de divisions au gouvernement." } }
|
"fr": "Le gouvernement se reprend après des semaines agitées"
|
||||||
|
},
|
||||||
|
"d": { "en": "Coalition partners close ranks.", "fr": "Les partenaires de la coalition resserrent les rangs." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Political climate cools", "fr": "Le climat politique se détend" },
|
||||||
|
"d": {
|
||||||
|
"en": "Analysts note fewer cabinet rifts.",
|
||||||
|
"fr": "Les analystes notent moins de divisions au gouvernement."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Cracks show in the ruling circle", "fr": "Des fissures dans le cercle dirigeant" },
|
{
|
||||||
"d": { "en": "Rumours of a reshuffle grow.", "fr": "Les rumeurs de remaniement se multiplient." } },
|
"h": { "en": "Cracks show in the ruling circle", "fr": "Des fissures dans le cercle dirigeant" },
|
||||||
{ "h": { "en": "Instability worries diplomats", "fr": "L'instabilité inquiète les diplomates" },
|
"d": { "en": "Rumours of a reshuffle grow.", "fr": "Les rumeurs de remaniement se multiplient." }
|
||||||
"d": { "en": "Embassies advise caution.", "fr": "Les ambassades recommandent la prudence." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Instability worries diplomats", "fr": "L'instabilité inquiète les diplomates" },
|
||||||
|
"d": { "en": "Embassies advise caution.", "fr": "Les ambassades recommandent la prudence." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"civilRights": {
|
"civilRights": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Freedoms widen, say rights groups", "fr": "Les libertés s'élargissent, selon les ONG" },
|
{
|
||||||
"d": { "en": "Press and assembly face fewer limits.", "fr": "La presse et les rassemblements sont moins entravés." } },
|
"h": { "en": "Freedoms widen, say rights groups", "fr": "Les libertés s'élargissent, selon les ONG" },
|
||||||
{ "h": { "en": "Courts restore protections", "fr": "Les tribunaux rétablissent des protections" },
|
"d": {
|
||||||
"d": { "en": "Lawyers hail landmark rulings.", "fr": "Les avocats saluent des décisions historiques." } }
|
"en": "Press and assembly face fewer limits.",
|
||||||
|
"fr": "La presse et les rassemblements sont moins entravés."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Courts restore protections", "fr": "Les tribunaux rétablissent des protections" },
|
||||||
|
"d": { "en": "Lawyers hail landmark rulings.", "fr": "Les avocats saluent des décisions historiques." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Rights groups sound the alarm", "fr": "Les ONG tirent la sonnette d'alarme" },
|
{
|
||||||
"d": { "en": "Curbs on press and assembly tighten.", "fr": "Les restrictions sur la presse et les rassemblements se durcissent." } },
|
"h": { "en": "Rights groups sound the alarm", "fr": "Les ONG tirent la sonnette d'alarme" },
|
||||||
{ "h": { "en": "Civil liberties under pressure", "fr": "Les libertés civiles sous pression" },
|
"d": {
|
||||||
"d": { "en": "Critics speak of a creeping crackdown.", "fr": "Les critiques dénoncent une répression rampante." } }
|
"en": "Curbs on press and assembly tighten.",
|
||||||
|
"fr": "Les restrictions sur la presse et les rassemblements se durcissent."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Civil liberties under pressure", "fr": "Les libertés civiles sous pression" },
|
||||||
|
"d": { "en": "Critics speak of a creeping crackdown.", "fr": "Les critiques dénoncent une répression rampante." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"opinion": {
|
"opinion": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Public mood brightens", "fr": "L'humeur du public s'éclaircit" },
|
{
|
||||||
"d": { "en": "Polls show growing approval of the head of state.", "fr": "Les sondages montrent une approbation croissante du chef de l'État." } },
|
"h": { "en": "Public mood brightens", "fr": "L'humeur du public s'éclaircit" },
|
||||||
{ "h": { "en": "Approval ratings climb", "fr": "La cote de popularité grimpe" },
|
"d": {
|
||||||
"d": { "en": "Citizens credit recent measures.", "fr": "Les citoyens saluent les dernières mesures." } }
|
"en": "Polls show growing approval of the head of state.",
|
||||||
|
"fr": "Les sondages montrent une approbation croissante du chef de l'État."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Approval ratings climb", "fr": "La cote de popularité grimpe" },
|
||||||
|
"d": { "en": "Citizens credit recent measures.", "fr": "Les citoyens saluent les dernières mesures." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Anger grows among the public", "fr": "La colère monte dans la population" },
|
{
|
||||||
"d": { "en": "Approval slides in the latest polls.", "fr": "La cote de popularité chute dans les derniers sondages." } },
|
"h": { "en": "Anger grows among the public", "fr": "La colère monte dans la population" },
|
||||||
{ "h": { "en": "Discontent in the queues", "fr": "Le mécontentement gagne les files d'attente" },
|
"d": {
|
||||||
"d": { "en": "Citizens blame the government.", "fr": "Les citoyens accusent le gouvernement." } }
|
"en": "Approval slides in the latest polls.",
|
||||||
|
"fr": "La cote de popularité chute dans les derniers sondages."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Discontent in the queues", "fr": "Le mécontentement gagne les files d'attente" },
|
||||||
|
"d": { "en": "Citizens blame the government.", "fr": "Les citoyens accusent le gouvernement." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"economy": {
|
"economy": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Business confidence recovers", "fr": "La confiance des entreprises revient" },
|
{
|
||||||
"d": { "en": "Factories report fuller order books.", "fr": "Les usines annoncent des carnets de commandes mieux remplis." } },
|
"h": { "en": "Business confidence recovers", "fr": "La confiance des entreprises revient" },
|
||||||
{ "h": { "en": "Economy gathers pace", "fr": "L'économie prend de la vitesse" },
|
"d": {
|
||||||
"d": { "en": "Investors return to the capital.", "fr": "Les investisseurs reviennent dans la capitale." } }
|
"en": "Factories report fuller order books.",
|
||||||
|
"fr": "Les usines annoncent des carnets de commandes mieux remplis."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Economy gathers pace", "fr": "L'économie prend de la vitesse" },
|
||||||
|
"d": { "en": "Investors return to the capital.", "fr": "Les investisseurs reviennent dans la capitale." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Economy loses momentum", "fr": "L'économie perd de son élan" },
|
{
|
||||||
"d": { "en": "Firms cut their hiring plans.", "fr": "Les entreprises réduisent leurs projets d'embauche." } },
|
"h": { "en": "Economy loses momentum", "fr": "L'économie perd de son élan" },
|
||||||
{ "h": { "en": "Investors turn cautious", "fr": "Les investisseurs se montrent prudents" },
|
"d": { "en": "Firms cut their hiring plans.", "fr": "Les entreprises réduisent leurs projets d'embauche." }
|
||||||
"d": { "en": "The stock exchange ends the week lower.", "fr": "La Bourse termine la semaine en baisse." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Investors turn cautious", "fr": "Les investisseurs se montrent prudents" },
|
||||||
|
"d": { "en": "The stock exchange ends the week lower.", "fr": "La Bourse termine la semaine en baisse." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hardPower": {
|
"hardPower": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Armed forces strengthened", "fr": "Les forces armées se renforcent" },
|
{
|
||||||
"d": { "en": "Generals welcome new readiness figures.", "fr": "Les généraux se félicitent des nouveaux chiffres de préparation." } },
|
"h": { "en": "Armed forces strengthened", "fr": "Les forces armées se renforcent" },
|
||||||
{ "h": { "en": "Military posture hardens", "fr": "La posture militaire se durcit" },
|
"d": {
|
||||||
"d": { "en": "Neighbours take note.", "fr": "Les voisins en prennent note." } }
|
"en": "Generals welcome new readiness figures.",
|
||||||
|
"fr": "Les généraux se félicitent des nouveaux chiffres de préparation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Military posture hardens", "fr": "La posture militaire se durcit" },
|
||||||
|
"d": { "en": "Neighbours take note.", "fr": "Les voisins en prennent note." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Army readiness questioned", "fr": "La préparation de l'armée en question" },
|
{
|
||||||
"d": { "en": "Equipment shortages reported.", "fr": "Des pénuries d'équipement sont signalées." } },
|
"h": { "en": "Army readiness questioned", "fr": "La préparation de l'armée en question" },
|
||||||
{ "h": { "en": "Defence capacity slipping", "fr": "Les capacités de défense s'érodent" },
|
"d": { "en": "Equipment shortages reported.", "fr": "Des pénuries d'équipement sont signalées." }
|
||||||
"d": { "en": "Foreign analysts see a weaker military.", "fr": "Des analystes étrangers voient une armée affaiblie." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Defence capacity slipping", "fr": "Les capacités de défense s'érodent" },
|
||||||
|
"d": {
|
||||||
|
"en": "Foreign analysts see a weaker military.",
|
||||||
|
"fr": "Des analystes étrangers voient une armée affaiblie."
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"softPower": {
|
"softPower": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Country's standing grows abroad", "fr": "Le prestige du pays grandit à l'étranger" },
|
{
|
||||||
"d": { "en": "Foreign press praises the national image.", "fr": "La presse étrangère loue l'image du pays." } },
|
"h": { "en": "Country's standing grows abroad", "fr": "Le prestige du pays grandit à l'étranger" },
|
||||||
{ "h": { "en": "Cultural influence expands", "fr": "L'influence culturelle s'étend" },
|
"d": { "en": "Foreign press praises the national image.", "fr": "La presse étrangère loue l'image du pays." }
|
||||||
"d": { "en": "Embassies report warm receptions.", "fr": "Les ambassades signalent un accueil chaleureux." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Cultural influence expands", "fr": "L'influence culturelle s'étend" },
|
||||||
|
"d": { "en": "Embassies report warm receptions.", "fr": "Les ambassades signalent un accueil chaleureux." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Reputation abroad takes a hit", "fr": "La réputation à l'étranger en prend un coup" },
|
{
|
||||||
"d": { "en": "Foreign editorials turn critical.", "fr": "Les éditoriaux étrangers se font critiques." } },
|
"h": { "en": "Reputation abroad takes a hit", "fr": "La réputation à l'étranger en prend un coup" },
|
||||||
{ "h": { "en": "Diplomatic isolation feared", "fr": "On craint l'isolement diplomatique" },
|
"d": { "en": "Foreign editorials turn critical.", "fr": "Les éditoriaux étrangers se font critiques." }
|
||||||
"d": { "en": "Partners cool towards the capital.", "fr": "Les partenaires se refroidissent envers la capitale." } }
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Diplomatic isolation feared", "fr": "On craint l'isolement diplomatique" },
|
||||||
|
"d": { "en": "Partners cool towards the capital.", "fr": "Les partenaires se refroidissent envers la capitale." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"unrest": {
|
"unrest": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Protests spread across the country", "fr": "Les manifestations gagnent tout le pays" },
|
{
|
||||||
"d": { "en": "Police on alert as crowds gather.", "fr": "La police en alerte alors que les foules se rassemblent." } }
|
"h": { "en": "Protests spread across the country", "fr": "Les manifestations gagnent tout le pays" },
|
||||||
|
"d": {
|
||||||
|
"en": "Police on alert as crowds gather.",
|
||||||
|
"fr": "La police en alerte alors que les foules se rassemblent."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Tensions ease in the streets", "fr": "Les tensions retombent dans la rue" },
|
{
|
||||||
"d": { "en": "Protest movements lose steam.", "fr": "Les mouvements de protestation s'essoufflent." } }
|
"h": { "en": "Tensions ease in the streets", "fr": "Les tensions retombent dans la rue" },
|
||||||
|
"d": { "en": "Protest movements lose steam.", "fr": "Les mouvements de protestation s'essoufflent." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"armyLoyalty": {
|
"armyLoyalty": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Barracks reaffirm their loyalty", "fr": "Les casernes réaffirment leur loyauté" },
|
{
|
||||||
"d": { "en": "Officers pledge allegiance.", "fr": "Les officiers prêtent allégeance." } }
|
"h": { "en": "Barracks reaffirm their loyalty", "fr": "Les casernes réaffirment leur loyauté" },
|
||||||
|
"d": { "en": "Officers pledge allegiance.", "fr": "Les officiers prêtent allégeance." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Unease reported in the barracks", "fr": "Malaise signalé dans les casernes" },
|
{
|
||||||
"d": { "en": "Officers whisper about the leadership.", "fr": "Les officiers murmurent à propos de la direction." } }
|
"h": { "en": "Unease reported in the barracks", "fr": "Malaise signalé dans les casernes" },
|
||||||
|
"d": {
|
||||||
|
"en": "Officers whisper about the leadership.",
|
||||||
|
"fr": "Les officiers murmurent à propos de la direction."
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"var": {
|
"var": {
|
||||||
"growth": {
|
"growth": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Growth picks up", "fr": "La croissance repart" },
|
{
|
||||||
"d": { "en": "Statisticians revise forecasts upward.", "fr": "Les statisticiens revoient leurs prévisions à la hausse." } }
|
"h": { "en": "Growth picks up", "fr": "La croissance repart" },
|
||||||
|
"d": {
|
||||||
|
"en": "Statisticians revise forecasts upward.",
|
||||||
|
"fr": "Les statisticiens revoient leurs prévisions à la hausse."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Growth slows", "fr": "La croissance ralentit" },
|
{
|
||||||
"d": { "en": "Forecasts are revised downward.", "fr": "Les prévisions sont revues à la baisse." } }
|
"h": { "en": "Growth slows", "fr": "La croissance ralentit" },
|
||||||
|
"d": { "en": "Forecasts are revised downward.", "fr": "Les prévisions sont revues à la baisse." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"unemployment": {
|
"unemployment": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Jobless queues lengthen", "fr": "Les files de chômeurs s'allongent" },
|
{
|
||||||
"d": { "en": "Unemployment edges higher.", "fr": "Le chômage progresse." } }
|
"h": { "en": "Jobless queues lengthen", "fr": "Les files de chômeurs s'allongent" },
|
||||||
|
"d": { "en": "Unemployment edges higher.", "fr": "Le chômage progresse." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Unemployment falls", "fr": "Le chômage recule" },
|
{
|
||||||
"d": { "en": "Employment offices report more vacancies.", "fr": "Les agences pour l'emploi signalent plus d'offres." } }
|
"h": { "en": "Unemployment falls", "fr": "Le chômage recule" },
|
||||||
|
"d": {
|
||||||
|
"en": "Employment offices report more vacancies.",
|
||||||
|
"fr": "Les agences pour l'emploi signalent plus d'offres."
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"inflation": {
|
"inflation": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Prices climb faster", "fr": "Les prix s'envolent" },
|
{
|
||||||
"d": { "en": "Households feel the squeeze at the market.", "fr": "Les ménages ressentent la hausse au marché." } }
|
"h": { "en": "Prices climb faster", "fr": "Les prix s'envolent" },
|
||||||
|
"d": { "en": "Households feel the squeeze at the market.", "fr": "Les ménages ressentent la hausse au marché." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Inflation cools", "fr": "L'inflation se calme" },
|
{
|
||||||
"d": { "en": "Prices stabilise in the shops.", "fr": "Les prix se stabilisent en magasin." } }
|
"h": { "en": "Inflation cools", "fr": "L'inflation se calme" },
|
||||||
|
"d": { "en": "Prices stabilise in the shops.", "fr": "Les prix se stabilisent en magasin." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"debtRatio": {
|
"debtRatio": {
|
||||||
"up": [
|
"up": [
|
||||||
{ "h": { "en": "Public debt keeps growing", "fr": "La dette publique continue d'augmenter" },
|
{
|
||||||
"d": { "en": "Bond traders watch the deficit.", "fr": "Les opérateurs obligataires surveillent le déficit." } }
|
"h": { "en": "Public debt keeps growing", "fr": "La dette publique continue d'augmenter" },
|
||||||
|
"d": { "en": "Bond traders watch the deficit.", "fr": "Les opérateurs obligataires surveillent le déficit." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"down": [
|
"down": [
|
||||||
{ "h": { "en": "Debt burden eases", "fr": "Le poids de la dette s'allège" },
|
{
|
||||||
"d": { "en": "The Treasury reports progress on the deficit.", "fr": "Le Trésor annonce des progrès sur le déficit." } }
|
"h": { "en": "Debt burden eases", "fr": "Le poids de la dette s'allège" },
|
||||||
|
"d": {
|
||||||
|
"en": "The Treasury reports progress on the deficit.",
|
||||||
|
"fr": "Le Trésor annonce des progrès sur le déficit."
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"level": {
|
"level": {
|
||||||
"unrest.high": [
|
"unrest.high": [
|
||||||
{ "h": { "en": "Capital braces for unrest", "fr": "La capitale se prépare aux troubles" },
|
{
|
||||||
"d": { "en": "Police leave cancelled as crowds swell.", "fr": "Les congés de la police sont annulés alors que les foules grossissent." } }
|
"h": { "en": "Capital braces for unrest", "fr": "La capitale se prépare aux troubles" },
|
||||||
|
"d": {
|
||||||
|
"en": "Police leave cancelled as crowds swell.",
|
||||||
|
"fr": "Les congés de la police sont annulés alors que les foules grossissent."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"opinion.low": [
|
"opinion.low": [
|
||||||
{ "h": { "en": "A nation out of patience", "fr": "Une nation à bout de patience" },
|
{
|
||||||
"d": { "en": "The opposition calls for the leader to go.", "fr": "L'opposition réclame le départ du dirigeant." } }
|
"h": { "en": "A nation out of patience", "fr": "Une nation à bout de patience" },
|
||||||
|
"d": { "en": "The opposition calls for the leader to go.", "fr": "L'opposition réclame le départ du dirigeant." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"debt.high": [
|
"debt.high": [
|
||||||
{ "h": { "en": "Lenders demand a premium", "fr": "Les prêteurs exigent une prime" },
|
{
|
||||||
"d": { "en": "The Treasury warns of a debt crisis.", "fr": "Le Trésor met en garde contre une crise de la dette." } }
|
"h": { "en": "Lenders demand a premium", "fr": "Les prêteurs exigent une prime" },
|
||||||
|
"d": { "en": "The Treasury warns of a debt crisis.", "fr": "Le Trésor met en garde contre une crise de la dette." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"stability.low": [
|
"stability.low": [
|
||||||
{ "h": { "en": "Government on the brink", "fr": "Le gouvernement au bord du gouffre" },
|
{
|
||||||
"d": { "en": "Allies quietly count the votes.", "fr": "Les alliés comptent discrètement les voix." } }
|
"h": { "en": "Government on the brink", "fr": "Le gouvernement au bord du gouffre" },
|
||||||
|
"d": { "en": "Allies quietly count the votes.", "fr": "Les alliés comptent discrètement les voix." }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"civilRights.low": [
|
"civilRights.low": [
|
||||||
{ "h": { "en": "Dissent muzzled", "fr": "La contestation muselée" },
|
{
|
||||||
"d": { "en": "Prisons fill as freedoms shrink.", "fr": "Les prisons se remplissent alors que les libertés se réduisent." } }
|
"h": { "en": "Dissent muzzled", "fr": "La contestation muselée" },
|
||||||
|
"d": {
|
||||||
|
"en": "Prisons fill as freedoms shrink.",
|
||||||
|
"fr": "Les prisons se remplissent alors que les libertés se réduisent."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"inflation.high": [
|
"inflation.high": [
|
||||||
{ "h": { "en": "Prices spiral out of control", "fr": "Les prix s'envolent hors de contrôle" },
|
{
|
||||||
"d": { "en": "Shoppers face another round of rises.", "fr": "Les consommateurs subissent une nouvelle vague de hausses." } }
|
"h": { "en": "Prices spiral out of control", "fr": "Les prix s'envolent hors de contrôle" },
|
||||||
|
"d": {
|
||||||
|
"en": "Shoppers face another round of rises.",
|
||||||
|
"fr": "Les consommateurs subissent une nouvelle vague de hausses."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"deficit.high": [
|
"deficit.high": [
|
||||||
{ "h": { "en": "Treasury bleeds red ink", "fr": "Le Trésor saigne" },
|
{
|
||||||
"d": { "en": "The budget gap widens to alarming levels.", "fr": "Le déficit budgétaire atteint des niveaux alarmants." } }
|
"h": { "en": "Treasury bleeds red ink", "fr": "Le Trésor saigne" },
|
||||||
|
"d": {
|
||||||
|
"en": "The budget gap widens to alarming levels.",
|
||||||
|
"fr": "Le déficit budgétaire atteint des niveaux alarmants."
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"army.low": [
|
"army.low": [
|
||||||
{ "h": { "en": "The army grows restless", "fr": "L'armée s'impatiente" },
|
{
|
||||||
"d": { "en": "The generals' loyalty is in doubt.", "fr": "La loyauté des généraux est mise en doute." } }
|
"h": { "en": "The army grows restless", "fr": "L'armée s'impatiente" },
|
||||||
|
"d": { "en": "The generals' loyalty is in doubt.", "fr": "La loyauté des généraux est mise en doute." }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"world.slump": [
|
||||||
|
{
|
||||||
|
"h": { "en": "Global slump hits export orders", "fr": "La récession mondiale frappe les commandes à l'export" },
|
||||||
|
"d": {
|
||||||
|
"en": "Trading partners cut back; the Treasury sees receipts fall.",
|
||||||
|
"fr": "Les partenaires commerciaux freinent ; le Trésor voit ses recettes reculer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "Foreign markets sour on the region", "fr": "Les marchés étrangers se détournent de la région" },
|
||||||
|
"d": {
|
||||||
|
"en": "Economists blame a downturn abroad, not policy at home.",
|
||||||
|
"fr": "Les économistes accusent la conjoncture mondiale, pas la politique intérieure."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"world.boom": [
|
||||||
|
{
|
||||||
|
"h": { "en": "World demand lifts the economy", "fr": "La demande mondiale tire l'économie" },
|
||||||
|
"d": {
|
||||||
|
"en": "Exporters report full order books.",
|
||||||
|
"fr": "Les exportateurs affichent des carnets de commandes pleins."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": { "en": "A tailwind from abroad", "fr": "Un vent favorable venu de l'étranger" },
|
||||||
|
"d": { "en": "Global growth is good news for the Treasury.", "fr": "La croissance mondiale réjouit le Trésor." }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
// title, body, yes: Option, no: Option }
|
// title, body, yes: Option, no: Option }
|
||||||
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
|
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
|
||||||
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
|
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
|
||||||
// "deficit" (% of GDP) and "q:<sector>" (sector quality).
|
// "deficit" (% of GDP), "q:<sector>" (sector quality), "ratio:<sector>" (budget / need)
|
||||||
|
// and "tax:<income|corporate|vat>" (rate).
|
||||||
|
|
||||||
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
||||||
import { computeFiscal } from "./economy.js";
|
import { computeFiscal } from "./economy.js";
|
||||||
@@ -20,6 +21,8 @@ function statValue(state, key, fiscal) {
|
|||||||
if (key === "debt") return fiscal.debtRatio;
|
if (key === "debt") return fiscal.debtRatio;
|
||||||
if (key === "deficit") return fiscal.deficit;
|
if (key === "deficit") return fiscal.deficit;
|
||||||
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
|
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
|
||||||
|
if (key.startsWith("ratio:")) { const s = state.sectors[key.slice(6)]; return s ? s.alloc / s.need : NaN; }
|
||||||
|
if (key.startsWith("tax:")) return state.economy.taxes[key.slice(4)] ?? NaN;
|
||||||
return NaN; // unknown key: never eligible (content tests catch typos)
|
return NaN; // unknown key: never eligible (content tests catch typos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function computeFiscal(state, bal) {
|
|||||||
const e = state.economy;
|
const e = state.economy;
|
||||||
let revenue = e.otherRevenue;
|
let revenue = e.otherRevenue;
|
||||||
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
|
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
|
||||||
|
// Receipts follow the cycle: a slump costs revenue, a boom adds some.
|
||||||
|
revenue += (bal.fiscal?.growthSens ?? 0) * (e.growth - state.ref.vars.growth);
|
||||||
|
|
||||||
const debtRatio = (e.debt / e.gdp) * 100;
|
const debtRatio = (e.debt / e.gdp) * 100;
|
||||||
const rate = interestRate(debtRatio, bal);
|
const rate = interestRate(debtRatio, bal);
|
||||||
@@ -85,4 +87,5 @@ export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) {
|
|||||||
|
|
||||||
export function confirmBudget(state) {
|
export function confirmBudget(state) {
|
||||||
state.pending.review = false;
|
state.pending.review = false;
|
||||||
|
state.risk.emergency = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -4,6 +4,7 @@ import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js";
|
|||||||
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
|
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
|
||||||
import { applyShifts, ageBias } from "./modifiers.js";
|
import { applyShifts, ageBias } from "./modifiers.js";
|
||||||
import { stepSectors } from "./sectors.js";
|
import { stepSectors } from "./sectors.js";
|
||||||
|
import { stepWorld } from "./world.js";
|
||||||
import { stepIndicators, stepVars } from "./indicators.js";
|
import { stepIndicators, stepVars } from "./indicators.js";
|
||||||
import { checkGameOver } from "./gameover.js";
|
import { checkGameOver } from "./gameover.js";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
|
|||||||
sectors,
|
sectors,
|
||||||
indicators: { ...scenario.indicators },
|
indicators: { ...scenario.indicators },
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
world: { cycle: 0 }, // global business cycle, see world.js
|
||||||
flags: {},
|
flags: {},
|
||||||
cardHistory: [],
|
cardHistory: [],
|
||||||
pendingCard: null,
|
pendingCard: null,
|
||||||
@@ -51,7 +53,8 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
|
|||||||
// Any true flag here pauses time (see advanceDay).
|
// Any true flag here pauses time (see advanceDay).
|
||||||
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
|
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
|
||||||
over: null,
|
over: null,
|
||||||
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone
|
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
|
||||||
|
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
|
||||||
alerts: [], // urgent telegrams waiting to be read, oldest first
|
alerts: [], // urgent telegrams waiting to be read, oldest first
|
||||||
history: { t: [], series: {} },
|
history: { t: [], series: {} },
|
||||||
log: [],
|
log: [],
|
||||||
@@ -111,6 +114,7 @@ export function advanceDay(state, bal) {
|
|||||||
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
|
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
|
||||||
|
|
||||||
applyShifts(state, bal);
|
applyShifts(state, bal);
|
||||||
|
stepWorld(state, bal);
|
||||||
const fiscal = computeFiscal(state, bal);
|
const fiscal = computeFiscal(state, bal);
|
||||||
const devs = computeDevs(state, bal, fiscal);
|
const devs = computeDevs(state, bal, fiscal);
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ export function advanceDay(state, bal) {
|
|||||||
state.day += 1;
|
state.day += 1;
|
||||||
|
|
||||||
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
|
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
|
||||||
|
const alertsBefore = state.alerts.length;
|
||||||
const fallen = checkGameOver(state, bal);
|
const fallen = checkGameOver(state, bal);
|
||||||
if (fallen) {
|
if (fallen) {
|
||||||
state.over = { type: fallen, day: state.day };
|
state.over = { type: fallen, day: state.day };
|
||||||
@@ -135,7 +140,15 @@ export function advanceDay(state, bal) {
|
|||||||
} else {
|
} else {
|
||||||
if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal);
|
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.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true;
|
||||||
if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true;
|
if (state.day % bal.time.daysPerQuarter === 0) {
|
||||||
|
state.pending.review = events.review = true;
|
||||||
|
state.risk.emergency = false;
|
||||||
|
} else if (state.alerts.length > alertsBefore && state.day - state.risk.lastEmergency >= bal.gameOver.emergencyCooldown) {
|
||||||
|
// A new danger warning: the cabinet meets at once, so the player can react before the next quarter.
|
||||||
|
state.pending.review = events.review = true;
|
||||||
|
state.risk.emergency = true;
|
||||||
|
state.risk.lastEmergency = state.day;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { advanced: true, blocked: null, events };
|
return { advanced: true, blocked: null, events };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function checkGameOver(state, bal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow;
|
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 inCoup = (ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow) || ind.armyLoyalty < (g.coup.armyAlone ?? 0);
|
||||||
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
|
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
|
||||||
track("impeachment", inImpeachment, "reset");
|
track("impeachment", inImpeachment, "reset");
|
||||||
track("coup", inCoup, 1);
|
track("coup", inCoup, 1);
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
|||||||
import { biasFor } from "./modifiers.js";
|
import { biasFor } from "./modifiers.js";
|
||||||
import { nextNoise } from "./rng.js";
|
import { nextNoise } from "./rng.js";
|
||||||
|
|
||||||
function computeTarget(def, base, group, key, state, devs) {
|
function computeTarget(def, base, group, key, state, devs, bal) {
|
||||||
let t = base;
|
let t = base;
|
||||||
|
const gain = group === "ind" ? bal.gain ?? 1 : 1;
|
||||||
for (const [id, w] of Object.entries(def.sectors ?? {})) {
|
for (const [id, w] of Object.entries(def.sectors ?? {})) {
|
||||||
t += w * (state.sectors[id].quality - state.ref.sectors[id]);
|
t += gain * w * (state.sectors[id].quality - state.ref.sectors[id]);
|
||||||
}
|
}
|
||||||
|
// Going big pays off: extra weight for quality above `from` (excess) or below it (short).
|
||||||
|
for (const [id, [from, w]] of Object.entries(def.excess ?? {})) t += w * Math.max(0, state.sectors[id].quality - from);
|
||||||
|
for (const [id, [from, w]] of Object.entries(def.short ?? {})) t -= w * Math.max(0, from - state.sectors[id].quality);
|
||||||
|
for (const [k, w] of Object.entries(def.world ?? {})) t += w * state.world[k];
|
||||||
|
if (def.drift) t += ((def.drift * (bal.driftScale ?? 1)) * state.day) / 365;
|
||||||
for (const [id, w] of Object.entries(def.alloc ?? {})) {
|
for (const [id, w] of Object.entries(def.alloc ?? {})) {
|
||||||
const s = state.sectors[id];
|
const s = state.sectors[id];
|
||||||
t += w * (s.alloc / s.need - state.ref.ratio[id]);
|
t += w * (s.alloc / s.need - state.ref.ratio[id]);
|
||||||
@@ -28,7 +34,7 @@ export function stepVars(state, bal, devs) {
|
|||||||
const next = {};
|
const next = {};
|
||||||
for (const id of VAR_IDS) {
|
for (const id of VAR_IDS) {
|
||||||
const def = bal.vars[id];
|
const def = bal.vars[id];
|
||||||
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs);
|
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs, bal);
|
||||||
const v = state.economy[id];
|
const v = state.economy[id];
|
||||||
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
|
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
|
||||||
}
|
}
|
||||||
@@ -39,7 +45,7 @@ export function stepIndicators(state, bal, devs) {
|
|||||||
const next = {};
|
const next = {};
|
||||||
for (const id of IND_IDS) {
|
for (const id of IND_IDS) {
|
||||||
const def = bal.indicators[id];
|
const def = bal.indicators[id];
|
||||||
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs);
|
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs, bal);
|
||||||
const v = state.indicators[id];
|
const v = state.indicators[id];
|
||||||
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
|
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -13,12 +13,17 @@ export const METRIC_IDS = [
|
|||||||
|
|
||||||
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
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.
|
const AVERAGED = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"];
|
||||||
function termAverages(state) {
|
|
||||||
|
// Averages over the whole term, plus the final debt ratio. With `lastYearShare` the average
|
||||||
|
// leans on the last 52 weekly samples: the historian judges what you leave behind, so a
|
||||||
|
// boom bought on credit does not pay for itself.
|
||||||
|
function termAverages(state, lastYearShare = 0) {
|
||||||
const s = state.history.series;
|
const s = state.history.series;
|
||||||
const m = {};
|
const m = {};
|
||||||
for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
|
for (const id of AVERAGED) {
|
||||||
m[id] = mean(s[id] ?? []);
|
const all = s[id] ?? [];
|
||||||
|
m[id] = lastYearShare > 0 ? (1 - lastYearShare) * mean(all) + lastYearShare * mean(all.slice(-52)) : mean(all);
|
||||||
}
|
}
|
||||||
m.debt = s.debtRatio?.at(-1) ?? 0;
|
m.debt = s.debtRatio?.at(-1) ?? 0;
|
||||||
m.years = state.day / 365;
|
m.years = state.day / 365;
|
||||||
@@ -76,7 +81,7 @@ function meets(entry, m) {
|
|||||||
// endgame: the parsed data/endgame.json (its `titles` list is in priority order).
|
// endgame: the parsed data/endgame.json (its `titles` list is in priority order).
|
||||||
export function buildReview(state, bal, cards, endgame) {
|
export function buildReview(state, bal, cards, endgame) {
|
||||||
const metrics = termAverages(state);
|
const metrics = termAverages(state);
|
||||||
let score = computeScore(metrics, bal);
|
let score = computeScore(termAverages(state, bal.score.lastYearShare ?? 0), bal);
|
||||||
if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap);
|
if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap);
|
||||||
metrics.score = score;
|
metrics.score = score;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { biasFor } from "./modifiers.js";
|
|||||||
export function stepSectors(state, bal, fiscal) {
|
export function stepSectors(state, bal, fiscal) {
|
||||||
const s = bal.sector;
|
const s = bal.sector;
|
||||||
for (const [id, sec] of Object.entries(state.sectors)) {
|
for (const [id, sec] of Object.entries(state.sectors)) {
|
||||||
|
// Needs grow with population, ageing and prices: standing still buys less each year.
|
||||||
|
sec.need *= Math.pow(1 + (bal.needGrowth?.[id] ?? 0), 1 / 365);
|
||||||
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
|
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
|
||||||
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
|
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
|
||||||
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
|
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Shared constants and tiny helpers. No game logic here.
|
// Shared constants and tiny helpers. No game logic here.
|
||||||
|
|
||||||
export const STATE_VERSION = 1;
|
export const STATE_VERSION = 2;
|
||||||
|
|
||||||
export const IND_IDS = [
|
export const IND_IDS = [
|
||||||
"order", "stability", "civilRights", "opinion", "economy",
|
"order", "stability", "civilRights", "opinion", "economy",
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export function restoreState(s, { scenarios, balance, cards }) {
|
|||||||
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();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// World cycle
|
||||||
|
if (!isObj(s.world) || !isNum(s.world.cycle, -50, 50)) return fail();
|
||||||
|
|
||||||
// Decisions and flags
|
// Decisions and flags
|
||||||
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
|
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
|
||||||
if (!Array.isArray(s.cardHistory)) return fail();
|
if (!Array.isArray(s.cardHistory)) return fail();
|
||||||
@@ -77,6 +80,8 @@ export function restoreState(s, { scenarios, balance, cards }) {
|
|||||||
for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true;
|
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 (!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 (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail();
|
||||||
|
s.risk.emergency = s.risk.emergency === true;
|
||||||
|
if (!isNum(s.risk.lastEmergency)) s.risk.lastEmergency = -999;
|
||||||
if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) 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
|
// History for the charts: one equal-length series per tracked value
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// The world outside the country: a slow business cycle with occasional shocks (global
|
||||||
|
// slumps, rare booms). It moves growth and inflation, so even a perfect budget meets
|
||||||
|
// good and bad years. state.world.cycle is roughly -4 (deep global recession) to +4.
|
||||||
|
|
||||||
|
import { clamp } from "./state.js";
|
||||||
|
import { nextFloat, nextNoise } from "./rng.js";
|
||||||
|
|
||||||
|
export function stepWorld(state, bal) {
|
||||||
|
const w = bal.world;
|
||||||
|
const s = state.world;
|
||||||
|
s.cycle -= s.cycle / w.tau;
|
||||||
|
s.cycle += nextNoise(state, w.noise);
|
||||||
|
if (nextFloat(state) < w.shockChancePerDay) {
|
||||||
|
const size = w.shock * (0.6 + 0.8 * nextFloat(state));
|
||||||
|
s.cycle += nextFloat(state) < w.boomShare ? size * 0.6 : -size;
|
||||||
|
}
|
||||||
|
s.cycle = clamp(s.cycle, -w.max, w.max);
|
||||||
|
}
|
||||||
+2
-1
@@ -70,8 +70,9 @@
|
|||||||
"tax.corporate": "Corporate tax",
|
"tax.corporate": "Corporate tax",
|
||||||
"tax.vat": "VAT",
|
"tax.vat": "VAT",
|
||||||
"ledger.title": "Treasury ledger",
|
"ledger.title": "Treasury ledger",
|
||||||
|
"ledger.emergency": "Emergency session: the cabinet has met after the warning. Changes take effect at once.",
|
||||||
"ledger.spending": "Spending, % of GDP",
|
"ledger.spending": "Spending, % of GDP",
|
||||||
"ledger.spendingHint": "The tick on each line marks what the sector needs.",
|
"ledger.spendingHint": "The tick on each line marks what the sector needs. Needs grow a little every year.",
|
||||||
"ledger.totalAlloc": "Allocated to sectors",
|
"ledger.totalAlloc": "Allocated to sectors",
|
||||||
"ledger.revenue": "Revenue: tax rates",
|
"ledger.revenue": "Revenue: tax rates",
|
||||||
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
|
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
|
||||||
|
|||||||
+2
-1
@@ -69,9 +69,10 @@
|
|||||||
"tax.income": "Impôt revenu",
|
"tax.income": "Impôt revenu",
|
||||||
"tax.corporate": "Impôt sociétés",
|
"tax.corporate": "Impôt sociétés",
|
||||||
"tax.vat": "TVA",
|
"tax.vat": "TVA",
|
||||||
|
"ledger.emergency": "Séance d'urgence : le cabinet s'est réuni après l'alerte. Les changements s'appliquent aussitôt.",
|
||||||
"ledger.title": "Registre du Trésor",
|
"ledger.title": "Registre du Trésor",
|
||||||
"ledger.spending": "Dépenses, % du PIB",
|
"ledger.spending": "Dépenses, % du PIB",
|
||||||
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.",
|
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur. Les besoins augmentent un peu chaque année.",
|
||||||
"ledger.totalAlloc": "Alloué aux secteurs",
|
"ledger.totalAlloc": "Alloué aux secteurs",
|
||||||
"ledger.revenue": "Recettes : taux d'imposition",
|
"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.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.",
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function devGame() {
|
|||||||
prepareWeek();
|
prepareWeek();
|
||||||
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||||
advanceDay(s, balance);
|
advanceDay(s, balance);
|
||||||
|
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
|
||||||
}
|
}
|
||||||
if (cardsById.has(params.get("card"))) {
|
if (cardsById.has(params.get("card"))) {
|
||||||
s.pendingCard = { id: params.get("card"), day: s.day };
|
s.pendingCard = { id: params.get("card"), day: s.day };
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export function encodeSave(state) {
|
|||||||
|
|
||||||
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
|
// 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.
|
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
|
||||||
export const MIGRATIONS = {};
|
export const MIGRATIONS = {
|
||||||
|
// v2 added the world cycle: older saves start it at neutral.
|
||||||
|
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
|
||||||
|
};
|
||||||
|
|
||||||
export function migrate(state) {
|
export function migrate(state) {
|
||||||
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
|
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function createLedger(root, scenario, { onConfirm }) {
|
|||||||
<div class="ledger__book">
|
<div class="ledger__book">
|
||||||
<article class="ledger__page ledger__page--left">
|
<article class="ledger__page ledger__page--left">
|
||||||
<h2>${t("ledger.title")}</h2>
|
<h2>${t("ledger.title")}</h2>
|
||||||
|
${state.risk.emergency ? `<p class="ledger__emergency">${t("ledger.emergency")}</p>` : ""}
|
||||||
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
|
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
|
||||||
<div class="ledger__rows">${sectorRows}</div>
|
<div class="ledger__rows">${sectorRows}</div>
|
||||||
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
|
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const LEVELS = [
|
|||||||
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
|
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
|
||||||
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
|
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
|
||||||
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
|
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
|
||||||
|
["world.slump", (s) => (s.world?.cycle ?? 0) <= -1.8, 1.4],
|
||||||
|
["world.boom", (s) => (s.world?.cycle ?? 0) >= 1.8, 1.2],
|
||||||
];
|
];
|
||||||
|
|
||||||
function candidates(state, tpl) {
|
function candidates(state, tpl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user