First commit, done on M6 from the PLAN.MD
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM golang:1.23-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY server ./server
|
||||||
|
COPY web ./web
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/game ./server
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static
|
||||||
|
COPY --from=build /out/game /game
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/game"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
game:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
# distroless has no shell or curl, so the binary probes itself
|
||||||
|
test: ["CMD", "/game", "-healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Writing decision cards
|
||||||
|
|
||||||
|
Guide for anyone (human or model) adding cards to `web/data/cards/`. Every card is one weekly yes/no decision
|
||||||
|
for the ruler of the fictional Republic of Valdoria. A run has 260 of them, in English and French.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read the existing file for your category (`web/data/cards/<category>.json`) to catch the voice and format,
|
||||||
|
and `web/data/characters.json` for the senders.
|
||||||
|
2. Write new cards in **batches of at most 25** as a JSON array in a scratch file outside the repo.
|
||||||
|
3. Merge the batch: `node tools/add-cards.mjs <category> <batch.json>`. It rejects duplicate ids and wrong
|
||||||
|
categories, appends the cards, and reformats the file.
|
||||||
|
4. Validate: `go test ./server`. It checks every field, both languages, effects, conditions, senders, flags and
|
||||||
|
follow-up links, and says which card is wrong.
|
||||||
|
5. Review the balance: `node tools/card-stats.mjs <category>`.
|
||||||
|
6. Edit the category file directly to fix whatever the tests or the stats show.
|
||||||
|
|
||||||
|
Only edit your own category files. Never touch `balance.json`, the code or the tests while adding content.
|
||||||
|
|
||||||
|
## The card
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "eco_example_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"weight": 1,
|
||||||
|
"conditions": { "min": { "debt": 70 }, "max": {}, "flags": [], "notFlags": [], "dayMin": 90, "dayMax": 1500 },
|
||||||
|
"title": { "en": "…", "fr": "…" },
|
||||||
|
"body": { "en": "…", "fr": "…" },
|
||||||
|
"yes": { "label": { "en": "…", "fr": "…" }, "effects": [ … ], "setFlags": [], "followUp": { "id": "eco_next_01", "inDays": 45 } },
|
||||||
|
"no": { "label": { "en": "…", "fr": "…" }, "effects": [ … ] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `doc`: `memo`, `letter`, `petition`, `report` or `telegram`. Telegrams are for urgent news only.
|
||||||
|
- `sender`: an id from `characters.json`. Pick the one who would really write it.
|
||||||
|
- `once` is true by default. `once: false` plus `cooldownDays` makes a card repeatable (only for crises and
|
||||||
|
recurring situations). `followUpOnly: true` keeps a card out of random draws; it appears only through a `followUp`.
|
||||||
|
- `weight` defaults to 1. Use 0.5 for niche cards and 1.5 for central ones, sparingly.
|
||||||
|
- Ids: `<prefix>_<slug>_<NN>`, unique across all files. Prefixes: `eco soc hlt edu sec def for ind trn hou env jus cul cri`.
|
||||||
|
|
||||||
|
### Effects (each option has a list)
|
||||||
|
|
||||||
|
| Shape | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `{ "stat": "opinion", "delta": 4 }` | instant shift |
|
||||||
|
| `{ "stat": "opinion", "delta": 4, "days": 30 }` | spread over the days, then fades back |
|
||||||
|
| `{ "stat": "economy", "delta": -2, "bias": true, "days": 365 }` | moves the stat's target for that many days (omit `days` for permanent) |
|
||||||
|
| `{ "sector": "health", "delta": 5, "days": 120 }` | sector quality (also instant or `bias`) |
|
||||||
|
| `{ "oneOff": 0.5 }` | one-time cost in % of GDP (negative = windfall) |
|
||||||
|
| `{ "extraSpending": 0.3 }` | permanent extra spending in % of GDP (negative = savings) |
|
||||||
|
| `{ "revenue": "income", "delta": 3 }` | tax rate in points (`income`, `corporate`, `vat`) |
|
||||||
|
| `{ "revenue": "other", "delta": 0.3 }` | permanent other revenue in % of GDP |
|
||||||
|
|
||||||
|
Stats: `order stability civilRights opinion economy hardPower softPower unrest armyLoyalty` (0 to 100), plus
|
||||||
|
`growth unemployment inflation` (percent). Sectors: `education health social police army justice industry
|
||||||
|
transport infrastructure housing research culture foreignAffairs environment administration`.
|
||||||
|
|
||||||
|
**Size guide.** Instant or spread shifts 1 to 6 points. Lasting `bias` effects 1 to 5 points for 180 to 730 days.
|
||||||
|
Sector quality 2 to 6 over 60 to 365 days. `oneOff` 0.1 to 1.5. `extraSpending` 0.1 to 1.0. Tax rates 1 to 4 points.
|
||||||
|
`growth` bias 0.1 to 0.6, `unemployment` 0.1 to 0.5, `inflation` 0.2 to 1.5. Use 2 to 4 effects per option, and mix
|
||||||
|
instant, spread, bias, sector and budget effects. The effects must fit the story: repression raises order and
|
||||||
|
lowers civil rights.
|
||||||
|
|
||||||
|
**Every option has a real cost and a real benefit.** Saying no is never a free "nothing happens": the status quo
|
||||||
|
has consequences too.
|
||||||
|
|
||||||
|
### Conditions and scheduling
|
||||||
|
|
||||||
|
Spread the cards over the whole term so each year brings new material:
|
||||||
|
|
||||||
|
- about 30 % available from the start (no `dayMin`), 25 % from day 90 to 400, 25 % from day 400 to 900,
|
||||||
|
20 % from day 900 to 1500;
|
||||||
|
- 30 to 40 % gated on the state of the country: `min`/`max` on any stat, on `debt` or `deficit` (% of GDP), or on
|
||||||
|
`q:<sector>` (sector quality, for "this sector is in trouble" cards);
|
||||||
|
- a few early-only cards with `dayMax`.
|
||||||
|
|
||||||
|
Flags: `setFlags` on an option records the decision; `conditions.flags` / `notFlags` read it. Every flag a card
|
||||||
|
reads must be set by some card (the tests check). Use flags prefixed with your category. The flags that already
|
||||||
|
exist are `wealth_tax austerity airline_sold crackdown energy_privatised trade_pact emergency_austerity concessions`.
|
||||||
|
|
||||||
|
### Follow-up chains
|
||||||
|
|
||||||
|
About one chain per eight cards: an option has `"followUp": { "id": "...", "inDays": 30 }` pointing at a
|
||||||
|
`followUpOnly` card that continues the story. Branch the story: set a flag in one option and let the follow-up
|
||||||
|
(or a second follow-up card) differ depending on the flag. Chains are 2 to 3 cards long.
|
||||||
|
|
||||||
|
## Voice
|
||||||
|
|
||||||
|
- **Tone**: dry, specific, politically grounded, a little satirical, like a briefing that has seen everything.
|
||||||
|
Concrete places, numbers and institutions in Valdoria. Both sides of every dilemma have a respectable argument.
|
||||||
|
- **Fiction only**: no real countries, people, parties or brands. No slurs. No cartoon villains.
|
||||||
|
- **Length**: body of 2 to 3 sentences (English up to 300 characters, French up to 340): the situation first, then
|
||||||
|
the tension. Title up to 50 characters. Option labels up to 32 characters, led by a verb, clearly different from
|
||||||
|
each other (never "Yes" / "No", never two "Approve").
|
||||||
|
- **No pronouns for people**: name ministries and organisations, not individuals, and never use a pronoun for the
|
||||||
|
player or anyone else. Address the player as "you" / "vous" sparingly, and in French never with an adjective
|
||||||
|
that agrees with the player.
|
||||||
|
- **Variety**: vary senders, document types and sentence shapes. Do not start every card with "The ministry
|
||||||
|
proposes". No two cards about the same dilemma.
|
||||||
|
- **French**: natural, idiomatic French rather than a translation word for word. Correct accents, « guillemets »
|
||||||
|
with spaces, a space before `? ! : ;`, typographic apostrophe `'` as in the existing cards. Telegram bodies are
|
||||||
|
UPPERCASE without accents and end with `STOP.` in both languages. Keep institution names consistent with the
|
||||||
|
existing cards ("ministère des Finances", "Banque centrale").
|
||||||
|
|
||||||
|
## Balance checks before you finish
|
||||||
|
|
||||||
|
`node tools/card-stats.mjs <category>` prints, per answer and for a coin flip (50/50):
|
||||||
|
|
||||||
|
- one-off cost and extra spending: the coin-flip rows should stay close to zero (about as many windfalls and savings
|
||||||
|
as costs), because a player who answers at random should not bankrupt or enrich the country;
|
||||||
|
- opinion, stability, economy: the coin-flip rows should stay within ±0.5 per card;
|
||||||
|
- the spread of availability and the share of gated cards, per the targets above;
|
||||||
|
- warnings for long bodies, duplicate titles and identical labels.
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
# Country Game: "The Desk" — Plan
|
||||||
|
|
||||||
|
> Shared plan for both of us. The first coding step copies this file to `docs/PLAN.md` in the repo. From then on, that copy is the one to follow: tick the checkboxes, edit the numbers, and add entries to the decisions log at the bottom.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The goal is a single-player browser game in the style of Suzerain, delivered as a docker-compose service. You rule a fictional country for 5 in-game years. You set the sector budgets (in % of GDP) every quarter and answer a yes/no policy card every week. The country's indicators change a little every in-game day. Debt, unrest and repression feed into each other and can end the run early (revolution, coup or default). If you survive, the game ends on a 5-year review ("bilan"). Everything on screen is shown as paper documents on a wooden desk with a leather pad.
|
||||||
|
|
||||||
|
The project folder is empty, so everything below is new code.
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Backend | **Go, standard library only** (`net/http` + `embed`). It serves static files and has a `/healthz` endpoint. No game logic runs on the server. |
|
||||||
|
| Frontend | **Vanilla JS ES modules + CSS, no bundler and no npm.** The only runtime dependency is zero: no framework, no chart library (charts are hand-drawn SVG). |
|
||||||
|
| Fonts | Special Elite (typewriter text, **Apache-2.0**, not OFL), IM Fell English (headings, OFL) and Courier Prime (figures, OFL), stored with their licence files in `web/fonts/` so the game works offline. |
|
||||||
|
| Time | **1 day = 10 s at 1× speed** (a full run is about 5 h). Speed controls: pause, 1×, 2×, 4×, plus a "slow" setting (60 s/day) in Settings. **The game pauses automatically** when the weekly card arrives, at the quarterly budget review, and on crisis telegrams. |
|
||||||
|
| Saves | Auto-save to `localStorage` every in-game day. 3 manual save slots. Export/import as a **plain base64 string** (and as a downloadable `.sav` file). |
|
||||||
|
| Language | **English and French.** Every text field is `{ "en": "...", "fr": "..." }`, and there's a language switch in the menu. |
|
||||||
|
| Randomness | A seeded PRNG (mulberry32) whose state is stored in the save, so reloading a save doesn't re-roll the cards. |
|
||||||
|
|
||||||
|
About "AppData": a web page can't write to AppData or the macOS/Linux equivalents directly. However, `localStorage` is already stored on disk inside the browser profile (which on Windows lives under AppData). The `.sav` file export covers the case of keeping a save file you can see.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
country_game/
|
||||||
|
├─ docker-compose.yml # one service "game", port 8080
|
||||||
|
├─ Dockerfile # golang:1.23-alpine build stage → gcr.io/distroless/static
|
||||||
|
├─ go.mod # module countrygame, no require block
|
||||||
|
├─ server/
|
||||||
|
│ ├─ main.go # embed ../web, FileServer, cache headers, /healthz, PORT env
|
||||||
|
│ └─ content_test.go # `go test` validates every card/scenario JSON (schema, ids, en+fr present, refs exist)
|
||||||
|
├─ web/
|
||||||
|
│ ├─ index.html
|
||||||
|
│ ├─ css/ desk.css papers.css ledger.css ui.css
|
||||||
|
│ ├─ fonts/ img/ (small noise/stamp SVGs only)
|
||||||
|
│ ├─ data/
|
||||||
|
│ │ ├─ balance.json # every tunable coefficient (no magic numbers in code)
|
||||||
|
│ │ ├─ scenarios/default.json (+ index.json listing the scenarios)
|
||||||
|
│ │ ├─ newspaper.json # headline templates (EN/FR), done in M2
|
||||||
|
│ │ ├─ endgame.json # game-over banners, urgent telegrams, historian titles and verdicts, done in M5
|
||||||
|
│ │ ├─ characters.json # ministers / senders (name, seal initials, role), done in M4
|
||||||
|
│ │ └─ cards/<category>.json # card pools, one file per category, plus cards/index.json (the manifest)
|
||||||
|
│ └─ js/
|
||||||
|
│ ├─ main.js # boot, load data, main loop (requestAnimationFrame + accumulator)
|
||||||
|
│ ├─ engine/ # PURE: no DOM, no fetch, so it can run headless
|
||||||
|
│ │ rng.js state.js time.js economy.js sectors.js indicators.js modifiers.js game.js index.js (done, M1)
|
||||||
|
│ │ projection.js (done, M3)
|
||||||
|
│ │ validate.js (done, M6)
|
||||||
|
│ │ cards.js (done, M4)
|
||||||
|
│ │ gameover.js score.js (done, M5)
|
||||||
|
│ ├─ ui/ # DOM only, reads state, dispatches actions
|
||||||
|
│ │ desk.js dashboard.js newspaper.js calendar.js charts.js inbox.js (stub) (done, M2)
|
||||||
|
│ │ ledger.js (done, M3)
|
||||||
|
│ │ decision.js (done, M4; replaces the inbox stub)
|
||||||
|
│ │ telegram.js endgame.js (done, M5)
|
||||||
|
│ │ menu.js (done, M6)
|
||||||
|
│ ├─ save/ storage.js codec.js (done, M6)
|
||||||
|
│ └─ i18n/ i18n.js en.json fr.json # UI strings only; card text lives in cards (done, M2)
|
||||||
|
├─ tools/
|
||||||
|
│ └─ engine-check.mjs # `node tools/engine-check.mjs`: engine regression checks (determinism, pause gating, effects)
|
||||||
|
└─ docs/PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/dev/sim.html` + `sim-core.js` (the balance runner) live under `web/` instead of `tools/`, because the page needs the Go server to load the engine as ES modules (`file://` blocks them). Open http://localhost:8080/dev/sim.html.
|
||||||
|
|
||||||
|
Rule: `engine/` never touches the DOM. `ui/` never changes the state directly; it calls `engine` actions (`answerCard`, `setBudget`, `tick`). This keeps the balance tools and any later server-side validation possible.
|
||||||
|
|
||||||
|
## Simulation model
|
||||||
|
|
||||||
|
### State (one JSON object, this is also the save)
|
||||||
|
`version, seed, rngState, day (0…1825), speed, lang, countryName, scenarioId, economy{}, sectors{}, indicators{}, modifiers[], flags{}, cardHistory[], pendingCard, followUps[], history{t[], series{}} (one sample per week), pending{weeklyCard, review}, over, ref{}, log[]`
|
||||||
|
|
||||||
|
`pending` holds the pauses waiting on the player; `advanceDay` refuses to run while either is set, so a reload mid-decision comes back to the same decision. `ref` freezes the scenario's start values: every indicator target is `start value + weighted deviations from the start state`, so the starting country is an exact equilibrium and a hands-off run stays flat apart from noise.
|
||||||
|
|
||||||
|
### Economy (in % of GDP unless stated)
|
||||||
|
- `gdp` (absolute value, in a fictional currency) and `growth` (annual %, applied daily).
|
||||||
|
- **Revenue**: three tax levers, adjustable at the quarterly review: income tax, corporate tax and VAT. Each one turns into revenue in % of GDP, with diminishing returns, and pushes growth or opinion in the opposite direction.
|
||||||
|
- **Spending** = the sum of sector allocations + debt interest.
|
||||||
|
- `deficit = spending − revenue`, which is added to `debt` every day (1/365 per day).
|
||||||
|
- **Interest rate** = base rate + a risk premium that rises with debt/GDP (convex curve).
|
||||||
|
- Debt bands (thresholds tunable in `balance.json`):
|
||||||
|
- < 60 %: no effect.
|
||||||
|
- 60–90 %: small stability drain, interest starts to climb.
|
||||||
|
- 90–150 %: industry quality decays, growth −, unrest +.
|
||||||
|
- > 150 %: daily chance of a "bond market panic" event.
|
||||||
|
- > 200 %: game over by sovereign default.
|
||||||
|
- Also tracked: `unemployment` and `inflation` (simple functions of growth, industry and deficit).
|
||||||
|
|
||||||
|
### Sectors (budget % of GDP, set every quarter)
|
||||||
|
Education, Health, Social welfare & pensions, Police, Army, Justice, Industry & energy, Public transport, Infrastructure, Housing, Research, Culture & media, Foreign affairs, Environment, Administration (15 sectors).
|
||||||
|
|
||||||
|
Each sector has:
|
||||||
|
- `alloc`: the % of GDP the player gave it.
|
||||||
|
- `need`: the baseline % from the scenario.
|
||||||
|
- `quality` (0–100), which moves toward a target every day: `target = f(alloc/need)`, a saturating curve, so overspending gives diminishing returns.
|
||||||
|
|
||||||
|
The **time constant differs per sector**. Police reacts in about 2 weeks, Health in about 3 months, Education in about 1 year, Infrastructure in about 2 years. This is what makes the game strategic: you pay now and see the effect later.
|
||||||
|
|
||||||
|
### Indicators (0–100)
|
||||||
|
Order, Stability, Civil Rights, Public Opinion (0 = angry, 100 = ecstatic), Economy, Hard Power, Soft Power. Two hidden or semi-visible gauges also exist: **Unrest** and **Army Loyalty**.
|
||||||
|
|
||||||
|
Every day, each indicator moves toward `target = Σ(weight × sector quality) + economy terms + active modifiers`. It moves by a fraction of the gap, plus small noise. The weight matrix lives in `balance.json`. Some examples:
|
||||||
|
- Police raises Order and lowers Civil Rights (strongly if over-funded).
|
||||||
|
- Army raises Hard Power and Army Loyalty.
|
||||||
|
- Culture and Foreign affairs raise Soft Power.
|
||||||
|
- Social welfare, Health and Housing raise Opinion and Stability.
|
||||||
|
- High unemployment or inflation lowers Opinion.
|
||||||
|
|
||||||
|
**Unrest** goes up when Opinion and Stability are low, when unemployment is high and when debt is above 90 %. It goes down with Order, but heavy police use lowers Civil Rights and Soft Power and feeds future unrest. This is the debt → unrest → police → instability spiral you described.
|
||||||
|
|
||||||
|
### Card effects (seen after each day)
|
||||||
|
An effect can be:
|
||||||
|
- **instant**: `{stat, delta}` (stat is an indicator or `growth`/`unemployment`/`inflation`);
|
||||||
|
- **spread over time**: `{stat, delta, days}`, applied as delta/days every day. The value then drifts back toward its target, so this is a temporary bump;
|
||||||
|
- **bias**: `{stat, delta, bias: true, days?}` offsets the stat's *target* for `days` days (permanent if omitted). Use it for lasting policy effects;
|
||||||
|
- **budget**: `{revenue: "income"|"corporate"|"vat", delta}` changes a tax rate in rate points; `{revenue: "other", delta}` and `{extraSpending: delta}` are permanent changes in % of GDP; `{oneOff: pct}` is a one-time cost in % of GDP (negative = windfall);
|
||||||
|
- **sector quality**: `{sector, delta[, days][, bias]}`;
|
||||||
|
- **flags** set or cleared;
|
||||||
|
- **follow-up**: `{cardId, inDays}`.
|
||||||
|
|
||||||
|
The daily tick applies every active modifier, so the consequences of a choice unfold day after day on the dashboard.
|
||||||
|
|
||||||
|
### Game over (checked daily, risk-based rather than hard cut-offs)
|
||||||
|
- **Revolution**: Unrest > 70 and Opinion < 20 → a daily chance of 2 % × days spent in the zone (the days fade by 1 per day outside it).
|
||||||
|
- **Military coup**: Army Loyalty < 35 and Stability < 35 → a daily chance of 1.5 % × days in the zone. The army only turns on a failing government: neglecting it alone is not enough.
|
||||||
|
- **Sovereign default**: debt > 200 % of GDP, immediately.
|
||||||
|
- **Impeachment / no-confidence**: Civil Rights < 10 and Opinion < 25 for 60 days in a row.
|
||||||
|
- **Warnings**: entering any of these zones (or debt reaching 160 %) queues an urgent telegram that stops time until the player acknowledges it. Thresholds live in `balance.json` under `gameOver`; the first four were lowered from the original draft (85/15 and 20/30) because the model never got close to them.
|
||||||
|
- The dashboard's "Hold on power" line shows Secure, Shaky (close to a zone) or Critical (inside one).
|
||||||
|
|
||||||
|
Each game over has its own illustrated final document, for example a newspaper front page announcing "GOVERNMENT FALLS".
|
||||||
|
|
||||||
|
### End-of-term review (day 1826)
|
||||||
|
A weighted score over the indicators averaged across the whole term (not just final values), plus bonuses or penalties for final debt, average growth and average unemployment (`balance.score`). Grades: S 75, A 66, B 58, C 50, D 40, F below. A game that ends early scores at most 35 and always gets an F. The historian's title is the first entry of `endgame.json` whose conditions match the term's averages (order of the list is the priority; the last entry has no conditions), or the one title tied to the way the player lost. The final screen is a newspaper front page with the grade, title and verdict, charts of opinion, stability, economy and debt over the term, and the five decisions with the greatest effect (`optionImpact` in `engine/score.js`). A hands-off term scores 57 (C, "The Caretaker").
|
||||||
|
|
||||||
|
**Pronouns.** The game opens by asking how the press should refer to the player: He/Him, She/Her, They/Them (Il/Lui, Elle/Elle, Iel/Ielle in French). It only changes text that refers to the player, which is the historian's verdict and title. Texts use tokens: `{sub} {Sub} {obj} {pos} {is} {has} {was}` in English, `{sub} {Sub} {tonic} {e}` in French (`{e}` is the agreement ending: nothing, e, ·e). French titles come in three forms (masculine, feminine, inclusive with a middle dot). Everywhere else (cards, newspaper, telegrams) text avoids pronouns for the player. `tp()` and `tt()` in `i18n.js` fill the tokens, and `server/endgame_test.go` rejects unknown tokens.
|
||||||
|
|
||||||
|
## Time and the quarterly cycle
|
||||||
|
- The main loop uses `requestAnimationFrame` with a time accumulator. A new day starts when the accumulator reaches `msPerDay / speed`.
|
||||||
|
- **Every Monday** a card is drawn and the game pauses. You can't resume until you answer it (stamp APPROVED or REJECTED).
|
||||||
|
- **Every 13 weeks** the budget ledger opens and the game pauses. You can change the sector allocations and the three tax levers, with a live projection of the deficit and of debt over the next 4 quarters. The rest of the time the ledger is read-only.
|
||||||
|
- Crisis events can arrive in the middle of a week as telegrams. They either need an answer or are information only.
|
||||||
|
|
||||||
|
## Card system
|
||||||
|
- **How many**: 5 years × 52 weeks = 260 weekly draws. The goal is a pool of **at least 800 base cards**, plus follow-up and crisis cards, so that each run sees less than a third of the pool.
|
||||||
|
- **Schema** (`web/data/cards/<category>.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "eco_tax_wealthy_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo", // memo | telegram | petition | report | letter
|
||||||
|
"sender": "min_finance",
|
||||||
|
"weight": 1.0,
|
||||||
|
"once": true,
|
||||||
|
"conditions": { "min": {"debt": 60}, "max": {}, "flags": [], "notFlags": [], "dayMin": 0, "dayMax": 1825 },
|
||||||
|
"title": { "en": "Tax the wealthy?", "fr": "Taxer les plus riches ?" },
|
||||||
|
"body": { "en": "...", "fr": "..." },
|
||||||
|
"yes": { "label": {"en":"Sign the decree","fr":"Signer le décret"},
|
||||||
|
"effects": [ {"revenue":"income","delta":3},
|
||||||
|
{"stat":"opinion","delta":4,"days":30},
|
||||||
|
{"stat":"economy","delta":-3,"days":90} ],
|
||||||
|
"setFlags": ["wealth_tax"], "followUp": {"id":"eco_capital_flight_01","inDays":45} },
|
||||||
|
"no": { "label": {"en":"Shelve it","fr":"Classer le dossier"}, "effects": [ ... ] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **How a card is drawn** (`engine/cards.js`): start from all cards whose conditions are met and that haven't been used. Weight each one by `weight` × a category boost (a sector below quality 40 makes its category up to 2× likelier) × a novelty factor (0.4 per card of the same category among the last 3). Follow-ups that are due take priority over random draws. With no eligible card the week is simply empty and the game is not blocked.
|
||||||
|
- **Fields beyond the schema above**: `once` defaults to true; `once: false` makes a card repeatable with `cooldownDays` (default 180); `followUpOnly: true` keeps a card out of random draws so it only appears through a `followUp`. Condition stats are any indicator, `growth`/`unemployment`/`inflation`, `debt` and `deficit` (% of GDP) and `q:<sector>` (sector quality). Options can also `clearFlags`. Every pending card is saved in `state.pendingCard`, so a reload shows the same card.
|
||||||
|
- **Effect size guide** used for the first 64 cards: instant or spread shifts of 1–6 points on indicators, lasting `bias` effects of 1–5 over 180–730 days, sector quality shifts of 2–6 over 60–365 days, one-off costs of 0.1–1.5 % of GDP (negative = windfall), `extraSpending` of 0.1–1.0 % of GDP, tax rate changes of 2–4 points. Every option has a cost; there is no free choice.
|
||||||
|
- **Target count per category** (about 920 in total): economy 100, social 70, health 60, education 60, police/security 70, army/defense 60, foreign/diplomacy 70, industry/energy 70, transport/infrastructure 60, housing 40, environment 50, justice/civil rights 60, culture/media 40, crises 50.
|
||||||
|
- **How the content gets written**: Sonnet writes it in batches of about 40 cards per category file, in English and French. After each batch, `go test ./server` checks the schema, that ids are unique, that both languages are present, that follow-up ids exist, and that effect stats are valid.
|
||||||
|
|
||||||
|
## Desk UI (Suzerain-like)
|
||||||
|
- **Background**: a wood texture made in CSS (layered `repeating-linear-gradient` plus an inline SVG `feTurbulence` grain), with a darker vignette.
|
||||||
|
- **Leather pad**: a rounded rectangle with a stitched border (`dashed` outline), its own grain and an inner shadow.
|
||||||
|
- **Everything is a paper on the desk**, each with a slight random rotation, a drop shadow and a paper texture:
|
||||||
|
- *Ministerial dashboard* (top left): a typed report with the 7 indicators as ink gauges and SVG sparklines drawn on graph paper.
|
||||||
|
- *Calendar / clock* (top right): a desk calendar with the date and the speed buttons as brass toggles.
|
||||||
|
- *Daily newspaper* (side): 1–3 generated headlines driven by state changes and events.
|
||||||
|
- *Decision folder* (centre): the weekly card as a memo, telegram or petition with a sender seal. You answer with two rubber stamps (APPROVED / REJECTED) and a stamp animation.
|
||||||
|
- *Budget ledger*: a green-ruled accounting book with one row per sector, a slider and an ink figure. It opens as a two-page spread.
|
||||||
|
- *Telegrams*: crisis alerts that slide in on yellow strips.
|
||||||
|
- **Main menu**: a closed folder labelled "TOP SECRET". It has New game (country name + scenario), Continue, Load slot, Import, Settings (language, slow mode, sound off) and Credits.
|
||||||
|
- **Accessibility**: real `<button>` elements, keyboard shortcuts (Space = pause, Y/N = stamp), and a minimum font size on the papers.
|
||||||
|
|
||||||
|
## Save system
|
||||||
|
- **Where**: `localStorage` keys `cg.autosave`, `cg.slot.1` to `cg.slot.3` and `cg.settings`. Each save holds `{ meta, state }`; `meta` (country, day, scenario, saved-at, how it ended) lets the menu list saves without reading the whole state. Every access is wrapped in try/catch, with an in-memory fallback: the game keeps running if storage is missing, blocked or full, and the Settings screen then warns that saves will not survive the visit.
|
||||||
|
- **Autosave**: written after every in-game day and every player action, and when the tab is hidden or closed, so a reload comes back to the same decision. A finished game (any ending) deletes the autosave, so "Continue" only appears for games still in progress. Games started with dev URL parameters never write the autosave.
|
||||||
|
- **Slots and menu**: the folder-style menu has Continue, New game (country name, scenario if there is more than one, pronouns), Load game, Import, Settings (language, slow time) and Credits. While playing, the Menu button on the calendar (or Escape) opens a pause menu with Resume, Save game, Load game, Export, Import, Settings and Main menu. Overwriting a slot, deleting one and loading over a running game all need a second click ("Sure?").
|
||||||
|
- **Export string**: `"CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON)`, about 40,000 characters for a full term, so no compression. The CRC only detects corrupted or truncated strings; it is not protection against editing, as you chose. Whitespace is ignored so wrapped strings still import. The menu offers Copy (with a fallback if the clipboard is blocked), Download .sav, and Import by paste or file.
|
||||||
|
- **Loading is checked**: `codec.js` verifies the format and checksum and migrates old versions (`MIGRATIONS[n]` turns version n into n+1; saves from a newer version are refused). Then `engine/validate.js` (`restoreState`) checks every number is finite and in range, every id is known (sectors, indicators, taxes, modifiers, history series), and repairs references to cards that no longer exist (an unknown pending card is dropped, unknown follow-ups removed). A damaged or tampered save is refused with a message; it never reaches the UI.
|
||||||
|
- **Settings**: language (also switchable in-game from the pause menu) and slow time (1 day = 60 s instead of 10). They are stored separately from games.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
- `Dockerfile`: a multi-stage build. The Go build stage runs `CGO_ENABLED=0 go build`, and the `web/` files are embedded into the binary. The final image is distroless/static, about 10 MB.
|
||||||
|
- `docker-compose.yml`: the `game` service, with `ports: ["8080:8080"]`, `restart: unless-stopped` and a healthcheck on `/healthz`.
|
||||||
|
|
||||||
|
## Milestones (tick as we go)
|
||||||
|
- [x] **M0 Skeleton**: go.mod, server/main.go, Dockerfile, compose, empty desk page served; copy this plan to `docs/PLAN.md`. (Verified as a local binary; `docker compose up --build` still to be run once Docker Desktop is started.)
|
||||||
|
- [x] **M1 Engine core**: rng, state, time loop, sectors, indicators, economy/debt, modifiers; `balance.json`; `web/dev/sim.html` runs headless years per strategy; `server/content_test.go` validates `balance.json` and the scenario.
|
||||||
|
- [x] **M2 Desk UI**: wood and leather, papers, dashboard with sparklines, calendar and speed controls, newspaper. The centre folder is a stub (see decisions log) until M3/M4.
|
||||||
|
- [x] **M3 Budget ledger**: quarterly pause, sliders, tax levers, deficit and debt projection.
|
||||||
|
- [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] **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.
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `docker compose up --build` → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
|
||||||
|
- `go test ./server` passes (content validation).
|
||||||
|
- `web/dev/sim.html`: 200 runs per strategy finish without exceptions, and the distribution of outcomes is printed. Check that debt > 90 % visibly increases unrest, and that unrest leads to a game over in the spendthrift strategy.
|
||||||
|
- Manual: answer a card and watch the effects build up over the following days. Open the quarterly ledger on day 91. Export a string, change one character and check that the import is rejected; import an untouched string and check it gives the same state. Switch to French in the middle of a game.
|
||||||
|
- Check in Chrome and Firefox; the layout must work at a 1280×720 minimum.
|
||||||
|
|
||||||
|
## Open points (to settle as we go)
|
||||||
|
- Starting scenario figures: a proposal is in `web/data/scenarios/default.json` ("Republic of Valdoria": GDP 480, debt 58 %, growth 2 %, unemployment 6.5 %, inflation 2.5 %, spending 34.3 % of GDP, revenue about 35.7 %). **Waiting for your review.**
|
||||||
|
- Whether elections happen during the term (for example a mid-term vote in year 3 that can end the run). Not included yet.
|
||||||
|
- Sound effects (typewriter, stamp, telegraph). Optional, in M8.
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-10-25: Go stdlib + vanilla JS; 10 s/day default with pause/speeds; plain base64 export; EN+FR.
|
||||||
|
- 2026-10-25: `go:embed` can't reference `../web`, so the embed lives in `web/embed.go` (package `web`, exported `Files`) and `server/main.go` imports `countrygame/web`. `web/data`, `web/fonts` and `web/img` hold a `README.txt` placeholder because embed rejects empty folders; delete them once real files exist.
|
||||||
|
- 2026-10-25: The binary has a `-healthcheck` flag (the distroless image has no curl), used by the compose healthcheck.
|
||||||
|
- 2026-10-25 (M1): The sim page moved to `web/dev/` (see layout note). History is sampled weekly, not daily, to keep saves small. Modifiers come in two kinds, shift (moves the current value) and bias (moves the target); see Card effects. Tax-rate effects are in rate points, so the schema example changed from 0.6 to 3.
|
||||||
|
- 2026-10-25 (M1 balance, from `web/dev/sim.html`, seed 1, 5 years): baseline stays flat. Spendthrift (all budgets x1.4) ends with debt 132 %, stability 44, unrest 49 but no game over yet. Austerity (x0.75) reaches debt 11 % with opinion 41 and unrest 35, which looks too safe. Police state: civil rights 23, order 72, soft power 33. Game-over thresholds (M5) and tuning (M8) still to do; the austerity result is the first thing to revisit.
|
||||||
|
- 2026-10-25 (M2): The desk is three papers on the leather pad (dashboard, centre folder, calendar + newspaper). Layout checked at 1280×720 in Edge, English and French. A language button (EN/FR) sat on the calendar so the translations could be checked early; in M6 it moved into Settings.
|
||||||
|
- 2026-10-25 (M2): **The centre folder is a stub.** It only shows the pending pause with an "Acknowledge" button; M3 puts the budget ledger there and M4 the decision cards. At the start of a game the first pause is the day-0 budget review.
|
||||||
|
- 2026-10-25 (M2): The game starts a new run on every page load (autosave comes in M6) at speed 1×, and resumes at that speed after each acknowledged pause. Keys: Space = pause/resume, 1/2/3 = 1×/2×/4×. Special Elite is Apache-2.0 rather than OFL, so its licence file ships in `web/fonts/` with the others.
|
||||||
|
- 2026-10-25 (M2): The newspaper picks headlines from the 4-week movement of each indicator plus a few "level" rules (unrest, opinion, debt, stability, civil rights, army loyalty, inflation, deficit). It uses a hash of the week, never the game RNG. Dev URL params in `main.js`: `?lang=fr`, `?seed=N`, `?alloc=K` (scale all budgets), `?skip=DAYS` (fast-forward), `?dev` (exposes `window.__game`).
|
||||||
|
- 2026-10-25 (M3): The ledger is a two-page book that covers the centre and right columns while a review is pending; the dashboard stays visible on the left. The left page sets the 15 sector budgets, the right page the three tax rates, the Treasury summary and a four-quarter projection. Nothing is applied until "Sign and file"; "Reset" restores the values the review opened with. The weekly briefing comes after the review when both fall on the same day (day 91, 182, ...).
|
||||||
|
- 2026-10-25 (M3): `engine/projection.js` (`previewBudget`) copies the game, applies the draft and runs the real daily tick 4 quarters ahead with noise switched off, so the projection uses the same rules as the game (about 7 ms per call). It assumes no new policies, so cards will move the real numbers. The ledger sliders stop at 2x a sector's need (`limits.sliderMaxRatio`); the engine still accepts up to 3x (`limits.allocMaxRatio`). A red tick on each slider marks the sector's need (budgets) or the revenue-maximising rate (taxes), and the ▲/▼ next to each budget compares the sector's quality now with the quality it is heading to at that budget.
|
||||||
|
- 2026-10-25 (M3): UI tested through the browser's DevTools protocol (slider input, live preview, reset, sign, day-91 reopening); no repo script for it because it depends on a local Edge/Chrome. `tools/engine-check.mjs` now also covers `previewBudget`.
|
||||||
|
- 2026-10-25 (M4): The centre folder now shows the weekly card as a paper document (memo, telegram, petition, report or letter, each with its own look), the sender's seal, and two stamp buttons showing the option text. Pressing one (or Y / N) plays a 0.75 s stamp animation and then applies the answer. The old "Acknowledge" stub is gone. Cards are drawn when the weekly pause starts, not when the folder is shown.
|
||||||
|
- 2026-10-25 (M4): **Bug fixed, present since M2:** the first animation frame could carry a timestamp earlier than the clock's start, giving a negative elapsed time that pre-loaded a whole day, so every game began one day early. `consumeClock` now ignores negative steps (regression test in `tools/engine-check.mjs`).
|
||||||
|
- 2026-10-25 (M4): 64 cards: economy 8, social 5, health 4, education 4, security 5, defense 5, foreign 5, industry 5, transport 4, housing 3, environment 4, justice 4, culture 3, crises 5 (bond panic at debt >= 100 %, outbreak, earthquake, general strike at unrest >= 50, coup plot at army loyalty <= 45; all repeatable with cooldowns). Three follow-up chains: wealth tax -> capital flight (45 days), crackdown -> rights report (30 days), energy privatisation -> energy strike (60 days). A random run answers about 62 cards, then the pool is empty and the remaining weeks pass with no card; M7 fixes that by growing the pool to 800+. Card bodies avoid pronouns for people and name ministries instead, so French needs no gender agreement.
|
||||||
|
- 2026-10-25 (M4): `server/cards_test.go` validates every card file against the manifest, scenario sectors, `characters.json` and `balance.json`: unknown or misspelled fields, stats, sectors and flags, missing English or French text, dangling or unreachable follow-ups, flags used in conditions but never set, and a minimum of 60 cards. Checked by deliberately breaking cards. `sim-core.js` now draws and answers cards (random, always-yes and always-no strategies added). With cards, 5-year averages over 6 seeds: baseline debt 58 %, spendthrift 140 % with unrest 55, austerity 15 % with opinion 39. Dev URL option `?card=ID` shows a given card first.
|
||||||
|
- 2026-10-25 (M5): Pronoun question added at the start of every game (see the Pronouns paragraph above). It is a pending pause like the others (`pending.profile`), shown first in the folder; the budget ledger waits for it. Dev URL option `?pronouns=he|she|they` skips it. The M6 new-game screen can take over the question; the engine side (`setPronouns`, `state.pronouns`) stays.
|
||||||
|
- 2026-10-25 (M5): `pending` now has four flags (profile, weeklyCard, review, alert) and `advanceDay` refuses to run while any is true (`isWaiting`). The previews in the ledger clear all of them. Game over is checked every day after the economy moves, before the weekly and quarterly pauses.
|
||||||
|
- 2026-10-25 (M5): Reachability, from `web/dev/sim.html` over 12 seeds: do-nothing, tax hikes, austerity, spendthrift and police-state runs all reach the end of the term. `ruin` (budgets x2, taxes cut) ends in default around day 1050, `tyrant` (police x3, army x2, justice and social x0.3) in impeachment around day 860, `starve` (budgets x0.25, taxes up) in revolution or coup around day 300. Army neglect alone survives. The thresholds are a first pass; tune them in M8 together with the rest of the balance (austerity is still too safe).
|
||||||
|
- 2026-10-25 (M5): The "Play again" button reloads the page, since there is no menu yet; M6 replaces it. Dev tool: `?alloc=K&skip=DAYS` fast-forwards a game at a budget multiple, which is the quickest way to see each end screen.
|
||||||
|
- 2026-10-25 (M6): The game now opens on the main menu. The New game screen asks for the country name and the pronouns (He/Him, She/Her, They/Them; Il/Lui, Elle/Elle, Iel/Ielle), so the in-folder pronoun question from M5 only appears for games started with dev parameters. "Play again" on the end screen returns to the menu. Dev URL options still start a game at once; `?menu` forces the menu even with them.
|
||||||
|
- 2026-10-25 (M6): The language button left the calendar (replaced by a Menu button); language lives in Settings, in the main menu and the pause menu, and is remembered. With no saved choice the browser language decides (French if it starts with fr). Time does not run while the menu is open. Space, 1-3 and Escape work in game; the menu has its own Escape (back, or resume).
|
||||||
|
- 2026-10-25 (M6): The decision panel's keyboard listener leaked every time the panels were rebuilt (language change since M4); it is now removed when the panels are rebuilt. On load, `restoreState` also keeps the alert pause flag consistent with the alert queue.
|
||||||
|
- 2026-10-25 (M6): New tests. `tools/engine-check.mjs` covers the codec (round trip, whitespace, CRC reference value, UTF-8, every kind of damage, versions and migrations), 23 kinds of invalid or tampered saves, repair of stale references, a restored game continuing identically, and storage (slots, settings, missing, throwing and full storage). `server/ui_content_test.go` checks that en.json and fr.json have identical keys and placeholders and that the scenario index matches the files. The menu was exercised in a real browser: new game, continue after reload with the same waiting card, slot save and overwrite, export, import (valid, tampered, garbage, empty), settings, load and delete with confirmation, and the game-over cleanup.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"countrygame/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
docTypes = set([]string{"memo", "telegram", "petition", "report", "letter"})
|
||||||
|
cardKeys = set([]string{"id", "category", "doc", "sender", "weight", "once", "cooldownDays", "followUpOnly", "conditions", "title", "body", "yes", "no"})
|
||||||
|
optionKeys = set([]string{"label", "effects", "setFlags", "clearFlags", "followUp"})
|
||||||
|
condKeys = set([]string{"min", "max", "flags", "notFlags", "dayMin", "dayMax"})
|
||||||
|
effectKeys = set([]string{"stat", "sector", "revenue", "oneOff", "extraSpending", "delta", "days", "bias"})
|
||||||
|
)
|
||||||
|
|
||||||
|
func isLocalized(v any) bool {
|
||||||
|
m, ok := v.(obj)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range []string{"en", "fr"} {
|
||||||
|
s, ok := m[l].(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(m) == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWholeNumber(v any) bool {
|
||||||
|
f, ok := v.(float64)
|
||||||
|
return ok && f >= 1 && f == math.Trunc(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cardChecker collects cross-card facts while validating one card at a time.
|
||||||
|
type cardChecker struct {
|
||||||
|
t *testing.T
|
||||||
|
sectors map[string]bool
|
||||||
|
stats map[string]bool
|
||||||
|
condStats map[string]bool
|
||||||
|
revenues map[string]bool
|
||||||
|
ids map[string]bool
|
||||||
|
setFlags map[string]bool
|
||||||
|
usedFlags map[string]string
|
||||||
|
followTargets map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardChecker) effects(id, side string, effects any) {
|
||||||
|
t := c.t
|
||||||
|
list, _ := effects.([]any)
|
||||||
|
for i, raw := range list {
|
||||||
|
where := id + "." + side + ".effects[" + string(rune('0'+i)) + "]"
|
||||||
|
e, ok := raw.(obj)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s is not an object", where)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k := range e {
|
||||||
|
if !effectKeys[k] {
|
||||||
|
t.Errorf("%s: unknown field %q", where, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kinds := 0
|
||||||
|
for _, k := range []string{"stat", "sector", "revenue", "oneOff", "extraSpending"} {
|
||||||
|
if _, ok := e[k]; ok {
|
||||||
|
kinds++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kinds != 1 {
|
||||||
|
t.Errorf("%s: needs exactly one of stat/sector/revenue/oneOff/extraSpending", where)
|
||||||
|
}
|
||||||
|
if s, ok := e["stat"].(string); ok && !c.stats[s] {
|
||||||
|
t.Errorf("%s: unknown stat %q", where, s)
|
||||||
|
}
|
||||||
|
if s, ok := e["sector"].(string); ok && !c.sectors[s] {
|
||||||
|
t.Errorf("%s: unknown sector %q", where, s)
|
||||||
|
}
|
||||||
|
if s, ok := e["revenue"].(string); ok && !c.revenues[s] {
|
||||||
|
t.Errorf("%s: unknown revenue %q", where, s)
|
||||||
|
}
|
||||||
|
_, isStat := e["stat"]
|
||||||
|
_, isSector := e["sector"]
|
||||||
|
_, isRevenue := e["revenue"]
|
||||||
|
if isStat || isSector || isRevenue {
|
||||||
|
if _, ok := e["delta"].(float64); !ok {
|
||||||
|
t.Errorf("%s: missing numeric delta", where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, ok := e["days"]; ok && !isWholeNumber(d) {
|
||||||
|
t.Errorf("%s: days must be a whole number >= 1", where)
|
||||||
|
}
|
||||||
|
if b, ok := e["bias"]; ok {
|
||||||
|
if _, isBool := b.(bool); !isBool {
|
||||||
|
t.Errorf("%s: bias must be a boolean", where)
|
||||||
|
}
|
||||||
|
if !isStat && !isSector {
|
||||||
|
t.Errorf("%s: bias only applies to stat/sector effects", where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardChecker) conditions(id string, cond obj) {
|
||||||
|
t := c.t
|
||||||
|
for k := range cond {
|
||||||
|
if !condKeys[k] {
|
||||||
|
t.Errorf("%s.conditions: unknown field %q", id, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bound := range []string{"min", "max"} {
|
||||||
|
m, _ := cond[bound].(obj)
|
||||||
|
for k, v := range m {
|
||||||
|
okKey := c.condStats[k] || (strings.HasPrefix(k, "q:") && c.sectors[strings.TrimPrefix(k, "q:")])
|
||||||
|
if !okKey {
|
||||||
|
t.Errorf("%s.conditions.%s: unknown stat %q", id, bound, k)
|
||||||
|
}
|
||||||
|
if _, isNum := v.(float64); !isNum {
|
||||||
|
t.Errorf("%s.conditions.%s.%s must be a number", id, bound, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fl := range []string{"flags", "notFlags"} {
|
||||||
|
list, _ := cond[fl].([]any)
|
||||||
|
for _, x := range list {
|
||||||
|
c.usedFlags[x.(string)] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo, hasLo := cond["dayMin"].(float64)
|
||||||
|
hi, hasHi := cond["dayMax"].(float64)
|
||||||
|
if hasLo && hasHi && lo > hi {
|
||||||
|
t.Errorf("%s.conditions: dayMin > dayMax", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardChecker) option(id, side string, opt obj) {
|
||||||
|
t := c.t
|
||||||
|
for k := range opt {
|
||||||
|
if !optionKeys[k] {
|
||||||
|
t.Errorf("%s.%s: unknown field %q", id, side, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isLocalized(opt["label"]) {
|
||||||
|
t.Errorf("%s.%s.label needs non-empty en and fr", id, side)
|
||||||
|
}
|
||||||
|
c.effects(id, side, opt["effects"])
|
||||||
|
flags, _ := opt["setFlags"].([]any)
|
||||||
|
for _, x := range flags {
|
||||||
|
c.setFlags[x.(string)] = true
|
||||||
|
}
|
||||||
|
if fu, ok := opt["followUp"].(obj); ok {
|
||||||
|
target, _ := fu["id"].(string)
|
||||||
|
c.followTargets[target] = id
|
||||||
|
if !isWholeNumber(fu["inDays"]) {
|
||||||
|
t.Errorf("%s.%s.followUp.inDays must be a whole number >= 1", id, side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCards(t *testing.T) {
|
||||||
|
scn := load(t, "data/scenarios/default.json")
|
||||||
|
bal := load(t, "data/balance.json")
|
||||||
|
chars := load(t, "data/characters.json")
|
||||||
|
index := load(t, "data/cards/index.json")
|
||||||
|
|
||||||
|
c := &cardChecker{
|
||||||
|
t: t,
|
||||||
|
sectors: set(keys(scn["sectors"].(obj))),
|
||||||
|
stats: set(append(append([]string{}, indIDs...), varIDs...)),
|
||||||
|
condStats: set(append([]string{"debt", "deficit"}, append(append([]string{}, indIDs...), varIDs...)...)),
|
||||||
|
revenues: set(append([]string{"other"}, taxIDs...)),
|
||||||
|
ids: map[string]bool{},
|
||||||
|
setFlags: map[string]bool{},
|
||||||
|
usedFlags: map[string]string{},
|
||||||
|
followTargets: map[string]string{},
|
||||||
|
}
|
||||||
|
categories := set(append(keys(bal["cards"].(obj)["boost"].(obj)), "crises"))
|
||||||
|
|
||||||
|
// Every card file must be in the manifest and vice versa.
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, f := range index["files"].([]any) {
|
||||||
|
listed[f.(string)] = true
|
||||||
|
}
|
||||||
|
entries, err := web.Files.ReadDir("data/cards")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() != "index.json" && !listed[e.Name()] {
|
||||||
|
t.Errorf("cards/%s is not listed in cards/index.json", e.Name())
|
||||||
|
}
|
||||||
|
delete(listed, e.Name())
|
||||||
|
}
|
||||||
|
for f := range listed {
|
||||||
|
t.Errorf("cards/index.json lists %s but the file does not exist", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
followOnly := map[string]bool{}
|
||||||
|
total := 0
|
||||||
|
for _, f := range index["files"].([]any) {
|
||||||
|
file := f.(string)
|
||||||
|
raw, err := web.Files.ReadFile("data/cards/" + file)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read %s: %v", file, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var list []obj
|
||||||
|
if err := json.Unmarshal(raw, &list); err != nil {
|
||||||
|
t.Errorf("parse %s: %v", file, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(file, ".json")
|
||||||
|
for _, card := range list {
|
||||||
|
id, _ := card["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
t.Errorf("%s: card without id", file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if c.ids[id] {
|
||||||
|
t.Errorf("duplicate card id %s", id)
|
||||||
|
}
|
||||||
|
c.ids[id] = true
|
||||||
|
if only, _ := card["followUpOnly"].(bool); only {
|
||||||
|
followOnly[id] = true
|
||||||
|
}
|
||||||
|
for k := range card {
|
||||||
|
if !cardKeys[k] {
|
||||||
|
t.Errorf("%s: unknown field %q", id, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cat, _ := card["category"].(string); cat != stem || !categories[cat] {
|
||||||
|
t.Errorf("%s: category %q must match its file (%s) and be a known category", id, card["category"], stem)
|
||||||
|
}
|
||||||
|
if d, _ := card["doc"].(string); !docTypes[d] {
|
||||||
|
t.Errorf("%s: unknown doc type %q", id, card["doc"])
|
||||||
|
}
|
||||||
|
if s, _ := card["sender"].(string); chars[s] == nil {
|
||||||
|
t.Errorf("%s: unknown sender %q", id, card["sender"])
|
||||||
|
}
|
||||||
|
if !isLocalized(card["title"]) || !isLocalized(card["body"]) {
|
||||||
|
t.Errorf("%s: title and body need non-empty en and fr", id)
|
||||||
|
}
|
||||||
|
if w, ok := card["weight"]; ok {
|
||||||
|
if f, isNum := w.(float64); !isNum || f <= 0 {
|
||||||
|
t.Errorf("%s: weight must be positive", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cd, ok := card["cooldownDays"]; ok && !isWholeNumber(cd) {
|
||||||
|
t.Errorf("%s: cooldownDays must be a whole number >= 1", id)
|
||||||
|
}
|
||||||
|
if cond, ok := card["conditions"].(obj); ok {
|
||||||
|
c.conditions(id, cond)
|
||||||
|
}
|
||||||
|
for _, side := range []string{"yes", "no"} {
|
||||||
|
opt, ok := card[side].(obj)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: missing %s option", id, side)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.option(id, side, opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for target, from := range c.followTargets {
|
||||||
|
if !c.ids[target] {
|
||||||
|
t.Errorf("%s: follow-up %q does not exist", from, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range followOnly {
|
||||||
|
if c.followTargets[id] == "" {
|
||||||
|
t.Errorf("%s is followUpOnly but no card leads to it", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fl, by := range c.usedFlags {
|
||||||
|
if !c.setFlags[fl] {
|
||||||
|
t.Errorf("%s: flag %q is used in conditions but never set by any card", by, fl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total < 60 {
|
||||||
|
t.Errorf("only %d cards; M4 expects at least 60", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"countrygame/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
indIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty"}
|
||||||
|
varIDs = []string{"growth", "unemployment", "inflation"}
|
||||||
|
taxIDs = []string{"income", "corporate", "vat"}
|
||||||
|
devKeys = []string{"growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type obj = map[string]any
|
||||||
|
|
||||||
|
func load(t *testing.T, path string) obj {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := web.Files.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var out obj
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m obj) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(list []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(list))
|
||||||
|
for _, s := range list {
|
||||||
|
m[s] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameKeys(t *testing.T, what string, got obj, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
w := append([]string(nil), want...)
|
||||||
|
sort.Strings(w)
|
||||||
|
g := keys(got)
|
||||||
|
if len(g) != len(w) {
|
||||||
|
t.Errorf("%s: keys %v, want %v", what, g, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range g {
|
||||||
|
if g[i] != w[i] {
|
||||||
|
t.Errorf("%s: keys %v, want %v", what, g, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWeights verifies every key of def[field] is in the allowed set.
|
||||||
|
func checkWeights(t *testing.T, who string, def obj, field string, allowed map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
m, ok := def[field].(obj)
|
||||||
|
if !ok {
|
||||||
|
return // optional section
|
||||||
|
}
|
||||||
|
for k := range m {
|
||||||
|
if !allowed[k] {
|
||||||
|
t.Errorf("%s.%s references unknown %q", who, field, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBalanceAndScenario(t *testing.T) {
|
||||||
|
bal := load(t, "data/balance.json")
|
||||||
|
scn := load(t, "data/scenarios/default.json")
|
||||||
|
|
||||||
|
sectors := scn["sectors"].(obj)
|
||||||
|
sectorSet := make(map[string]bool)
|
||||||
|
for id := range sectors {
|
||||||
|
sectorSet[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario consistency.
|
||||||
|
sameKeys(t, "scenario.indicators", scn["indicators"].(obj), indIDs)
|
||||||
|
sameKeys(t, "scenario.economy.taxes", scn["economy"].(obj)["taxes"].(obj), taxIDs)
|
||||||
|
limit := bal["limits"].(obj)["allocMaxRatio"].(float64)
|
||||||
|
for id, raw := range sectors {
|
||||||
|
s := raw.(obj)
|
||||||
|
need, alloc := s["need"].(float64), s["alloc"].(float64)
|
||||||
|
if need <= 0 || alloc < 0 || alloc > need*limit {
|
||||||
|
t.Errorf("scenario sector %s: need=%v alloc=%v out of range", id, need, alloc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, v := range scn["indicators"].(obj) {
|
||||||
|
if f := v.(float64); f < 0 || f > 100 {
|
||||||
|
t.Errorf("scenario indicator %s=%v outside 0..100", id, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance coverage.
|
||||||
|
sameKeys(t, "balance.sectorTau", bal["sectorTau"].(obj), keys(sectors))
|
||||||
|
sameKeys(t, "balance.indicators", bal["indicators"].(obj), indIDs)
|
||||||
|
sameKeys(t, "balance.vars", bal["vars"].(obj), varIDs)
|
||||||
|
sameKeys(t, "balance.tax", bal["tax"].(obj), taxIDs)
|
||||||
|
for id, tau := range bal["sectorTau"].(obj) {
|
||||||
|
if tau.(float64) <= 0 {
|
||||||
|
t.Errorf("sectorTau.%s must be positive", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every weight points at something that exists.
|
||||||
|
dev, ind := set(devKeys), set(indIDs)
|
||||||
|
groups := []obj{bal["indicators"].(obj), bal["vars"].(obj)}
|
||||||
|
for _, g := range groups {
|
||||||
|
for id, raw := range g {
|
||||||
|
def := raw.(obj)
|
||||||
|
if def["tau"].(float64) <= 0 {
|
||||||
|
t.Errorf("%s.tau must be positive", id)
|
||||||
|
}
|
||||||
|
checkWeights(t, id, def, "sectors", sectorSet)
|
||||||
|
checkWeights(t, id, def, "alloc", sectorSet)
|
||||||
|
checkWeights(t, id, def, "econ", dev)
|
||||||
|
checkWeights(t, id, def, "ind", ind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
earlyEnds = []string{"revolution", "coup", "default", "impeachment"}
|
||||||
|
metricIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation", "debt", "score", "years"}
|
||||||
|
tokenPattern = regexp.MustCompile(`\{(\w+)\}`)
|
||||||
|
// Pronoun tokens each language knows how to fill (see i18n.js, tp()).
|
||||||
|
allowedTokens = map[string]map[string]bool{
|
||||||
|
"en": set([]string{"sub", "Sub", "obj", "pos", "is", "has", "was", "country"}),
|
||||||
|
"fr": set([]string{"sub", "Sub", "tonic", "e", "country"}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkTokens(t *testing.T, where string, v any) {
|
||||||
|
t.Helper()
|
||||||
|
m, _ := v.(obj)
|
||||||
|
for lang, raw := range m {
|
||||||
|
s, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, match := range tokenPattern.FindAllStringSubmatch(s, -1) {
|
||||||
|
if !allowedTokens[lang][match[1]] {
|
||||||
|
t.Errorf("%s.%s: unknown token {%s}", where, lang, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndgame(t *testing.T) {
|
||||||
|
eg := load(t, "data/endgame.json")
|
||||||
|
bal := load(t, "data/balance.json")
|
||||||
|
|
||||||
|
// Banners: the term plus one per way of losing.
|
||||||
|
over := eg["over"].(obj)
|
||||||
|
sameKeys(t, "endgame.over", over, append([]string{"term"}, earlyEnds...))
|
||||||
|
for k, v := range over {
|
||||||
|
e := v.(obj)
|
||||||
|
if !isLocalized(e["h"]) || !isLocalized(e["d"]) {
|
||||||
|
t.Errorf("over.%s: h and d need non-empty en and fr", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urgent telegrams: one per danger zone.
|
||||||
|
alerts := eg["alerts"].(obj)
|
||||||
|
sameKeys(t, "endgame.alerts", alerts, earlyEnds)
|
||||||
|
for k, v := range alerts {
|
||||||
|
a := v.(obj)
|
||||||
|
if !isLocalized(a["title"]) || !isLocalized(a["body"]) {
|
||||||
|
t.Errorf("alerts.%s: title and body need non-empty en and fr", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historian titles.
|
||||||
|
metrics := set(metricIDs)
|
||||||
|
ids := map[string]bool{}
|
||||||
|
perEnd := map[string]int{}
|
||||||
|
titles := eg["titles"].([]any)
|
||||||
|
var lastTerm obj
|
||||||
|
for i, raw := range titles {
|
||||||
|
ti := raw.(obj)
|
||||||
|
id, _ := ti["id"].(string)
|
||||||
|
if id == "" || ids[id] {
|
||||||
|
t.Errorf("titles[%d]: missing or duplicate id %q", i, id)
|
||||||
|
}
|
||||||
|
ids[id] = true
|
||||||
|
|
||||||
|
title, _ := ti["title"].(obj)
|
||||||
|
if s, _ := title["en"].(string); strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("%s: title.en is empty", id)
|
||||||
|
}
|
||||||
|
fr, _ := title["fr"].(obj)
|
||||||
|
for _, form := range []string{"he", "she", "they"} {
|
||||||
|
if s, _ := fr[form].(string); strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("%s: title.fr.%s is empty", id, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isLocalized(ti["verdict"]) {
|
||||||
|
t.Errorf("%s: verdict needs non-empty en and fr", id)
|
||||||
|
}
|
||||||
|
checkTokens(t, id+".verdict", ti["verdict"])
|
||||||
|
|
||||||
|
cond, hasCond := ti["conditions"].(obj)
|
||||||
|
for _, bound := range []string{"min", "max"} {
|
||||||
|
m, _ := cond[bound].(obj)
|
||||||
|
for k := range m {
|
||||||
|
if !metrics[k] {
|
||||||
|
t.Errorf("%s.conditions.%s: unknown metric %q", id, bound, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if end, ok := ti["over"].(string); ok {
|
||||||
|
if !set(earlyEnds)[end] {
|
||||||
|
t.Errorf("%s: unknown over %q", id, end)
|
||||||
|
}
|
||||||
|
perEnd[end]++
|
||||||
|
if hasCond {
|
||||||
|
t.Errorf("%s: titles for an early end take no conditions", id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastTerm = ti
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, end := range earlyEnds {
|
||||||
|
if perEnd[end] != 1 {
|
||||||
|
t.Errorf("early end %q needs exactly one title, has %d", end, perEnd[end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, has := lastTerm["conditions"]; has || lastTerm == nil {
|
||||||
|
t.Errorf("the last term title must have no conditions so every term gets one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance sections the review and the game-over rules rely on.
|
||||||
|
g := bal["gameOver"].(obj)
|
||||||
|
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution"})
|
||||||
|
sc := bal["score"].(obj)
|
||||||
|
valid := set(append(append([]string{}, indIDs...), "calm"))
|
||||||
|
for k := range sc["weights"].(obj) {
|
||||||
|
if !valid[k] {
|
||||||
|
t.Errorf("score.weights: unknown key %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grades := sc["grades"].([]any)
|
||||||
|
prev := 101.0
|
||||||
|
for _, raw := range grades {
|
||||||
|
pair := raw.([]any)
|
||||||
|
min := pair[0].(float64)
|
||||||
|
if min >= prev {
|
||||||
|
t.Errorf("score.grades must be in strictly descending order of threshold")
|
||||||
|
}
|
||||||
|
prev = min
|
||||||
|
}
|
||||||
|
if prev != 0 {
|
||||||
|
t.Errorf("the last grade must have threshold 0 so every score gets a grade")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"countrygame/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by the compose healthcheck: the distroless image has no curl.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||||
|
resp, err := http.Get("http://127.0.0.1:" + port + "/healthz")
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := fs.Sub(web.Files, ".")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.Handle("/", noCache(http.FileServer(http.FS(root))))
|
||||||
|
|
||||||
|
log.Printf("country game listening on :%s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// noCache keeps iteration simple: data and scripts are always revalidated.
|
||||||
|
func noCache(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"countrygame/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var placeholder = regexp.MustCompile(`\{(\w+)\}`)
|
||||||
|
|
||||||
|
func placeholders(s string) string {
|
||||||
|
var found []string
|
||||||
|
for _, m := range placeholder.FindAllStringSubmatch(s, -1) {
|
||||||
|
found = append(found, m[1])
|
||||||
|
}
|
||||||
|
sort.Strings(found)
|
||||||
|
return strings.Join(found, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// English and French must offer exactly the same strings with the same {placeholders}.
|
||||||
|
func TestI18nParity(t *testing.T) {
|
||||||
|
en := load(t, "js/i18n/en.json")
|
||||||
|
fr := load(t, "js/i18n/fr.json")
|
||||||
|
|
||||||
|
for k, v := range en {
|
||||||
|
other, ok := fr[k]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("fr.json is missing %q", k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enText, _ := v.(string)
|
||||||
|
frText, _ := other.(string)
|
||||||
|
if strings.TrimSpace(enText) == "" || strings.TrimSpace(frText) == "" {
|
||||||
|
t.Errorf("%q has an empty string in en or fr", k)
|
||||||
|
}
|
||||||
|
if placeholders(enText) != placeholders(frText) {
|
||||||
|
t.Errorf("%q: placeholders differ (en %q, fr %q)", k, placeholders(enText), placeholders(frText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range fr {
|
||||||
|
if _, ok := en[k]; !ok {
|
||||||
|
t.Errorf("en.json is missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every scenario in the index exists, and every scenario file is in the index.
|
||||||
|
func TestScenarioIndex(t *testing.T) {
|
||||||
|
index := load(t, "data/scenarios/index.json")
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, id := range index["scenarios"].([]any) {
|
||||||
|
listed[id.(string)] = true
|
||||||
|
sc := load(t, "data/scenarios/"+id.(string)+".json")
|
||||||
|
if sc["id"] != id {
|
||||||
|
t.Errorf("scenarios/%s.json has id %v", id, sc["id"])
|
||||||
|
}
|
||||||
|
if !isLocalized(sc["name"]) {
|
||||||
|
t.Errorf("scenario %s needs a name in en and fr", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(listed) == 0 {
|
||||||
|
t.Error("scenarios/index.json lists no scenario")
|
||||||
|
}
|
||||||
|
entries, err := web.Files.ReadDir("data/scenarios")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
id := strings.TrimSuffix(e.Name(), ".json")
|
||||||
|
if e.Name() != "index.json" && !listed[id] {
|
||||||
|
t.Errorf("scenarios/%s is not listed in scenarios/index.json", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Appends a batch of new cards to a category file.
|
||||||
|
// node tools/add-cards.mjs <category> <batch.json>
|
||||||
|
// The batch is a JSON array of cards. Ids must be new across ALL card files, and every card's
|
||||||
|
// `category` must match. Existing cards are kept as they are. The file is rewritten in a compact
|
||||||
|
// layout (small objects on one line). Run `go test ./server` afterwards for the full validation.
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { format } from "./cardfmt.mjs";
|
||||||
|
|
||||||
|
const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url));
|
||||||
|
const [category, batchPath] = process.argv.slice(2);
|
||||||
|
if (!category || !batchPath) {
|
||||||
|
console.error("usage: node tools/add-cards.mjs <category> <batch.json>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(dir + "index.json", "utf8")).files;
|
||||||
|
const file = `${category}.json`;
|
||||||
|
if (!manifest.includes(file)) {
|
||||||
|
console.error(`unknown category "${category}" (known: ${manifest.map((f) => f.replace(".json", "")).join(", ")})`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = {};
|
||||||
|
const used = new Set();
|
||||||
|
for (const f of manifest) {
|
||||||
|
existing[f] = JSON.parse(fs.readFileSync(dir + f, "utf8"));
|
||||||
|
for (const c of existing[f]) used.add(c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch;
|
||||||
|
try {
|
||||||
|
batch = JSON.parse(fs.readFileSync(batchPath, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`cannot read the batch: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(batch)) {
|
||||||
|
console.error("the batch must be a JSON array of cards");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [i, c] of batch.entries()) {
|
||||||
|
const who = c?.id ?? `#${i}`;
|
||||||
|
if (!c?.id) problems.push(`${who}: missing id`);
|
||||||
|
else if (used.has(c.id) || seen.has(c.id)) problems.push(`${who}: id already exists`);
|
||||||
|
seen.add(c?.id);
|
||||||
|
if (c?.category !== category) problems.push(`${who}: category must be "${category}"`);
|
||||||
|
if (!c?.title || !c?.body || !c?.yes || !c?.no) problems.push(`${who}: needs title, body, yes and no`);
|
||||||
|
}
|
||||||
|
if (problems.length) {
|
||||||
|
console.error("batch rejected, nothing written:\n " + problems.join("\n "));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = [...existing[file], ...batch];
|
||||||
|
fs.writeFileSync(dir + file, `${format(all)}\n`);
|
||||||
|
console.log(`${file}: +${batch.length} cards, ${all.length} in total`);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Overview of the card pool, for reviewing content and balance.
|
||||||
|
// node tools/card-stats.mjs whole pool
|
||||||
|
// node tools/card-stats.mjs economy one category in detail (adds the longest texts)
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { optionImpact } from "../web/js/engine/index.js";
|
||||||
|
|
||||||
|
const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url));
|
||||||
|
const files = JSON.parse(fs.readFileSync(dir + "index.json", "utf8")).files;
|
||||||
|
const only = process.argv[2];
|
||||||
|
|
||||||
|
const pool = files.flatMap((f) => JSON.parse(fs.readFileSync(dir + f, "utf8")));
|
||||||
|
const cards = only ? pool.filter((c) => c.category === only) : pool;
|
||||||
|
if (!cards.length) {
|
||||||
|
console.error("no cards for", only);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = (xs, key) => xs.reduce((m, x) => ((m[key(x)] = (m[key(x)] ?? 0) + 1), m), {});
|
||||||
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
||||||
|
const fmt = (n) => n.toFixed(2);
|
||||||
|
const table = (o) => Object.entries(o).map(([k, v]) => `${k} ${v}`).join(", ");
|
||||||
|
|
||||||
|
console.log(`cards: ${cards.length}${only ? ` in ${only}` : ` in ${files.length} categories`}`);
|
||||||
|
if (!only) console.log("per category:", table(count(cards, (c) => c.category)));
|
||||||
|
console.log("doc types:", table(count(cards, (c) => c.doc)));
|
||||||
|
console.log("senders:", table(count(cards, (c) => c.sender)));
|
||||||
|
console.log("follow-up only:", cards.filter((c) => c.followUpOnly).length, "| repeatable:", cards.filter((c) => c.once === false).length);
|
||||||
|
|
||||||
|
const bucket = (c) => {
|
||||||
|
const d = c.conditions?.dayMin ?? 0;
|
||||||
|
if (d < 90) return "start";
|
||||||
|
if (d < 365) return "y1";
|
||||||
|
if (d < 730) return "y2";
|
||||||
|
if (d < 1095) return "y3";
|
||||||
|
return "y4+";
|
||||||
|
};
|
||||||
|
console.log("available from:", table(count(cards, bucket)));
|
||||||
|
console.log("with conditions:", cards.filter((c) => c.conditions).length, "| with stat conditions:",
|
||||||
|
cards.filter((c) => c.conditions?.min || c.conditions?.max).length);
|
||||||
|
|
||||||
|
const set = new Set();
|
||||||
|
const used = new Set();
|
||||||
|
let links = 0;
|
||||||
|
for (const c of cards) {
|
||||||
|
for (const side of ["yes", "no"]) {
|
||||||
|
for (const f of c[side].setFlags ?? []) set.add(f);
|
||||||
|
if (c[side].followUp) links++;
|
||||||
|
}
|
||||||
|
for (const f of [...(c.conditions?.flags ?? []), ...(c.conditions?.notFlags ?? [])]) used.add(f);
|
||||||
|
}
|
||||||
|
console.log(`flags set: ${set.size} | flags used: ${used.size} | follow-up links: ${links}`);
|
||||||
|
|
||||||
|
// Balance: what an average card does to the country, for each answer
|
||||||
|
const stat = (c, side, pick) => (c[side].effects ?? []).filter(pick).reduce((s, e) => s + (e.delta ?? e.oneOff ?? e.extraSpending ?? 0), 0);
|
||||||
|
const row = (label, f) => console.log(label.padEnd(34), `yes ${fmt(mean(cards.map((c) => f(c, "yes"))))}`.padEnd(12), `no ${fmt(mean(cards.map((c) => f(c, "no"))))}`.padEnd(12),
|
||||||
|
`coin flip ${fmt(mean(cards.map((c) => (f(c, "yes") + f(c, "no")) / 2)))}`);
|
||||||
|
console.log("\naverage per card (instant, spread and bias deltas added up):");
|
||||||
|
row("impact (size of the effects)", (c, s) => optionImpact(c[s]));
|
||||||
|
for (const id of ["opinion", "stability", "economy", "order", "civilRights", "unrest"]) row(`${id}`, (c, s) => stat(c, s, (e) => e.stat === id));
|
||||||
|
row("one-off cost, % of GDP", (c, s) => stat(c, s, (e) => e.oneOff !== undefined));
|
||||||
|
row("permanent extra spending, % GDP", (c, s) => stat(c, s, (e) => e.extraSpending !== undefined));
|
||||||
|
row("tax rate points (income+corp+vat)", (c, s) => stat(c, s, (e) => ["income", "corporate", "vat"].includes(e.revenue)));
|
||||||
|
row("other revenue, % of GDP", (c, s) => stat(c, s, (e) => e.revenue === "other"));
|
||||||
|
const effects = cards.flatMap((c) => ["yes", "no"].flatMap((s) => c[s].effects ?? []));
|
||||||
|
console.log("\neffects per option:", fmt(effects.length / (cards.length * 2)),
|
||||||
|
"| with bias:", effects.filter((e) => e.bias).length, "| spread over time:", effects.filter((e) => e.days && !e.bias).length,
|
||||||
|
"| instant:", effects.filter((e) => (e.stat || e.sector) && !e.days && !e.bias).length);
|
||||||
|
|
||||||
|
const long = cards.map((c) => ({ id: c.id, en: c.body.en.length, fr: c.body.fr.length })).filter((x) => x.en > 360 || x.fr > 400);
|
||||||
|
if (long.length) console.log("\nlong bodies (en > 360 or fr > 400 chars):", long.map((x) => `${x.id} (${x.en}/${x.fr})`).join(", "));
|
||||||
|
const dupTitles = Object.entries(count(cards, (c) => c.title.en.toLowerCase())).filter(([, n]) => n > 1);
|
||||||
|
if (dupTitles.length) console.log("duplicate English titles:", dupTitles.map(([t]) => t).join("; "));
|
||||||
|
const labelDup = cards.filter((c) => c.yes.label.en === c.no.label.en);
|
||||||
|
if (labelDup.length) console.log("same label on both options:", labelDup.map((c) => c.id).join(", "));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Compact JSON layout for the card files: anything that fits on one line stays on one line.
|
||||||
|
|
||||||
|
const inline = (v) => {
|
||||||
|
if (Array.isArray(v)) return "[" + v.map(inline).join(", ") + "]";
|
||||||
|
if (v && typeof v === "object") return "{ " + Object.entries(v).map(([k, x]) => JSON.stringify(k) + ": " + inline(x)).join(", ") + " }";
|
||||||
|
return JSON.stringify(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function format(v, indent = "") {
|
||||||
|
const flat = inline(v);
|
||||||
|
if (flat.length + indent.length <= 118 || typeof v !== "object" || v === null) return flat;
|
||||||
|
const next = indent + " ";
|
||||||
|
if (Array.isArray(v)) return "[\n" + v.map((x) => next + format(x, next)).join(",\n") + "\n" + indent + "]";
|
||||||
|
return "{\n" + Object.entries(v).map(([k, x]) => next + JSON.stringify(k) + ": " + format(x, next)).join(",\n") + "\n" + indent + "}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
// Engine regression checks. Run from the repo root: node tools/engine-check.mjs
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as E from "../web/js/engine/index.js";
|
||||||
|
import { encodeSave, decodeSave, crc32, MIGRATIONS } from "../web/js/save/codec.js";
|
||||||
|
import { createStorage, writeSave, readSave, loadSettings, saveSettings, KEYS } from "../web/js/save/storage.js";
|
||||||
|
const root = fileURLToPath(new URL("../web/data/", import.meta.url));
|
||||||
|
const bal = JSON.parse(fs.readFileSync(root + "balance.json"));
|
||||||
|
const scn = JSON.parse(fs.readFileSync(root + "scenarios/default.json"));
|
||||||
|
const fresh = (seed = 7) => E.createGame({ scenario: scn, balance: bal, seed, pronouns: "they" });
|
||||||
|
const open = (s) => { E.confirmBudget(s); E.clearPending(s, "weeklyCard"); };
|
||||||
|
const run = (s, n) => { for (let i = 0; i < n; i++) { if (s.pending.review) E.confirmBudget(s); if (s.pending.weeklyCard) E.clearPending(s, "weeklyCard"); E.advanceDay(s, bal); } };
|
||||||
|
|
||||||
|
// 1. blocked while pending
|
||||||
|
let s = fresh();
|
||||||
|
assert.equal(E.advanceDay(s, bal).blocked, "pending");
|
||||||
|
open(s); assert.equal(E.advanceDay(s, bal).advanced, true); assert.equal(s.day, 1);
|
||||||
|
|
||||||
|
// 2. determinism + JSON round trip mid-game
|
||||||
|
let a = fresh(), b = fresh(); run(a, 200); run(b, 100);
|
||||||
|
b = JSON.parse(JSON.stringify(b)); run(b, 100);
|
||||||
|
assert.deepEqual(a, b);
|
||||||
|
|
||||||
|
// 3. event cadence
|
||||||
|
s = fresh(); open(s); const evs = [];
|
||||||
|
for (let i = 0; i < 200; i++) { const r = E.advanceDay(s, bal); if (r.events.weeklyCard) E.clearPending(s, "weeklyCard"); if (r.events.review) { evs.push(s.day); E.confirmBudget(s); } }
|
||||||
|
assert.deepEqual(evs, [91, 182]);
|
||||||
|
|
||||||
|
// 4. budget lock + validation
|
||||||
|
s = fresh(); E.confirmBudget(s);
|
||||||
|
assert.deepEqual(E.setBudget(s, bal, { alloc: { police: 2 } }).errors, ["budget_locked"]);
|
||||||
|
s = fresh();
|
||||||
|
assert.equal(E.setBudget(s, bal, { alloc: { police: 999 } }).ok, false);
|
||||||
|
assert.equal(E.setBudget(s, bal, { alloc: { nope: 1 } }).ok, false);
|
||||||
|
assert.equal(E.setBudget(s, bal, { taxes: { vat: -1 } }).ok, false);
|
||||||
|
assert.equal(s.sectors.police.alloc, 1.2); // nothing applied on failure
|
||||||
|
assert.equal(E.setBudget(s, bal, { alloc: { police: 2 }, taxes: { vat: 22 } }).ok, true);
|
||||||
|
|
||||||
|
// 5. effects
|
||||||
|
s = fresh(); open(s);
|
||||||
|
const op0 = s.indicators.opinion;
|
||||||
|
E.applyEffect(s, bal, { stat: "opinion", delta: 5 });
|
||||||
|
assert.equal(s.indicators.opinion, op0 + 5);
|
||||||
|
E.applyEffect(s, bal, { stat: "opinion", delta: 100 });
|
||||||
|
assert.equal(s.indicators.opinion, 100); // clamped
|
||||||
|
E.applyEffect(s, bal, { stat: "growth", delta: -1, days: 10 });
|
||||||
|
assert.equal(s.modifiers.length, 1);
|
||||||
|
run(s, 10); assert.equal(s.modifiers.length, 0);
|
||||||
|
const d0 = s.economy.debt; E.applyEffect(s, bal, { oneOff: 2 });
|
||||||
|
assert.ok(Math.abs(s.economy.debt - d0 - 0.02 * s.economy.gdp) < 1e-9);
|
||||||
|
E.applyEffect(s, bal, { revenue: "income", delta: 3 }); assert.equal(s.economy.taxes.income, 33);
|
||||||
|
assert.equal(E.applyEffect(s, bal, { stat: "bogus", delta: 1 }), false);
|
||||||
|
|
||||||
|
// 6. bias lasts exactly `days` and moves the target
|
||||||
|
s = fresh(); open(s);
|
||||||
|
E.applyEffect(s, bal, { stat: "opinion", delta: 15, bias: true, days: 60 });
|
||||||
|
run(s, 59); assert.equal(s.modifiers.length, 1); run(s, 1); assert.equal(s.modifiers.length, 0);
|
||||||
|
|
||||||
|
// 7. term end
|
||||||
|
s = fresh(); run(s, 2000);
|
||||||
|
assert.equal(s.over.type, "term"); assert.equal(s.day, bal.time.termDays);
|
||||||
|
assert.equal(E.advanceDay(s, bal).blocked, "over");
|
||||||
|
// 8. real-time clock: 1 day = msPerDay / speed, frame time is capped at 1 s
|
||||||
|
{
|
||||||
|
const tick = (speed, frames, dt = 1000) => { const c = E.createClock(); let d = 0; for (let i = 0; i < frames; i++) d += E.consumeClock(c, dt, bal.time.msPerDay, speed); return d; };
|
||||||
|
assert.equal(tick(1, 9), 0);
|
||||||
|
assert.equal(tick(1, 10), 1);
|
||||||
|
assert.equal(tick(4, 10), 4); // 10 s at 4x = 4 days
|
||||||
|
assert.equal(tick(1, 1, 60000), 0); // a huge frame (tab was hidden) is capped, no fast-forward
|
||||||
|
{ const c = E.createClock(); assert.equal(E.consumeClock(c, -25, bal.time.msPerDay, 1), 0); assert.equal(c.acc, 0, "a negative first frame must not pre-load a day"); }
|
||||||
|
assert.equal(E.setSpeed(fresh(), bal, 3), false);
|
||||||
|
assert.equal(E.setSpeed(fresh(), bal, 4), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. budget preview
|
||||||
|
{
|
||||||
|
s = fresh(); // day-0 review is open
|
||||||
|
const before = JSON.stringify(s);
|
||||||
|
const same = E.previewBudget(s, bal, {});
|
||||||
|
assert.equal(JSON.stringify(s), before, "preview must not touch the real state");
|
||||||
|
assert.equal(same.ok, true);
|
||||||
|
assert.equal(same.points.length, 5);
|
||||||
|
assert.equal(same.points[4].day, 364);
|
||||||
|
|
||||||
|
// matches a real noise-free run of the same year
|
||||||
|
const quiet = structuredClone(bal);
|
||||||
|
quiet.noise.indicator = 0; for (const d of Object.values(quiet.vars)) d.noise = 0;
|
||||||
|
const real = fresh(); E.confirmBudget(real);
|
||||||
|
for (let i = 0; i < 364; i++) { E.clearPending(real, "weeklyCard"); if (real.pending.review) E.confirmBudget(real); E.advanceDay(real, quiet); }
|
||||||
|
assert.ok(Math.abs(real.economy.debt / real.economy.gdp * 100 - same.points[4].debtRatio) < 1e-9);
|
||||||
|
|
||||||
|
// a spending draft shows a bigger deficit and more debt than the status quo
|
||||||
|
const big = E.previewBudget(s, bal, { alloc: { police: 3.0, army: 5.0 } });
|
||||||
|
assert.ok(big.fiscal.deficit > same.fiscal.deficit + 4);
|
||||||
|
assert.ok(big.points[4].debtRatio > same.points[4].debtRatio);
|
||||||
|
|
||||||
|
// invalid drafts are rejected, outside a review too
|
||||||
|
assert.equal(E.previewBudget(s, bal, { alloc: { police: 999 } }).ok, false);
|
||||||
|
E.confirmBudget(s);
|
||||||
|
assert.equal(E.previewBudget(s, bal, { taxes: { vat: 25 } }).ok, true);
|
||||||
|
assert.equal(s.pending.review, false);
|
||||||
|
|
||||||
|
// fast enough to run while a slider is dragged
|
||||||
|
const t0 = performance.now(); for (let i = 0; i < 20; i++) E.previewBudget(s, bal, { alloc: { police: 1 + i / 20 } });
|
||||||
|
const ms = (performance.now() - t0) / 20;
|
||||||
|
console.log("preview ms:", ms.toFixed(1)); assert.ok(ms < 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. cards
|
||||||
|
{
|
||||||
|
const mk = (id, extra = {}) => ({
|
||||||
|
id, category: "economy", doc: "memo", sender: "min_finance",
|
||||||
|
title: { en: id, fr: id }, body: { en: "b", fr: "b" },
|
||||||
|
yes: { label: { en: "y", fr: "o" }, effects: [] }, no: { label: { en: "n", fr: "n" }, effects: [] },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const week = (g, cards, choice) => { // advance to the next weekly pause, draw and answer
|
||||||
|
for (let i = 0; i < 20 && !g.pending.weeklyCard; i++) { if (g.pending.review) E.confirmBudget(g); E.advanceDay(g, bal); }
|
||||||
|
const card = E.ensureCard(g, bal, cards);
|
||||||
|
if (card) E.answerCard(g, bal, cards, choice);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
// once-only cards come up once; conditions gate eligibility
|
||||||
|
let cards = [mk("a"), mk("b", { conditions: { min: { unrest: 99 } } })];
|
||||||
|
let g = fresh(1); E.confirmBudget(g);
|
||||||
|
assert.equal(week(g, cards, "yes").id, "a");
|
||||||
|
assert.equal(week(g, cards, "yes"), null, "card b is gated, card a is used up");
|
||||||
|
assert.equal(g.pending.weeklyCard, false, "an empty week must not block the game");
|
||||||
|
|
||||||
|
// a repeatable card respects its cooldown
|
||||||
|
cards = [mk("r", { once: false, cooldownDays: 21 })];
|
||||||
|
g = fresh(2); E.confirmBudget(g);
|
||||||
|
const seen = []; for (let i = 0; i < 8; i++) { const c = week(g, cards, "no"); seen.push(c ? g.day : null); }
|
||||||
|
const days = seen.filter((d) => d !== null);
|
||||||
|
assert.ok(days.length >= 2 && days.every((d, i) => i === 0 || d - days[i - 1] >= 21));
|
||||||
|
|
||||||
|
// flags, effects and follow-ups (a due follow-up beats a random draw)
|
||||||
|
cards = [
|
||||||
|
mk("start", {
|
||||||
|
yes: { label: { en: "y", fr: "y" }, effects: [{ stat: "opinion", delta: 5 }, { revenue: "income", delta: 2 }],
|
||||||
|
setFlags: ["f"], followUp: { id: "later", inDays: 10 } },
|
||||||
|
}),
|
||||||
|
mk("later", { followUpOnly: true, conditions: { flags: ["f"] } }),
|
||||||
|
mk("filler", { once: false, cooldownDays: 1 }),
|
||||||
|
];
|
||||||
|
g = fresh(3); E.confirmBudget(g);
|
||||||
|
const op = g.indicators.opinion;
|
||||||
|
for (let i = 0; i < 10 && g.pendingCard?.id !== "start"; i++) { if (g.pending.weeklyCard && !g.pendingCard) E.ensureCard(g, bal, [cards[0]]); if (!g.pendingCard) E.advanceDay(g, bal); }
|
||||||
|
assert.equal(g.pendingCard.id, "start");
|
||||||
|
E.answerCard(g, bal, cards, "yes");
|
||||||
|
assert.equal(g.indicators.opinion, op + 5);
|
||||||
|
assert.equal(g.economy.taxes.income, 32);
|
||||||
|
assert.ok(g.flags.f !== undefined);
|
||||||
|
assert.equal(g.followUps.length, 1);
|
||||||
|
assert.equal(g.cardHistory[0].id, "start");
|
||||||
|
let order = [];
|
||||||
|
for (let i = 0; i < 6; i++) { const c = week(g, cards, "no"); if (c) order.push(c.id); }
|
||||||
|
assert.ok(order.includes("later"), "follow-up was drawn");
|
||||||
|
assert.equal(order.filter((x) => x === "later").length, 1);
|
||||||
|
assert.equal(E.answerCard(g, bal, cards, "yes").ok, false, "nothing to answer");
|
||||||
|
|
||||||
|
// the real card files: every draw is valid, and a full term never throws
|
||||||
|
const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url));
|
||||||
|
const files = JSON.parse(fs.readFileSync(dir + "index.json")).files;
|
||||||
|
const real = files.flatMap((f) => JSON.parse(fs.readFileSync(dir + f)));
|
||||||
|
const play = (seed) => {
|
||||||
|
const gg = fresh(seed);
|
||||||
|
const drawn = new Set();
|
||||||
|
for (let guard = 0; guard < 6000 && !gg.over; guard++) {
|
||||||
|
if (gg.pending.review) E.confirmBudget(gg);
|
||||||
|
if (gg.pending.weeklyCard) {
|
||||||
|
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"); }
|
||||||
|
}
|
||||||
|
E.advanceDay(gg, bal);
|
||||||
|
}
|
||||||
|
return { gg, drawn };
|
||||||
|
};
|
||||||
|
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.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.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length);
|
||||||
|
console.log("cards in pool:", real.length, "| drawn in a run:", a.drawn.size, "| answered:", a.gg.cardHistory.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. profile, game over, alerts and the end-of-term review
|
||||||
|
{
|
||||||
|
// the pronoun question pauses the game until answered
|
||||||
|
let g = E.createGame({ scenario: scn, balance: bal, seed: 3 });
|
||||||
|
assert.equal(g.pending.profile, true);
|
||||||
|
assert.equal(g.pronouns, "they", "generic default");
|
||||||
|
E.confirmBudget(g); E.clearPending(g, "weeklyCard");
|
||||||
|
assert.equal(E.advanceDay(g, bal).blocked, "pending", "profile still pending");
|
||||||
|
assert.equal(E.setPronouns(g, "xyz"), false);
|
||||||
|
assert.equal(E.setPronouns(g, "she"), true);
|
||||||
|
assert.equal(g.pronouns, "she");
|
||||||
|
assert.equal(E.advanceDay(g, bal).advanced, true);
|
||||||
|
|
||||||
|
// helper: a game past all the day-0 pauses
|
||||||
|
const ready = (seed) => { const x = fresh(seed); E.confirmBudget(x); E.clearPending(x, "weeklyCard"); return x; };
|
||||||
|
// helper: advance one day, clearing whatever pauses appear, optionally forcing indicators first
|
||||||
|
const step = (x, force = {}) => {
|
||||||
|
Object.assign(x.indicators, force);
|
||||||
|
for (const k of ["weeklyCard", "review"]) x.pending[k] = false;
|
||||||
|
return E.advanceDay(x, bal);
|
||||||
|
};
|
||||||
|
|
||||||
|
// sovereign default: immediate, with the lenders' telegram queued
|
||||||
|
g = ready(4);
|
||||||
|
g.economy.debt = g.economy.gdp * 2.05;
|
||||||
|
step(g);
|
||||||
|
assert.equal(g.over.type, "default");
|
||||||
|
assert.ok(g.alerts.some((a) => a.kind === "default"));
|
||||||
|
|
||||||
|
// impeachment: certain after 60 days in the zone, and the alert comes on day one
|
||||||
|
g = ready(5);
|
||||||
|
let days = 0;
|
||||||
|
for (; days < 80 && !g.over; days++) { while (g.pending.alert) E.dismissAlert(g); step(g, { civilRights: 3, opinion: 10 }); }
|
||||||
|
assert.equal(g.over?.type, "impeachment");
|
||||||
|
assert.equal(days, 60, "exactly the configured number of days");
|
||||||
|
|
||||||
|
// leaving the zone resets impeachment and fades the other two
|
||||||
|
g = ready(6);
|
||||||
|
for (let i = 0; i < 20; i++) { while (g.pending.alert) E.dismissAlert(g); step(g, { civilRights: 3, opinion: 10 }); }
|
||||||
|
assert.equal(g.risk.impeachment, 20);
|
||||||
|
step(g, { civilRights: 60, opinion: 60, unrest: 20 });
|
||||||
|
assert.equal(g.risk.impeachment, 0);
|
||||||
|
|
||||||
|
// coup and revolution: a growing daily chance, so they happen, but not on day one
|
||||||
|
for (const [name, zone] of [["coup", { armyLoyalty: 5, stability: 5 }], ["revolution", { unrest: 98, opinion: 3 }]]) {
|
||||||
|
g = ready(7);
|
||||||
|
let n = 0;
|
||||||
|
for (; n < 300 && !g.over; n++) { while (g.pending.alert) E.dismissAlert(g); step(g, zone); }
|
||||||
|
assert.equal(g.over?.type, name);
|
||||||
|
assert.ok(n > 1 && n < 120, name + " came after " + n + " days");
|
||||||
|
const g2 = ready(7); let n2 = 0;
|
||||||
|
for (; n2 < 300 && !g2.over; n2++) { while (g2.pending.alert) E.dismissAlert(g2); step(g2, zone); }
|
||||||
|
assert.equal(n, n2, "same seed, same fall");
|
||||||
|
}
|
||||||
|
|
||||||
|
// an alert blocks time until it is dismissed
|
||||||
|
g = ready(8);
|
||||||
|
step(g, { civilRights: 3, opinion: 10 });
|
||||||
|
assert.equal(g.pending.alert, true);
|
||||||
|
assert.equal(g.alerts[0].kind, "impeachment");
|
||||||
|
assert.equal(E.advanceDay(g, bal).blocked, "pending");
|
||||||
|
E.dismissAlert(g);
|
||||||
|
assert.equal(g.pending.alert, false);
|
||||||
|
|
||||||
|
// decision impact ranking
|
||||||
|
assert.equal(E.optionImpact({ effects: [{ stat: "opinion", delta: -5 }] }), 5);
|
||||||
|
assert.equal(E.optionImpact({ effects: [{ stat: "opinion", delta: 2, bias: true, days: 180 }] }), 4);
|
||||||
|
assert.equal(E.optionImpact({ effects: [{ oneOff: 1 }, { revenue: "income", delta: 3 }] }), 11);
|
||||||
|
assert.equal(E.optionImpact({}), 0);
|
||||||
|
|
||||||
|
// the review, with the real cards and titles
|
||||||
|
const rdir = fileURLToPath(new URL("../web/data/", import.meta.url));
|
||||||
|
const endgame = JSON.parse(fs.readFileSync(rdir + "endgame.json"));
|
||||||
|
const files = JSON.parse(fs.readFileSync(rdir + "cards/index.json")).files;
|
||||||
|
const real = files.flatMap((f) => JSON.parse(fs.readFileSync(rdir + "cards/" + f)));
|
||||||
|
const run = (seed, review) => {
|
||||||
|
const x = fresh(seed);
|
||||||
|
for (let guard = 0; guard < 6000 && !x.over; guard++) {
|
||||||
|
if (x.pending.review) { if (review) E.setBudget(x, bal, review(x)); E.confirmBudget(x); }
|
||||||
|
while (x.pending.alert) E.dismissAlert(x);
|
||||||
|
if (x.pending.weeklyCard) { const c = E.ensureCard(x, bal, real); if (c) E.answerCard(x, bal, real, E.nextFloat(x) < 0.5 ? "yes" : "no"); }
|
||||||
|
E.advanceDay(x, bal);
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
const calm = run(21);
|
||||||
|
assert.equal(calm.over.type, "term");
|
||||||
|
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(["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(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5);
|
||||||
|
assert.ok(rv.topDecisions.every((d, i, a) => i === 0 || a[i - 1].impact >= d.impact), "sorted by impact");
|
||||||
|
assert.deepEqual(E.buildReview(calm, bal, real, endgame), rv, "pure and repeatable");
|
||||||
|
|
||||||
|
const ruin = run(22, (x) => ({ alloc: Object.fromEntries(Object.entries(x.sectors).map(([id, s]) => [id, s.need * 2])), taxes: { income: 15, corporate: 12, vat: 10 } }));
|
||||||
|
assert.equal(ruin.over.type, "default");
|
||||||
|
const rr = E.buildReview(ruin, bal, real, endgame);
|
||||||
|
assert.equal(rr.grade, "F");
|
||||||
|
assert.equal(rr.titleId, "defaulted");
|
||||||
|
assert.ok(rr.score <= bal.score.overCap);
|
||||||
|
console.log("review:", rv.grade, Math.round(rv.score), rv.titleId, "| ruin ends day", ruin.over.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. saves: codec, validation, storage
|
||||||
|
{
|
||||||
|
const sdir = fileURLToPath(new URL("../web/data/", import.meta.url));
|
||||||
|
const cardsAll = JSON.parse(fs.readFileSync(sdir + "cards/index.json")).files.flatMap((f) => JSON.parse(fs.readFileSync(sdir + "cards/" + f)));
|
||||||
|
const scenarios = { default: scn };
|
||||||
|
const content = { scenarios, balance: bal, cards: cardsAll };
|
||||||
|
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||||
|
|
||||||
|
// a game with some history: budget set, cards answered, an alert waiting
|
||||||
|
const play = (g, days) => {
|
||||||
|
for (let guard = 0; guard < days * 4 && g.day < days && !g.over; guard++) {
|
||||||
|
if (g.pending.review) E.confirmBudget(g);
|
||||||
|
while (g.pending.alert) E.dismissAlert(g);
|
||||||
|
if (g.pending.weeklyCard) { const c = E.ensureCard(g, bal, cardsAll); if (c) E.answerCard(g, bal, cardsAll, E.nextFloat(g) < 0.5 ? "yes" : "no"); }
|
||||||
|
E.advanceDay(g, bal);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
};
|
||||||
|
const g = play(fresh(31), 200);
|
||||||
|
const text = encodeSave(g);
|
||||||
|
|
||||||
|
// codec round trip, whitespace tolerance, the CRC reference value
|
||||||
|
assert.ok(text.startsWith("CG1:"));
|
||||||
|
assert.deepEqual(decodeSave(text).state, clone(g));
|
||||||
|
assert.deepEqual(decodeSave(" " + text.replace(/(.{76})/g, "$1\n ") + "\n").state, clone(g));
|
||||||
|
assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
|
||||||
|
assert.equal(encodeSave({ a: "é✓" }).length > 10, true);
|
||||||
|
assert.deepEqual(decodeSave(encodeSave({ version: 1, note: "é✓ 日本" })).state.note, "é✓ 日本");
|
||||||
|
|
||||||
|
// damage is detected, never thrown
|
||||||
|
const flip = (s, i) => s.slice(0, i) + (s[i] === "A" ? "B" : "A") + s.slice(i + 1);
|
||||||
|
assert.equal(decodeSave(flip(text, 200)).error, "checksum");
|
||||||
|
assert.equal(decodeSave(text.slice(0, text.length - 40)).error, "checksum");
|
||||||
|
assert.equal(decodeSave("hello").error, "format");
|
||||||
|
assert.equal(decodeSave("CG1:zzzz:abcd").error, "format");
|
||||||
|
assert.equal(decodeSave("").error, "format");
|
||||||
|
assert.equal(decodeSave(text.replace("CG1:", "CG2:")).error, "format");
|
||||||
|
|
||||||
|
// versions: newer is refused, older is migrated step by step
|
||||||
|
const tooNew = encodeSave({ ...clone(g), version: 99 });
|
||||||
|
assert.equal(decodeSave(tooNew).error, "tooNew");
|
||||||
|
const old = encodeSave({ ...clone(g), version: 0 });
|
||||||
|
assert.equal(decodeSave(old).error, "version", "no migration registered");
|
||||||
|
MIGRATIONS[0] = (s) => ({ ...s, version: 1, migrated: true });
|
||||||
|
assert.equal(decodeSave(old).state.migrated, true);
|
||||||
|
delete MIGRATIONS[0];
|
||||||
|
|
||||||
|
// validation accepts a real save and resumes the exact same game
|
||||||
|
const loaded = E.restoreState(decodeSave(text).state, content);
|
||||||
|
assert.equal(loaded.ok, true);
|
||||||
|
const a = play(clone(g), 330), b = play(loaded.state, 330);
|
||||||
|
assert.deepEqual(a, b, "a restored game continues identically");
|
||||||
|
|
||||||
|
// validation rejects damage and tampering
|
||||||
|
const bad = (mutate, code = "invalid") => {
|
||||||
|
const s = clone(g); mutate(s);
|
||||||
|
const r = E.restoreState(s, content);
|
||||||
|
assert.equal(r.ok, false, "should have been rejected: " + mutate);
|
||||||
|
assert.equal(r.error, code);
|
||||||
|
};
|
||||||
|
bad((s) => { s.day = -1; });
|
||||||
|
bad((s) => { s.day = 99999; });
|
||||||
|
bad((s) => { s.day = 1.5; });
|
||||||
|
bad((s) => { s.indicators.order = 140; });
|
||||||
|
bad((s) => { s.indicators.order = null; });
|
||||||
|
bad((s) => { s.economy.gdp = NaN; });
|
||||||
|
bad((s) => { s.economy.debt = -5; });
|
||||||
|
bad((s) => { delete s.sectors.police; });
|
||||||
|
bad((s) => { s.sectors.police.alloc = 99; });
|
||||||
|
bad((s) => { s.sectors.mystery = { need: 1, alloc: 1, quality: 50 }; });
|
||||||
|
bad((s) => { s.economy.taxes.income = "high"; });
|
||||||
|
bad((s) => { s.pronouns = "xyz"; });
|
||||||
|
bad((s) => { s.countryName = ""; });
|
||||||
|
bad((s) => { s.countryName = "x".repeat(200); });
|
||||||
|
bad((s) => { s.speed = 7; });
|
||||||
|
bad((s) => { s.history.series.opinion.pop(); });
|
||||||
|
bad((s) => { delete s.history.series.debtRatio; });
|
||||||
|
bad((s) => { s.modifiers.push({ type: "shift", group: "ind", key: "bogus", perDay: 1, remaining: 3 }); });
|
||||||
|
bad((s) => { s.cardHistory.push({ id: "a", category: "x", day: 3, choice: "maybe" }); });
|
||||||
|
bad((s) => { s.over = { type: "exploded", day: 3 }; });
|
||||||
|
bad((s) => { s.ref.dev.growth = Infinity; });
|
||||||
|
bad((s) => { s.scenarioId = "nowhere"; }, "scenario");
|
||||||
|
bad((s) => { s.version = 7; }, "version");
|
||||||
|
assert.equal(E.restoreState(null, content).ok, false);
|
||||||
|
assert.equal(E.restoreState([], content).ok, false);
|
||||||
|
|
||||||
|
// content that changed since the save is repaired, not fatal
|
||||||
|
const stale = clone(g);
|
||||||
|
stale.pendingCard = { id: "deleted_card", day: stale.day }; stale.pending.weeklyCard = true;
|
||||||
|
stale.followUps.push({ id: "deleted_card", due: 5 }, { id: cardsAll[0].id, due: 9 });
|
||||||
|
const fixed = E.restoreState(stale, content);
|
||||||
|
assert.equal(fixed.ok, true);
|
||||||
|
assert.equal(fixed.state.pendingCard, null);
|
||||||
|
assert.deepEqual(fixed.state.followUps.map((f) => f.id).filter((id) => id === "deleted_card"), []);
|
||||||
|
assert.ok(fixed.state.followUps.some((f) => f.id === cardsAll[0].id));
|
||||||
|
// an alert queue and its pause flag stay consistent
|
||||||
|
const al = clone(g); al.alerts = [{ kind: "coup", day: 10 }]; al.pending.alert = false;
|
||||||
|
assert.equal(E.restoreState(al, content).state.pending.alert, true);
|
||||||
|
|
||||||
|
// a game saved while waiting on a decision comes back waiting on the same decision
|
||||||
|
const waiting = fresh(32); E.confirmBudget(waiting); E.ensureCard(waiting, bal, cardsAll);
|
||||||
|
const w = E.restoreState(decodeSave(encodeSave(waiting)).state, content).state;
|
||||||
|
assert.equal(w.pendingCard.id, waiting.pendingCard.id);
|
||||||
|
assert.equal(E.advanceDay(w, bal).blocked, "pending");
|
||||||
|
|
||||||
|
// storage: slots, autosave, settings, and the fallbacks
|
||||||
|
const mem = new Map();
|
||||||
|
const backend = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v), removeItem: (k) => mem.delete(k) };
|
||||||
|
const store = createStorage(backend);
|
||||||
|
assert.equal(store.persistent, true);
|
||||||
|
assert.equal(readSave(store, KEYS.slot(1)), null);
|
||||||
|
assert.equal(writeSave(store, KEYS.slot(1), g), true);
|
||||||
|
const back = readSave(store, KEYS.slot(1));
|
||||||
|
assert.deepEqual(back.state, clone(g));
|
||||||
|
assert.equal(back.meta.day, g.day);
|
||||||
|
assert.equal(back.meta.countryName, g.countryName);
|
||||||
|
store.remove(KEYS.slot(1));
|
||||||
|
assert.equal(readSave(store, KEYS.slot(1)), null);
|
||||||
|
mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty");
|
||||||
|
mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null);
|
||||||
|
|
||||||
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false });
|
||||||
|
saveSettings(store, { lang: "fr", slow: true });
|
||||||
|
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true });
|
||||||
|
mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" }));
|
||||||
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false }, "unknown values are ignored");
|
||||||
|
|
||||||
|
// no storage at all, a throwing storage, and a full storage: the game keeps going in memory
|
||||||
|
const none = createStorage(null);
|
||||||
|
assert.equal(none.persistent, false);
|
||||||
|
assert.equal(none.set("k", "v"), false);
|
||||||
|
assert.equal(none.get("k"), "v");
|
||||||
|
none.remove("k"); assert.equal(none.get("k"), null);
|
||||||
|
const angry = createStorage({ getItem() { throw new Error("blocked"); }, setItem() { throw new Error("quota"); }, removeItem() { throw new Error("blocked"); } });
|
||||||
|
assert.equal(angry.set("k", "v"), false);
|
||||||
|
assert.equal(angry.get("k"), "v");
|
||||||
|
assert.equal(angry.persistent, false);
|
||||||
|
angry.remove("k");
|
||||||
|
assert.equal(writeSave(angry, KEYS.auto, g), false);
|
||||||
|
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("all engine checks passed");
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/* Decision cards: documents in the manila folder, answered with rubber stamps */
|
||||||
|
|
||||||
|
.inbox__body.is-card {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
background-color: var(--paper);
|
||||||
|
background-image: var(--paper-grain);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.card__kind { font-weight: 700; color: var(--ink); }
|
||||||
|
.card__date { font-family: var(--font-num); letter-spacing: 0.04em; text-transform: none; }
|
||||||
|
|
||||||
|
.card__from { display: flex; align-items: center; gap: 10px; margin: 10px 0 6px; }
|
||||||
|
.card__from b { display: block; font-size: 13px; font-weight: 400; }
|
||||||
|
.card__from small { display: block; font-size: 11px; opacity: 0.75; }
|
||||||
|
|
||||||
|
.seal {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--paper), inset 0 0 0 3px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title { margin: 4px 0 6px; font-family: var(--font-head); font-weight: 400; font-size: 24px; line-height: 1.1; }
|
||||||
|
.card__body p { margin: 0 0 8px; font-size: 14px; line-height: 1.45; }
|
||||||
|
|
||||||
|
.card__choices { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; }
|
||||||
|
.card__keys { margin: 8px 0 0; text-align: center; font-size: 10px; opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Document types */
|
||||||
|
.card--telegram {
|
||||||
|
background-color: #efe0a2;
|
||||||
|
font-family: var(--font-num);
|
||||||
|
border-top: 6px solid #c9a93a;
|
||||||
|
border-bottom: 6px solid #c9a93a;
|
||||||
|
}
|
||||||
|
.card--telegram .card__head { border-bottom: 2px dashed var(--ink); }
|
||||||
|
.card--telegram .card__body p { text-transform: uppercase; letter-spacing: 0.04em; font-size: 13px; }
|
||||||
|
.card--telegram .card__title { font-family: var(--font-num); font-weight: 700; font-size: 20px; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.card--petition {
|
||||||
|
background-image:
|
||||||
|
var(--paper-grain),
|
||||||
|
repeating-linear-gradient(transparent 0 21px, rgba(60, 105, 140, 0.28) 21px 22px);
|
||||||
|
background-position: 0 0, 0 4px;
|
||||||
|
}
|
||||||
|
.card--petition .card__kind::after { content: " ✎"; }
|
||||||
|
|
||||||
|
.card--report { background-color: #e4e6df; border-top: 8px solid var(--ink); }
|
||||||
|
.card--report .card__head { border-bottom: 1px solid var(--ink); }
|
||||||
|
|
||||||
|
.card--letter { background-color: #f3e8cb; font-family: var(--font-head); }
|
||||||
|
.card--letter .card__body p { font-size: 15px; }
|
||||||
|
.card--letter .seal { background: #8d2b1f; color: #f6e7c8; border-color: #5e1a12; box-shadow: inset 0 0 0 2px #8d2b1f, inset 0 0 0 3px #f6e7c8; }
|
||||||
|
|
||||||
|
/* Stamp buttons */
|
||||||
|
.stamp-btn {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 10px 10px 8px;
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
border: 2px solid rgba(43, 36, 24, 0.55);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stamp-btn__ink {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.stamp-btn--yes .stamp-btn__ink { color: var(--ink-green); }
|
||||||
|
.stamp-btn--no .stamp-btn__ink { color: var(--ink-red); }
|
||||||
|
.stamp-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.6); }
|
||||||
|
.stamp-btn:hover:not(:disabled) .stamp-btn__ink { opacity: 1; transform: rotate(-4deg) scale(1.08); }
|
||||||
|
.stamp-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
.stamp-btn:disabled { cursor: default; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* The big stamp that lands on the document */
|
||||||
|
.card__stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 46%;
|
||||||
|
left: 50%;
|
||||||
|
padding: 4px 22px;
|
||||||
|
border: 6px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 44px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
transform: translate(-50%, -50%) rotate(-12deg);
|
||||||
|
animation: stamp-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
||||||
|
}
|
||||||
|
.card__stamp--yes { color: var(--ink-green); }
|
||||||
|
.card__stamp--no { color: var(--ink-red); }
|
||||||
|
|
||||||
|
@keyframes stamp-in {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -50%) rotate(-12deg) scale(2.6); }
|
||||||
|
to { opacity: 0.9; transform: translate(-50%, -50%) rotate(-12deg) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card__stamp { animation: none; opacity: 0.9; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Special Elite";
|
||||||
|
src: url("../fonts/SpecialElite-Regular.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IM Fell English";
|
||||||
|
src: url("../fonts/IMFeENrm28P.ttf") format("truetype");
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IM Fell English";
|
||||||
|
src: url("../fonts/IMFeENit28P.ttf") format("truetype");
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Courier Prime";
|
||||||
|
src: url("../fonts/CourierPrime-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Courier Prime";
|
||||||
|
src: url("../fonts/CourierPrime-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-type: "Special Elite", "Courier New", monospace;
|
||||||
|
--font-head: "IM Fell English", Georgia, serif;
|
||||||
|
--font-num: "Courier Prime", "Courier New", monospace;
|
||||||
|
|
||||||
|
--ink: #1c2a44;
|
||||||
|
--ink-red: #8d2b1f;
|
||||||
|
--ink-green: #2c5a3a;
|
||||||
|
--text: #2b2418;
|
||||||
|
--paper: #efe4c8;
|
||||||
|
--news-paper: #e2dccb;
|
||||||
|
|
||||||
|
--paper-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .38 0 0 0 0 .30 0 0 0 0 .18 .5 0 0 0 -.08'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||||
|
--leather-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='l'><feTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .9 0 0 0 -.25'/></filter><rect width='100%' height='100%' filter='url(%23l)'/></svg>");
|
||||||
|
--wood-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='700' height='260'><filter id='w'><feTurbulence type='fractalNoise' baseFrequency='0.004 0.45' numOctaves='4' seed='4'/><feColorMatrix values='0 0 0 0 .10 0 0 0 0 .05 0 0 0 0 .02 1.3 0 0 0 -.45'/></filter><rect width='100%' height='100%' filter='url(%23w)'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #4b2f1b;
|
||||||
|
background-image:
|
||||||
|
var(--wood-grain),
|
||||||
|
repeating-linear-gradient(0deg, transparent 0 258px, rgba(0, 0, 0, 0.5) 258px 261px, rgba(255, 215, 160, 0.07) 261px 263px),
|
||||||
|
linear-gradient(90deg, #3d2615, #5d3b22 35%, #47301b 70%, #603d22);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
#desk {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: inset 0 0 180px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leather desk pad with a stitched border */
|
||||||
|
#pad {
|
||||||
|
position: relative;
|
||||||
|
width: min(1320px, 100%);
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
min-height: 640px;
|
||||||
|
padding: 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1.1fr) minmax(300px, 1fr) minmax(300px, 0.9fr);
|
||||||
|
gap: 24px;
|
||||||
|
background-color: #26322c;
|
||||||
|
background-image:
|
||||||
|
var(--leather-grain),
|
||||||
|
radial-gradient(ellipse at 30% 15%, rgba(255, 255, 255, 0.08), transparent 60%);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px rgba(0, 0, 0, 0.45),
|
||||||
|
inset 0 0 80px rgba(0, 0, 0, 0.6),
|
||||||
|
0 20px 44px rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pad::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px;
|
||||||
|
border: 2px dashed rgba(224, 196, 140, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#side {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pad > * { min-height: 0; }
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/* Urgent telegrams and the end-of-game front page */
|
||||||
|
|
||||||
|
/* ---- Urgent telegram: slides in over the desk ---- */
|
||||||
|
.alerts {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 30;
|
||||||
|
width: min(560px, 90%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.alerts[hidden] { display: none; }
|
||||||
|
|
||||||
|
.telegram {
|
||||||
|
padding: 12px 18px 14px;
|
||||||
|
background: #efe0a2 var(--paper-grain);
|
||||||
|
border-top: 7px solid #c9a93a;
|
||||||
|
border-bottom: 7px solid #c9a93a;
|
||||||
|
font-family: var(--font-num);
|
||||||
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.7);
|
||||||
|
transform: rotate(-0.6deg);
|
||||||
|
animation: telegram-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
|
||||||
|
}
|
||||||
|
.telegram__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 2px dashed var(--ink);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-red);
|
||||||
|
}
|
||||||
|
.telegram h3 { margin: 8px 0 4px; font-family: var(--font-num); font-weight: 700; font-size: 19px; text-transform: uppercase; }
|
||||||
|
.telegram p { margin: 0 0 6px; font-size: 13px; line-height: 1.5; letter-spacing: 0.04em; }
|
||||||
|
.telegram .brass { margin-top: 6px; }
|
||||||
|
|
||||||
|
@keyframes telegram-in {
|
||||||
|
from { opacity: 0; transform: translateY(-60px) rotate(-3deg); }
|
||||||
|
to { opacity: 1; transform: translateY(0) rotate(-0.6deg); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .telegram { animation: none; } }
|
||||||
|
|
||||||
|
/* ---- Profile question (first thing in the folder) ---- */
|
||||||
|
.profile__ask { font-weight: 700; margin-top: 14px; }
|
||||||
|
.profile__choices { display: grid; gap: 8px; margin: 10px 0 6px; }
|
||||||
|
.profile__choices .brass--wide { margin-top: 0; }
|
||||||
|
.profile__note { margin: 8px 0 0; font-size: 11px; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ---- End of game ---- */
|
||||||
|
#endgame {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#endgame[hidden] { display: none; }
|
||||||
|
|
||||||
|
#pad.is-endgame #dashboard,
|
||||||
|
#pad.is-endgame #folder,
|
||||||
|
#pad.is-endgame #side,
|
||||||
|
#pad.is-endgame #ledger { display: none; }
|
||||||
|
|
||||||
|
.end {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 22px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end__banner { text-align: center; padding: 4px 0 10px; }
|
||||||
|
.end__banner h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.end--lost .end__banner h1 { color: var(--ink-red); }
|
||||||
|
.end__banner p { margin: 4px 0 0; font-size: 16px; font-style: italic; }
|
||||||
|
|
||||||
|
.end__grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(250px, 1fr) minmax(300px, 1.3fr) minmax(250px, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--text);
|
||||||
|
}
|
||||||
|
.end__grid h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid rgba(43, 36, 24, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end__verdict { display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.end__grade {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.end__letter {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 74px;
|
||||||
|
height: 74px;
|
||||||
|
border: 5px double var(--ink);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 50px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink);
|
||||||
|
transform: rotate(-6deg);
|
||||||
|
}
|
||||||
|
.end__grade[data-grade="S"] .end__letter,
|
||||||
|
.end__grade[data-grade="A"] .end__letter { color: var(--ink-green); border-color: var(--ink-green); }
|
||||||
|
.end__grade[data-grade="D"] .end__letter,
|
||||||
|
.end__grade[data-grade="F"] .end__letter { color: var(--ink-red); border-color: var(--ink-red); }
|
||||||
|
.end__title { margin: 4px 0; font-size: 24px; line-height: 1.1; font-style: italic; }
|
||||||
|
.end__title::before { content: "« "; }
|
||||||
|
.end__title::after { content: " »"; }
|
||||||
|
.end__text { margin: 0 0 8px; font-size: 15px; line-height: 1.4; }
|
||||||
|
.end__score { margin: auto 0 0; font-family: var(--font-num); font-size: 12px; opacity: 0.85; }
|
||||||
|
|
||||||
|
.end__charts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-content: start; }
|
||||||
|
.end__chart { margin: 0; }
|
||||||
|
.end__chart figcaption { display: flex; align-items: baseline; gap: 6px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.end__chart figcaption span { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.end__chart figcaption b { font-family: var(--font-num); }
|
||||||
|
.end__chart figcaption small { margin-left: auto; font-size: 10px; opacity: 0.7; }
|
||||||
|
.chart { display: block; height: 96px; background: rgba(255, 255, 255, 0.3); border: 1px solid rgba(60, 105, 140, 0.4); }
|
||||||
|
|
||||||
|
.end__decisions ol { margin: 0; padding: 0; list-style: none; counter-reset: d; }
|
||||||
|
.end__decisions li { counter-increment: d; position: relative; padding: 0 0 8px 26px; font-size: 14px; line-height: 1.25; }
|
||||||
|
.end__decisions li::before {
|
||||||
|
content: counter(d);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -1px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--ink-red);
|
||||||
|
}
|
||||||
|
.end__decisions li b { display: block; font-weight: 400; font-size: 15px; }
|
||||||
|
.end__when { display: block; font-size: 11px; opacity: 0.7; }
|
||||||
|
.end__chose { display: block; font-size: 12px; font-style: italic; }
|
||||||
|
|
||||||
|
.end__foot { padding-top: 8px; text-align: center; }
|
||||||
|
.end__foot .brass--wide { margin-top: 0; }
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/* Quarterly ledger: a two-page accounting book over the centre and right columns */
|
||||||
|
|
||||||
|
.ledger {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
grid-row: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.ledger[hidden] { display: none; }
|
||||||
|
|
||||||
|
#pad.is-ledger #folder,
|
||||||
|
#pad.is-ledger #side { display: none; }
|
||||||
|
|
||||||
|
.ledger__book {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 10px;
|
||||||
|
background: #1d3a2b;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.4), 0 12px 24px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger__page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 18px 12px 26px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: #f1e9cf;
|
||||||
|
background-image:
|
||||||
|
var(--paper-grain),
|
||||||
|
linear-gradient(90deg, transparent 18px, rgba(160, 50, 40, 0.5) 18px 19px, transparent 19px);
|
||||||
|
}
|
||||||
|
.ledger__page--left { border-radius: 4px 0 0 4px; box-shadow: inset -14px 0 18px -10px rgba(60, 40, 10, 0.5); }
|
||||||
|
.ledger__page--right { border-radius: 0 4px 4px 0; box-shadow: inset 14px 0 18px -10px rgba(60, 40, 10, 0.5); }
|
||||||
|
|
||||||
|
.ledger__page h2 { margin: 0; font-family: var(--font-head); font-weight: 400; font-size: 21px; letter-spacing: 0.03em; }
|
||||||
|
.ledger__page h3 {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid rgba(43, 36, 24, 0.5);
|
||||||
|
}
|
||||||
|
.ledger__sub { margin: 2px 0 6px; font-size: 11px; line-height: 1.3; opacity: 0.8; }
|
||||||
|
|
||||||
|
/* Rows sit on the green ruled lines of the book (28px pitch) */
|
||||||
|
.ledger__rows {
|
||||||
|
background: repeating-linear-gradient(transparent 0 27px, rgba(50, 120, 80, 0.38) 27px 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lrow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 124px minmax(60px, 1fr) 54px 18px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.lrow--tax { grid-template-columns: 112px minmax(50px, 1fr) 54px 74px; }
|
||||||
|
|
||||||
|
.lrow__name {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.lrow__ctl { position: relative; display: block; height: 20px; }
|
||||||
|
.lrow__ctl input { position: absolute; inset: 0; width: 100%; margin: 0; }
|
||||||
|
.lrow__tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
width: 2px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--ink-red);
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.lrow__val { white-space: nowrap; font-family: var(--font-num); font-weight: 700; font-size: 13px; text-align: right; color: var(--ink); }
|
||||||
|
.lrow.is-changed .lrow__val { color: var(--ink-red); }
|
||||||
|
.lrow__q { text-align: center; font-size: 11px; cursor: help; }
|
||||||
|
.lrow__q.up { color: var(--ink-green); }
|
||||||
|
.lrow__q.down { color: var(--ink-red); }
|
||||||
|
.lrow__q.flat { opacity: 0.5; }
|
||||||
|
.lrow__yield { font-size: 11px; text-align: right; opacity: 0.8; }
|
||||||
|
|
||||||
|
.ledger__total {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
border-top: 2px solid var(--ink);
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
.ledger__total b { font-family: var(--font-num); }
|
||||||
|
|
||||||
|
.sum { display: grid; grid-template-columns: 1fr auto; gap: 1px 10px; margin: 0; font-size: 13px; }
|
||||||
|
.sum dd { margin: 0; text-align: right; font-family: var(--font-num); font-weight: 700; }
|
||||||
|
.sum small { display: block; font-weight: 400; font-size: 11px; }
|
||||||
|
.sum dt:nth-of-type(4), .sum dd:nth-of-type(4) { border-top: 1px solid var(--ink); }
|
||||||
|
.sum__balance { font-weight: 700; }
|
||||||
|
.sum__balance.good { color: var(--ink-green); }
|
||||||
|
.sum__balance.bad { color: var(--ink-red); }
|
||||||
|
|
||||||
|
.proj { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.proj th { font-weight: 400; text-align: left; padding: 1px 0; }
|
||||||
|
.proj thead th { text-align: right; font-size: 11px; opacity: 0.8; }
|
||||||
|
.proj td { white-space: nowrap; text-align: right; font-family: var(--font-num); padding: 1px 0 1px 6px; }
|
||||||
|
.proj tbody tr:nth-child(odd) { background: rgba(50, 120, 80, 0.08); }
|
||||||
|
|
||||||
|
.ledger__actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; }
|
||||||
|
.ledger__actions .brass { white-space: nowrap; padding: 5px 12px; font-size: 13px; }
|
||||||
|
.ledger__actions .brass--wide { margin-top: 0; }
|
||||||
|
.ledger__error { color: var(--ink-red); margin: 6px 0 0; font-size: 12px; }
|
||||||
|
.ledger__error[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Range sliders: an ink rule with a brass knob */
|
||||||
|
.ledger input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ledger input[type="range"]::-webkit-slider-runnable-track { height: 4px; background: var(--ink); border-radius: 2px; }
|
||||||
|
.ledger input[type="range"]::-moz-range-track { height: 4px; background: var(--ink); border-radius: 2px; }
|
||||||
|
.ledger input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #6e5316;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #f1d98c, #b98e32);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.ledger input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #6e5316;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #f1d98c, #b98e32);
|
||||||
|
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; }
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* Main menu and pause menu: a "Top secret" folder in the middle of the desk */
|
||||||
|
|
||||||
|
#menu {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* While the menu is open it replaces everything else on the pad. */
|
||||||
|
#pad.is-menu > :not(#menu) { display: none !important; }
|
||||||
|
|
||||||
|
.cal__menu { display: block; width: 100%; margin-top: 8px; }
|
||||||
|
|
||||||
|
.menu__folder {
|
||||||
|
position: relative;
|
||||||
|
width: min(580px, 94%);
|
||||||
|
max-height: 100%;
|
||||||
|
padding: 42px 22px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #d4b77a var(--paper-grain);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 40px rgba(110, 75, 25, 0.35),
|
||||||
|
0 2px 2px rgba(0, 0, 0, 0.35),
|
||||||
|
0 18px 34px rgba(0, 0, 0, 0.6);
|
||||||
|
transform: rotate(-0.4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 22px;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
transform: rotate(7deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__sheet {
|
||||||
|
padding: 20px 24px 18px;
|
||||||
|
background: var(--paper) var(--paper-grain);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.menu__sheet h1,
|
||||||
|
.menu__sheet h3 { margin: 0 0 6px; font-family: var(--font-head); font-weight: 400; }
|
||||||
|
.menu__title { font-size: 46px; line-height: 1; letter-spacing: 0.02em; }
|
||||||
|
.menu__sheet h3 { font-size: 24px; }
|
||||||
|
.menu__sheet p { margin: 0 0 10px; font-size: 14px; line-height: 1.4; }
|
||||||
|
.menu__sub { opacity: 0.85; }
|
||||||
|
|
||||||
|
.menu__list { display: grid; gap: 8px; margin: 14px 0 4px; }
|
||||||
|
.menu__list .brass--wide,
|
||||||
|
.menu__row .brass--wide { margin-top: 0; width: 100%; }
|
||||||
|
.brass small { display: block; font-size: 11px; font-weight: 400; opacity: 0.8; }
|
||||||
|
|
||||||
|
.menu__row { display: flex; gap: 10px; margin: 10px 0 4px; }
|
||||||
|
.menu__row .brass { flex: 1; }
|
||||||
|
.menu__foot { margin-top: 14px; display: grid; }
|
||||||
|
.menu__foot .brass--wide { margin-top: 0; }
|
||||||
|
|
||||||
|
.menu__field { display: block; margin: 12px 0 0; padding: 0; border: 0; font-size: 13px; }
|
||||||
|
.menu__field input[type="text"],
|
||||||
|
.menu__field select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border: 1px solid rgba(43, 36, 24, 0.55);
|
||||||
|
}
|
||||||
|
.menu__field legend { padding: 0; margin-bottom: 4px; }
|
||||||
|
.menu__radios label { display: block; padding: 4px 0; cursor: pointer; }
|
||||||
|
.menu__radios input { margin-right: 8px; accent-color: var(--ink); }
|
||||||
|
.menu__check { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; }
|
||||||
|
.menu__check input { margin-top: 4px; accent-color: var(--ink); }
|
||||||
|
.menu__check small { display: block; font-size: 11px; opacity: 0.75; }
|
||||||
|
|
||||||
|
.menu__text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: var(--font-num);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border: 1px solid rgba(43, 36, 24, 0.55);
|
||||||
|
resize: vertical;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__msg { margin: 8px 0 0; padding: 4px 8px; font-size: 13px; border-left: 4px solid var(--ink-green); background: rgba(44, 90, 58, 0.1); }
|
||||||
|
.menu__msg--error { border-left-color: var(--ink-red); background: rgba(141, 43, 31, 0.1); color: var(--ink-red); }
|
||||||
|
|
||||||
|
/* Save slots */
|
||||||
|
.menu__slots { margin: 10px 0 0; padding: 0; list-style: none; }
|
||||||
|
.menu__slot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-bottom: 1px dotted rgba(43, 36, 24, 0.45);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.menu__slotname { font-family: var(--font-head); font-size: 17px; }
|
||||||
|
.menu__slotinfo small { display: block; font-size: 11px; opacity: 0.7; }
|
||||||
|
.menu__slotinfo em { opacity: 0.6; }
|
||||||
|
.menu__slotbtns { display: flex; gap: 6px; }
|
||||||
|
.menu__slotbtns .brass { padding: 4px 10px; font-size: 12px; white-space: nowrap; }
|
||||||
|
.brass--armed { background: linear-gradient(#d9826f, #a8422f); color: #fff; border-color: #5e1a12; }
|
||||||
|
|
||||||
|
.menu__file { display: grid; place-items: center; margin: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.menu__folder { animation: folder-in 0.3s ease-out both; }
|
||||||
|
}
|
||||||
|
@keyframes folder-in {
|
||||||
|
from { opacity: 0; transform: translateY(14px) rotate(-1.5deg); }
|
||||||
|
to { opacity: 1; transform: translateY(0) rotate(-0.4deg); }
|
||||||
|
}
|
||||||
|
.menu__stamp { max-width: none; white-space: nowrap; }
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/* Paper sheets on the desk */
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
position: relative;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background-color: var(--paper);
|
||||||
|
background-image: var(--paper-grain);
|
||||||
|
color: var(--text);
|
||||||
|
transform: rotate(var(--rot, 0deg));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 28px rgba(120, 90, 40, 0.22),
|
||||||
|
0 1px 1px rgba(0, 0, 0, 0.35),
|
||||||
|
0 10px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper h2,
|
||||||
|
.paper h3,
|
||||||
|
.paper h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graph paper (dashboard) */
|
||||||
|
.paper--graph {
|
||||||
|
background-image:
|
||||||
|
var(--paper-grain),
|
||||||
|
linear-gradient(rgba(60, 105, 140, 0.16) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(60, 105, 140, 0.16) 1px, transparent 1px);
|
||||||
|
background-size: auto, 16px 16px, 16px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__head h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__sub {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 2px solid var(--ink-red);
|
||||||
|
color: var(--ink-red);
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transform: rotate(4deg);
|
||||||
|
opacity: 0.8;
|
||||||
|
max-width: 118px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp--small { font-size: 10px; transform: rotate(-3deg); }
|
||||||
|
|
||||||
|
/* Gauges */
|
||||||
|
.gauge {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 124px minmax(60px, 1fr) 28px 76px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 33px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__label { font-size: 13px; line-height: 1.1; }
|
||||||
|
.gauge__band {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__track {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
background: rgba(28, 42, 68, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__track i {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(28, 42, 68, 0.35);
|
||||||
|
}
|
||||||
|
.gauge__track i:nth-of-type(1) { left: 25%; }
|
||||||
|
.gauge__track i:nth-of-type(2) { left: 50%; }
|
||||||
|
.gauge__track i:nth-of-type(3) { left: 75%; }
|
||||||
|
|
||||||
|
.gauge__fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0 2px, transparent 2px 5px),
|
||||||
|
var(--ink);
|
||||||
|
transition: width 0.6s ease, background-color 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge.is-low .gauge__fill { background-color: var(--ink-red); }
|
||||||
|
.gauge.is-high .gauge__fill { background-color: var(--ink-green); }
|
||||||
|
|
||||||
|
.gauge__value {
|
||||||
|
font-family: var(--font-num);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__spark { height: 24px; width: 76px; }
|
||||||
|
|
||||||
|
.spark { display: block; background: rgba(255, 255, 255, 0.25); border: 1px solid rgba(60, 105, 140, 0.4); }
|
||||||
|
.spark__grid line { stroke: rgba(60, 105, 140, 0.25); stroke-width: 0.6; }
|
||||||
|
.spark__line { fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-linejoin: round; }
|
||||||
|
.spark__dot { fill: var(--ink-red); }
|
||||||
|
|
||||||
|
/* Economic table */
|
||||||
|
.econ { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.econ th { text-align: left; font-weight: 400; padding: 1px 0; }
|
||||||
|
.econ__val { font-family: var(--font-num); font-weight: 700; text-align: right; padding-right: 8px; }
|
||||||
|
.econ__trend { width: 18px; text-align: center; font-size: 11px; }
|
||||||
|
.econ__trend.good { color: var(--ink-green); }
|
||||||
|
.econ__trend.bad { color: var(--ink-red); }
|
||||||
|
.econ__trend.flat { color: rgba(43, 36, 24, 0.5); }
|
||||||
|
|
||||||
|
/* Intelligence summary */
|
||||||
|
.intel { list-style: none; margin: 0; padding: 0; font-size: 13px; }
|
||||||
|
.intel li { display: flex; justify-content: space-between; padding: 1px 0; border-bottom: 1px dotted rgba(43, 36, 24, 0.4); }
|
||||||
|
.intel b { font-family: var(--font-num); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.intel li[data-level="0"] b { color: var(--ink-green); }
|
||||||
|
.intel li[data-level="1"] b { color: var(--ink); }
|
||||||
|
.intel li[data-level="2"] b { color: #a4631b; }
|
||||||
|
.intel li[data-level="3"] b,
|
||||||
|
.intel li[data-level="4"] b { color: var(--ink-red); }
|
||||||
|
|
||||||
|
/* Manila folder (centre) */
|
||||||
|
.paper--folder {
|
||||||
|
background-color: #d4b77a;
|
||||||
|
padding-top: 38px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 40px rgba(110, 75, 25, 0.35),
|
||||||
|
0 1px 1px rgba(0, 0, 0, 0.35),
|
||||||
|
0 10px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox__tab {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 22px;
|
||||||
|
padding: 6px 16px 4px;
|
||||||
|
background: #c6a566;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: inset 0 -2px 4px rgba(90, 60, 20, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox__body {
|
||||||
|
background: var(--paper);
|
||||||
|
background-image: var(--paper-grain);
|
||||||
|
padding: 18px 20px;
|
||||||
|
min-height: 160px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.inbox__body h3 { font-size: 20px; margin-bottom: 8px; }
|
||||||
|
.inbox__idle { font-style: italic; opacity: 0.8; }
|
||||||
|
|
||||||
|
/* Desk calendar */
|
||||||
|
.paper--calendar { padding: 0; text-align: center; }
|
||||||
|
|
||||||
|
.cal__head {
|
||||||
|
padding: 5px 0 4px;
|
||||||
|
background: var(--ink-red);
|
||||||
|
color: #f6ead0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal__body { padding: 8px 14px 12px; }
|
||||||
|
.cal__day { font-family: var(--font-head); font-size: 54px; line-height: 1; }
|
||||||
|
.cal__month { font-family: var(--font-head); font-size: 18px; text-transform: capitalize; }
|
||||||
|
.cal__count { margin-top: 4px; font-size: 12px; opacity: 0.8; }
|
||||||
|
|
||||||
|
.cal__term {
|
||||||
|
height: 6px;
|
||||||
|
margin: 5px 0 8px;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
background: rgba(28, 42, 68, 0.08);
|
||||||
|
}
|
||||||
|
.cal__term span { display: block; height: 100%; background: var(--ink); }
|
||||||
|
|
||||||
|
.cal__next {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 1px 10px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.cal__next dt { opacity: 0.8; }
|
||||||
|
.cal__next dd { margin: 0; font-family: var(--font-num); font-weight: 700; text-align: right; }
|
||||||
|
|
||||||
|
.cal__controls { display: flex; justify-content: center; gap: 6px; }
|
||||||
|
|
||||||
|
/* Newspaper */
|
||||||
|
.paper--news {
|
||||||
|
background-color: var(--news-paper);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news__mast {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 3px double var(--text);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.news__mast h2 { font-size: 26px; letter-spacing: 0.02em; }
|
||||||
|
.news__edition { font-size: 11px; font-style: italic; }
|
||||||
|
.news__edition::first-letter { text-transform: uppercase; }
|
||||||
|
|
||||||
|
.news__lead h3 { font-size: 22px; line-height: 1.12; margin-bottom: 4px; }
|
||||||
|
.news__lead p { margin: 0 0 8px; font-size: 14px; }
|
||||||
|
|
||||||
|
.news__side { display: grid; gap: 8px; border-top: 1px solid var(--text); padding-top: 8px; }
|
||||||
|
.news__side article + article { border-top: 1px solid rgba(43, 36, 24, 0.35); padding-top: 6px; }
|
||||||
|
.news__side h4 { font-size: 16px; line-height: 1.15; }
|
||||||
|
.news__side p { margin: 2px 0 0; font-size: 13px; }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Brass buttons */
|
||||||
|
|
||||||
|
.brass {
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-family: var(--font-num);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #3a2a0c;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(#e7c872, #b98e32 55%, #a37a24);
|
||||||
|
border: 1px solid #6e5316;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 245, 200, 0.7), 0 2px 3px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brass:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
.brass:active,
|
||||||
|
.brass[aria-pressed="true"] {
|
||||||
|
background: linear-gradient(#a37a24, #c79d45);
|
||||||
|
box-shadow: inset 0 2px 4px rgba(60, 40, 5, 0.6);
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brass:focus-visible {
|
||||||
|
outline: 2px solid var(--ink);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brass--lang { margin-left: 6px; min-width: 38px; }
|
||||||
|
.brass--wide { margin-top: 12px; padding: 8px 18px; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Placeholder so go:embed accepts this folder. Delete once real files exist.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
|
||||||
|
"time": {
|
||||||
|
"daysPerWeek": 7,
|
||||||
|
"daysPerQuarter": 91,
|
||||||
|
"termDays": 1826,
|
||||||
|
"msPerDay": 10000,
|
||||||
|
"slowMsPerDay": 60000,
|
||||||
|
"speeds": [1, 2, 4],
|
||||||
|
"historyEvery": 7
|
||||||
|
},
|
||||||
|
|
||||||
|
"limits": { "allocMaxRatio": 3, "sliderMaxRatio": 2, "taxMaxFactorOfPeak": 2 },
|
||||||
|
|
||||||
|
"noise": { "indicator": 0.35 },
|
||||||
|
|
||||||
|
"cards": {
|
||||||
|
"recentWindow": 3,
|
||||||
|
"recentPenalty": 0.4,
|
||||||
|
"boostBelow": 40,
|
||||||
|
"boostMax": 1.0,
|
||||||
|
"defaultCooldown": 180,
|
||||||
|
"boost": {
|
||||||
|
"economy": { "indicator": "economy" },
|
||||||
|
"social": { "sector": "social" },
|
||||||
|
"health": { "sector": "health" },
|
||||||
|
"education": { "sector": "education" },
|
||||||
|
"security": { "sector": "police" },
|
||||||
|
"defense": { "sector": "army" },
|
||||||
|
"foreign": { "sector": "foreignAffairs" },
|
||||||
|
"industry": { "sector": "industry" },
|
||||||
|
"transport": { "sector": "transport" },
|
||||||
|
"housing": { "sector": "housing" },
|
||||||
|
"environment": { "sector": "environment" },
|
||||||
|
"justice": { "sector": "justice" },
|
||||||
|
"culture": { "sector": "culture" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"sector": {
|
||||||
|
"qualityCurve": 0.9,
|
||||||
|
"industryDebtPenaltyFrom": 90,
|
||||||
|
"industryDebtPenaltyPerPoint": 0.25
|
||||||
|
},
|
||||||
|
|
||||||
|
"sectorTau": {
|
||||||
|
"education": 365,
|
||||||
|
"health": 90,
|
||||||
|
"social": 60,
|
||||||
|
"police": 14,
|
||||||
|
"army": 120,
|
||||||
|
"justice": 60,
|
||||||
|
"industry": 180,
|
||||||
|
"transport": 365,
|
||||||
|
"infrastructure": 730,
|
||||||
|
"housing": 365,
|
||||||
|
"research": 365,
|
||||||
|
"culture": 120,
|
||||||
|
"foreignAffairs": 90,
|
||||||
|
"environment": 365,
|
||||||
|
"administration": 90
|
||||||
|
},
|
||||||
|
|
||||||
|
"debt": {
|
||||||
|
"freeBelow": 60,
|
||||||
|
"baseRate": 3.0,
|
||||||
|
"premiumFrom": 40,
|
||||||
|
"premiumCoef": 0.0003
|
||||||
|
},
|
||||||
|
|
||||||
|
"crackdown": { "unrestStart": 45 },
|
||||||
|
|
||||||
|
"gameOver": {
|
||||||
|
"default": { "debtAbove": 200, "warnAbove": 160 },
|
||||||
|
"impeachment": { "civilRightsBelow": 10, "opinionBelow": 25, "days": 60 },
|
||||||
|
"coup": { "armyBelow": 35, "stabilityBelow": 35, "chancePerDay": 0.015 },
|
||||||
|
"revolution": { "unrestAbove": 70, "opinionBelow": 20, "chancePerDay": 0.02 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"score": {
|
||||||
|
"weights": {
|
||||||
|
"opinion": 0.20, "stability": 0.15, "economy": 0.15, "order": 0.10,
|
||||||
|
"civilRights": 0.10, "softPower": 0.10, "hardPower": 0.10, "calm": 0.10
|
||||||
|
},
|
||||||
|
"debt": { "ref": 60, "per": 0.12, "min": -10, "max": 6 },
|
||||||
|
"growth": { "ref": 2, "per": 2.5, "min": -5, "max": 7.5 },
|
||||||
|
"unemployment": { "ref": 6.5, "per": 1, "min": -5, "max": 3 },
|
||||||
|
"overCap": 35,
|
||||||
|
"grades": [[75, "S"], [66, "A"], [58, "B"], [50, "C"], [40, "D"], [0, "F"]],
|
||||||
|
"topDecisions": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"tax": {
|
||||||
|
"income": { "coef": 0.50, "peak": 60, "opinion": 0.50, "growth": 0.030 },
|
||||||
|
"corporate": { "coef": 0.25, "peak": 50, "opinion": 0.15, "growth": 0.060 },
|
||||||
|
"vat": { "coef": 0.55, "peak": 40, "opinion": 0.60, "growth": 0.025 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"vars": {
|
||||||
|
"growth": {
|
||||||
|
"tau": 60, "min": -15, "max": 12, "noise": 0.02,
|
||||||
|
"sectors": { "industry": 0.02, "infrastructure": 0.012, "research": 0.015, "education": 0.010, "transport": 0.006 },
|
||||||
|
"econ": { "debt": -0.03, "taxGrowthDrag": -1 },
|
||||||
|
"ind": { "unrest": -0.02 }
|
||||||
|
},
|
||||||
|
"unemployment": {
|
||||||
|
"tau": 120, "min": 1, "max": 40, "noise": 0.01,
|
||||||
|
"sectors": { "industry": -0.03, "education": -0.015, "infrastructure": -0.01 },
|
||||||
|
"econ": { "growth": -0.6 }
|
||||||
|
},
|
||||||
|
"inflation": {
|
||||||
|
"tau": 90, "min": -2, "max": 40, "noise": 0.015,
|
||||||
|
"econ": { "deficit": 0.35, "growth": 0.15, "debt": 0.02 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"order": {
|
||||||
|
"tau": 30,
|
||||||
|
"sectors": { "police": 0.35, "justice": 0.20, "administration": 0.08 },
|
||||||
|
"econ": { "unemployment": -0.6, "crackdown": 25 },
|
||||||
|
"ind": { "unrest": -0.30 }
|
||||||
|
},
|
||||||
|
"stability": {
|
||||||
|
"tau": 45,
|
||||||
|
"sectors": { "social": 0.15, "health": 0.08, "housing": 0.08, "justice": 0.08, "administration": 0.10 },
|
||||||
|
"econ": { "debt": -0.15, "unemployment": -0.7, "inflation": -0.4 },
|
||||||
|
"ind": { "unrest": -0.25, "opinion": 0.15, "order": 0.10, "economy": 0.08 }
|
||||||
|
},
|
||||||
|
"civilRights": {
|
||||||
|
"tau": 40,
|
||||||
|
"sectors": { "justice": 0.30 },
|
||||||
|
"alloc": { "police": -20, "army": -4 },
|
||||||
|
"econ": { "crackdown": -35 }
|
||||||
|
},
|
||||||
|
"opinion": {
|
||||||
|
"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 },
|
||||||
|
"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 }
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"tau": 30,
|
||||||
|
"sectors": { "industry": 0.30, "infrastructure": 0.15, "research": 0.12, "transport": 0.10, "education": 0.10, "administration": 0.05 },
|
||||||
|
"econ": { "growth": 6, "unemployment": -1.2, "inflation": -0.8, "debt": -0.12 },
|
||||||
|
"ind": { "stability": 0.10 }
|
||||||
|
},
|
||||||
|
"hardPower": {
|
||||||
|
"tau": 60,
|
||||||
|
"sectors": { "army": 0.50, "research": 0.10, "industry": 0.08 },
|
||||||
|
"alloc": { "army": 8 },
|
||||||
|
"econ": { "debt": -0.05 },
|
||||||
|
"ind": { "economy": 0.08 }
|
||||||
|
},
|
||||||
|
"softPower": {
|
||||||
|
"tau": 60,
|
||||||
|
"sectors": { "culture": 0.30, "foreignAffairs": 0.35, "education": 0.08, "research": 0.08, "environment": 0.05 },
|
||||||
|
"alloc": { "police": -4 },
|
||||||
|
"econ": { "crackdown": -15 },
|
||||||
|
"ind": { "civilRights": 0.15, "economy": 0.05 }
|
||||||
|
},
|
||||||
|
"unrest": {
|
||||||
|
"tau": 20,
|
||||||
|
"sectors": { "social": -0.15, "housing": -0.10 },
|
||||||
|
"econ": { "unemployment": 2.5, "inflation": 1.0, "debt": 0.20, "crackdown": -10 },
|
||||||
|
"ind": { "opinion": -0.50, "stability": -0.25, "order": -0.15, "civilRights": -0.12 }
|
||||||
|
},
|
||||||
|
"armyLoyalty": {
|
||||||
|
"tau": 60,
|
||||||
|
"sectors": { "army": 0.40 },
|
||||||
|
"alloc": { "army": 15 },
|
||||||
|
"econ": { "debt": -0.10, "inflation": -0.3 },
|
||||||
|
"ind": { "opinion": 0.05, "stability": 0.10 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cri_bond_panic_01", "category": "crises", "doc": "telegram", "sender": "rating_agency",
|
||||||
|
"once": false, "cooldownDays": 180,
|
||||||
|
"conditions": { "min": { "debt": 100 }, "dayMin": 60 },
|
||||||
|
"title": { "en": "Bond markets in panic", "fr": "Panique sur le marché obligataire" },
|
||||||
|
"body": {
|
||||||
|
"en": "THE AGENCY DOWNGRADES SOVEREIGN DEBT. YIELDS SURGE. INVESTORS ASK FOR A CREDIBLE PLAN WITHIN DAYS. STOP.",
|
||||||
|
"fr": "L'AGENCE DEGRADE LA DETTE SOUVERAINE. LES TAUX S'ENVOLENT. LES INVESTISSEURS EXIGENT UN PLAN CREDIBLE SOUS PEU DE JOURS. STOP."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Emergency austerity", "fr": "Austérité d'urgence" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -1.0 },
|
||||||
|
{ "stat": "opinion", "delta": -6, "days": 60 },
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||||
|
],
|
||||||
|
"setFlags": ["emergency_austerity"]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Ignore the market", "fr": "Ignorer le marché" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": -5, "days": 90 },
|
||||||
|
{ "stat": "inflation", "delta": 1.5, "bias": true, "days": 180 },
|
||||||
|
{ "stat": "stability", "delta": -4, "days": 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cri_epidemic_01", "category": "crises", "doc": "telegram", "sender": "min_health",
|
||||||
|
"once": false, "cooldownDays": 365,
|
||||||
|
"conditions": { "dayMin": 100 },
|
||||||
|
"title": { "en": "Outbreak in the north", "fr": "Épidémie dans le nord" },
|
||||||
|
"body": {
|
||||||
|
"en": "A FAST-SPREADING FEVER REPORTED IN THREE NORTHERN DISTRICTS. HOSPITALS NEAR CAPACITY. AWAIT YOUR ORDERS. STOP.",
|
||||||
|
"fr": "UNE FIEVRE A PROPAGATION RAPIDE SIGNALEE DANS TROIS DISTRICTS DU NORD. HOPITAUX PROCHES DE LA SATURATION. ATTENDONS VOS ORDRES. STOP."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Quarantine the region", "fr": "Mettre la région en quarantaine" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": -3, "days": 60 },
|
||||||
|
{ "stat": "order", "delta": 2, "days": 30 },
|
||||||
|
{ "stat": "civilRights", "delta": -3, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Rely on the hospitals", "fr": "Compter sur les hôpitaux" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -5, "days": 40 },
|
||||||
|
{ "sector": "health", "delta": -4, "days": 90 },
|
||||||
|
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cri_earthquake_01", "category": "crises", "doc": "telegram", "sender": "min_interior",
|
||||||
|
"once": false, "cooldownDays": 540,
|
||||||
|
"conditions": { "dayMin": 150 },
|
||||||
|
"title": { "en": "Earthquake on the coast", "fr": "Séisme sur la côte" },
|
||||||
|
"body": {
|
||||||
|
"en": "MAGNITUDE SEVEN EARTHQUAKE STRIKES COASTAL TOWNS. BRIDGES DOWN. HUNDREDS TRAPPED. REQUEST NATIONAL EMERGENCY. STOP.",
|
||||||
|
"fr": "SEISME DE MAGNITUDE SEPT FRAPPE LES VILLES COTIERES. PONTS EFFONDRES. DES CENTAINES DE PERSONNES PIEGEES. DEMANDONS L'ETAT D'URGENCE NATIONAL. STOP."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Declare a national emergency", "fr": "Déclarer l'urgence nationale" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 1.0 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||||
|
{ "stat": "order", "delta": 2, "days": 30 },
|
||||||
|
{ "sector": "infrastructure", "delta": -3, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Leave it to local authorities", "fr": "Laisser faire les autorités locales" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -6, "days": 45 },
|
||||||
|
{ "stat": "unrest", "delta": 3, "days": 45 },
|
||||||
|
{ "stat": "stability", "delta": -3, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cri_general_strike_01", "category": "crises", "doc": "telegram", "sender": "union",
|
||||||
|
"once": false, "cooldownDays": 270,
|
||||||
|
"conditions": { "min": { "unrest": 50 } },
|
||||||
|
"title": { "en": "General strike called", "fr": "Grève générale décrétée" },
|
||||||
|
"body": {
|
||||||
|
"en": "THE CONFEDERATION CALLS A GENERAL STRIKE FROM TOMORROW. TRANSPORT, PORTS AND SCHOOLS TO STOP. DEMANDS ATTACHED. STOP.",
|
||||||
|
"fr": "LA CONFEDERATION DECRETE UNE GREVE GENERALE A COMPTER DE DEMAIN. TRANSPORTS, PORTS ET ECOLES A L'ARRET. REVENDICATIONS JOINTES. STOP."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Concede", "fr": "Céder" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.5 },
|
||||||
|
{ "stat": "unrest", "delta": -6, "days": 30 },
|
||||||
|
{ "stat": "economy", "delta": -2, "days": 30 }
|
||||||
|
],
|
||||||
|
"setFlags": ["concessions"]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Break the strike", "fr": "Briser la grève" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": 3, "days": 21 },
|
||||||
|
{ "stat": "unrest", "delta": 3, "days": 40 },
|
||||||
|
{ "stat": "civilRights", "delta": -4 },
|
||||||
|
{ "stat": "economy", "delta": -4, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cri_coup_whispers_01", "category": "crises", "doc": "telegram", "sender": "intelligence",
|
||||||
|
"once": false, "cooldownDays": 270,
|
||||||
|
"conditions": { "max": { "armyLoyalty": 45 }, "dayMin": 60 },
|
||||||
|
"title": { "en": "Officers are plotting", "fr": "Des officiers complotent" },
|
||||||
|
"body": {
|
||||||
|
"en": "INTERCEPTED CONVERSATIONS SUGGEST A GROUP OF SENIOR OFFICERS DISCUSSING YOUR REMOVAL. NAMES TO FOLLOW. STOP.",
|
||||||
|
"fr": "DES CONVERSATIONS INTERCEPTEES LAISSENT PENSER QU'UN GROUPE D'OFFICIERS SUPERIEURS DISCUTE DE VOTRE DESTITUTION. NOMS A SUIVRE. STOP."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Arrest the ringleaders", "fr": "Arrêter les meneurs" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "armyLoyalty", "delta": 6, "days": 60 },
|
||||||
|
{ "stat": "hardPower", "delta": -3, "days": 60 },
|
||||||
|
{ "stat": "civilRights", "delta": -3 },
|
||||||
|
{ "stat": "stability", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Raise the officers' pay", "fr": "Augmenter la solde des officiers" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.4 },
|
||||||
|
{ "stat": "armyLoyalty", "delta": 8, "days": 90 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cul_state_broadcaster_01", "category": "culture", "doc": "memo", "sender": "min_culture",
|
||||||
|
"title": { "en": "Put the national broadcaster under ministry control?", "fr": "Placer l'audiovisuel public sous tutelle ministérielle ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "The national broadcaster airs criticism of the government every evening. Placing it under ministerial control would give you a calmer press.",
|
||||||
|
"fr": "L'audiovisuel public diffuse chaque soir des critiques contre le gouvernement. Le placer sous tutelle ministérielle vous offrirait une presse plus calme."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Take control", "fr": "Prendre le contrôle" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Leave it independent", "fr": "Le laisser indépendant" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "stability", "delta": -1, "days": 30 },
|
||||||
|
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cul_film_fund_01", "category": "culture", "doc": "letter", "sender": "min_culture",
|
||||||
|
"title": { "en": "A national film fund", "fr": "Un fonds national pour le cinéma" },
|
||||||
|
"body": {
|
||||||
|
"en": "Local directors ask for a fund to finance films. A single festival prize abroad could do more for our image than a year of speeches.",
|
||||||
|
"fr": "Des réalisateurs locaux demandent un fonds pour financer des films. Un seul prix dans un festival étranger pourrait faire plus pour notre image qu'une année de discours."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Create the fund", "fr": "Créer le fonds" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.15 },
|
||||||
|
{ "sector": "culture", "delta": 4, "days": 180 },
|
||||||
|
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "sector": "culture", "delta": -2, "days": 120 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cul_football_holiday_01", "category": "culture", "doc": "memo", "sender": "press_office",
|
||||||
|
"title": { "en": "A holiday for the football victory", "fr": "Un jour férié pour la victoire au football" },
|
||||||
|
"body": {
|
||||||
|
"en": "The national team won the continental final. The streets are full and everyone is asking for a day off. Employers are not.",
|
||||||
|
"fr": "L'équipe nationale a remporté la finale continentale. Les rues sont pleines et tout le monde réclame un jour de congé. Les employeurs, non."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Declare a holiday", "fr": "Décréter un jour férié" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.1 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 15 },
|
||||||
|
{ "stat": "economy", "delta": -1, "days": 15 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Business as usual", "fr": "Travail comme d'habitude" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": -2, "days": 20 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "def_fighter_jets_01", "category": "defense", "doc": "memo", "sender": "min_defense",
|
||||||
|
"title": { "en": "Buy new fighter jets", "fr": "Acheter de nouveaux avions de chasse" },
|
||||||
|
"body": {
|
||||||
|
"en": "The air force's fleet is ageing. The Defence Ministry has a deal for a squadron of modern fighters, at a price that will be remembered in the next budget.",
|
||||||
|
"fr": "La flotte de l'armée de l'air vieillit. Le ministère de la Défense a trouvé un accord pour un escadron de chasseurs modernes, à un prix dont le prochain budget se souviendra."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Sign the contract", "fr": "Signer le contrat" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 1.0 },
|
||||||
|
{ "stat": "hardPower", "delta": 4, "bias": true, "days": 730 },
|
||||||
|
{ "stat": "armyLoyalty", "delta": 4, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Cancel the deal", "fr": "Annuler l'accord" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "armyLoyalty", "delta": -4, "days": 60 },
|
||||||
|
{ "stat": "hardPower", "delta": -2, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def_conscription_01", "category": "defense", "doc": "memo", "sender": "general",
|
||||||
|
"title": { "en": "Reintroduce conscription", "fr": "Rétablir le service militaire" },
|
||||||
|
"body": {
|
||||||
|
"en": "The General Staff argues that a professional army is too small. Conscription would fill the barracks and trim the youth unemployment figures. Parents will not be pleased.",
|
||||||
|
"fr": "L'état-major juge l'armée de métier trop petite. Le service militaire remplirait les casernes et réduirait le chômage des jeunes. Les parents ne seront pas ravis."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Reintroduce it", "fr": "Le rétablir" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "hardPower", "delta": 5, "bias": true, "days": 730 },
|
||||||
|
{ "stat": "unemployment", "delta": -0.3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "stat": "armyLoyalty", "delta": -2, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def_exercises_01", "category": "defense", "doc": "letter", "sender": "general",
|
||||||
|
"title": { "en": "Joint exercises with an ally", "fr": "Exercices conjoints avec un allié" },
|
||||||
|
"body": {
|
||||||
|
"en": "A friendly army proposes joint manoeuvres along the shared border. Neighbours will read the signal in their own way.",
|
||||||
|
"fr": "Une armée amie propose des manœuvres conjointes le long de la frontière commune. Les voisins liront ce signal à leur manière."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Accept", "fr": "Accepter" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.1 },
|
||||||
|
{ "stat": "hardPower", "delta": 3, "days": 60 },
|
||||||
|
{ "stat": "softPower", "delta": 3, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "stat": "armyLoyalty", "delta": -2, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def_parade_01", "category": "defense", "doc": "memo", "sender": "general",
|
||||||
|
"title": { "en": "Military parade on national day", "fr": "Défilé militaire pour la fête nationale" },
|
||||||
|
"body": {
|
||||||
|
"en": "The General Staff proposes a full parade through the capital. It costs money and closes the main avenue, but flags and drums have a way of lifting spirits.",
|
||||||
|
"fr": "L'état-major propose un grand défilé dans la capitale. Cela coûte de l'argent et ferme l'avenue principale, mais drapeaux et tambours ont le don de remonter le moral."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Hold the parade", "fr": "Organiser le défilé" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.15 },
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 20 },
|
||||||
|
{ "stat": "armyLoyalty", "delta": 3, "days": 30 },
|
||||||
|
{ "stat": "hardPower", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Cancel it", "fr": "L'annuler" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "armyLoyalty", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "opinion", "delta": -1, "days": 20 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def_garrison_closure_01", "category": "defense", "doc": "memo", "sender": "min_defense",
|
||||||
|
"conditions": { "min": { "debt": 50 } },
|
||||||
|
"title": { "en": "Close an unprofitable garrison", "fr": "Fermer une garnison peu rentable" },
|
||||||
|
"body": {
|
||||||
|
"en": "A remote garrison costs more than it protects. Closing it saves money but empties a small town of its main employer and its officers of a post.",
|
||||||
|
"fr": "Une garnison isolée coûte plus qu'elle ne protège. La fermer ferait des économies mais priverait une petite ville de son principal employeur et des officiers d'un poste."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Close it", "fr": "La fermer" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -0.2 },
|
||||||
|
{ "stat": "armyLoyalty", "delta": -4, "days": 60 },
|
||||||
|
{ "stat": "hardPower", "delta": -2, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep it open", "fr": "La maintenir" },
|
||||||
|
"effects": [{ "extraSpending": 0.15 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "eco_wealth_tax_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"conditions": { "notFlags": ["wealth_tax"] },
|
||||||
|
"title": { "en": "Tax the wealthy?", "fr": "Taxer les plus riches ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "The Finance Ministry proposes a surcharge on the highest incomes. Polls show the idea is popular, but business leaders are already drafting letters to the press.",
|
||||||
|
"fr": "Le ministère des Finances propose une surtaxe sur les plus hauts revenus. Les sondages y sont favorables, mais les chefs d'entreprise rédigent déjà des tribunes pour la presse."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Sign the decree", "fr": "Signer le décret" },
|
||||||
|
"effects": [
|
||||||
|
{ "revenue": "income", "delta": 4 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 30 },
|
||||||
|
{ "stat": "economy", "delta": -2, "bias": true, "days": 365 }
|
||||||
|
],
|
||||||
|
"setFlags": ["wealth_tax"],
|
||||||
|
"followUp": { "id": "eco_capital_flight_01", "inDays": 45 }
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Shelve the proposal", "fr": "Classer le dossier" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": -2, "days": 30 }, { "stat": "unrest", "delta": 2, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_capital_flight_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "report",
|
||||||
|
"sender": "central_bank",
|
||||||
|
"followUpOnly": true,
|
||||||
|
"title": { "en": "Capital is leaving the country", "fr": "Les capitaux quittent le pays" },
|
||||||
|
"body": {
|
||||||
|
"en": "Since the new tax, wealthy savers are moving assets abroad. The Central Bank warns that reserves are thinning and asks what the government intends to do.",
|
||||||
|
"fr": "Depuis la nouvelle taxe, les épargnants fortunés transfèrent leurs avoirs à l'étranger. La Banque centrale alerte sur l'érosion des réserves et demande ce que le gouvernement compte faire."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Impose capital controls", "fr": "Imposer un contrôle des capitaux" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "softPower", "delta": -4, "days": 60 },
|
||||||
|
{ "stat": "civilRights", "delta": -2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Let the market decide", "fr": "Laisser faire le marché" },
|
||||||
|
"effects": [{ "stat": "economy", "delta": -5, "days": 90 }, { "revenue": "other", "delta": -0.4 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_rate_cut_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "letter",
|
||||||
|
"sender": "central_bank",
|
||||||
|
"conditions": { "max": { "inflation": 5 } },
|
||||||
|
"title": { "en": "Cut interest rates?", "fr": "Baisser les taux d'intérêt ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "The Central Bank asks for the government's backing to cut rates and revive credit. Growth would pick up, and prices would likely follow.",
|
||||||
|
"fr": "La Banque centrale demande le soutien du gouvernement pour baisser ses taux et relancer le crédit. La croissance repartirait, et les prix probablement aussi."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Back the cut", "fr": "Soutenir la baisse" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "growth", "delta": 0.5, "bias": true, "days": 180 },
|
||||||
|
{ "stat": "inflation", "delta": 1, "bias": true, "days": 180 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Hold rates", "fr": "Maintenir les taux" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "growth", "delta": -0.2, "bias": true, "days": 180 },
|
||||||
|
{ "stat": "inflation", "delta": -0.3, "bias": true, "days": 180 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_vat_holiday_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"title": { "en": "VAT holiday on essentials", "fr": "Pause de TVA sur les produits de base" },
|
||||||
|
"body": {
|
||||||
|
"en": "A temporary VAT cut on food and medicine would be felt in every household. The Treasury reminds you that every point of revenue has to come from somewhere.",
|
||||||
|
"fr": "Une baisse temporaire de la TVA sur l'alimentation et les médicaments serait ressentie dans tous les foyers. Le Trésor rappelle que chaque point de recettes doit venir de quelque part."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Cut the VAT", "fr": "Baisser la TVA" },
|
||||||
|
"effects": [{ "revenue": "vat", "delta": -2 }, { "stat": "opinion", "delta": 3, "days": 30 }]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep the revenue", "fr": "Garder les recettes" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": -1, "days": 20 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_austerity_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"conditions": { "min": { "debt": 70 }, "notFlags": ["austerity"] },
|
||||||
|
"title": { "en": "A package of spending cuts", "fr": "Un plan de réduction des dépenses" },
|
||||||
|
"body": {
|
||||||
|
"en": "With debt this high, the Finance Ministry has drawn up a package of permanent cuts to administration and subsidies. Lenders will notice. So will the streets.",
|
||||||
|
"fr": "Avec une dette aussi élevée, le ministère des Finances a préparé un plan de coupes durables dans l'administration et les subventions. Les prêteurs le remarqueront. La rue aussi."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Approve the cuts", "fr": "Approuver les coupes" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -0.8 },
|
||||||
|
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||||
|
],
|
||||||
|
"setFlags": ["austerity"]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": -3, "bias": true, "days": 180 },
|
||||||
|
{ "stat": "stability", "delta": -2, "days": 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_stake_sale_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "memo",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"conditions": { "notFlags": ["airline_sold"] },
|
||||||
|
"title": { "en": "Sell the national airline?", "fr": "Vendre la compagnie aérienne nationale ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "A foreign consortium offers a generous price for the state's stake in the national airline. Unions call it a betrayal. The Treasury calls it a windfall.",
|
||||||
|
"fr": "Un consortium étranger propose un prix généreux pour la participation de l'État dans la compagnie aérienne nationale. Les syndicats y voient une trahison. Le Trésor, une aubaine."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Accept the offer", "fr": "Accepter l'offre" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": -1.2 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 40 },
|
||||||
|
{ "stat": "economy", "delta": 2, "bias": true, "days": 365 }
|
||||||
|
],
|
||||||
|
"setFlags": ["airline_sold"]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep it public", "fr": "La garder publique" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": 1, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_amnesty_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "letter",
|
||||||
|
"sender": "min_finance",
|
||||||
|
"title": { "en": "Offshore money amnesty", "fr": "Amnistie fiscale sur les avoirs offshore" },
|
||||||
|
"body": {
|
||||||
|
"en": "An amnesty would bring undeclared fortunes back home and fill the Treasury at once. Critics say it rewards those who cheated.",
|
||||||
|
"fr": "Une amnistie ferait revenir au pays des fortunes non déclarées et remplirait aussitôt les caisses du Trésor. Les critiques y voient une prime aux tricheurs."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Grant the amnesty", "fr": "Accorder l'amnistie" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": -0.8 },
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": { "label": { "en": "Refuse", "fr": "Refuser" }, "effects": [{ "stat": "opinion", "delta": 1, "days": 30 }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco_min_wage_01",
|
||||||
|
"category": "economy",
|
||||||
|
"doc": "petition",
|
||||||
|
"sender": "union",
|
||||||
|
"title": { "en": "Raise the minimum wage", "fr": "Relever le salaire minimum" },
|
||||||
|
"body": {
|
||||||
|
"en": "The Labour Confederation presents a petition with half a million signatures demanding a higher minimum wage. Employers warn that jobs will go.",
|
||||||
|
"fr": "La Confédération du travail présente une pétition de cinq cent mille signatures réclamant un salaire minimum plus élevé. Le patronat prévient que des emplois disparaîtront."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Raise it", "fr": "Le relever" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||||
|
{ "stat": "unemployment", "delta": 0.4, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "stat": "unrest", "delta": 3, "days": 45 }, { "stat": "opinion", "delta": -3, "days": 45 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "edu_free_university_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||||
|
"title": { "en": "Free university tuition", "fr": "Université gratuite" },
|
||||||
|
"body": {
|
||||||
|
"en": "Student groups demand an end to tuition fees. The Education Ministry says it would widen access but add a lasting cost.",
|
||||||
|
"fr": "Les associations étudiantes exigent la fin des frais d'inscription. Le ministère de l'Éducation estime que cela élargirait l'accès mais ajouterait une charge durable."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Abolish the fees", "fr": "Supprimer les frais" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.5 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||||
|
{ "sector": "education", "delta": 4, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep the fees", "fr": "Garder les frais" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edu_curriculum_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||||
|
"title": { "en": "Rewrite the history curriculum", "fr": "Réécrire le programme d'histoire" },
|
||||||
|
"body": {
|
||||||
|
"en": "A new national curriculum would give pupils a single, patriotic account of the country's past. Historians call it propaganda.",
|
||||||
|
"fr": "Un nouveau programme national donnerait aux élèves un récit unique et patriotique du passé du pays. Les historiens y voient de la propagande."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Adopt it", "fr": "L'adopter" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Leave the curriculum", "fr": "Laisser le programme" },
|
||||||
|
"effects": [{ "stat": "stability", "delta": -1, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edu_teacher_pay_01", "category": "education", "doc": "petition", "sender": "union",
|
||||||
|
"title": { "en": "Teachers ask for a pay rise", "fr": "Les enseignants réclament une revalorisation" },
|
||||||
|
"body": {
|
||||||
|
"en": "Teachers' unions say salaries no longer keep pace with living costs and warn that classrooms will empty by autumn.",
|
||||||
|
"fr": "Les syndicats d'enseignants affirment que les salaires ne suivent plus le coût de la vie et préviennent que les classes se videront d'ici l'automne."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Grant the rise", "fr": "Accorder la hausse" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.4 },
|
||||||
|
{ "sector": "education", "delta": 5, "days": 180 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "education", "delta": -4, "days": 180 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edu_school_meals_01", "category": "education", "doc": "memo", "sender": "min_education",
|
||||||
|
"title": { "en": "Free school meals", "fr": "Repas scolaires gratuits" },
|
||||||
|
"body": {
|
||||||
|
"en": "Many children arrive at school hungry. Free meals would help them learn, and help stretched families too.",
|
||||||
|
"fr": "Beaucoup d'enfants arrivent à l'école le ventre vide. Des repas gratuits les aideraient à apprendre, et aideraient aussi les familles en difficulté."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Fund the meals", "fr": "Financer les repas" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 45 },
|
||||||
|
{ "sector": "education", "delta": 2, "days": 180 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": -1, "days": 30 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "env_carbon_tax_01", "category": "environment", "doc": "memo", "sender": "min_environment",
|
||||||
|
"title": { "en": "A carbon tax", "fr": "Une taxe carbone" },
|
||||||
|
"body": {
|
||||||
|
"en": "A tax on carbon emissions would pay for cleaner air and impress foreign partners. Factories and commuters would pay for it first.",
|
||||||
|
"fr": "Une taxe sur les émissions de carbone financerait un air plus propre et impressionnerait les partenaires étrangers. Les usines et les travailleurs pendulaires en paieraient d'abord le prix."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Introduce the tax", "fr": "Instaurer la taxe" },
|
||||||
|
"effects": [
|
||||||
|
{ "revenue": "other", "delta": 0.6 },
|
||||||
|
{ "sector": "environment", "delta": 5, "days": 180 },
|
||||||
|
{ "sector": "industry", "delta": -2, "days": 120 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 45 },
|
||||||
|
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||||
|
{ "sector": "environment", "delta": -2, "days": 120 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env_national_park_01", "category": "environment", "doc": "letter", "sender": "min_environment",
|
||||||
|
"title": { "en": "Create a national park", "fr": "Créer un parc national" },
|
||||||
|
"body": {
|
||||||
|
"en": "A forested valley could be protected for good. The mining lobby has other plans for it.",
|
||||||
|
"fr": "Une vallée boisée pourrait être protégée définitivement. Le lobby minier avait d'autres projets pour elle."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Protect the valley", "fr": "Protéger la vallée" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.2 },
|
||||||
|
{ "sector": "environment", "delta": 4, "days": 180 },
|
||||||
|
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||||
|
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Open it to mining", "fr": "L'ouvrir aux mines" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "environment", "delta": -2, "days": 120 },
|
||||||
|
{ "stat": "economy", "delta": 1, "days": 90 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env_coal_phaseout_01", "category": "environment", "doc": "memo", "sender": "min_environment",
|
||||||
|
"title": { "en": "Phase out coal", "fr": "Sortir du charbon" },
|
||||||
|
"body": {
|
||||||
|
"en": "Closing the coal plants by decade's end would cut emissions sharply. It would also cost jobs in mining regions that have voted for you.",
|
||||||
|
"fr": "Fermer les centrales à charbon d'ici la fin de la décennie réduirait fortement les émissions. Cela coûterait aussi des emplois dans des régions minières qui ont voté pour vous."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Set the date", "fr": "Fixer l'échéance" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.5 },
|
||||||
|
{ "sector": "environment", "delta": 6, "days": 365 },
|
||||||
|
{ "stat": "unemployment", "delta": 0.3, "bias": true, "days": 365 },
|
||||||
|
{ "sector": "industry", "delta": -3, "days": 180 },
|
||||||
|
{ "stat": "softPower", "delta": 3, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep burning", "fr": "Continuer à brûler" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "environment", "delta": -3, "days": 180 },
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env_flood_defences_01", "category": "environment", "doc": "report", "sender": "min_environment",
|
||||||
|
"title": { "en": "Flood defences for the river towns", "fr": "Digues pour les villes riveraines" },
|
||||||
|
"body": {
|
||||||
|
"en": "Two floods in three years have drowned the same towns. Engineers say a line of defences would pay for itself within a decade.",
|
||||||
|
"fr": "Deux crues en trois ans ont noyé les mêmes villes. Les ingénieurs affirment qu'une ligne de digues se rentabiliserait en dix ans."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Build the defences", "fr": "Construire les digues" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.7 },
|
||||||
|
{ "sector": "environment", "delta": 3, "days": 180 },
|
||||||
|
{ "sector": "infrastructure", "delta": 3, "days": 180 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 40 },
|
||||||
|
{ "sector": "infrastructure", "delta": -2, "days": 180 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "for_trade_pact_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||||
|
"conditions": { "notFlags": ["trade_pact"] },
|
||||||
|
"title": { "en": "Sign a regional trade pact", "fr": "Signer un pacte commercial régional" },
|
||||||
|
"body": {
|
||||||
|
"en": "Our neighbours offer a trade pact opening their markets to our exporters. Farmers worry about cheap imports in return.",
|
||||||
|
"fr": "Nos voisins proposent un pacte commercial ouvrant leurs marchés à nos exportateurs. Les agriculteurs craignent en retour des importations bon marché."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Sign the pact", "fr": "Signer le pacte" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "growth", "delta": 0.3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||||
|
],
|
||||||
|
"setFlags": ["trade_pact"]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "economy", "delta": -1, "days": 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "for_sanctions_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||||
|
"title": { "en": "Join sanctions against a neighbour", "fr": "Se joindre aux sanctions contre un voisin" },
|
||||||
|
"body": {
|
||||||
|
"en": "Our allies ask us to join sanctions against a neighbour accused of violating the border. Trade with that neighbour would suffer.",
|
||||||
|
"fr": "Nos alliés nous demandent de nous joindre aux sanctions contre un voisin accusé d'avoir violé la frontière. Le commerce avec ce voisin en souffrirait."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Join the sanctions", "fr": "Appliquer les sanctions" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "softPower", "delta": 4, "days": 90 },
|
||||||
|
{ "stat": "economy", "delta": -3, "bias": true, "days": 180 },
|
||||||
|
{ "stat": "hardPower", "delta": 1, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Stay neutral", "fr": "Rester neutre" },
|
||||||
|
"effects": [{ "stat": "softPower", "delta": -3, "days": 60 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "for_summit_01", "category": "foreign", "doc": "letter", "sender": "min_foreign",
|
||||||
|
"title": { "en": "Host the regional summit", "fr": "Accueillir le sommet régional" },
|
||||||
|
"body": {
|
||||||
|
"en": "The capital has been offered the regional summit. Delegations, cameras and security bills will all arrive together.",
|
||||||
|
"fr": "La capitale se voit proposer l'organisation du sommet régional. Délégations, caméras et factures de sécurité arriveront ensemble."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Host it", "fr": "L'accueillir" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.4 },
|
||||||
|
{ "stat": "softPower", "delta": 6, "days": 90 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 },
|
||||||
|
{ "stat": "order", "delta": -1, "days": 14 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "stat": "softPower", "delta": -2, "days": 60 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "for_refugee_corridor_01", "category": "foreign", "doc": "report", "sender": "min_foreign",
|
||||||
|
"title": { "en": "A corridor for refugees", "fr": "Un corridor pour les réfugiés" },
|
||||||
|
"body": {
|
||||||
|
"en": "Thousands are fleeing conflict across the border. Aid agencies ask us to open a corridor. Reception centres would be needed, and some voters are wary.",
|
||||||
|
"fr": "Des milliers de personnes fuient un conflit de l'autre côté de la frontière. Les agences humanitaires nous demandent d'ouvrir un corridor. Des centres d'accueil seraient nécessaires, et certains électeurs sont méfiants."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Open the corridor", "fr": "Ouvrir le corridor" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "stat": "softPower", "delta": 5, "days": 90 },
|
||||||
|
{ "stat": "opinion", "delta": -4, "days": 60 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Close the border", "fr": "Fermer la frontière" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "softPower", "delta": -5, "days": 90 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "for_expel_ambassador_01", "category": "foreign", "doc": "memo", "sender": "min_foreign",
|
||||||
|
"title": { "en": "Expel a hostile ambassador", "fr": "Expulser un ambassadeur hostile" },
|
||||||
|
"body": {
|
||||||
|
"en": "A foreign ambassador gave a speech mocking the government. Expelling the ambassador would play well at home and badly in the embassies.",
|
||||||
|
"fr": "Un ambassadeur étranger a prononcé un discours moquant le gouvernement. L'expulser serait bien reçu dans le pays et mal dans les ambassades."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Expel", "fr": "Expulser" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 30 },
|
||||||
|
{ "stat": "softPower", "delta": -3, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Ignore it", "fr": "Ignorer l'affront" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "softPower", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "hlt_hospital_funds_01", "category": "health", "doc": "memo", "sender": "min_health",
|
||||||
|
"title": { "en": "Emergency hospital funding", "fr": "Financement d'urgence des hôpitaux" },
|
||||||
|
"body": {
|
||||||
|
"en": "Regional hospitals report overflowing wards and unpaid suppliers. The Health Ministry requests an emergency payment to keep them open.",
|
||||||
|
"fr": "Les hôpitaux régionaux signalent des services débordés et des fournisseurs impayés. Le ministère de la Santé demande une aide d'urgence pour les maintenir ouverts."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Release the funds", "fr": "Débloquer les fonds" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.6 },
|
||||||
|
{ "sector": "health", "delta": 6, "days": 120 },
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "health", "delta": -3, "days": 120 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hlt_vaccination_01", "category": "health", "doc": "report", "sender": "min_health",
|
||||||
|
"title": { "en": "National vaccination drive", "fr": "Campagne nationale de vaccination" },
|
||||||
|
"body": {
|
||||||
|
"en": "Immunisation rates have fallen below the safe threshold. A national campaign would cost little compared to an outbreak.",
|
||||||
|
"fr": "Les taux de vaccination sont passés sous le seuil de sécurité. Une campagne nationale coûterait peu comparée à une épidémie."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Launch the campaign", "fr": "Lancer la campagne" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "sector": "health", "delta": 4, "days": 90 },
|
||||||
|
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||||
|
"effects": [{ "sector": "health", "delta": -2, "days": 120 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hlt_private_clinics_01", "category": "health", "doc": "memo", "sender": "min_health",
|
||||||
|
"title": { "en": "Open health care to private clinics?", "fr": "Ouvrir la santé aux cliniques privées ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "Allowing private clinics into the public system would save money and shorten some queues. Doctors fear a two-tier system.",
|
||||||
|
"fr": "Admettre les cliniques privées dans le système public ferait des économies et raccourcirait certaines files d'attente. Les médecins craignent un système à deux vitesses."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Open the system", "fr": "Ouvrir le système" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -0.4 },
|
||||||
|
{ "sector": "health", "delta": -3, "days": 180 },
|
||||||
|
{ "stat": "economy", "delta": 1, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep it public", "fr": "Rester public" },
|
||||||
|
"effects": [{ "sector": "health", "delta": 2, "days": 90 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hlt_foreign_doctors_01", "category": "health", "doc": "letter", "sender": "min_health",
|
||||||
|
"title": { "en": "Recruit doctors from abroad", "fr": "Recruter des médecins à l'étranger" },
|
||||||
|
"body": {
|
||||||
|
"en": "Rural clinics have no doctors. The ministry proposes a fast-track visa for foreign physicians. Some will object to the arrivals.",
|
||||||
|
"fr": "Les dispensaires ruraux n'ont plus de médecins. Le ministère propose un visa accéléré pour les praticiens étrangers. Certains s'opposeront à ces arrivées."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Fast-track the visas", "fr": "Accélérer les visas" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.2 },
|
||||||
|
{ "sector": "health", "delta": 5, "days": 120 },
|
||||||
|
{ "stat": "softPower", "delta": 2, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "sector": "health", "delta": -3, "days": 120 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "hou_rent_cap_01", "category": "housing", "doc": "petition", "sender": "mayor",
|
||||||
|
"title": { "en": "Cap rents in the cities", "fr": "Plafonner les loyers en ville" },
|
||||||
|
"body": {
|
||||||
|
"en": "Tenants' groups ask for a cap on rents. Landlords reply that they will stop building and stop repairing.",
|
||||||
|
"fr": "Les associations de locataires demandent un plafonnement des loyers. Les propriétaires répondent qu'ils cesseront de construire et de rénover."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Cap the rents", "fr": "Plafonner les loyers" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||||
|
{ "sector": "housing", "delta": -5, "days": 180 },
|
||||||
|
{ "stat": "economy", "delta": -1, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Leave the market alone", "fr": "Laisser faire le marché" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hou_social_housing_01", "category": "housing", "doc": "memo", "sender": "min_housing",
|
||||||
|
"title": { "en": "A social housing programme", "fr": "Un programme de logements sociaux" },
|
||||||
|
"body": {
|
||||||
|
"en": "The Housing Ministry proposes ten thousand new social homes over three years. Waiting lists are at record length.",
|
||||||
|
"fr": "Le ministère du Logement propose dix mille nouveaux logements sociaux en trois ans. Les listes d'attente battent des records."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Launch the programme", "fr": "Lancer le programme" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.8 },
|
||||||
|
{ "sector": "housing", "delta": 6, "days": 365 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 45 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "housing", "delta": -2, "days": 180 },
|
||||||
|
{ "stat": "unrest", "delta": 2, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hou_state_land_01", "category": "housing", "doc": "memo", "sender": "min_housing",
|
||||||
|
"title": { "en": "Release state land to developers", "fr": "Céder des terrains de l'État aux promoteurs" },
|
||||||
|
"body": {
|
||||||
|
"en": "The state owns vacant land in every major city. Selling it to developers would raise cash and homes, and invite accusations of favours.",
|
||||||
|
"fr": "L'État possède des terrains vacants dans toutes les grandes villes. Les vendre aux promoteurs rapporterait de l'argent et des logements, et ferait naître des soupçons de faveurs."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Sell the land", "fr": "Vendre les terrains" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": -0.5 },
|
||||||
|
{ "sector": "housing", "delta": 3, "days": 365 },
|
||||||
|
{ "stat": "economy", "delta": 1, "days": 90 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep the land", "fr": "Garder les terrains" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": 1, "days": 30 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"economy.json", "social.json", "health.json", "education.json", "security.json",
|
||||||
|
"defense.json", "foreign.json", "industry.json", "transport.json", "housing.json",
|
||||||
|
"environment.json", "justice.json", "culture.json", "crises.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ind_privatise_energy_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||||
|
"conditions": { "notFlags": ["energy_privatised"] },
|
||||||
|
"title": { "en": "Privatise the energy industry?", "fr": "Privatiser le secteur de l'énergie ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "Selling the state energy company would raise a large sum and bring in outside investment. Prices may rise, and the workers are already organising.",
|
||||||
|
"fr": "Vendre l'entreprise publique d'énergie rapporterait une somme importante et attirerait des investisseurs. Les prix pourraient monter, et les salariés s'organisent déjà."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Privatise", "fr": "Privatiser" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": -1.5 },
|
||||||
|
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "inflation", "delta": 0.5, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "opinion", "delta": -4, "days": 60 }
|
||||||
|
],
|
||||||
|
"setFlags": ["energy_privatised"],
|
||||||
|
"followUp": { "id": "ind_energy_strike_01", "inDays": 60 }
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep it public", "fr": "Garder le contrôle public" },
|
||||||
|
"effects": [
|
||||||
|
{ "sector": "industry", "delta": -3, "days": 120 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ind_energy_strike_01", "category": "industry", "doc": "petition", "sender": "union", "followUpOnly": true,
|
||||||
|
"title": { "en": "Energy workers walk out", "fr": "Les salariés de l'énergie débrayent" },
|
||||||
|
"body": {
|
||||||
|
"en": "Since the sale, the energy workers have lost job guarantees. They have walked out, and blackouts are being reported in several districts.",
|
||||||
|
"fr": "Depuis la vente, les salariés de l'énergie ont perdu leurs garanties d'emploi. Ils ont débrayé et des coupures de courant sont signalées dans plusieurs quartiers."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Negotiate", "fr": "Négocier" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "stat": "stability", "delta": 2, "days": 30 },
|
||||||
|
{ "stat": "economy", "delta": -1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Order them back", "fr": "Ordonner la reprise" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 40 },
|
||||||
|
{ "stat": "civilRights", "delta": -2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ind_green_subsidy_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||||
|
"title": { "en": "Subsidies for green industry", "fr": "Subventions à l'industrie verte" },
|
||||||
|
"body": {
|
||||||
|
"en": "Manufacturers of solar panels and batteries ask for state support to get started. The Industry Ministry sees a growth sector; the Treasury sees a bill.",
|
||||||
|
"fr": "Les fabricants de panneaux solaires et de batteries demandent un soutien de l'État pour démarrer. Le ministère de l'Industrie y voit un secteur d'avenir ; le Trésor, une facture."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Grant the subsidies", "fr": "Accorder les subventions" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.6 },
|
||||||
|
{ "sector": "industry", "delta": 5, "days": 180 },
|
||||||
|
{ "sector": "environment", "delta": 3, "days": 180 },
|
||||||
|
{ "stat": "economy", "delta": 2, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "sector": "industry", "delta": -2, "days": 90 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ind_steel_bailout_01", "category": "industry", "doc": "letter", "sender": "min_industry",
|
||||||
|
"title": { "en": "Bail out the steel mill", "fr": "Sauver l'aciérie" },
|
||||||
|
"body": {
|
||||||
|
"en": "The steel mill in the north is about to close, taking four thousand jobs with it. A rescue loan would keep the furnaces lit for now.",
|
||||||
|
"fr": "L'aciérie du nord est sur le point de fermer, emportant quatre mille emplois. Un prêt de sauvetage maintiendrait les hauts fourneaux allumés pour l'instant."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Rescue it", "fr": "La sauver" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.5 },
|
||||||
|
{ "stat": "unemployment", "delta": -0.2, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "economy", "delta": 1, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Let it close", "fr": "La laisser fermer" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "unemployment", "delta": 0.4, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "unrest", "delta": 3, "days": 40 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ind_special_zone_01", "category": "industry", "doc": "memo", "sender": "min_industry",
|
||||||
|
"title": { "en": "A special economic zone", "fr": "Une zone économique spéciale" },
|
||||||
|
"body": {
|
||||||
|
"en": "Foreign investors would build factories in a tax-free zone with light regulation. Local workers would get jobs, and fewer protections.",
|
||||||
|
"fr": "Des investisseurs étrangers construiraient des usines dans une zone franche à la réglementation allégée. Les travailleurs locaux auraient des emplois, et moins de protections."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Create the zone", "fr": "Créer la zone" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "economy", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "growth", "delta": 0.3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "civilRights", "delta": -1 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "stat": "economy", "delta": -1, "days": 60 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "jus_judicial_independence_01", "category": "justice", "doc": "memo", "sender": "min_justice",
|
||||||
|
"title": { "en": "Guarantee judicial independence", "fr": "Garantir l'indépendance de la justice" },
|
||||||
|
"body": {
|
||||||
|
"en": "A reform would stop the government from appointing or dismissing judges. It would also stop it from correcting a ruling it dislikes.",
|
||||||
|
"fr": "Une réforme empêcherait le gouvernement de nommer ou de révoquer les juges. Elle l'empêcherait aussi de corriger une décision qui lui déplaît."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Pass the reform", "fr": "Adopter la réforme" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "civilRights", "delta": 4, "bias": true, "days": 365 },
|
||||||
|
{ "sector": "justice", "delta": 4, "days": 180 },
|
||||||
|
{ "stat": "stability", "delta": -1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Keep control", "fr": "Garder la main" },
|
||||||
|
"effects": [{ "stat": "civilRights", "delta": -2, "bias": true, "days": 365 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jus_fake_news_law_01", "category": "justice", "doc": "memo", "sender": "press_office",
|
||||||
|
"title": { "en": "A law against “fake news”", "fr": "Une loi contre les « fausses informations »" },
|
||||||
|
"body": {
|
||||||
|
"en": "The Press Office wants to criminalise the spread of false reports. Journalists point out that the definition of false will be set by the government.",
|
||||||
|
"fr": "Le service de presse veut pénaliser la diffusion de fausses nouvelles. Les journalistes font remarquer que c'est le gouvernement qui définira ce qui est faux."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Pass the law", "fr": "Voter la loi" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "stability", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "civilRights", "delta": -5, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "softPower", "delta": -3, "days": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Drop the idea", "fr": "Abandonner l'idée" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -1, "days": 30 },
|
||||||
|
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jus_prisons_01", "category": "justice", "doc": "report", "sender": "min_justice",
|
||||||
|
"title": { "en": "Overcrowded prisons", "fr": "Des prisons surpeuplées" },
|
||||||
|
"body": {
|
||||||
|
"en": "The prisons hold a third more inmates than they were built for. Releasing non-violent offenders would relieve them. Building new cells would cost money and take years.",
|
||||||
|
"fr": "Les prisons accueillent un tiers de détenus de plus que prévu. Libérer les condamnés non violents les soulagerait. Construire de nouvelles cellules coûterait cher et prendrait des années."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Release non-violent inmates", "fr": "Libérer les non-violents" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -0.1 },
|
||||||
|
{ "stat": "order", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "civilRights", "delta": 2 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Build new prisons", "fr": "Construire de nouvelles prisons" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.4 },
|
||||||
|
{ "sector": "justice", "delta": 3, "days": 120 },
|
||||||
|
{ "stat": "opinion", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jus_anticorruption_01", "category": "justice", "doc": "memo", "sender": "min_justice",
|
||||||
|
"title": { "en": "An anti-corruption agency", "fr": "Une agence anticorruption" },
|
||||||
|
"body": {
|
||||||
|
"en": "An independent agency would investigate bribery in ministries and state contracts. Some of the people it would investigate sit close to you.",
|
||||||
|
"fr": "Une agence indépendante enquêterait sur la corruption dans les ministères et les marchés publics. Certaines des personnes visées sont proches de vous."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Create the agency", "fr": "Créer l'agence" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.2 },
|
||||||
|
{ "sector": "justice", "delta": 4, "days": 180 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 60 },
|
||||||
|
{ "stat": "stability", "delta": -2, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 40 },
|
||||||
|
{ "stat": "softPower", "delta": -1, "days": 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sec_crackdown_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||||
|
"conditions": { "min": { "unrest": 35 } },
|
||||||
|
"title": { "en": "Send police to the protesters?", "fr": "Envoyer la police contre les manifestants ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "Thousands have occupied the central square for three days. The Interior Ministry can clear it by force tonight. The alternative is to wait and hope it burns out.",
|
||||||
|
"fr": "Des milliers de personnes occupent la place centrale depuis trois jours. Le ministère de l'Intérieur peut la faire évacuer par la force ce soir. L'autre option est d'attendre que le mouvement s'essouffle."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Clear the square", "fr": "Évacuer la place" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": 6, "days": 14 },
|
||||||
|
{ "stat": "unrest", "delta": -6, "days": 30 },
|
||||||
|
{ "stat": "civilRights", "delta": -5 },
|
||||||
|
{ "stat": "softPower", "delta": -4, "days": 30 }
|
||||||
|
],
|
||||||
|
"setFlags": ["crackdown"],
|
||||||
|
"followUp": { "id": "sec_backlash_01", "inDays": 30 }
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Let them march", "fr": "Les laisser manifester" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": -4, "days": 14 },
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 30 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sec_backlash_01", "category": "security", "doc": "report", "sender": "press_office", "followUpOnly": true,
|
||||||
|
"title": { "en": "Rights report condemns the crackdown", "fr": "Un rapport condamne la répression" },
|
||||||
|
"body": {
|
||||||
|
"en": "An international rights group has published testimonies from the night the square was cleared. Foreign editors are asking for a response.",
|
||||||
|
"fr": "Une organisation internationale de défense des droits a publié des témoignages sur la nuit de l'évacuation de la place. Les rédactions étrangères demandent une réaction."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Open an inquiry", "fr": "Ouvrir une enquête" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "civilRights", "delta": 3 },
|
||||||
|
{ "stat": "softPower", "delta": 3, "days": 60 },
|
||||||
|
{ "stat": "order", "delta": -1, "days": 30 },
|
||||||
|
{ "stat": "stability", "delta": -1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Dismiss the report", "fr": "Rejeter le rapport" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "softPower", "delta": -4, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sec_cctv_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||||
|
"title": { "en": "Nationwide CCTV network", "fr": "Un réseau national de vidéosurveillance" },
|
||||||
|
"body": {
|
||||||
|
"en": "A camera network covering every major street would deter crime and help solve it. Privacy lawyers are already preparing their case.",
|
||||||
|
"fr": "Un réseau de caméras couvrant chaque grande rue dissuaderait la criminalité et aiderait à la résoudre. Les avocats de la vie privée préparent déjà leur dossier."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Install the network", "fr": "Installer le réseau" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.5 },
|
||||||
|
{ "stat": "order", "delta": 3, "bias": true, "days": 365 },
|
||||||
|
{ "stat": "civilRights", "delta": -3, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "stat": "order", "delta": -1, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sec_riot_gear_01", "category": "security", "doc": "memo", "sender": "min_interior",
|
||||||
|
"title": { "en": "Armoured vehicles for riot police", "fr": "Véhicules blindés pour les forces anti-émeute" },
|
||||||
|
"body": {
|
||||||
|
"en": "The riot police ask for armoured vehicles and new equipment. The images of them on the streets may unsettle the public.",
|
||||||
|
"fr": "Les forces anti-émeute demandent des véhicules blindés et du nouveau matériel. Leur image dans les rues pourrait inquiéter la population."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Approve the purchase", "fr": "Approuver l'achat" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "sector": "police", "delta": 5, "days": 60 },
|
||||||
|
{ "stat": "civilRights", "delta": -2 },
|
||||||
|
{ "stat": "softPower", "delta": -2, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "sector": "police", "delta": -2, "days": 60 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sec_gang_amnesty_01", "category": "security", "doc": "letter", "sender": "mayor",
|
||||||
|
"title": { "en": "Offer the street gangs an amnesty", "fr": "Proposer une amnistie aux gangs de rue" },
|
||||||
|
"body": {
|
||||||
|
"en": "The mayor has brokered talks with gang leaders: weapons handed in against a pardon for minor offences. Many will see it as surrender.",
|
||||||
|
"fr": "Le maire a organisé des pourparlers avec les chefs de gangs : des armes rendues contre une grâce pour les délits mineurs. Beaucoup y verront une capitulation."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Grant the amnesty", "fr": "Accorder l'amnistie" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": 3, "days": 90 },
|
||||||
|
{ "stat": "unrest", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "order", "delta": -2, "days": 60 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "soc_pension_age_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||||
|
"conditions": { "dayMin": 60 },
|
||||||
|
"title": { "en": "Raise the retirement age?", "fr": "Reculer l'âge de la retraite ?" },
|
||||||
|
"body": {
|
||||||
|
"en": "The pension fund is running a growing gap. The Labour Ministry suggests raising the retirement age by two years. Every union has already said no.",
|
||||||
|
"fr": "La caisse des retraites accuse un déficit croissant. Le ministère du Travail suggère de repousser l'âge de départ de deux ans. Tous les syndicats ont déjà dit non."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Raise the age", "fr": "Reculer l'âge" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": -0.5 },
|
||||||
|
{ "stat": "opinion", "delta": -5, "days": 60 },
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 60 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Leave it as is", "fr": "Ne rien changer" },
|
||||||
|
"effects": [{ "extraSpending": 0.3 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soc_childcare_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||||
|
"title": { "en": "Universal childcare", "fr": "Garde d'enfants universelle" },
|
||||||
|
"body": {
|
||||||
|
"en": "Free public childcare would let thousands of parents return to work. It would also add a permanent line to the budget.",
|
||||||
|
"fr": "Une garde d'enfants publique et gratuite permettrait à des milliers de parents de retourner travailler. Elle ajouterait aussi une ligne permanente au budget."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Fund it", "fr": "La financer" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.6 },
|
||||||
|
{ "stat": "opinion", "delta": 4, "days": 45 },
|
||||||
|
{ "stat": "unemployment", "delta": -0.3, "bias": true, "days": 365 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Decline", "fr": "Décliner" },
|
||||||
|
"effects": [{ "stat": "opinion", "delta": -2, "days": 30 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soc_port_strike_01", "category": "social", "doc": "petition", "sender": "union",
|
||||||
|
"conditions": { "dayMin": 30 },
|
||||||
|
"title": { "en": "Port workers demand a raise", "fr": "Les dockers réclament une augmentation" },
|
||||||
|
"body": {
|
||||||
|
"en": "Dockworkers threaten to stop loading ships on Monday unless wages are raised. The harbour master reports a queue of cargo vessels already waiting.",
|
||||||
|
"fr": "Les dockers menacent de cesser de charger les navires lundi si les salaires ne sont pas relevés. La capitainerie signale déjà une file de cargos en attente."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Grant the raise", "fr": "Accorder la hausse" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.2 },
|
||||||
|
{ "stat": "stability", "delta": 2, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Hold the line", "fr": "Tenir bon" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "unrest", "delta": 4, "days": 30 },
|
||||||
|
{ "stat": "economy", "delta": -2, "days": 30 },
|
||||||
|
{ "stat": "order", "delta": -2, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soc_food_subsidy_01", "category": "social", "doc": "memo", "sender": "min_labour",
|
||||||
|
"conditions": { "min": { "inflation": 4 } },
|
||||||
|
"title": { "en": "Subsidise bread and fuel", "fr": "Subventionner le pain et le carburant" },
|
||||||
|
"body": {
|
||||||
|
"en": "Prices are climbing faster than wages. A subsidy on bread and fuel would ease the pressure on poorer households, at the cost of the deficit.",
|
||||||
|
"fr": "Les prix montent plus vite que les salaires. Une subvention sur le pain et le carburant soulagerait les ménages modestes, au prix du déficit."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Subsidise", "fr": "Subventionner" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.5 },
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 30 },
|
||||||
|
{ "stat": "inflation", "delta": -0.5, "bias": true, "days": 120 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Let prices run", "fr": "Laisser les prix filer" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -4, "days": 40 },
|
||||||
|
{ "stat": "unrest", "delta": 3, "days": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soc_shelters_01", "category": "social", "doc": "letter", "sender": "mayor",
|
||||||
|
"title": { "en": "Winter shelters for the homeless", "fr": "Des abris d'hiver pour les sans-abri" },
|
||||||
|
"body": {
|
||||||
|
"en": "The mayor of the capital asks for emergency funds to open heated shelters before the first frost. Without them, the city expects a hard winter.",
|
||||||
|
"fr": "Le maire de la capitale demande des fonds d'urgence pour ouvrir des abris chauffés avant les premières gelées. Sans eux, la ville s'attend à un hiver difficile."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Release the funds", "fr": "Débloquer les fonds" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.3 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 30 },
|
||||||
|
{ "stat": "order", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Not now", "fr": "Pas maintenant" },
|
||||||
|
"effects": [
|
||||||
|
{ "stat": "opinion", "delta": -1, "days": 30 },
|
||||||
|
{ "stat": "unrest", "delta": 1, "days": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "trn_high_speed_rail_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||||
|
"title": { "en": "A high-speed rail link", "fr": "Une ligne ferroviaire à grande vitesse" },
|
||||||
|
"body": {
|
||||||
|
"en": "A high-speed line between the capital and the southern port would tie the country together. It is the biggest construction project in a generation, and the bill matches.",
|
||||||
|
"fr": "Une ligne à grande vitesse entre la capitale et le port du sud réunirait le pays. C'est le plus grand chantier depuis une génération, et la facture est à l'avenant."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Build it", "fr": "La construire" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 1.2 },
|
||||||
|
{ "sector": "transport", "delta": 6, "days": 365 },
|
||||||
|
{ "stat": "economy", "delta": 2, "bias": true, "days": 730 },
|
||||||
|
{ "stat": "opinion", "delta": 2, "days": 45 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Shelve the project", "fr": "Mettre le projet de côté" },
|
||||||
|
"effects": [{ "sector": "transport", "delta": -2, "days": 180 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trn_free_buses_01", "category": "transport", "doc": "petition", "sender": "mayor",
|
||||||
|
"title": { "en": "Free buses in the capital", "fr": "Bus gratuits dans la capitale" },
|
||||||
|
"body": {
|
||||||
|
"en": "The city asks the government to cover bus fares for residents. Congestion would ease, and so would household budgets.",
|
||||||
|
"fr": "La ville demande au gouvernement de prendre en charge les tickets de bus des habitants. Les embouteillages diminueraient, ainsi que les budgets des ménages."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Cover the fares", "fr": "Prendre en charge" },
|
||||||
|
"effects": [
|
||||||
|
{ "extraSpending": 0.2 },
|
||||||
|
{ "stat": "opinion", "delta": 3, "days": 40 },
|
||||||
|
{ "sector": "transport", "delta": 3, "days": 120 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "sector": "transport", "delta": -2, "days": 90 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trn_port_expansion_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||||
|
"title": { "en": "Expand the main port", "fr": "Agrandir le grand port" },
|
||||||
|
"body": {
|
||||||
|
"en": "Cargo volumes are outgrowing the main port. New quays and cranes would cut waiting times and attract shipping lines.",
|
||||||
|
"fr": "Les volumes de fret dépassent les capacités du grand port. De nouveaux quais et des grues réduiraient les temps d'attente et attireraient les compagnies maritimes."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Fund the expansion", "fr": "Financer l'extension" },
|
||||||
|
"effects": [
|
||||||
|
{ "oneOff": 0.8 },
|
||||||
|
{ "stat": "economy", "delta": 2, "bias": true, "days": 540 },
|
||||||
|
{ "stat": "growth", "delta": 0.2, "bias": true, "days": 540 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Postpone", "fr": "Reporter" },
|
||||||
|
"effects": [{ "stat": "economy", "delta": -1, "days": 60 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trn_road_tolls_01", "category": "transport", "doc": "memo", "sender": "min_transport",
|
||||||
|
"title": { "en": "Introduce road tolls", "fr": "Instaurer des péages routiers" },
|
||||||
|
"body": {
|
||||||
|
"en": "Tolls on the main motorways would pay for maintenance and a new revenue line. Drivers will not like it.",
|
||||||
|
"fr": "Des péages sur les grandes autoroutes financeraient l'entretien et créeraient une nouvelle source de recettes. Les automobilistes n'apprécieront pas."
|
||||||
|
},
|
||||||
|
"yes": {
|
||||||
|
"label": { "en": "Introduce the tolls", "fr": "Instaurer les péages" },
|
||||||
|
"effects": [
|
||||||
|
{ "revenue": "other", "delta": 0.3 },
|
||||||
|
{ "stat": "opinion", "delta": -3, "days": 45 },
|
||||||
|
{ "sector": "transport", "delta": 2, "days": 120 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no": {
|
||||||
|
"label": { "en": "Refuse", "fr": "Refuser" },
|
||||||
|
"effects": [{ "sector": "infrastructure", "delta": -3, "days": 180 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"min_finance": { "seal": "EV", "name": { "en": "Elena Voss", "fr": "Elena Voss" }, "role": { "en": "Minister of Finance", "fr": "Ministre des Finances" } },
|
||||||
|
"min_interior": { "seal": "TB", "name": { "en": "Tomas Brandt", "fr": "Tomas Brandt" }, "role": { "en": "Minister of the Interior", "fr": "Ministre de l'Intérieur" } },
|
||||||
|
"min_defense": { "seal": "AH", "name": { "en": "Anders Holm", "fr": "Anders Holm" }, "role": { "en": "Minister of Defence", "fr": "Ministre de la Défense" } },
|
||||||
|
"min_health": { "seal": "IC", "name": { "en": "Irina Castellan", "fr": "Irina Castellan" }, "role": { "en": "Minister of Health", "fr": "Ministre de la Santé" } },
|
||||||
|
"min_education": { "seal": "LM", "name": { "en": "Leon Marek", "fr": "Leon Marek" }, "role": { "en": "Minister of Education", "fr": "Ministre de l'Éducation" } },
|
||||||
|
"min_foreign": { "seal": "SA", "name": { "en": "Sofia Alder", "fr": "Sofia Alder" }, "role": { "en": "Minister of Foreign Affairs", "fr": "Ministre des Affaires étrangères" } },
|
||||||
|
"min_justice": { "seal": "RQ", "name": { "en": "Rafael Quist", "fr": "Rafael Quist" }, "role": { "en": "Minister of Justice", "fr": "Ministre de la Justice" } },
|
||||||
|
"min_industry": { "seal": "GL", "name": { "en": "Greta Lindqvist", "fr": "Greta Lindqvist" }, "role": { "en": "Minister of Industry and Energy", "fr": "Ministre de l'Industrie et de l'Énergie" } },
|
||||||
|
"min_transport": { "seal": "PN", "name": { "en": "Pavel Novak", "fr": "Pavel Novak" }, "role": { "en": "Minister of Transport", "fr": "Ministre des Transports" } },
|
||||||
|
"min_housing": { "seal": "CB", "name": { "en": "Clara Bellamy", "fr": "Clara Bellamy" }, "role": { "en": "Minister of Housing", "fr": "Ministre du Logement" } },
|
||||||
|
"min_environment": { "seal": "HS", "name": { "en": "Henrik Saal", "fr": "Henrik Saal" }, "role": { "en": "Minister of the Environment", "fr": "Ministre de l'Environnement" } },
|
||||||
|
"min_culture": { "seal": "OM", "name": { "en": "Odile Marchand", "fr": "Odile Marchand" }, "role": { "en": "Minister of Culture", "fr": "Ministre de la Culture" } },
|
||||||
|
"min_labour": { "seal": "VR", "name": { "en": "Viktor Rask", "fr": "Viktor Rask" }, "role": { "en": "Minister of Labour and Social Affairs", "fr": "Ministre du Travail et des Affaires sociales" } },
|
||||||
|
"central_bank": { "seal": "DF", "name": { "en": "Dorian Faulk", "fr": "Dorian Faulk" }, "role": { "en": "Governor of the Central Bank", "fr": "Gouverneur de la Banque centrale" } },
|
||||||
|
"press_office": { "seal": "JE", "name": { "en": "Jonas Ekberg", "fr": "Jonas Ekberg" }, "role": { "en": "Press Secretary", "fr": "Porte-parole du gouvernement" } },
|
||||||
|
"general": { "seal": "KV", "name": { "en": "Katarina Voight", "fr": "Katarina Voight" }, "role": { "en": "Chief of the General Staff", "fr": "Chef d'état-major" } },
|
||||||
|
"intelligence": { "seal": "LO", "name": { "en": "Lucas Orme", "fr": "Lucas Orme" }, "role": { "en": "Director of Intelligence", "fr": "Directeur du renseignement" } },
|
||||||
|
"mayor": { "seal": "AR", "name": { "en": "Anselm Roth", "fr": "Anselm Roth" }, "role": { "en": "Mayor of the capital", "fr": "Maire de la capitale" } },
|
||||||
|
"union": { "seal": "NL", "name": { "en": "National Labour Confederation", "fr": "Confédération nationale du travail" }, "role": { "en": "Trade union", "fr": "Syndicat" } },
|
||||||
|
"rating_agency": { "seal": "HP", "name": { "en": "Hallam & Pryce", "fr": "Hallam & Pryce" }, "role": { "en": "Credit rating agency", "fr": "Agence de notation" } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"over": {
|
||||||
|
"term": {
|
||||||
|
"h": { "en": "Five years on: the verdict", "fr": "Cinq ans après : le verdict" },
|
||||||
|
"d": { "en": "A term ends. The country weighs what it was given and what was done with it.", "fr": "Un mandat s'achève. Le pays pèse ce qu'on lui a laissé et ce qu'on en a fait." }
|
||||||
|
},
|
||||||
|
"revolution": {
|
||||||
|
"h": { "en": "GOVERNMENT TOPPLED BY UPRISING", "fr": "LE GOUVERNEMENT RENVERSÉ PAR LE SOULÈVEMENT" },
|
||||||
|
"d": { "en": "Crowds storm the ministries as the head of state flees the capital.", "fr": "La foule prend d'assaut les ministères alors que le chef de l'État fuit la capitale." }
|
||||||
|
},
|
||||||
|
"coup": {
|
||||||
|
"h": { "en": "ARMY SEIZES POWER", "fr": "L'ARMÉE PREND LE POUVOIR" },
|
||||||
|
"d": { "en": "Tanks in the squares and marches on the radio: the government has fallen.", "fr": "Des chars sur les places, des marches à la radio : le gouvernement est tombé." }
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"h": { "en": "COUNTRY DEFAULTS ON ITS DEBT", "fr": "LE PAYS FAIT DÉFAUT SUR SA DETTE" },
|
||||||
|
"d": { "en": "Lenders walk away, banks close their doors and the government loses the confidence of the markets.", "fr": "Les prêteurs se retirent, les banques ferment leurs portes et le gouvernement perd la confiance des marchés." }
|
||||||
|
},
|
||||||
|
"impeachment": {
|
||||||
|
"h": { "en": "PARLIAMENT VOTES NO CONFIDENCE", "fr": "LE PARLEMENT VOTE LA DÉFIANCE" },
|
||||||
|
"d": { "en": "The motion passes by a wide margin. The head of state is removed from office.", "fr": "La motion passe à une large majorité. Le chef de l'État est destitué." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"alerts": {
|
||||||
|
"revolution": {
|
||||||
|
"title": { "en": "Crowds at the gates", "fr": "La foule aux portes" },
|
||||||
|
"body": {
|
||||||
|
"en": "SECURITY SERVICES REPORT MASS CROWDS AROUND THE MINISTRIES. THE CAPITAL IS ON THE EDGE OF REVOLT. WITHOUT RELIEF THE GOVERNMENT MAY NOT HOLD. STOP.",
|
||||||
|
"fr": "LES SERVICES DE SECURITE SIGNALENT DES FOULES IMMENSES AUTOUR DES MINISTERES. LA CAPITALE EST AU BORD DE LA REVOLTE. SANS APAISEMENT, LE GOUVERNEMENT NE TIENDRA PAS. STOP."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coup": {
|
||||||
|
"title": { "en": "The generals are restless", "fr": "Les généraux s'impatientent" },
|
||||||
|
"body": {
|
||||||
|
"en": "INTELLIGENCE WARNS THAT SENIOR OFFICERS NO LONGER BELIEVE IN THE GOVERNMENT. UNITS ARE MOVING TOWARD THE CAPITAL. STOP.",
|
||||||
|
"fr": "LE RENSEIGNEMENT ALERTE : LES OFFICIERS SUPERIEURS NE CROIENT PLUS AU GOUVERNEMENT. DES UNITES SE DEPLACENT VERS LA CAPITALE. STOP."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"impeachment": {
|
||||||
|
"title": { "en": "A motion of no confidence", "fr": "Une motion de défiance" },
|
||||||
|
"body": {
|
||||||
|
"en": "OPPOSITION DEPUTIES FILE A MOTION OF NO CONFIDENCE. YOUR MAJORITY IS DISSOLVING. SIXTY DAYS REMAIN TO RESTORE TRUST. STOP.",
|
||||||
|
"fr": "LES DEPUTES D'OPPOSITION DEPOSENT UNE MOTION DE DEFIANCE. VOTRE MAJORITE SE DISSOUT. SOIXANTE JOURS POUR RETABLIR LA CONFIANCE. STOP."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": { "en": "Lenders pull back", "fr": "Les prêteurs se retirent" },
|
||||||
|
"body": {
|
||||||
|
"en": "BOND AUCTIONS FAIL. LENDERS REFUSE NEW LOANS AT ANY REASONABLE RATE. THE TREASURY CANNOT BORROW MUCH LONGER. STOP.",
|
||||||
|
"fr": "LES ADJUDICATIONS D'OBLIGATIONS ECHOUENT. LES PRETEURS REFUSENT DE NOUVEAUX PRETS A TAUX RAISONNABLE. LE TRESOR NE POURRA PLUS EMPRUNTER LONGTEMPS. STOP."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"titles": [
|
||||||
|
{
|
||||||
|
"id": "iron_chancellor",
|
||||||
|
"conditions": { "min": { "order": 66, "score": 45 }, "max": { "civilRights": 45 } },
|
||||||
|
"title": { "en": "The Iron Chancellor", "fr": { "he": "Chancelier de fer", "she": "Chancelière de fer", "they": "Chancelier·ère de fer" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "{Sub} {has} kept the streets quiet and the barracks loyal, and the price was every freedom that stood in the way. Order was never in doubt. Consent was.",
|
||||||
|
"fr": "{Sub} a gardé les rues calmes et les casernes loyales, au prix de toutes les libertés qui gênaient. L'ordre n'a jamais fait de doute. Le consentement, si."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soldier_ruler",
|
||||||
|
"conditions": { "min": { "hardPower": 62, "armyLoyalty": 72 } },
|
||||||
|
"title": { "en": "The Soldier-Ruler", "fr": { "he": "Chef de guerre", "she": "Cheffe de guerre", "they": "Chef·fe de guerre" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "Under {pos} rule the army grew strong and stayed faithful, and the country learned to look to its generals first and its voters second.",
|
||||||
|
"fr": "Sous ce mandat l'armée est devenue puissante et fidèle, et le pays a appris à regarder ses généraux avant ses électeurs."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reformer",
|
||||||
|
"conditions": { "min": { "civilRights": 58, "growth": 2.2, "score": 58 } },
|
||||||
|
"title": { "en": "The Reformer", "fr": { "he": "Réformateur", "she": "Réformatrice", "they": "Réformateur·rice" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "{Sub} {was} patient, and it paid: wages rose, courts grew stronger and few people were afraid to say what they thought.",
|
||||||
|
"fr": "{Sub} a été patient{e}, et cela a payé : les salaires ont monté, les tribunaux se sont renforcés et peu de gens avaient peur de dire ce qu'ils pensaient."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "statesman",
|
||||||
|
"conditions": { "min": { "softPower": 56, "stability": 58, "score": 55 } },
|
||||||
|
"title": { "en": "The Statesman", "fr": { "he": "Homme d'État", "she": "Femme d'État", "they": "Figure d'État" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "Abroad, {pos} name opened doors. At home, {sub} {has} left institutions steadier than {sub} found them.",
|
||||||
|
"fr": "À l'étranger, ce nom ouvrait des portes. Chez nous, les institutions sont plus solides qu'à son arrivée."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "champion",
|
||||||
|
"conditions": { "min": { "opinion": 58 }, "max": { "unrest": 28 } },
|
||||||
|
"title": { "en": "The People's Champion", "fr": { "he": "Champion du peuple", "she": "Championne du peuple", "they": "Champion·ne du peuple" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "The crowds cheered, the polls agreed, and for five years the palace and the street seemed to want the same thing.",
|
||||||
|
"fr": "Les foules ont acclamé, les sondages ont suivi, et pendant cinq ans le palais et la rue ont semblé vouloir la même chose."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "builder",
|
||||||
|
"conditions": { "min": { "economy": 60, "growth": 2.4 } },
|
||||||
|
"title": { "en": "The Builder", "fr": { "he": "Bâtisseur", "she": "Bâtisseuse", "they": "Bâtisseur·se" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "Cranes over the capital, ports full of cargo and a growth rate the neighbours envied. {Sub} {was} remembered for what got built.",
|
||||||
|
"fr": "Des grues au-dessus de la capitale, des ports pleins de cargos et une croissance que les voisins enviaient. On se souviendra de ce qui a été construit."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bankrupt",
|
||||||
|
"conditions": { "min": { "debt": 130 } },
|
||||||
|
"title": { "en": "The Bankrupt", "fr": { "he": "Banqueroutier", "she": "Banqueroutière", "they": "Banqueroutier·ère" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "The books never balanced, and the bill was passed to those who came next. {Sub} {was} generous with money the country did not have.",
|
||||||
|
"fr": "Les comptes ne sont jamais revenus à l'équilibre et la facture est passée à ceux qui suivent. {Sub} a dépensé sans compter un argent que le pays n'avait pas."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forgotten",
|
||||||
|
"conditions": { "max": { "score": 42 } },
|
||||||
|
"title": { "en": "The Forgotten", "fr": { "he": "Oublié", "she": "Oubliée", "they": "Oublié·e" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "Five years passed, and little of it was worth a history book. {Sub} will be remembered mostly by those who worked in the palace.",
|
||||||
|
"fr": "Cinq ans ont passé, et peu de choses méritent un manuel d'histoire. On se souviendra surtout de {tonic} dans les couloirs du palais."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "caretaker",
|
||||||
|
"title": { "en": "The Caretaker", "fr": { "he": "Gestionnaire", "she": "Gestionnaire", "they": "Gestionnaire" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "{Sub} kept the lights on. No one was thrilled, no one was ruined, and the next government inherited a country much as it was.",
|
||||||
|
"fr": "{Sub} a gardé les lumières allumées. Personne n'a été enthousiasmé, personne n'a été ruiné, et le gouvernement suivant a hérité d'un pays à peu près inchangé."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "deposed",
|
||||||
|
"over": "revolution",
|
||||||
|
"title": { "en": "The Fallen Leader", "fr": { "he": "Dirigeant déchu", "she": "Dirigeante déchue", "they": "Dirigeant·e déchu·e" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "The crowds reached the palace before the loyal troops did. {Sub} left by the back door, and the portraits came down by morning.",
|
||||||
|
"fr": "Les foules ont atteint le palais avant les troupes loyales. La sortie s'est faite par la porte de service, et les portraits étaient décrochés au matin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ousted",
|
||||||
|
"over": "coup",
|
||||||
|
"title": { "en": "The Ousted", "fr": { "he": "Évincé", "she": "Évincée", "they": "Évincé·e" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "At dawn the radio played marches and the generals announced a new order. {Sub} {was} escorted from the palace with {pos} papers.",
|
||||||
|
"fr": "À l'aube, la radio a diffusé des marches militaires et les généraux ont annoncé un nouvel ordre. {Sub} a été escorté{e} hors du palais."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "defaulted",
|
||||||
|
"over": "default",
|
||||||
|
"title": { "en": "The Bankrupt", "fr": { "he": "Banqueroutier", "she": "Banqueroutière", "they": "Banqueroutier·ère" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "The creditors stopped lending and the Treasury ran dry. The country defaulted, and {pos} signature was on the last budget.",
|
||||||
|
"fr": "Les créanciers ont cessé de prêter et le Trésor s'est vidé. Le pays a fait défaut, et le dernier budget porte sa signature."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "impeached",
|
||||||
|
"over": "impeachment",
|
||||||
|
"title": { "en": "The Impeached", "fr": { "he": "Destitué", "she": "Destituée", "they": "Destitué·e" } },
|
||||||
|
"verdict": {
|
||||||
|
"en": "Parliament voted, and not one deputy rose to speak for {obj}. The term ended in a half-empty chamber.",
|
||||||
|
"fr": "Le Parlement a voté, et aucun député ne s'est levé pour prendre la parole en faveur de {tonic}. Le mandat s'est achevé dans un hémicycle à moitié vide."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
{
|
||||||
|
"name": { "en": "The Daily Herald", "fr": "Le Courrier du Jour" },
|
||||||
|
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
|
||||||
|
"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": "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": {
|
||||||
|
"order": {
|
||||||
|
"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": "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": [
|
||||||
|
{ "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": "Lawlessness on the rise", "fr": "L'insécurité progresse" },
|
||||||
|
"d": { "en": "Shopkeepers demand protection.", "fr": "Les commerçants réclament une protection." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stability": {
|
||||||
|
"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": { "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": [
|
||||||
|
{ "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": "Instability worries diplomats", "fr": "L'instabilité inquiète les diplomates" },
|
||||||
|
"d": { "en": "Embassies advise caution.", "fr": "Les ambassades recommandent la prudence." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"civilRights": {
|
||||||
|
"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": "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": [
|
||||||
|
{ "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": "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": {
|
||||||
|
"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": "Approval ratings climb", "fr": "La cote de popularité grimpe" },
|
||||||
|
"d": { "en": "Citizens credit recent measures.", "fr": "Les citoyens saluent les dernières mesures." } }
|
||||||
|
],
|
||||||
|
"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": "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": {
|
||||||
|
"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": "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": [
|
||||||
|
{ "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": "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": {
|
||||||
|
"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": "Military posture hardens", "fr": "La posture militaire se durcit" },
|
||||||
|
"d": { "en": "Neighbours take note.", "fr": "Les voisins en prennent note." } }
|
||||||
|
],
|
||||||
|
"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": "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": {
|
||||||
|
"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": "Cultural influence expands", "fr": "L'influence culturelle s'étend" },
|
||||||
|
"d": { "en": "Embassies report warm receptions.", "fr": "Les ambassades signalent un accueil chaleureux." } }
|
||||||
|
],
|
||||||
|
"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": "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": {
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"armyLoyalty": {
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"var": {
|
||||||
|
"growth": {
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"down": [
|
||||||
|
{ "h": { "en": "Growth slows", "fr": "La croissance ralentit" },
|
||||||
|
"d": { "en": "Forecasts are revised downward.", "fr": "Les prévisions sont revues à la baisse." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unemployment": {
|
||||||
|
"up": [
|
||||||
|
{ "h": { "en": "Jobless queues lengthen", "fr": "Les files de chômeurs s'allongent" },
|
||||||
|
"d": { "en": "Unemployment edges higher.", "fr": "Le chômage progresse." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inflation": {
|
||||||
|
"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é." } }
|
||||||
|
],
|
||||||
|
"down": [
|
||||||
|
{ "h": { "en": "Inflation cools", "fr": "L'inflation se calme" },
|
||||||
|
"d": { "en": "Prices stabilise in the shops.", "fr": "Les prix se stabilisent en magasin." } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"debtRatio": {
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"level": {
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
],
|
||||||
|
"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." } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": { "en": "Republic of Valdoria", "fr": "République de Valdorie" },
|
||||||
|
"startDate": "2027-01-04",
|
||||||
|
"currency": "VLD",
|
||||||
|
|
||||||
|
"economy": {
|
||||||
|
"gdp": 480,
|
||||||
|
"debtRatio": 58,
|
||||||
|
"growth": 2.0,
|
||||||
|
"unemployment": 6.5,
|
||||||
|
"inflation": 2.5,
|
||||||
|
"otherRevenue": 11.5,
|
||||||
|
"taxes": { "income": 30, "corporate": 25, "vat": 20 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"order": 62,
|
||||||
|
"stability": 58,
|
||||||
|
"civilRights": 64,
|
||||||
|
"opinion": 52,
|
||||||
|
"economy": 55,
|
||||||
|
"hardPower": 45,
|
||||||
|
"softPower": 48,
|
||||||
|
"unrest": 24,
|
||||||
|
"armyLoyalty": 65
|
||||||
|
},
|
||||||
|
|
||||||
|
"sectors": {
|
||||||
|
"education": { "need": 4.8, "alloc": 4.5 },
|
||||||
|
"health": { "need": 6.5, "alloc": 6.0 },
|
||||||
|
"social": { "need": 10.0, "alloc": 10.0 },
|
||||||
|
"police": { "need": 1.2, "alloc": 1.2 },
|
||||||
|
"army": { "need": 2.0, "alloc": 2.0 },
|
||||||
|
"justice": { "need": 0.8, "alloc": 0.8 },
|
||||||
|
"industry": { "need": 1.5, "alloc": 1.5 },
|
||||||
|
"transport": { "need": 1.2, "alloc": 1.2 },
|
||||||
|
"infrastructure": { "need": 1.8, "alloc": 1.5 },
|
||||||
|
"housing": { "need": 1.2, "alloc": 1.0 },
|
||||||
|
"research": { "need": 1.0, "alloc": 0.8 },
|
||||||
|
"culture": { "need": 0.5, "alloc": 0.5 },
|
||||||
|
"foreignAffairs": { "need": 0.4, "alloc": 0.4 },
|
||||||
|
"environment": { "need": 0.9, "alloc": 0.7 },
|
||||||
|
"administration": { "need": 2.2, "alloc": 2.2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"scenarios": [
|
||||||
|
"default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Headless balance runner. No DOM, no fetch: works in the browser (sim.html) and in Node.
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGame, advanceDay, summarize, setBudget, confirmBudget, ensureCard, answerCard, nextFloat, dismissAlert,
|
||||||
|
} from "../js/engine/index.js";
|
||||||
|
|
||||||
|
// A strategy decides what to do at each quarterly review (review) and how to answer
|
||||||
|
// each weekly card (answer, default: a coin flip from the game's own RNG).
|
||||||
|
export const STRATEGIES = {
|
||||||
|
baseline: { label: "Baseline (change nothing)", review: () => ({}) },
|
||||||
|
yesMan: { label: "Yes to every card", review: () => ({}), answer: () => "yes" },
|
||||||
|
noMan: { label: "No to every card", review: () => ({}), answer: () => "no" },
|
||||||
|
spendthrift: {
|
||||||
|
label: "Spendthrift (all budgets x1.4)",
|
||||||
|
review: (state) => ({ alloc: scaled(state, 1.4) }),
|
||||||
|
},
|
||||||
|
austerity: {
|
||||||
|
label: "Austerity (all budgets x0.75)",
|
||||||
|
review: (state) => ({ alloc: scaled(state, 0.75) }),
|
||||||
|
},
|
||||||
|
policeState: {
|
||||||
|
label: "Police state (police x3, army x1.5)",
|
||||||
|
review: (state) => ({ alloc: { police: state.sectors.police.need * 3, army: state.sectors.army.need * 1.5 } }),
|
||||||
|
},
|
||||||
|
taxTheRich: {
|
||||||
|
label: "Tax hike (income +10, corporate +10)",
|
||||||
|
review: (state) => ({ taxes: { income: state.ref.taxes.income + 10, corporate: state.ref.taxes.corporate + 10 } }),
|
||||||
|
},
|
||||||
|
// Deliberately bad play: each should be able to end the term early.
|
||||||
|
ruin: {
|
||||||
|
label: "Ruin (all budgets x2, taxes cut to 15/12/10)",
|
||||||
|
review: (state) => ({ alloc: scaled(state, 2), taxes: { income: 15, corporate: 12, vat: 10 } }),
|
||||||
|
},
|
||||||
|
starve: {
|
||||||
|
label: "Starve (all budgets x0.25, taxes up)",
|
||||||
|
review: (state) => ({
|
||||||
|
alloc: scaled(state, 0.25),
|
||||||
|
taxes: { income: state.ref.taxes.income + 15, corporate: state.ref.taxes.corporate + 10, vat: state.ref.taxes.vat + 10 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tyrant: {
|
||||||
|
label: "Tyrant (police x3, army x2, justice and social x0.3)",
|
||||||
|
review: (state) => ({
|
||||||
|
alloc: {
|
||||||
|
...scaled(state, 0.7),
|
||||||
|
police: state.sectors.police.need * 3,
|
||||||
|
army: state.sectors.army.need * 2,
|
||||||
|
justice: state.sectors.justice.need * 0.3,
|
||||||
|
social: state.sectors.social.need * 0.3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
neglectArmy: {
|
||||||
|
label: "Neglect the army (army x0.1, the rest x1.1)",
|
||||||
|
review: (state) => ({ alloc: { ...scaled(state, 1.1), army: state.sectors.army.need * 0.1 } }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs one game to the end of `years` (or the end of the term) and samples every `sampleEvery` days.
|
||||||
|
export function runSim({ scenario, balance, cards = [], strategy, years = 1, seed = 1, sampleEvery = 30 }) {
|
||||||
|
const state = createGame({ scenario, balance, seed, pronouns: "they" });
|
||||||
|
const maxDay = Math.min(balance.time.termDays, Math.round(years * 365));
|
||||||
|
const rows = [];
|
||||||
|
const strat = STRATEGIES[strategy];
|
||||||
|
|
||||||
|
const sample = () => rows.push(summarize(state, balance));
|
||||||
|
sample();
|
||||||
|
|
||||||
|
while (!state.over && state.day < maxDay) {
|
||||||
|
if (state.pending.review) {
|
||||||
|
const res = setBudget(state, balance, strat.review(state));
|
||||||
|
if (!res.ok) throw new Error(`strategy ${strategy} produced an invalid budget: ${res.errors.join(", ")}`);
|
||||||
|
confirmBudget(state);
|
||||||
|
}
|
||||||
|
while (state.pending.alert) dismissAlert(state);
|
||||||
|
if (state.pending.weeklyCard) {
|
||||||
|
const card = ensureCard(state, balance, cards);
|
||||||
|
if (card) {
|
||||||
|
const choice = strat.answer ? strat.answer(state, card) : nextFloat(state) < 0.5 ? "yes" : "no";
|
||||||
|
answerCard(state, balance, cards, choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advanceDay(state, balance);
|
||||||
|
if (state.day % sampleEvery === 0) sample();
|
||||||
|
}
|
||||||
|
sample();
|
||||||
|
return { rows, final: rows[rows.length - 1], over: state.over };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Balance sim</title>
|
||||||
|
<style>
|
||||||
|
body { font: 13px/1.4 "Courier New", monospace; margin: 24px; background: #1d1a15; color: #e8dfc8; }
|
||||||
|
h1, h2 { font-weight: normal; }
|
||||||
|
label { margin-right: 16px; }
|
||||||
|
input, select, button { font: inherit; background: #2b261e; color: inherit; border: 1px solid #6b5a3e; padding: 3px 6px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
table { border-collapse: collapse; margin: 12px 0 28px; }
|
||||||
|
th, td { border: 1px solid #4a4030; padding: 2px 8px; text-align: right; }
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
th { background: #2b261e; }
|
||||||
|
.bad { color: #ff8a6b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Balance sim</h1>
|
||||||
|
<p>
|
||||||
|
<label>Years <input id="years" type="number" min="1" max="5" value="1" style="width:4em"></label>
|
||||||
|
<label>Seed <input id="seed" type="number" value="1" style="width:6em"></label>
|
||||||
|
<label>Detail <select id="detail"></select></label>
|
||||||
|
<button id="run">Run</button>
|
||||||
|
</p>
|
||||||
|
<h2>Final state per strategy</h2>
|
||||||
|
<div id="summary"></div>
|
||||||
|
<h2>Timeline</h2>
|
||||||
|
<div id="timeline"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { runSim, STRATEGIES } from "./sim-core.js";
|
||||||
|
|
||||||
|
const getJson = (u) => fetch(u).then((r) => r.json());
|
||||||
|
const [balance, scenario, cardIndex] = await Promise.all([
|
||||||
|
getJson("../data/balance.json"),
|
||||||
|
getJson("../data/scenarios/default.json"),
|
||||||
|
getJson("../data/cards/index.json"),
|
||||||
|
]);
|
||||||
|
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`../data/cards/${f}`)))).flat();
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const detail = $("detail");
|
||||||
|
for (const [k, s] of Object.entries(STRATEGIES)) detail.add(new Option(s.label, k));
|
||||||
|
|
||||||
|
const COLS = [
|
||||||
|
["day", "day", 0], ["growth", "growth %", 2], ["unemployment", "unempl %", 1], ["inflation", "infl %", 1],
|
||||||
|
["debtRatio", "debt %GDP", 1], ["deficit", "deficit %GDP", 2], ["interestRate", "rate %", 2],
|
||||||
|
["order", "order", 0], ["stability", "stab", 0], ["civilRights", "civil", 0], ["opinion", "opinion", 0],
|
||||||
|
["economy", "econ", 0], ["hardPower", "hard", 0], ["softPower", "soft", 0], ["unrest", "unrest", 0], ["armyLoyalty", "army", 0],
|
||||||
|
];
|
||||||
|
const val = (row, k) => (k in row ? row[k] : row.indicators[k]);
|
||||||
|
|
||||||
|
function table(rows, firstCol) {
|
||||||
|
const head = `<tr><th>${firstCol}</th>${COLS.map((c) => `<th>${c[1]}</th>`).join("")}</tr>`;
|
||||||
|
const body = rows.map(([name, row]) =>
|
||||||
|
`<tr><td>${name}</td>${COLS.map((c) => `<td>${val(row, c[0]).toFixed(c[2])}</td>`).join("")}</tr>`).join("");
|
||||||
|
return `<table>${head}${body}</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const years = Number($("years").value);
|
||||||
|
const seed = Number($("seed").value);
|
||||||
|
const results = {};
|
||||||
|
for (const k of Object.keys(STRATEGIES)) results[k] = runSim({ scenario, balance, cards, strategy: k, years, seed });
|
||||||
|
const ended = (r) => (r.over && r.over.type !== "term" ? ` ← ${r.over.type}, day ${r.over.day}` : "");
|
||||||
|
$("summary").innerHTML = table(Object.entries(results).map(([k, r]) => [STRATEGIES[k].label + ended(r), r.final]), "strategy");
|
||||||
|
const d = results[detail.value];
|
||||||
|
$("timeline").innerHTML = table(d.rows.map((row) => [`d${row.day}`, row]), "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("run").addEventListener("click", run);
|
||||||
|
detail.addEventListener("change", run);
|
||||||
|
run();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Package web embeds the static frontend so the server ships as one binary.
|
||||||
|
// It lives next to the files because go:embed cannot reference parent directories.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed index.html css js data dev fonts img
|
||||||
|
var Files embed.FS
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2015 The Courier Prime Project Authors (https://github.com/quoteunquoteapps/CourierPrime).
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
Placeholder so go:embed accepts this folder. Delete once real files exist.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>The Desk</title>
|
||||||
|
<link rel="stylesheet" href="css/desk.css">
|
||||||
|
<link rel="stylesheet" href="css/papers.css">
|
||||||
|
<link rel="stylesheet" href="css/ui.css">
|
||||||
|
<link rel="stylesheet" href="css/ledger.css">
|
||||||
|
<link rel="stylesheet" href="css/decision.css">
|
||||||
|
<link rel="stylesheet" href="css/endgame.css">
|
||||||
|
<link rel="stylesheet" href="css/menu.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="desk">
|
||||||
|
<div id="pad">
|
||||||
|
<section id="dashboard" class="paper paper--graph" aria-label="Ministerial briefing"></section>
|
||||||
|
<section id="folder" class="paper paper--folder"></section>
|
||||||
|
<section id="ledger" class="ledger" hidden aria-label="Treasury ledger"></section>
|
||||||
|
<section id="endgame" hidden aria-label="End of term"></section>
|
||||||
|
<section id="alerts" class="alerts" hidden></section>
|
||||||
|
<section id="menu" hidden aria-label="Menu"></section>
|
||||||
|
<div id="side">
|
||||||
|
<section id="calendar" class="paper paper--calendar"></section>
|
||||||
|
<section id="newspaper" class="paper paper--news"></section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Weekly decision cards: drawing, eligibility and applying the player's answer.
|
||||||
|
//
|
||||||
|
// Card: { id, category, doc, sender, weight?, once?, cooldownDays?, followUpOnly?,
|
||||||
|
// conditions?: { min:{stat:n}, max:{stat:n}, flags:[], notFlags:[], dayMin, dayMax },
|
||||||
|
// title, body, yes: Option, no: Option }
|
||||||
|
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
|
||||||
|
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
|
||||||
|
// "deficit" (% of GDP) and "q:<sector>" (sector quality).
|
||||||
|
|
||||||
|
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
||||||
|
import { computeFiscal } from "./economy.js";
|
||||||
|
import { applyEffect } from "./modifiers.js";
|
||||||
|
import { pickWeighted } from "./rng.js";
|
||||||
|
|
||||||
|
export const indexCards = (cards) => new Map(cards.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
function statValue(state, key, fiscal) {
|
||||||
|
if (IND_IDS.includes(key)) return state.indicators[key];
|
||||||
|
if (VAR_IDS.includes(key)) return state.economy[key];
|
||||||
|
if (key === "debt") return fiscal.debtRatio;
|
||||||
|
if (key === "deficit") return fiscal.deficit;
|
||||||
|
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
|
||||||
|
return NaN; // unknown key: never eligible (content tests catch typos)
|
||||||
|
}
|
||||||
|
|
||||||
|
function meetsConditions(card, state, fiscal) {
|
||||||
|
const c = card.conditions;
|
||||||
|
if (!c) return true;
|
||||||
|
if (c.dayMin !== undefined && state.day < c.dayMin) return false;
|
||||||
|
if (c.dayMax !== undefined && state.day > c.dayMax) return false;
|
||||||
|
for (const [k, v] of Object.entries(c.min ?? {})) if (!(statValue(state, k, fiscal) >= v)) return false;
|
||||||
|
for (const [k, v] of Object.entries(c.max ?? {})) if (!(statValue(state, k, fiscal) <= v)) return false;
|
||||||
|
for (const f of c.flags ?? []) if (!state.flags[f]) return false;
|
||||||
|
for (const f of c.notFlags ?? []) if (state.flags[f]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sector (or indicator) in trouble makes cards about it more likely.
|
||||||
|
function boostFor(card, state, bal) {
|
||||||
|
const b = bal.cards.boost[card.category];
|
||||||
|
if (!b) return 1;
|
||||||
|
const v = b.sector ? state.sectors[b.sector].quality : state.indicators[b.indicator];
|
||||||
|
const below = bal.cards.boostBelow;
|
||||||
|
return 1 + bal.cards.boostMax * clamp((below - v) / below, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawCard(state, bal, cards) {
|
||||||
|
const fiscal = computeFiscal(state, bal);
|
||||||
|
const lastAnswered = new Map();
|
||||||
|
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
|
||||||
|
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
|
||||||
|
|
||||||
|
const pool = [];
|
||||||
|
const weights = [];
|
||||||
|
for (const card of cards) {
|
||||||
|
if (card.followUpOnly) continue;
|
||||||
|
const last = lastAnswered.get(card.id);
|
||||||
|
if (last !== undefined) {
|
||||||
|
if (card.once !== false) continue;
|
||||||
|
if (state.day - last < (card.cooldownDays ?? bal.cards.defaultCooldown)) continue;
|
||||||
|
}
|
||||||
|
if (!meetsConditions(card, state, fiscal)) continue;
|
||||||
|
const seen = recent.filter((c) => c === card.category).length;
|
||||||
|
pool.push(card);
|
||||||
|
weights.push((card.weight ?? 1) * boostFor(card, state, bal) * Math.pow(bal.cards.recentPenalty, seen));
|
||||||
|
}
|
||||||
|
return pickWeighted(state, pool, weights);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
|
||||||
|
// A due follow-up always comes first. With no eligible card the week is simply empty.
|
||||||
|
export function ensureCard(state, bal, cards) {
|
||||||
|
if (!state.pending.weeklyCard || state.pendingCard) return null;
|
||||||
|
const byId = indexCards(cards);
|
||||||
|
|
||||||
|
let card = null;
|
||||||
|
const due = state.followUps.findIndex((f) => f.due <= state.day);
|
||||||
|
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
|
||||||
|
card ??= drawCard(state, bal, cards);
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
state.pending.weeklyCard = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
state.pendingCard = { id: card.id, day: state.day };
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function answerCard(state, bal, cards, choice) {
|
||||||
|
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
|
||||||
|
const card = indexCards(cards).get(state.pendingCard.id);
|
||||||
|
if (!card) return { ok: false };
|
||||||
|
|
||||||
|
const option = card[choice];
|
||||||
|
for (const effect of option.effects ?? []) applyEffect(state, bal, effect);
|
||||||
|
for (const f of option.setFlags ?? []) state.flags[f] = state.day;
|
||||||
|
for (const f of option.clearFlags ?? []) delete state.flags[f];
|
||||||
|
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
|
||||||
|
|
||||||
|
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
|
||||||
|
state.pendingCard = null;
|
||||||
|
state.pending.weeklyCard = false;
|
||||||
|
return { ok: true, card };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Public finances: revenue, spending, interest, debt and GDP. All flows are in
|
||||||
|
// % of GDP per year; debt and gdp are absolute (billions of the scenario currency).
|
||||||
|
|
||||||
|
import { TAX_IDS, clamp } from "./state.js";
|
||||||
|
|
||||||
|
// Laffer-style curve: revenue peaks at rate == peak, then falls.
|
||||||
|
export function taxRevenue(id, rate, bal) {
|
||||||
|
const t = bal.tax[id];
|
||||||
|
return t.coef * rate * (1 - rate / (2 * t.peak));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interestRate(debtRatio, bal) {
|
||||||
|
const d = bal.debt;
|
||||||
|
const excess = Math.max(0, debtRatio - d.premiumFrom);
|
||||||
|
return d.baseRate + d.premiumCoef * excess * excess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFiscal(state, bal) {
|
||||||
|
const e = state.economy;
|
||||||
|
let revenue = e.otherRevenue;
|
||||||
|
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
|
||||||
|
|
||||||
|
const debtRatio = (e.debt / e.gdp) * 100;
|
||||||
|
const rate = interestRate(debtRatio, bal);
|
||||||
|
const interest = (debtRatio * rate) / 100;
|
||||||
|
|
||||||
|
let allocTotal = 0;
|
||||||
|
for (const s of Object.values(state.sectors)) allocTotal += s.alloc;
|
||||||
|
const spending = allocTotal + interest + e.extraSpending;
|
||||||
|
|
||||||
|
return { revenue, allocTotal, interest, spending, deficit: spending - revenue, debtRatio, rate };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived quantities that indicator/variable targets are written against.
|
||||||
|
// ref values are captured at game creation so the start is an equilibrium.
|
||||||
|
export function computeDevs(state, bal, fiscal) {
|
||||||
|
const e = state.economy;
|
||||||
|
let taxPressure = 0;
|
||||||
|
let taxGrowthDrag = 0;
|
||||||
|
for (const id of TAX_IDS) {
|
||||||
|
const d = e.taxes[id] - state.ref.taxes[id];
|
||||||
|
taxPressure += bal.tax[id].opinion * d;
|
||||||
|
taxGrowthDrag += bal.tax[id].growth * d;
|
||||||
|
}
|
||||||
|
const start = bal.crackdown.unrestStart;
|
||||||
|
return {
|
||||||
|
growth: e.growth,
|
||||||
|
unemployment: e.unemployment,
|
||||||
|
inflation: e.inflation,
|
||||||
|
debt: Math.max(0, fiscal.debtRatio - bal.debt.freeBelow),
|
||||||
|
deficit: fiscal.deficit,
|
||||||
|
taxPressure,
|
||||||
|
taxGrowthDrag,
|
||||||
|
// police sent in when unrest is high: 0..1
|
||||||
|
crackdown: clamp((state.indicators.unrest - start) / (100 - start), 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceEconomy(state, fiscal) {
|
||||||
|
const e = state.economy;
|
||||||
|
e.debt = Math.max(0, e.debt + ((fiscal.deficit / 100) * e.gdp) / 365);
|
||||||
|
e.gdp *= Math.pow(1 + e.growth / 100, 1 / 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player action, only allowed while the quarterly review is open.
|
||||||
|
export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) {
|
||||||
|
if (!state.pending.review) return { ok: false, errors: ["budget_locked"] };
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
for (const [id, v] of Object.entries(alloc)) {
|
||||||
|
const s = state.sectors[id];
|
||||||
|
if (!s) errors.push(`unknown_sector:${id}`);
|
||||||
|
else if (!(v >= 0 && v <= s.need * bal.limits.allocMaxRatio)) errors.push(`alloc_out_of_range:${id}`);
|
||||||
|
}
|
||||||
|
for (const [id, v] of Object.entries(taxes)) {
|
||||||
|
if (!TAX_IDS.includes(id)) errors.push(`unknown_tax:${id}`);
|
||||||
|
else if (!(v >= 0 && v <= bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak)) errors.push(`tax_out_of_range:${id}`);
|
||||||
|
}
|
||||||
|
if (errors.length) return { ok: false, errors };
|
||||||
|
|
||||||
|
for (const [id, v] of Object.entries(alloc)) state.sectors[id].alloc = v;
|
||||||
|
for (const [id, v] of Object.entries(taxes)) state.economy.taxes[id] = v;
|
||||||
|
return { ok: true, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmBudget(state) {
|
||||||
|
state.pending.review = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Game creation and the daily tick. Pure: data (balance, scenario) is passed in.
|
||||||
|
|
||||||
|
import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js";
|
||||||
|
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
|
||||||
|
import { applyShifts, ageBias } from "./modifiers.js";
|
||||||
|
import { stepSectors } from "./sectors.js";
|
||||||
|
import { stepIndicators, stepVars } from "./indicators.js";
|
||||||
|
import { checkGameOver } from "./gameover.js";
|
||||||
|
|
||||||
|
export const PRONOUNS = ["he", "she", "they"];
|
||||||
|
|
||||||
|
// pronouns: omit it and the game opens with the "who are you" question (pending.profile).
|
||||||
|
export function createGame({ scenario, balance, seed = Date.now(), countryName, lang = "en", pronouns }) {
|
||||||
|
const se = scenario.economy;
|
||||||
|
const gdp = se.gdp;
|
||||||
|
|
||||||
|
const sectors = {};
|
||||||
|
for (const [id, s] of Object.entries(scenario.sectors)) {
|
||||||
|
sectors[id] = { need: s.need, alloc: s.alloc, quality: qualityFromRatio(s.alloc / s.need, balance) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
version: STATE_VERSION,
|
||||||
|
seed: seed >>> 0,
|
||||||
|
rngState: seed >>> 0,
|
||||||
|
day: 0,
|
||||||
|
speed: 1, // 0 = paused, otherwise one of balance.time.speeds
|
||||||
|
startDate: scenario.startDate,
|
||||||
|
lang,
|
||||||
|
pronouns: PRONOUNS.includes(pronouns) ? pronouns : "they",
|
||||||
|
countryName: countryName ?? scenario.name[lang] ?? scenario.name.en,
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
economy: {
|
||||||
|
gdp,
|
||||||
|
debt: (gdp * se.debtRatio) / 100,
|
||||||
|
growth: se.growth,
|
||||||
|
unemployment: se.unemployment,
|
||||||
|
inflation: se.inflation,
|
||||||
|
taxes: { ...se.taxes },
|
||||||
|
otherRevenue: se.otherRevenue,
|
||||||
|
extraSpending: 0,
|
||||||
|
},
|
||||||
|
sectors,
|
||||||
|
indicators: { ...scenario.indicators },
|
||||||
|
modifiers: [],
|
||||||
|
flags: {},
|
||||||
|
cardHistory: [],
|
||||||
|
pendingCard: null,
|
||||||
|
followUps: [],
|
||||||
|
// Day 0 opens with the pronoun question, the initial budget review and a first card.
|
||||||
|
// Any true flag here pauses time (see advanceDay).
|
||||||
|
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
|
||||||
|
over: null,
|
||||||
|
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone
|
||||||
|
alerts: [], // urgent telegrams waiting to be read, oldest first
|
||||||
|
history: { t: [], series: {} },
|
||||||
|
log: [],
|
||||||
|
ref: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.ref = {
|
||||||
|
taxes: { ...se.taxes },
|
||||||
|
ind: { ...scenario.indicators },
|
||||||
|
vars: { growth: se.growth, unemployment: se.unemployment, inflation: se.inflation },
|
||||||
|
sectors: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.quality])),
|
||||||
|
ratio: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.alloc / s.need])),
|
||||||
|
dev: null,
|
||||||
|
};
|
||||||
|
state.ref.dev = computeDevs(state, balance, computeFiscal(state, balance));
|
||||||
|
|
||||||
|
recordHistory(state, balance);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordHistory(state, bal) {
|
||||||
|
const f = computeFiscal(state, bal);
|
||||||
|
const h = state.history;
|
||||||
|
h.t.push(state.day);
|
||||||
|
const sample = { debtRatio: f.debtRatio, deficit: f.deficit };
|
||||||
|
for (const id of IND_IDS) sample[id] = state.indicators[id];
|
||||||
|
for (const id of VAR_IDS) sample[id] = state.economy[id];
|
||||||
|
for (const [k, v] of Object.entries(sample)) (h.series[k] ??= []).push(round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (v) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
export function setSpeed(state, bal, speed) {
|
||||||
|
if (speed !== 0 && !bal.time.speeds.includes(speed)) return false;
|
||||||
|
state.speed = speed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears a pause condition once the player has dealt with it.
|
||||||
|
export function clearPending(state, kind) {
|
||||||
|
state.pending[kind] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPronouns(state, pronouns) {
|
||||||
|
if (!PRONOUNS.includes(pronouns)) return false;
|
||||||
|
state.pronouns = pronouns;
|
||||||
|
state.pending.profile = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True while the game is waiting on the player (profile, budget review, weekly card, alert).
|
||||||
|
export const isWaiting = (state) => Object.values(state.pending).some(Boolean);
|
||||||
|
|
||||||
|
// Advances one in-game day unless the game is over or waiting on the player.
|
||||||
|
export function advanceDay(state, bal) {
|
||||||
|
if (state.over) return { advanced: false, blocked: "over", events: null };
|
||||||
|
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
|
||||||
|
|
||||||
|
applyShifts(state, bal);
|
||||||
|
const fiscal = computeFiscal(state, bal);
|
||||||
|
const devs = computeDevs(state, bal, fiscal);
|
||||||
|
|
||||||
|
advanceEconomy(state, fiscal);
|
||||||
|
stepVars(state, bal, devs);
|
||||||
|
stepIndicators(state, bal, devs);
|
||||||
|
stepSectors(state, bal, fiscal);
|
||||||
|
ageBias(state);
|
||||||
|
|
||||||
|
state.day += 1;
|
||||||
|
|
||||||
|
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
|
||||||
|
const fallen = checkGameOver(state, bal);
|
||||||
|
if (fallen) {
|
||||||
|
state.over = { type: fallen, day: state.day };
|
||||||
|
events.gameOver = fallen;
|
||||||
|
recordHistory(state, bal);
|
||||||
|
} else if (state.day >= bal.time.termDays) {
|
||||||
|
state.over = { type: "term", day: state.day };
|
||||||
|
events.termEnd = true;
|
||||||
|
recordHistory(state, bal);
|
||||||
|
} else {
|
||||||
|
if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal);
|
||||||
|
if (state.day % bal.time.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true;
|
||||||
|
if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true;
|
||||||
|
}
|
||||||
|
return { advanced: true, blocked: null, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only snapshot for the UI and the sim.
|
||||||
|
export function summarize(state, bal) {
|
||||||
|
const f = computeFiscal(state, bal);
|
||||||
|
return {
|
||||||
|
day: state.day,
|
||||||
|
gdp: state.economy.gdp,
|
||||||
|
debt: state.economy.debt,
|
||||||
|
debtRatio: f.debtRatio,
|
||||||
|
interestRate: f.rate,
|
||||||
|
revenue: f.revenue,
|
||||||
|
spending: f.spending,
|
||||||
|
interest: f.interest,
|
||||||
|
deficit: f.deficit,
|
||||||
|
growth: state.economy.growth,
|
||||||
|
unemployment: state.economy.unemployment,
|
||||||
|
inflation: state.economy.inflation,
|
||||||
|
indicators: { ...state.indicators },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Ways the term can end early. Checked once per day after the economy has moved.
|
||||||
|
//
|
||||||
|
// default debt above the limit: immediate
|
||||||
|
// impeachment civil rights and opinion both very low for N days in a row: certain
|
||||||
|
// coup army loyalty and stability both very low: a daily chance that grows each day in the zone
|
||||||
|
// revolution unrest very high and opinion very low: same growing daily chance
|
||||||
|
//
|
||||||
|
// Entering a danger zone queues an urgent telegram (state.alerts) and pauses the game
|
||||||
|
// until the player acknowledges it. state.risk counts the days spent in each zone.
|
||||||
|
|
||||||
|
import { nextFloat } from "./rng.js";
|
||||||
|
|
||||||
|
function alert(state, kind) {
|
||||||
|
state.alerts.push({ kind, day: state.day });
|
||||||
|
state.pending.alert = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the game-over type or null. Mutates state.risk, state.alerts and the RNG.
|
||||||
|
export function checkGameOver(state, bal) {
|
||||||
|
const g = bal.gameOver;
|
||||||
|
const ind = state.indicators;
|
||||||
|
const risk = state.risk;
|
||||||
|
const debtRatio = (state.economy.debt / state.economy.gdp) * 100;
|
||||||
|
|
||||||
|
// Zone bookkeeping: count consecutive days inside, fade (or reset) outside.
|
||||||
|
const track = (name, inZone, fade) => {
|
||||||
|
if (inZone) {
|
||||||
|
risk[name] += 1;
|
||||||
|
if (risk[name] === 1) alert(state, name);
|
||||||
|
} else {
|
||||||
|
risk[name] = fade === "reset" ? 0 : Math.max(0, risk[name] - fade);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const chance = (name, perDay) => risk[name] > 0 && nextFloat(state) < Math.min(1, perDay * risk[name]);
|
||||||
|
|
||||||
|
if (debtRatio >= g.default.warnAbove) {
|
||||||
|
if (!risk.defaultZone) {
|
||||||
|
risk.defaultZone = true;
|
||||||
|
alert(state, "default");
|
||||||
|
}
|
||||||
|
} else if (debtRatio < g.default.warnAbove - 10) {
|
||||||
|
risk.defaultZone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow;
|
||||||
|
const inCoup = ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow;
|
||||||
|
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
|
||||||
|
track("impeachment", inImpeachment, "reset");
|
||||||
|
track("coup", inCoup, 1);
|
||||||
|
track("revolution", inRevolution, 1);
|
||||||
|
|
||||||
|
if (debtRatio > g.default.debtAbove) return "default";
|
||||||
|
if (risk.impeachment >= g.impeachment.days) return "impeachment";
|
||||||
|
if (inCoup && chance("coup", g.coup.chancePerDay)) return "coup";
|
||||||
|
if (inRevolution && chance("revolution", g.revolution.chancePerDay)) return "revolution";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player has read the telegram at the front of the queue.
|
||||||
|
export function dismissAlert(state) {
|
||||||
|
state.alerts.shift();
|
||||||
|
state.pending.alert = state.alerts.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Public engine API. The UI and tools import from here only.
|
||||||
|
|
||||||
|
export { createGame, advanceDay, clearPending, setSpeed, setPronouns, isWaiting, summarize, PRONOUNS } from "./game.js";
|
||||||
|
export { dismissAlert } from "./gameover.js";
|
||||||
|
export { buildReview, optionImpact, METRIC_IDS } from "./score.js";
|
||||||
|
export { restoreState } from "./validate.js";
|
||||||
|
export { setBudget, confirmBudget, computeFiscal, taxRevenue } from "./economy.js";
|
||||||
|
export { previewBudget } from "./projection.js";
|
||||||
|
export { qualityFromRatio } from "./state.js";
|
||||||
|
export { applyEffect } from "./modifiers.js";
|
||||||
|
export { ensureCard, answerCard, drawCard, indexCards } from "./cards.js";
|
||||||
|
export { createClock, consumeClock, dateOfDay, weekOfDay, quarterOfDay } from "./time.js";
|
||||||
|
export { nextFloat, nextInt, pickWeighted } from "./rng.js";
|
||||||
|
export { IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Indicators and economy variables move toward a target each day:
|
||||||
|
// target = start value + sum of weighted deviations from the start state + active biases
|
||||||
|
// Because every term is a deviation from the scenario start, the start is an equilibrium.
|
||||||
|
|
||||||
|
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
|
||||||
|
import { biasFor } from "./modifiers.js";
|
||||||
|
import { nextNoise } from "./rng.js";
|
||||||
|
|
||||||
|
function computeTarget(def, base, group, key, state, devs) {
|
||||||
|
let t = base;
|
||||||
|
for (const [id, w] of Object.entries(def.sectors ?? {})) {
|
||||||
|
t += w * (state.sectors[id].quality - state.ref.sectors[id]);
|
||||||
|
}
|
||||||
|
for (const [id, w] of Object.entries(def.alloc ?? {})) {
|
||||||
|
const s = state.sectors[id];
|
||||||
|
t += w * (s.alloc / s.need - state.ref.ratio[id]);
|
||||||
|
}
|
||||||
|
for (const [k, w] of Object.entries(def.econ ?? {})) {
|
||||||
|
t += w * (devs[k] - state.ref.dev[k]);
|
||||||
|
}
|
||||||
|
for (const [k, w] of Object.entries(def.ind ?? {})) {
|
||||||
|
t += w * (state.indicators[k] - state.ref.ind[k]);
|
||||||
|
}
|
||||||
|
return t + biasFor(state, group, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepVars(state, bal, devs) {
|
||||||
|
const next = {};
|
||||||
|
for (const id of VAR_IDS) {
|
||||||
|
const def = bal.vars[id];
|
||||||
|
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs);
|
||||||
|
const v = state.economy[id];
|
||||||
|
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
|
||||||
|
}
|
||||||
|
Object.assign(state.economy, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepIndicators(state, bal, devs) {
|
||||||
|
const next = {};
|
||||||
|
for (const id of IND_IDS) {
|
||||||
|
const def = bal.indicators[id];
|
||||||
|
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs);
|
||||||
|
const v = state.indicators[id];
|
||||||
|
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
|
||||||
|
}
|
||||||
|
Object.assign(state.indicators, next);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Effects and timed modifiers. Card choices end up here.
|
||||||
|
//
|
||||||
|
// Effect shapes:
|
||||||
|
// { stat, delta } instant shift of an indicator or economy variable
|
||||||
|
// { stat, delta, days } shift spread evenly over `days` (then the value mean-reverts)
|
||||||
|
// { stat, delta, bias: true, days? } offset on the stat's *target* for `days` (permanent if omitted)
|
||||||
|
// { sector, delta[, days][, bias] } same, on a sector's quality
|
||||||
|
// { revenue: "income"|"corporate"|"vat", delta } change tax rate (rate points), permanent
|
||||||
|
// { revenue: "other", delta } change other revenue (% of GDP), permanent
|
||||||
|
// { oneOff: pct } one-time cost in % of GDP (negative = windfall)
|
||||||
|
// { extraSpending: delta } permanent extra spending in % of GDP
|
||||||
|
|
||||||
|
import { IND_IDS, VAR_IDS, TAX_IDS, clamp } from "./state.js";
|
||||||
|
|
||||||
|
function groupOf(effect, state) {
|
||||||
|
if (effect.sector !== undefined) return state.sectors[effect.sector] ? ["sector", effect.sector] : null;
|
||||||
|
if (IND_IDS.includes(effect.stat)) return ["ind", effect.stat];
|
||||||
|
if (VAR_IDS.includes(effect.stat)) return ["var", effect.stat];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shift(state, bal, group, key, delta) {
|
||||||
|
if (group === "ind") state.indicators[key] = clamp(state.indicators[key] + delta, 0, 100);
|
||||||
|
else if (group === "sector") state.sectors[key].quality = clamp(state.sectors[key].quality + delta, 0, 100);
|
||||||
|
else {
|
||||||
|
const v = bal.vars[key];
|
||||||
|
state.economy[key] = clamp(state.economy[key] + delta, v.min, v.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyEffect(state, bal, effect) {
|
||||||
|
if (effect.stat !== undefined || effect.sector !== undefined) {
|
||||||
|
const found = groupOf(effect, state);
|
||||||
|
if (!found) return false;
|
||||||
|
const [group, key] = found;
|
||||||
|
if (effect.bias) {
|
||||||
|
state.modifiers.push({ type: "bias", group, key, value: effect.delta, remaining: effect.days ?? null });
|
||||||
|
} else if (effect.days > 0) {
|
||||||
|
state.modifiers.push({ type: "shift", group, key, perDay: effect.delta / effect.days, remaining: effect.days });
|
||||||
|
} else {
|
||||||
|
shift(state, bal, group, key, effect.delta);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const e = state.economy;
|
||||||
|
if (effect.revenue !== undefined) {
|
||||||
|
if (TAX_IDS.includes(effect.revenue)) e.taxes[effect.revenue] = Math.max(0, e.taxes[effect.revenue] + effect.delta);
|
||||||
|
else if (effect.revenue === "other") e.otherRevenue = Math.max(0, e.otherRevenue + effect.delta);
|
||||||
|
else return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (effect.oneOff !== undefined) {
|
||||||
|
e.debt = Math.max(0, e.debt + (effect.oneOff / 100) * e.gdp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (effect.extraSpending !== undefined) {
|
||||||
|
e.extraSpending += effect.extraSpending;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start of each day: apply the spread-out shifts.
|
||||||
|
export function applyShifts(state, bal) {
|
||||||
|
for (const m of state.modifiers) {
|
||||||
|
if (m.type !== "shift") continue;
|
||||||
|
shift(state, bal, m.group, m.key, m.perDay);
|
||||||
|
m.remaining -= 1;
|
||||||
|
}
|
||||||
|
state.modifiers = state.modifiers.filter((m) => m.type !== "shift" || m.remaining > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum of active target offsets for one stat.
|
||||||
|
export function biasFor(state, group, key) {
|
||||||
|
let sum = 0;
|
||||||
|
for (const m of state.modifiers) if (m.type === "bias" && m.group === group && m.key === key) sum += m.value;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of each day: age timed biases.
|
||||||
|
export function ageBias(state) {
|
||||||
|
for (const m of state.modifiers) if (m.type === "bias" && m.remaining !== null) m.remaining -= 1;
|
||||||
|
state.modifiers = state.modifiers.filter((m) => m.type !== "bias" || m.remaining === null || m.remaining > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Budget preview for the quarterly ledger: applies a draft budget to a copy of
|
||||||
|
// the game and runs the real daily tick forward, without noise, so the player
|
||||||
|
// sees where debt, deficit and the main indicators are heading.
|
||||||
|
// It assumes no new policies (future cards are unknown).
|
||||||
|
|
||||||
|
import { advanceDay, summarize } from "./game.js";
|
||||||
|
import { computeFiscal, setBudget } from "./economy.js";
|
||||||
|
|
||||||
|
const quietCache = new WeakMap();
|
||||||
|
|
||||||
|
// Same balance with all daily noise switched off.
|
||||||
|
function quietBalance(bal) {
|
||||||
|
let q = quietCache.get(bal);
|
||||||
|
if (!q) {
|
||||||
|
q = structuredClone(bal);
|
||||||
|
q.noise.indicator = 0;
|
||||||
|
for (const def of Object.values(q.vars)) def.noise = 0;
|
||||||
|
for (const def of Object.values(q.indicators)) if ("noise" in def) def.noise = 0;
|
||||||
|
quietCache.set(bal, q);
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { ok, errors, fiscal, points }. points[0] is "now with the draft applied";
|
||||||
|
// points[i] is the state i quarters later (fewer if the term ends first).
|
||||||
|
export function previewBudget(state, bal, draft, { quarters = 4 } = {}) {
|
||||||
|
const sim = structuredClone(state);
|
||||||
|
sim.pending.review = true; // the preview may edit the budget even outside a review
|
||||||
|
const res = setBudget(sim, bal, draft);
|
||||||
|
if (!res.ok) return { ok: false, errors: res.errors, fiscal: null, points: [] };
|
||||||
|
|
||||||
|
const quiet = quietBalance(bal);
|
||||||
|
const fiscal = computeFiscal(sim, quiet);
|
||||||
|
const points = [summarize(sim, quiet)];
|
||||||
|
|
||||||
|
for (let q = 0; q < quarters && !sim.over; q++) {
|
||||||
|
for (let d = 0; d < bal.time.daysPerQuarter && !sim.over; d++) {
|
||||||
|
for (const k of Object.keys(sim.pending)) sim.pending[k] = false;
|
||||||
|
advanceDay(sim, quiet);
|
||||||
|
}
|
||||||
|
points.push(summarize(sim, quiet));
|
||||||
|
}
|
||||||
|
return { ok: true, errors: [], fiscal, points };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// mulberry32 PRNG. The generator state lives in state.rngState (a uint32) so a
|
||||||
|
// saved game resumes with the exact same sequence.
|
||||||
|
|
||||||
|
export function nextFloat(state) {
|
||||||
|
state.rngState = (state.rngState + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = state.rngState;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniform in [-amp, amp].
|
||||||
|
export function nextNoise(state, amp) {
|
||||||
|
return (nextFloat(state) * 2 - 1) * amp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextInt(state, maxExclusive) {
|
||||||
|
return Math.floor(nextFloat(state) * maxExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickWeighted(state, items, weights) {
|
||||||
|
let total = 0;
|
||||||
|
for (const w of weights) total += w;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
let r = nextFloat(state) * total;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
r -= weights[i];
|
||||||
|
if (r < 0) return items[i];
|
||||||
|
}
|
||||||
|
return items[items.length - 1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// End-of-term review: a score from the whole term (not just the last day), a letter
|
||||||
|
// grade, the historian's title and the decisions that mattered most.
|
||||||
|
// Pure: works for a finished term and for a game that ended early.
|
||||||
|
|
||||||
|
import { clamp } from "./state.js";
|
||||||
|
import { indexCards } from "./cards.js";
|
||||||
|
|
||||||
|
// Names usable in the `min` / `max` conditions of endgame.json titles.
|
||||||
|
export const METRIC_IDS = [
|
||||||
|
"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty",
|
||||||
|
"growth", "unemployment", "inflation", "debt", "score", "years",
|
||||||
|
];
|
||||||
|
|
||||||
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
||||||
|
|
||||||
|
// Averages over the whole term, plus the final debt ratio.
|
||||||
|
function termAverages(state) {
|
||||||
|
const s = state.history.series;
|
||||||
|
const m = {};
|
||||||
|
for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
|
||||||
|
m[id] = mean(s[id] ?? []);
|
||||||
|
}
|
||||||
|
m.debt = s.debtRatio?.at(-1) ?? 0;
|
||||||
|
m.years = state.day / 365;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeScore(m, bal) {
|
||||||
|
const sc = bal.score;
|
||||||
|
let base = 0;
|
||||||
|
for (const [id, w] of Object.entries(sc.weights)) base += w * (id === "calm" ? 100 - m.unrest : m[id]);
|
||||||
|
const bonus = (cfg, value) => clamp((cfg.ref - value) * cfg.per, cfg.min, cfg.max);
|
||||||
|
const total = base
|
||||||
|
+ bonus(sc.debt, m.debt)
|
||||||
|
+ clamp((m.growth - sc.growth.ref) * sc.growth.per, sc.growth.min, sc.growth.max)
|
||||||
|
+ bonus(sc.unemployment, m.unemployment);
|
||||||
|
return clamp(total, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradeOf = (score, bal) => bal.score.grades.find(([min]) => score >= min)?.[1] ?? "F";
|
||||||
|
|
||||||
|
// How strongly one effect moves the country, in rough "points". Used to rank decisions.
|
||||||
|
export function effectImpact(e) {
|
||||||
|
const d = Math.abs(e.delta ?? 0);
|
||||||
|
if (e.stat !== undefined || e.sector !== undefined) {
|
||||||
|
if (e.bias) return d * clamp((e.days ?? 365) / 90, 1, 8);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
if (e.revenue !== undefined) return d * (e.revenue === "other" ? 8 : 2);
|
||||||
|
if (e.oneOff !== undefined) return Math.abs(e.oneOff) * 5;
|
||||||
|
if (e.extraSpending !== undefined) return Math.abs(e.extraSpending) * 8;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const optionImpact = (option) => (option.effects ?? []).reduce((sum, e) => sum + effectImpact(e), 0);
|
||||||
|
|
||||||
|
function topDecisions(state, cards, count) {
|
||||||
|
const byId = indexCards(cards);
|
||||||
|
return state.cardHistory
|
||||||
|
.map((h) => {
|
||||||
|
const card = byId.get(h.id);
|
||||||
|
return card ? { id: h.id, day: h.day, choice: h.choice, impact: optionImpact(card[h.choice]) } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.impact - a.impact || a.day - b.day)
|
||||||
|
.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function meets(entry, m) {
|
||||||
|
const c = entry.conditions ?? {};
|
||||||
|
for (const [k, v] of Object.entries(c.min ?? {})) if (!(m[k] >= v)) return false;
|
||||||
|
for (const [k, v] of Object.entries(c.max ?? {})) if (!(m[k] <= v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// endgame: the parsed data/endgame.json (its `titles` list is in priority order).
|
||||||
|
export function buildReview(state, bal, cards, endgame) {
|
||||||
|
const metrics = termAverages(state);
|
||||||
|
let score = computeScore(metrics, bal);
|
||||||
|
if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap);
|
||||||
|
metrics.score = score;
|
||||||
|
|
||||||
|
const earlyEnd = state.over && state.over.type !== "term" ? state.over.type : null;
|
||||||
|
const title = endgame.titles.find((t) => (t.over ?? null) === earlyEnd && (earlyEnd || meets(t, metrics)));
|
||||||
|
return {
|
||||||
|
over: state.over,
|
||||||
|
metrics,
|
||||||
|
score,
|
||||||
|
grade: earlyEnd ? "F" : gradeOf(score, bal),
|
||||||
|
titleId: title?.id ?? null,
|
||||||
|
topDecisions: topDecisions(state, cards, bal.score.topDecisions),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Sector quality drifts toward a target set by the budget ratio (alloc / need).
|
||||||
|
// Each sector has its own time constant: police reacts in weeks, infrastructure in years.
|
||||||
|
|
||||||
|
import { clamp, qualityFromRatio } from "./state.js";
|
||||||
|
import { biasFor } from "./modifiers.js";
|
||||||
|
|
||||||
|
export function stepSectors(state, bal, fiscal) {
|
||||||
|
const s = bal.sector;
|
||||||
|
for (const [id, sec] of Object.entries(state.sectors)) {
|
||||||
|
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
|
||||||
|
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
|
||||||
|
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
|
||||||
|
}
|
||||||
|
sec.quality = clamp(sec.quality + (clamp(target, 0, 100) - sec.quality) / bal.sectorTau[id], 0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Shared constants and tiny helpers. No game logic here.
|
||||||
|
|
||||||
|
export const STATE_VERSION = 1;
|
||||||
|
|
||||||
|
export const IND_IDS = [
|
||||||
|
"order", "stability", "civilRights", "opinion", "economy",
|
||||||
|
"hardPower", "softPower", "unrest", "armyLoyalty",
|
||||||
|
];
|
||||||
|
export const VAR_IDS = ["growth", "unemployment", "inflation"];
|
||||||
|
export const TAX_IDS = ["income", "corporate", "vat"];
|
||||||
|
|
||||||
|
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
|
|
||||||
|
// Saturating curve: overspending has diminishing returns, ratio 0 gives 0.
|
||||||
|
export function qualityFromRatio(ratio, bal) {
|
||||||
|
return 100 * (1 - Math.exp(-bal.sector.qualityCurve * Math.max(0, ratio)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Real-time clock accumulator and in-game calendar helpers.
|
||||||
|
|
||||||
|
export function createClock() {
|
||||||
|
return { acc: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed it the real elapsed ms; it returns how many in-game days are due.
|
||||||
|
// dt is capped so a background tab doesn't fast-forward the game on return, and
|
||||||
|
// floored at 0: the first animation frame can carry a timestamp slightly before
|
||||||
|
// the one the caller recorded, and a negative step would pre-load a whole day.
|
||||||
|
export function consumeClock(clock, dtMs, msPerDay, speed) {
|
||||||
|
clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed;
|
||||||
|
const days = Math.floor(clock.acc / msPerDay);
|
||||||
|
clock.acc -= days * msPerDay;
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateOfDay(startDate, day) {
|
||||||
|
const [y, m, d] = startDate.split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(y, m - 1, d + day));
|
||||||
|
return {
|
||||||
|
year: date.getUTCFullYear(),
|
||||||
|
month: date.getUTCMonth() + 1,
|
||||||
|
day: date.getUTCDate(),
|
||||||
|
weekday: date.getUTCDay(), // 0 = Sunday
|
||||||
|
iso: date.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const weekOfDay = (day, bal) => Math.floor(day / bal.time.daysPerWeek);
|
||||||
|
export const quarterOfDay = (day, bal) => Math.floor(day / bal.time.daysPerQuarter);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Checks a game loaded from a save (autosave, slot or imported string) before the game
|
||||||
|
// resumes from it, and repairs what can be repaired. A hostile or damaged save must never
|
||||||
|
// get as far as the UI: every number is finite and in range, every id is known.
|
||||||
|
|
||||||
|
import { STATE_VERSION, IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
|
||||||
|
import { PRONOUNS } from "./game.js";
|
||||||
|
|
||||||
|
const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"];
|
||||||
|
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
|
||||||
|
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
|
||||||
|
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
|
||||||
|
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
|
||||||
|
|
||||||
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
||||||
|
const isInt = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||||
|
const sameKeys = (obj, keys) => isObj(obj) && Object.keys(obj).length === keys.length && keys.every((k) => k in obj);
|
||||||
|
const allNums = (obj, keys, lo, hi) => sameKeys(obj, keys) && keys.every((k) => isNum(obj[k], lo, hi));
|
||||||
|
|
||||||
|
// Returns { ok: true, state } (repaired in place) or { ok: false, error: "invalid" | "scenario" | "version" }.
|
||||||
|
export function restoreState(s, { scenarios, balance, cards }) {
|
||||||
|
const fail = (error = "invalid") => ({ ok: false, error });
|
||||||
|
if (!isObj(s) || s.version !== STATE_VERSION) return fail("version");
|
||||||
|
const scenario = scenarios[s.scenarioId];
|
||||||
|
if (!scenario) return fail("scenario");
|
||||||
|
|
||||||
|
const lim = balance.limits;
|
||||||
|
const cardIds = new Set(cards.map((c) => c.id));
|
||||||
|
const sectorIds = Object.keys(scenario.sectors);
|
||||||
|
const modGroups = { ind: IND_IDS, var: VAR_IDS, sector: sectorIds };
|
||||||
|
|
||||||
|
// Scalars
|
||||||
|
if (!isInt(s.day, 0, balance.time.termDays)) return fail();
|
||||||
|
if (!isInt(s.rngState, 0, 0xffffffff) || !isInt(s.seed, 0, 0xffffffff)) return fail();
|
||||||
|
if (s.speed !== 0 && !balance.time.speeds.includes(s.speed)) return fail();
|
||||||
|
if (s.startDate !== scenario.startDate) return fail();
|
||||||
|
if (!PRONOUNS.includes(s.pronouns)) return fail();
|
||||||
|
if (typeof s.countryName !== "string" || s.countryName.length < 1 || s.countryName.length > 60) return fail();
|
||||||
|
if (s.lang !== "en" && s.lang !== "fr") s.lang = "en";
|
||||||
|
|
||||||
|
// Economy, sectors, indicators
|
||||||
|
const e = s.economy;
|
||||||
|
if (!isObj(e) || !isNum(e.gdp, 1e-6) || !isNum(e.debt, 0) || !isNum(e.otherRevenue) || !isNum(e.extraSpending)) return fail();
|
||||||
|
if (!isNum(e.growth, -100, 100) || !isNum(e.unemployment, 0, 100) || !isNum(e.inflation, -100, 1000)) return fail();
|
||||||
|
if (!allNums(e.taxes, TAX_IDS, 0, 500)) return fail();
|
||||||
|
|
||||||
|
if (!sameKeys(s.sectors, sectorIds)) return fail();
|
||||||
|
for (const id of sectorIds) {
|
||||||
|
const x = s.sectors[id];
|
||||||
|
if (!isObj(x) || !isNum(x.need, 1e-6) || !isNum(x.alloc, 0, x.need * lim.allocMaxRatio + 1e-9) || !isNum(x.quality, 0, 100)) return fail();
|
||||||
|
}
|
||||||
|
if (!allNums(s.indicators, IND_IDS, 0, 100)) return fail();
|
||||||
|
|
||||||
|
// Reference values captured at the start of the game
|
||||||
|
const r = s.ref;
|
||||||
|
if (!isObj(r) || !allNums(r.taxes, TAX_IDS, 0, 500) || !allNums(r.ind, IND_IDS, 0, 100)) return fail();
|
||||||
|
if (!allNums(r.vars, VAR_IDS) || !allNums(r.sectors, sectorIds) || !allNums(r.ratio, sectorIds) || !allNums(r.dev, DEV_KEYS)) return fail();
|
||||||
|
|
||||||
|
// Timed modifiers
|
||||||
|
if (!Array.isArray(s.modifiers)) return fail();
|
||||||
|
for (const m of s.modifiers) {
|
||||||
|
if (!isObj(m) || !(m.group in modGroups) || !modGroups[m.group].includes(m.key)) return fail();
|
||||||
|
if (m.type === "shift" ? !(isNum(m.perDay) && isNum(m.remaining, 1)) : !(m.type === "bias" && isNum(m.value) && (m.remaining === null || isNum(m.remaining, 1)))) return fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decisions and flags
|
||||||
|
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
|
||||||
|
if (!Array.isArray(s.cardHistory)) return fail();
|
||||||
|
for (const h of s.cardHistory) {
|
||||||
|
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
|
||||||
|
}
|
||||||
|
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
|
||||||
|
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
|
||||||
|
|
||||||
|
// Pauses, alerts and endings
|
||||||
|
if (!isObj(s.pending)) return fail();
|
||||||
|
for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true;
|
||||||
|
if (!Array.isArray(s.alerts) || !s.alerts.every((a) => isObj(a) && ALERT_KINDS.includes(a.kind) && isInt(a.day, 0, balance.time.termDays))) return fail();
|
||||||
|
if (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail();
|
||||||
|
if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) return fail();
|
||||||
|
|
||||||
|
// History for the charts: one equal-length series per tracked value
|
||||||
|
const h = s.history;
|
||||||
|
if (!isObj(h) || !Array.isArray(h.t) || !isObj(h.series) || !h.t.every((d) => isInt(d, 0, balance.time.termDays))) return fail();
|
||||||
|
for (const id of HISTORY_SERIES) {
|
||||||
|
const series = h.series[id];
|
||||||
|
if (!Array.isArray(series) || series.length !== h.t.length || !series.every((v) => isNum(v))) return fail();
|
||||||
|
}
|
||||||
|
if (!Array.isArray(s.log)) return fail();
|
||||||
|
|
||||||
|
// Repairs: content that changed since the save was made
|
||||||
|
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
|
||||||
|
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
|
||||||
|
s.pending.alert = s.alerts.length > 0;
|
||||||
|
return { ok: true, state: s };
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"app.title": "The Desk",
|
||||||
|
"dash.title": "Ministerial Briefing",
|
||||||
|
"dash.confidential": "Strictly confidential",
|
||||||
|
"dash.econ": "Economic figures",
|
||||||
|
"dash.intel": "Intelligence summary",
|
||||||
|
"dash.classified": "Classified",
|
||||||
|
"ind.order": "Order",
|
||||||
|
"ind.stability": "Stability",
|
||||||
|
"ind.civilRights": "Civil rights",
|
||||||
|
"ind.opinion": "Public opinion",
|
||||||
|
"ind.economy": "Economy",
|
||||||
|
"ind.hardPower": "Hard power",
|
||||||
|
"ind.softPower": "Soft power",
|
||||||
|
"ind.unrest": "Unrest",
|
||||||
|
"ind.armyLoyalty": "Army loyalty",
|
||||||
|
"opinion.0": "Angry",
|
||||||
|
"opinion.1": "Hostile",
|
||||||
|
"opinion.2": "Uneasy",
|
||||||
|
"opinion.3": "Neutral",
|
||||||
|
"opinion.4": "Content",
|
||||||
|
"opinion.5": "Happy",
|
||||||
|
"opinion.6": "Ecstatic",
|
||||||
|
"unrest.0": "Calm",
|
||||||
|
"unrest.1": "Restless",
|
||||||
|
"unrest.2": "Tense",
|
||||||
|
"unrest.3": "Volatile",
|
||||||
|
"unrest.4": "Explosive",
|
||||||
|
"army.0": "Mutinous",
|
||||||
|
"army.1": "Wavering",
|
||||||
|
"army.2": "Steady",
|
||||||
|
"army.3": "Loyal",
|
||||||
|
"econ.gdp": "GDP",
|
||||||
|
"econ.growth": "Growth",
|
||||||
|
"econ.unemployment": "Unemployment",
|
||||||
|
"econ.inflation": "Inflation",
|
||||||
|
"econ.debt": "Debt / GDP",
|
||||||
|
"econ.deficit": "Deficit / GDP",
|
||||||
|
"econ.rate": "Interest rate",
|
||||||
|
"econ.gdpUnit": "bn {currency}",
|
||||||
|
"cal.dayCount": "Day {day} of {total}",
|
||||||
|
"cal.briefing": "Next briefing",
|
||||||
|
"cal.review": "Budget review",
|
||||||
|
"cal.inDays": "in {n} days",
|
||||||
|
"cal.today": "today",
|
||||||
|
"cal.tomorrow": "tomorrow",
|
||||||
|
"cal.pause": "Pause",
|
||||||
|
"cal.speed": "Speed {n}×",
|
||||||
|
"inbox.title": "In-tray",
|
||||||
|
"inbox.idle": "Nothing awaits your signature. The country carries on.",
|
||||||
|
"inbox.overTitle": "End of term",
|
||||||
|
"inbox.overBody": "Your term has ended. (The final review arrives in a later build.)",
|
||||||
|
"news.edition": "Edition of {date}",
|
||||||
|
"sector.education": "Education",
|
||||||
|
"sector.health": "Health",
|
||||||
|
"sector.social": "Social & pensions",
|
||||||
|
"sector.police": "Police",
|
||||||
|
"sector.army": "Army",
|
||||||
|
"sector.justice": "Justice",
|
||||||
|
"sector.industry": "Industry & energy",
|
||||||
|
"sector.transport": "Public transport",
|
||||||
|
"sector.infrastructure": "Infrastructure",
|
||||||
|
"sector.housing": "Housing",
|
||||||
|
"sector.research": "Research",
|
||||||
|
"sector.culture": "Culture & media",
|
||||||
|
"sector.foreignAffairs": "Foreign affairs",
|
||||||
|
"sector.environment": "Environment",
|
||||||
|
"sector.administration": "Administration",
|
||||||
|
"tax.income": "Income tax",
|
||||||
|
"tax.corporate": "Corporate tax",
|
||||||
|
"tax.vat": "VAT",
|
||||||
|
"ledger.title": "Treasury ledger",
|
||||||
|
"ledger.spending": "Spending, % of GDP",
|
||||||
|
"ledger.spendingHint": "The tick on each line marks what the sector needs.",
|
||||||
|
"ledger.totalAlloc": "Allocated to sectors",
|
||||||
|
"ledger.revenue": "Revenue: tax rates",
|
||||||
|
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
|
||||||
|
"ledger.yield": "{n} % of GDP",
|
||||||
|
"ledger.quality": "Quality {now} → {target}",
|
||||||
|
"ledger.summary": "Treasury summary",
|
||||||
|
"ledger.sumRevenue": "Revenue",
|
||||||
|
"ledger.sumSectors": "Sector spending",
|
||||||
|
"ledger.sumInterest": "Debt interest",
|
||||||
|
"ledger.sumSpending": "Total spending",
|
||||||
|
"ledger.sumSurplus": "Surplus",
|
||||||
|
"ledger.sumDeficit": "Deficit",
|
||||||
|
"ledger.perYear": "{n} bn {currency} per year",
|
||||||
|
"ledger.projection": "Next four quarters, if nothing else changes",
|
||||||
|
"ledger.now": "Now",
|
||||||
|
"ledger.months": "+{n} mo",
|
||||||
|
"ledger.reset": "Reset",
|
||||||
|
"ledger.confirm": "Sign and file",
|
||||||
|
"ledger.error": "The Treasury rejected this budget.",
|
||||||
|
"kind.memo": "Memorandum",
|
||||||
|
"kind.telegram": "Telegram",
|
||||||
|
"kind.petition": "Petition",
|
||||||
|
"kind.report": "Report",
|
||||||
|
"kind.letter": "Letter",
|
||||||
|
"card.approved": "Approved",
|
||||||
|
"card.rejected": "Rejected",
|
||||||
|
"card.keys": "Keys: Y approve · N reject",
|
||||||
|
"card.from": "From",
|
||||||
|
"profile.title": "Sworn in",
|
||||||
|
"profile.lead": "Before you take office, the Press Office needs one thing for the official records.",
|
||||||
|
"profile.body": "How should the press refer to you?",
|
||||||
|
"profile.he": "He / Him",
|
||||||
|
"profile.she": "She / Her",
|
||||||
|
"profile.they": "They / Them",
|
||||||
|
"profile.note": "This only changes how you are named in the history books.",
|
||||||
|
"alert.urgent": "Urgent telegram",
|
||||||
|
"alert.ack": "Understood",
|
||||||
|
"ind.regime": "Hold on power",
|
||||||
|
"regime.0": "Secure",
|
||||||
|
"regime.1": "Shaky",
|
||||||
|
"regime.2": "Critical",
|
||||||
|
"end.verdict": "The historians' verdict",
|
||||||
|
"end.score": "Score {n} / 100",
|
||||||
|
"end.grade": "Grade",
|
||||||
|
"end.served": "Served {days} days",
|
||||||
|
"end.decisions": "Decisions that defined the term",
|
||||||
|
"end.noDecisions": "No decision was signed.",
|
||||||
|
"end.chose": "You chose: {label}",
|
||||||
|
"end.again": "Play again",
|
||||||
|
"end.averages": "avg",
|
||||||
|
"end.final": "end",
|
||||||
|
"cal.menu": "Menu",
|
||||||
|
"menu.secret": "Top secret",
|
||||||
|
"menu.tagline": "A country. Five years. Every decision carries your signature.",
|
||||||
|
"menu.paused": "Game paused",
|
||||||
|
"menu.continue": "Continue",
|
||||||
|
"menu.resume": "Resume",
|
||||||
|
"menu.new": "New game",
|
||||||
|
"menu.load": "Load game",
|
||||||
|
"menu.save": "Save game",
|
||||||
|
"menu.export": "Export",
|
||||||
|
"menu.import": "Import",
|
||||||
|
"menu.settings": "Settings",
|
||||||
|
"menu.credits": "Credits",
|
||||||
|
"menu.quit": "Main menu",
|
||||||
|
"menu.back": "Back",
|
||||||
|
"menu.info": "{country} · day {day}",
|
||||||
|
"new.title": "New game",
|
||||||
|
"new.name": "Name of the country",
|
||||||
|
"new.scenario": "Scenario",
|
||||||
|
"new.pronouns": "How should the press refer to you?",
|
||||||
|
"new.start": "Take office",
|
||||||
|
"load.title": "Load game",
|
||||||
|
"load.auto": "Autosave",
|
||||||
|
"load.slot": "Slot {n}",
|
||||||
|
"load.empty": "Empty",
|
||||||
|
"load.info": "{country} · day {day}{date}",
|
||||||
|
"load.finished": "finished",
|
||||||
|
"load.unreadable": "Unreadable save",
|
||||||
|
"load.load": "Load",
|
||||||
|
"load.delete": "Delete",
|
||||||
|
"load.sure": "Sure?",
|
||||||
|
"load.replace": "Loading replaces the game in progress.",
|
||||||
|
"save.title": "Save game",
|
||||||
|
"save.here": "Save here",
|
||||||
|
"save.overwrite": "Overwrite",
|
||||||
|
"save.done": "Saved to slot {n}.",
|
||||||
|
"save.failed": "The browser refused the save. Use Export to keep your game.",
|
||||||
|
"export.title": "Export",
|
||||||
|
"export.hint": "Copy this text or download the file. You can import it later, on any device.",
|
||||||
|
"export.copy": "Copy",
|
||||||
|
"export.copied": "Copied.",
|
||||||
|
"export.copyFailed": "Select the text and copy it by hand.",
|
||||||
|
"export.download": "Download .sav",
|
||||||
|
"import.title": "Import",
|
||||||
|
"import.hint": "Paste a save string, or choose a .sav file.",
|
||||||
|
"import.file": "Choose a file",
|
||||||
|
"import.go": "Import",
|
||||||
|
"import.error.format": "That does not look like a save string.",
|
||||||
|
"import.error.checksum": "The save is damaged or incomplete (checksum mismatch).",
|
||||||
|
"import.error.tooNew": "This save comes from a newer version of the game.",
|
||||||
|
"import.error.version": "This save is from a version that cannot be converted.",
|
||||||
|
"import.error.invalid": "The contents of this save are not valid.",
|
||||||
|
"import.error.scenario": "This save uses a scenario that is not installed.",
|
||||||
|
"import.error.empty": "Nothing to import yet.",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.slow": "Slow time",
|
||||||
|
"settings.slowHint": "One in-game day takes 60 seconds instead of 10.",
|
||||||
|
"settings.storage": "This browser is not keeping saves between visits (private window or blocked storage). Use Export to keep your game.",
|
||||||
|
"credits.title": "Credits",
|
||||||
|
"credits.fiction": "Valdoria and everyone in it are fictional.",
|
||||||
|
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
|
||||||
|
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game."
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"app.title": "Le Bureau",
|
||||||
|
"dash.title": "Note ministérielle",
|
||||||
|
"dash.confidential": "Strictement confidentiel",
|
||||||
|
"dash.econ": "Chiffres économiques",
|
||||||
|
"dash.intel": "Synthèse du renseignement",
|
||||||
|
"dash.classified": "Classifié",
|
||||||
|
"ind.order": "Ordre public",
|
||||||
|
"ind.stability": "Stabilité",
|
||||||
|
"ind.civilRights": "Droits civiques",
|
||||||
|
"ind.opinion": "Opinion publique",
|
||||||
|
"ind.economy": "Économie",
|
||||||
|
"ind.hardPower": "Puissance militaire",
|
||||||
|
"ind.softPower": "Influence",
|
||||||
|
"ind.unrest": "Agitation",
|
||||||
|
"ind.armyLoyalty": "Loyauté de l'armée",
|
||||||
|
"opinion.0": "En colère",
|
||||||
|
"opinion.1": "Hostile",
|
||||||
|
"opinion.2": "Inquiète",
|
||||||
|
"opinion.3": "Neutre",
|
||||||
|
"opinion.4": "Satisfaite",
|
||||||
|
"opinion.5": "Heureuse",
|
||||||
|
"opinion.6": "Extatique",
|
||||||
|
"unrest.0": "Calme",
|
||||||
|
"unrest.1": "Agité",
|
||||||
|
"unrest.2": "Tendu",
|
||||||
|
"unrest.3": "Volatil",
|
||||||
|
"unrest.4": "Explosif",
|
||||||
|
"army.0": "Mutinée",
|
||||||
|
"army.1": "Hésitante",
|
||||||
|
"army.2": "Stable",
|
||||||
|
"army.3": "Loyale",
|
||||||
|
"econ.gdp": "PIB",
|
||||||
|
"econ.growth": "Croissance",
|
||||||
|
"econ.unemployment": "Chômage",
|
||||||
|
"econ.inflation": "Inflation",
|
||||||
|
"econ.debt": "Dette / PIB",
|
||||||
|
"econ.deficit": "Déficit / PIB",
|
||||||
|
"econ.rate": "Taux d'intérêt",
|
||||||
|
"econ.gdpUnit": "Md {currency}",
|
||||||
|
"cal.dayCount": "Jour {day} sur {total}",
|
||||||
|
"cal.briefing": "Prochain dossier",
|
||||||
|
"cal.review": "Revue budgétaire",
|
||||||
|
"cal.inDays": "dans {n} jours",
|
||||||
|
"cal.today": "aujourd'hui",
|
||||||
|
"cal.tomorrow": "demain",
|
||||||
|
"cal.pause": "Pause",
|
||||||
|
"cal.speed": "Vitesse {n}×",
|
||||||
|
"inbox.title": "Corbeille d'arrivée",
|
||||||
|
"inbox.idle": "Rien n'attend votre signature. Le pays poursuit sa route.",
|
||||||
|
"inbox.overTitle": "Fin de mandat",
|
||||||
|
"inbox.overBody": "Votre mandat est terminé. (Le bilan final arrive dans une prochaine version.)",
|
||||||
|
"news.edition": "Édition du {date}",
|
||||||
|
"sector.education": "Éducation",
|
||||||
|
"sector.health": "Santé",
|
||||||
|
"sector.social": "Social, retraites",
|
||||||
|
"sector.police": "Police",
|
||||||
|
"sector.army": "Armée",
|
||||||
|
"sector.justice": "Justice",
|
||||||
|
"sector.industry": "Industrie, énergie",
|
||||||
|
"sector.transport": "Transports publics",
|
||||||
|
"sector.infrastructure": "Infrastructures",
|
||||||
|
"sector.housing": "Logement",
|
||||||
|
"sector.research": "Recherche",
|
||||||
|
"sector.culture": "Culture, médias",
|
||||||
|
"sector.foreignAffairs": "Diplomatie",
|
||||||
|
"sector.environment": "Environnement",
|
||||||
|
"sector.administration": "Administration",
|
||||||
|
"tax.income": "Impôt revenu",
|
||||||
|
"tax.corporate": "Impôt sociétés",
|
||||||
|
"tax.vat": "TVA",
|
||||||
|
"ledger.title": "Registre du Trésor",
|
||||||
|
"ledger.spending": "Dépenses, % du PIB",
|
||||||
|
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.",
|
||||||
|
"ledger.totalAlloc": "Alloué aux secteurs",
|
||||||
|
"ledger.revenue": "Recettes : taux d'imposition",
|
||||||
|
"ledger.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.",
|
||||||
|
"ledger.yield": "{n} % du PIB",
|
||||||
|
"ledger.quality": "Qualité {now} → {target}",
|
||||||
|
"ledger.summary": "Synthèse du Trésor",
|
||||||
|
"ledger.sumRevenue": "Recettes",
|
||||||
|
"ledger.sumSectors": "Dépenses sectorielles",
|
||||||
|
"ledger.sumInterest": "Intérêts de la dette",
|
||||||
|
"ledger.sumSpending": "Dépenses totales",
|
||||||
|
"ledger.sumSurplus": "Excédent",
|
||||||
|
"ledger.sumDeficit": "Déficit",
|
||||||
|
"ledger.perYear": "{n} Md {currency} par an",
|
||||||
|
"ledger.projection": "Quatre prochains trimestres, sans autre changement",
|
||||||
|
"ledger.now": "Auj.",
|
||||||
|
"ledger.months": "+{n} m",
|
||||||
|
"ledger.reset": "Réinitialiser",
|
||||||
|
"ledger.confirm": "Signer et classer",
|
||||||
|
"ledger.error": "Le Trésor a rejeté ce budget.",
|
||||||
|
"kind.memo": "Mémorandum",
|
||||||
|
"kind.telegram": "Télégramme",
|
||||||
|
"kind.petition": "Pétition",
|
||||||
|
"kind.report": "Rapport",
|
||||||
|
"kind.letter": "Lettre",
|
||||||
|
"card.approved": "Approuvé",
|
||||||
|
"card.rejected": "Rejeté",
|
||||||
|
"card.keys": "Touches : Y approuver · N rejeter",
|
||||||
|
"card.from": "De la part de",
|
||||||
|
"profile.title": "Prestation de serment",
|
||||||
|
"profile.lead": "Avant votre entrée en fonction, le service de presse a besoin d'une précision pour les registres officiels.",
|
||||||
|
"profile.body": "Comment la presse doit-elle s'adresser à vous ?",
|
||||||
|
"profile.he": "Il / Lui",
|
||||||
|
"profile.she": "Elle / Elle",
|
||||||
|
"profile.they": "Iel / Ielle",
|
||||||
|
"profile.note": "Cela ne change que la manière dont vous serez nommé dans les livres d'histoire.",
|
||||||
|
"alert.urgent": "Télégramme urgent",
|
||||||
|
"alert.ack": "Compris",
|
||||||
|
"ind.regime": "Emprise sur le pouvoir",
|
||||||
|
"regime.0": "Solide",
|
||||||
|
"regime.1": "Fragile",
|
||||||
|
"regime.2": "Critique",
|
||||||
|
"end.verdict": "Le verdict des historiens",
|
||||||
|
"end.score": "Score {n} / 100",
|
||||||
|
"end.grade": "Note",
|
||||||
|
"end.served": "{days} jours au pouvoir",
|
||||||
|
"end.decisions": "Les décisions qui ont marqué le mandat",
|
||||||
|
"end.noDecisions": "Aucune décision n'a été signée.",
|
||||||
|
"end.chose": "Votre choix : {label}",
|
||||||
|
"end.again": "Rejouer",
|
||||||
|
"end.averages": "moy.",
|
||||||
|
"end.final": "fin",
|
||||||
|
"cal.menu": "Menu",
|
||||||
|
"menu.secret": "Secret défense",
|
||||||
|
"menu.tagline": "Un pays. Cinq ans. Chaque décision porte votre signature.",
|
||||||
|
"menu.paused": "Partie en pause",
|
||||||
|
"menu.continue": "Continuer",
|
||||||
|
"menu.resume": "Reprendre",
|
||||||
|
"menu.new": "Nouvelle partie",
|
||||||
|
"menu.load": "Charger une partie",
|
||||||
|
"menu.save": "Sauvegarder",
|
||||||
|
"menu.export": "Exporter",
|
||||||
|
"menu.import": "Importer",
|
||||||
|
"menu.settings": "Réglages",
|
||||||
|
"menu.credits": "Crédits",
|
||||||
|
"menu.quit": "Menu principal",
|
||||||
|
"menu.back": "Retour",
|
||||||
|
"menu.info": "{country} · jour {day}",
|
||||||
|
"new.title": "Nouvelle partie",
|
||||||
|
"new.name": "Nom du pays",
|
||||||
|
"new.scenario": "Scénario",
|
||||||
|
"new.pronouns": "Comment la presse doit-elle s'adresser à vous ?",
|
||||||
|
"new.start": "Prendre ses fonctions",
|
||||||
|
"load.title": "Charger une partie",
|
||||||
|
"load.auto": "Sauvegarde auto",
|
||||||
|
"load.slot": "Emplacement {n}",
|
||||||
|
"load.empty": "Vide",
|
||||||
|
"load.info": "{country} · jour {day}{date}",
|
||||||
|
"load.finished": "terminée",
|
||||||
|
"load.unreadable": "Sauvegarde illisible",
|
||||||
|
"load.load": "Charger",
|
||||||
|
"load.delete": "Supprimer",
|
||||||
|
"load.sure": "Sûr ?",
|
||||||
|
"load.replace": "Charger remplace la partie en cours.",
|
||||||
|
"save.title": "Sauvegarder",
|
||||||
|
"save.here": "Sauvegarder ici",
|
||||||
|
"save.overwrite": "Écraser",
|
||||||
|
"save.done": "Sauvegardé dans l'emplacement {n}.",
|
||||||
|
"save.failed": "Le navigateur a refusé la sauvegarde. Utilisez Exporter pour garder votre partie.",
|
||||||
|
"export.title": "Exporter",
|
||||||
|
"export.hint": "Copiez ce texte ou téléchargez le fichier. Vous pourrez l'importer plus tard, sur n'importe quel appareil.",
|
||||||
|
"export.copy": "Copier",
|
||||||
|
"export.copied": "Copié.",
|
||||||
|
"export.copyFailed": "Sélectionnez le texte et copiez-le à la main.",
|
||||||
|
"export.download": "Télécharger le .sav",
|
||||||
|
"import.title": "Importer",
|
||||||
|
"import.hint": "Collez une chaîne de sauvegarde, ou choisissez un fichier .sav.",
|
||||||
|
"import.file": "Choisir un fichier",
|
||||||
|
"import.go": "Importer",
|
||||||
|
"import.error.format": "Cela ne ressemble pas à une chaîne de sauvegarde.",
|
||||||
|
"import.error.checksum": "La sauvegarde est endommagée ou incomplète (somme de contrôle invalide).",
|
||||||
|
"import.error.tooNew": "Cette sauvegarde vient d'une version plus récente du jeu.",
|
||||||
|
"import.error.version": "Cette sauvegarde vient d'une version qui ne peut pas être convertie.",
|
||||||
|
"import.error.invalid": "Le contenu de cette sauvegarde n'est pas valide.",
|
||||||
|
"import.error.scenario": "Cette sauvegarde utilise un scénario qui n'est pas installé.",
|
||||||
|
"import.error.empty": "Rien à importer pour l'instant.",
|
||||||
|
"settings.title": "Réglages",
|
||||||
|
"settings.language": "Langue",
|
||||||
|
"settings.slow": "Temps ralenti",
|
||||||
|
"settings.slowHint": "Un jour en jeu dure 60 secondes au lieu de 10.",
|
||||||
|
"settings.storage": "Ce navigateur ne conserve pas les sauvegardes entre deux visites (fenêtre privée ou stockage bloqué). Utilisez Exporter pour garder votre partie.",
|
||||||
|
"credits.title": "Crédits",
|
||||||
|
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
|
||||||
|
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
|
||||||
|
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu."
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// UI strings live in en.json / fr.json. Content fields (cards, newspaper) are
|
||||||
|
// { en, fr } objects read through tx().
|
||||||
|
|
||||||
|
const dict = {};
|
||||||
|
let lang = "en";
|
||||||
|
|
||||||
|
export async function initI18n(initial = "en") {
|
||||||
|
for (const l of ["en", "fr"]) {
|
||||||
|
dict[l] = await (await fetch(`js/i18n/${l}.json`)).json();
|
||||||
|
}
|
||||||
|
setLang(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLang(l) {
|
||||||
|
lang = dict[l] ? l : "en";
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLang = () => lang;
|
||||||
|
|
||||||
|
export function t(key, vars) {
|
||||||
|
let s = dict[lang]?.[key] ?? dict.en?.[key] ?? key;
|
||||||
|
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the current language from a { en, fr } content field.
|
||||||
|
export const tx = (obj) => obj?.[lang] ?? obj?.en ?? "";
|
||||||
|
|
||||||
|
// Pronoun tokens for text that refers to the player: {sub} {Sub} {obj} {pos} {is} {has} {was}
|
||||||
|
// in English, {sub} {Sub} {tonic} {e} (agreement ending) in French. Text that needs none of
|
||||||
|
// them should avoid pronouns altogether.
|
||||||
|
const PRONOUN_FORMS = {
|
||||||
|
en: {
|
||||||
|
he: { sub: "he", Sub: "He", obj: "him", pos: "his", is: "is", has: "has", was: "was" },
|
||||||
|
she: { sub: "she", Sub: "She", obj: "her", pos: "her", is: "is", has: "has", was: "was" },
|
||||||
|
they: { sub: "they", Sub: "They", obj: "them", pos: "their", is: "are", has: "have", was: "were" },
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
he: { sub: "il", Sub: "Il", tonic: "lui", e: "" },
|
||||||
|
she: { sub: "elle", Sub: "Elle", tonic: "elle", e: "e" },
|
||||||
|
they: { sub: "iel", Sub: "Iel", tonic: "ielle", e: "·e" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tp(text, pronouns = "they") {
|
||||||
|
const forms = PRONOUN_FORMS[lang]?.[pronouns] ?? PRONOUN_FORMS[lang]?.they ?? {};
|
||||||
|
return text.replace(/\{(\w+)\}/g, (match, key) => forms[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A historian's title: English is one string, French has a form per pronoun set.
|
||||||
|
export function tt(title, pronouns = "they") {
|
||||||
|
const v = title?.[lang] ?? title?.en ?? "";
|
||||||
|
return typeof v === "string" ? v : (v[pronouns] ?? v.they);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmt(n, digits = 1) {
|
||||||
|
return new Intl.NumberFormat(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO date (YYYY-MM-DD, treated as UTC) → long localized parts.
|
||||||
|
export function dateParts(iso) {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
const f = (opts) => new Intl.DateTimeFormat(lang, { timeZone: "UTC", ...opts }).format(d);
|
||||||
|
return {
|
||||||
|
weekday: f({ weekday: "long" }),
|
||||||
|
day: f({ day: "numeric" }),
|
||||||
|
month: f({ month: "long" }),
|
||||||
|
year: f({ year: "numeric" }),
|
||||||
|
full: f({ dateStyle: "full" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
|
||||||
|
//
|
||||||
|
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
|
||||||
|
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
|
||||||
|
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
|
||||||
|
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
|
||||||
|
createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat,
|
||||||
|
} from "./engine/index.js";
|
||||||
|
import { initI18n, setLang, getLang, t } from "./i18n/i18n.js";
|
||||||
|
import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js";
|
||||||
|
import { tilt } from "./ui/desk.js";
|
||||||
|
import { createDashboard } from "./ui/dashboard.js";
|
||||||
|
import { createCalendar } from "./ui/calendar.js";
|
||||||
|
import { createNewspaper } from "./ui/newspaper.js";
|
||||||
|
import { createDecision } from "./ui/decision.js";
|
||||||
|
import { createLedger } from "./ui/ledger.js";
|
||||||
|
import { createTelegram } from "./ui/telegram.js";
|
||||||
|
import { createEndgame } from "./ui/endgame.js";
|
||||||
|
import { createMenu } from "./ui/menu.js";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
// ---- Content ----
|
||||||
|
|
||||||
|
const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([
|
||||||
|
getJson("data/balance.json"),
|
||||||
|
getJson("data/newspaper.json"),
|
||||||
|
getJson("data/endgame.json"),
|
||||||
|
getJson("data/characters.json"),
|
||||||
|
getJson("data/cards/index.json"),
|
||||||
|
getJson("data/scenarios/index.json"),
|
||||||
|
]);
|
||||||
|
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat();
|
||||||
|
const scenarios = Object.fromEntries(
|
||||||
|
await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])),
|
||||||
|
);
|
||||||
|
const cardsById = indexCards(cards);
|
||||||
|
const content = { scenarios, balance, cards };
|
||||||
|
|
||||||
|
// ---- Settings and storage ----
|
||||||
|
|
||||||
|
const storage = createStorage();
|
||||||
|
let settings = loadSettings(storage);
|
||||||
|
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
||||||
|
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
||||||
|
|
||||||
|
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||||
|
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||||
|
|
||||||
|
// ---- Game and screen state ----
|
||||||
|
|
||||||
|
let state = null; // the running game, or null in the main menu
|
||||||
|
let scenario = null;
|
||||||
|
let panels = null;
|
||||||
|
let menuOpen = true;
|
||||||
|
let lastSpeed = 1;
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
dash: document.getElementById("dashboard"),
|
||||||
|
folder: document.getElementById("folder"),
|
||||||
|
ledger: document.getElementById("ledger"),
|
||||||
|
alerts: document.getElementById("alerts"),
|
||||||
|
endgame: document.getElementById("endgame"),
|
||||||
|
cal: document.getElementById("calendar"),
|
||||||
|
news: document.getElementById("newspaper"),
|
||||||
|
menu: document.getElementById("menu"),
|
||||||
|
};
|
||||||
|
const pad = document.getElementById("pad");
|
||||||
|
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||||
|
|
||||||
|
// A weekly pause needs a card; with none eligible the week is simply empty.
|
||||||
|
function prepareWeek() {
|
||||||
|
ensureCard(state, balance, cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autosave() {
|
||||||
|
if (!state || devRun) return;
|
||||||
|
if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue
|
||||||
|
else writeSave(storage, KEYS.auto, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Panels ----
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
panels?.decision.destroy();
|
||||||
|
document.title = t("app.title");
|
||||||
|
state.lang = getLang();
|
||||||
|
panels = {
|
||||||
|
dash: createDashboard(el.dash, scenario),
|
||||||
|
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
|
||||||
|
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
|
||||||
|
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
||||||
|
news: createNewspaper(el.news, newsTpl),
|
||||||
|
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||||
|
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state) return;
|
||||||
|
for (const p of Object.values(panels)) p.update(state, balance);
|
||||||
|
// The ledger covers the folder and the side column; the end screen covers everything.
|
||||||
|
pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over));
|
||||||
|
pad.classList.toggle("is-endgame", Boolean(state.over));
|
||||||
|
autosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Game lifecycle ----
|
||||||
|
|
||||||
|
function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) {
|
||||||
|
return createGame({
|
||||||
|
scenario: scenarios[scenarioId],
|
||||||
|
balance,
|
||||||
|
seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0],
|
||||||
|
lang: getLang(),
|
||||||
|
countryName,
|
||||||
|
pronouns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterGame(next) {
|
||||||
|
state = next;
|
||||||
|
scenario = scenarios[state.scenarioId];
|
||||||
|
if (params.has("dev")) window.__game = state;
|
||||||
|
lastSpeed = state.speed || 1;
|
||||||
|
prepareWeek();
|
||||||
|
closeMenu();
|
||||||
|
build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
if (!state) return;
|
||||||
|
menuOpen = true;
|
||||||
|
pad.classList.add("is-menu");
|
||||||
|
menu.show("game");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuOpen = false;
|
||||||
|
pad.classList.remove("is-menu");
|
||||||
|
menu.hide();
|
||||||
|
clock.acc = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitToMenu() {
|
||||||
|
autosave();
|
||||||
|
panels?.decision.destroy();
|
||||||
|
state = null;
|
||||||
|
panels = null;
|
||||||
|
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
|
||||||
|
el.ledger.hidden = true;
|
||||||
|
el.alerts.hidden = true;
|
||||||
|
el.endgame.hidden = true;
|
||||||
|
pad.classList.remove("is-ledger", "is-endgame");
|
||||||
|
pad.classList.add("is-menu");
|
||||||
|
menuOpen = true;
|
||||||
|
menu.show("main");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(patch) {
|
||||||
|
const langChanged = patch.lang && patch.lang !== getLang();
|
||||||
|
settings = { ...settings, ...patch };
|
||||||
|
saveSettings(storage, settings);
|
||||||
|
if (!langChanged) return;
|
||||||
|
setLang(patch.lang);
|
||||||
|
document.title = t("app.title");
|
||||||
|
if (state) build();
|
||||||
|
menu.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = createMenu(el.menu, {
|
||||||
|
content,
|
||||||
|
storage,
|
||||||
|
getState: () => state,
|
||||||
|
getSettings: () => settings,
|
||||||
|
handlers: {
|
||||||
|
onStart: (options) => enterGame(newGame(options)),
|
||||||
|
onLoadState: enterGame,
|
||||||
|
onResume: closeMenu,
|
||||||
|
onQuit: quitToMenu,
|
||||||
|
onSettings: applySettings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Player actions ----
|
||||||
|
|
||||||
|
function onSpeed(speed) {
|
||||||
|
if (speed > 0) lastSpeed = speed;
|
||||||
|
setSpeed(state, balance, speed);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnswer(choice) {
|
||||||
|
answerCard(state, balance, cards, choice);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProfile(pronouns) {
|
||||||
|
setPronouns(state, pronouns);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDismiss() {
|
||||||
|
dismissAlert(state);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (menuOpen) return; // the menu handles its own keys
|
||||||
|
if (e.key === "Escape" && !state.over) {
|
||||||
|
openMenu();
|
||||||
|
} else if (e.code === "Space") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSpeed(state.speed > 0 ? 0 : lastSpeed);
|
||||||
|
} else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) {
|
||||||
|
onSpeed(balance.time.speeds[Number(e.key) - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("pagehide", autosave);
|
||||||
|
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
|
||||||
|
|
||||||
|
// ---- Dev shortcuts ----
|
||||||
|
|
||||||
|
function devGame() {
|
||||||
|
const s = newGame({ pronouns: params.get("pronouns") ?? undefined });
|
||||||
|
state = s; // the helpers below act on `state`
|
||||||
|
if (params.has("alloc")) {
|
||||||
|
const k = Number(params.get("alloc"));
|
||||||
|
setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) });
|
||||||
|
}
|
||||||
|
for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) {
|
||||||
|
if (s.pending.profile) setPronouns(s, "they");
|
||||||
|
s.pending.review = false;
|
||||||
|
while (s.pending.alert) dismissAlert(s);
|
||||||
|
prepareWeek();
|
||||||
|
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||||
|
advanceDay(s, balance);
|
||||||
|
}
|
||||||
|
if (cardsById.has(params.get("card"))) {
|
||||||
|
s.pendingCard = { id: params.get("card"), day: s.day };
|
||||||
|
s.pending.weeklyCard = true;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Start ----
|
||||||
|
|
||||||
|
const clock = createClock();
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
if (devRun) enterGame(devGame());
|
||||||
|
else quitToMenu();
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
const dt = now - last;
|
||||||
|
last = now;
|
||||||
|
if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) {
|
||||||
|
const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay;
|
||||||
|
let days = consumeClock(clock, dt, msPerDay, state.speed);
|
||||||
|
while (days-- > 0) {
|
||||||
|
const r = advanceDay(state, balance);
|
||||||
|
if (!r.advanced) break;
|
||||||
|
if (r.events.weeklyCard) prepareWeek();
|
||||||
|
render();
|
||||||
|
if (isWaiting(state) || state.over) {
|
||||||
|
clock.acc = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clock.acc = 0;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Save strings for export and import: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON).
|
||||||
|
// The checksum only catches corrupted or truncated strings. It is not protection against editing:
|
||||||
|
// anyone can decode, change and re-encode a save, and a single-player game does not need more.
|
||||||
|
|
||||||
|
import { STATE_VERSION } from "../engine/state.js";
|
||||||
|
|
||||||
|
const PREFIX = "CG1:";
|
||||||
|
const FORMAT = /^CG1:([0-9a-f]{8}):([A-Za-z0-9+/]+={0,2})$/;
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function crc32(bytes) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(bytes) {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64(text) {
|
||||||
|
const binary = atob(text);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSave(state) {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
||||||
|
return `${PREFIX}${crc32(bytes).toString(16).padStart(8, "0")}:${toBase64(bytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
|
||||||
|
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
|
||||||
|
export const MIGRATIONS = {};
|
||||||
|
|
||||||
|
export function migrate(state) {
|
||||||
|
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
|
||||||
|
if (state.version > STATE_VERSION) return { ok: false, error: "tooNew" };
|
||||||
|
let s = state;
|
||||||
|
while (s.version < STATE_VERSION) {
|
||||||
|
const step = MIGRATIONS[s.version];
|
||||||
|
if (!step) return { ok: false, error: "version" };
|
||||||
|
s = step(s);
|
||||||
|
}
|
||||||
|
return { ok: true, state: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { ok: true, state } or { ok: false, error: "format" | "checksum" | "tooNew" | "version" }.
|
||||||
|
// Whitespace is ignored, so strings that were wrapped when pasted still work.
|
||||||
|
export function decodeSave(text) {
|
||||||
|
const match = FORMAT.exec(String(text).replace(/\s+/g, ""));
|
||||||
|
if (!match) return { ok: false, error: "format" };
|
||||||
|
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
bytes = fromBase64(match[2]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "format" };
|
||||||
|
}
|
||||||
|
if (crc32(bytes).toString(16).padStart(8, "0") !== match[1]) return { ok: false, error: "checksum" };
|
||||||
|
|
||||||
|
let state;
|
||||||
|
try {
|
||||||
|
state = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "format" };
|
||||||
|
}
|
||||||
|
return migrate(state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Browser storage for the autosave, the manual slots and the settings.
|
||||||
|
// Every access is wrapped: if localStorage is missing or throws (private window, blocked
|
||||||
|
// site data, full quota) the game keeps working and uses memory for the rest of the session.
|
||||||
|
|
||||||
|
export const SLOT_COUNT = 3;
|
||||||
|
export const KEYS = {
|
||||||
|
auto: "cg.autosave",
|
||||||
|
slot: (n) => `cg.slot.${n}`,
|
||||||
|
settings: "cg.settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
function browserStorage() {
|
||||||
|
try {
|
||||||
|
const probe = "cg.probe";
|
||||||
|
globalThis.localStorage.setItem(probe, "1");
|
||||||
|
globalThis.localStorage.removeItem(probe);
|
||||||
|
return globalThis.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend: anything with getItem/setItem/removeItem, for tests. Default: localStorage.
|
||||||
|
export function createStorage(backend = browserStorage()) {
|
||||||
|
const memory = new Map();
|
||||||
|
const store = { persistent: backend !== null };
|
||||||
|
|
||||||
|
store.get = (key) => {
|
||||||
|
try {
|
||||||
|
if (backend) return backend.getItem(key);
|
||||||
|
} catch {
|
||||||
|
store.persistent = false;
|
||||||
|
}
|
||||||
|
return memory.get(key) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns false when the write could not be made durable.
|
||||||
|
store.set = (key, value) => {
|
||||||
|
try {
|
||||||
|
if (backend) {
|
||||||
|
backend.setItem(key, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
store.persistent = false;
|
||||||
|
}
|
||||||
|
memory.set(key, value);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.remove = (key) => {
|
||||||
|
memory.delete(key);
|
||||||
|
try {
|
||||||
|
backend?.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
store.persistent = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Saved games: { meta, state } under one key ----
|
||||||
|
|
||||||
|
export function writeSave(store, key, state) {
|
||||||
|
const meta = {
|
||||||
|
savedAt: Date.now(),
|
||||||
|
day: state.day,
|
||||||
|
countryName: state.countryName,
|
||||||
|
pronouns: state.pronouns,
|
||||||
|
scenarioId: state.scenarioId,
|
||||||
|
over: state.over?.type ?? null,
|
||||||
|
};
|
||||||
|
return store.set(key, JSON.stringify({ meta, state }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { meta, state } or null when the slot is empty or unreadable.
|
||||||
|
export function readSave(store, key) {
|
||||||
|
const raw = store.get(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed?.state && parsed?.meta ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = { lang: null, slow: false };
|
||||||
|
|
||||||
|
export function loadSettings(store) {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(store.get(KEYS.settings) ?? "{}");
|
||||||
|
return {
|
||||||
|
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||||
|
slow: s.slow === true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(store, settings) {
|
||||||
|
return store.set(KEYS.settings, JSON.stringify(settings));
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
|
||||||
|
// controls and the way into the menu.
|
||||||
|
|
||||||
|
import { t, dateParts } from "../i18n/i18n.js";
|
||||||
|
import { dateOfDay } from "../engine/index.js";
|
||||||
|
import { $ } from "./desk.js";
|
||||||
|
|
||||||
|
export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||||
|
const speeds = bal.time.speeds;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="cal__head"><span class="cal__weekday"></span></div>
|
||||||
|
<div class="cal__body">
|
||||||
|
<div class="cal__day"></div>
|
||||||
|
<div class="cal__month"></div>
|
||||||
|
<div class="cal__count"></div>
|
||||||
|
<div class="cal__term"><span></span></div>
|
||||||
|
<dl class="cal__next">
|
||||||
|
<dt>${t("cal.briefing")}</dt><dd data-next="briefing"></dd>
|
||||||
|
<dt>${t("cal.review")}</dt><dd data-next="review"></dd>
|
||||||
|
</dl>
|
||||||
|
<div class="cal__controls">
|
||||||
|
<button class="brass" data-speed="0" title="${t("cal.pause")}" aria-label="${t("cal.pause")}">II</button>
|
||||||
|
${speeds.map((s) => `<button class="brass" data-speed="${s}" aria-label="${t("cal.speed", { n: s })}">${s}×</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
<button class="brass cal__menu" data-menu>${t("cal.menu")}</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button");
|
||||||
|
if (!b) return;
|
||||||
|
if (b.dataset.menu !== undefined) onMenu();
|
||||||
|
else onSpeed(Number(b.dataset.speed));
|
||||||
|
});
|
||||||
|
|
||||||
|
const until = (day, every) => (every - (day % every)) % every;
|
||||||
|
const when = (n) => {
|
||||||
|
if (n === 0) return t("cal.today");
|
||||||
|
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
|
||||||
|
};
|
||||||
|
|
||||||
|
function update(state) {
|
||||||
|
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||||
|
$(root, ".cal__weekday").textContent = p.weekday;
|
||||||
|
$(root, ".cal__day").textContent = p.day;
|
||||||
|
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
|
||||||
|
const shown = Math.min(state.day + 1, bal.time.termDays);
|
||||||
|
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
|
||||||
|
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
|
||||||
|
|
||||||
|
const over = Boolean(state.over);
|
||||||
|
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
|
||||||
|
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
|
||||||
|
|
||||||
|
for (const b of root.querySelectorAll("button[data-speed]")) {
|
||||||
|
b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === state.speed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Hand-drawn SVG helpers. Output is built from numbers only, so it is safe as innerHTML.
|
||||||
|
|
||||||
|
// Bigger chart for the end-of-term review: weekly samples over the whole term,
|
||||||
|
// a gridline per year and per quarter of the value range.
|
||||||
|
export function lineChart(values, { w = 260, h = 96, min = 0, max = 100, yearEvery = 52 } = {}) {
|
||||||
|
const pad = 3;
|
||||||
|
const n = values.length;
|
||||||
|
const span = max - min || 1;
|
||||||
|
const x = (i) => pad + (n > 1 ? (i / (n - 1)) * (w - 2 * pad) : 0);
|
||||||
|
const y = (v) => h - pad - Math.min(1, Math.max(0, (v - min) / span)) * (h - 2 * pad);
|
||||||
|
|
||||||
|
const grid = [];
|
||||||
|
for (let i = yearEvery; i < n; i += yearEvery) grid.push(`<line x1="${x(i).toFixed(1)}" y1="0" x2="${x(i).toFixed(1)}" y2="${h}"/>`);
|
||||||
|
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${(h * f).toFixed(1)}" x2="${w}" y2="${(h * f).toFixed(1)}"/>`);
|
||||||
|
|
||||||
|
const pts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||||
|
return `<svg class="chart" viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="none" aria-hidden="true">` +
|
||||||
|
`<g class="spark__grid">${grid.join("")}</g>` +
|
||||||
|
(n > 1 ? `<polyline class="spark__line" points="${pts}" vector-effect="non-scaling-stroke"/>` : "") +
|
||||||
|
`</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small line chart on graph paper. `values` are plotted evenly left to right.
|
||||||
|
export function sparkline(values, { w = 84, h = 24, min = 0, max = 100 } = {}) {
|
||||||
|
const grid = [];
|
||||||
|
for (let x = 12; x < w; x += 12) grid.push(`<line x1="${x}" y1="0" x2="${x}" y2="${h}"/>`);
|
||||||
|
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${h * f}" x2="${w}" y2="${h * f}"/>`);
|
||||||
|
|
||||||
|
let line = "";
|
||||||
|
const n = values.length;
|
||||||
|
if (n > 1) {
|
||||||
|
const span = max - min || 1;
|
||||||
|
const pts = values.map((v, i) => {
|
||||||
|
const x = 2 + (i / (n - 1)) * (w - 4);
|
||||||
|
const y = h - 2 - Math.min(1, Math.max(0, (v - min) / span)) * (h - 4);
|
||||||
|
return [x.toFixed(1), y.toFixed(1)];
|
||||||
|
});
|
||||||
|
const last = pts[n - 1];
|
||||||
|
line =
|
||||||
|
`<polyline class="spark__line" points="${pts.map((p) => p.join(",")).join(" ")}"/>` +
|
||||||
|
`<circle class="spark__dot" cx="${last[0]}" cy="${last[1]}" r="2"/>`;
|
||||||
|
}
|
||||||
|
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true">` +
|
||||||
|
`<g class="spark__grid">${grid.join("")}</g>${line}</svg>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Ministerial briefing: seven indicator gauges with sparklines, economic figures
|
||||||
|
// with trend arrows, and a classified intelligence summary for the hidden gauges.
|
||||||
|
|
||||||
|
import { t, fmt } from "../i18n/i18n.js";
|
||||||
|
import { sparkline } from "./charts.js";
|
||||||
|
import { $ } from "./desk.js";
|
||||||
|
import { summarize } from "../engine/index.js";
|
||||||
|
|
||||||
|
const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"];
|
||||||
|
|
||||||
|
// key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising
|
||||||
|
const ECON = [
|
||||||
|
["gdp", "econ.gdp", 0, "", "gdp", null, 0, true],
|
||||||
|
["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true],
|
||||||
|
["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false],
|
||||||
|
["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false],
|
||||||
|
["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false],
|
||||||
|
["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false],
|
||||||
|
["rate", "econ.rate", 2, " %", "interestRate", null, 0, false],
|
||||||
|
];
|
||||||
|
|
||||||
|
const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length;
|
||||||
|
const OPINION_CUTS = [15, 30, 45, 60, 75, 90];
|
||||||
|
const UNREST_CUTS = [20, 35, 50, 65];
|
||||||
|
const ARMY_CUTS = [25, 45, 65];
|
||||||
|
|
||||||
|
const SPARK_WEEKS = 26;
|
||||||
|
const SPARK_W = 76;
|
||||||
|
const MIN_SPAN = 16;
|
||||||
|
|
||||||
|
export function createDashboard(root, scenario) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="dash__head">
|
||||||
|
<h2>${t("dash.title")}</h2>
|
||||||
|
<span class="stamp stamp--red">${t("dash.confidential")}</span>
|
||||||
|
</header>
|
||||||
|
<div class="dash__gauges">
|
||||||
|
${MAIN.map((id) => `
|
||||||
|
<div class="gauge" data-ind="${id}">
|
||||||
|
<span class="gauge__label">${t("ind." + id)}<small class="gauge__band"></small></span>
|
||||||
|
<span class="gauge__track"><span class="gauge__fill"></span><i></i><i></i><i></i></span>
|
||||||
|
<span class="gauge__value"></span>
|
||||||
|
<span class="gauge__spark"></span>
|
||||||
|
</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
<h3 class="dash__sub">${t("dash.econ")}</h3>
|
||||||
|
<table class="econ"><tbody>
|
||||||
|
${ECON.map(([key, label]) => `
|
||||||
|
<tr data-econ="${key}"><th>${t(label)}</th><td class="econ__val"></td><td class="econ__trend"></td></tr>`).join("")}
|
||||||
|
</tbody></table>
|
||||||
|
<h3 class="dash__sub">${t("dash.intel")} <span class="stamp stamp--small">${t("dash.classified")}</span></h3>
|
||||||
|
<ul class="intel">
|
||||||
|
<li data-intel="unrest"><span>${t("ind.unrest")}</span><b></b></li>
|
||||||
|
<li data-intel="armyLoyalty"><span>${t("ind.armyLoyalty")}</span><b></b></li>
|
||||||
|
<li data-intel="regime"><span>${t("ind.regime")}</span><b></b></li>
|
||||||
|
</ul>`;
|
||||||
|
|
||||||
|
const gauges = Object.fromEntries(MAIN.map((id) => [id, {
|
||||||
|
el: $(root, `.gauge[data-ind="${id}"]`),
|
||||||
|
fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`),
|
||||||
|
value: $(root, `.gauge[data-ind="${id}"] .gauge__value`),
|
||||||
|
band: $(root, `.gauge[data-ind="${id}"] .gauge__band`),
|
||||||
|
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
|
||||||
|
}]));
|
||||||
|
const sparkLen = {};
|
||||||
|
|
||||||
|
function trend(series, eps, goodUp) {
|
||||||
|
if (!series || series.length < 2) return ["", ""];
|
||||||
|
const cur = series[series.length - 1];
|
||||||
|
const prev = series[Math.max(0, series.length - 5)];
|
||||||
|
const d = cur - prev;
|
||||||
|
if (Math.abs(d) < eps) return ["▬", "flat"];
|
||||||
|
return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(state, bal) {
|
||||||
|
const sum = summarize(state, bal);
|
||||||
|
|
||||||
|
for (const id of MAIN) {
|
||||||
|
const g = gauges[id];
|
||||||
|
const v = state.indicators[id];
|
||||||
|
g.fill.style.width = `${v}%`;
|
||||||
|
g.el.classList.toggle("is-low", v < 30);
|
||||||
|
g.el.classList.toggle("is-high", v >= 70);
|
||||||
|
g.value.textContent = Math.round(v);
|
||||||
|
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
|
||||||
|
|
||||||
|
const series = state.history.series[id] ?? [];
|
||||||
|
if (sparkLen[id] !== series.length) {
|
||||||
|
sparkLen[id] = series.length;
|
||||||
|
const win = series.slice(-SPARK_WEEKS);
|
||||||
|
// Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible.
|
||||||
|
const mid = (Math.min(...win) + Math.max(...win)) / 2;
|
||||||
|
const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN);
|
||||||
|
g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) {
|
||||||
|
const row = $(root, `[data-econ="${key}"]`);
|
||||||
|
const val = sum[field];
|
||||||
|
$(row, ".econ__val").textContent = key === "gdp"
|
||||||
|
? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}`
|
||||||
|
: `${fmt(val, digits)}${suffix}`;
|
||||||
|
const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp);
|
||||||
|
const cell = $(row, ".econ__trend");
|
||||||
|
cell.textContent = arrow;
|
||||||
|
cell.className = `econ__trend ${cls}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS));
|
||||||
|
$(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS));
|
||||||
|
// Hold on power: Critical once any danger zone has been entered, Shaky when close to one.
|
||||||
|
const i = state.indicators;
|
||||||
|
const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone;
|
||||||
|
const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40
|
||||||
|
|| i.civilRights <= 20 || sum.debtRatio >= 130;
|
||||||
|
const regime = inZone ? 2 : close ? 1 : 0;
|
||||||
|
$(root, '[data-intel="regime"] b').textContent = t("regime." + regime);
|
||||||
|
$(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime];
|
||||||
|
|
||||||
|
$(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS);
|
||||||
|
$(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Centre folder: the weekly decision card as a paper document with two rubber
|
||||||
|
// stamps. Also shows the idle and end-of-term messages.
|
||||||
|
|
||||||
|
import { t, tx, dateParts } from "../i18n/i18n.js";
|
||||||
|
import { dateOfDay } from "../engine/index.js";
|
||||||
|
import { $, esc } from "./desk.js";
|
||||||
|
|
||||||
|
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
|
||||||
|
|
||||||
|
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2 class="inbox__tab">${t("inbox.title")}</h2>
|
||||||
|
<div class="inbox__body"></div>`;
|
||||||
|
const body = $(root, ".inbox__body");
|
||||||
|
|
||||||
|
let shown = "";
|
||||||
|
let locked = false;
|
||||||
|
let latest = null;
|
||||||
|
|
||||||
|
function choose(choice) {
|
||||||
|
if (locked) return;
|
||||||
|
const card = $(body, ".card");
|
||||||
|
if (!card) return;
|
||||||
|
locked = true;
|
||||||
|
card.dataset.stamped = choice;
|
||||||
|
for (const b of card.querySelectorAll("button")) b.disabled = true;
|
||||||
|
const stamp = document.createElement("div");
|
||||||
|
stamp.className = `card__stamp card__stamp--${choice}`;
|
||||||
|
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
|
||||||
|
card.append(stamp);
|
||||||
|
setTimeout(() => onAnswer(choice), STAMP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button[data-choice]");
|
||||||
|
if (b) choose(b.dataset.choice);
|
||||||
|
const p = e.target.closest("button[data-pronouns]");
|
||||||
|
if (p) onProfile(p.dataset.pronouns);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (!keysEnabled() || !latest?.pendingCard) return;
|
||||||
|
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (e.target.closest?.("input, textarea, select")) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === "y") choose("yes");
|
||||||
|
else if (k === "n") choose("no");
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
function option(card, side) {
|
||||||
|
const label = side === "yes" ? t("card.approved") : t("card.rejected");
|
||||||
|
return `
|
||||||
|
<button class="stamp-btn stamp-btn--${side}" data-choice="${side}">
|
||||||
|
<span class="stamp-btn__label">${esc(tx(card[side].label))}</span>
|
||||||
|
<span class="stamp-btn__ink">${esc(label)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(card, state) {
|
||||||
|
const from = characters[card.sender];
|
||||||
|
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
|
||||||
|
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
|
||||||
|
return `
|
||||||
|
<article class="card card--${esc(card.doc)}">
|
||||||
|
<header class="card__head">
|
||||||
|
<span class="card__kind">${t("kind." + card.doc)}</span>
|
||||||
|
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||||
|
</header>
|
||||||
|
<div class="card__from">
|
||||||
|
<span class="seal">${esc(from.seal)}</span>
|
||||||
|
<span><b>${esc(tx(from.name))}</b><small>${esc(tx(from.role))}</small></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="card__title">${esc(tx(card.title))}</h3>
|
||||||
|
<div class="card__body">${paragraphs}</div>
|
||||||
|
<div class="card__choices">${option(card, "yes")}${option(card, "no")}</div>
|
||||||
|
<p class="card__keys">${t("card.keys")}</p>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(state) {
|
||||||
|
latest = state;
|
||||||
|
let kind = "idle";
|
||||||
|
if (state.over) kind = "over";
|
||||||
|
else if (state.pending.profile) kind = "profile";
|
||||||
|
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
|
||||||
|
if (kind === shown) return;
|
||||||
|
shown = kind;
|
||||||
|
locked = false;
|
||||||
|
body.classList.toggle("is-card", kind.startsWith("card:"));
|
||||||
|
|
||||||
|
if (kind === "profile") {
|
||||||
|
body.innerHTML = `
|
||||||
|
<h3>${t("profile.title")}</h3>
|
||||||
|
<p>${t("profile.lead")}</p>
|
||||||
|
<p class="profile__ask">${t("profile.body")}</p>
|
||||||
|
<div class="profile__choices">
|
||||||
|
${["he", "she", "they"].map((p) => `<button class="brass brass--wide" data-pronouns="${p}">${t("profile." + p)}</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p class="profile__note">${t("profile.note")}</p>`;
|
||||||
|
} else if (kind === "idle") {
|
||||||
|
body.innerHTML = `<p class="inbox__idle">${t("inbox.idle")}</p>`;
|
||||||
|
} else if (kind === "over") {
|
||||||
|
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
|
||||||
|
} else {
|
||||||
|
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Desk-level helpers shared by the paper panels.
|
||||||
|
|
||||||
|
export function hash(str) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 16777619);
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give a paper a stable slight rotation derived from its id.
|
||||||
|
export function tilt(el, id, amp = 0.8) {
|
||||||
|
const r = ((hash(id) % 1000) / 1000 - 0.5) * 2 * amp;
|
||||||
|
el.style.setProperty("--rot", `${r.toFixed(2)}deg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $ = (root, sel) => root.querySelector(sel);
|
||||||
|
|
||||||
|
// Escape text for use inside innerHTML templates.
|
||||||
|
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// End of the game: a retrospective front page. It covers both the normal end of
|
||||||
|
// the term and each way of losing power early.
|
||||||
|
|
||||||
|
import { t, tx, tp, tt, getLang, dateParts, fmt } from "../i18n/i18n.js";
|
||||||
|
import { buildReview, dateOfDay, indexCards } from "../engine/index.js";
|
||||||
|
import { lineChart } from "./charts.js";
|
||||||
|
import { $, esc } from "./desk.js";
|
||||||
|
|
||||||
|
const MIN_SPAN = 30;
|
||||||
|
|
||||||
|
// series id in state.history, label key, show the term average or the final value
|
||||||
|
const CHARTS = [
|
||||||
|
["opinion", "ind.opinion", "avg"],
|
||||||
|
["stability", "ind.stability", "avg"],
|
||||||
|
["economy", "ind.economy", "avg"],
|
||||||
|
["debtRatio", "econ.debt", "final"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createEndgame(root, { newsName, endgame, cards, onAgain }) {
|
||||||
|
const cardsById = indexCards(cards);
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("button[data-again]")) onAgain();
|
||||||
|
});
|
||||||
|
|
||||||
|
let shown = "";
|
||||||
|
|
||||||
|
function chart(state, [id, label, mode], review) {
|
||||||
|
const series = state.history.series[id] ?? [];
|
||||||
|
const value = mode === "avg" ? review.metrics[id] : series.at(-1);
|
||||||
|
const tag = mode === "avg" ? t("end.averages") : t("end.final");
|
||||||
|
|
||||||
|
// Zoom on the data (at least MIN_SPAN points tall) so trends are visible; stay within 0-100
|
||||||
|
// for indicators, and above 0 for debt.
|
||||||
|
const lo = Math.min(...series);
|
||||||
|
const hi = Math.max(...series);
|
||||||
|
const span = Math.max(hi - lo, MIN_SPAN);
|
||||||
|
let min = Math.max(0, (lo + hi) / 2 - span / 2);
|
||||||
|
let max = min + span;
|
||||||
|
if (id !== "debtRatio" && max > 100) {
|
||||||
|
max = 100;
|
||||||
|
min = 100 - span;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<figure class="end__chart">
|
||||||
|
<figcaption><span>${t(label)}</span><b>${esc(fmt(value, 0))}</b><small>${tag}</small></figcaption>
|
||||||
|
${lineChart(series, { min, max })}
|
||||||
|
</figure>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decision(d) {
|
||||||
|
const card = cardsById.get(d.id);
|
||||||
|
const date = dateParts(dateOfDay(root.dataset.startDate, d.day).iso);
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<span class="end__when">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||||
|
<b>${esc(tx(card.title))}</b>
|
||||||
|
<span class="end__chose">${esc(t("end.chose", { label: tx(card[d.choice].label) }))}</span>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(state, bal) {
|
||||||
|
if (!state.over) {
|
||||||
|
if (shown) {
|
||||||
|
root.hidden = true;
|
||||||
|
root.innerHTML = "";
|
||||||
|
shown = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${state.over.type}:${state.over.day}:${getLang()}`;
|
||||||
|
if (key === shown) return;
|
||||||
|
shown = key;
|
||||||
|
root.dataset.startDate = state.startDate;
|
||||||
|
|
||||||
|
const review = buildReview(state, bal, cards, endgame);
|
||||||
|
const banner = endgame.over[state.over.type];
|
||||||
|
const entry = endgame.titles.find((x) => x.id === review.titleId);
|
||||||
|
const date = dateParts(dateOfDay(state.startDate, state.over.day).iso);
|
||||||
|
const lost = state.over.type !== "term";
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<article class="paper paper--news end ${lost ? "end--lost" : ""}">
|
||||||
|
<header class="news__mast">
|
||||||
|
<h2>${esc(tx(newsName))}</h2>
|
||||||
|
<div class="news__edition">${esc(t("news.edition", { date: date.full }))}</div>
|
||||||
|
</header>
|
||||||
|
<div class="end__banner">
|
||||||
|
<h1>${esc(tx(banner.h))}</h1>
|
||||||
|
<p>${esc(tx(banner.d))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="end__grid">
|
||||||
|
<section class="end__verdict">
|
||||||
|
<h3>${t("end.verdict")}</h3>
|
||||||
|
<div class="end__grade" data-grade="${esc(review.grade)}">
|
||||||
|
<span class="end__letter">${esc(review.grade)}</span>
|
||||||
|
<span>${t("end.grade")}</span>
|
||||||
|
</div>
|
||||||
|
<p class="end__title">${entry ? esc(tt(entry.title, state.pronouns)) : ""}</p>
|
||||||
|
<p class="end__text">${entry ? esc(tp(tx(entry.verdict), state.pronouns)) : ""}</p>
|
||||||
|
<p class="end__score">${t("end.score", { n: Math.round(review.score) })}<br>${t("end.served", { days: state.over.day })}</p>
|
||||||
|
</section>
|
||||||
|
<section class="end__charts">${CHARTS.map((c) => chart(state, c, review)).join("")}</section>
|
||||||
|
<section class="end__decisions">
|
||||||
|
<h3>${t("end.decisions")}</h3>
|
||||||
|
${review.topDecisions.length
|
||||||
|
? `<ol>${review.topDecisions.map(decision).join("")}</ol>`
|
||||||
|
: `<p class="inbox__idle">${t("end.noDecisions")}</p>`}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer class="end__foot"><button class="brass brass--wide" data-again>${t("end.again")}</button></footer>
|
||||||
|
</article>`;
|
||||||
|
root.hidden = false;
|
||||||
|
$(root, "button[data-again]").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Quarterly budget ledger: a two-page accounting book. The left page sets sector
|
||||||
|
// budgets, the right page sets tax rates and shows the Treasury summary and a
|
||||||
|
// four-quarter projection. Nothing is applied until the player signs.
|
||||||
|
|
||||||
|
import { t, fmt, getLang } from "../i18n/i18n.js";
|
||||||
|
import {
|
||||||
|
previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS,
|
||||||
|
} from "../engine/index.js";
|
||||||
|
import { $ } from "./desk.js";
|
||||||
|
|
||||||
|
const ALLOC_STEP = 0.05;
|
||||||
|
const TAX_STEP = 0.5;
|
||||||
|
|
||||||
|
// Projection rows: label key, field on the summary, digits, suffix
|
||||||
|
const PROJ_ROWS = [
|
||||||
|
["econ.debt", (p) => p.debtRatio, 1, ""],
|
||||||
|
["econ.deficit", (p) => p.deficit, 1, ""],
|
||||||
|
["econ.growth", (p) => p.growth, 1, ""],
|
||||||
|
["econ.unemployment", (p) => p.unemployment, 1, ""],
|
||||||
|
["ind.opinion", (p) => p.indicators.opinion, 0, ""],
|
||||||
|
["ind.stability", (p) => p.indicators.stability, 0, ""],
|
||||||
|
["ind.economy", (p) => p.indicators.economy, 0, ""],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createLedger(root, scenario, { onConfirm }) {
|
||||||
|
let openKey = null;
|
||||||
|
let ctx = null; // { state, bal }
|
||||||
|
let draft = null;
|
||||||
|
let base = null;
|
||||||
|
let queued = false;
|
||||||
|
|
||||||
|
const sectorIds = (state) => Object.keys(state.sectors);
|
||||||
|
|
||||||
|
function open(state, bal) {
|
||||||
|
ctx = { state, bal };
|
||||||
|
base = {
|
||||||
|
alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])),
|
||||||
|
taxes: { ...state.economy.taxes },
|
||||||
|
};
|
||||||
|
draft = structuredClone(base);
|
||||||
|
|
||||||
|
const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio);
|
||||||
|
const sectorRows = sectorIds(state).map((id) => {
|
||||||
|
const s = state.sectors[id];
|
||||||
|
const max = Math.max(s.need * allocMax, s.alloc);
|
||||||
|
return `
|
||||||
|
<div class="lrow" data-sector="${id}">
|
||||||
|
<label class="lrow__name" for="alloc-${id}" title="${t("sector." + id)}">${t("sector." + id)}</label>
|
||||||
|
<span class="lrow__ctl">
|
||||||
|
<input type="range" id="alloc-${id}" min="0" max="${max}" step="${ALLOC_STEP}" value="${s.alloc}">
|
||||||
|
<i class="lrow__tick" style="left:${(s.need / max) * 100}%"></i>
|
||||||
|
</span>
|
||||||
|
<output class="lrow__val"></output>
|
||||||
|
<span class="lrow__q"></span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const taxRows = TAX_IDS.map((id) => {
|
||||||
|
const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak;
|
||||||
|
return `
|
||||||
|
<div class="lrow lrow--tax" data-tax="${id}">
|
||||||
|
<label class="lrow__name" for="tax-${id}">${t("tax." + id)}</label>
|
||||||
|
<span class="lrow__ctl">
|
||||||
|
<input type="range" id="tax-${id}" min="0" max="${max}" step="${TAX_STEP}" value="${state.economy.taxes[id]}">
|
||||||
|
<i class="lrow__tick" style="left:${(bal.tax[id].peak / max) * 100}%"></i>
|
||||||
|
</span>
|
||||||
|
<output class="lrow__val"></output>
|
||||||
|
<span class="lrow__yield"></span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="ledger__book">
|
||||||
|
<article class="ledger__page ledger__page--left">
|
||||||
|
<h2>${t("ledger.title")}</h2>
|
||||||
|
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
|
||||||
|
<div class="ledger__rows">${sectorRows}</div>
|
||||||
|
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
|
||||||
|
</article>
|
||||||
|
<article class="ledger__page ledger__page--right">
|
||||||
|
<h2>${t("ledger.revenue")}</h2>
|
||||||
|
<p class="ledger__sub">${t("ledger.revenueHint")}</p>
|
||||||
|
<div class="ledger__rows">${taxRows}</div>
|
||||||
|
<h3>${t("ledger.summary")}</h3>
|
||||||
|
<dl class="sum" data-summary></dl>
|
||||||
|
<h3>${t("ledger.projection")}</h3>
|
||||||
|
<table class="proj"><thead></thead><tbody></tbody></table>
|
||||||
|
<footer class="ledger__actions">
|
||||||
|
<button class="brass" data-act="reset">${t("ledger.reset")}</button>
|
||||||
|
<button class="brass brass--wide" data-act="confirm">${t("ledger.confirm")}</button>
|
||||||
|
</footer>
|
||||||
|
<p class="ledger__error" data-error hidden>${t("ledger.error")}</p>
|
||||||
|
</article>
|
||||||
|
</div>`;
|
||||||
|
root.hidden = false;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
root.hidden = true;
|
||||||
|
root.innerHTML = "";
|
||||||
|
openKey = null;
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame.
|
||||||
|
function refresh() {
|
||||||
|
const { state, bal } = ctx;
|
||||||
|
let total = 0;
|
||||||
|
for (const id of sectorIds(state)) {
|
||||||
|
const s = state.sectors[id];
|
||||||
|
const v = draft.alloc[id];
|
||||||
|
total += v;
|
||||||
|
const row = $(root, `[data-sector="${id}"]`);
|
||||||
|
$(row, ".lrow__val").textContent = fmt(v, 2);
|
||||||
|
const target = qualityFromRatio(v / s.need, bal);
|
||||||
|
const diff = target - s.quality;
|
||||||
|
const q = $(row, ".lrow__q");
|
||||||
|
q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "=";
|
||||||
|
q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`;
|
||||||
|
q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) });
|
||||||
|
row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9);
|
||||||
|
}
|
||||||
|
$(root, "[data-total]").textContent = `${fmt(total, 2)} %`;
|
||||||
|
|
||||||
|
for (const id of TAX_IDS) {
|
||||||
|
const row = $(root, `[data-tax="${id}"]`);
|
||||||
|
$(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`;
|
||||||
|
$(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) });
|
||||||
|
row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queued) {
|
||||||
|
queued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
queued = false;
|
||||||
|
if (ctx) renderPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
const { state, bal } = ctx;
|
||||||
|
const pv = previewBudget(state, bal, draft);
|
||||||
|
if (!pv.ok) return;
|
||||||
|
const f = pv.fiscal;
|
||||||
|
const gdp = state.economy.gdp;
|
||||||
|
const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1);
|
||||||
|
const balance = -f.deficit; // positive = surplus
|
||||||
|
|
||||||
|
$(root, "[data-summary]").innerHTML = `
|
||||||
|
<dt>${t("ledger.sumRevenue")}</dt><dd>${fmt(f.revenue, 1)} %</dd>
|
||||||
|
<dt>${t("ledger.sumSectors")}</dt><dd>${fmt(f.allocTotal, 1)} %</dd>
|
||||||
|
<dt>${t("ledger.sumInterest")}</dt><dd>${fmt(f.interest, 1)} %</dd>
|
||||||
|
<dt>${t("ledger.sumSpending")}</dt><dd>${fmt(f.spending, 1)} %</dd>
|
||||||
|
<dt class="sum__balance ${balance >= 0 ? "good" : "bad"}">${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}</dt>
|
||||||
|
<dd class="sum__balance ${balance >= 0 ? "good" : "bad"}">${fmt(Math.abs(balance), 1)} %
|
||||||
|
<small>${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}</small></dd>`;
|
||||||
|
|
||||||
|
const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 }));
|
||||||
|
$(root, ".proj thead").innerHTML = `<tr><th></th>${pv.points.map((_, i) => `<th>${months(i)}</th>`).join("")}</tr>`;
|
||||||
|
$(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) =>
|
||||||
|
`<tr><th>${t(label)}</th>${pv.points.map((p) => `<td>${fmt(get(p), digits)}${suffix}</td>`).join("")}</tr>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSliders() {
|
||||||
|
for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id];
|
||||||
|
for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener("input", (e) => {
|
||||||
|
const input = e.target;
|
||||||
|
if (!ctx || input.type !== "range") return;
|
||||||
|
const v = Number(input.value);
|
||||||
|
if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v;
|
||||||
|
else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button[data-act]");
|
||||||
|
if (!b || !ctx) return;
|
||||||
|
if (b.dataset.act === "reset") {
|
||||||
|
draft = structuredClone(base);
|
||||||
|
syncSliders();
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
const { state, bal } = ctx;
|
||||||
|
const res = setBudget(state, bal, draft);
|
||||||
|
if (!res.ok) {
|
||||||
|
$(root, "[data-error]").hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmBudget(state);
|
||||||
|
close();
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(state, bal) {
|
||||||
|
if (!(state.pending.review && !state.pending.profile && !state.over)) {
|
||||||
|
if (openKey !== null) close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${state.day}:${getLang()}`;
|
||||||
|
if (key !== openKey) {
|
||||||
|
openKey = key;
|
||||||
|
open(state, bal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time.
|
||||||
|
// Modes: "main" (before a game) and "game" (pause menu while a game is running).
|
||||||
|
// The menu never changes the game itself; it hands results to main.js through `handlers`.
|
||||||
|
|
||||||
|
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
|
||||||
|
import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
|
||||||
|
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
||||||
|
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||||
|
import { $, esc } from "./desk.js";
|
||||||
|
|
||||||
|
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||||
|
|
||||||
|
export function createMenu(root, { content, storage, getState, getSettings, handlers }) {
|
||||||
|
let mode = "main";
|
||||||
|
let screen = "home";
|
||||||
|
let message = null; // { text, kind: "ok" | "error" }
|
||||||
|
let armed = null; // "action:arg" waiting for a second click
|
||||||
|
let armTimer = 0;
|
||||||
|
let exportText = "";
|
||||||
|
|
||||||
|
const say = (text, kind = "ok") => { message = { text, kind }; };
|
||||||
|
const go = (next) => { screen = next; message = null; disarm(); render(); };
|
||||||
|
|
||||||
|
function disarm() {
|
||||||
|
armed = null;
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arm(key) {
|
||||||
|
armed = key;
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reading saves ----
|
||||||
|
|
||||||
|
const autosave = () => readSave(storage, KEYS.auto);
|
||||||
|
|
||||||
|
function describe(entry) {
|
||||||
|
const scenario = content.scenarios[entry.meta.scenarioId];
|
||||||
|
let date = "";
|
||||||
|
if (scenario) {
|
||||||
|
const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso);
|
||||||
|
date = ` · ${p.day} ${p.month} ${p.year}`;
|
||||||
|
}
|
||||||
|
const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt));
|
||||||
|
const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date });
|
||||||
|
return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}<small>${esc(saved)}</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turns a stored or imported state into a playable one, or explains why not.
|
||||||
|
function restore(rawState) {
|
||||||
|
const migrated = migrate(rawState);
|
||||||
|
if (!migrated.ok) return migrated;
|
||||||
|
return restoreState(migrated.state, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEntry(entry) {
|
||||||
|
const r = entry ? restore(entry.state) : { ok: false, error: "invalid" };
|
||||||
|
if (!r.ok) {
|
||||||
|
say(t("load.unreadable"), "error");
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onLoadState(r.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Screens ----
|
||||||
|
|
||||||
|
const button = (action, label, extra = "", cls = "") =>
|
||||||
|
`<button class="brass brass--wide ${cls}" data-action="${action}" ${extra}>${label}</button>`;
|
||||||
|
const back = () => `<footer class="menu__foot">${button("back", t("menu.back"))}</footer>`;
|
||||||
|
const notice = () => (message ? `<p class="menu__msg menu__msg--${message.kind}" role="status">${esc(message.text)}</p>` : "");
|
||||||
|
|
||||||
|
function home() {
|
||||||
|
const state = getState();
|
||||||
|
if (mode === "game") {
|
||||||
|
return `
|
||||||
|
<h3>${t("menu.paused")}</h3>
|
||||||
|
<p class="menu__sub">${esc(t("menu.info", { country: state.countryName, day: state.day }))}</p>
|
||||||
|
<div class="menu__list">
|
||||||
|
${button("resume", t("menu.resume"))}
|
||||||
|
${button("save", t("menu.save"))}
|
||||||
|
${button("load", t("menu.load"))}
|
||||||
|
${button("export", t("menu.export"))}
|
||||||
|
${button("import", t("menu.import"))}
|
||||||
|
${button("settings", t("menu.settings"))}
|
||||||
|
${button("quit", t("menu.quit"))}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const auto = autosave();
|
||||||
|
const canContinue = auto && !auto.meta.over;
|
||||||
|
return `
|
||||||
|
<h1 class="menu__title">${t("app.title")}</h1>
|
||||||
|
<p class="menu__sub">${t("menu.tagline")}</p>
|
||||||
|
${notice()}
|
||||||
|
<div class="menu__list">
|
||||||
|
${canContinue ? button("continue", `${t("menu.continue")}<small>${esc(t("menu.info", { country: auto.meta.countryName, day: auto.meta.day }))}</small>`) : ""}
|
||||||
|
${button("new", t("menu.new"))}
|
||||||
|
${button("load", t("menu.load"))}
|
||||||
|
${button("import", t("menu.import"))}
|
||||||
|
${button("settings", t("menu.settings"))}
|
||||||
|
${button("credits", t("menu.credits"))}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGame() {
|
||||||
|
const ids = Object.keys(content.scenarios);
|
||||||
|
const first = content.scenarios[ids[0]];
|
||||||
|
return `
|
||||||
|
<h3>${t("new.title")}</h3>
|
||||||
|
<label class="menu__field">${t("new.name")}
|
||||||
|
<input id="new-name" type="text" maxlength="40" placeholder="${esc(tx(first.name))}" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
${ids.length > 1
|
||||||
|
? `<label class="menu__field">${t("new.scenario")}
|
||||||
|
<select id="new-scenario">${ids.map((id) => `<option value="${esc(id)}">${esc(tx(content.scenarios[id].name))}</option>`).join("")}</select>
|
||||||
|
</label>`
|
||||||
|
: `<input id="new-scenario" type="hidden" value="${esc(ids[0])}">`}
|
||||||
|
<fieldset class="menu__field menu__radios">
|
||||||
|
<legend>${t("new.pronouns")}</legend>
|
||||||
|
${PRONOUNS.map((p) => `<label><input type="radio" name="pronouns" value="${p}" ${p === "they" ? "checked" : ""}> ${t("profile." + p)}</label>`).join("")}
|
||||||
|
</fieldset>
|
||||||
|
<div class="menu__list">${button("start", t("new.start"))}</div>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotRow(label, entry, actions) {
|
||||||
|
return `
|
||||||
|
<li class="menu__slot">
|
||||||
|
<span class="menu__slotname">${label}</span>
|
||||||
|
<span class="menu__slotinfo">${entry ? describe(entry) : `<em>${t("load.empty")}</em>`}</span>
|
||||||
|
<span class="menu__slotbtns">${actions}</span>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmable = (action, arg, label, enabled = true) => {
|
||||||
|
const isArmed = armed === `${action}:${arg}`;
|
||||||
|
return `<button class="brass ${isArmed ? "brass--armed" : ""}" data-action="${action}" data-arg="${arg}" ${enabled ? "" : "disabled"}>${isArmed ? t("load.sure") : label}</button>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const rows = [];
|
||||||
|
const auto = autosave();
|
||||||
|
rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : ""));
|
||||||
|
for (let n = 1; n <= SLOT_COUNT; n++) {
|
||||||
|
const entry = readSave(storage, KEYS.slot(n));
|
||||||
|
rows.push(slotRow(t("load.slot", { n }), entry,
|
||||||
|
entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : ""));
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<h3>${t("load.title")}</h3>
|
||||||
|
${mode === "game" ? `<p class="menu__sub">${t("load.replace")}</p>` : ""}
|
||||||
|
${notice()}
|
||||||
|
<ul class="menu__slots">${rows.join("")}</ul>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const rows = [];
|
||||||
|
for (let n = 1; n <= SLOT_COUNT; n++) {
|
||||||
|
const entry = readSave(storage, KEYS.slot(n));
|
||||||
|
rows.push(slotRow(t("load.slot", { n }), entry,
|
||||||
|
confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here"))));
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<h3>${t("save.title")}</h3>
|
||||||
|
${notice()}
|
||||||
|
<ul class="menu__slots">${rows.join("")}</ul>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportScreen() {
|
||||||
|
return `
|
||||||
|
<h3>${t("export.title")}</h3>
|
||||||
|
<p class="menu__sub">${t("export.hint")}</p>
|
||||||
|
<textarea class="menu__text" id="export-text" readonly rows="8" spellcheck="false">${esc(exportText)}</textarea>
|
||||||
|
${notice()}
|
||||||
|
<div class="menu__row">
|
||||||
|
${button("copy", t("export.copy"))}
|
||||||
|
${button("download", t("export.download"))}
|
||||||
|
</div>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importScreen() {
|
||||||
|
return `
|
||||||
|
<h3>${t("import.title")}</h3>
|
||||||
|
<p class="menu__sub">${t("import.hint")}${mode === "game" ? ` ${t("load.replace")}` : ""}</p>
|
||||||
|
<textarea class="menu__text" id="import-text" rows="8" spellcheck="false" placeholder="CG1:…"></textarea>
|
||||||
|
${notice()}
|
||||||
|
<div class="menu__row">
|
||||||
|
<label class="brass brass--wide menu__file">${t("import.file")}<input type="file" id="import-file" accept=".sav,.txt,text/plain" hidden></label>
|
||||||
|
${button("doImport", t("import.go"))}
|
||||||
|
</div>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settings() {
|
||||||
|
const s = getSettings();
|
||||||
|
const lang = getLang();
|
||||||
|
return `
|
||||||
|
<h3>${t("settings.title")}</h3>
|
||||||
|
<div class="menu__field">
|
||||||
|
<span>${t("settings.language")}</span>
|
||||||
|
<div class="menu__row">
|
||||||
|
<button class="brass" data-action="lang" data-arg="en" aria-pressed="${lang === "en"}">English</button>
|
||||||
|
<button class="brass" data-action="lang" data-arg="fr" aria-pressed="${lang === "fr"}">Français</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="menu__field menu__check">
|
||||||
|
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
|
||||||
|
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
|
||||||
|
</label>
|
||||||
|
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credits() {
|
||||||
|
return `
|
||||||
|
<h3>${t("credits.title")}</h3>
|
||||||
|
<p>${t("credits.fiction")}</p>
|
||||||
|
<p>${t("credits.tech")}</p>
|
||||||
|
<p>${t("credits.fonts")}</p>
|
||||||
|
${back()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="menu__folder">
|
||||||
|
<h2 class="inbox__tab">${t("menu.secret")}</h2>
|
||||||
|
<span class="stamp menu__stamp" aria-hidden="true">${t("menu.secret")}</span>
|
||||||
|
<div class="menu__sheet">${SCREENS[screen]()}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
|
||||||
|
async function copyExport() {
|
||||||
|
const box = $(root, "#export-text");
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(exportText);
|
||||||
|
say(t("export.copied"));
|
||||||
|
} catch {
|
||||||
|
box.select();
|
||||||
|
say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok");
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const state = getState();
|
||||||
|
const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game";
|
||||||
|
const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${slug}-day${state.day}.sav`;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doImport() {
|
||||||
|
const text = $(root, "#import-text").value;
|
||||||
|
if (!text.trim()) {
|
||||||
|
say(t("import.error.empty"), "error");
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decoded = decodeSave(text);
|
||||||
|
const r = decoded.ok ? restoreState(decoded.state, content) : decoded;
|
||||||
|
if (!r.ok) {
|
||||||
|
say(t("import.error." + r.error), "error");
|
||||||
|
const keep = text;
|
||||||
|
render();
|
||||||
|
$(root, "#import-text").value = keep;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onLoadState(r.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const id = $(root, "#new-scenario").value;
|
||||||
|
const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they";
|
||||||
|
const name = $(root, "#new-name").value.trim();
|
||||||
|
handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
continue: () => loadEntry(autosave()),
|
||||||
|
new: () => go("new"),
|
||||||
|
load: () => go("load"),
|
||||||
|
save: () => go("save"),
|
||||||
|
export: () => { exportText = encodeSave(getState()); go("export"); },
|
||||||
|
import: () => go("import"),
|
||||||
|
settings: () => go("settings"),
|
||||||
|
credits: () => go("credits"),
|
||||||
|
back: () => go("home"),
|
||||||
|
resume: () => handlers.onResume(),
|
||||||
|
quit: () => handlers.onQuit(),
|
||||||
|
start,
|
||||||
|
copy: copyExport,
|
||||||
|
download,
|
||||||
|
doImport,
|
||||||
|
lang: (arg) => { handlers.onSettings({ lang: arg }); render(); },
|
||||||
|
loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))),
|
||||||
|
deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); },
|
||||||
|
saveSlot: (arg) => {
|
||||||
|
const ok = writeSave(storage, KEYS.slot(arg), getState());
|
||||||
|
disarm();
|
||||||
|
say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error");
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Destructive actions need a second click on the same button.
|
||||||
|
const NEEDS_CONFIRM = {
|
||||||
|
loadEntry: () => mode === "game",
|
||||||
|
deleteSlot: () => true,
|
||||||
|
saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null,
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
const el = e.target.closest("[data-action]");
|
||||||
|
if (!el || el.disabled) return;
|
||||||
|
const { action, arg } = el.dataset;
|
||||||
|
const key = `${action}:${arg}`;
|
||||||
|
if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) {
|
||||||
|
arm(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disarm();
|
||||||
|
ACTIONS[action](arg);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener("change", async (e) => {
|
||||||
|
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||||
|
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||||
|
$(root, "#import-text").value = await e.target.files[0].text();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (root.hidden || e.key !== "Escape") return;
|
||||||
|
if (screen !== "home") go("home");
|
||||||
|
else if (mode === "game") handlers.onResume();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
show(nextMode, nextScreen = "home") {
|
||||||
|
mode = nextMode;
|
||||||
|
screen = nextScreen;
|
||||||
|
message = null;
|
||||||
|
disarm();
|
||||||
|
render();
|
||||||
|
root.hidden = false;
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
root.hidden = true;
|
||||||
|
root.innerHTML = "";
|
||||||
|
disarm();
|
||||||
|
},
|
||||||
|
// Re-render after the language changed.
|
||||||
|
refresh() {
|
||||||
|
if (!root.hidden) render();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Daily newspaper: headlines picked from templates by how much the indicators
|
||||||
|
// moved over the last few weeks. Selection is deterministic (hash of week + key)
|
||||||
|
// and never touches the game RNG, so reading the paper can't change the game.
|
||||||
|
|
||||||
|
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
|
||||||
|
import { IND_IDS, dateOfDay } from "../engine/index.js";
|
||||||
|
import { hash, $ } from "./desk.js";
|
||||||
|
|
||||||
|
const IND_THRESHOLD = 4; // points moved over the window to make news
|
||||||
|
const VAR_THRESHOLD = { growth: 0.4, unemployment: 0.4, inflation: 0.5, debtRatio: 3 };
|
||||||
|
|
||||||
|
// Each entry: [template key, test on the live state, severity score]
|
||||||
|
const LEVELS = [
|
||||||
|
["unrest.high", (s, f) => s.indicators.unrest >= 50, 1.7],
|
||||||
|
["opinion.low", (s, f) => s.indicators.opinion <= 30, 1.6],
|
||||||
|
["debt.high", (s, f) => f.debtRatio >= 100, 1.6],
|
||||||
|
["stability.low", (s, f) => s.indicators.stability <= 35, 1.6],
|
||||||
|
["civilRights.low", (s, f) => s.indicators.civilRights <= 25, 1.5],
|
||||||
|
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
|
||||||
|
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
|
||||||
|
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
|
||||||
|
];
|
||||||
|
|
||||||
|
function candidates(state, tpl) {
|
||||||
|
const out = [];
|
||||||
|
const h = state.history;
|
||||||
|
const n = h.t.length;
|
||||||
|
if (n >= 2) {
|
||||||
|
const back = Math.min(4, n - 1);
|
||||||
|
const consider = (group, id, threshold) => {
|
||||||
|
const s = h.series[id];
|
||||||
|
const d = s[n - 1] - s[n - 1 - back];
|
||||||
|
const dir = d > 0 ? "up" : "down";
|
||||||
|
const tpls = tpl[group][id]?.[dir];
|
||||||
|
if (tpls && Math.abs(d) / threshold >= 1) out.push({ score: Math.abs(d) / threshold, key: `${group}.${id}.${dir}`, tpls });
|
||||||
|
};
|
||||||
|
for (const id of IND_IDS) consider("ind", id, IND_THRESHOLD);
|
||||||
|
for (const [id, thr] of Object.entries(VAR_THRESHOLD)) consider("var", id, thr);
|
||||||
|
}
|
||||||
|
const debtRatio = h.series.debtRatio?.[n - 1] ?? 0;
|
||||||
|
const deficit = h.series.deficit?.[n - 1] ?? 0;
|
||||||
|
for (const [key, test, score] of LEVELS) {
|
||||||
|
if (tpl.level[key] && test(state, { debtRatio, deficit })) out.push({ score, key: `level.${key}`, tpls: tpl.level[key] });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeHeadlines(state, tpl) {
|
||||||
|
const week = Math.floor(state.day / 7);
|
||||||
|
const pick = (tpls, key) => tpls[hash(`${key}:${week}`) % tpls.length];
|
||||||
|
|
||||||
|
if (state.history.t.length < 2 && state.day === 0) return [pick(tpl.intro, "intro")];
|
||||||
|
const found = candidates(state, tpl);
|
||||||
|
if (!found.length) return [pick(tpl.quiet, "quiet")];
|
||||||
|
return found.slice(0, 3).map((c) => pick(c.tpls, c.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNewspaper(root, tpl) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="news__mast">
|
||||||
|
<h2>${tx(tpl.name)}</h2>
|
||||||
|
<div class="news__edition"></div>
|
||||||
|
</header>
|
||||||
|
<article class="news__lead"><h3></h3><p></p></article>
|
||||||
|
<div class="news__side"></div>`;
|
||||||
|
|
||||||
|
let shown = "";
|
||||||
|
|
||||||
|
function update(state) {
|
||||||
|
// The date line follows the calendar; headlines only change with the weekly history sample.
|
||||||
|
$(root, ".news__edition").textContent = t("news.edition", { date: dateParts(dateOfDay(state.startDate, state.day).iso).full });
|
||||||
|
|
||||||
|
const week = Math.floor(state.day / 7);
|
||||||
|
const key = `${week}|${getLang()}|${state.history.t.length}`;
|
||||||
|
if (key === shown) return;
|
||||||
|
shown = key;
|
||||||
|
|
||||||
|
const fill = (s) => s.replaceAll("{country}", state.countryName);
|
||||||
|
const [lead, ...rest] = computeHeadlines(state, tpl);
|
||||||
|
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
|
||||||
|
$(root, ".news__lead p").textContent = fill(tx(lead.d));
|
||||||
|
|
||||||
|
const side = $(root, ".news__side");
|
||||||
|
side.replaceChildren(...rest.map((item) => {
|
||||||
|
const a = document.createElement("article");
|
||||||
|
const h = document.createElement("h4");
|
||||||
|
const p = document.createElement("p");
|
||||||
|
h.textContent = fill(tx(item.h));
|
||||||
|
p.textContent = fill(tx(item.d));
|
||||||
|
a.append(h, p);
|
||||||
|
return a;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Urgent telegrams: the game warns that a way of losing power is getting close.
|
||||||
|
// They slide in over the desk and time stays stopped until the player acknowledges.
|
||||||
|
|
||||||
|
import { t, tx } from "../i18n/i18n.js";
|
||||||
|
import { $, esc } from "./desk.js";
|
||||||
|
|
||||||
|
export function createTelegram(root, { alerts, onDismiss }) {
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("button[data-dismiss]")) onDismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
let shown = "";
|
||||||
|
|
||||||
|
function update(state) {
|
||||||
|
const alert = state.pending.alert && !state.over ? state.alerts[0] : null;
|
||||||
|
const key = alert ? `${alert.kind}:${alert.day}` : "";
|
||||||
|
if (key === shown) return;
|
||||||
|
shown = key;
|
||||||
|
|
||||||
|
if (!alert) {
|
||||||
|
root.hidden = true;
|
||||||
|
root.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = alerts[alert.kind];
|
||||||
|
root.innerHTML = `
|
||||||
|
<article class="telegram" role="alertdialog" aria-labelledby="telegram-title">
|
||||||
|
<header class="telegram__head">
|
||||||
|
<span>${t("alert.urgent")}</span>
|
||||||
|
<span>${state.alerts.length > 1 ? `+${state.alerts.length - 1}` : ""}</span>
|
||||||
|
</header>
|
||||||
|
<h3 id="telegram-title">${esc(tx(text.title))}</h3>
|
||||||
|
<p>${esc(tx(text.body))}</p>
|
||||||
|
<button class="brass brass--wide" data-dismiss>${t("alert.ack")}</button>
|
||||||
|
</article>`;
|
||||||
|
root.hidden = false;
|
||||||
|
$(root, "button").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user