Gave the world spies, treaties and nuclear weapons

This commit is contained in:
2026-09-24 19:23:54 +02:00
parent 54efd66a63
commit cc5c57c5f4
85 changed files with 7219 additions and 260 deletions
+5 -2
View File
@@ -50,13 +50,16 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
- The server creates its game at startup; add `--testing` to
`node server/server.js --testing` to make it a test game, where every city's
train and build rows gain a second "Free & instant" button.
train and build rows gain a second "Free & instant" button and every technology
card an "Unlock now" button.
- The free shortcut is gated on the server, never trusted from the client:
`server/server.js` strips a `free` flag from any non-loopback order
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), and `GameServer`
honours it only while `this.testing` is set.
`GameState.requestTrain/requestBuild(..., free)` then spawn or raise
immediately without cost or queue (placement rules still apply).
immediately without cost or queue (placement rules still apply), and
`GameState.unlockTechnology(civ, index)` grants a technology outright
(prerequisites included).
- The `testing` flag is shipped in the snapshot so joiners see the same buttons.
## Gotchas
+197
View File
@@ -0,0 +1,197 @@
# Air movement — design brief
A working document for a session on making **aircraft fly in straight lines**
instead of following the hex grid.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model; `server/game_server.js` owns one and validates orders;
`server/server.js` drives the clocks (movement every tick, an in-game hour per
`SECONDS_PER_HOUR`).
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
(no Node, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and `Object.assign`s its methods on. A new
method file must be imported **and** added there.
- **Movement is continuous** and the browser **predicts** it: the client
extrapolates each unit along its snapshot path and mirrors `find_path` in
`client/js/map_view/motion.js`. **Any pathfinding or movement-cost change must
be mirrored on both sides**, or prediction fights the server.
- **Roads** (`shared/roads.js`, `GameState.roads`) flatten tiles' movement cost
on both sides via `_tileTravelHours` / `_stepHours`.
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- Server: `node server/server.js --port 27015 --bind 127.0.0.1`.
### Conventions
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>` when art is
missing.
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
`tests/framework/helpers.js`. Force RNG with `state._random = () => value`.
- Visual changes get a minimal standalone HTML preview linking
`client/css/style.css`, opened in Firefox, before commit.
---
## 2. How air works today
Aircraft are ordinary tile-bound units that happen to ignore terrain obstacles:
- `shared/data/units.js`: `jet_fighter` and `bomber` have `air: true`,
`speed: 5`, `traversableTerrains: ["Land", "Sea", "Ice"]`, `enduranceHours`
(10 / 20) and `missionRange` (80 / 100), plus `requiresBuilding: "airport"`.
`bomber.strike = true`. Missiles (`missile`, `icbm`) are `strike` units too but
not `air`.
- `shared/game_state/air.js`:
- `_isAirProto` / `_isAirUnit`; `_isStrikeUnit` (any aircraft, or `strike`, or
range > 1).
- `_friendlyAirportAt`, `_returnAirport` (nearest friendly airport to the
heading), airport-only rebasing.
- `_pathHours`, `_canStrikeAndReturn` (whole round trip must fit remaining
fuel), `_strikeReady` (parked, fuelled, off cooldown `strikeReadyHour`).
- `requestAirStrike(unitIds, goal)`: a **sortie** — the plane flies to the
target, bombs once on arrival in `advanceMovement`, then returns to a
friendly airport, where landing arms the `AIR_STRIKE.cooldownHours` cooldown.
- Hourly `_tickAir` (or equivalent) burns one hour of endurance per airborne
aircraft, refuels/lands those parked at an airport, crashes those that ran
dry, and sends idle planes home.
- `shared/game_state/movement.js` `_canUnitEnter`: aircraft skip terrain-class
and foreign-territory checks (they fly over fog and borders); only the tile's
existence and terrain class constrain them. Ferry (rebasing) is bounded by
fuel, not `missionRange`.
- Client: `client/js/map_view/motion.js` extrapolates the snapshot path and
predicts local move orders; `map_view/motion.js` already has `_nearestCopy`
for the cylindrical wrap and strike-target line drawing.
The path is still a list of tile-centre waypoints (from `find_path`), so on the
map a plane visibly zig-zags along hexes, and its fuel/time come from summed
per-tile `_stepHours`.
---
## 3. Requirement (from `ROADMAP.md`)
> - [ ] Airplanes move in straight lines as they are not bound by tiles.
---
## 4. Design space (settle before coding)
1. **A straight-line path representation.** The simplest change: produce, for an
air move/sortie, a path of **two world-pixel points** — origin centre to
destination centre — instead of tile waypoints. The continuous mover already
advances along path segments, so a 2-point path is a straight line for free.
Where is the helper? It must be shared so the client prediction draws the
same line: a pure function (e.g. `airPath(from, to, topology)` returning pixel
points, using `topology.pixelDelta` for the shortest wrapped copy) in
`shared/` (hex.js or air.js), called by both `GameState` and `motion.js`.
2. **Distance and time on a straight line.** Fuel is in hours; convert pixel
distance to tiles (`hypot(delta) / maxStepLength`) then to hours at the unit's
speed, matching `_pathHours`'s units. Decide whether air ignores roads and
terrain multipliers entirely (yes — it already effectively does, since it
flies) and whether the speed is tiles/hour directly.
3. **Destination semantics.** A straight line ends at the destination tile
centre. For a move order, the plane ends on that tile (then `_tickAir` sends
it home if it is not an airport — or must a move order only target an
airport? Today rebasing is airport-only; confirm). For a sortie, the plane
arrives at the target tile centre, bombs, turns for the nearest airport.
4. **Interception / air defense.** Combat is tile-based: `AIR_DEFENSE` fires on
aircraft "near" a tile. With a straight line, does air defense trigger when
the segment passes within range of an AA/`aa_building`, or only when the
endpoint is on/near it? This is the biggest open question — it changes both
the model and the client's strike preview. Options: (a) check the tiles the
segment crosses (sample the line at tile centres); (b) treat a strike as
still resolved at the target tile only; (c) add a continuous "flak corridor".
5. **Strike radius and range.** `missionRange` and `range` are tile counts;
keep them, but measure them along the straight line (tile distance between
endpoints) rather than path length. Round-trip fuel uses the straight-line
distance to target plus straight-line distance home.
6. **The cylindrical map.** A straight line must take the shortest wrapped copy
and be drawn across the seam. `topology.pixelDelta` gives the shortest offset;
the renderer needs the same wrapped copy (`_nearestCopy`) so the plane icon
does not jump. A "straight line" across the seam may be a line that leaves one
edge and re-enters the other.
7. **Rendering.** The icon layer positions units along the path; a 2-point path
is trivial, but the strike/bombard arc drawing and the dashed target line may
need to follow the wrapped copy. Check `client/js/map_view/motion.js` and the
entity icon code.
8. **Zone of control / territory.** Irrelevant to aircraft (they already ignore
both). Make sure the new path code does not accidentally reintroduce the
tile-based `_canUnitEnter` checks for air.
9. **Missiles.** `missile`/`icbm` are `strike` but not `air`; decide whether the
straight-line rule applies to them too (they are "fired, not marched", so
likely yes for their strike flight).
10. **Endurance drift.** The hourly fuel burn is one hour per airborne plane
regardless of distance. With straight lines the time and the fuel must agree;
keep `_pathHours` (or its replacement) the single source for both.
---
## 5. Suggested order of work
1. Add the shared straight-line path helper (with wrap) and unit tests for the
distance/time/wrap maths.
2. Use it for **ferrying** (rebasing) first — the simplest case, no combat.
3. Use it for **sorties** and keep the round-trip fuel check.
4. Decide and implement interception/air-defense semantics (question 4).
5. Mirror in the client prediction and the icon/arc rendering.
6. Tick the roadmap line.
---
## 6. Tests to add
- A straight-line flight between two tiles has the expected time/fuel, including
a flight across the wrap seam (shortest copy).
- A sortie whose round trip fits the fuel is accepted; one that does not is
refused (server and client prediction agree).
- The plane arrives and bombs at the target's hour (headless `advanceMovement`).
- Client `planMove`/prediction yields the same endpoint hour as the server.
- Air defense still triggers under the chosen rule (question 4).
---
## 7. Gotchas
- **Mirror server and client.** The plane's position is extrapolated by the
browser; if the client keeps the old tile path the icon will lag or jump.
- **Fuel units.** `_pathHours` returns in-game hours; keep new distance maths in
the same units or planes will run dry mid-flight.
- **Wrap seam.** Any new geometry must use `topology.pixelDelta` / `_nearestCopy`,
not raw `x2 - x1`.
- **Snapshot path.** Paths are shipped to the client; a 2-point pixel path is
smaller, not larger, but confirm the serialiser accepts non-integer points.
- **`_tickAir` idle-return.** Planes that are not at an airport are sent home
each hour; a straight-line move to a non-airport tile may be immediately
reversed. Confirm the intended behaviour.
---
## 8. Relevant files
- `shared/data/units.js` — aircraft/`strike`/`air`/missile protos.
- `shared/game_state/air.js` — endurance, strikes, airports, cooldown.
- `shared/game_state/movement.js` — `advanceMovement`, `_pathHours`,
`_stepHours`, air exemption in `_canUnitEnter`.
- `shared/hex.js` — `mapToLocal`, `COL_STEP`, `HEX_H`, `MapTopology.pixelDelta`.
- `client/js/map_view/motion.js` — prediction, `_nearestCopy`, strike drawing.
- `server/server.js` — the movement tick.
- `shared/data/combat.js` — `AIR_DEFENSE`, `AIR_STRIKE`.
- Tests: `tests/air_test.js`, `tests/bombing_test.js`, `tests/movement`-related
suites, `tests/map_view_test.js`.
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`,
`TRAINING_UI.md`, `ICONS.md` (repo root).
+23
View File
@@ -0,0 +1,23 @@
# Design docs — index
Standalone briefs for the open work, each written so a new session can pick up
one area without re-reading the codebase. They duplicate the common project
context on purpose.
| Doc | Area |
| --- | --- |
| `INTELLIGENCE.md` | Intelligence tab, spy unit, sabotage, city/comms spying, counter-intelligence, spy satellites, the uranium-enrichment warning. |
| `STACKS.md` | Splitting unit stacks ergonomically; stacks pathfinding as one body with allied/at-war territory walkable. |
| `AIR_MOVEMENT.md` | Airplanes moving in straight lines instead of along hex tiles. |
| `ECONOMY_BALANCE.md` | Making luxury and high-tech world prices settle instead of plateauing. |
| `POLITICS_PERFORMANCE.md` | Speeding up the hourly politics/migration tick with version-keyed memoisation. |
| `TRAINING_UI.md` | Showing what a unit being trained is gathering when the player clicks it. |
| `ICONS.md` | Replacing the placeholder "dummy icon" SVGs with real art. |
| `SIMPLIFICATIONS.md` | The `[~]` roadmap items: deliberate simplifications worth revisiting. |
`ROADMAP.md` remains the authoritative task list; these docs add the *why* and
the design space around the open items.
**Done but not yet ticked in `ROADMAP.md`** (as of writing): the ranged-attack
button for a stack (roadmap §5) and the resource-cost line in the budget table
(roadmap §7) are implemented in the working tree.
+195
View File
@@ -0,0 +1,195 @@
# Economy balance — design brief
A working document for a session on the two commodities whose world-market
prices fail to settle: **luxury** and **high-tech**.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model; `server/game_server.js` owns one and validates orders.
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
(no Node, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and adds its methods to the prototype. A new
method file must be imported **and** added there.
- **The economy ticks hourly** (`shared/game_state/resources.js`
`_tickResources`); the world can be **settled** without players via
`GameState.warmUp(days)` and `warmUpToStability({ maxDays })`, which the
server calls from `configureGame` (the `--warmup` flag). "Steady" means every
commodity within 10% over the trailing seven days (`pricesAreSteady`).
- **Snapshot performance.** The trade graph is cached across ticks and days
under a stamp built from road/railway, territory and storage-node counters;
`getCityEconomy` must not be memoised outside a snapshot. New per-day walks
should be memoised against the counters that actually change them.
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- A quick way to observe prices is the server's startup settle:
`node server/server.js --port 27015 --bind 127.0.0.1` (logs how many days it
settles, `--warmup 0` skips it). There is also `tests/warmup_test.js`.
### Conventions
- 2-space indent; no new libraries.
- Tests in `tests/<topic>_test.js` extending `TestCase`. Force RNG with
`state._random = () => value`.
- Commit only when asked; messages are one imperative sentence; the pre-commit
hook runs the whole suite.
---
## 2. The problem (from `ROADMAP.md`)
> - [ ] Luxury and high-tech prices climb to a plateau far above their base (the
> economy simulator's index reaches about 500-650 for luxury and 200-230
> for high-tech) instead of settling; revisit the supply and demand balance
> of the two rarer goods.
(Index 100 = the base price.) The two rare goods are in permanent structural
shortage, so the equilibrium price is high and the easing never reaches steady.
---
## 3. How the market works today
- **Base prices** (`shared/data/resources.js` `RESOURCE_MARKET_BASE`):
energy 0.051 /kWh, steel 10,200 /t, food 5,100 /t, **luxury 1,020 /carat**,
**high-tech 13,600 /unit**.
- **Market model** (`RESOURCE_MARKET`): each day the imbalance sets an
*equilibrium* — the price that clears supply against demand — and the price
eases toward it by `adjustment` (0.2). `maxRatio` (10) bounds how sharp a
shortage may read (and is also the ratio a zero-supply day is treated as).
- **Demand for the rare goods**:
- luxury: `luxuryPerPersonPerDay: 0.01` carat per person per day.
- high-tech: science buildings spend `highTechPerResearchPoint: 1` unit per
research point, advanced buildings/units pay high-tech upkeep
(`upkeepHighTechPerEuro`), and construction of science/advanced buildings
costs `constructionHighTechPerLevel: 100` per level.
- **Supply**: gold/diamond mines (`energyPerLuxuryCarat`), chip foundries
(`energyPerHighTechUnit`). The opening works are generated from the world seed
and are *fixed* per map; nothing in normal play adds supply except a player
raising more works.
- **Energy gates supply**: every unit of luxury/high-tech costs a large amount of
electricity, and food synthesis competes for the same power
(`foodSynthesisQuadratic`). So a tight electricity balance throttles rare-good
supply from above.
- **Food** is the one good a region synthesises from energy, with a reserve-based
price signal (`_tickResources` grows toward
`need + (stockpileTarget - store) - harvest`; `_consumeCityResources` bids the
empty-pantry appetite). Rare goods have no such synthesis.
---
## 4. Why it plateaus (hypotheses to test)
1. **Demand scales with population/research, supply does not.** Luxury demand
grows with every person; high-tech demand grows with research points and the
advanced-building stock. The seeded mine/foundry supply is a fixed number, so
a mid-game world is structurally short and `maxRatio` caps the price high.
2. **The equilibrium is computed, but the price only eases 20% a day**, so if the
equilibrium itself is far above base the plateau is simply the equilibrium.
3. **No elasticity on demand.** A very high rare-good price should tempt someone
to build more mines/foundries; the AI may not, and the player may not either,
so supply never answers the signal.
4. **Energy competition.** The power needed for more supply may be going to food
synthesis, pinning supply.
5. **Stockpile behaviour.** Cities aim to hold `stockpileDays: 30` of luxury and
high-tech, so a one-off demand spike is absorbed then restocked, keeping
demand elevated.
---
## 5. Design space (settle before coding)
1. **What is the target?** Should luxury/high-tech settle near their base (index
~100), or is a moderate premium intended (they *are* rare)? The roadmap says
"settle", implying they should stop climbing; a stable plateau near, say,
120-200 may be acceptable. Decide the target band first, then tune.
2. **Make the equilibrium self-correcting.** Options: relax `maxRatio` so a
shortage cannot pin the read; make the equilibrium elastic in supply
(higher price → modelled extra production) so the price cannot run away;
or add a slow mean-reversion term toward base when demand is met.
3. **Add synthesis for the rare goods** (like food) so energy can always buy
more at a rising marginal cost — the food model is the template
(`foodSynthesisQuadratic`).
4. **Retune the physical ratios** (`energyPerLuxuryCarat`,
`energyPerHighTechUnit`, per-person luxury, upkeep/high-tech coefficients) so
the world's seeded supply covers its steady-state demand.
5. **Seed more supply** at world generation (more mines/foundries) — a blunt fix,
but it changes the opening economy, so measure with the warm-up.
6. **Cap the demand side**: e.g. research high-tech spend or advanced upkeep
scaled down.
7. **Warm-up as the acceptance test.** After tuning, `warmUpToStability` should
stop well under the cap with luxury/high-tech within the 10%/7-day band.
---
## 6. Suggested order of work
1. Write a small measurement harness (a test or script) that runs
`warmUpToStability` on the standard map and prints the final price indices for
all five goods and the day count. Use it for every change.
2. Identify whether the equilibrium is the plateau (hypothesis 2) by logging
supply/demand/equilibrium for the two goods over the settle.
3. Pick the mechanism (synthesis, elastic equilibrium, retune, or reseed) and
tune to the target band.
4. Re-run `warmup_test.js` and the economy suites; add a regression test that the
rare-good indices stay within a band after a fixed settle.
5. Tick the roadmap line.
---
## 7. Tests to add / extend
- A test that settles the standard map and asserts each good's price index is
within a stated band (e.g. luxury and high-tech both < some multiple of base)
and that `pricesAreSteady` holds.
- Unit tests for any new synthesis curve or elastic-equilibrium function
(monotonic, as expected).
- Keep `tests/warmup_test.js`, `economy_test.js`, `trade_graph_test.js`,
`resources_test.js`, `stockpile_test.js`, `food_synthesis_test.js`,
`food_power_test.js` green.
---
## 8. Gotchas
- **Warm-up cost.** `warmUpToStability` runs real simulation days; a tuning that
makes it run to the cap will slow every server start and the tests. Keep the
settle bounded.
- **The trade graph cache** is keyed on network/territory/storage counters, not
tile improvements; do not fold new counters into its stamp without reason.
- **`getCityEconomy` must not be memoised outside a snapshot**; a test pins that
a simulation read reflects a direct `tilePopulation` edit at once.
- **Prices are global** — a change helps/hurts every nation at once; watch the
affordability of construction/upkeep, which is priced off the same figures.
- **Energy is deliberately cheap** (`base.energy: 0.051`); do not raise it
casually, or every material's energy bill dwarfs its value.
---
## 9. Relevant files
- `shared/data/resources.js` — base prices, `RESOURCE_MARKET`, `RESOURCE_RULES`
(all the ratios), `PRICE_INDEX_WINDOW_DAYS`, `FAMINE`.
- `shared/game_state/resources.js` — `_tickResources`, `_consumeCityResources`,
supply/demand/equilibrium, food synthesis, `payCombatResources`.
- `shared/game_state/economy.js` — production, stockpiles, construction costs.
- `server/server.js` / `server/game_server.js` — `configureGame` warm-up.
- Tests: `tests/warmup_test.js`, `tests/economy_test.js`,
`tests/resources_test.js`, `tests/stockpile_test.js`,
`tests/trade_graph_test.js`, `tests/food_synthesis_test.js`.
## 10. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `POLITICS_PERFORMANCE.md`,
`TRAINING_UI.md`, `ICONS.md` (repo root).
+173
View File
@@ -0,0 +1,173 @@
# Icons / art — design brief
A working document for a session on replacing the placeholder **"dummy icon"**
SVGs with real art.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative**, but art is purely client-side: SVGs live in
`client/assets/` and are referenced by filename from `shared/data/*.js`
(`icon: "icon_<name>.svg"`) and a few `client/assets/` references in JS.
- **No build step and no image pipeline** — dropping an `.svg` in
`client/assets/` and referencing its filename is the whole process. The
`nix/package.nix` `web` output copies `client/` and `shared/` verbatim.
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- Server + client: `node server/server.js --port 27015 --bind 127.0.0.1`.
### Conventions
- A missing icon gets a dummy SVG reading `dummy icon - <name>` (the standing
rule in `AGENTS.md`/`ROADMAP.md`).
- **Visual changes are reviewed by eye before commit.** For icons, build a small
standalone HTML gallery that links `client/css/style.css` and shows the icons
at the sizes the UI actually uses, then **open it in Firefox**
(`firefox <path>`). Do not screenshot and do not just print the path.
- 2-space indent for `.js`; no new libraries.
- Commit only when asked; one imperative sentence; the hook runs the full suite.
---
## 2. Requirement (from `ROADMAP.md`)
> - [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
> `icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
> `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
> `icon_wind_turbines.svg`.
Note: `icon_radar.svg`, `icon_coastal_cannon.svg`, `icon_chip_foundry.svg` and
`icon_wind_turbines.svg` no longer contain the word "dummy" (they appear to have
been replaced already), so the list may be partly stale — **re-derive the live
list** with the command below before starting.
---
## 3. Current placeholders
Find them with:
```sh
rg -il "dummy" client/assets/*.svg
```
As of this writing that lists (subject to change):
- `icon_computer.svg`, `icon_human.svg` — player-list control icons (diplomacy
tab; used at 20px).
- `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg` — power plants.
- `icon_icbm.svg`, `icon_missile.svg` — missiles.
- `icon_spy.svg` — the spy unit.
- `icon_submarine.svg` — the submarine.
- `icon_uranium_enrichment.svg` — the nuclear building.
The dummy template (all of them share it):
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">spy</text>
</svg>
```
---
## 4. Art direction
- **Canvas:** `viewBox="0 0 512 512"`, square. Keep the `style="height:512px;width:512px"`
attribute the other assets use.
- **Reads small.** Icons render at roughly **20-34 px** — `.stack-type-icon` 22,
`.stack-unit-icon` 26, `.bt-top .building-icon` 34, `.row-card .building-icon`
34, `.control-icon` 20, `.value .value-icon` 13. A single strong silhouette
beats detail; avoid thin strokes, fine text and tiny gradients.
- **Flat, high-contrast vector** in the game's palette (see the existing icons
for the house style, e.g. `icon_battleship.svg`, `icon_jet_fighter.svg`,
`icon_modern_infantry.svg`). A dark plate behind the subject helps it read on
the map and in the panel, as the dummy does.
- **No text** in real icons. Transparent or plate background, consistent with
neighbours.
- **Specifics to capture:** a nuclear trefoil for the enrichment centre; a
country silhouette / masked figure for the spy; a fish-like hull for the
submarine; a rocket with fins for the missile and an intercontinental variant
for the ICBM; a globe/bust for human vs. a monitor/chip for computer; a
geyser for geothermal; an offshore turbine on its platform vs. an onshore
turbine.
---
## 5. Workflow per icon
1. Draw the SVG (any editor; keep it hand-editable, no embedded bitmaps).
2. Save as `client/assets/icon_<name>.svg` — **same filename**, so no data file
change is needed. If a filename must change, update the `icon` field in the
matching `shared/data/*.js` entry (units, buildings, resources, improvements,
technologies, ...).
3. Build a small gallery HTML (below) linking `client/css/style.css`, showing
every changed icon at the real sizes, on the real backgrounds.
4. Open it in Firefox and show the user; only commit once approved.
5. Run `node tests/run_tests.js --file data_test.js` (it asserts some icons are
`.svg`) and any suite that references the icon.
### Gallery skeleton
```html
<!doctype html><meta charset="utf-8">
<link rel="stylesheet" href="file:///home/adrien/strategy-game/client/css/style.css">
<body style="background:#05070c;color:#e8eef7;font:14px sans-serif;padding:24px">
<h1>Sizes: 20 / 22 / 26 / 34 px</h1>
<div style="display:flex;gap:24px;align-items:flex-end">
<!-- per icon: <img src="file:///home/.../client/assets/icon_x.svg" style="width:34px"> etc. -->
</div>
</body>
```
---
## 6. Tests to add / extend
- `tests/data_test.js` already checks a few `icon` fields end in `.svg` and are
non-empty; extend it to cover the newly drawn assets (units, buildings,
resources) so a missing/renamed file is caught.
- No pixel tests — icons are reviewed by eye, per the workflow above.
---
## 7. Gotchas
- **The filename is the contract.** Most data entries reference the file by
string; renaming without editing the data leaves a broken `<img>` that only
shows in the browser, not in tests.
- **Case and path.** Assets are served from the repo root as `/client/...`;
`client/js` references them as `assets/icon_x.svg` relative to the page. Keep
lowercase `snake_case` filenames.
- **Small-size legibility** is the acceptance test, not how good it looks at
512px. Zoom the gallery down to 20px before judging.
- **Nix packaging** copies the tree, so no manifest needs updating — but a new
file must be committed for the deployed build to include it.
- A serving cache may hide a replaced file; hard-reload when checking in-game.
---
## 8. Relevant files
- `client/assets/` — all icons (this is where art goes).
- `shared/data/units.js`, `buildings.js`, `resources.js`, `improvements.js`,
`technologies.js` — the `icon:` references.
- `client/css/style.css` — icon display sizes.
- `client/index.html` — where icons appear in the HUD/modals.
- `tests/data_test.js` — icon sanity checks.
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md` (repo root).
+602
View File
@@ -0,0 +1,602 @@
# Intelligence — design brief
The **Intelligence** feature: its tab, the spy unit, covert actions (sabotage,
city spying, comms spying), counter-intelligence and spy satellites. This
document began as the design brief and now also records what was built. All
phases are implemented; the status list in §2 is the authoritative summary and
§5 records the design decisions each open question settled to.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model; `server/game_server.js` (`GameServer`) owns one and
validates orders; `server/server.js` is the transport.
- **Shared logic runs in both Node and the browser.** `shared/` must stay
framework-free: no Node built-ins, no DOM. `server/websocket.js` is the only
place Node built-ins are expected. The browser is a view
(`client/js/map_view.js` and friends) that rebuilds terrain from the shared
seed and renders snapshots.
- **GameState is composed of mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and `Object.assign`s its methods onto the
prototype (world, territory, economy, movement, combat, air, status,
diplomacy, orders, visibility, serialization, politics, treaties, wmd, ...).
A new method file must be imported **and** added to that `Object.assign`.
- **Data-driven content** lives in `shared/data/` (one module per topic:
`units.js`, `buildings.js`, `technologies.js`, `relations.js`, ...).
`shared/data.js` is a barrel — import from it, edit the file under
`shared/data/`.
- **Client mirroring.** Movement, pathfinding and movement-cost changes must be
mirrored between `shared/game_state/movement.js` and the browser's prediction
in `client/js/map_view/motion.js`, or prediction fights the server.
- **Snapshot performance.** `_broadcastState` rebuilds the snapshot whenever the
state is dirty (orders, hourly ticks). Large collections are versioned in
`DELTA_COLLECTIONS` (`server/game_server.js`) — if you add a big snapshot
collection, pair it with a version counter or it is re-sent whole on every
broadcast. Walks are memoised against version counters (`_populationVersion`,
`_territoryVersion`, `_modifiersVersion`, `_tileImprovementVersion`,
`_gdpEpoch`, `_visibleVersion`, ...).
### Commands
- Install dev deps once: `npm install` (only `c8` and `jsdom` for tests).
- Run one suite: `node tests/run_tests.js --file <name>.js` (note the `.js`).
**Never run the full suite during development** — the commit hook does that.
- Server + client locally:
`node server/server.js --port 27015 --bind 127.0.0.1`, then open
`http://127.0.0.1:27015/`. Add `--testing` for the free/instant buttons.
- If `node` is missing: `nix-shell -p nodejs --run "node tests/run_tests.js"`.
### Conventions
- 2-space indent for `.js`. No new libraries (only vendored jQuery under
`client/vendor/`).
- Missing icon → add a dummy SVG reading `dummy icon - <name>`.
- Tests live in `tests/<topic>_test.js`, extend `TestCase`
(`tests/framework/`), and are discovered automatically. Fixtures:
`tests/framework/helpers.js` (`smallState()`, `defaultState()`,
`adjacentLand()`, `seaTile()`, `grantBuilding()`).
- **Visual changes** are reviewed by eye before commit: build a minimal
standalone HTML example, link `client/css/style.css`, copy in the few assets
needed, and **open it in Firefox** (`firefox <path>`). Do not screenshot.
- Commit messages are a single evocative sentence in the imperative.
Only commit when the user asks; the pre-commit hook runs the whole suite.
---
## 2. Where the feature stands
Status legend: `[x]` done, `[~]` done with a simplification, `[ ]` open.
- [x] Intelligence tab exists, split into **Spies** and **Propaganda** sub-tabs.
Propaganda owns the campaigns; Spies owns the viewer's spy **roster** (with
a **Locate** control).
- [x] Spy unit core: data, gating, invisibility, 1-tile vision, travel through
foreign territory.
- [x] Spy operation buttons live in the **unit stack panel** (one button per
operation, from `SPY_OPERATIONS`), shown when a spy is selected.
- [x] Sabotage action: a spy on **foreign land** zeroes that tile's production
for a week. The victim is notified; blame falls on the author, a chosen
scapegoat, or no one, and the victim cannot tell which.
- [x] City spying: a spy beside a foreign city files a report on its people,
buildings and garrison. Counter-intelligence can falsify it.
- [x] Comms spying: a spy beside a foreign capital intercepts the last private
messages sent from and to that nation. Counter-intelligence can garble
them and swap the correspondents.
- [x] Counter-intelligence mechanics: the unlock-only technology now shifts the
blame roll and distorts incoming reports and intercepts.
- [x] Spies are **single-use**: a covert operation spends the whole acting
stack, and each spy past the first blunts the target's counter-
intelligence, so a bigger team is the one lever against it.
- [x] Spy satellites + space launch centre: the building gates a launch, everyone
is told, and each satellite uncovers a random 2-tile patch every two days.
- [x] Foreign intelligence may be warned that a nation is enriching uranium
(a chance roll when the first enrichment centre is raised).
### What is already built
**Tab.** `client/index.html` has a `<button class="tab" data-tab="intelligence">
Intelligence</button>` after Politics and a `#tab-intelligence` page with
`#intelligence-sub`, `#intelligence-subtabs` and the two views. `client/js/modals/nation.js`
builds and refreshes it (`_renderIntelligence`, `_buildIntelligence`,
`_refreshIntelligence`), and `NationModal.show/refresh` take a trailing
`intelligence` argument. `client/js/game_screen/panels.js` supplies it via
`_intelligenceInfo()` → `{ civs, localCiv, totalHours, campaigns, opinions }`.
**Propaganda moved.** The campaign UI (`_fillCampaigns`, `_buildCampaignRow`,
`_buildCampaignForm`, `_syncCampaignReadout`, `_syncCampaignForm`) now lives on
the Intelligence tab's **Propaganda** sub-tab; the DOM state uses
`this.$intelligenceCampaigns` and
`this._intelligenceState.campaign`. Politics keeps populations, migrations,
minority policies and the opinion breakdown. `#campaignSignature` /
`#campaignRows` remain the rebuild guards.
**Sub-tabs (Phase A).** The Intelligence tab is split into two sub-tabs,
**Spies** and **Propaganda**, mirroring the Economy and Resources tabs
(`#intelligence-subtabs` `.subtabs`, `_buildIntelligenceSubtabs` /
`_setIntelligenceView`, and the two `.modal-body` views
`#intelligence-spies-view` / `#intelligence-propaganda-view` in
`client/index.html`). Spies opens first; the campaigns moved into the
Propaganda view. Switching only toggles `.hidden`, so a snapshot refresh never
tears a view down.
**Spy controls (Phase A).** The Spies view holds one card, **Your spies**
(`_buildSpiesCard`, `_fillSpies`, `_hasResearched` in
`client/js/modals/nation.js`). The roster rebuilds only when a spy's id, tile or
location label changes (`_spySignature`); it names each spy by position ("Spy 1")
and its whereabouts ("at Paris", "in home territory", "in Britain", "in
unclaimed territory"). A **Locate** button calls `onLocateUnit(id)`, wired to
`_locateUnit` in `client/js/game_screen/panels.js`, which centres the map,
selects the spy and closes the panel. `_intelligenceInfo()` supplies the roster
as `spies`.
**Covert operations are unit actions, not tab content.** They live in the unit
panel (`#unit-spy-actions`, a row under the pillage/trench/attack buttons),
because an operation is something a *spy* does, tied to the selected stack.
`_updateSpyActions(units)` in `client/js/game_screen/panels.js` shows the row
only when the selection is all spies; `_buildSpyActionButtons()` fills it once
from `SPY_OPERATIONS`. Sabotage enables when the stack stands on a foreign land
tile (`_canSabotage`, mirroring `GameState.canSabotage`); the other two stay
disabled ("Not yet available") until they are wired. The catalogue lives in
`shared/data/intelligence.js` (`SPY_OPERATIONS`, with the id constants
`SPY_SABOTAGE` / `SPY_CITY` / `SPY_COMMS`), re-exported from `shared/data.js`.
**Sabotage (Phase B).** A new model mixin `shared/game_state/intelligence.js`
owns the covert operations; only `canSabotage` / `requestSabotage` /
`isTileSabotaged` / `_tickSabotage` exist so far. The target is **the tile the
spy stands on**, which must be foreign land (`civAt >= 0`, not the spy's own);
no map target-picking is needed. `requestSabotage` records the tile in
`GameState.tileSabotage` (key → the hour the block ends, `SABOTAGE.blockDays`
= 7) and drops the tile's GDP memo. The block reaches the economy through
`productionFactors`' new `sabotaged` flag: a last `×0` factor that zeroes the
tile's per-head production (and the client's tile readout), mirroring how
pillage and trenches already mark a tile. `_tickSabotage` runs in `tickHour` and
drops lapsed blocks, restoring the tile. The block list ships as
`tileSabotage: [[x, y, untilHour], ...]`, rebuilt client-side into
`GameScreen._tileSabotage`. The victim gets a `NEWS_SABOTAGE` feed line
(`describeNews`: "Saboteurs have crippled production in <region>") and, if the
viewer is the victim, a notification; `suspect` names the author, a scapegoat, or is null.
The order is `{ type: "sabotage", units, frame? }`, validated in
`GameServer._handleSabotage` and sent by `_sabotageSelected`. The acting spies
are spent (`_spendSpies`): a spy is single-use, so it is removed quietly (no
news, no notification). A larger acting stack lowers the victim's chance of
identifying the author (`_effectiveCounterIntelligence`).
**Counter-intelligence and blame (Phase C).** `intelligence.js` adds the two
level helpers `_intelligenceLevel` / `_counterIntelligenceLevel` (0 or 1, read
from the researched set via `hasTechnology`) and `_blameCulprit`. The blame roll
starts at `ESPIONAGE.baseIdentifyChance`, rises with the victim's
counter-intelligence and falls with the author's agencies; a `frame` scapegoat
(another nation, chosen in the unit panel's **Blame** picker) sticks with
`ESPIONAGE.frameChance` when the author is not identified. A correct blame and a
successful false flag are the same `suspect` field, so the victim cannot tell
them apart.
**City and comms spying (Phases B/D).** `canCitySpy`/`requestCitySpy` and
`canCommsSpy`/`requestCommsSpy` share `_spyStackAtOneTile` and `_spyWatches`: a
spy can never enter a hostile city, so it watches from the city's own tile or a
neighbour. Reports are stored per nation in `GameState.spyReports` (capped at
`ESPIONAGE.reportLimit`) and shipped only to their owner in `viewerSnapshot` as
`spyReports`; the chat log they are drawn from never ships. A city report lists
the population, building levels and garrison; with the target's
counter-intelligence, values are jittered or nudged (`_jitterCount`). A comms
report lists the last `ESPIONAGE.interceptCount` messages involving the target
(`GameState.messages`, fed by `recordChat`); counter-intelligence garbles the
text and swaps sender/receiver (`_garbleMessage`/`_scramble`). Both appear in
the Intelligence tab's **Intelligence reports** card. Both spend the acting
spies (a spy is single-use) and a larger stack blunts the target's
counter-intelligence when it decides whether to falsify or garble the report.
The target picker reuses
the ranged **Attack** overlay: `_toggleSpyOperation` arms it with
`operation: "spy_city"/"spy_comms"` and `_chooseTarget` dispatches on it.
**Comms store.** `server/server.js` `case "chat"` now takes an optional `to`
nation, telling one peer (the public channel when absent), and records every
line through `GameState.recordChat`. The client's chat gained a recipient
`<select id="chat-target">`; private lines are marked in the log. This is the
one piece of the design that needed a new server-side store (question 6).
**Spy satellites (Phase E).** A new `space_launch_center` building (`mechanic:
"space_launch"`, extremely expensive, dummy icon) gives a nation one launch pad
per level. `requestSatellite(civ, city)` checks the city holds the centre,
pushes a `{ civ, launchedHours, nextRevealHour }` record, and publishes
`NEWS_SATELLITE` to everyone. `_tickSatellites` runs in `tickHour` and calls
`_revealSatelliteChunk`, which adds a random patch of `SATELLITE.revealRadius`
tiles to the owner's `explored` set every `SATELLITE.revealEveryDays` days and
marks visibility dirty. Launches and next-sweep times show in the tab's **Spy
satellites** card.
**Uranium warning (Phase F).** `_completeBuilding` calls `_warnOfEnrichment`
when a nation's first uranium enrichment centre is raised; with
`ESPIONAGE.enrichmentWarningChance` it publishes `NEWS_ENRICHMENT`, which
`describeNews` renders as an intelligence warning.
**Spy unit.** `shared/data/units.js`:
```js
{
id: "spy", advanced: true, name: "Spy", maxHp: 100, cost: 250_000_000,
materialUpkeep: { steel: 0, energy: 2_000, hightech: 3 }, // mostly high-tech
moveable: true, speed: 0.5, military: false, attack: 0, defense: 0,
range: 0, vision: 1, spy: true,
traversableTerrains: ["Land"],
requiresTechnology: "intelligence_agencies",
icon: "icon_spy.svg",
}
```
`spy` is in `TRAINABLE_UNIT_IDS`. Visibility: `shared/game_state/visibility.js`
`_hiddenEnemyUnits` hides any unit with `proto.spy` from every viewer except its
owner. Movement: `shared/game_state/movement.js` `_canUnitEnter` lets a spy
cross foreign territory (mirrored in `client/js/map_view/motion.js`
`_canEnter`), so the foreign-territory wall does not apply to it.
**Technologies.** `shared/data/technologies.js`:
```js
{ id: "intelligence_agencies", name: "Intelligence Agencies", theme: "society",
cost: 250, prerequisites: ["scientific_method"], unlocks: true, effects: [] },
{ id: "counter_intelligence", name: "Counter-Intelligence", theme: "society",
cost: 1_500, prerequisites: ["intelligence_agencies"], unlocks: true, effects: [] },
```
`unlocks: true` marks a technology whose value is content, not a stat;
`tests/data_test.js` exempts those from the "every tech has effects" rule.
`counter_intelligence` currently has **no mechanics** — wiring it is part of
this feature.
**Icon.** `client/assets/icon_spy.svg` is a placeholder (`dummy icon - spy`).
**Tests.** `tests/spy_test.js` (4) covers the proto/gating, the covert-operation
catalogue, invisibility, and foreign-territory travel. `tests/game_screen_test.js` has
`test_intelligence_tab_hosts_propaganda` and the campaign tests now drive
`#tab-intelligence`. `data_test.js` knows about `unlocks`.
**Preview.** A standalone mock of the tab is at
`/tmp/tismo-preview/intelligence.html` (links the real `style.css`, hand-filled
representative data, a faux top-left summary to get the modal's 104px dock
offset right); `/tmp/tismo-preview/intelligence-phase-a.html` adds the sub-tabs and the spies
roster; `/tmp/tismo-preview/intelligence-spy-actions.html` shows the spy action
buttons in the unit panel. All throwaway — do not commit them.
---
## 3. Requirements (from `ROADMAP.md`, restated)
> `[ ]` Gets its own tab
> - Move propaganda operations to intelligence
> `[ ]` Spy unit
> - Invisible to all other players, by default
> - 1 tile-radius vision
> - Mostly high-tech cost
> - Can move into enemy territory
> - `[ ]` Sabotage action:
> - Blocks production on a given tile for a week, performed while in *any*
> country
> - Perpetrator is given a choice to try and blame someone else
> - The victim gets a notification; depending on intelligence and
> counter-intelligence level, someone or no one will be blamed.
> - No way for the player to differentiate between a correct blame
> assignment or a successful false flag
> - `[ ]` City spying
> - Perpetrator gets a report on a city's status, building levels, etc.
> Counter-intelligence can make the report contain false information
> - `[ ]` Comms spying
> - Done in the capital of a country
> - Reveals the N last private messages sent from and to that player
> - Counter-intelligence may garble the messages, or change the sender and
> receiver of messages
> `[ ]` Counter-intelligence tech
> - `[ ]` Extremely expensive
> `[ ]` Spy satellites
> - `[ ]` New space launch center
> - Extremely expensive
> - `[ ]` allow "send spy satellite" action
> - Everyone is made aware that a satellite was launched
> - Reveal a random 2-tile-radius chunk of the map every two days
Plus the WMD follow-up:
> `[ ]` Foreign intelligence may be warned that you are enriching uranium
---
## 4. Design decisions taken so far
1. **Propaganda lives on the Intelligence tab; Politics keeps the rest.**
Campaigns are a covert action, so they belong with spies.
2. **The spy is `military: false`** — it never joins a melee, never captures a
city, and right-click/attack rules treat it like a worker. It fights nothing.
3. **Spy invisibility is unconditional for now.** Unlike a submarine (revealed
by an adjacent naval unit), a spy is hidden from every other nation. A
detected/revealed state, driven by counter-intelligence, is deliberately left
open (see §6).
4. **A spy crosses foreign territory** as a through-route, not only as a goal.
This is the one exception to the "foreign land is a wall" rule and is mirrored
client-side so prediction agrees. A spy may also **board a friendly launch**
like infantry (`requestEmbark` accepts `spy` as well as `infantry`, and
`_tryTransportOrder` mirrors it), so a land-locked spy can reach an island.
5. **New technologies may be unlock-only** (`unlocks: true`), so
`intelligence_agencies` and `counter_intelligence` carry no numeric effect.
The `data_test` rule was relaxed accordingly.
6. **The spy is gated by `intelligence_agencies`** (not by a building), because
the roadmap specifies no spy building. `counter_intelligence` is deliberately
"extremely expensive" (1,500 base cost × the 100× research multiplier =
150,000 points). A gated unit is not hidden from the city train list: every
trainable unit shows, locked ones as a dimmed card carrying
"Requires Intelligence Agencies" (`_trainableUnits` / `_unitRequirement` in
`panels.js`, the `locked` branch of `city.js` `_renderTrainList`), so a player
discovers the spy before researching the agency.
7. **Spy operations are unit actions in the unit stack panel.** An operation
belongs to a spy, so the buttons sit beside the ranged **Attack** in the unit
panel (`#unit-spy-actions`), not in the Intelligence tab. The tab keeps only
the roster (with **Locate**). Sabotage acts on the spy's own tile, so it needs
no target picker; a future operation that points at a distant tile can still
arm the same overlay the **Attack** uses. Unwired operations stay disabled
("Not yet available").
8. **Sabotage zeroes the tile's production**, using the same `productionFactors`
mechanism as pillaging and trenches rather than a second production model.
It lasts a week (`SABOTAGE.blockDays`). It leaves the physical resource trade
alone for now — as pillaging does — and the culprit is not yet named; the
blame roll and false-flag choice belong to counter-intelligence (Phase C).
9. **A spy is single-use.** Every covert operation (sabotage, city spying, comms
spying) removes the whole acting stack through `_spendSpies` — quietly, with
no news line and no notice to the owner or the victim. The only reason to send
more than one spy is to overwhelm counter-intelligence: each spy past the
first cancels `ESPIONAGE.agentsPerCounter` of the target's service
(`_effectiveCounterIntelligence`), so three spies beat a full service outright.
The operation button's tooltip says the spies are spent.
---
## 5. Open design questions (settle these before coding)
1. ~~**Where do spy actions live in the UI?**~~ **Settled (Phase A): the unit
stack panel, plus map targeting.** Operations are spy unit actions, so their
buttons live in the unit panel (`#unit-spy-actions`) with the ranged
**Attack**; Phase B arms the map's target-picking overlay (the **Attack**
pattern: `_toggleTargeting`, `map.beginTargeting`, `#layer-targeting`,
`map.onTargetChosen`) once an operation is chosen. The Intelligence tab keeps
only the spy roster (decision 7).
2. ~~**How is a job resolved and paid for?**~~ **Settled: every operation is
free, instantaneous and spends the acting spies.** A spy is single-use: the
whole selected stack is removed quietly, with no news and no notice. The cost
is those spies and the risk of being blamed; a bigger stack blunts the
target's counter-intelligence (`ESPIONAGE.agentsPerCounter`), so stacking is
the one lever against it. No cooldown.
3. ~~**Sabotage specifics.**~~ **Settled (Phase B):** the spy must stand **on**
the target tile (foreign land); the block is the tile's production, zeroed
for `SABOTAGE.blockDays` days through `productionFactors`. Stored as
`GameState.tileSabotage` (key → end hour), pruned on the hourly tick and
shipped in the snapshot, so it survives a reload. The physical resource trade
is not (yet) affected — see decision 8.
4. ~~**Blame model.**~~ **Settled (Phase C):** `_blameCulprit` returns the author
(identified), the chosen scapegoat (false flag), or null. The message carries
only `suspect`, so a correct blame and a successful false flag read
identically. The framed nation has no special reaction yet — the blame is
only a news line.
5. ~~**City spying report.**~~ **Settled (Phase B):** a one-off report listing
population, building levels and garrison, kept in the owner's report list.
Counter-intelligence jitters the population and nudges building/garrison
counts by one, never marking the report as suspect.
6. ~~**Comms spying needs stored messages.**~~ **Settled (Phase D):** a server
message store exists (`GameState.messages`, fed by `recordChat`, capped at
`ESPIONAGE.messageLogLimit`) and chat gained private recipients. Comms spying
reads the last `ESPIONAGE.interceptCount` messages involving the target.
7. ~~**Counter-intelligence mechanics.**~~ **Settled (Phase C):** one permanent
unlock tech, read through `_counterIntelligenceLevel(civ)`. It raises blame
accuracy, falsifies city reports and garbles comms. It does not hide spies —
they stay unconditionally invisible (decision 3).
8. ~~**Satellites.**~~ **Settled (Phase E):** one satellite per centre level,
launched from a city holding the centre, free once built; the launch is public
news. Each satellite uncovers a random 2-tile patch of **permanently
explored** land every two days, forever, and multiple satellites stack.
9. ~~**Uranium warning.**~~ **Settled (Phase F):** an automatic chance roll when
a nation's first enrichment centre is completed publishes a world news
warning; no spy needs to be present.
10. ~~**Spy visibility on the map for its owner.**~~ **Settled:** the owner always
sees its own spy. A spy cannot enter a hostile city tile (the movement rules
keep non-military units out), so city and comms spying watch from the city
tile's own hex or a neighbour.
---
## 6. Suggested implementation plan
**Phase A — tab completion. [done]** Added the spy-controls section to the
Intelligence tab (the "Your spies" roster with Locate, and the "Covert
operations" list) and settled question 1: the tab is the control surface, the map
overlay will pick targets. Added `shared/data/intelligence.js` (the operation
catalogue) and the `spies`/`spyOperations` fields of `_intelligenceInfo()`.
**Phase B — one covert action end to end. [done: sabotage]** Picked sabotage:
it is self-contained (no private report UI) and its block is world state. Added
the `sabotage` order + `GameServer._handleSabotage` validation, the
`tileSabotage` snapshot field, the `NEWS_SABOTAGE` notification, the
`productionFactors` block and the client mirror. City spying is next. The
target-picking overlay was not needed because sabotage acts on the spy's own
tile.
**Phase C — counter-intelligence. [done]** `_intelligenceLevel` /
`_counterIntelligenceLevel` feed `_blameCulprit`, report falsification and
comms garbling.
**Phase D — comms spying. [done]** The server records chat through
`GameState.recordChat` (with private recipients), and `requestCommsSpy` files an
interception report, garbled when the target has counter-intelligence.
**Phase E — satellites. [done]** `space_launch_center`, `requestSatellite`, the
`_tickSatellites` reveal cadence and the `NEWS_SATELLITE` launch notice.
**Phase F — uranium warning. [done]** `_completeBuilding` → `_warnOfEnrichment`
→ `NEWS_ENRICHMENT`, a chance roll on the first enrichment centre.
---
## 7. Data-model sketch
Names for discussion; keep them cheap to serialise.
- Operation catalogue: `shared/data/intelligence.js` `SPY_OPERATIONS`
(`{ id, name, description }`), with the id constants `SPY_SABOTAGE`,
`SPY_CITY`, `SPY_COMMS`. The server will validate orders against these ids.
- `GameState.spies` — not needed; spies are ordinary units with `proto.spy`.
Single-use is not stored either: an operation calls `_spendSpies` on the
acting units at once.
- Sabotage block (**done**): `GameState.tileSabotage` (key → end hour), pruned in
`_tickSabotage` and read by `isTileSabotaged` from `productionFactors`. Shipped
as `tileSabotage: [[x, y, untilHour], ...]`; small enough not to need a version
counter.
- Reports (**done**): `GameState.spyReports` (civ → capped list) holding city
dossiers and comms intercepts; shipped to their owner only in `viewerSnapshot`.
- Comms store (**done**): `GameState.messages`, `{ from, to, text, hours }`,
capped at `ESPIONAGE.messageLogLimit`; never serialised.
- Satellites (**done**): `GameState.satellites` as
`{ civ, launchedHours, nextRevealHour }`; the reveal adds to `explored` and
marks visibility dirty. Shipped whole (tiny) as `satellites`.
- Counter-intelligence: `_counterIntelligenceLevel(civ)` read from the researched
set; no stored state, one permanent tech. `_effectiveCounterIntelligence(civ,
agents)` scales it down by the acting stack, and is the one place the team size
touches the blame roll, report falsification and comms garbling.
No new large snapshot collection was added, so no `DELTA_COLLECTIONS` entry or
version counter is needed. `covertOps` was never needed — each operation acts
immediately rather than as a stored job.
---
## 8. Server / orders / client plumbing
- New orders go through `GameServer` validation in `server/game_server.js`
(see how `_handleAttack`, `requestTrain`, treaties are handled) and land on
`GameState` methods. Never trust client-only flags outside `--testing`.
- News/notifications: add a `NEWS_<THING>` constant in
`shared/data/relations.js` (the others live there) and dispatch it from the
relevant `GameState` method; the client feed renders it via
`client/js/modals/format.js` `describeNews`, and the game screen pushes alert
lines in `client/js/game_screen/panels.js` `_updateFeed`/`_pushNews`.
A clickable feed line (like a treaty offer) is the model for "someone blamed X
for the sabotage".
- Client actions: wire a `map.on<Action>Chosen` callback for the target-picking
overlay (mirror the ranged Attack flow: `client/js/map_view/motion.js`
`beginTargeting`/`_drawRangeBoundary`, `client/js/map_view/input.js`
`_onLeftClick`/`_onRightClick`, `client/js/game_screen.js`
`onTargetChosen`/`onTargetCancelled`, `client/js/game_screen/panels.js`
`_toggleTargeting`/`_chooseTarget`).
---
## 9. Tests
Done in Phase A:
- `tests/spy_test.js` `test_the_covert_operations_are_well_formed`: the
`SPY_OPERATIONS` catalogue has unique, non-empty ids/names/descriptions and
carries all three operation ids.
- `tests/game_screen_test.js` `test_intelligence_subtabs_split_spies_and_propaganda`:
the two sub-tabs exist, Spies opens first and switching toggles the views.
- `tests/game_screen_test.js` `test_intelligence_tab_lists_the_viewers_spies_and_locates_them`:
a spawned spy shows in the roster with its city and a Locate control, and
clicking it selects the spy and closes the panel.
- `tests/game_screen_test.js` `test_spy_actions_live_in_the_unit_panel`: selecting
a spy reveals `#unit-spy-actions` with one button per `SPY_OPERATIONS` entry,
selecting a soldier hides it, and the tab carries no operations card.
- The campaign tests click the **Propaganda** sub-tab before reaching for a
campaign control.
Done in Phases B–F:
- `tests/intelligence_test.js` (11 tests): sabotage zeroes and restores a foreign
tile; own land and non-spies are refused; the blame roll pins the author, a
framed nation, or no one (forced RNG sequences); counter-intelligence raises
identification; city reports are accurate, then falsified when the target has
counter-intelligence; comms are intercepted clean, then garbled; a satellite
launches, is capped by centre levels, and uncovers explored tiles on its
cadence; the enrichment warning fires on a chance roll.
- `tests/game_screen_test.js` adds `test_city_spying_arms_targeting_and_sends`
(the city-spy button arms the target overlay and sends the order) and
`test_launch_satellite_button_sends` (the tab's launch control sends the order).
- `tests/game_server_test.js` validates the `spy_city` and `launch_satellite`
orders and rejects another nation's spy.
- `tests/spy_test.js` grows: a spy cannot capture, cannot fight, and cannot
enter a hostile city tile.
- `tests/intelligence_test.js` covers single-use: an operation destroys the
acting spies and no others; `_effectiveCounterIntelligence` steps 1 → 0.5 → 0
as the team grows; a three-spy team escapes a blame roll a lone spy would
fail; and a three-spy city report stays honest against a counter-intelligence
service.
- Client: `tests/map_view_input_test.js` for target-picking of a spy action.
Determinism: `state._random = () => value` is the established way to pin RNG
outcomes.
---
## 10. Gotchas
- **Chat is now stored** in `GameState.messages` (fed by `recordChat`); it is
never serialised, so it stays on the server.
- **Invisibility and snapshot size.** `_hiddenEnemyUnits` is consulted on the
snapshot path; keep the check cheap and version-aware.
- **Prediction mirroring** for any movement/visibility change (server
`movement.js` ↔ client `motion.js`).
- **Do not memoise `getCityEconomy` outside a snapshot** and keep new snapshot
walks version-keyed (see the AGENTS.md notes on the snapshot path).
- **Tech-tree ripple.** Adding technologies changes nothing count-based
(`TECHNOLOGIES.length` is used by tests) but does change the tree; run
`game_state_technology_test.js`, `data_test.js`, `modals_test.js` and
`game_screen_test.js`.
- **`unlocks` techs** are exempt from the effects rule; if you give
`counter_intelligence` a numeric effect later, drop `unlocks`.
---
## 11. Relevant files
- Tab / modal: `client/index.html` (`#tab-intelligence`, `#unit-spy-actions`),
`client/js/modals/nation.js` (`_renderIntelligence`, campaign methods),
`client/js/game_screen/panels.js` (`_intelligenceInfo`, `_updateSpyActions`,
`_buildSpyActionButtons`, news feed).
- Spy data/behaviour: `shared/data/units.js`, `shared/data/technologies.js`,
`shared/data/intelligence.js` (`SPY_OPERATIONS`, `SABOTAGE`, `ESPIONAGE`,
`SATELLITE`, report kinds), `shared/game_state/intelligence.js` (the whole
covert-op mixin: sabotage, blame, city/comms reports, satellites, secrets),
`shared/game_state/visibility.js`, `shared/game_state/movement.js`,
`client/js/map_view/motion.js`.
- Production block: `shared/rules.js` (`productionFactors` `sabotaged`),
`shared/game_state/economy.js` (`getTileGdpPerCapita`, `_completeBuilding`),
`shared/game_state/serialization.js` (`_serializeSabotage`, viewer
`spyReports`/`satellites`).
- Building: `shared/data/buildings.js` (`space_launch_center`,
`BUILDING_MECHANIC.SPACE_LAUNCH`), `client/assets/icon_space_launch_center.svg`.
- Orders/server/news: `server/game_server.js` (`_handleSabotage`,
`_handleSpyCity`, `_handleSpyComms`, `_handleLaunchSatellite`),
`server/server.js` (`case "chat"` with `to`, `recordChat`),
`shared/game_state/orders.js`, `shared/data/relations.js` (`NEWS_SABOTAGE`,
`NEWS_SATELLITE`, `NEWS_ENRICHMENT`), `client/js/modals/format.js`
`describeNews`.
- Client views: `client/js/modals/nation.js` (`_buildReportsCard`,
`_fillReports`, `_buildSatellitesCard`, `_fillSatellites`, `onLaunchSatellite`),
`client/js/game_screen.js` (`_chatTarget`, `_updateChatTargets`, `addChat`),
`client/index.html` (`#chat-target`).
- Target picking to imitate: `client/js/map_view/motion.js`,
`client/js/map_view/input.js`, `client/js/game_screen.js`,
`client/js/game_screen/panels.js`.
- Chat (for comms spying): `server/server.js` `case "chat"`,
`client/js/net.js`, `client/js/app.js`, `client/js/game_screen.js`
(`openChat`/`addChat`).
- Tests: `tests/intelligence_test.js`, `tests/spy_test.js`,
`tests/game_screen_test.js`, `tests/game_server_test.js`, `tests/modals_test.js`,
`tests/data_test.js`.
- Previews: `/tmp/tismo-preview/intelligence-complete.html` (reports,
satellites, private chat), `intelligence-phase-a.html`, `intelligence.html`,
`intelligence-spy-actions.html`.
## 12. Related design docs
`STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`,
`TRAINING_UI.md`, `ICONS.md` (siblings in the repo root).
+163
View File
@@ -0,0 +1,163 @@
# Politics tick performance — design brief
A working document for a session on making the hourly **politics/migration tick**
cheap in a large, settled world.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model, ticked hourly by `server/server.js`; the browser only
renders snapshots.
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
(no Node, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and adds its methods to the prototype.
- **Version counters** are how expensive derived data is memoised on the snapshot
and tick paths: `_gdpEpoch`, `_populationVersion`, `_territoryVersion`,
`_modifiersVersion`, `_tileImprovementVersion`, `_visibleVersion`,
`_ethnicityVersion`, `_regionVersion`, and the trade graph's own stamp.
- **The trade graph is the reference pattern**: a walk from each city floods the
ocean (a sea lane runs any distance), so `_tradeGraph` is cached across ticks
*and* days under a stamp built from the road/railway, territory and
storage-node counters. Any network change rebuilds lazily; a quiet world reuses
it. The tile-improvement version is deliberately **not** in the stamp.
- **Do not memoise `getCityEconomy` outside a snapshot** — a test pins that a
simulation read reflects a direct `tilePopulation` edit immediately.
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- Time a settle: `node server/server.js --port 27015 --bind 127.0.0.1` logs how
many days the warm-up took (and how long). `--warmup 0` skips it.
### Conventions
- 2-space indent; no new libraries.
- Tests in `tests/<topic>_test.js` extending `TestCase`. Force RNG with
`state._random = () => value`.
- Commit only when asked; one imperative sentence; the hook runs the full suite.
---
## 2. The problem (from `ROADMAP.md`)
> - [ ] Faster politics tick: with the trade-graph walks cached, `_tickPolitics`
> is the largest remaining cost in a settled world — `_migrateForIncome`
> through `_regionIncomePerCapita` and `_movePopulation`, and the repeated
> `_cityMechanicLevel` lookups each of them makes. Memoise the per-region
> income the way the trade graph is now kept, and let a migration sweep
> visit only the regions whose pull actually changed.
---
## 3. Where the time goes
`shared/game_state/politics.js`:
- `_tickPolitics()` (line 71) runs hourly and calls, among others,
`_migrateForIncome()`.
- `_migrateForIncome()` (744) computes `_regionIncomePerCapita(this.cities)`
(852) and then, per city, `_cityMechanicLevel(city, AIR_IMMIGRATION)` and
nested loops over origin/destination cities, calling `_movePopulation` (865)
which bumps `_populationVersion` (922).
- `_regionIncomePerCapita` (852) is the expensive walk; it recomputes income for
every region on every tick even when nothing that feeds it changed.
- `_cityMechanicLevel` is called repeatedly for the same city/building within one
tick.
- Some politics walks are already memoised (`politics.js:222`, `:251` use
`_ethnicityVersion:_populationVersion:_territoryVersion[: _regionVersion]`
stamps), so there is a local idiom to follow.
---
## 4. Design space (settle before coding)
1. **Memoise `_regionIncomePerCapita`** under a stamp of the counters that can
change a region's per-capita income — likely
`_populationVersion`, `_territoryVersion`, `_ethnicityVersion`,
`_gdpEpoch`, `_modifiersVersion`, and the building/mechanic version (check
what `_regionIncomePerCapita` actually reads). Keep the stamp minimal: the
trade-graph lesson is that folding in a fast-changing counter throws the cache
away constantly. Rebuild lazily on demand.
2. **Visit only changed regions.** If a region's "pull" (income, or the
migration allowance) did not change, skip its whole migration sweep. Needs a
per-region pull signature / version and a place to keep it across ticks.
3. **Hoist `_cityMechanicLevel`.** Compute each city's mechanic levels once per
tick into a small map and pass it down, instead of calling it per city inside
the loops.
4. **Bound the inner loop.** `_migrateForIncome` is effectively all-pairs over
cities in a region (or the world?). If it is world-wide, restrict to regions
that are connected / have a pull difference, or use a nearest-first sweep.
5. **Avoid `_populationVersion` churn from no-op moves.** `_movePopulation` bumps
the version even for trivial/fractional moves; only bump when the population
actually changes. (This also keeps every population-keyed memo alive longer.)
6. **Measure first.** Build a benchmark that settles the standard map N days and
times `_tickPolitics`, and/or count calls to `_regionIncomePerCapita` and
`_cityMechanicLevel` per tick. Change nothing until the hot spot is confirmed.
---
## 5. Suggested order of work
1. Add a benchmark/profiling test (or temporary counters) that reports the
per-tick politics cost and call counts on a settled standard map, so the
before/after is objective.
2. Memoise `_regionIncomePerCapita` with the right stamp (item 1). Re-measure.
3. Hoist `_cityMechanicLevel` (item 3). Re-measure.
4. Skip unchanged regions (item 2) if still hot. Re-measure.
5. Keep the full politics/upkeep suites green; the migration tests pin the
observable behaviour, so the refactor must not change outcomes.
---
## 6. Tests to add / extend
- A performance-oriented test that asserts `_regionIncomePerCapita` is computed
once per tick when nothing changed (e.g. count invocations via a spy or a
counter), and is recomputed when a counter it depends on moves.
- A correctness test that a direct `tilePopulation` edit is still reflected in a
simulation read (do not break the `getCityEconomy` rule).
- Keep `tests/politics_test.js`, `tests/migration_test.js`, `tests/growth_test.js`,
`tests/warmup_test.js` green. `warmup_test.js` timing is a coarse guard.
---
## 7. Gotchas
- **A wrong stamp is worse than no cache.** If a counter that changes income is
missing, migration reads stale data and tests will flake; if a fast counter is
included, the cache is useless.
- **Deltas.** Any new persisted/cached structure that is serialised must pair
with a `DELTA_COLLECTIONS` entry and a version (see §1), or the snapshot grows.
- **`_populationVersion` is load-bearing** for GDP/population memoisation; do not
bump it in a hot loop for no reason, and do not *stop* bumping it when
population truly changes.
- **Same numbers, different cost.** The goal is identical simulation output at
lower cost; verify by comparing settlement figures before/after, not just
timing.
---
## 8. Relevant files
- `shared/game_state/politics.js` — `_tickPolitics`, `_migrateForIncome`,
`_regionIncomePerCapita`, `_movePopulation`, `_cityMechanicLevel`, existing
memoisation at lines ~222/~251.
- `shared/game_state/resources.js` — `_tradeGraph` cache/stamp (the template),
`_tickResources`.
- `shared/game_state.js` — version counters, tick order.
- `server/server.js` — the hourly tick and warm-up timing logs.
- Tests: `tests/politics_test.js`, `tests/migration_test.js`,
`tests/growth_test.js`, `tests/warmup_test.js`, `tests/trade_graph_test.js`.
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`TRAINING_UI.md`, `ICONS.md` (repo root).
+103 -3
View File
@@ -71,7 +71,7 @@ simplification worth revisiting. Anything unticked is still open.
work already done.
- [x] Repeatable technologies with a 2% cost growth per level, notified without
flooding the news list.
- [ ] Repeatable technologies, once researched, get automatically focused again
- [x] Repeatable technologies, once researched, get automatically focused again
- [x] Theme tabs (Science / Economy / Society / Military).
- [x] Technology panel shows the research rate rather than a stockpile.
- [x] Readable effect text for the military and repeatable effects.
@@ -256,9 +256,109 @@ simplification worth revisiting. Anything unticked is still open.
economy simulator's index reaches about 500-650 for luxury and 200-230 for
high-tech) instead of settling; revisit the supply and demand balance of
the two rarer goods.
- [ ] A dedicated structure hit-point model for cities and tile improvements
(see the `[~]` note above).
- [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
`icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
`icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
`icon_wind_turbines.svg`.
### Player interaction
- [x] In-game chat
- Access by pressing T
- Shows a text input field in the bottom center
- chat messages are ordered down to up, last to first
- [x] Player list
- In the diplomacy tab, show who's in control of a nation: the AI or a player
- Show it using an icon: human or computer
- Also add a column with the leader's name
- [x] Diplomatic treaties, that can convey custom text
- [x] In the diplomacy tab, show currently active and past treaties
- [x] Players receiving a treaty proposal get a notification, clicking on it opens the treaty info
- [x] Treaties have a "Sign" and "Reject" buttons
- [x] The whole world is informed that a treaty is being sought, has been signed or has been rejected
- Types of treaty effects:
- [x] Peace: stops the state of war between two players. All battles cease, troops in enemy territory immediately move to their closest city.
- Stranded troops on islands get teleported back, for now
- Going back to war breaks the treaty and warns the player
- [x] Alliance: signatory players cannot go to war with each other without breaking the treaty. Attacking a member of an alliance declares war on the entire alliance.
- [x] Free trade: all tariffs to 0 between the signatory parties
- [x] Give city
- [x] Give military materials (cannot gift infantry)
- [x] Give resources
- [~] Give money
- Denominated in any currency. Taken from the giving player's central bank foreign reserves if possible, otherwise, bought from the relevant central bank
(simplified: the sum moves straight between the two treasuries, no FX or reserves leg)
- Cannot get currency from a central bank from a country at war with you
- [x] Joint research
- Each players gets the sum of tech points produced by the signatory parties when they focus tech, except on military technology
### Military
- [x] Better troop management
- [x] In the unit stack panel, can select all units of a type at once
- [x] Show a table of icons instead of the current list
- [x] Submarine unit
- [x] Invisible to other players unless a naval unit is DIRECTLY adjacent
- [~] Missiles
- [x] One-use units.
- [x] Types of missiles
- [x] Conventional, 30 tiles range
- [x] Nuclear (locked behind nuclear tech), 30 tiles range
- [x] ICBM, nuclear (locked behind nuclear tech and space program), reaches any tile
(a plain missile carries a conventional warhead unless the player arms it
nuclear from the unit panel; the ICBM is nuclear by design)
- [~] WMDs
- [x] Nuclear program
- [x] Build uranium enrichment centers in cities
- [ ] Foreign intelligence may be warned that you are enriching uranium
- [x] Once a uranium enrichment center exists, the nuclear weapons tech is unlocked
- [x] Once the technology research is complete, players can choose nuclear payloads
in their missiles
- [x] Nuclear explosion effects:
- [x] -90% population on a tile
- [x] All buildings and improvements destroyed, except for bunkers and fortifications
- [x] -1000 popularity across the board
(any unit caught in the blast is destroyed too)
- [ ] Stacks must behave as single units when it comes to pathfinding
- [ ] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- [ ] Airplanes move in straight lines as they are not bound by tiles.
* [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them
### Intelligence
* [ ] Gets its own tab
- Move propaganda operations to intelligence
* [ ] Spy unit
- Invisible to all other players, by default
- 1 tile-radius vision
- Mostly high-tech cost
- Can move into enemy territory
- [ ] Sabotage action:
- Blocks production of on a given tile for a week, performed while in *any* country
- Perpetrator is given a choice to try and blame someone else
- The victim gets a notification, depending on intelligence and counterintelligence level, someone, or no one, will be blamed.
- No way for the player to differentiate between a correct blame assignment or a successful false flag
- [ ] City spying
- Perpetrator gets a report on a city's status, building levels, etc. Counter-intelligence can make the report contain false information
- [ ] Comms spying
- Done in the capital of a country
- Reveals the N last private messages sent from and to that player
- Counter-intelligence may garble the messages, or change the sender and receiver of messages
* [ ] Counter-intelligence tech
- [ ] Extremely expensive
* [ ] Spy satellites
- [ ] New space launch center
- Extremely expensive
- [ ] allow "send spy satellite" action
- Everyone is made aware that a satellite was launched
- Reveal a random 2-tile-radius chunk of the map every two days
### Migrations
- [x] Populations cannot migrate from their country to a hostile one
- [x] Expel [population] prevents that population from moving to the country
- [x] Migration rate cannot be more than 0,01%/day from the original tile.
### Economics
- [x] Remove the "1% per day budget increase". A construction site simply tries to get materials at current market prices.
- [ ] Show that units being trained are gathering resources when the player clicks on training them
+139
View File
@@ -0,0 +1,139 @@
# Simplifications worth revisiting — design brief
The `[~]` items in `ROADMAP.md`: features that are implemented with a deliberate
shortcut, kept honest so a later session can decide whether to build them out.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is the model;
`server/game_server.js` validates orders; the browser renders snapshots.
- **Shared logic runs in Node and the browser**; `shared/` stays framework-free
(no Node, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and adds its methods to the prototype.
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
- **Version counters / memoisation** guard the snapshot and tick paths
(`_populationVersion`, `_territoryVersion`, `_tileImprovementVersion`,
`_gdpEpoch`, the trade-graph stamp). A new snapshot collection needs a
`DELTA_COLLECTIONS` entry and a version.
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- Server: `node server/server.js --port 27015 --bind 127.0.0.1`.
### Conventions
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>`.
- Tests in `tests/<topic>_test.js` extending `TestCase`; force RNG with
`state._random = () => value`.
- Commit only when asked; one imperative sentence; the hook runs the full suite.
---
## 2. Structure damage is abstracted
**`[~]`** *Structure and city damage is modelled as city population/GDP loss
plus improvement hit points, not a dedicated per-structure HP system.*
- Today: a city level or tile work has hit points / a repair debt
(`RESOURCE_RULES.repair`, `shared/game_state/` combat/improvements), and its
loss shows as population and GDP loss. There is no per-building HP.
- Full model: each structure on a tile tracks its own condition, so a
bombardment knocks out one specific building rather than degrading the city as
a whole; repairs target the structure.
- Questions: does per-structure HP add meaningful decisions, or just bookkeeping?
How does it interact with the existing repair debt and the capture/destruction
rules? Snapshot cost if every structure carries HP.
- Files: `shared/game_state/resources.js` (repair rules), combat/improvements
mixins, `shared/data/combat.js`.
## 3. Fossil fuels are not a resource
**`[~]`** *Fossil fuels are abstracted into the transformative plants' money
upkeep rather than a stored fuel resource (the roadmap lists only the five
groups: energy, steel, food, luxury, high-tech).*
- Today: coal/gas/oil plants pay their fuel as money upkeep; there is no coal,
gas or oil stock to buy, store or blockade.
- Full model: fossil fuels as storable resources with extraction buildings,
market prices and a transport bill, making a blockade or a price spike bite.
- Questions: does adding two-to-three more resources pay for the extra UI and
simulation? The roadmap deliberately lists only five groups.
- Files: `shared/data/resources.js`, `shared/data/buildings.js`,
`shared/game_state/resources.js`.
## 4. Energy is a daily flow, not a store
**`[~]`** *Energy is modelled as a daily flow everywhere, so no city can bank
it. Only the other four groups have per-tile storage.*
- Today: electricity is produced and consumed within the tick; no battery.
- Full model: energy storage (batteries), so a surge can be banked and a
brownout ridden out; possibly strategic reserves.
- Questions: does storage break the "energy is cheap and everywhere" balance?
Round-trip losses? Interaction with the delivery graph (10 GWh = 1 t of
delivered energy).
- Files: `shared/game_state/resources.js`, `shared/data/resources.js`,
`shared/data/buildings.js`.
## 5. Cities buy from the cheapest source with a nominal delivery
**`[~]`** *A city always buys from the cheapest source: a nearby store whose
delivered price beats the market, or the global market itself. Market buying
uses a nominal two-tile road delivery.*
- Today: the choice is price-only; distance affects the delivered price but not
which routes are physically available beyond the network rules.
- Full model: a city bids across the transport network with real routing, lead
times and possible stock-outs, instead of a nominal two-tile market leg.
- Questions: does the simplification hide transport bottlenecks the delivery
graph is meant to surface? How is the nominal leg calibrated?
- Files: `shared/game_state/resources.js`, `shared/game_state/economy.js`,
`client/js/map_view/modes.js` (delivery graph), `client/js/map_view/economic.js`.
## 6. Give-money treaties bypass FX and reserves
**`[~]`** *Give money is denominated in any currency but (simplified) the sum
moves straight between the two treasuries, with no FX or central-bank reserve
leg.*
- Today: `shared/game_state/treaties.js` moves the sum directly; the
"cannot get currency from a central bank at war with you" rule is not
modelled.
- Full model: draw from the giver's foreign reserves, otherwise buy from the
relevant central bank at the market rate; block a bank whose nation is at war.
- Questions: which FX rate and spread? Does a reserve shortage fail the treaty
or partially settle?
- Files: `shared/game_state/treaties.js`, `shared/game_state/` currency/central
bank mixins, `client/js/modals/nation.js` (treaty compose).
## 7. Missiles / WMD
**`[~]`** *Missiles* and *WMDs* are marked `[~]` while their sub-items are
largely `[x]`.
- Today: a plain `missile` is conventional unless the player arms it nuclear
from the unit panel (then it needs `nuclear_weapons`); the `icbm` is nuclear by
design and needs `nuclear_weapons` + `space_program`. A nuclear blast kills
~90% of a tile's people, destroys buildings/improvements except bunkers and
fortifications, and costs every nation popularity.
- What is unfinished: the WMD follow-up "foreign intelligence may be warned that
you are enriching uranium" (see `INTELLIGENCE.md`), and any further
refinement of blast falloff / interception.
- Files: `shared/game_state/wmd.js`, `shared/data/units.js`,
`shared/data/combat.js`, `client/js/game_screen/panels.js`.
---
## 8. Related design docs
`DESIGN.md` (index), `INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`,
`ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md`.
+220
View File
@@ -0,0 +1,220 @@
# Stacks & unit management — design brief
A working document for a session on **unit stacks**: how a player splits them
ergonomically, and how a stack moves as one body through the world.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model; `server/game_server.js` (`GameServer`) owns one and
validates orders; `server/server.js` is the transport.
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
(no Node built-ins, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and `Object.assign`s its methods onto the
prototype. A new method file must be imported **and** added there.
- **Client mirroring.** Any pathfinding or movement-cost change must be mirrored
between `shared/game_state/movement.js` and the browser prediction in
`client/js/map_view/motion.js`, or prediction fights the server.
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never run the full
suite during development** — the commit hook does that.
- Server: `node server/server.js --port 27015 --bind 127.0.0.1` (`--testing`
adds free/instant buttons).
### Conventions
- 2-space indent; no new libraries (only vendored jQuery).
- Missing icon → dummy SVG `dummy icon - <name>`.
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
`tests/framework/helpers.js` (`smallState()`, `defaultState()`,
`adjacentLand()`, `grantBuilding()`). Force RNG with
`state._random = () => value`.
- Visual changes are previewed as a small standalone HTML page linking
`client/css/style.css`, **opened in Firefox**, before commit.
- Commit only when asked; messages are one imperative sentence.
---
## 2. What exists today
- The stack panel was just reworked (commit "Grouped the unit stack by type and
let ranged units pick a target"): one line per unit type, tri-state group
checkbox, opaque icon cards with healthbars, overlap-to-fit, cards to the left
on top, deselected cards stay and sort to the back — `client/js/game_screen/panels.js`
(`_renderStackList`, `_layoutStackDeck`, `_stackSelectionChanged`) and the
`_stackMembers` whole-stack concept in `client/js/game_screen.js`.
- Selecting a unit is by left-click on the map (`client/js/map_view/input.js`
`_onLeftClick`) or from the right-click **stack menu**:
`client/js/game_screen/panels.js` `_openStackMenu` lists each unit on a tile
("Move here", per-unit select, "Select all").
- Right-click **only moves** now (the ranged Attack action is a panel button);
moving onto a hostile stack triggers the fight server-side.
- Group movement: `_groupMovePlan` and `_groupSpeed` (slowest member sets the
pace); `find_path` is called per unit with a per-type route cache.
- Territory rules: `shared/game_state/movement.js` `_canUnitEnter` treats
foreign territory as a wall on through-routes except the civilisation targeted
by the move (`targetCiv` = owner of the goal). `_zoneOfControl` denies the ring
around hostile ground units. Mirrored in `client/js/map_view/motion.js`
`_canEnter` / `_zoneOfControl` / `_targetCivFor`.
- The client has the diplomacy matrix in `this.snapshot.diplomacy` and a helper
`_isAtWar(a, b)` in `client/js/game_screen/panels.js`, but the **MapView does
not hold relations yet** (needed for prediction — see §5).
---
## 3. Requirements (from `ROADMAP.md`)
> - [ ] Easy and ergonomic way to split unit stacks.
> - [ ] Give stacks with range units a dedicated button to perform ranged
> attacks, right-clicking will now ONLY move (obviously triggers a battle
> when moving on the same tile)
> *(the ranged button is done; this line can be ticked)*
> - [ ] Stacks must behave as single units when it comes to pathfinding
> - [ ] Cannot go through neutral third-party territory, allied and at-war
> territory can be walked through
> - [ ] Airplanes move in straight lines as they are not bound by tiles.
> *(see `AIR_MOVEMENT.md`)*
---
## 4. Splitting a stack — design space (settle before coding)
The problem: a player wants two units out of a five-unit stack to go one way and
the rest another, without hunting each one down in the right-click menu.
Candidate mechanics (pick one primary, maybe one shortcut):
1. **Split button + destination.** With a tile or stack selected, a "Split"
action opens the stack panel with per-type count steppers ("move 2 of 3
infantry"), then a target pick. Good for precise splits.
2. **Shift/drag cards.** In the stack panel, drag a card (or shift-click) to
detach it into a new sub-selection, then right-click to move the detached
group. Fast, but needs clear feedback for "this card is now moving".
3. **Halve / split by type.** Buttons "Split in half" and "Split by type" (each
type into its own stack) — one-click, no destination.
4. **Stack menu "Split off".** Extend `_openStackMenu`: checkboxes per row to
pick the units to detach, then a "Move selected" action.
Open questions:
- Does a "split" create a persistent sub-stack, or is it just a temporary
multi-selection that disperses after one order? (There is no persistent stack
entity today — units sharing a tile are the stack.)
- How does splitting interact with the group-speed rule (slowest member sets the
pace for the whole move)? If you split off a slow artillery and send the fast
infantry alone, the infantry should move at its own speed — confirm the group
move only groups the selected units.
- Where does the split land if the destination is one tile (all units together)
versus a route (do they arrive as a stack)?
- Undo/confirm: splitting should not be a mis-click trap.
---
## 5. Stack pathfinding as one unit
Goal: a stack routes as a body and never cuts through a neutral third party,
while allied and at-war territory may be crossed.
Current rule (`_canUnitEnter`, through-route, non-air):
```js
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
```
Desired rule: block only **neutral** third parties; allow **allied** and **at-war**
territory.
Open questions:
1. **Where does the alliance/war state come from on the server?** `GameState`
has relations and (with the treaties work) alliance treaties. A helper like
`_canTraverseTerritory(civ, owner)` reading `_relation(a, b)` / alliance
membership would centralise it. Decide whether "allied" means an alliance
treaty specifically or also non-aggression/peace.
2. **Client mirror.** The browser predicts paths, so `MapView` needs the same
answer. `this.snapshot.diplomacy` is available when a snapshot is applied;
add a relations/at-war/allied structure to `MapView` (set in `_setupWorld` or
`onState`) and have `_canEnter` consult it. Without this, prediction will
draw a route the server refuses.
3. **"Stacks behave as single units."** Today each unit is pathfound separately
with a shared route cache per type and the slowest speed wins. Decide whether
the whole stack should share **one route** (computed once, followed by all)
so units never fan out, and what happens if a unit is blocked mid-route by a
zone of control that another unit is exempt from.
4. **Zone of control vs stack.** Should a stack be able to force a crossing a
single unit could not, or does the ZoC apply per unit? The roadmap is silent;
keep per-unit unless asked.
5. **War declaration side effect.** `_groupMovePlan` already records the owners a
route crosses and `_orderMove` declares war on them. With allied/at-war
traversal allowed, make sure a permitted crossing does **not** declare war on
an ally (and that crossing an at-war nation is already legal).
6. **Air and spies are exceptions.** Aircraft ignore territory entirely;
spies cross foreign land (already implemented). The new rule should not
regress either.
---
## 6. Suggested order of work
1. Decide the split interaction (§4) and build it in the stack panel + menu,
with client tests.
2. Add the server helper for traversable territory and wire `_canUnitEnter`.
3. Mirror it in `MapView` (`_canEnter`) with the relations the snapshot ships;
add a prediction test in `tests/map_view_input_test.js` / `map_view_test.js`.
4. Decide the one-route-per-stack question; if yes, change `_groupMovePlan` and
the server's group move together.
5. Tick the roadmap lines.
---
## 7. Tests to add
- Split: selecting a subset splits correctly; the split group moves at its own
(slowest member) speed; a mis-click is recoverable.
- Territory: a route crosses an ally's land but not a neutral's; crossing an
ally's land does not declare war; crossing an at-war nation's land is allowed;
client prediction matches the server for each case.
- Regression: spies and aircraft keep their exceptions; zone of control still
applies.
---
## 8. Gotchas
- **Mirror server and client** or prediction fights the server (this is the
single most common bug in this area).
- **Do not add a per-broadcast walk.** If relations are shipped, they are small;
if you add a derived structure, memoise it against the diplomacy version.
- The stack panel's `_stackMembers` (whole stack) vs `selectedUnitIds` (ticked)
distinction is load-bearing; read `client/js/game_screen/panels.js`
`_stackSelectionChanged` before changing selection semantics.
- `_orderMove`/`_groupMovePlan` also handle transport (embark/disembark) and
escort; check those paths when changing group movement.
---
## 9. Relevant files
- `client/js/game_screen/panels.js` — stack panel, stack menu, `_groupMovePlan`,
`_groupSpeed`, `_isAtWar`.
- `client/js/game_screen.js` — `_stackMembers`, target/selection state.
- `client/js/map_view/input.js` — click handling, `_onRightClick`.
- `client/js/map_view/motion.js` — prediction `_canEnter`, `_zoneOfControl`,
`_targetCivFor`, `planMove`.
- `client/js/map_view.js` — where relations would be stored for prediction.
- `shared/game_state/movement.js` — `_canUnitEnter`, `_zoneOfControl`, pathfinding.
- `shared/game_state/diplomacy.js`, `shared/game_state/treaties.js` — relations
and alliances.
- Tests: `tests/game_screen_test.js`, `tests/map_view_input_test.js`,
`tests/map_view_test.js`, `tests/stacking_test.js`, `tests/orders_test.js`.
## 10. Related design docs
`INTELLIGENCE.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md` (repo root).
+171
View File
@@ -0,0 +1,171 @@
# Training / production UI — design brief
A working document for a session on showing **what a unit being trained is
gathering** (and, relatedly, the materials a building or tile work is buying)
when the player clicks it.
---
## 1. Shared project context (read this first)
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
Pure JavaScript, no build step, no runtime dependencies.
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
framework-free model; `server/game_server.js` owns one and validates orders.
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
(no Node, no DOM).
- **GameState is mixins.** `shared/game_state.js` imports each
`shared/game_state/<topic>.js` and adds its methods to the prototype.
- **The economy is hourly** (`shared/game_state/resources.js`
`_tickResources`) and materials are bought at current market prices; there is
no more "1% per day budget increase" (a construction site just buys what it
needs).
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
### Commands
- Install once: `npm install`.
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
during development — the commit hook runs it.
- Server: `node server/server.js --port 27015 --bind 127.0.0.1` (`--testing`
adds the free/instant train and build buttons).
### Conventions
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>`.
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
`tests/framework/helpers.js`. Force RNG with `state._random = () => value`.
- Visual changes get a minimal standalone HTML preview linking
`client/css/style.css`, opened in Firefox, before commit.
---
## 2. Requirement (from `ROADMAP.md`)
> - [ ] Show that units being trained are gathering resources when the player
> clicks on training them
This sits directly under the Economics section, next to the construction-market
change ("a construction site simply tries to get materials at current market
prices"). The likely intent: the production queue should visibly account for the
**materials phase** — as it already does for buildings — for **units** too, and
clicking a queued/training item should reveal the resources it is drawing.
---
## 3. How production works today
- **City panel** is `client/js/modals/city.js`; DOM in `client/index.html`
(`#city-train-list`, `#city-train-status`, `#city-train-label`,
`#city-train-progress`, `#city-train-queue`). The train tab shows both units
and buildings (the shared production queue).
- **Queue rendering** `renderQueue(queue)` (city.js:467) groups contiguous
identical entries, shows "Ready in …" and a Cancel button, and — for a
**building** in the `phase === "materials"` phase — swaps the timing for
"Gathering materials" and adds no time to the estimate.
- **Training status** `beginTraining` / `setTrainingProgress` / `endTraining`
(city.js:516-531); `setGatheringProgress` (536) shows "Gathering materials for
X…" with a fraction, used for the head-of-queue building.
- **Server side** (to confirm): `shared/game_state/orders.js` `requestTrain` /
`requestBuild` enqueue orders; `shared/game_state/sites.js` runs the building
materials phase (buying steel/high-tech at market, a `phase: "materials"`
with a `fraction`); `shared/game_state/resources.js` does the buying. Units
are trained from money plus a population draw (infantry) and may have a
`materialUpkeep` but currently have **no materials-gathering phase** visible to
the client.
- **Testing mode** adds free/instant variants (`--testing`), which skip the
queue; keep the display correct there too (or intentionally silent).
---
## 4. Open questions (settle before coding)
1. **What does "gathering resources" mean for a unit?** Options:
(a) units get a real materials phase (buy steel/high-tech before the clock
starts), mirroring buildings — a model change; (b) units are paid in money and
population only, and the UI should instead show the **population draw** (the
soldiers taken from the region) and the money cost as it is paid; (c) the UI
should show the materials **upkeep** the unit will need once built.
The roadmap wording ("gathering resources") suggests (a).
2. **Click to inspect.** The requirement says *when the player clicks on training
them*. Today queue rows are read-only (Cancel only). Decide whether clicking a
queue row expands a detail panel (resources still to buy, bought so far, cost
paid, population drawn, ready time) or opens a modal.
3. **Where do the figures live?** The queue entries need to ship the per-entry
materials state (needed/bought/phase/fraction) in the snapshot; confirm what
the server already sends (`view.queue`) and extend it. Watch snapshot size —
the queue is small, so it is probably fine, but pair it with the city view.
4. **Cancel/refund.** Cancelling a building refunds nothing today. If units get a
materials phase, does cancelling refund the materials? Keep it consistent with
buildings.
5. **Construction sites on tiles** share this materials model
(`shared/game_state/sites.js`); decide whether tile works get the same
click-to-inspect treatment (they are improved in `client/js/game_screen/panels.js`
tile panel / `_openTileManageModal`).
6. **Testing mode.** The free/instant button bypasses the queue; make sure the
new display does not show a phantom materials phase.
---
## 5. Suggested order of work
1. Read `shared/game_state/sites.js` and `orders.js` to pin down exactly what the
building materials phase is and what the snapshot already carries for the
queue.
2. Decide question 1. If units stay money+population, the "gathering" UI is
about the population draw and the money/materials cost; if they gain a
materials phase, reuse the site phase.
3. Extend the queue entry with the fields the detail view needs and render the
click-to-inspect panel in `client/js/modals/city.js`.
4. Add tests: the queue entry carries the right phase/fraction; clicking a
training item shows the expected fields; testing mode does not show a
materials phase.
---
## 6. Tests to add / extend
- Server: a queued unit's entry exposes its materials/population state (whatever
question 1 decides).
- Client: `tests/game_screen_test.js` / a city-modal test clicks a training queue
row and asserts the detail is shown; the gathering label appears only in the
materials phase.
- Keep `tests/construction_site_test.js`, `tests/game_screen_test.js`,
`tests/modals_test.js` green.
---
## 7. Gotchas
- **Snapshot churn.** The queue is rebuilt each refresh; keep the click-to-inspect
selection keyed by queue index/run, not a DOM node that gets thrown away (the
city panel already has this problem — see the "signature" guards in
`_renderTrainList`/`renderQueue`).
- **Testing mode** free/instant orders must not be given a fake gathering phase.
- **Population draw correctness**: training draws people from the region at
random; if you surface it, read the same code path the model uses.
- **Market prices move**: a quoted cost is a snapshot; label it as an estimate
the way the rest of the UI does (`quotedBuildCost`, `_quoted`).
---
## 8. Relevant files
- `client/js/modals/city.js` — `renderQueue`, `beginTraining`,
`setTrainingProgress`, `setGatheringProgress`, `_renderTrainList`.
- `client/index.html` — the city modal and `#city-train-*` DOM.
- `shared/game_state/sites.js` — the building/tile materials phase and fractions.
- `shared/game_state/orders.js` — `requestTrain` / `requestBuild` /
`cancel_train`.
- `shared/game_state/resources.js` — buying and `payCombatResources`/
construction spending.
- `client/js/game_screen/panels.js` — the city view (`_cityView`) that fills the
queue, and the tile-improvement panel.
- Tests: `tests/construction_site_test.js`, `tests/game_screen_test.js`,
`tests/modals_test.js`, `tests/testing_mode_test.js`.
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`POLITICS_PERFORMANCE.md`, `ICONS.md` (repo root).
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">computer</text>
</svg>

After

Width:  |  Height:  |  Size: 478 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">human</text>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#5a3a1f" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">ICBM</text>
</svg>

After

Width:  |  Height:  |  Size: 474 B

+5 -1
View File
@@ -1 +1,5 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M18.78 25.97c2.6 25.278 9.316 48.72 19.532 69.75l-21.218-6.564L73.53 146.72l-.843-1.782c16.737 17.29 36.797 31.38 59.438 41.593-13.546-18.845-21.597-41.677-22.03-69.28 27.78.35 50.396 8.56 69.25 22.063-.627-1.4-1.284-2.776-1.94-4.157l.72.625-34.438-86.655-2.53 33.844c-30.974-31.598-73.044-52.084-122.376-57zm236.814 31.593L217.22 160.875c-30.663 7.52-54.566 32.236-60.907 63.344l-.188-.22-106.563 39.563 74.313 74.312 93-8.03.375-.033 23.03-2.468-77.31-94.938 13.28-13.28 89.406 109.75 10.875 13.374-17.124 1.844-28.22 3.03a448.92 448.92 0 0 0 47.22 47.907c7.62-56.784 52.175-102.042 108.625-110.717a443.59 443.59 0 0 0-47.75-46.688l-2.78 26.125-1.844 17.125L321.28 270l-107.874-88.03 13.25-13.25 93.094 75.905 2.22-20.844 7.905-91.936-74.28-74.28zM401.53 301.625a111.011 111.011 0 0 0-25.436 4.156l13.875 16.908a116.11 116.11 0 0 1 23.467-5.282c-3.863-5.344-7.814-10.62-11.906-15.78zm-43.78 11.25a110.843 110.843 0 0 0-20.5 13.063l15.75 17.28a117.217 117.217 0 0 1 19.125-12.812l-14.375-17.53zm68 22.438a98.919 98.919 0 0 0-22.97 3.03l14.5 17.688c6.108-1.82 12.447-3.043 18.97-3.686-3.387-5.76-6.865-11.445-10.5-17.03zm-102.03 3.53a110.815 110.815 0 0 0-16.845 25.344l17.313 14.125c4.28-7.958 9.44-15.37 15.375-22.093l-15.844-17.376zm60.467 6.282c-6.67 3.247-12.908 7.267-18.625 11.875l16.282 17.875a102.325 102.325 0 0 1 17.312-11.47l-14.97-18.28zm-32 24.938a98.164 98.164 0 0 0-13.25 20.28l17.97 14.688a102.341 102.341 0 0 1 11.624-17l-16.342-17.967zm94.094.218v.22c-45.915 0-82.96 36.985-83.06 82.875a444.741 444.741 0 0 0 127.06 44.313c-8.284-45.328-23.3-88.145-44-127.407zM299.94 382.626a111.035 111.035 0 0 0-3.97 26.938 448.277 448.277 0 0 0 15.063 11.343c.93-8.58 2.75-16.88 5.44-24.812l-16.533-13.47zm32.156 26.25a98.892 98.892 0 0 0-3.094 24.375 447.679 447.679 0 0 0 16.063 9.938 101.51 101.51 0 0 1 4.218-20.282l-17.186-14.03z" fill="#c9c9c9" stroke="#000000" stroke-opacity="1" stroke-width="5" fill-opacity="1"></path></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#4a2b2b" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">missile</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 477 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">space_launch_center</text>
</svg>

After

Width:  |  Height:  |  Size: 489 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">spy</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">submarine</text>
</svg>

After

Width:  |  Height:  |  Size: 479 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b4a35" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="251" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="301" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">uranium</text>
<text x="256" y="351" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">enrichment</text>
</svg>

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+124
View File
@@ -157,6 +157,15 @@ input:focus, select:focus { border-color: var(--accent); }
.aero:disabled { opacity: 0.4; cursor: default; filter: none; }
.aero.primary { background: linear-gradient(180deg, rgba(110, 175, 255, 0.7), rgba(40, 95, 175, 0.9)); }
.aero.big { display: block; width: 100%; margin: 10px 0; padding: 14px; font-size: 17px; }
/* A toggled-on action (an armed nuclear warhead) reads hot. */
.aero.active {
background: linear-gradient(180deg, rgba(255, 120, 90, 0.9), rgba(150, 30, 25, 0.95));
border-color: rgba(255, 150, 120, 0.85);
box-shadow: 0 0 0 1px rgba(255, 90, 70, 0.6), 0 0 14px rgba(255, 70, 50, 0.55);
}
.aero.active:hover { filter: brightness(1.1); }
/* The ranged Attack action stands apart: amber while offered, hot while the
player is picking a target. */
.aero.attack { background: linear-gradient(180deg, rgba(255, 160, 90, 0.85), rgba(175, 80, 25, 0.95)); border-color: rgba(255, 180, 120, 0.8); }
.aero.attack.active {
background: linear-gradient(180deg, rgba(255, 120, 90, 0.95), rgba(150, 30, 25, 0.98));
@@ -1125,6 +1134,20 @@ input:focus, select:focus { border-color: var(--accent); }
.tile-gdp-mods { margin-top: 2px; }
.tile-gdp-mod { font-size: 12px; color: var(--muted); padding-left: 12px; font-variant-numeric: tabular-nums; }
#tile-panel .row, #unit-panel .row { justify-content: flex-start; margin-top: 10px; }
/* The spy action row can hold three operations with longer names than the other
unit actions, so it wraps and its buttons sit tighter to fit the narrow panel. */
#unit-panel #unit-spy-actions { flex-wrap: wrap; gap: 6px; margin-top: 6px; }
#unit-panel #unit-spy-actions .aero { padding: 5px 10px; font-size: 12px; }
/* The "blame someone else" picker beside the spy actions. */
#unit-panel #unit-spy-actions .spy-frame {
flex: 1 1 100%;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(10, 14, 22, 0.9);
color: var(--text);
font-size: 12px;
}
.health-row { display: flex; align-items: center; gap: 8px; }
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; }
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
@@ -1341,6 +1364,62 @@ input:focus, select:focus { border-color: var(--accent); }
color: #ffcf7a;
}
/* ---------------------------------------------------------------- chat -- */
/* Bottom-centre in-game chat. The log is anchored to the bottom so new
messages appear beside the input and older ones stack upward. */
.chat {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
width: min(560px, 60vw);
z-index: 65;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.chat.hidden { display: none; }
.chat-log {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 30vh;
overflow-y: auto;
padding: 8px 10px;
border-radius: 8px;
background: rgba(10, 14, 22, 0.72);
border: 1px solid var(--panel-border);
pointer-events: auto;
}
.chat-line { font-size: 13px; line-height: 1.4; color: var(--text); }
.chat-line .chat-author { color: #ffcf7a; font-weight: 600; margin-right: 6px; }
.chat-line.chat-system .chat-author { color: var(--muted); }
.chat-line.chat-private .chat-author { color: #c9a0ff; }
.chat-compose { display: flex; gap: 6px; }
.chat-target {
flex: 0 0 auto;
max-width: 130px;
padding: 8px 6px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: rgba(10, 14, 22, 0.9);
color: var(--text);
font-size: 12px;
}
.chat-input {
pointer-events: auto;
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: rgba(10, 14, 22, 0.9);
color: var(--text);
font-size: 13px;
}
.chat-input:focus { outline: none; border-color: #6eafff; }
/* --------------------------------------------------------------- modals -- */
.modal {
@@ -1734,6 +1813,49 @@ input:focus, select:focus { border-color: var(--accent); }
.diplomacy-table tbody tr.local-nation td.name { font-weight: 600; }
.diplomacy-table td.status { white-space: nowrap; }
.diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; }
/* Who commands a nation: a human or the computer, plus the leader's name. */
.diplomacy-table td.control { width: 1%; white-space: nowrap; }
.diplomacy-table .control-icon { width: 20px; height: 20px; vertical-align: middle; }
.diplomacy-table td.leader { white-space: nowrap; }
/* Diplomacy tab: treaty offers, the compose row, and the active/past lists. */
.treaty-panel { padding: 4px 10px 14px; }
.treaty-heading { color: var(--muted); font-weight: 600; margin: 12px 0 6px; }
.treaty-compose {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--panel-border);
}
.treaty-compose-title { color: var(--muted); font-weight: 600; }
.treaty-compose select,
.treaty-compose input[type="number"] {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid var(--panel-border);
background: rgba(10, 14, 22, 0.9);
color: var(--text);
}
#treaty-payload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.treaty-resource-row,
.treaty-unit-row { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.treaty-list { display: flex; flex-direction: column; gap: 4px; }
.treaty-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.treaty-row .treaty-text { flex: 1; }
.treaty-row .aero { padding: 4px 10px; }
/* A news-feed line that opens a dialog when clicked. */
.feed-clickable { cursor: pointer; text-decoration: underline dotted; }
/* A foreign figure's equivalent in our money, kept quiet beside it. */
.conversion { color: var(--muted); font-size: 0.85em; }
@@ -1903,6 +2025,8 @@ input:focus, select:focus { border-color: var(--accent); }
font-size: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
/* A row's explanatory sub-line, e.g. a covert operation's description. */
.politics-row .desc { color: var(--muted); font-size: 12px; margin: 2px 0 0; }
.politics-penalty { color: #f0b060; }
.famine-warning { color: #e05a4a; font-weight: 600; }
.ethnic-chart { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 8px; }
+37
View File
@@ -174,6 +174,17 @@
<!-- Right-click menu listing the units sharing a tile -->
<div id="stack-menu" class="stack-menu hidden"></div>
<!-- In-game chat, opened with T. Messages read bottom to top, newest by
the input, which sits at the bottom centre of the screen. -->
<div id="chat" class="chat hidden">
<div id="chat-log" class="chat-log"></div>
<div class="chat-compose">
<select id="chat-target" class="chat-target" title="Who hears this line"></select>
<input id="chat-input" class="chat-input" type="text" maxlength="240"
autocomplete="off" placeholder="Say something… (Enter to send, Esc to close)" />
</div>
</div>
<!-- Lower-left dock: the news feed sits above the selected-unit panel. -->
<div id="left-dock">
<div class="game-corner">
@@ -216,8 +227,11 @@
<div class="row">
<button id="unit-pillage" class="aero hidden">Pillage</button>
<button id="unit-trench" class="aero hidden">Dig trench</button>
<button id="unit-nuclear" class="aero hidden">Nuclear warhead</button>
<button id="unit-attack-order" class="aero attack hidden">Attack</button>
</div>
<!-- Covert operations for a selected spy, built from SPY_OPERATIONS. -->
<div id="unit-spy-actions" class="row hidden"></div>
</div>
</div>
</div>
@@ -342,6 +356,7 @@
<button class="tab" data-tab="technology">Technology</button>
<button class="tab" data-tab="diplomacy">Diplomacy</button>
<button class="tab" data-tab="politics">Politics</button>
<button class="tab" data-tab="intelligence">Intelligence</button>
<button class="tab" data-tab="cities">Cities</button>
<button class="tab" data-tab="conflicts">Ongoing conflicts</button>
<button class="tab" data-tab="budget">Budget</button>
@@ -360,11 +375,33 @@
<div class="tab-page" id="tab-diplomacy">
<div id="diplomacy-sub" class="modal-sub"></div>
<div id="diplomacy-list" class="modal-body"></div>
<!-- Treaties live outside #diplomacy-list, which the snapshot
refresh rebuilds, so the compose controls keep their input. -->
<div id="treaty-panel" class="treaty-panel">
<div class="treaty-compose">
<span class="treaty-compose-title">Propose a treaty</span>
<select id="treaty-type"></select>
<select id="treaty-target"></select>
<span id="treaty-payload"></span>
<button id="treaty-propose" class="aero">Propose</button>
</div>
<div id="treaty-pending" class="treaty-list"></div>
<div class="treaty-heading">Active treaties</div>
<div id="treaty-active" class="treaty-list"></div>
<div class="treaty-heading">Past treaties</div>
<div id="treaty-past" class="treaty-list"></div>
</div>
</div>
<div class="tab-page" id="tab-politics">
<div id="politics-sub" class="modal-sub"></div>
<div id="politics-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-intelligence">
<div id="intelligence-sub" class="modal-sub"></div>
<div id="intelligence-subtabs" class="subtabs"></div>
<div id="intelligence-spies-view" class="modal-body"></div>
<div id="intelligence-propaganda-view" class="modal-body hidden"></div>
</div>
<div class="tab-page" id="tab-cities">
<div id="cities-sub" class="modal-sub"></div>
<div id="cities-list" class="modal-body"></div>
+3
View File
@@ -278,6 +278,9 @@ network.on("game_list_received", (list) => renderGames(list));
network.on("game_state_received", (state) => {
if (gameScreen) gameScreen.onState(state);
});
network.on("chat_message", (message) => {
if (gameScreen) gameScreen.addChat(message);
});
network.on("server_disconnected", () => {
$("#disconnect-banner").text("Disconnected from the server.").removeClass("hidden");
});
+54 -5
View File
@@ -19,8 +19,18 @@ const EFFECT_SOURCES = {
bomb: "assets/sounds/death_flash.flac",
};
// Crashed Ship (Ted Kerr), looped softly under the world map.
const MUSIC_SOURCE = "assets/sounds/crashed_ship.ogg";
// Tracks played softly under the world map. They are shuffled into a playlist
// and played one after another, so a long session hears both.
const MUSIC_SOURCES = [
// Crashed Ship, copyright Ted Kerr.
"assets/sounds/crashed_ship.ogg",
// Dark City, by Muncheybobo, used under CC BY 3.0.
"assets/sounds/dark_city.ogg",
// Constellation, by Trevor Lentz, used under CC BY-SA 3.0.
"assets/sounds/constellation.ogg",
// Patrol, by Alexandr Zhelanov, used under CC BY 3.0.
"assets/sounds/patrol.ogg",
];
const EFFECT_VOLUMES = { click: 0.35, complete: 0.6, notification: 0.6 };
const MUSIC_VOLUME = 0.3;
@@ -45,6 +55,17 @@ function clamp01(value) {
return Math.min(1, Math.max(0, value));
}
// Fisher-Yates over the track indexes, so each pass over the playlist visits
// every track once in a different order.
function shuffledIndexes(count, random) {
const order = Array.from({ length: count }, (_, index) => index);
for (let i = order.length - 1; i > 0; i -= 1) {
const j = Math.floor(random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
return order;
}
// A real AudioContext, or null where Web Audio is unavailable (jsdom, an old
// browser). Kept in a function so merely importing this module never touches it.
function defaultContextFactory() {
@@ -65,11 +86,15 @@ export class AudioManager {
this.presets = Object.fromEntries(
Object.entries(EFFECT_PRESETS).map(([name, preset]) => [name, { ...preset }])
);
this.musicSource = MUSIC_SOURCE;
this.musicSources = [...MUSIC_SOURCES];
// Whether the game has asked for music; kept apart from `enabled` so the
// sound toggle can mute and unmute without losing the request.
this._wanted = false;
this._music = null;
// The shuffled order of track indexes and how far into it playback is.
this._musicOrder = [];
this._musicCursor = 0;
this._random = options.random || Math.random;
this._retryArmed = false;
// Web Audio context for the shaped effects, built on first use. `null`
// after a failure so the plain element path is used from then on.
@@ -259,10 +284,22 @@ export class AudioManager {
_startMusic() {
if (!this.enabled || !this._wanted || !this.supported() || this._music) return;
const source = this._nextMusicSource();
if (!source) return;
try {
const music = new Audio(this.musicSource);
music.loop = true;
const music = new Audio(source);
music.volume = MUSIC_VOLUME;
if (this.musicSources.length <= 1) {
// A lone track has nothing to hand over to, so it loops.
music.loop = true;
} else {
// Otherwise the end of a track starts the next one in the shuffle.
music.onended = () => {
if (this._music !== music) return;
this._music = null;
this._startMusic();
};
}
this._music = music;
const playback = music.play();
if (playback && typeof playback.catch === "function") {
@@ -276,6 +313,18 @@ export class AudioManager {
}
}
// The next track of the shuffled playlist, reshuffling once a full pass ends.
_nextMusicSource() {
if (this.musicSources.length === 0) return null;
if (this._musicCursor >= this._musicOrder.length) {
this._musicOrder = shuffledIndexes(this.musicSources.length, this._random);
this._musicCursor = 0;
}
const index = this._musicOrder[this._musicCursor];
this._musicCursor += 1;
return this.musicSources[index];
}
_releaseMusic() {
if (this._music) {
try {
+12
View File
@@ -63,6 +63,18 @@ export const CREDIT_SECTIONS = [
name: "Crashed Ship",
detail: "in-game background music, copyright Ted Kerr",
},
{
name: "Dark City",
detail: "in-game background music, by Muncheybobo, used under CC BY 3.0",
},
{
name: "Constellation",
detail: "in-game background music, by Trevor Lentz, used under CC BY-SA 3.0",
},
{
name: "Patrol",
detail: "in-game background music, by Alexandr Zhelanov, used under CC BY 3.0",
},
{
name: "UI Sound Library",
detail: "button, completion and notification effects",
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "54efd66",
"date": "2026-09-24",
"subject": "Grouped the unit stack by type and let ranged units pick a target",
"body": ""
},
{
"hash": "1c6f0ae",
"date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Turned unit upkeep into a resource bill and gave research the erlenmeyer icon",
"body": ""
},
{
"hash": "15d0c38",
"date": "2026-09-23",
"subject": "Gave figures a unified signed display and templated the resource table",
"body": ""
}
];
+122 -6
View File
@@ -48,6 +48,8 @@ export class GameScreen {
this._regionVersion = undefined;
this._tileProductionPenalties = new Map();
this._tileBattleProductionDeficits = new Map();
// Tiles crippled by sabotage, keyed "x,y".
this._tileSabotage = new Set();
this._territoryOwner = new Map();
this._tileRegion = new Map();
this._gdpExpanded = false;
@@ -77,6 +79,9 @@ export class GameScreen {
// Previous snapshot's strike state per unit, to spot a plane's bomb run
// ending (see `_detectAirStrikes`).
this._airStrikeState = null;
// Whether the next missile attack should carry a nuclear warhead (see the
// unit panel's toggle).
this._nuclearPayload = false;
// While set, the map is picking a ranged-strike target: { ids } of the
// units that will fire. See the Attack action in the unit panel.
this._targeting = null;
@@ -117,9 +122,14 @@ export class GameScreen {
this.$stackMenu = $("#stack-menu");
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
this.$chat = $("#chat");
this.$chatLog = $("#chat-log");
this.$chatInput = $("#chat-input");
this.$chatTarget = $("#chat-target");
this._bindMap();
this._bindHud();
this._bindChat();
}
enter() {
@@ -158,6 +168,7 @@ export class GameScreen {
this._hideStackMenu();
this.mapModesModal.close();
$(document).off("mousedown.stackmenu");
this.closeChat();
}
// ------------------------------------------------------------- orders ---
@@ -181,6 +192,7 @@ export class GameScreen {
this.civilisations = state.civs.map(
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
);
this._updateChatTargets(this.civilisations, this.localCiv);
this.protoUnits = state.protos.map(
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
);
@@ -225,6 +237,7 @@ export class GameScreen {
this._tileBattleProductionDeficits = new Map(
(state.tileBattleProductionDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
this._tileSabotage = new Set((state.tileSabotage || []).map((e) => `${e[0]},${e[1]}`));
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
this._cityBuildings = new Map();
for (const city of state.cities) {
@@ -369,15 +382,15 @@ export class GameScreen {
return !!(proto && proto.transportCapacity > 0);
}
// Right-click transport orders: infantry board a friendly launch they stand
// beside, and a loaded launch puts its passengers ashore on an adjacent land
// tile. Returns true when the click was consumed.
// Right-click transport orders: infantry (and spies) board a friendly launch
// they stand beside, and a loaded launch puts its passengers ashore on an
// adjacent land tile. Returns true when the click was consumed.
_tryTransportOrder(units, coords) {
const infantry = units.every((unit) => {
const embarkable = units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return proto && proto.infantry;
return proto && (proto.infantry || proto.spy);
});
if (infantry) {
if (embarkable) {
const launch = (this.snapshot.units || []).find(
(unit) =>
unit.civ === this.localCiv &&
@@ -447,6 +460,7 @@ export class GameScreen {
if (!proto) return false;
if (striking) {
const from = { x: unit.coords[0], y: unit.coords[1] };
if (proto.globalRange) return false;
return this.map.topology.tileDistance(from, coords) > (proto.range || 1);
}
const route = routes.get(unit.id);
@@ -479,6 +493,16 @@ export class GameScreen {
const order = { type, coords: [coords.x, coords.y] };
if (ids.length === 1) order.unit = ids[0];
else order.units = ids;
// A missile volley carries the warhead the player armed: nuclear only when
// the toggle is on and every unit in the stack is a missile.
if (
type === "attack" &&
this._nuclearPayload &&
units.length > 0 &&
units.every((unit) => this._isMissileUnit(unit))
) {
order.nuclear = true;
}
if (type === "schedule") {
order.goals = [[coords.x, coords.y]];
order.append = true;
@@ -515,10 +539,102 @@ export class GameScreen {
this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) });
}
// --------------------------------------------------------------- chat ---
// Wires the chat input: Enter sends the line, Escape closes the panel.
_bindChat() {
if (!this.$chatInput || !this.$chatInput.length) return;
this.$chatInput.on("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
const text = this.$chatInput.val();
this.$chatInput.val("");
this.network.sendChat(text, this._chatRecipient());
} else if (event.key === "Escape") {
event.preventDefault();
this.closeChat();
}
event.stopPropagation();
});
}
// T opens the chat: the log fades in at the bottom centre and the input takes
// focus so the player can type straight away.
openChat() {
if (!this.$chat || !this.$chat.length) return;
this.$chat.removeClass("hidden");
this.$chatInput.trigger("focus");
}
closeChat() {
if (!this.$chat || !this.$chat.length) return;
this.$chat.addClass("hidden");
this.$chatInput.trigger("blur");
}
// The nation a chat line is addressed to, or null for the public channel.
_chatRecipient() {
if (!this.$chatTarget || !this.$chatTarget.length) return null;
const value = this.$chatTarget.val();
if (value === "" || value === null || value === undefined || value === "public") return null;
const civ = Number(value);
return Number.isInteger(civ) && civ >= 0 ? civ : null;
}
// Rebuilds the chat recipient list: the public channel plus every other
// nation. Rebuilt only when the roster or the viewer changes.
_updateChatTargets(civs, localCiv) {
if (!this.$chatTarget || !this.$chatTarget.length) return;
const signature = `${localCiv}:${civs.map((civ) => civ.id).join(",")}`;
if (this._chatTargetSignature === signature) return;
this._chatTargetSignature = signature;
const previous = this.$chatTarget.val();
this.$chatTarget.empty();
this.$chatTarget.append($("<option value='public'></option>").text("Everyone"));
civs.forEach((civ, index) => {
if (index === localCiv) return;
this.$chatTarget.append($("<option></option>").attr("value", index).text(civ.name));
});
if (previous !== null && previous !== undefined) this.$chatTarget.val(previous);
}
// Appends one line to the chat log. Messages are read bottom to top: the
// newest sits by the input, the oldest scroll off the top, and the list is
// capped so a long game cannot grow it without bound.
addChat(message) {
if (!this.$chatLog || !this.$chatLog.length) return;
const system = !message.name;
const $line = $("<div class='chat-line'></div>");
if (system) $line.addClass("chat-system");
const to = Number.isInteger(message.to) ? message.to : null;
const $author = $("<span class='chat-author'></span>").text(system ? "·" : message.name);
if (to !== null) {
const target = this.civilisations[to];
$line.addClass("chat-private");
$author.text(`${message.name} → ${target ? target.name : `Nation ${to + 1}`}`);
}
$line.append($author);
$line.append($("<span class='chat-text'></span>").text(message.text || ""));
this.$chatLog.append($line);
const maxLines = 100;
const children = this.$chatLog.children();
if (children.length > maxLines) {
children.slice(0, children.length - maxLines).remove();
}
this.$chatLog.scrollTop(this.$chatLog[0].scrollHeight);
}
// --------------------------------------------------------------- input ---
_onKey(event) {
if (!this._entered) return;
// A text field owns its own keys, so typing never pans the map.
if ($(event.target).is("input, textarea")) return;
if (event.key === "t" || event.key === "T") {
this.openChat();
event.preventDefault();
return;
}
const step = event.shiftKey ? 120 : 40;
switch (event.key) {
case "w": case "ArrowUp": this.map.panBy(0, step); break;
+474 -13
View File
@@ -15,6 +15,12 @@ import {
NEWS_TECHNOLOGY,
NEWS_JOIN,
NEWS_FAMINE,
NEWS_SABOTAGE,
NEWS_SATELLITE,
NEWS_ENRICHMENT,
NEWS_TREATY,
TREATY_PROPOSED,
treatyById,
ECONOMY,
EFFECT_PRODUCTION,
APPROVAL,
@@ -30,6 +36,11 @@ import {
RESOURCE_RULES,
resourceBuildingOutputAt,
tileImprovementById,
SPY_OPERATIONS,
SPY_SABOTAGE,
SPY_CITY,
SPY_COMMS,
BUILDING_MECHANIC,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
@@ -80,6 +91,8 @@ export const panelMethods = {
this.sendOrder({ type: "government", government: index });
this.nationModal.onFocusTechnology = (index) =>
this.sendOrder({ type: "focus_technology", technology: index });
this.nationModal.onUnlockTechnology = (index) =>
this.sendOrder({ type: "unlock_technology", technology: index, free: true });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
@@ -88,6 +101,9 @@ export const panelMethods = {
this.sendOrder({ type: "cancel_campaign", campaign });
this.nationModal.onSetCampaignSpending = (campaign, amount) =>
this.sendOrder({ type: "campaign_spending", campaign, amount });
this.nationModal.onLocateUnit = (id) => this._locateUnit(id);
this.nationModal.onLaunchSatellite = (cityId) =>
this.sendOrder({ type: "launch_satellite", city: cityId });
this.nationModal.onConfirm = (title, message, onConfirm) =>
this.confirmModal.show(title, message, onConfirm);
this.nationModal.onPolicy = (policy, ethnicity) =>
@@ -98,6 +114,12 @@ export const panelMethods = {
this.sendOrder({ type: "set_interest_rate", rate });
this.nationModal.onSetTaxRate = (kind, rate, resource) =>
this.sendOrder({ type: "set_tax_rate", kind, rate, resource });
this.nationModal.onProposeTreaty = (type, to, payload) =>
this.sendOrder({ type: "propose_treaty", treaty: type, to, payload });
this.nationModal.onRespondTreaty = (proposal, accept) =>
this.sendOrder({ type: "respond_treaty", proposal, accept });
this.nationModal.onCancelTreaty = (treatyId) =>
this.sendOrder({ type: "cancel_treaty", treatyId });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
@@ -110,6 +132,7 @@ export const panelMethods = {
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-trench").on("click", () => this._digTrenchSelected());
$("#unit-nuclear").on("click", () => this._toggleNuclearPayload());
$("#unit-attack-order").on("click", () => this._toggleTargeting());
// Every summary stat explains itself on hover through the shared bubble.
@@ -371,6 +394,180 @@ export const panelMethods = {
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
},
// A missile warhead is conventional unless the player arms it otherwise. The
// toggle only shows for a nuclear power with missiles selected.
_updateNuclearButton(units) {
const $button = $("#unit-nuclear");
if (!$button.length) return;
const missiles = units.length > 0 && units.every((unit) => this._isMissileUnit(unit));
const armed = missiles && this._hasTechnology("nuclear_weapons");
$button.toggleClass("hidden", !armed);
$button.toggleClass("active", armed && !!this._nuclearPayload);
},
_toggleNuclearPayload() {
this._nuclearPayload = !this._nuclearPayload;
const units = this._selectedUnits();
this._updateNuclearButton(units);
},
// The covert operations a spy may run, as buttons in the unit panel. Built
// once from the shared catalogue; disabled until each action is wired end to
// end (see INTELLIGENCE.md), so the player sees where they will live.
_buildSpyActionButtons() {
const $actions = $("#unit-spy-actions");
if (!$actions.length || $actions.data("built")) return;
$actions.data("built", true);
for (const operation of SPY_OPERATIONS) {
const $button = $("<button class='aero'></button>")
.attr("data-operation", operation.id)
.attr("title", "Not yet available")
.text(operation.name)
.prop("disabled", true);
$button.on("click", () => this._runSpyOperation(operation.id));
$actions.append($button);
}
// A saboteur may try to pin the act on another nation. It is a bluff either
// way: the victim cannot tell a true blame from a false flag.
const $frame = $("<select class='spy-frame' id='unit-sabotage-frame'></select>")
.attr("title", "Pin the sabotage on another nation");
$frame.append($("<option value=''></option>").text("Blame no one"));
this.civilisations.forEach((civ, index) => {
if (index === this.localCiv) return;
$frame.append($("<option></option>").attr("value", index).text(civ.name));
});
$actions.append($frame);
},
// Spy actions show only for a stack of spies. Each is enabled when the spy is
// in position; city and comms spying then pick a target on the map.
_updateSpyActions(units) {
const $actions = $("#unit-spy-actions");
if (!$actions.length) return;
this._buildSpyActionButtons();
const spies = units.length > 0 && units.every((unit) => this._isSpy(unit));
$actions.toggleClass("hidden", !spies);
if (!spies) return;
const armed = this._targeting ? this._targeting.operation : null;
for (const operation of SPY_OPERATIONS) {
const $button = $actions.find(`[data-operation="${operation.id}"]`);
if (!$button.length) continue;
let enabled = false;
let hint = "Not yet available";
if (operation.id === SPY_SABOTAGE) {
enabled = this._canSabotage(units);
hint = enabled
? "Cripple this tile's production for a week (spends the spies)"
: "Move the spy onto foreign land";
} else if (operation.id === SPY_CITY) {
enabled = this._spyActionTarget(SPY_CITY, units);
hint = enabled
? "Read a neighbouring foreign city (spends the spies)"
: "Move the spy beside a foreign city";
} else if (operation.id === SPY_COMMS) {
enabled = this._spyActionTarget(SPY_COMMS, units);
hint = enabled
? "Intercept a neighbouring capital's messages (spends the spies)"
: "Move the spy beside a foreign capital";
}
const active = armed === operation.id;
$button
.prop("disabled", !enabled)
.attr("title", hint)
.toggleClass("active", active)
.text(active ? `${operation.name} — pick a target` : operation.name);
}
},
_runSpyOperation(operation) {
if (operation === SPY_SABOTAGE) this._sabotageSelected();
else if (operation === SPY_CITY || operation === SPY_COMMS) this._toggleSpyOperation(operation);
},
// Whether the selected stack may sabotage the tile it stands on: all spies,
// sharing one foreign land tile. Mirrors GameState.canSabotage.
_canSabotage(units) {
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1])) {
return false;
}
const k = `${coords[0]},${coords[1]}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
if (owner === null || owner === undefined || owner === this.localCiv) return false;
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
return !!tile && tile.terrainClass === "Land";
},
_sabotageSelected() {
const units = this._selectedUnits();
if (!this._canSabotage(units)) return;
const raw = $("#unit-sabotage-frame").val();
const frame = raw === "" || raw === null || raw === undefined ? null : Number(raw);
const order = { type: "sabotage", units: units.map((unit) => unit.id) };
if (Number.isInteger(frame) && frame >= 0) order.frame = frame;
this.sendOrder(order);
},
// The city standing on `coords`, from the snapshot.
_cityAtCoords(coords) {
return (
(this.snapshot.cities || []).find(
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
) || null
);
},
// Mirrors GameState.canCitySpy: a foreign city within one tile of the spy.
_canCitySpy(units, coords) {
const city = this._cityAtCoords(coords);
if (!city || city.civ === this.localCiv) return false;
const from = units[0].coords;
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
},
// Mirrors GameState.canCommsSpy: a foreign capital within one tile.
_canCommsSpy(units, coords) {
const city = this._cityAtCoords(coords);
if (!city || !city.isCapital || city.civ === this.localCiv) return false;
const from = units[0].coords;
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
},
// Whether any tile the spy can watch satisfies the operation's target.
_spyActionTarget(operation, units) {
if (units.length === 0) return false;
const from = units[0].coords;
const centre = { x: from[0], y: from[1] };
const candidates = [centre, ...this.map.topology.neighbours(centre.x, centre.y)];
return candidates.some((coords) =>
operation === SPY_CITY
? this._canCitySpy(units, coords)
: this._canCommsSpy(units, coords)
);
},
// Arms target-picking for a spy operation that points at a neighbouring city.
_toggleSpyOperation(operation) {
const units = this._selectedUnits();
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return;
if (this._targeting && this._targeting.operation === operation) {
this._endTargeting();
return;
}
if (!this._spyActionTarget(operation, units)) return;
const ids = units.map((unit) => unit.id);
const coords = units[0].coords;
this._targeting = { ids, operation };
this.map.beginTargeting({
unitIds: ids,
origin: { x: coords[0], y: coords[1] },
range: 1,
globalRange: false,
});
this._updateSpyActions(units);
},
// The Attack action, offered only when every selected unit is the same type
// and that type strikes at range. It enters target-picking mode rather than
// firing straight away.
@@ -398,7 +595,7 @@ export const panelMethods = {
if (!this._isRangedUnit(units[0])) return;
const proto = this.protoUnits[units[0].proto];
const ids = units.map((unit) => unit.id);
this._targeting = { ids };
this._targeting = { ids, operation: "attack" };
this.map.beginTargeting({
unitIds: ids,
origin: { x: units[0].coords[0], y: units[0].coords[1] },
@@ -413,20 +610,47 @@ export const panelMethods = {
this._targeting = null;
this.map.clearTargeting();
this._updateAttackButton(this._selectedUnits());
this._updateSpyActions(this._selectedUnits());
},
// A left-click in target-picking mode: fire when the tile holds a hostile
// military unit, otherwise just leave the mode.
// A left-click in target-picking mode. A ranged attack fires when the tile
// holds a hostile military unit; a spy operation files its report or opens its
// bombardment when the tile holds the city it wants.
_chooseTarget(coords) {
const targeting = this._targeting;
if (!targeting) return;
const units = targeting.ids.map((id) => this._unit(id)).filter(Boolean);
this._endTargeting();
if (units.length === 0) return;
if (targeting.operation === SPY_CITY) {
if (this._canCitySpy(units, coords)) {
this.sendOrder({
type: "spy_city",
units: units.map((unit) => unit.id),
coords: [coords.x, coords.y],
});
}
return;
}
if (targeting.operation === SPY_COMMS) {
if (this._canCommsSpy(units, coords)) {
this.sendOrder({
type: "spy_comms",
units: units.map((unit) => unit.id),
coords: [coords.x, coords.y],
});
}
return;
}
if (!this._isEnemyMilitaryAt(coords)) return;
this._orderMove(units, coords, "attack");
},
_isMissileUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.missile);
},
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
@@ -771,7 +995,9 @@ export const panelMethods = {
this._renderStackList(members);
this._updatePillageButton(units);
this._updateTrenchButton(units);
this._updateNuclearButton(units);
this._updateAttackButton(units);
this._updateSpyActions(units);
this._updateCargo(units);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
@@ -976,7 +1202,9 @@ export const panelMethods = {
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
this._updateTrenchButton([unit]);
this._updateNuclearButton([unit]);
this._updateAttackButton([unit]);
this._updateSpyActions([unit]);
this._updateCargo([unit]);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
@@ -1724,6 +1952,7 @@ export const panelMethods = {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && this._isNextToCity(coords, owner),
pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
sabotaged: this._tileSabotage.has(k),
battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
trench: this.map.trenches.has(k),
regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
@@ -1957,10 +2186,11 @@ export const panelMethods = {
this._updateCityModal();
},
// The units a city may train right now. Aircraft and warships only appear
// once the city has the installation that trains them, so the order can never
// be sent and rejected. Recomputed each snapshot so a building finished while
// the modal is open unlocks its unit without reopening the panel.
// The units a city may train. A unit whose building or research is missing is
// still listed, marked `locked` with the requirement, so the player can see
// what a city could build and what it needs; only the unlocked ones take an
// order. Recomputed each snapshot so a building or technology finished while
// the modal is open flips its card without reopening the panel.
_trainableUnits(city) {
const levels = this._cityBuildings.get(city.id) || {};
const hasBuilding = (id) => {
@@ -1969,8 +2199,42 @@ export const panelMethods = {
};
return TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
if (index < 0) return null;
const proto = this.protoUnits[index];
const requirement = this._unitRequirement(proto, hasBuilding);
return {
...proto,
serverIndex: index,
locked: !!requirement,
requiresLabel: requirement,
};
}).filter(Boolean);
},
// The first unmet requirement on a trainable unit, as a short label ("Requires
// Airport"), or an empty string when nothing blocks it.
_unitRequirement(proto, hasBuilding) {
if (proto.requiresBuilding && !hasBuilding(proto.requiresBuilding)) {
const building = BUILDINGS.find((entry) => entry.id === proto.requiresBuilding);
return `Requires ${building ? building.name : proto.requiresBuilding}`;
}
const technologies = Array.isArray(proto.requiresTechnologies)
? proto.requiresTechnologies
: proto.requiresTechnology
? [proto.requiresTechnology]
: [];
const missing = technologies.find((id) => !this._hasTechnology(id));
if (missing) {
const tech = TECHNOLOGIES.find((entry) => entry.id === missing);
return `Requires ${tech ? tech.name : missing}`;
}
return "";
},
// Whether the nation the viewer controls has researched a technology, by id.
_hasTechnology(id) {
const index = TECHNOLOGIES.findIndex((tech) => tech.id === id);
return index >= 0 && this._researched.has(index);
},
_cityView(city) {
@@ -2143,7 +2407,8 @@ export const panelMethods = {
this._politicsInfo(),
this._techInfo(),
this._resourcesInfo(),
this._moneyInfo()
this._moneyInfo(),
this._intelligenceInfo()
);
},
@@ -2202,11 +2467,26 @@ export const panelMethods = {
// Progress is kept per technology, so a focus that was switched away from
// still shows what it had committed when it is selected again.
const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
// The buildings the viewer's cities hold, so a technology gated behind one
// (nuclear weapons behind the uranium enrichment centre) shows as locked.
const buildings = new Set();
for (const levels of this._cityBuildings.values()) {
for (const index of Object.keys(levels)) {
if ((levels[index] || 0) > 0) {
const proto = BUILDINGS[Number(index)];
if (proto) buildings.add(proto.id);
}
}
}
return {
focus: focusIndex,
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
progressByTech: byTech,
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
buildings,
// In a local testing game the technology cards gain an instant-unlock
// button, like the free build and train buttons.
testing: !!this.testing,
};
},
@@ -2225,16 +2505,33 @@ export const panelMethods = {
this._politicsInfo(),
this._techInfo(),
this._resourcesInfo(),
this._moneyInfo()
this._moneyInfo(),
this._intelligenceInfo()
);
},
_diplomacyInfo() {
const snapshot = this.snapshot || {};
const stats = snapshot.viewerStats || {};
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
stats: (this.snapshot && this.snapshot.civStats) || [],
relations: (snapshot.diplomacy) || [],
stats: snapshot.civStats || [],
// Who is behind each nation: the network's player table, keyed by peer id,
// each entry naming the nation they claimed. A nation with no player is AI.
players: (this.network && this.network.players) || {},
// Treaties: offers awaiting an answer, the agreements in force and the
// ones that ended.
treaties: snapshot.treaties || [],
treatyProposals: snapshot.treatyProposals || [],
treatyHistory: snapshot.treatyHistory || [],
// What the viewer may offer: its own cities and non-infantry units, plus
// the stores it can hand over.
localCities: (snapshot.cities || []).filter((city) => city.civ === this.localCiv),
localUnits: (snapshot.units || []).filter((unit) => unit.civ === this.localCiv),
unitProtos: this.protoUnits,
resources: stats.resources || null,
};
},
@@ -2255,6 +2552,96 @@ export const panelMethods = {
};
},
// Everything the Intelligence tab needs from the snapshot: the viewer's spies
// and where they stand, the reports they have brought home, the satellites in
// orbit, the propaganda campaigns (moved off Politics) and the opinion
// matrices their readout quotes. A spy's covert operations are unit actions,
// wired in the unit panel rather than here.
_intelligenceInfo() {
const snapshot = this.snapshot || {};
const localCiv = this.localCiv;
const satellites = snapshot.satellites || [];
const own = satellites.filter((satellite) => satellite.civ === localCiv);
return {
civs: this.civilisations,
localCiv,
totalHours: snapshot.totalHours || 0,
campaigns: snapshot.campaigns || [],
opinions: snapshot.opinions || { ethnic: [], government: [] },
spies: (snapshot.units || [])
.filter((unit) => unit.civ === localCiv && this._isSpy(unit))
.map((unit) => ({
id: unit.id,
coords: unit.coords,
location: this._unitLocation(unit.coords, localCiv),
})),
reports: snapshot.spyReports || [],
satellites: own,
otherSatellites: satellites.filter((satellite) => satellite.civ !== localCiv).length,
spaceLaunchCapacity: this._spaceLaunchCapacity(),
launchCity: this._launchCity(),
};
},
// Total levels of the viewer's space launch centres, and the city it would
// launch from. Mirrors GameState._spaceLaunchCapacity. Only the viewer's own
// cities count, though `_cityBuildings` carries every nation's.
_spaceLaunchCapacity() {
let total = 0;
for (const city of this.snapshot.cities || []) {
if (city.civ !== this.localCiv) continue;
const levels = this._cityBuildings.get(city.id) || {};
for (const [index, level] of Object.entries(levels)) {
if ((level || 0) <= 0) continue;
const proto = BUILDINGS[Number(index)];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) total += level;
}
}
return total;
},
_launchCity() {
for (const city of this.snapshot.cities || []) {
if (city.civ !== this.localCiv) continue;
const levels = this._cityBuildings.get(city.id) || {};
for (const [index, level] of Object.entries(levels)) {
if ((level || 0) <= 0) continue;
const proto = BUILDINGS[Number(index)];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) return city;
}
}
return null;
},
_isSpy(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.spy);
},
// A short, player-facing description of where a unit stands: the city it is
// in, its own territory, a foreign nation, or unclaimed land.
_unitLocation(coords, localCiv) {
const city = (this.snapshot.cities || []).find(
(entry) => entry.coords[0] === coords[0] && entry.coords[1] === coords[1]
);
if (city) return `at ${city.name}`;
const k = `${coords[0]},${coords[1]}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
if (owner === null || owner === undefined) return "in unclaimed territory";
if (owner === localCiv) return "in home territory";
return `in ${this._civName(owner)}`;
},
// Brings the map to one of the viewer's units and selects it, for the
// Intelligence tab's spy roster.
_locateUnit(id) {
const unit = this._unit(id);
if (!unit || unit.civ !== this.localCiv) return;
this.map.centerOn({ x: unit.coords[0], y: unit.coords[1] });
this._selectUnits([unit.id]);
this.nationModal.close();
},
_openNewsModal() {
this.newsModal.show(this._newsForModal(), this.civilisations);
},
@@ -2289,6 +2676,21 @@ export const panelMethods = {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
// An offer addressed to the player stays put and can be clicked to review it.
if (entry.type === NEWS_TREATY && entry.stage === TREATY_PROPOSED && entry.to === this.localCiv) {
const proto = treatyById(entry.treaty);
const label = proto ? proto.name : entry.treaty;
audio.play("notification");
const $line = this.feed.push(
`${this._civName(entry.from)} proposes ${label} — click to review`,
{ alert: true, ttl: -1 }
);
if ($line) {
$line.addClass("feed-clickable");
$line.on("click", () => this._openTreatyProposal(entry));
}
return;
}
// A technology the viewer finishes chimes like a building or unit; someone
// else taking control of a nation is the neutral notice (Mouth_08).
if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) {
@@ -2299,12 +2701,71 @@ export const panelMethods = {
audio.play("notification");
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
return;
} else if (entry.type === NEWS_SABOTAGE && entry.civ === this.localCiv) {
// The victim of a sabotage is warned; the culprit may or may not be named.
audio.play("notification");
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
return;
} else if (entry.type === NEWS_SATELLITE) {
// Every launch is public knowledge.
audio.play("notification");
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
return;
} else if (entry.type === NEWS_ENRICHMENT) {
// A secret the world's intelligence services have caught.
audio.play("notification");
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
return;
} else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
audio.play("notification");
}
this.feed.push(describeNews(entry, this.civilisations));
},
// Opens the terms of an incoming treaty offer with Sign and Reject buttons.
// The proposal is looked up by the parties it names, so a click on the feed
// line reaches the live record the server shipped.
_openTreatyProposal(entry) {
const proposals = (this.snapshot && this.snapshot.treatyProposals) || [];
const sameTerms = (p) =>
p.from === entry.from && p.to === this.localCiv && p.type === entry.treaty;
const proposal = proposals.find(
(p) => sameTerms(p) && (entry.totalHours === undefined || p.proposedHours === entry.totalHours)
) || proposals.find(sameTerms);
if (!proposal) return;
const proto = treatyById(proposal.type);
const label = proto ? proto.name : proposal.type;
const terms = this._treatyTermsText(proposal);
const message = `${proto ? proto.description : ""}${terms ? ` - ${terms}` : ""}`;
this.confirmModal.show(
`${this._civName(proposal.from)} proposes ${label}`,
message,
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: true }),
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: false }),
"Sign",
"Reject"
);
},
// A plain-text summary of what a treaty hands over, for the offer dialog.
_treatyTermsText(treaty) {
const payload = treaty.payload || {};
if (payload.city !== undefined) {
const city = (this.snapshot && this.snapshot.cities || []).find((c) => c.id === payload.city);
return `gives ${city ? city.name : "a city"}`;
}
if (payload.units) return `gives ${payload.units.length} unit(s)`;
if (payload.resources) {
const parts = Object.entries(payload.resources).map(([id, amount]) => {
const proto = resourceById(id);
return `${compact(amount)} ${proto ? proto.name : id}`;
});
return `gives ${parts.join(", ")}`;
}
if (payload.amount !== undefined) return `gives ${compact(payload.amount)} money`;
return "";
},
// Pops the viewer's own daily resource-shortage notices into the corner feed.
// Each building, region and resource is announced once per game day.
_updateResourceAlerts(stats) {
+5 -1
View File
@@ -796,7 +796,11 @@ export const motionMethods = {
return false;
}
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
// A spy slips through foreign territory at will; every other unit treats a
// third party's land as a wall (the targeted civilisation is exempt).
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv && !proto.spy) {
return false;
}
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
const hostile = occupants.filter((data) => data.civ !== civ);
if (hostile.length > 0) {
+10
View File
@@ -103,6 +103,16 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
}
}
// The real resource bills of the last day, as hourly averages, in their own
// section so they are not confused with the forecast upkeep rows above.
const resourceCosts = breakdown.resourceCosts || [];
if (resourceCosts.length > 0) {
$body.append(sectionRow("Resource costs"));
for (const entry of resourceCosts) {
$body.append(detailRow(entry.label, -entry.amount, "expense-detail"));
}
}
const $table = $("<table class='budget-table'></table>");
$table.append($body);
$table.append($("<tfoot></tfoot>").append(totalRow("TOTAL", net)));
+16 -10
View File
@@ -367,24 +367,28 @@ export class CityModal {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
const locked = !!proto.locked;
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
signature.push(serverIndex);
signature.push(`${serverIndex}:${locked ? 1 : 0}`);
this.protoNames.set(serverIndex, proto.name);
const card = buildCard({
icon: proto.icon,
name: proto.name,
status: this._unitStatus(proto),
locked,
status: locked ? proto.requiresLabel : this._unitStatus(proto),
note: this._unitNote(proto),
action: {
label: "Train",
money: this._quotedCost(proto.cost, proto, 0),
hours: hours(duration),
},
action: locked
? { label: "Locked", disabled: true }
: {
label: "Train",
money: this._quotedCost(proto.cost, proto, 0),
hours: hours(duration),
},
tip: () => this._unitTip(proto),
onAction: () => this.onTrain(this.cityId, serverIndex),
onAction: locked ? null : () => this.onTrain(this.cityId, serverIndex),
});
// In a local test game, a second button trains the unit for free.
if (this.testing) {
if (this.testing && !locked) {
const $free = $("<button class='aero bt-free free-button'></button>")
.text(`Free · ${proto.name}`)
.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
@@ -456,7 +460,9 @@ export class CityModal {
// finishes while the panel is open (an airport, say) unlocks its unit without
// reopening the city and without discarding the list's scroll position.
refreshTrainList(trainableUnits) {
const signature = trainableUnits.map((proto) => proto.serverIndex).join(",");
const signature = trainableUnits
.map((proto) => `${proto.serverIndex}:${proto.locked ? 1 : 0}`)
.join(",");
if (signature === this._trainableSignature) return;
this._renderTrainList(this.gdp, trainableUnits);
}
+16 -3
View File
@@ -6,10 +6,17 @@ export class ConfirmModal {
this.$modal = $("#modal-confirm");
this.$title = $("#confirm-title");
this.$message = $("#confirm-message");
this.$ok = this.$modal.find("#confirm-ok");
this.$cancel = this.$modal.find("#confirm-cancel");
this._onConfirm = null;
this._onCancel = null;
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#confirm-cancel").on("click", () => this.close());
this.$modal.find("#confirm-ok").on("click", () => {
this.$cancel.on("click", () => {
const callback = this._onCancel;
this.close();
if (callback) callback();
});
this.$ok.on("click", () => {
const callback = this._onConfirm;
this.close();
if (callback) callback();
@@ -20,15 +27,21 @@ export class ConfirmModal {
return !this.$modal.hasClass("hidden");
}
show(title, message, onConfirm) {
// Shows a confirmation. `onCancel` (and the button labels) are optional; a
// plain confirm keeps the default "Confirm"/"Cancel" wording.
show(title, message, onConfirm, onCancel = null, okLabel = null, cancelLabel = null) {
this.$title.text(title);
this.$message.text(message);
this.$ok.text(okLabel || "Confirm");
this.$cancel.text(cancelLabel || "Cancel");
this._onConfirm = onConfirm || null;
this._onCancel = onCancel || null;
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
this._onConfirm = null;
this._onCancel = null;
}
}
+28 -1
View File
@@ -1,7 +1,7 @@
// Shared formatting helpers for the modal panels.
import { groupDigits } from "../../../shared/text_format.js";
import { policyById, resourceById } from "../../../shared/data.js";
import { policyById, resourceById, treatyById } from "../../../shared/data.js";
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
import { formatMoneySigned, currencySymbol } from "../currency.js";
import { researchNode } from "../ui/card.js";
@@ -193,6 +193,33 @@ export function describeNews(entry, civs) {
}
case "famine":
return `${people(entry.civ)} are starving in ${entry.region}: a famine has begun`;
case "sabotage": {
const base = `Saboteurs have crippled production in ${entry.region}`;
if (entry.suspect === null || entry.suspect === undefined) {
return `${base} — no one has been blamed`;
}
return `${base} — suspicion falls on ${name(entry.suspect)}`;
}
case "satellite":
return `${adjective(entry.civ)} rocket has placed a spy satellite in orbit`;
case "enrichment":
return `Intelligence agencies warn that ${name(entry.civ)} has begun enriching uranium`;
case "treaty": {
const proto = treatyById(entry.treaty);
const label = proto ? proto.name : entry.treaty;
switch (entry.stage) {
case "proposed":
return `${name(entry.from)} proposes ${label} to ${name(entry.to)}`;
case "signed":
return `${name(entry.from)} and ${name(entry.to)} sign ${label}`;
case "rejected":
return `${name(entry.to)} rejects ${name(entry.from)}'s ${label} offer`;
case "broken":
return `${name(entry.from)} and ${name(entry.to)} break their ${label}`;
default:
return label;
}
}
default:
return entry.type;
}
+546 -30
View File
@@ -13,8 +13,11 @@ import {
technologyCost,
RESOURCES,
RESOURCE_IDS,
STORABLE_RESOURCE_IDS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
TREATIES,
treatyById,
} from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { renderMonthlyBudget } from "./budget.js";
@@ -82,6 +85,12 @@ export class NationModal {
this.$conflictsList = $("#conflicts-list");
this.$politicsSub = $("#politics-sub");
this.$politicsList = $("#politics-list");
this.$intelligenceSub = $("#intelligence-sub");
this.$intelligenceSubtabs = $("#intelligence-subtabs");
this.$intelligenceSpyView = $("#intelligence-spies-view");
this.$intelligencePropagandaView = $("#intelligence-propaganda-view");
// Which Intelligence sub-tab is open: the spy controls or the campaigns.
this.intelligenceView = "spies";
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
@@ -110,12 +119,21 @@ export class NationModal {
this._politicsCiv = -1;
this._politicsState = null;
this._politicsWizardOpen = false;
this.intelligence = null;
// The Intelligence tab, like Politics, is built once so the propaganda
// controls survive an arriving snapshot; only its lists refresh.
this._intelligenceBuilt = false;
this._intelligenceCiv = -1;
this._intelligenceState = null;
this.onGovernment = () => {};
this.onFocusTechnology = () => {};
this.onUnlockTechnology = () => {};
this.onDeclareWar = () => {};
this.onCampaign = () => {};
this.onCancelCampaign = () => {};
this.onSetCampaignSpending = () => {};
this.onLocateUnit = () => {};
this.onLaunchSatellite = () => {};
this.onPolicy = () => {};
this.onRevokePolicy = () => {};
this.onSetInterestRate = () => {};
@@ -147,12 +165,16 @@ export class NationModal {
return quotedMoney(amount, this._prices());
}
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) {
this.governments = governments;
this.technologies = technologies;
// Needed before the technology cards are built: a testing game adds the
// instant-unlock button when each card is created.
if (techInfo) this.techInfo = techInfo;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
this.politics = politics;
this.intelligence = intelligence;
this.resources = resourcesInfo;
this.money = moneyInfo;
// Rebuild the rows from scratch: reopening the panel must not stack a
@@ -165,13 +187,17 @@ export class NationModal {
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
this._buildTechnologyThemes();
this._buildResourceSubtabs();
this._buildIntelligenceSubtabs();
// Reopening the panel shows the server's rates, not an unsent edit left
// over from a previous open.
this.$economyTaxes.find(".tax-input").each((_, element) =>
this._clearTaxDirty($(element))
);
this._buildEconomySubtabs();
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo, intelligence);
// Force the treaty compose controls to rebuild their options for this open.
$("#treaty-type").data("lastType", null);
$("#treaty-target").removeAttr("data-signature");
this.$modal.removeClass("hidden");
}
@@ -1301,9 +1327,27 @@ export class NationModal {
row.$label = card.$label;
row.$meta = card.$meta;
row.$fill = card.$progressFill;
// A local testing game gains a second button that grants the technology at
// once, ignoring its cost and prerequisites.
if (this.techInfo && this.techInfo.testing) {
const $free = $("<button class='aero bt-free free-button'></button>")
.text("Unlock now")
.on("click", (event) => {
event.stopPropagation();
this.onUnlockTechnology(index);
});
card.$card.append($free);
row.$free = $free;
}
return row;
}
// Whether the viewer's nation holds the building a technology is gated
// behind, such as the uranium enrichment centre that unlocks nuclear weapons.
_hasTechBuilding(id) {
return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id));
}
// The hover detail for a technology card: its description, what its next
// level yields, and what it still needs before it can be focused.
_technologyTip(row) {
@@ -1326,7 +1370,7 @@ export class NationModal {
row.$action &&
row.$action.prop("disabled") &&
!this.researched.has(row.index) &&
!prerequisitesMet(proto, this.researched, this.technologies)
!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))
) {
const names = (proto.prerequisites || [])
.map((id) => {
@@ -1339,7 +1383,7 @@ export class NationModal {
return sections;
}
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) {
this.currentGovernment = currentGovernment;
// A snapshot rebuilds the open panels in place; hold each scroll container
// where the player left it so the panel does not jump back to the top under
@@ -1353,6 +1397,7 @@ export class NationModal {
if (diplomacy !== null) this.diplomacy = diplomacy;
if (conflicts !== null) this.conflicts = conflicts || [];
if (politics !== null) this.politics = politics;
if (intelligence !== null) this.intelligence = intelligence;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`
);
@@ -1409,7 +1454,7 @@ export class NationModal {
row.$label.text("Researched");
row.$action.prop("disabled", true);
setCardMeta(row.$meta, {});
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
} else if (!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))) {
row.$status.text("Locked");
row.$card.addClass("locked");
row.$label.text("Locked");
@@ -1437,12 +1482,18 @@ export class NationModal {
setCardMeta(row.$meta, {});
row.$meta.append(researchNode(cost));
}
// The testing unlock disappears once a one-off technology is held; a
// repeatable keeps it, to grant another level.
if (row.$free) {
row.$free.toggleClass("hidden", this.researched.has(row.index) && !proto.repeatable);
}
}
this._applyTechnologyFilter();
this._renderDiplomacy();
this._renderConflicts();
this._renderPolitics();
this._renderIntelligence();
for (const entry of scrolls) entry.el.scrollTop = entry.top;
}
@@ -1459,6 +1510,13 @@ export class NationModal {
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
// Which nations a connected player commands. Everything else is run by the
// computer, so the table can say who is human and name the leader.
const controllers = new Map();
for (const player of Object.values(info.players || {})) {
if (!player || !Number.isInteger(player.civ) || player.civ < 0) continue;
controllers.set(player.civ, player.name || "");
}
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
@@ -1478,6 +1536,7 @@ export class NationModal {
for (let index = 0; index < civs.length; index++) {
const stats = (info.stats || [])[index] || {};
const self = index === localCiv;
const human = controllers.has(index);
rows.push({
index,
name: civs[index].name,
@@ -1487,12 +1546,16 @@ export class NationModal {
approval: stats.approval || 0,
atWar: self ? false : wars.has(index),
self,
human,
leader: human ? controllers.get(index) : "",
});
}
this._sortDiplomacyRows(rows);
const columns = [
{ key: "name", label: "Nation" },
{ key: "control", label: "Control" },
{ key: "leader", label: "Leader" },
{ key: "gdp", label: "GDP", numeric: true },
{ key: "gdpPerCapita", label: "GDP /\u00a0capita", numeric: true },
{ key: "population", label: "Population", numeric: true },
@@ -1522,6 +1585,14 @@ export class NationModal {
const $tr = $("<tr></tr>");
if (row.self) $tr.addClass("local-nation");
$tr.append($("<td class='name'></td>").text(row.name));
const $control = $("<td class='control'></td>");
const $icon = $("<img class='control-icon'>")
.attr("src", row.human ? "assets/icon_human.svg" : "assets/icon_computer.svg")
.attr("alt", row.human ? "Player" : "Computer")
.attr("title", row.human ? "Played by a human" : "Run by the computer");
$control.append($icon);
$tr.append($control);
$tr.append($("<td class='leader'></td>").text(row.leader || "—"));
$tr.append(this._moneyCell(row.gdp, row.index, row.self));
$tr.append(this._moneyCell(row.gdpPerCapita, row.index, row.self));
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
@@ -1547,6 +1618,190 @@ export class NationModal {
}
$table.append($head, $body);
this.$diplomacyList.append($table);
this._renderTreaties(info);
}
// The treaty controls: the compose row (type, target, payload), the offers
// waiting for an answer, and the active and past treaties. The compose row
// lives outside the table the snapshot refresh rebuilds, so a half-filled
// offer is not wiped by the next broadcast.
_renderTreaties(info) {
if (!info) return;
this._bindTreatyCompose(info);
const name = (index) => (info.civs[index] ? info.civs[index].name : `Nation ${index + 1}`);
const localCiv = info.localCiv;
const labelOf = (type) => {
const proto = treatyById(type);
return proto ? proto.name : type;
};
const $pending = $("#treaty-pending").empty();
for (const proposal of info.treatyProposals || []) {
const $row = $("<div class='treaty-row'></div>");
if (proposal.to === localCiv) {
$row.append($("<span class='treaty-text'></span>").text(
`${name(proposal.from)} proposes ${labelOf(proposal.type)}`
));
const $sign = $("<button class='aero'>Sign</button>");
$sign.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, true));
const $reject = $("<button class='aero'>Reject</button>");
$reject.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, false));
$row.append($sign, $reject);
} else if (proposal.from === localCiv) {
$row.append($("<span class='treaty-text'></span>").text(
`You proposed ${labelOf(proposal.type)} to ${name(proposal.to)} — awaiting reply`
));
} else {
$row.append($("<span class='treaty-text'></span>").text(
`${name(proposal.from)} proposes ${labelOf(proposal.type)} to ${name(proposal.to)}`
));
}
$pending.append($row);
}
const $active = $("#treaty-active").empty();
const active = info.treaties || [];
if (active.length === 0) $active.append($("<div class='desc'></div>").text("No treaties in force."));
for (const treaty of active) {
const $row = $("<div class='treaty-row'></div>");
$row.append($("<span class='treaty-text'></span>").text(
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (signed ${treaty.signedDate})`
));
if (treaty.a === localCiv || treaty.b === localCiv) {
const $break = $("<button class='aero'>Break</button>");
$break.on("click", () => this.onCancelTreaty && this.onCancelTreaty(treaty.id));
$row.append($break);
}
$active.append($row);
}
const $past = $("#treaty-past").empty();
const past = (info.treatyHistory || []).slice().reverse();
if (past.length === 0) $past.append($("<div class='desc'></div>").text("No past treaties."));
for (const treaty of past) {
$past.append($("<div class='treaty-row'><span class='treaty-text'></span></div>").each(function () {
$(this).find(".treaty-text").text(
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (${treaty.reason} ${treaty.endedDate})`
);
}));
}
}
// Populates the compose controls once per opened panel. The target list only
// needs rebuilding when the nations themselves change.
_bindTreatyCompose(info) {
const $type = $("#treaty-type");
if (!$type.length) return;
if ($type.children().length === 0) {
for (const proto of TREATIES) {
$type.append($("<option></option>").attr("value", proto.id).text(proto.name));
}
}
const signature = (info.civs || []).map((civ) => civ.name).join("|");
const $target = $("#treaty-target");
if ($target.attr("data-signature") !== signature) {
$target.empty();
for (let index = 0; index < (info.civs || []).length; index++) {
if (index === info.localCiv) continue;
$target.append($("<option></option>").attr("value", index).text(info.civs[index].name));
}
$target.attr("data-signature", signature);
}
if (!$type.data("treatyBound")) {
$type.data("treatyBound", true);
$type.on("change", () => {
$type.data("lastType", $type.val());
this._renderTreatyPayload(info);
});
$("#treaty-propose").on("click", () => this._treatyPayloadFromForm(info));
}
// Only rebuild the payload controls when the type changes, so the snapshot
// refresh does not wipe a partly filled offer on every broadcast.
const current = $type.val();
if ($type.data("lastType") !== current) {
$type.data("lastType", current);
this._renderTreatyPayload(info);
}
}
// Rebuilds the per-type payload controls: a city picker, a money amount, a
// resource basket or a checklist of giftable units.
_renderTreatyPayload(info) {
const type = $("#treaty-type").val();
const $payload = $("#treaty-payload").empty();
if (type === "give_city") {
const $select = $("<select class='treaty-city'></select>");
for (const city of info.localCities || []) {
$select.append($("<option></option>").attr("value", city.id).text(city.name));
}
if (!(info.localCities || []).length) {
$select.append($("<option></option>").attr("value", "").text("No city to give"));
}
$payload.append($select);
} else if (type === "give_money") {
$payload.append(
$("<input class='treaty-money' type='number' min='0' step='1000' placeholder='amount'>")
);
} else if (type === "give_resources") {
for (const id of STORABLE_RESOURCE_IDS) {
const proto = resourceById(id);
const $label = $("<label class='treaty-resource-row'></label>");
$label.append($("<span></span>").text(proto ? proto.name : id));
$label.append(
$("<input class='treaty-resource' type='number' min='0' step='1'>").attr("data-id", id)
);
$payload.append($label);
}
} else if (type === "give_military") {
const options = (info.localUnits || []).filter(
(unit) => !((info.unitProtos || {})[unit.proto] || {}).infantry
);
for (const unit of options) {
const proto = (info.unitProtos || {})[unit.proto] || {};
const $label = $("<label class='treaty-unit-row'></label>");
$label.append($("<input class='treaty-unit' type='checkbox'>").attr("value", unit.id));
$label.append($("<span></span>").text(proto.name || `Unit ${unit.id}`));
$payload.append($label);
}
if (options.length === 0) {
$payload.append($("<span class='desc'></span>").text("No non-infantry unit to give"));
}
}
}
// Reads the compose controls into a treaty order. Returns null when the offer
// is incomplete, so an empty form sends nothing.
_treatyPayloadFromForm(info) {
const type = $("#treaty-type").val();
const to = Number($("#treaty-target").val());
if (!info || Number.isNaN(to) || to === info.localCiv) return null;
const payload = {};
if (type === "give_city") {
const city = Number($("#treaty-payload .treaty-city").val());
if (!city) return null;
payload.city = city;
} else if (type === "give_money") {
const amount = Number($("#treaty-payload .treaty-money").val());
if (!(amount > 0)) return null;
payload.amount = amount;
} else if (type === "give_resources") {
const resources = {};
$("#treaty-payload .treaty-resource").each((_, element) => {
const amount = Number($(element).val());
if (amount > 0) resources[$(element).data("id")] = amount;
});
if (Object.keys(resources).length === 0) return null;
payload.resources = resources;
} else if (type === "give_military") {
const units = [];
$("#treaty-payload .treaty-unit:checked").each((_, element) => {
units.push(Number($(element).val()));
});
if (units.length === 0) return null;
payload.units = units;
}
if (this.onProposeTreaty) this.onProposeTreaty(type, to, payload);
return payload;
}
// A nation's money figure, in that nation's own currency, with the viewer's
@@ -1585,6 +1840,9 @@ export class NationModal {
rows.sort((a, b) => {
let result;
if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
else if (key === "leader") {
result = (a.leader || "").localeCompare(b.leader || "");
} else if (key === "control") result = (a.human ? 1 : 0) - (b.human ? 1 : 0);
else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
else result = a[key] - b[key];
if (result === 0) result = a.index - b.index;
@@ -1680,24 +1938,13 @@ export class NationModal {
this.$politicsList.empty();
this._politicsWizardOpen = false;
this._politicsState = {
campaign: {
observer: localCiv,
kind: "government",
target: this._otherCiv(localCiv, civs),
direction: 1,
},
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
opinionObserver: localCiv,
};
this._campaignSignature = null;
this._campaignRows = new Map();
this.$politicsMakeup = $("<div class='politics-card'></div>");
this.$politicsCampaigns = $("<div class='politics-card'></div>");
this.$politicsMigrations = $("<div class='politics-card'></div>");
this.$politicsList.append(
this.$politicsMakeup,
this.$politicsCampaigns,
this._buildCampaignForm(civs),
this.$politicsMigrations,
this._buildPolicies(civs),
this._buildOpinions(civs)
@@ -1720,15 +1967,284 @@ export class NationModal {
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
else this.$politicsMakeup.append($("<div class='desc'></div>").text("No population data."));
this._fillCampaigns(info, localCiv);
this._syncCampaignReadout();
this._syncCampaignForm();
this._fillMigrations(info, civs, localCiv);
this._fillActivePolicies(info, civs, localCiv);
this._syncPolicyWizard();
this._fillOpinions();
}
// The Intelligence tab's sub-tabs, built once per open: the spy controls and
// the propaganda campaigns. Switching only shows and hides the two views.
_buildIntelligenceSubtabs() {
if (!this.$intelligenceSubtabs || !this.$intelligenceSubtabs.length) return;
this.$intelligenceSubtabs.empty();
const views = [
{ id: "spies", label: "Spies" },
{ id: "propaganda", label: "Propaganda" },
];
for (const view of views) {
const $button = $("<button type='button' class='subtab'></button>")
.attr("data-view", view.id)
.text(view.label);
if (view.id === this.intelligenceView) $button.addClass("active");
$button.on("click", () => this._setIntelligenceView(view.id));
this.$intelligenceSubtabs.append($button);
}
this._setIntelligenceView(this.intelligenceView);
}
_setIntelligenceView(view) {
this.intelligenceView = view;
if (this.$intelligenceSubtabs) {
this.$intelligenceSubtabs.find(".subtab").removeClass("active");
this.$intelligenceSubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
}
if (this.$intelligenceSpyView) {
this.$intelligenceSpyView.toggleClass("hidden", view !== "spies");
}
if (this.$intelligencePropagandaView) {
this.$intelligencePropagandaView.toggleClass("hidden", view !== "propaganda");
}
}
// The Intelligence tab: the spy roster and the propaganda campaigns. Spies'
// covert operations are unit actions and live in the unit panel, not here.
// Built once per open like Politics, so its controls survive the snapshots
// that arrive while the tab is open.
_renderIntelligence() {
if (!this.$intelligenceSpyView || !this.$intelligenceSpyView.length) return;
const info = this.intelligence;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
if (!this._intelligenceBuilt || this._intelligenceCiv !== localCiv) {
this._buildIntelligence(info, civs, localCiv);
this._intelligenceBuilt = true;
this._intelligenceCiv = localCiv;
}
this._refreshIntelligence(info, civs, localCiv);
}
_buildIntelligence(info, civs, localCiv) {
this.$intelligenceSpyView.empty();
this.$intelligencePropagandaView.empty();
this._intelligenceState = {
campaign: {
observer: localCiv,
kind: "government",
target: this._otherCiv(localCiv, civs),
direction: 1,
},
};
this._campaignSignature = null;
this._campaignRows = new Map();
this._spySignature = null;
this._spyRows = new Map();
this._reportsSignature = null;
this.$intelligenceSpies = $("<div class='politics-card'></div>");
this.$intelligenceCampaigns = $("<div class='politics-card'></div>");
this.$intelligenceSpyView.append(
this._buildSpiesCard(),
this._buildReportsCard(),
this._buildSatellitesCard()
);
this.$intelligencePropagandaView.append(
this.$intelligenceCampaigns,
this._buildCampaignForm(civs)
);
this._setIntelligenceView(this.intelligenceView);
}
_refreshIntelligence(info, civs, localCiv) {
this.$intelligenceSub.text(
"Your spies and the propaganda campaigns they serve."
);
this._fillSpies(info, localCiv);
this._fillReports(info);
this._fillSatellites(info);
this._fillCampaigns(info, localCiv);
this._syncCampaignReadout();
this._syncCampaignForm();
}
// The spy roster: one row per spy in the field, with a Locate button that
// selects it and brings the map to it. Rows are only rebuilt when the roster
// (or a spy's whereabouts) changes, so an arriving snapshot does not yank a
// button out from under the cursor.
_buildSpiesCard() {
this.$intelligenceSpies.empty();
this.$intelligenceSpies.append($("<div class='name'></div>").text("Your spies"));
this.$intelligenceSpyList = $("<div></div>");
this.$intelligenceSpies.append(this.$intelligenceSpyList);
return this.$intelligenceSpies;
}
_fillSpies(info, localCiv) {
if (!this.$intelligenceSpyList) return;
const spies = info.spies || [];
const signature = spies
.map((spy) => `${spy.id}:${spy.coords[0]},${spy.coords[1]}:${spy.location}`)
.join("|");
if (signature === this._spySignature) return;
this._spySignature = signature;
this.$intelligenceSpyList.empty();
this._spyRows = new Map();
if (spies.length === 0) {
this.$intelligenceSpyList.append(
$("<div class='desc'></div>").text(
this._hasResearched("intelligence_agencies")
? "No spies in the field. Train one in a city."
: "Research Intelligence Agencies to train spies."
)
);
return;
}
spies.forEach((spy, index) => {
const $row = $("<div class='politics-row politics-policy-row'></div>");
$row.append($("<div></div>").text(`Spy ${index + 1} — ${spy.location}`));
const $locate = $("<button class='aero'></button>").text("Locate");
$locate.on("click", () => this.onLocateUnit(spy.id));
$row.append($("<div class='politics-row-actions'></div>").append($locate));
this.$intelligenceSpyList.append($row);
this._spyRows.set(spy.id, $row);
});
}
// The reports the viewer's spies have brought home: city dossiers and
// intercepted communications. Rebuilt only when a report id changes.
_buildReportsCard() {
this.$intelligenceReports = $("<div class='politics-card'></div>");
this.$intelligenceReports.append($("<div class='name'></div>").text("Intelligence reports"));
this.$intelligenceReportsList = $("<div></div>");
this.$intelligenceReports.append(this.$intelligenceReportsList);
return this.$intelligenceReports;
}
_fillReports(info) {
if (!this.$intelligenceReportsList) return;
const reports = (info.reports || []).slice().reverse();
const signature = reports.map((report) => report.id).join(",");
if (signature === this._reportsSignature) return;
this._reportsSignature = signature;
this.$intelligenceReportsList.empty();
if (reports.length === 0) {
this.$intelligenceReportsList.append(
$("<div class='desc'></div>").text("No reports yet. Send a spy to watch a foreign city.")
);
return;
}
for (const report of reports) this._buildReportRow(report);
}
_buildReportRow(report) {
const owner = this._civName(report.civ);
const $row = $("<div class='politics-row spy-report'></div>");
if (report.kind === "comms") {
$row.append($("<div></div>").text(`${owner} communications from ${report.capital}`));
if ((report.messages || []).length === 0) {
$row.append($("<div class='desc'></div>").text("No messages intercepted."));
}
for (const message of report.messages || []) {
const from = message.from === null ? "public" : this._civName(message.from);
const to = message.to === null ? "public" : this._civName(message.to);
$row.append(
$("<div class='desc'></div>").text(`${from} → ${to}: ${message.text}`)
);
}
} else {
$row.append($("<div></div>").text(`${owner} city of ${report.city}`));
$row.append(
$("<div class='desc'></div>").text(
`Population ${groupDigits(Math.round(report.population || 0))}`
)
);
if ((report.buildings || []).length > 0) {
$row.append(
$("<div class='desc'></div>").text(
`Buildings: ${report.buildings.map((b) => `${b.name} ${b.level}`).join(", ")}`
)
);
}
if ((report.garrison || []).length > 0) {
$row.append(
$("<div class='desc'></div>").text(
`Garrison: ${report.garrison.map((g) => `${g.name} ×${g.count}`).join(", ")}`
)
);
} else {
$row.append($("<div class='desc'></div>").text("Garrison: none seen."));
}
}
this.$intelligenceReportsList.append($row);
}
// The spy satellites in orbit, and the launch control for a nation that holds
// a space launch centre.
_buildSatellitesCard() {
this.$intelligenceSatellites = $("<div class='politics-card'></div>");
this.$intelligenceSatellites.append($("<div class='name'></div>").text("Spy satellites"));
this.$intelligenceSatelliteStatus = $("<div class='desc'></div>");
this.$intelligenceSatelliteList = $("<div></div>");
this.$intelligenceSatelliteLaunch = $("<button class='aero'></button>").text(
"Launch spy satellite"
);
this.$intelligenceSatelliteLaunch.on("click", () => {
const city = this.intelligence && this.intelligence.launchCity;
if (city) this.onLaunchSatellite(city.id);
});
this.$intelligenceSatellites.append(
this.$intelligenceSatelliteStatus,
this.$intelligenceSatelliteList,
$("<div class='politics-row-actions'></div>").append(this.$intelligenceSatelliteLaunch)
);
return this.$intelligenceSatellites;
}
_fillSatellites(info) {
if (!this.$intelligenceSatelliteList) return;
const satellites = info.satellites || [];
const capacity = info.spaceLaunchCapacity || 0;
const totalHours = info.totalHours || 0;
this.$intelligenceSatelliteList.empty();
if (satellites.length === 0) {
this.$intelligenceSatelliteList.append(
$("<div class='desc'></div>").text(
capacity > 0
? "No satellites in orbit."
: "Build a space launch centre to launch spy satellites."
)
);
} else {
satellites.forEach((satellite, index) => {
const hours = Math.max(0, (satellite.nextRevealHour || 0) - totalHours);
const days = Math.ceil(hours / 24);
this.$intelligenceSatelliteList.append(
$("<div class='politics-row'></div>").text(
`Satellite ${index + 1} — next sweep in ${days} day${days === 1 ? "" : "s"}`
)
);
});
}
this.$intelligenceSatelliteStatus.text(
info.otherSatellites
? `${info.otherSatellites} foreign satellite${info.otherSatellites === 1 ? "" : "s"} in orbit.`
: ""
);
const can = capacity > satellites.length && !!info.launchCity;
this.$intelligenceSatelliteLaunch
.prop("disabled", !can)
.attr(
"title",
can ? "Everyone will see the launch" : "No free launch pad"
);
}
// Whether the viewer has researched the technology with the given id.
_hasResearched(id) {
const index = this.technologies.findIndex((tech) => tech.id === id);
return index >= 0 && this.researched.has(index);
}
// Campaigns the player is running or being targeted by. The rows are only
// rebuilt when the set of campaigns (or a spend level) changes, so a slider
// being dragged is not yanked away by an arriving snapshot; the cost text is
@@ -1740,7 +2256,7 @@ export class NationModal {
campaign.observer === localCiv ||
campaign.target === localCiv
);
this.$politicsCampaigns.toggleClass("hidden", campaigns.length === 0);
this.$intelligenceCampaigns.toggleClass("hidden", campaigns.length === 0);
const signature = campaigns
.map((campaign) =>
[
@@ -1756,10 +2272,10 @@ export class NationModal {
.join("|");
if (signature !== this._campaignSignature) {
this._campaignSignature = signature;
this.$politicsCampaigns.empty();
this.$intelligenceCampaigns.empty();
this._campaignRows = new Map();
if (campaigns.length > 0) {
this.$politicsCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
this.$intelligenceCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign);
}
}
@@ -1836,13 +2352,13 @@ export class NationModal {
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
$row.append($("<div class='politics-row-actions'></div>").append($cancel));
}
this.$politicsCampaigns.append($row);
this.$intelligenceCampaigns.append($row);
this._campaignRows.set(campaign.id, row);
}
// The campaign launcher, built once so its selects survive a snapshot.
_buildCampaignForm(civs) {
const campaign = this._politicsState.campaign;
const campaign = this._intelligenceState.campaign;
const $card = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$card.append(
@@ -1873,7 +2389,7 @@ export class NationModal {
const $readout = $("<span class='politics-readout'></span>");
this.$campaignReadout = $readout;
const sync = () => {
this._politicsState.campaign = {
this._intelligenceState.campaign = {
observer: Number($observer.val()),
kind: String($kind.val()),
target: Number($target.val()),
@@ -1896,7 +2412,7 @@ export class NationModal {
const $start = $("<button class='aero'></button>").text("Start campaign");
this.$campaignStart = $start;
$start.on("click", () => {
const state = this._politicsState.campaign;
const state = this._intelligenceState.campaign;
this.onCampaign(state.observer, state.kind, state.target, state.direction);
});
this.$campaignDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
@@ -1907,9 +2423,9 @@ export class NationModal {
_syncCampaignReadout() {
if (!this.$campaignReadout) return;
const info = this.politics;
const info = this.intelligence;
if (!info) return;
const state = this._politicsState.campaign;
const state = this._intelligenceState.campaign;
const matrix =
state.kind === "government" ? info.opinions.government : info.opinions.ethnic;
const row = (matrix && matrix[state.observer]) || [];
@@ -1920,9 +2436,9 @@ export class NationModal {
// Blocks a second campaign on the same population and target.
_syncCampaignForm() {
if (!this.$campaignStart) return;
const info = this.politics;
const info = this.intelligence;
if (!info) return;
const state = this._politicsState.campaign;
const state = this._intelligenceState.campaign;
const duplicate = (info.campaigns || []).some(
(campaign) =>
campaign.civ === info.localCiv &&
+16
View File
@@ -131,6 +131,14 @@ export class Network {
this._send({ t: "order", order });
}
sendChat(text, to = null) {
const value = String(text || "").trim();
if (value.length === 0) return;
const message = { t: "chat", text: value };
if (Number.isInteger(to) && to >= 0) message.to = to;
this._send(message);
}
close() {
if (!this.socket) return;
const socket = this.socket;
@@ -189,6 +197,14 @@ export class Network {
this.players = message.players || {};
this._emit("players_updated", this.players);
break;
case "chat":
this._emit("chat_message", {
name: message.name || "",
civ: message.civ,
to: Number.isInteger(message.to) ? message.to : null,
text: message.text || "",
});
break;
case "game_list":
this._emit("game_list_received", message.games || []);
break;
+80 -4
View File
@@ -205,6 +205,14 @@ export class GameServer extends EventEmitter {
return this._handleSchedule(civ, order);
case "pillage":
return this._handlePillage(civ, order);
case "sabotage":
return this._handleSabotage(civ, order);
case "spy_city":
return this._handleSpyCity(civ, order);
case "spy_comms":
return this._handleSpyComms(civ, order);
case "launch_satellite":
return this._handleLaunchSatellite(civ, order);
case "train":
return this._handleTrain(peerId, civ, order);
case "cancel_train":
@@ -233,8 +241,16 @@ export class GameServer extends EventEmitter {
return this._handleGovernment(civ, order);
case "focus_technology":
return this._handleFocusTechnology(civ, order);
case "unlock_technology":
return this._handleUnlockTechnology(peerId, civ, order);
case "declare_war":
return this._handleDeclareWar(civ, order);
case "propose_treaty":
return this._handleProposeTreaty(civ, order);
case "respond_treaty":
return this._handleRespondTreaty(civ, order);
case "cancel_treaty":
return this._handleCancelTreaty(civ, order);
case "campaign":
return this._handleCampaign(civ, order);
case "cancel_campaign":
@@ -266,6 +282,11 @@ export class GameServer extends EventEmitter {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
const coords = coordsOf(order.coords);
// A missile is a one-use rocket: firing it consumes the unit and lands its
// warhead where it lands, whether that is conventional or nuclear.
if (units.every((id) => this.state._isMissileUnit(this.state.findUnit(id)))) {
return this.state.requestMissileStrike(units, coords, { nuclear: !!order.nuclear });
}
// Aircraft fly to a hostile unit and bomb it on arrival, then return to a
// friendly airport. A ground/naval battery (artillery, warships) instead
// opens a sustained hourly bombardment from where it stands. Anything else
@@ -315,6 +336,31 @@ export class GameServer extends EventEmitter {
return this.state.requestPillage(units);
}
_handleSabotage(civ, order) {
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
const frame = Number.isInteger(order.frame) ? order.frame : null;
return this.state.requestSabotage(units, { frame });
}
_handleSpyCity(civ, order) {
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestCitySpy(units, coordsOf(order.coords));
}
_handleSpyComms(civ, order) {
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestCommsSpy(units, coordsOf(order.coords));
}
_handleLaunchSatellite(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.requestSatellite(civ, city);
}
_handleTrain(peerId, civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
@@ -400,11 +446,33 @@ export class GameServer extends EventEmitter {
return this.state.requestFocusTechnology(civ, Number(order.technology));
}
// The testing shortcut: grants a technology outright. Honoured only for a
// local page or a `--testing` game, exactly like the free build and train
// orders; a normal client's `free` flag is ignored.
_handleUnlockTechnology(peerId, civ, order) {
if (!(!!order.free && this._freeAllowed(peerId))) return false;
return this.state.unlockTechnology(civ, Number(order.technology));
}
_handleDeclareWar(civ, order) {
const target = Number(order.target !== undefined ? order.target : order.civ);
return this.state.requestDeclareWar(civ, target);
}
_handleProposeTreaty(civ, order) {
const to = Number(order.to);
const type = String(order.treaty || order.treatyType || "");
return this.state.requestProposeTreaty(civ, to, type, order.payload || {});
}
_handleRespondTreaty(civ, order) {
return this.state.requestRespondTreaty(civ, Number(order.proposal), !!order.accept);
}
_handleCancelTreaty(civ, order) {
return this.state.requestCancelTreaty(civ, Number(order.treatyId));
}
_handleCampaign(civ, order) {
const observer = Number(order.observer);
const target = Number(order.target);
@@ -612,6 +680,14 @@ export class GameServer extends EventEmitter {
// by one as they change rather than as a whole army every broadcast. The first
// snapshot to a peer (and after a reconnect) is always full.
_deltaState(peerId, snapshot, unitSignatures) {
// A viewer does not necessarily see every unit -- a hidden submarine is
// omitted from its snapshot -- so the per-peer bookkeeping is keyed to the
// units in this viewer's own snapshot.
const viewerSignatures = new Map();
for (const unit of snapshot.units || []) {
const signature = unitSignatures.get(unit.id);
if (signature !== undefined) viewerSignatures.set(unit.id, signature);
}
const versions = snapshot.versions;
const previous = this._peerSnapshotVersions.get(peerId);
// A viewer that changed civilisation sees different per-viewer collections
@@ -620,7 +696,7 @@ export class GameServer extends EventEmitter {
this._peerSnapshotVersions.set(peerId, versions);
this._peerExploredSent.set(peerId, (snapshot.explored || []).length);
this._peerCivSent.set(peerId, snapshot.viewer);
this._peerUnitSignatures.set(peerId, unitSignatures);
this._peerUnitSignatures.set(peerId, viewerSignatures);
return { ...snapshot, delta: false };
}
const state = { ...snapshot, delta: true };
@@ -639,17 +715,17 @@ export class GameServer extends EventEmitter {
const units = snapshot.units || [];
const updated = [];
for (const unit of units) {
if (sentUnits.get(unit.id) !== unitSignatures.get(unit.id)) updated.push(unit);
if (sentUnits.get(unit.id) !== viewerSignatures.get(unit.id)) updated.push(unit);
}
const removed = [];
for (const id of sentUnits.keys()) {
if (!unitSignatures.has(id)) removed.push(id);
if (!viewerSignatures.has(id)) removed.push(id);
}
delete state.units;
if (updated.length > 0) state.unitsUpdated = updated;
if (removed.length > 0) state.unitsRemoved = removed;
}
this._peerUnitSignatures.set(peerId, unitSignatures);
this._peerUnitSignatures.set(peerId, viewerSignatures);
this._peerSnapshotVersions.set(peerId, versions);
this._peerCivSent.set(peerId, snapshot.viewer);
return state;
+39 -1
View File
@@ -144,6 +144,7 @@ export function startServer({
id: peerId,
name: connection.name,
admin: connection.name === gameServer.adminName,
civ: gameServer.peerCiv.has(peerId) ? gameServer.peerCiv.get(peerId) : -1,
};
}
const message = JSON.stringify({ t: "players", players });
@@ -169,13 +170,50 @@ export function startServer({
admin: result.admin,
name: result.name,
}));
broadcastPlayers();
gameServer.onPeerJoined(connection.id, result.name);
// Broadcast after the join, so a reclaimed nation shows its player
// immediately in everyone's diplomacy tab.
broadcastPlayers();
break;
}
case "claim_civ":
gameServer.claimCiv(connection.id, message.civ);
broadcastPlayers();
break;
case "chat": {
// Relay a short line of chat. `to` names a nation index for a private
// line (delivered to that nation and echoed to the sender); without it
// the line goes to every signed-in player. The line is recorded in the
// game state so a spy can intercept it later. Empty lines and anonymous
// senders are dropped.
if (!connection.name) break;
const text = String(message.text || "").trim().slice(0, 240);
if (text.length === 0) break;
const fromCiv = gameServer.peerCiv.has(connection.id)
? gameServer.peerCiv.get(connection.id)
: -1;
const toCiv = Number.isInteger(message.to) && message.to >= 0 ? message.to : null;
const chat = JSON.stringify({
t: "chat",
name: connection.name,
civ: fromCiv,
to: toCiv,
text,
});
for (const peer of connections.values()) {
if (!peer.name) continue;
if (toCiv === null) {
peer.send(chat);
} else {
const peerCiv = gameServer.peerCiv.has(peer.id)
? gameServer.peerCiv.get(peer.id)
: -1;
if (peer.id === connection.id || peerCiv === toCiv) peer.send(chat);
}
}
if (gameServer.state) gameServer.state.recordChat(fromCiv, toCiv, text);
break;
}
case "order": {
const order = message.order || {};
// The free shortcut is only honoured for a page on the local machine,
+33
View File
@@ -19,6 +19,10 @@ export const BUILDING_MECHANIC = {
PORT_PRODUCTION: "port_production",
// Lets people fly in from any other city with an airport.
AIR_IMMIGRATION: "air_immigration",
// Unlocks the Nuclear Weapons research for the nation that raises one.
NUCLEAR_ENRICHMENT: "nuclear_enrichment",
// Each level lets the nation launch one spy satellite (see INTELLIGENCE.md).
SPACE_LAUNCH: "space_launch",
};
export const BUILDINGS = [
@@ -46,6 +50,8 @@ export const BUILDINGS = [
// Multiplies the odds of the city losing a level of this building to a
// bombardment: at a tenth of the normal chance it is very hard to destroy.
destructionResistance: 0.1,
// A bunker survives a nuclear blast that flattens everything around it.
nuclearProof: true,
icon: "icon_shelters.svg",
},
{
@@ -148,6 +154,33 @@ export const BUILDINGS = [
coastal: true,
requiresBuilding: "port",
},
{
id: "uranium_enrichment_center",
advanced: true,
name: "Uranium enrichment centre",
description: "Uranium centrifuges. Raising one unlocks the Nuclear Weapons research for the whole nation.",
baseCost: 2_000_000_000,
costAlpha: 2,
refundFraction: 0.5,
effects: [],
mechanic: "nuclear_enrichment",
nuclear: true,
materialUpkeep: { steel: 3, energy: 4000, hightech: 1 },
icon: "icon_uranium_enrichment.svg",
},
{
id: "space_launch_center",
advanced: true,
name: "Space launch centre",
description: "A rocket complex. Each level lets the nation launch one spy satellite, which uncovers a random patch of the world every two days. Extremely expensive.",
baseCost: 10_000_000_000,
costAlpha: 2,
refundFraction: 0.5,
effects: [],
mechanic: "space_launch",
materialUpkeep: { steel: 5, energy: 8000, hightech: 4 },
icon: "icon_space_launch_center.svg",
},
];
export function buildingById(id) {
+14
View File
@@ -69,6 +69,20 @@ export const AIR_DEFENSE = {
damagePerLevel: 150,
};
// Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five
// the crew spikes or scuttles it rather than let it be turned around.
export const CAPTURE = {
destroyChance: 0.8,
};
// A nuclear detonation: nine people in ten on the tile are killed, every
// building and improvement there is flattened except the ones built to survive
// a bomb (bunkers, fortifications), and the horror costs every nation standing.
export const WMD = {
populationSurvivorFraction: 0.1,
popularityPenalty: 1000,
};
// The result of pillaging a tile with `count` units in sequence, starting from
// its current population. Each step removes a tenth of what is left and heals
// for the people it removed; the production multiplier compounds. Shared so
+1
View File
@@ -11,6 +11,7 @@ export const EFFECT_CULTURE = "culture";
export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
export const EFFECT_INFANTRY_DEFENSE = "infantry_defense";
export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
export const EFFECT_MISSILE_ATTACK = "missile_attack";
export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
export const EFFECT_RAIL_SPEED = "rail_speed";
+2
View File
@@ -55,6 +55,8 @@ export const TILE_IMPROVEMENTS = [
defense: { melee: 2.0, artillery: 4.0 },
permanentControl: true,
takesCombatDamage: true,
// Earthworks and bunkers shrug off a nuclear blast.
nuclearProof: true,
icon: "icon_fortifications.svg",
},
{
+2
View File
@@ -13,8 +13,10 @@ export * from "./statuses.js";
export * from "./combat.js";
export * from "./economy.js";
export * from "./politics.js";
export * from "./intelligence.js";
export * from "./map_config.js";
export * from "./terrain.js";
export * from "./roads.js";
export * from "./resources.js";
export * from "./improvements.js";
export * from "./treaties.js";
+90
View File
@@ -0,0 +1,90 @@
// Covert operations a spy may run abroad. The ids are shared by the client
// (to label the Intelligence tab's operations) and, in time, the server (to
// validate the orders), so they live here rather than in the view.
//
// Intelligence Agencies unlocks the spy and every operation below;
// Counter-Intelligence is the defence against them.
export const SPY_SABOTAGE = "sabotage";
export const SPY_CITY = "city_spying";
export const SPY_COMMS = "comms_spying";
// Covert-operation tuning. A sabotage cripples the tile the spy stands on for a
// week; the tile's production is zeroed, not the physical resource trade, which
// mirrors how pillaging already marks a tile (see `productionFactors`).
export const SABOTAGE = {
blockDays: 7,
};
// Espionage tuning: how the intelligence agencies and their counter-intelligence
// rivals decide who gets blamed, what a spy report really contains, and how
// easily a secret leaks.
export const ESPIONAGE = {
// The chance a victim correctly pins a covert act on its author, before the
// two nations' services shift it. A counter-intelligence service raises it;
// the author's own agencies lower it.
baseIdentifyChance: 0.5,
counterIntelligenceWeight: 0.45,
agencyWeight: 0.2,
// When the author is not identified and chose a scapegoat, how often the
// victim swallows the false flag. A correct blame and a successful frame are
// deliberately indistinguishable to the victim.
frameChance: 0.6,
// The extra chance, per level of the target's counter-intelligence, that a
// spy's city report is quietly falsified.
falsifyChance: 0.7,
// The extra chance, per level, that intercepted comms come back garbled.
garbleChance: 0.6,
// Covert operations spend their operatives, so the only way to stack the odds
// against a counter-intelligence service is to send more spies: each operative
// beyond the first cancels this much of the target's counter-intelligence.
// Three spies (two extra) beat a full service outright.
agentsPerCounter: 0.5,
// How many recent messages comms spying reveals.
interceptCount: 8,
// How many reports a nation keeps for its Intelligence tab.
reportLimit: 20,
// How many chat lines the server keeps for comms spying.
messageLogLimit: 400,
// The chance a nation with Intelligence Agencies is warned when another nation
// raises its first uranium enrichment centre.
enrichmentWarningChance: 0.5,
};
// Spy satellites: a launch is free once the (very expensive) space launch centre
// stands, and each satellite uncovers a random patch of the world on a cadence.
export const SATELLITE = {
revealEveryDays: 2,
revealRadius: 2,
};
// `description` is the reference text the Intelligence tab shows, not a
// rules summary: the mechanics are applied server-side (see INTELLIGENCE.md).
// The kinds of report a spy brings home. Kept as ids so the model and the view
// can name them without magic strings.
export const SPY_REPORT_CITY = "city";
export const SPY_REPORT_COMMS = "comms";
// Every operation spends the acting spies: a spy is single-use, so only one
// operation is ever run per operative. A larger stack improves the odds against
// counter-intelligence (see ESPIONAGE.agentsPerCounter).
export const SPY_OPERATIONS = [
{
id: SPY_SABOTAGE,
name: "Sabotage",
description:
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
},
{
id: SPY_CITY,
name: "Spy on a city",
description:
"Read a foreign city's buildings, garrison and stores. A counter-intelligence service can feed back false figures. Spends the spies.",
},
{
id: SPY_COMMS,
name: "Intercept communications",
description:
"Read a nation's recent private messages from its capital. Counter-intelligence can garble or redirect them. Spends the spies.",
},
];
+3
View File
@@ -182,6 +182,9 @@ export const MIGRATION = {
// most this many people per day for each airport level on the route.
airHours: 6,
airPeoplePerDayPerLevel: 1000,
// No tile loses more than this share of its people to migration in one day,
// whichever mechanism carries them.
maxDailyOutShare: 0.0001,
// At generation, regions of a nation trade people until their production per
// head is level. `equalizeTolerance` is the residual spread accepted and
+12
View File
@@ -21,3 +21,15 @@ export const NEWS_JOIN = "join";
export const NEWS_TARIFF = "tariff";
// A region's food ran out: a famine has begun. Reported to every nation.
export const NEWS_FAMINE = "famine";
// A diplomatic treaty was proposed, signed, rejected or broken. The stage
// travels in the record so one feed entry covers each step.
export const NEWS_TREATY = "treaty";
// A spy sabotaged a tile abroad: its production is crippled for a week. The
// record may name a `suspect`, which counter-intelligence may have got right or
// wrong.
export const NEWS_SABOTAGE = "sabotage";
// A spy satellite was launched. Everyone is made aware of the launch.
export const NEWS_SATELLITE = "satellite";
// A nation's intelligence services have learned that another nation has begun
// enriching uranium.
export const NEWS_ENRICHMENT = "enrichment";
+37
View File
@@ -14,6 +14,7 @@ import {
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
EFFECT_MISSILE_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
EFFECT_ENERGY_EFFICIENCY,
@@ -89,6 +90,42 @@ export const TECHNOLOGIES = [
prerequisites: ["space_program", "quantum_computing"],
effects: [effect(EFFECT_RESEARCH, 8.0, 1.0)],
},
{
id: "nuclear_weapons",
name: "Nuclear Weapons",
description: "Fission warheads for the nation's missiles. Only a nation enriching uranium may begin the research.",
theme: "military",
// 300,000 research points: a project a whole economy bends toward.
cost: 3_000,
prerequisites: ["scientific_method"],
// The technology is unlocked by the nation's first uranium enrichment
// centre; without one it cannot be focused at all.
requiresBuilding: "uranium_enrichment_center",
effects: [effect(EFFECT_MISSILE_ATTACK, 0.25, 1.0)],
},
{
id: "intelligence_agencies",
name: "Intelligence Agencies",
description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.",
theme: "society",
cost: 250,
prerequisites: ["scientific_method"],
// Unlocks the spy unit rather than carrying a numeric effect of its own.
unlocks: true,
effects: [],
},
{
id: "counter_intelligence",
name: "Counter-Intelligence",
description: "Screening, ciphers and deception to shield the nation's secrets from foreign spies. Extremely expensive.",
theme: "society",
// 150,000 research points: the most a nation can spend on its secrets.
cost: 1_500,
prerequisites: ["intelligence_agencies"],
// Shields the nation against spy operations rather than raising a stat.
unlocks: true,
effects: [],
},
// Repeatable levels. Each raises a single stat by 1% of its base value.
{
+76
View File
@@ -0,0 +1,76 @@
// Diplomatic treaties. A treaty is proposed by one nation, accepted by another,
// and then lives on until it is broken or superseded. The catalogue is
// data-driven so a new treaty type only needs an entry here and a case in
// `_applyTreatyEffect`.
//
// `payload` names the field a proposal must carry (a city id, a unit list, a
// resource basket or a sum of money), and the UI uses `needs` to decide which
// picker to show.
export const TREATY_PEACE = "peace";
export const TREATY_ALLIANCE = "alliance";
export const TREATY_FREE_TRADE = "free_trade";
export const TREATY_GIVE_CITY = "give_city";
export const TREATY_GIVE_MILITARY = "give_military";
export const TREATY_GIVE_RESOURCES = "give_resources";
export const TREATY_GIVE_MONEY = "give_money";
export const TREATY_JOINT_RESEARCH = "joint_research";
export const TREATIES = [
{
id: TREATY_PEACE,
name: "Peace",
description: "Ends the war between the two signatories and sends their troops home.",
},
{
id: TREATY_ALLIANCE,
name: "Alliance",
description:
"The signatories cannot go to war with each other; attacking one declares war on all.",
},
{
id: TREATY_FREE_TRADE,
name: "Free trade",
description: "Lifts every tariff between the two signatories.",
},
{
id: TREATY_GIVE_CITY,
name: "Give city",
description: "Transfers one of the proposer's cities to the other signatory.",
payload: "city",
},
{
id: TREATY_GIVE_MILITARY,
name: "Give military materials",
description: "Transfers units to the other signatory. Infantry cannot be gifted.",
payload: "units",
},
{
id: TREATY_GIVE_RESOURCES,
name: "Give resources",
description: "Transfers stores of steel, high-tech, luxury, food or energy.",
payload: "resources",
},
{
id: TREATY_GIVE_MONEY,
name: "Give money",
description: "Transfers a sum of money from the proposer's treasury.",
payload: "money",
},
{
id: TREATY_JOINT_RESEARCH,
name: "Joint research",
description:
"Each signatory also earns the research points the others produce, except on military technology.",
},
];
export function treatyById(id) {
return TREATIES.find((treaty) => treaty.id === id) || null;
}
// News stages, so one feed entry can announce a treaty at each step.
export const TREATY_PROPOSED = "proposed";
export const TREATY_SIGNED = "signed";
export const TREATY_REJECTED = "rejected";
export const TREATY_BROKEN = "broken";
+97
View File
@@ -122,6 +122,28 @@ export const PROTO_UNITS = [
requiresBuilding: "shipyard",
icon: "icon_battleship.svg",
},
{
id: "submarine",
advanced: true,
materialUpkeep: { steel: 4, energy: 4000, hightech: 1 },
name: "Submarine",
maxHp: 400,
cost: 350_000_000,
moveable: true,
speed: 0.5,
military: true,
attack: 350,
defense: 60,
// Strikes from two tiles out like a warship.
range: 2,
naval: true,
// Hidden from every other nation unless one of its naval units is on the
// submarine's tile or directly beside it (see `_hiddenEnemyUnits`).
submarine: true,
traversableTerrains: ["Sea"],
requiresBuilding: "shipyard",
icon: "icon_submarine.svg",
},
{
id: "mobile_radar",
advanced: true,
@@ -142,6 +164,29 @@ export const PROTO_UNITS = [
traversableTerrains: ["Land"],
icon: "icon_radar.svg",
},
{
id: "spy",
advanced: true,
name: "Spy",
maxHp: 100,
cost: 250_000_000,
// Mostly a high-tech bill: a spy is equipment and networks, not steel.
materialUpkeep: { steel: 0, energy: 2_000, hightech: 3 },
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
// A spy fights nothing and strikes nothing; it watches one tile out.
range: 0,
vision: 1,
// Hidden from every other nation by default (see `_hiddenEnemyUnits`), and
// free to slip through foreign territory (see `_canUnitEnter`).
spy: true,
traversableTerrains: ["Land"],
requiresTechnology: "intelligence_agencies",
icon: "icon_spy.svg",
},
{
id: "launch",
name: "Launch",
@@ -162,6 +207,54 @@ export const PROTO_UNITS = [
requiresBuilding: "port",
icon: "icon_launch.svg",
},
{
id: "missile",
name: "Missile",
maxHp: 100,
cost: 150_000_000,
materialUpkeep: { steel: 2, energy: 2000, hightech: 1 },
// Missiles are fired, not marched: `moveable: false` keeps them out of every
// movement order, and `oneUse` means the rocket is gone once launched.
moveable: false,
oneUse: true,
speed: 10,
military: true,
attack: 900,
defense: 0,
// An intercontinental ballistic missile of the theatre: 30 tiles of reach.
range: 30,
// A missile flies like a strike aircraft but needs no airfield; a nuclear
// payload additionally needs the nuclear-weapons research.
strike: true,
missile: true,
traversableTerrains: ["Land", "Sea", "Ice"],
icon: "icon_missile.svg",
},
{
id: "icbm",
name: "ICBM",
maxHp: 100,
cost: 800_000_000,
materialUpkeep: { steel: 4, energy: 4000, hightech: 2 },
moveable: false,
oneUse: true,
speed: 10,
military: true,
attack: 900,
defense: 0,
// A global rocket: `globalRange` lifts the 30-tile ceiling so it may be
// aimed at any tile on the map.
range: 0,
globalRange: true,
strike: true,
missile: true,
// Nuclear by design, and gated behind both the bomb and the orbit that
// carries it.
nuclear: true,
requiresTechnologies: ["nuclear_weapons", "space_program"],
traversableTerrains: ["Land", "Sea", "Ice"],
icon: "icon_icbm.svg",
},
];
// Only units in this list can be trained from the city screen.
@@ -171,8 +264,12 @@ export const TRAINABLE_UNIT_IDS = [
"jet_fighter",
"bomber",
"battleship",
"submarine",
"mobile_radar",
"spy",
"launch",
"missile",
"icbm",
];
export function protoUnitById(id) {
+26
View File
@@ -40,6 +40,9 @@ import { combatMethods } from "./game_state/combat.js";
import { airMethods } from "./game_state/air.js";
import { statusMethods } from "./game_state/statuses.js";
import { diplomacyMethods } from "./game_state/diplomacy.js";
import { treatyMethods } from "./game_state/treaties.js";
import { wmdMethods } from "./game_state/wmd.js";
import { intelligenceMethods } from "./game_state/intelligence.js";
import { politicsMethods } from "./game_state/politics.js";
import { orderMethods } from "./game_state/orders.js";
import { visibilityMethods } from "./game_state/visibility.js";
@@ -147,6 +150,14 @@ export class GameState {
// recovery may begin. A tile leaves both maps once it has recovered.
this.tileBattleProductionDeficit = new Map();
this.tileBattleRecoveryStart = new Map();
// Tiles crippled by a spy's sabotage, each mapped to the hour its week ends.
this.tileSabotage = new Map();
// Espionage: reports each nation's spies have brought home (per civ, capped),
// the chat log comms spying reads, and the spy satellites in orbit.
this.spyReports = new Map();
this._nextSpyReportId = 1;
this.messages = [];
this.satellites = [];
this.budgets = new Map();
this.training = new Map();
this.culture = new Map();
@@ -169,6 +180,14 @@ export class GameState {
// One record per war, keyed like `wars`: when it started and the military
// and civilian casualties each side has suffered so far.
this.conflicts = new Map();
// Diplomatic treaties: offers waiting for an answer, the agreements in
// force, and the ones that ended (broken, rejected, or made obsolete).
this.treaties = [];
this.treatyProposals = [];
this.treatyHistory = [];
this._nextTreatyId = 1;
this._nextTreatyProposalId = 1;
this._treatyHistoryLimit = 40;
this.news = [];
// Politics: the ethnic makeup of every tile, the opinions each ethnicity
@@ -499,9 +518,13 @@ export class GameState {
this._tickTraining();
this._tickAirUnits();
this._tickStrikes();
this._tickSabotage();
this._tickSatellites();
this._tickGarrisonHealing();
// Popularity drifts toward neutral every hour, not just daily.
this._tickOpinionDrift();
// Stale treaty offers fall out of the receiver's inbox.
this._expireTreatyProposals();
if (this._visibilityDirty) {
this._refreshVisibility();
this._visibilityDirty = false;
@@ -547,6 +570,9 @@ Object.assign(
airMethods,
statusMethods,
diplomacyMethods,
treatyMethods,
wmdMethods,
intelligenceMethods,
politicsMethods,
orderMethods,
visibilityMethods,
+2 -2
View File
@@ -356,7 +356,7 @@ export const airMethods = {
// Declares war on the target if the two were at peace.
requestBombard(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter(
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit)
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit) && !this._isMissileUnit(unit)
);
if (units.length === 0) return false;
const civ = units[0].civ;
@@ -438,7 +438,7 @@ export const airMethods = {
_tickStrikes() {
const damage = new Map();
for (const unit of this.units) {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
if (!unit.strikeTarget || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
if (!target) {
+29 -15
View File
@@ -2,12 +2,13 @@
// and the lasting scars a battlefield leaves on a tile.
import { key } from "../hex.js";
import { BATTLE } from "../data/combat.js";
import { BATTLE, CAPTURE } from "../data/combat.js";
import { POPULARITY } from "../data/politics.js";
import {
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
EFFECT_MISSILE_ATTACK,
} from "../data/effects.js";
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
import { defenseMultiplier } from "../rules.js";
@@ -28,11 +29,13 @@ export const combatMethods = {
_unitAttack(unit) {
const proto = this.unitProto(unit);
let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
if (proto && (proto.infantry || proto.artillery)) {
if (proto && (proto.infantry || proto.artillery || proto.missile)) {
const modifiers = this.getCivModifiers(unit.civ);
const bonus = proto.infantry
? modifiers[EFFECT_INFANTRY_ATTACK]
: modifiers[EFFECT_ARTILLERY_ATTACK];
: proto.artillery
? modifiers[EFFECT_ARTILLERY_ATTACK]
: modifiers[EFFECT_MISSILE_ATTACK];
attack *= 1 + (bonus || 0);
}
return attack;
@@ -86,7 +89,7 @@ export const combatMethods = {
// or null. With every combatant on one tile this is simply the weakest
// hostile military unit there, so a stack focuses its fire.
_combatTarget(unit) {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) return null;
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) return null;
const enemies = this.unitsAt(unit.coords).filter(
(other) =>
other.id !== unit.id &&
@@ -124,8 +127,9 @@ export const combatMethods = {
}
const byTile = new Map();
for (const unit of this.units) {
// Aircraft fly over a battlefield without taking part in the melee.
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
// Aircraft fly over a battlefield without taking part in the melee, and a
// missile is a rocket waiting for a launch order, not a combatant.
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
const k = key(unit.coords.x, unit.coords.y);
let entry = byTile.get(k);
if (!entry) {
@@ -251,7 +255,7 @@ export const combatMethods = {
_resolveCaptures() {
const claimed = new Set();
for (const unit of this.units) {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
const coords = unit.coords;
// Support units and unaccompanied artillery are taken from underfoot.
this._captureSupportUnits(unit);
@@ -268,7 +272,7 @@ export const combatMethods = {
// tile changes hands, or is scuttled when the captor is a warship. Artillery
// escapes capture while any of its own infantry stands beside it.
_captureSupportUnits(captor) {
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor) || this._isMissileUnit(captor)) return false;
const captorProto = this.unitProto(captor);
const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
if (enemies.length === 0) return false;
@@ -290,8 +294,13 @@ export const combatMethods = {
}
changed = true;
} else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
// A non-melee gun is more often spiked than taken.
if (this._random() < CAPTURE.destroyChance) {
this._destroyUnit(enemy);
} else {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
}
changed = true;
}
}
@@ -305,15 +314,20 @@ export const combatMethods = {
return changed;
},
// Enemy aircraft caught on a captured city's airfield change allegiance.
// Enemy aircraft caught on a captured city's airfield are usually destroyed
// rather than turned around; only a fifth change allegiance with the field.
_captureCityAircraft(city, newCiv) {
let changed = false;
for (const unit of this.unitsAt(city.coords)) {
for (const unit of this.unitsAt(city.coords).slice()) {
const proto = this.unitProto(unit);
if (!proto || !proto.air || unit.civ === newCiv) continue;
unit.civ = newCiv;
unit.homeCityId = city.id;
unit.strikeTarget = null;
if (this._random() < CAPTURE.destroyChance) {
this._destroyUnit(unit);
} else {
unit.civ = newCiv;
unit.homeCityId = city.id;
unit.strikeTarget = null;
}
changed = true;
}
if (changed) this._invalidateUnitCaches();
+24 -18
View File
@@ -31,26 +31,32 @@ export const diplomacyMethods = {
},
// Records the war and its news item without emitting; the caller decides when
// to publish. Returns false when the pair is already at war.
// to publish. Returns false when the pair is already at war. An alliance
// between the two is broken first, and the target's allies are drawn in.
_declareWar(actor, target) {
if (actor === target) return false;
const k = warKey(actor, target);
if (this.wars.has(k)) return false;
this.wars.add(k);
this._invalidateSpatialCaches();
this.conflicts.set(k, {
a: Math.min(actor, target),
b: Math.max(actor, target),
startHours: this.totalHours,
startDate: dateString(this.totalHours),
military: new Map(),
civilians: new Map(),
});
// The world watches: a war on a well-liked government costs the aggressor
// its standing with onlookers, a war on a loathed one can be popular.
this._applyWarPopularity(actor, target);
this._addNews({ type: NEWS_WAR, actor, target });
return true;
const enemies = this._handleWarAlliances(actor, target);
let declared = false;
for (const enemy of enemies) {
const k = warKey(actor, enemy);
if (this.wars.has(k)) continue;
this.wars.add(k);
this._invalidateSpatialCaches();
this.conflicts.set(k, {
a: Math.min(actor, enemy),
b: Math.max(actor, enemy),
startHours: this.totalHours,
startDate: dateString(this.totalHours),
military: new Map(),
civilians: new Map(),
});
// The world watches: a war on a well-liked government costs the aggressor
// its standing with onlookers, a war on a loathed one can be popular.
this._applyWarPopularity(actor, enemy);
this._addNews({ type: NEWS_WAR, actor, target: enemy });
declared = true;
}
return declared;
},
// The ongoing war record between two civilisations, or null when they are at
+73 -2
View File
@@ -118,6 +118,8 @@ export const economyMethods = {
pillagePenalty: this.tileProductionPenalty.get(k) !== undefined
? this.tileProductionPenalty.get(k)
: 1,
// A spy's sabotage zeroes the tile outright for a week.
sabotaged: this.isTileSabotaged(coords),
// Fighting scars the tile too, but that loss recovers over time.
battleDeficit: this.tileBattleProductionDeficit.get(k) || 0,
trench: this.trenches.has(k),
@@ -460,6 +462,18 @@ export const economyMethods = {
const resourceMoney = RESOURCE_IDS
.map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
.filter((entry) => entry.bought > 0 || entry.sold > 0);
// What the nation actually paid for materials, power and freight over the
// last day, as hourly averages. The upkeep rows above are a forecast at
// market prices; these are the real bills the stores and the market settled,
// shown as their own line so the two are never confused.
const resourceCosts = [
{ id: "energy", label: "Electric imports", amount: (record.energy || 0) / HOURS_PER_DAY },
{ id: "goods", label: "Goods", amount: (record.goods || 0) / HOURS_PER_DAY },
{ id: "transport", label: "Freight", amount: (record.transport || 0) / HOURS_PER_DAY },
{ id: "upkeep", label: "Material upkeep", amount: (record.upkeep || 0) / HOURS_PER_DAY },
{ id: "construction", label: "Construction", amount: (record.construction || 0) / HOURS_PER_DAY },
{ id: "combat", label: "Munitions", amount: (record.combat || 0) / HOURS_PER_DAY },
].filter((entry) => entry.amount > 0);
return {
income: { rate: 0, base: 0, modifier: 0, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
@@ -472,6 +486,7 @@ export const economyMethods = {
groups: tileImprovementSources,
},
resourceMoney,
resourceCosts,
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
@@ -741,7 +756,22 @@ export const economyMethods = {
const tech = this.technologies[index];
// A repeatable technology may be researched again; any other is done once.
if (!tech.repeatable && this.isResearched(civ, index)) return false;
return prerequisitesMet(tech, this.researched.get(civ) || new Set(), this.technologies);
return prerequisitesMet(
tech,
this.researched.get(civ) || new Set(),
this.technologies,
(id) => this._civHasBuilding(civ, id)
);
},
// Whether any of a nation's cities has at least one level of the named
// building. Gates a technology that a building unlocks, such as the uranium
// enrichment centre opening nuclear weapons.
_civHasBuilding(civ, id) {
for (const city of this.cities) {
if (city.civ === civ && this.hasCityBuilding(city, id)) return true;
}
return false;
},
// The technology a nation is currently focusing its research on, or -1.
@@ -797,11 +827,36 @@ export const economyMethods = {
// Points a nation creates flow straight into its focused technology; there is
// no bank, so time spent with nothing focused is simply time not advancing.
// A joint-research treaty shares the points with the partners' own focused
// technology, except when the technology being researched is military.
_addResearch(civ, amount) {
if (!(amount > 0)) return;
const index = this.getFocusedTechnology(civ);
if (index < 0) return;
this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
const tech = this.technologies[index];
if (!tech || tech.theme === "military") return;
// The partners' copies must not echo back to the origin, so one sharing
// pass is guarded from re-entering.
if (this._sharingResearch) return;
const partners = this.jointResearchPartners(civ);
if (partners.length === 0) return;
this._sharingResearch = true;
try {
for (const partner of partners) {
const partnerIndex = this.getFocusedTechnology(partner);
if (partnerIndex < 0) continue;
const partnerTech = this.technologies[partnerIndex];
if (partnerTech && partnerTech.theme === "military") continue;
this._applyTechnologyProgress(
partner,
partnerIndex,
this.getTechnologyProgress(partner, partnerIndex) + amount
);
}
} finally {
this._sharingResearch = false;
}
},
// Records the points committed to one technology.
@@ -827,7 +882,14 @@ export const economyMethods = {
// The completed level spends its points; a repeatable starts the next level
// from zero. Other technologies keep whatever they had banked.
this._setTechnologyProgress(civ, index, 0);
this._clearFocus(civ);
// A repeatable technology that was just completed comes straight back into
// focus, so its next level keeps accruing without the player having to ask
// again. Anything else clears the focus as before.
if (this.technologies[index] && this.technologies[index].repeatable) {
this.focusTechnology.set(civ, index);
} else {
this._clearFocus(civ);
}
this._clearTileGdpCache();
this._emitChanged();
},
@@ -1110,6 +1172,15 @@ export const economyMethods = {
city.buildings[entry.protoIndex] = level + 1;
this._clearTileGdpCache();
this._touchModifiers();
// A nation's first uranium enrichment centre may be noticed abroad.
const proto = this.protoBuildings[entry.protoIndex];
if (
level === 0 &&
proto &&
proto.mechanic === BUILDING_MECHANIC.NUCLEAR_ENRICHMENT
) {
this._warnOfEnrichment(city.civ);
}
},
// Units resting inside one of their own cities recover a little health each
+426
View File
@@ -0,0 +1,426 @@
// Espionage: what a spy unit does once it reaches a foreign tile. The spy
// itself is an ordinary unit marked `proto.spy`, so its invisibility lives in
// visibility.js and its freedom to cross foreign land in movement.js; this
// module owns the covert operations ordered against it, the reports they bring
// back, the counter-intelligence that fights them, and the spy satellites that
// watch the world from orbit.
import { key, parseKey } from "../hex.js";
import {
SABOTAGE,
ESPIONAGE,
SATELLITE,
SPY_REPORT_CITY,
SPY_REPORT_COMMS,
} from "../data/intelligence.js";
import {
NEWS_SABOTAGE,
NEWS_SATELLITE,
NEWS_ENRICHMENT,
} from "../data/relations.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { HOURS_PER_DAY } from "./constants.js";
export const intelligenceMethods = {
// ------------------------------------------------- intelligence levels --
// Whether a nation has professional intelligence services. This is the
// technology that unlocks the spy, so any nation with a spy has it.
_hasIntelligenceAgencies(civ) {
return this.hasTechnology(civ, "intelligence_agencies");
},
_hasCounterIntelligence(civ) {
return this.hasTechnology(civ, "counter_intelligence");
},
// The strength of a nation's offensive intelligence, 0 or 1 for now (one
// unlock-only technology). Read by the blame roll.
_intelligenceLevel(civ) {
return this._hasIntelligenceAgencies(civ) ? 1 : 0;
},
// The strength of a nation's defence against spies, 0 or 1. It makes blame
// land on the true culprit more often and makes incoming reports less honest.
_counterIntelligenceLevel(civ) {
return this._hasCounterIntelligence(civ) ? 1 : 0;
},
// A covert operation spends its operatives, so a larger team is the only way
// to out-match a counter-intelligence service: each spy beyond the first
// cancels `ESPIONAGE.agentsPerCounter` of the target's defence.
_effectiveCounterIntelligence(civ, agents = 1) {
const level = this._counterIntelligenceLevel(civ);
const extra = Math.max(0, (Number(agents) || 1) - 1);
return Math.max(0, level - extra * ESPIONAGE.agentsPerCounter);
},
// Removes the operatives an operation spent. A spy is single-use: it vanishes
// quietly, with no news line and no notice to its owner or its victim.
_spendSpies(units) {
for (const unit of units) this._destroyUnit(unit);
this._visibilityDirty = true;
},
// --------------------------------------------------------- sabotage ------
// Whether a stack may sabotage the tile it stands on: every unit is a spy,
// they share one tile, and that tile is foreign land.
canSabotage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
if (!units.every((unit) => this._isSpy(unit))) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
const owner = this.civAt(coords);
if (owner < 0 || owner === units[0].civ) return false;
const tile = this.tiles[key(coords.x, coords.y)];
return !!tile && tile.terrainClass === "Land";
},
// Sabotages the tile the spies stand on: its production is zeroed until a week
// has passed. `frame` is an optional third nation the author tries to pin it
// on; the victim cannot tell a correct blame from a successful false flag. The
// acting spies are spent.
requestSabotage(unitIds, { frame = null } = {}) {
const units = this._unitsForOrder(unitIds);
if (!this.canSabotage(unitIds)) return false;
const actor = units[0].civ;
const coords = units[0].coords;
const k = key(coords.x, coords.y);
const owner = this.civAt(coords);
const until = this.totalHours + SABOTAGE.blockDays * HOURS_PER_DAY;
this.tileSabotage.set(k, until);
this._dropTileGdpCache(k);
const region = this.regionCityAt(coords);
this._addNews({
type: NEWS_SABOTAGE,
civ: owner,
region: region ? region.name : "the frontier",
suspect: this._blameCulprit(actor, owner, frame, units.length),
});
this._spendSpies(units);
this._emitChanged();
return true;
},
// Who the victim pins a covert act on: the true author, a chosen scapegoat, or
// no one. A counter-intelligence service raises the chance of a true
// identification; the author's own agencies lower it, and a larger team of
// `agents` blunts the service in proportion.
_blameCulprit(actor, victim, frame, agents = 1) {
const chance = Math.max(
0,
Math.min(
1,
ESPIONAGE.baseIdentifyChance +
ESPIONAGE.counterIntelligenceWeight * this._effectiveCounterIntelligence(victim, agents) -
ESPIONAGE.agencyWeight * this._intelligenceLevel(actor)
)
);
if (this._random() < chance) return actor;
const scapegoat = Number.isInteger(frame) && frame >= 0 && frame < this.civilisations.length
? frame
: null;
if (scapegoat !== null && scapegoat !== actor && scapegoat !== victim) {
if (this._random() < ESPIONAGE.frameChance) return scapegoat;
}
return null;
},
// Whether a tile's production is currently crippled by sabotage.
isTileSabotaged(coords) {
const k = key(coords.x, coords.y);
return (this.tileSabotage.get(k) || 0) > this.totalHours;
},
// Drops the sabotage blocks whose week has run out and forgets the GDP figures
// they cached as zero, so a tile's production returns when the block ends.
_tickSabotage() {
if (!this.tileSabotage || this.tileSabotage.size === 0) return;
for (const [k, until] of this.tileSabotage) {
if (until > this.totalHours) continue;
this.tileSabotage.delete(k);
this._dropTileGdpCache(k);
}
},
// --------------------------------------------------- city/comms spying ---
// A spy may read a foreign city from the tile it stands on or the six around
// it: it can never enter a hostile city, so it watches from the doorstep.
_spyWatches(units, city) {
if (!city || city.civ === units[0].civ) return false;
const from = units[0].coords;
return this.topology.tileDistance(from, city.coords) <= 1;
},
_spyStackAtOneTile(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return null;
if (!units.every((unit) => this._isSpy(unit) && unit.civ === units[0].civ)) return null;
const coords = units[0].coords;
for (const unit of units) {
if (unit.coords.x !== coords.x || unit.coords.y !== coords.y) return null;
}
return units;
},
canCitySpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!units) return false;
return this._spyWatches(units, this.cityAt(goal));
},
// Reads a foreign city: its people, building levels and garrison. A
// counter-intelligence service can quietly falsify the report. The spying
// spies are spent.
requestCitySpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!this.canCitySpy(unitIds, goal)) return false;
const actor = units[0].civ;
this._addSpyReport(actor, this._buildCityReport(this.cityAt(goal), units.length));
this._spendSpies(units);
this._emitChanged();
return true;
},
_buildCityReport(city, agents = 1) {
const target = city.civ;
const falsify =
this._random() < ESPIONAGE.falsifyChance * this._effectiveCounterIntelligence(target, agents);
const buildings = [];
for (const [index, level] of Object.entries(city.buildings || {})) {
const n = Number(level) || 0;
if (n <= 0) continue;
const proto = this.protoBuildings[index];
buildings.push({
name: proto ? proto.name : `Building ${index}`,
level: falsify ? Math.max(0, n + (this._random() < 0.5 ? -1 : 1)) : n,
});
}
const counts = new Map();
for (const unit of this.unitsAt(city.coords)) {
if (unit.civ !== target) continue;
const proto = this.unitProto(unit);
const name = proto ? proto.name : "Unit";
counts.set(name, (counts.get(name) || 0) + 1);
}
const garrison = [];
for (const [name, count] of counts) {
garrison.push({
name,
count: falsify ? Math.max(0, count + (this._random() < 0.5 ? -1 : 1)) : count,
});
}
return {
id: this._nextSpyReportId++,
kind: SPY_REPORT_CITY,
hours: this.totalHours,
city: city.name,
civ: target,
coords: [city.coords.x, city.coords.y],
population: falsify ? this._jitterCount(city.population) : city.population,
buildings,
garrison,
};
},
canCommsSpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!units) return false;
const city = this.cityAt(goal);
if (!city || !city.isCapital) return false;
return this._spyWatches(units, city);
},
// Intercepts the target capital's recent messages. A counter-intelligence
// service can garble the text and swap the correspondents. The spying spies
// are spent.
requestCommsSpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!this.canCommsSpy(unitIds, goal)) return false;
const actor = units[0].civ;
this._addSpyReport(actor, this._buildCommsReport(this.cityAt(goal), units.length));
this._spendSpies(units);
this._emitChanged();
return true;
},
_buildCommsReport(capital, agents = 1) {
const target = capital.civ;
const garble =
this._random() < ESPIONAGE.garbleChance * this._effectiveCounterIntelligence(target, agents);
const involving = this.messages.filter(
(message) => message.from === target || message.to === target
);
const recent = involving.slice(-ESPIONAGE.interceptCount);
const messages = recent.map((message) =>
garble ? this._garbleMessage(message) : { ...message, garbled: false }
);
return {
id: this._nextSpyReportId++,
kind: SPY_REPORT_COMMS,
hours: this.totalHours,
civ: target,
capital: capital.name,
messages,
};
},
_garbleMessage(message) {
const swap = this._random() < 0.5;
return {
from: swap ? message.to : message.from,
to: swap ? message.from : message.to,
text: this._scramble(message.text),
hours: message.hours,
garbled: true,
};
},
_scramble(text) {
let out = "";
for (const character of String(text)) {
out += this._random() < 0.3 ? "▒" : character;
}
return out;
},
_jitterCount(value) {
const base = Number(value) || 0;
if (base <= 0) return 0;
const factor = 0.6 + this._random() * 0.8;
return Math.round(base * factor);
},
_addSpyReport(civ, report) {
if (!this.spyReports.has(civ)) this.spyReports.set(civ, []);
const list = this.spyReports.get(civ);
list.push(report);
while (list.length > ESPIONAGE.reportLimit) list.shift();
},
// ------------------------------------------------------------ comms ------
// Records a line of chat for the message log comms spying reads. `to` is a
// nation index for a private line, or null for the public channel.
recordChat(from, to, text) {
if (!Number.isInteger(from) || from < 0) return;
const message = {
from,
to: Number.isInteger(to) && to >= 0 ? to : null,
text: String(text || "").slice(0, 240),
hours: this.totalHours,
};
this.messages.push(message);
while (this.messages.length > ESPIONAGE.messageLogLimit) this.messages.shift();
},
// --------------------------------------------------------- satellites ----
// How many satellites a nation's space launch centres can sustain: one per
// centre level.
_spaceLaunchCapacity(civ) {
let total = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
total += Number(level) || 0;
}
}
}
return total;
},
_satelliteCount(civ) {
let total = 0;
for (const satellite of this.satellites) {
if (satellite.civ === civ) total += 1;
}
return total;
},
canLaunchSatellite(civ) {
return this._spaceLaunchCapacity(civ) > this._satelliteCount(civ);
},
// Launches a spy satellite from `city`, which must hold a space launch centre.
// Everyone is told; the satellite then uncovers a patch of the world every two
// days for as long as the game runs.
requestSatellite(civ, city) {
if (!city || city.civ !== civ) return false;
if (!this.canLaunchSatellite(civ)) return false;
let holdsCentre = false;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH && (Number(level) || 0) > 0) {
holdsCentre = true;
}
}
if (!holdsCentre) return false;
this.satellites.push({
civ,
launchedHours: this.totalHours,
nextRevealHour: this.totalHours + SATELLITE.revealEveryDays * HOURS_PER_DAY,
});
this._addNews({ type: NEWS_SATELLITE, civ });
this._emitChanged();
return true;
},
// Uncovers each satellite's next patch once its cadence comes round.
_tickSatellites() {
if (!this.satellites || this.satellites.length === 0) return;
for (const satellite of this.satellites) {
let guard = 0;
while (satellite.nextRevealHour <= this.totalHours && guard < 64) {
this._revealSatelliteChunk(satellite.civ);
satellite.nextRevealHour += SATELLITE.revealEveryDays * HOURS_PER_DAY;
guard += 1;
}
}
},
// Adds a random patch of the world to `civ`'s explored set, the same set the
// fog of war is drawn from.
_revealSatelliteChunk(civ) {
const explored = this.explored.get(civ);
if (!explored) return;
const pool = this.landCells.length > 0 ? this.landCells : Object.keys(this.tiles).map(parseKey);
if (pool.length === 0) return;
const centre = pool[Math.floor(this._random() * pool.length)];
const queue = [{ x: centre.x, y: centre.y, d: 0 }];
const seen = new Set([key(centre.x, centre.y)]);
while (queue.length > 0) {
const node = queue.shift();
const k = key(node.x, node.y);
if (this.tiles[k]) explored.add(k);
if (node.d >= SATELLITE.revealRadius) continue;
for (const neighbour of this.topology.neighbours(node.x, node.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk)) continue;
seen.add(nk);
queue.push({ x: neighbour.x, y: neighbour.y, d: node.d + 1 });
}
}
this._visibilityDirty = true;
},
// ---------------------------------------------------------- secrets ------
// A newly raised enrichment centre may be noticed by foreign intelligence.
_warnOfEnrichment(civ) {
if (this._random() >= ESPIONAGE.enrichmentWarningChance) return;
this._addNews({ type: NEWS_ENRICHMENT, civ });
},
_isSpy(unit) {
const proto = this.unitProto(unit);
return !!(proto && proto.spy);
},
};
+5 -1
View File
@@ -410,7 +410,11 @@ export const movementMethods = {
return false;
}
const owner = this.civAt(coords);
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
// A spy slips through foreign territory at will; every other unit treats a
// third party's land as a wall (the targeted civilisation is exempt).
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv && !proto.spy) {
return false;
}
const occupants = occupancy
? (occupancy.get(key(coords.x, coords.y)) || [])
: this.unitsAt(coords).filter((other) => other !== unit);
+25
View File
@@ -21,6 +21,15 @@ export const orderMethods = {
const proto = this.protoUnits[protoIndex];
// Aircraft and warships need the right installation in the city first.
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
// A unit may also be gated behind research, a single technology or several
// (an ICBM needs both the bomb and the rocket).
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
if (
Array.isArray(proto.requiresTechnologies) &&
!proto.requiresTechnologies.every((id) => this.hasTechnology(city.civ, id))
) {
return false;
}
// The local testing shortcut: no cost, no production time, no queue.
if (free) {
this._spawnTrainedUnit(city, proto);
@@ -259,6 +268,22 @@ export const orderMethods = {
return true;
},
// Debug/testing shortcut: grants a technology at once, skipping cost,
// progress and prerequisites. Reached only through the server's testing gate
// (a local page or a `--testing` game), never from a normal client.
unlockTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
const tech = this.technologies[index];
if (!tech) return false;
if (!tech.repeatable && this.isResearched(civ, index)) return false;
this._discoverTechnology(civ, index);
this._setTechnologyProgress(civ, index, 0);
if (!tech.repeatable && this.getFocusedTechnology(civ) === index) this._clearFocus(civ);
this._clearTileGdpCache();
this._emitChanged();
return true;
},
// Whether the units may pillage the tile they stand on: all of them are on
// one hostile tile where no battle is raging.
canPillage(unitIds) {
+73 -7
View File
@@ -70,8 +70,15 @@ export const politicsMethods = {
_tickPolitics() {
this._tickCampaigns();
this._tickMigration();
this._migrateForIncome();
// One day's out-migration budget per tile, shared by every mechanism that
// moves people below, so no tile sheds more than its daily share.
this._migrationOutDay = new Map();
try {
this._tickMigration();
this._migrateForIncome();
} finally {
this._migrationOutDay = null;
}
this._tickUprisings();
this._tickCultureImpact();
this._expireGrudges();
@@ -694,6 +701,39 @@ export const politicsMethods = {
this._migrateByAir();
},
// Whether migration of `eth` from one country into `toCiv` is forbidden:
// people never move into a country they are at war with, and a nation that
// expels a people does not let them back in.
_migrationBlocked(fromCiv, toCiv, eth) {
if (fromCiv >= 0 && toCiv >= 0 && fromCiv !== toCiv && this.isAtWar(fromCiv, toCiv)) {
return true;
}
return toCiv >= 0 && this._expelsEthnicity(toCiv, eth);
},
// Whether a nation has an active expel policy against a people.
_expelsEthnicity(civ, eth) {
const record = this.policies.get(civ);
if (!record) return false;
return record.some(
(policy) => policy.type === POLICY_EXPEL && policy.ethnicity === eth
);
},
// Clamps the `people` leaving one tile to the day's remaining out-migration
// budget and books what is spent. With no daily budget active (a direct call
// outside the tick) nothing is clamped.
_takeMigrationAllowance(fromK, people) {
if (!(people > 0)) return 0;
if (!this._migrationOutDay) return people;
const population = this.tilePopulation.get(fromK) || 0;
const cap = population * MIGRATION.maxDailyOutShare;
const used = this._migrationOutDay.get(fromK) || 0;
const allowed = Math.max(0, Math.min(people, cap - used));
if (allowed > 0) this._migrationOutDay.set(fromK, used + allowed);
return allowed;
},
// ------------------------------------------------- income migration --
// People drift from poorer regions to richer ones, but only to places clearly
@@ -833,7 +873,18 @@ export const politicsMethods = {
if (source.length === 0 || dest.length === 0) return 0;
const totalFrom = source.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
if (totalFrom <= 0) return 0;
const moved = Math.min(amount, totalFrom);
let moved = Math.min(amount, totalFrom);
// No tile may shed more than its daily out-migration share, so the move is
// bounded by the budget still open across the source region.
if (this._migrationOutDay) {
let allowance = 0;
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
const cap = people * MIGRATION.maxDailyOutShare;
allowance += Math.max(0, cap - (this._migrationOutDay.get(entry.k) || 0));
}
if (moved > allowance) moved = allowance;
}
if (!(moved > 0)) return 0;
// The aggregate ethnic mix of the people leaving.
@@ -849,7 +900,11 @@ export const politicsMethods = {
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
this.tilePopulation.set(entry.k, people - moved * people / totalFrom);
const leave = moved * people / totalFrom;
this.tilePopulation.set(entry.k, people - leave);
if (this._migrationOutDay && leave > 0) {
this._migrationOutDay.set(entry.k, (this._migrationOutDay.get(entry.k) || 0) + leave);
}
}
const totalTo = dest.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
for (const entry of dest) {
@@ -1112,7 +1167,9 @@ export const politicsMethods = {
},
// Moves `share` of one ethnicity's people from tile `fromK` to tile `toK`,
// renormalises both tiles' shares and returns how many people moved.
// renormalises both tiles' shares and returns how many people moved. Migration
// into a hostile country, or a people the destination expels, is refused, and
// the move is clamped to the tile's daily out-migration budget.
_transferEthnicity(fromK, toK, eth, share) {
const fromShares = this.tileEthnicity.get(fromK);
const toShares = this.tileEthnicity.get(toK);
@@ -1122,14 +1179,23 @@ export const politicsMethods = {
const fromPopulation = this.tilePopulation.get(fromK) || 0;
const toPopulation = this.tilePopulation.get(toK) || 0;
if (fromPopulation <= 0 || toPopulation <= 0) return 0;
const movedFraction = fromFraction * share;
const fromCiv = this.territory.has(fromK) ? this.territory.get(fromK) : -1;
const toCiv = this.territory.has(toK) ? this.territory.get(toK) : -1;
if (this._migrationBlocked(fromCiv, toCiv, eth)) return 0;
let movedFraction = fromFraction * share;
let movedPeople = movedFraction * fromPopulation;
const allowed = this._takeMigrationAllowance(fromK, movedPeople);
if (!(allowed > 0)) return 0;
if (allowed < movedPeople) {
movedPeople = allowed;
movedFraction = movedPeople / fromPopulation;
}
const remaining = Math.max(0, fromFraction - movedFraction);
if (remaining <= 0.0001) fromShares.delete(eth);
else fromShares.set(eth, remaining);
toShares.set(eth, (toShares.get(eth) || 0) + movedFraction * (fromPopulation / toPopulation));
this._setTileEthnicFractions(parseKey(toK), new Map(toShares));
this._setTileEthnicFractions(parseKey(fromK), new Map(fromShares));
const movedPeople = movedFraction * fromPopulation;
this._trackMigration(fromK, toK, eth, movedPeople);
this._touchPolitics();
return movedPeople;
+7 -3
View File
@@ -2272,8 +2272,10 @@ export const resourceMethods = {
// its central bank. Freight rides with the goods.
if (foreign) {
// The seller's government charges the foreign buyer an export tariff,
// in the seller's currency, on top of the goods.
const exportTax = value * this.getTaxRate(supplier.civ, "export", id);
// in the seller's currency, on top of the goods. A free-trade treaty
// between the two governments lifts it.
const exemption = this._tariffExempt(supplier.civ, city.civ);
const exportTax = exemption ? 0 : value * this.getTaxRate(supplier.civ, "export", id);
// The central bank pays part of the bill from its reserves, so a region
// or converter imports food and energy more cheaply than the market.
const subsidy = this._centralBankImportSubsidy(city.civ, id, value + exportTax);
@@ -2295,7 +2297,9 @@ export const resourceMethods = {
// The buyer's own government taxes the national currency it had to spend
// to buy the foreign currency (nothing held means nothing spent).
if (!payer && acquired.spent > 0) {
const importRate = this.getTaxRate(city.civ, "import", id);
const importRate = this._tariffExempt(supplier.civ, city.civ)
? 0
: this.getTaxRate(city.civ, "import", id);
if (importRate > 0) {
const importTax = acquired.spent * importRate;
charge(importTax);
+71
View File
@@ -59,6 +59,8 @@ export const serializationMethods = {
// production per head without shipping the whole breakdown for every tile.
tileProductionPenalties: this._serializeTileValues(this.tileProductionPenalty),
tileBattleProductionDeficits: this._serializeTileValues(this.tileBattleProductionDeficit),
// Tiles a spy has crippled, with the hour each block ends.
tileSabotage: this._serializeSabotage(),
civStats: this._serializeCivStats(),
cityStats: cityStats,
budgets: this._serializeCivValues(this.budgets),
@@ -93,6 +95,11 @@ export const serializationMethods = {
repeatCounts: this._serializeRepeatCounts(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
// Diplomatic treaties: offers awaiting an answer, the agreements in
// force, and the ones that ended.
treaties: this._serializeTreaties(),
treatyProposals: this._serializeTreatyProposals(),
treatyHistory: this._serializeTreatyHistory(),
news: this._serializeNews(),
battles: this._serializeBattles(),
// The global resource market: today's price and the supply and demand
@@ -142,6 +149,12 @@ export const serializationMethods = {
result.viewerStats = stats || this.viewerStats(viewerCiv);
result.explored = this._cachedExplored(viewerCiv);
result.visible = this._cachedVisible(viewerCiv);
// Enemy submarines stay off the map until one of the viewer's naval units
// is on or beside them.
const hidden = this._hiddenEnemyUnits(viewerCiv);
if (hidden.size > 0) {
result.units = (shared.units || []).filter((unit) => !hidden.has(unit.id));
}
// What a city is producing is its owner's business: every other viewer sees
// an empty queue, so neither the map bar nor the city panel can reveal
// whether a foreign city is busy.
@@ -149,6 +162,15 @@ export const serializationMethods = {
// The delivery graph is the viewer's own network: its storage nodes and the
// legs that carried goods into its cities. Only the economic map reveals it.
result.resourceGraph = this._serializeResourceGraph(viewerCiv);
// The spy reports brought home for this viewer. The chat log they are drawn
// from stays on the server and never ships.
result.spyReports = (this.spyReports && this.spyReports.get(viewerCiv)) || [];
// Satellites are few and their launches are public knowledge.
result.satellites = (this.satellites || []).map((satellite) => ({
civ: satellite.civ,
launchedHours: satellite.launchedHours,
nextRevealHour: satellite.nextRevealHour,
}));
return result;
},
@@ -344,6 +366,18 @@ export const serializationMethods = {
return result;
},
// Tiles crippled by sabotage, as [x, y, untilHour]. The map only ever holds
// live blocks (`_tickSabotage` drops them as they lapse), so the list is tiny.
_serializeSabotage() {
const result = [];
for (const [k, until] of this.tileSabotage) {
if (until <= this.totalHours) continue;
const coords = parseKey(k);
result.push([coords.x, coords.y, until]);
}
return result;
},
_serializeTileImprovements() {
const result = [];
for (const [k, id] of this.tileImprovements) {
@@ -608,6 +642,43 @@ export const serializationMethods = {
return this.news.map((entry) => ({ ...entry }));
},
// A treaty as it travels on the wire: the parties, the terms' payload, and
// the signing (and, in the history, ending) dates.
_serializeTreaty(treaty) {
return {
id: treaty.id,
type: treaty.type,
a: treaty.a,
b: treaty.b,
payload: treaty.payload ? JSON.parse(JSON.stringify(treaty.payload)) : {},
signedHours: treaty.signedHours,
signedDate: treaty.signedDate,
endedHours: treaty.endedHours,
endedDate: treaty.endedDate,
reason: treaty.reason,
};
},
_serializeTreaties() {
return this.treaties.map((treaty) => this._serializeTreaty(treaty));
},
_serializeTreatyHistory() {
return this.treatyHistory.map((treaty) => this._serializeTreaty(treaty));
},
_serializeTreatyProposals() {
return this.treatyProposals.map((proposal) => ({
id: proposal.id,
type: proposal.type,
from: proposal.from,
to: proposal.to,
payload: proposal.payload ? JSON.parse(JSON.stringify(proposal.payload)) : {},
proposedHours: proposal.proposedHours,
proposedDate: proposal.proposedDate,
}));
},
// One entry per battlefield: [x, y, defenderCiv, attackerCiv]. The tile's
// owner is the defender when present; otherwise the lowest-numbered civ on
// the tile defends. The browser uses this to draw the two stacks facing each
+26 -39
View File
@@ -3,19 +3,15 @@
// Ordering a tile improvement no longer pays for it and raises it at once.
// Instead the state hands the site a forecast budget and the tile becomes an
// economic node that first gathers the materials it needs -- buying them from
// the reachable stores at the prices that stood when the work was decided -- and
// only then builds, drawing power from the grid as it goes. Leftover money stays
// with the improvement; a bill the site cannot cover is taken from the public
// treasury.
// the reachable stores at whatever the market currently asks -- and only then
// builds, drawing power from the grid as it goes. Leftover money stays with the
// improvement; a bill the site cannot cover is taken from the public treasury.
//
// A site stalls when a whole day passes without buying a needed material at the
// locked price (the market moved against it). Rather than waiting for a manual
// top-up, it raises its budgeted prices -- and the money behind them -- each
// day, 1% at first and doubling daily, until it can buy; only when no supplier
// is within reach at all does it keep a lasting warning. The owner may still
// cancel the work, losing everything already spent. Production buildings that
// finish become private agents and step through the same phases again when they
// upgrade.
// A site stalls only when no supplier of a needed material is within reach;
// there is no daily budget rise any more, the work simply buys at the current
// market price. The owner may still cancel the work, losing everything already
// spent. Production buildings that finish become private agents and step
// through the same phases again when they upgrade.
import { key } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
@@ -26,14 +22,6 @@ import {
} from "../resources.js";
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
// A supplier whose delivered price has risen more than this much over the
// locked price is not bought from: the site waits for the market or raises its
// own budget.
const PRICE_TOLERANCE = 0.01;
// The first day a stalled site cannot buy, its budgeted prices and the matching
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
// it bids its way up to the market unaided.
const BUDGET_DAILY_RISE = 0.01;
const MATERIAL_IDS = ["steel", "hightech"];
const RESOURCE_IDS = ["steel", "hightech", "energy"];
@@ -219,10 +207,13 @@ export const siteMethods = {
}
},
// Tries to buy every material the site still needs at its locked prices. A day
// that buys nothing while materials are still missing stalls the work, and the
// budget below starts climbing to meet the market.
// Tries to buy every material the site still needs at the current market
// prices. A day that buys nothing while materials are still missing stalls the
// work because no supplier is within reach.
_gatherSiteMaterials(site) {
// Keep the site's reported prices pointed at the live market rather than a
// price frozen when the work was ordered.
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
let boughtAnything = false;
for (const id of MATERIAL_IDS) {
const missing = site.needed[id] - site.bought[id];
@@ -250,22 +241,19 @@ export const siteMethods = {
if (!stalled) site.budgetRaises = 0;
},
// A day that bought nothing while a supplier is within reach raises the site's
// budgeted prices and moves the matching money out of the treasury, then tries
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
// 8%, ...), so a stubborn price is met within a few days. A supplier still
// above the raised cap simply stalls it once more; the count resets as soon as
// it buys. When no supplier can reach the site at any price the rise cannot
// help, so it is left alone. A private site raises its own agent's account
// instead of the treasury: the money is borrowed, not subsidised.
// A day that bought nothing while a supplier is within reach prices what is
// still missing at the current market and moves the difference into the site's
// account, then tries again. There is no daily percentage rise any more: the
// site simply buys at whatever the market currently asks. When no supplier can
// reach the site at any price the top-up cannot help, so it is left alone. A
// private site raises its own agent's account instead of the treasury: the
// money is borrowed, not subsidised.
_raiseSiteBudgetIfStalled(site) {
if (!site.stalled || site.phase !== "materials") return;
if (this._siteUnreachableMaterials(site).length > 0) return;
const raises = site.budgetRaises || 0;
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
site.budgetRaises = raises + 1;
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
const add = this._siteTopUp(site, site.prices);
if (!(add > 0)) return;
if (!site.private) this._reserveSiteBudget(site.civ, add);
site.budget += add;
this._gatherSiteMaterials(site);
@@ -274,8 +262,8 @@ export const siteMethods = {
// Buys `amount` of one resource for the site: first from the allied city the
// route hauls to, then from the reachable stores, paying from the site's own
// budget first and the public treasury after. A supplier quoting above the
// locked price is skipped.
// budget first and the public treasury after. The site does not hold out for a
// locked price -- it buys at whatever the cheapest reachable supplier asks.
_siteBuy(site, id, amount) {
if (!(amount > 1e-9)) return 0;
if (!site.cityId) return 0;
@@ -298,11 +286,10 @@ export const siteMethods = {
const nodes = this._resourceNodes();
const temp = emptyResources();
const allowSea = this._cityMarketAccess(city);
const cap = site.prices[id] * (1 + PRICE_TOLERANCE);
this._procureFromNeighbours(city, id, remaining, temp, nodes, {
payer,
allowSea,
maxUnitCost: cap,
maxUnitCost: Infinity,
});
bought += temp[id] || 0;
}
+390
View File
@@ -0,0 +1,390 @@
// Diplomatic treaties: proposals that one nation makes to another, the active
// agreements they become when signed, and the history of those that ended. The
// type catalogue lives in `data/treaties.js`; this module owns the state machine
// (propose -> sign/reject -> break) and the effects each treaty carries.
//
// A signed treaty is a record `{ id, type, a, b, signedHours, signedDate,
// payload }` in `this.treaties`. A proposal is `{ id, type, from, to, payload,
// proposedHours, proposedDate }` in `this.treatyProposals`. Ended treaties move
// to `this.treatyHistory` with the reason and hour they ended. Everything is
// public, so it all travels in the shared snapshot.
import { key } from "../hex.js";
import {
TREATY_PEACE,
TREATY_ALLIANCE,
TREATY_FREE_TRADE,
TREATY_GIVE_CITY,
TREATY_GIVE_MILITARY,
TREATY_GIVE_RESOURCES,
TREATY_GIVE_MONEY,
TREATY_JOINT_RESEARCH,
treatyById,
TREATY_PROPOSED,
TREATY_SIGNED,
TREATY_REJECTED,
TREATY_BROKEN,
} from "../data/treaties.js";
import { NEWS_TREATY } from "../data/relations.js";
import { STORABLE_RESOURCE_IDS } from "../data/resources.js";
import { warKey } from "./helpers.js";
import { dateString } from "../game_clock.js";
// A treaty proposal older than this many hours is dropped, so a stale offer
// does not sit in the receiver's inbox for ever.
const PROPOSAL_TTL_HOURS = 24 * 30;
export const treatyMethods = {
// ------------------------------------------------------------ lifecycle --
// Offers a treaty from one nation to another. The type must be known, the
// parties distinct and valid, and any payload (a city, units, a resource
// basket or money) must belong to the proposer. A duplicate offer for the
// same type between the same two nations is refused.
requestProposeTreaty(from, to, type, payload = {}) {
if (!this._validCiv(from) || !this._validCiv(to) || from === to) return false;
const proto = treatyById(type);
if (!proto) return false;
if (this._pendingTreaty(from, to, type)) return false;
if (this.hasTreaty(from, to, type)) return false;
const clean = this._validateTreatyPayload(from, to, type, payload);
if (clean === null) return false;
const proposal = {
id: this._nextTreatyProposalId++,
type,
from,
to,
payload: clean,
proposedHours: this.totalHours,
proposedDate: dateString(this.totalHours),
};
this.treatyProposals.push(proposal);
this._addNews({
type: NEWS_TREATY, stage: TREATY_PROPOSED, treaty: type, from, to,
});
this._emitChanged();
return true;
},
// Accepts or rejects a proposal addressed to `civ`. Only the nation the
// proposal names as its receiver may answer, and only once.
requestRespondTreaty(civ, proposalId, accept) {
const index = this.treatyProposals.findIndex((p) => p.id === proposalId);
if (index < 0) return false;
const proposal = this.treatyProposals[index];
if (proposal.to !== civ) return false;
this.treatyProposals.splice(index, 1);
if (!accept) {
this._addNews({
type: NEWS_TREATY, stage: TREATY_REJECTED, treaty: proposal.type,
from: proposal.from, to: proposal.to,
});
this._emitChanged();
return true;
}
const payload = this._validateTreatyPayload(proposal.from, proposal.to, proposal.type, proposal.payload);
if (payload === null) return false;
const treaty = {
id: this._nextTreatyId++,
type: proposal.type,
a: proposal.from,
b: proposal.to,
payload,
signedHours: this.totalHours,
signedDate: dateString(this.totalHours),
};
// Free trade is a standing exemption, not a one-off effect.
if (proposal.type !== TREATY_FREE_TRADE) this._applyTreatyEffect(treaty);
this.treaties.push(treaty);
this._addNews({
type: NEWS_TREATY, stage: TREATY_SIGNED, treaty: treaty.type,
from: treaty.a, to: treaty.b,
});
this._emitChanged();
return true;
},
// Ends an active treaty, whoever asks. Used when a player breaks an
// agreement and when a war cancels an alliance.
_endTreaty(treaty, reason = TREATY_BROKEN) {
const index = this.treaties.indexOf(treaty);
if (index < 0) return false;
this.treaties.splice(index, 1);
treaty.endedHours = this.totalHours;
treaty.endedDate = dateString(this.totalHours);
treaty.reason = reason;
this.treatyHistory.push(treaty);
while (this.treatyHistory.length > this._treatyHistoryLimit) this.treatyHistory.shift();
this._addNews({
type: NEWS_TREATY, stage: reason, treaty: treaty.type,
from: treaty.a, to: treaty.b,
});
return true;
},
// A player cancels one of their own active treaties.
requestCancelTreaty(civ, treatyId) {
const treaty = this.treaties.find((t) => t.id === treatyId);
if (!treaty || (treaty.a !== civ && treaty.b !== civ)) return false;
this._endTreaty(treaty, TREATY_BROKEN);
this._emitChanged();
return true;
},
// ------------------------------------------------------------- queries --
hasTreaty(a, b, type) {
return this.getTreaty(a, b, type) !== null;
},
getTreaty(a, b, type) {
if (a < 0 || b < 0 || a === b) return null;
for (const treaty of this.treaties) {
if (treaty.type !== type) continue;
if ((treaty.a === a && treaty.b === b) || (treaty.a === b && treaty.b === a)) {
return treaty;
}
}
return null;
},
areAllied(a, b) {
return this.hasTreaty(a, b, TREATY_ALLIANCE);
},
freeTradePartners(civ) {
const partners = new Set();
for (const treaty of this.treaties) {
if (treaty.type !== TREATY_FREE_TRADE) continue;
if (treaty.a === civ) partners.add(treaty.b);
else if (treaty.b === civ) partners.add(treaty.a);
}
return partners;
},
// Whether a tariff between a seller and a buyer is lifted by a free-trade
// treaty. Used by the resource market when it charges the tax.
_tariffExempt(seller, buyer) {
if (seller < 0 || buyer < 0) return false;
return this.freeTradePartners(seller).has(buyer);
},
// Every nation allied with `civ`.
_alliesOf(civ) {
const allies = [];
for (const treaty of this.treaties) {
if (treaty.type !== TREATY_ALLIANCE) continue;
if (treaty.a === civ) allies.push(treaty.b);
else if (treaty.b === civ) allies.push(treaty.a);
}
return allies;
},
// Nations that share a joint-research treaty with `civ`.
jointResearchPartners(civ) {
const partners = [];
for (const treaty of this.treaties) {
if (treaty.type !== TREATY_JOINT_RESEARCH) continue;
if (treaty.a === civ) partners.push(treaty.b);
else if (treaty.b === civ) partners.push(treaty.a);
}
return partners;
},
_pendingTreaty(from, to, type) {
return this.treatyProposals.some(
(p) => p.type === type && p.from === from && p.to === to
);
},
// Expires old proposals; called from the hourly tick. Returns true when the
// inbox changed.
_expireTreatyProposals() {
if (this.treatyProposals.length === 0) return false;
const before = this.treatyProposals.length;
this.treatyProposals = this.treatyProposals.filter(
(p) => this.totalHours - p.proposedHours < PROPOSAL_TTL_HOURS
);
return this.treatyProposals.length !== before;
},
// ------------------------------------------------------------ validation --
// Returns the cleaned payload for a treaty, or null when it cannot be
// honoured. The `from` nation must own whatever it is giving away.
_validateTreatyPayload(from, to, type, payload) {
const data = payload || {};
if (type === TREATY_GIVE_CITY) {
const city = this.findCity(Number(data.city));
if (!city || city.civ !== from) return null;
return { city: city.id };
}
if (type === TREATY_GIVE_MILITARY) {
const ids = Array.isArray(data.units) ? data.units.map(Number) : [];
const units = [];
for (const id of ids) {
const unit = this.findUnit(id);
if (!unit || unit.civ !== from) return null;
const proto = this.unitProto(unit);
if (proto && proto.infantry) return null;
units.push(unit.id);
}
if (units.length === 0) return null;
return { units };
}
if (type === TREATY_GIVE_RESOURCES) {
const basket = {};
let any = false;
for (const id of STORABLE_RESOURCE_IDS) {
const amount = Number((data.resources || {})[id] || 0);
if (!(amount > 0)) continue;
basket[id] = amount;
any = true;
}
return any ? { resources: basket } : null;
}
if (type === TREATY_GIVE_MONEY) {
const amount = Number(data.amount || 0);
if (!(amount > 0)) return null;
return { amount };
}
// Peace, alliance, free trade and joint research carry no payload.
return {};
},
// -------------------------------------------------------------- effects --
_applyTreatyEffect(treaty) {
switch (treaty.type) {
case TREATY_PEACE:
this._makePeace(treaty.a, treaty.b);
break;
case TREATY_GIVE_CITY: {
const city = this.findCity(treaty.payload.city);
if (city && city.civ === treaty.a) this._captureCity(city, treaty.b);
break;
}
case TREATY_GIVE_MILITARY:
this._giftUnits(treaty.a, treaty.b, treaty.payload.units);
break;
case TREATY_GIVE_RESOURCES:
this._giftResources(treaty.a, treaty.b, treaty.payload.resources);
break;
case TREATY_GIVE_MONEY: {
const amount = treaty.payload.amount;
this.budgets.set(treaty.a, this.getBudget(treaty.a) - amount);
this.budgets.set(treaty.b, this.getBudget(treaty.b) + amount);
break;
}
default:
break;
}
},
// Peace ends the war, forgets the conflict record only in so far as combat
// stops, and sends each side's troops back to their nearest city.
_makePeace(a, b) {
const k = warKey(a, b);
if (this.wars.has(k)) {
this.wars.delete(k);
this.conflicts.delete(k);
this._invalidateSpatialCaches();
}
this._sendTroopsHome(a, b);
this._sendTroopsHome(b, a);
},
// Teleports every unit of `civ` standing in `enemy`'s territory to its
// nearest own city. An island unit with no reachable home simply appears at
// the nearest city by tile distance, mirroring the "for now" simplification.
_sendTroopsHome(civ, enemy) {
const home = this.cities.filter((city) => city.civ === civ);
if (home.length === 0) return;
for (const unit of this.units.slice()) {
if (unit.civ !== civ) continue;
const k = key(unit.coords.x, unit.coords.y);
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (owner !== enemy) continue;
let best = home[0];
let bestDistance = Infinity;
for (const city of home) {
const distance = this.topology.tileDistance(unit.coords, city.coords);
if (distance < bestDistance) {
bestDistance = distance;
best = city;
}
}
unit.path = [];
unit.pathIndex = 0;
unit.strikeTarget = null;
this._moveUnitTo(unit, { x: best.coords.x, y: best.coords.y });
}
this._invalidateUnitCaches();
},
_giftUnits(from, to, ids) {
let moved = 0;
for (const id of ids) {
const unit = this.findUnit(id);
if (!unit || unit.civ !== from) continue;
this._unindexUnit(unit);
unit.civ = to;
this._indexUnit(unit);
moved += 1;
}
if (moved > 0) {
this._invalidateUnitCaches();
this._touchModifiers();
}
},
// Moves a resource basket from one nation to another, draining the giver's
// cities in proportion to what each holds and dropping it all on the
// receiver's capital (or first city). Non-storable goods such as energy are
// skipped.
_giftResources(from, to, basket) {
const givers = this.cities.filter((city) => city.civ === from);
const takers = this.cities.filter((city) => city.civ === to);
if (givers.length === 0 || takers.length === 0) return;
const sink = takers.find((city) => city.isCapital) || takers[0];
const sinkStore = this.getCityResourceStock(sink);
for (const [id, wanted] of Object.entries(basket || {})) {
let remaining = wanted;
for (const city of givers) {
if (remaining <= 0) break;
const store = this.getCityResourceStock(city);
const take = Math.min(store[id] || 0, remaining);
if (take <= 0) continue;
store[id] -= take;
remaining -= take;
}
const moved = wanted - remaining;
if (moved > 0) sinkStore[id] = (sinkStore[id] || 0) + moved;
}
},
// Declaring war tears up any alliance or peace between the two and drags the
// target's allies into the fight. Called from `_declareWar` before the war
// records are written, so an alliance never coexists with a war. The broken
// treaty's news entry is the warning the players get.
_handleWarAlliances(actor, target) {
const defenders = new Set([target]);
for (const ally of this._alliesOf(target)) defenders.add(ally);
for (const defender of defenders) {
this._breakTreaty(actor, defender, TREATY_ALLIANCE);
this._breakTreaty(actor, defender, TREATY_PEACE);
}
return Array.from(defenders).filter((civ) => civ !== actor);
},
_breakTreaty(a, b, type) {
const treaty = this.getTreaty(a, b, type);
if (!treaty) return false;
this._endTreaty(treaty, TREATY_BROKEN);
return true;
},
_breakAlliance(a, b) {
return this._breakTreaty(a, b, TREATY_ALLIANCE);
},
};
+33 -2
View File
@@ -47,8 +47,39 @@ export const visibilityMethods = {
this._visibleVersion += 1;
},
_revealAll() {
for (let i = 0; i < this.civilisations.length; i++) {
// The enemy units a nation cannot see: a submarine stays hidden unless one of
// the viewer's naval units sits on its tile or directly beside it, and a spy
// is hidden from every other nation outright. A spectator (viewer -1) sees
// everything.
_hiddenEnemyUnits(viewerCiv) {
const hidden = new Set();
if (viewerCiv < 0) return hidden;
for (const unit of this.units) {
const proto = this.unitProto(unit);
if (!proto || unit.civ === viewerCiv) continue;
if (proto.spy) {
hidden.add(unit.id);
continue;
}
if (proto.submarine && !this._submarineDetectedBy(unit.coords, viewerCiv)) {
hidden.add(unit.id);
}
}
return hidden;
},
_submarineDetectedBy(coords, viewerCiv) {
for (const spot of [coords, ...this._neighbours(coords)]) {
for (const other of this.unitsAt(spot)) {
if (other.civ !== viewerCiv) continue;
const proto = this.unitProto(other);
if (proto && proto.naval) return true;
}
}
return false;
},
_revealAll() { for (let i = 0; i < this.civilisations.length; i++) {
this.explored.set(i, new Set(Object.keys(this.tiles)));
}
},
+12 -10
View File
@@ -212,32 +212,34 @@ export const warfareMethods = {
// ------------------------------------------------------ transports --
// Infantry board a friendly launch. They must stand on the shore beside it;
// the boarding takes EMBARK_HOURS in-game hours to complete.
requestEmbark(infantryIds, launchId) {
// Infantry and spies board a friendly launch. They must stand on the shore
// beside it; the boarding takes EMBARK_HOURS in-game hours to complete. A spy
// rides a launch to reach an island its land legs cannot.
requestEmbark(passengerIds, launchId) {
const launch = this.findUnit(Number(launchId));
if (!launch) return false;
const proto = this.unitProto(launch);
if (!proto || !(proto.transportCapacity > 0)) return false;
if (launch.embarking) return false;
const cargo = launch.cargo || [];
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
const passengers = this._unitsForOrder(passengerIds).filter((unit) => {
const unitProto = this.unitProto(unit);
return unitProto && unitProto.infantry && unit.civ === launch.civ;
const boards = unitProto && (unitProto.infantry || unitProto.spy);
return boards && unit.civ === launch.civ;
});
if (infantry.length === 0) return false;
if (cargo.length + infantry.length > proto.transportCapacity) return false;
if (passengers.length === 0) return false;
if (cargo.length + passengers.length > proto.transportCapacity) return false;
// The troops must be on the shore next to the launch.
if (!infantry.every((unit) =>
if (!passengers.every((unit) =>
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
return false;
}
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
launch.embarking = { ids: passengers.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
this._emitChanged();
return true;
},
// Finishes any boarding: at zero hours the infantry leave the map and become
// Finishes any boarding: at zero hours the passengers leave the map and become
// the launch's cargo, and are carried until put ashore.
_tickEmbark() {
// Iterate a copy: boarding destroys the infantry, and splicing the live
+152
View File
@@ -0,0 +1,152 @@
// Weapons of mass destruction: one-use missiles and the nuclear warhead they
// can carry. A missile is trained like any other unit but never marches; firing
// it consumes the rocket and lands its warhead -- conventional or nuclear -- on
// a tile within reach. An intercontinental ballistic missile carries its
// nuclear payload to any tile on the map.
import { key } from "../hex.js";
import { WMD } from "../data/combat.js";
import { tileImprovementById } from "../data/improvements.js";
export const wmdMethods = {
// ------------------------------------------------------------- missiles --
_isMissileProto(proto) {
return !!(proto && proto.missile);
},
_isMissileUnit(unit) {
return this._isMissileProto(this.unitProto(unit));
},
// How far a missile reaches: a global rocket reaches anywhere.
_missileRange(proto) {
if (!proto) return 0;
return proto.globalRange ? Infinity : (proto.range || 0);
},
// Whether a nation may arm a missile with a nuclear warhead: it needs the
// bomb, which in turn only became researchable once a uranium enrichment
// centre stood somewhere in the country.
_canUseNuclearPayload(civ) {
return this.hasTechnology(civ, "nuclear_weapons");
},
// Fires one or more missiles at `goal`. A conventional warhead strikes the
// unit or city there; a nuclear one flattens the whole tile. `nuclear` is
// honoured only for a nation that has researched the bomb, and an ICBM is
// always nuclear. Every missile is consumed, so the stack is gone afterwards.
requestMissileStrike(unitIds, goal, { nuclear = false } = {}) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit));
if (units.length === 0) return false;
const civ = units[0].civ;
if (units.some((unit) => unit.civ !== civ)) return false;
// An ICBM carries a nuclear warhead by design; a plain missile may be
// up-armed only by a nuclear power.
const anyNuclear = units.some((unit) => {
const proto = this.unitProto(unit);
return !!(proto && proto.nuclear);
});
const useNuclear = nuclear || anyNuclear;
if (useNuclear && !this._canUseNuclearPayload(civ)) return false;
const target = this._strikeTargetAt(goal, civ);
const city = target ? null : this._strikeCityAt(goal, civ);
if (!target && !city) return false;
if (units.some((unit) => !this._missileInRange(unit, goal))) return false;
this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) {
if (useNuclear) this._applyNuclearBlast(goal, civ);
else this._resolveConventionalMissile(unit, goal);
this._destroyUnit(unit);
}
this._invalidateUnitCaches();
this._visibilityDirty = true;
this._emitChanged();
return true;
},
_missileInRange(unit, goal) {
const proto = this.unitProto(unit);
if (!proto) return false;
const distance = this.topology.tileDistance(unit.coords, goal);
return distance <= this._missileRange(proto) + 1e-9;
},
// A conventional warhead is simply a very heavy ranged strike: it hits the
// garrison if one is there, otherwise the city itself.
_resolveConventionalMissile(unit, goal) {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
this._applyStrikeDamage(target, damage);
return;
}
const city = this._strikeCityAt(goal, unit.civ);
if (city) this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
},
// ------------------------------------------------------------------ WMD --
// A nuclear detonation: nine people in ten on the tile die, every building
// and work there is flattened except the ones built to survive a bomb, and
// the world recoils -- every nation, the attacker included, loses a thousand
// points of standing.
_applyNuclearBlast(coords, attackerCiv = -1) {
const k = key(coords.x, coords.y);
const population = this.tilePopulation.get(k) || 0;
if (population > 0) {
this.tilePopulation.set(k, population * WMD.populationSurvivorFraction);
this._populationVersion += 1;
}
this._dropTileGdpCache(k);
const city = this.cityAt(coords);
if (city) this._nuclearRazeCity(city);
this._nuclearDestroyImprovement(coords);
// No unit caught on the ground at the point of detonation survives, whatever
// flag it flies.
for (const unit of this.unitsAt(coords).slice()) this._destroyUnit(unit);
this._invalidateUnitCaches();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this._shiftOwnPopularity(civ, -WMD.popularityPenalty);
}
this._emitChanged();
},
// Every building in the city is destroyed outright, except the bomb-proof
// ones (bunkers). The levels are gone for good, not queued for repair.
_nuclearRazeCity(city) {
let razed = false;
for (const index of Object.keys(city.buildings || {})) {
const level = city.buildings[index];
if (level <= 0) continue;
const proto = this.protoBuildings[index];
if (proto && proto.nuclearProof) continue;
delete city.buildings[index];
razed = true;
}
if (razed) {
this._clearTileGdpCache();
this._touchModifiers();
}
},
// The works and roads on a nuked tile are destroyed, save the fortifications
// (and any other nuclear-proof work) that were built to survive one.
_nuclearDestroyImprovement(coords) {
const k = key(coords.x, coords.y);
if (this.tileImprovements.has(k)) {
const id = this.tileImprovements.get(k);
const proto = tileImprovementById(id);
if (!(proto && proto.nuclearProof)) {
this.tileImprovements.delete(k);
this.tileImprovementHp.delete(k);
this.tileImprovementOwner.delete(k);
this.tileRepairDebt.delete(k);
}
}
// Roads and railways do not survive a direct hit.
this.roads.delete(k);
this.railways.delete(k);
this._improvementVersion += 1;
},
};
+13 -2
View File
@@ -66,12 +66,17 @@ export function technologyEffects(proto) {
// True when every prerequisite technology is in the researched set. The set
// holds catalogue indices, so each prerequisite id is first resolved against
// the catalogue.
export function prerequisitesMet(technology, researchedSet, catalogue) {
// the catalogue. A technology may also carry a `requiresBuilding` gate -- the
// uranium enrichment centre that unlocks nuclear weapons -- checked through the
// optional `hasBuilding` predicate, so the rule stays free of any world state.
export function prerequisitesMet(technology, researchedSet, catalogue, hasBuilding = null) {
for (const id of technology.prerequisites) {
const index = catalogue ? catalogue.findIndex((t) => t.id === id) : -1;
if (index < 0 || !researchedSet.has(index)) return false;
}
if (technology.requiresBuilding && hasBuilding && !hasBuilding(technology.requiresBuilding)) {
return false;
}
return true;
}
@@ -118,6 +123,7 @@ export function productionFactors(tile, {
nationalMultiplier = 1,
portProduction = 0,
naturalGrowth = 0,
sabotaged = false,
} = {}) {
const factors = [];
const terrainMultiplier = tile ? tile.productionMultiplier || 0 : 0;
@@ -163,6 +169,11 @@ export function productionFactors(tile, {
if (battleDeficit > 0) {
factors.push({ label: "Battle damage", amount: -battleDeficit });
}
// A sabotaged tile produces nothing for a week. The zero multiplier is last,
// so it zeroes the absolute terms above (port output, natural growth) too.
if (sabotaged) {
factors.push({ label: "Sabotaged", multiplier: 0 });
}
return factors;
}
+58 -6
View File
@@ -43,7 +43,12 @@ export class AudioTest extends TestCase {
this.assertEqual(manager.effects.complete, "assets/sounds/build_complete.mp3");
this.assertEqual(manager.effects.notification, "assets/sounds/notification.mp3");
this.assertEqual(manager.effects.bomb, "assets/sounds/death_flash.flac");
this.assertEqual(manager.musicSource, "assets/sounds/crashed_ship.ogg");
this.assertEqual(manager.musicSources, [
"assets/sounds/crashed_ship.ogg",
"assets/sounds/dark_city.ogg",
"assets/sounds/constellation.ogg",
"assets/sounds/patrol.ogg",
]);
}
async test_plays_effects_and_mutes() {
@@ -108,16 +113,63 @@ export class AudioTest extends TestCase {
}
}
async test_music_loops_and_stops() {
async test_music_plays_a_shuffled_playlist() {
const env = await setupDom();
try {
FakeAudio.reset();
const manager = new AudioManager();
// random() near 1 leaves the shuffle order untouched: crashed_ship first.
const manager = new AudioManager({ random: () => 0.999 });
manager.requestMusic();
const music = FakeAudio.instances.find((audio) => audio.src === manager.musicSource);
const first = FakeAudio.instances.find(
(audio) => audio.src === "assets/sounds/crashed_ship.ogg"
);
this.assertNotNull(first, "the first track was created");
this.assertFalse(first.loop, "a playlist track does not loop itself");
this.assertEqual(first.plays, 1, "the first track was started once");
this.assertTrue(typeof first.onended === "function", "the track knows what follows");
// A finished track hands over to the next one in the playlist.
first.onended();
const second = FakeAudio.instances.find(
(audio) => audio.src === "assets/sounds/dark_city.ogg"
);
this.assertNotNull(second, "the second track follows");
this.assertEqual(second.plays, 1, "the second track was started once");
second.onended();
const third = FakeAudio.instances.find(
(audio) => audio.src === "assets/sounds/constellation.ogg"
);
this.assertNotNull(third, "the third track follows");
this.assertEqual(third.plays, 1, "the third track was started once");
third.onended();
const fourth = FakeAudio.instances.find(
(audio) => audio.src === "assets/sounds/patrol.ogg"
);
this.assertNotNull(fourth, "the fourth track follows");
this.assertEqual(fourth.plays, 1, "the fourth track was started once");
// After the last track the playlist reshuffles instead of falling silent.
const before = FakeAudio.instances.length;
fourth.onended();
this.assertGreater(FakeAudio.instances.length, before, "playback wraps around");
} finally {
teardownDom(env);
}
}
async test_music_stops_and_mutes() {
const env = await setupDom();
try {
FakeAudio.reset();
const manager = new AudioManager({ random: () => 0.999 });
manager.requestMusic();
const music = FakeAudio.instances.find(
(audio) => audio.src === "assets/sounds/crashed_ship.ogg"
);
this.assertNotNull(music, "the music element was created");
this.assertTrue(music.loop, "the music loops");
this.assertEqual(music.plays, 1, "the music was started once");
manager.stopMusic();
this.assertTrue(music.paused, "stopping pauses the music");
+13 -1
View File
@@ -103,8 +103,20 @@ export class BudgetTest extends TestCase {
this.assertSize(state.budgetHistory, BUDGET_HISTORY_MONTHS, "only two years are kept");
}
test_resource_trade_is_mirrored_into_the_month() {
test_resource_costs_are_their_own_budget_line() {
const state = smallState();
state._recordResourceSpend(0, "energy", 2_400);
state._recordResourceSpend(0, "goods", 480, "steel");
state._recordResourceSpend(0, "transport", 240);
const breakdown = state.budgetBreakdown(0);
const byId = new Map(breakdown.resourceCosts.map((entry) => [entry.id, entry]));
this.assertApprox(byId.get("energy").amount, 100, 1e-9, "the day's power bill, hourly");
this.assertApprox(byId.get("goods").amount, 20, 1e-9);
this.assertApprox(byId.get("transport").amount, 10, 1e-9);
this.assertEqual(byId.get("combat"), undefined, "an unused category is left out");
}
test_resource_trade_is_mirrored_into_the_month() { const state = smallState();
state._recordResourceSpend(0, "goods", 5_000, "steel");
state._recordResourceEarn(0, "steel", 2_000);
const report = state.budgetMonthReports(0).at(-1);
+25 -1
View File
@@ -389,6 +389,8 @@ export class CombatTest extends TestCase {
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// A gun is usually spiked when taken; force the one-in-five capture.
state._random = () => 0.9;
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
// With no friendly infantry beside it, the battery is taken instead of
@@ -397,8 +399,30 @@ export class CombatTest extends TestCase {
this.assertNull(state._combatTarget(infantry), "no melee follows the capture");
}
test_artillery_sheltered_by_infantry_still_fights() {
test_a_charging_infantry_usually_destroys_an_unaccompanied_artillery() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// The 80% case: the crew scuttles the gun rather than hand it over.
state._random = () => 0.1;
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
this.assertNull(state.findUnit(artillery.id), "the artillery is destroyed, not captured");
this.assertNull(state._combatTarget(infantry), "no melee follows");
}
test_capturing_an_airfield_usually_destroys_its_aircraft() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1 && !c.isCapital) || state.cities.find((c) => c.civ === 1);
const jet = state.protoUnits.findIndex((p) => p.id === "jet_fighter");
const aircraft = state._spawnUnit(city.coords, 1, state.protoUnits[jet], city.id);
state._random = () => 0.1;
this.assertTrue(state._captureCity(city, 0), "the city falls");
this.assertNull(state.findUnit(aircraft.id), "the aircraft is destroyed with the field");
}
test_artillery_sheltered_by_infantry_still_fights() { const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
+39 -45
View File
@@ -99,18 +99,22 @@ export class ConstructionSiteTest extends TestCase {
this.assertNotNull(site.cityId, "an allied city was found to haul to");
}
test_a_site_locks_its_prices_when_decided() {
test_a_site_does_not_freeze_a_price_when_decided() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const site = state.constructionSiteAt(coords);
const locked = site.prices.steel;
const before = state._marketUnitCost("steel");
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
this.assertApprox(site.prices.steel, locked, 1e-9, "the site keeps its locked price");
this.assertTrue(
state._marketUnitCost("steel") > locked,
"the market moved after the work was decided"
this.assertTrue(state._marketUnitCost("steel") > before, "the market moved");
// The stored figure is only a running quote; the next gather refreshes it.
state._gatherSiteMaterials(site);
this.assertApprox(
site.prices.steel,
state._marketUnitCost("steel"),
1e-6,
"the site follows the market"
);
}
@@ -175,7 +179,7 @@ export class ConstructionSiteTest extends TestCase {
this.assertEqual(site.phase, "materials", "it never reached construction");
}
test_a_stalled_budget_climbs_on_its_own() {
test_a_stalled_site_tops_up_to_the_current_market() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
@@ -187,23 +191,22 @@ export class ConstructionSiteTest extends TestCase {
stalled: true,
});
// A token of stock in the hub city keeps the material reachable, so the
// automatic rise applies; it is nowhere near enough to finish the work.
// top-up applies; it is nowhere near enough to finish the work.
state.getCityResourceStock(city).steel = 1;
const treasury = 1e9;
state.budgets.set(0, treasury);
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.01,
state._marketUnitCost("steel"),
1e-6,
"the first day's budgeted price rises 1%"
"the site prices at the live market, with no daily rise"
);
this.assertGreater(site.budget, 0, "the matching money moved into the site");
this.assertGreater(site.budget, 0, "the money needed for the rest moved into the site");
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
}
test_the_budget_rise_doubles_every_day() {
test_a_site_buys_at_the_live_market_not_a_stale_price() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
@@ -213,29 +216,35 @@ export class ConstructionSiteTest extends TestCase {
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
});
// A supplier far out of reach in price keeps the site stalling as it climbs.
// A stale, cheap price does not stop the site buying at today's market.
site.prices.steel = state._marketUnitCost("steel") * 0.001;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
const nodes = state._resourceNodes();
const suppliers = state._nearbySuppliers(city, "steel", nodes);
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
suppliers[0].stock.steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "the locked price is too low to buy at");
// The rises compound: 1%, then 2%, then 4%, ...
let prev = site.prices.steel;
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
const expected = prev * (1 + 0.01 * 2 ** day);
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
prev = site.prices.steel;
}
this.assertFalse(site.stalled, "the site did not hold out for the stale price");
this.assertEqual(site.phase, "construction", "it bought at the live market");
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
}
test_a_site_reports_the_live_market_price() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
const site = state.constructionSiteAt(coords);
const before = site.prices.steel;
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
state._gatherSiteMaterials(site);
this.assertApprox(
site.prices.steel,
state._marketUnitCost("steel"),
1e-6,
"the site tracks the market"
);
this.assertGreater(site.prices.steel, before, "and follows it up");
}
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
test_a_reachable_supplier_is_bought_from_at_any_price() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
@@ -245,8 +254,6 @@ export class ConstructionSiteTest extends TestCase {
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
});
// A supplier sits within reach, but its delivered price is far above the
// site's locked price, so it takes several days of rises to afford it.
site.prices.steel = state._marketUnitCost("steel") * 0.2;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
const nodes = state._resourceNodes();
@@ -256,20 +263,7 @@ export class ConstructionSiteTest extends TestCase {
suppliers[0].stock.steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "the locked price is too low to buy at");
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.01,
1e-6,
"the first day's budgeted price rises 1%"
);
// Keep letting the budget climb; it must eventually reach the supplier.
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
}
this.assertEqual(site.phase, "construction", "the site eventually bought at the market");
this.assertEqual(site.phase, "construction", "the site bought at the market");
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
}
+3
View File
@@ -10,6 +10,9 @@ export class CreditsTest extends TestCase {
"game-icons.net",
"opengameart.org",
"Ted Kerr",
"Muncheybobo",
"Trevor Lentz",
"Alexandr Zhelanov",
"Delapouite",
"Lorc",
"Mapbox Maki",
+3 -1
View File
@@ -88,7 +88,9 @@ export class DataTest extends TestCase {
const ids = new Set(TECHNOLOGIES.map((t) => t.id));
for (const tech of TECHNOLOGIES) {
this.assertGreater(tech.cost, 0, `${tech.id} cost`);
this.assertNotEmpty(tech.effects, `${tech.id} effects`);
// Most technologies carry a numeric effect; a few exist only to unlock
// content (the spy behind Intelligence Agencies), so they opt out.
if (!tech.unlocks) this.assertNotEmpty(tech.effects, `${tech.id} effects`);
for (const prerequisite of tech.prerequisites) {
this.assertTrue(ids.has(prerequisite), `${tech.id} prerequisite ${prerequisite} exists`);
}
+468 -16
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, SPY_OPERATIONS, TRANSPORT_IMPROVEMENTS, TREATY_PEACE } from "../shared/data.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact, groupDigits } from "../shared/text_format.js";
import { tileImprovementResourceCost } from "../shared/resources.js";
@@ -29,6 +29,37 @@ function makeConfig() {
};
}
// Switches the open Intelligence tab to one of its sub-tabs, by label.
function showIntelligenceSubtab(env, label) {
env
.$("#intelligence-subtabs .subtab")
.filter((index, element) => env.$(element).text() === label)
.trigger("click");
}
// The first populated foreign land tile in the fixture, for a spy to stand on.
function foreignPopulatedLand(state, civ = 1) {
for (const [k, owner] of state.territory) {
if (owner !== civ) continue;
const coords = parseKey(k);
if (state.cityAt(coords)) continue;
if (state.unitsAt(coords).length > 0) continue;
const tile = state.tiles[k];
if (!tile || tile.terrainClass !== "Land") continue;
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
return coords;
}
return null;
}
// The city-training cards whose action is live (a locked card's Train button is
// disabled), so counts ignore the gated units shown for reference.
function enabledTrainCards(env) {
return env.$("#city-train-list .building-tile").filter(
(index, element) => !env.$(element).find(".bt-upgrade").prop("disabled")
);
}
function landAtDistance(state, center, distance) {
for (const k in state.tiles) {
const parts = k.split(",").map(Number);
@@ -90,8 +121,8 @@ export class GameScreenTest extends TestCase {
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-train-list button"), 3, "infantry, artillery and radar can be trained");
env.$("#city-train-list button").first().click();
this.assertSize(enabledTrainCards(env), 4, "infantry, artillery, radar and missiles can be trained");
enabledTrainCards(env).first().find(".bt-upgrade").click();
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
const unit = state.units.find((u) => u.civ === 0);
@@ -245,6 +276,67 @@ export class GameScreenTest extends TestCase {
}
}
async test_spies_are_trainable_once_the_agency_is_researched() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.onState(state.snapshot(0));
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
const spyCard = () =>
env.$("#city-train-list .building-tile").filter(
(index, element) => env.$(element).find(".bt-name").first().text() === "Spy"
);
this.assertSize(spyCard(), 1, "the spy is listed even before the agency");
this.assertTrue(spyCard().find(".bt-upgrade").prop("disabled"), "but its card is locked");
this.assertTrue(
spyCard().text().includes("Requires Intelligence Agencies"),
"and names what it needs"
);
const tech = state.technologies.findIndex((t) => t.id === "intelligence_agencies");
state.researched.get(0).add(tech);
screen.onState(state.snapshot(0));
this.assertFalse(
spyCard().find(".bt-upgrade").prop("disabled"),
"the spy is trainable after the agency"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_testing_mode_offers_instant_technology_unlock() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.testing = true;
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='technology']").click();
const $unlock = env.$("#technology-list .free-button").filter(
(index, element) => env.$(element).text() === "Unlock now"
);
this.assertGreater($unlock.length, 0, "a testing game offers an instant unlock");
$unlock.first().trigger("click");
const order = network.orders.find((entry) => entry.type === "unlock_technology");
this.assertNotNull(order, "the unlock order is sent");
this.assertTrue(order.free, "flagged as the testing shortcut");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_training_modal_tracks_progress() {
const env = await setupDom();
try {
@@ -545,8 +637,8 @@ export class GameScreenTest extends TestCase {
screen.onState(state.snapshot(0));
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
const before = env.$("#city-train-list button").length;
this.assertEqual(before, 3, "only infantry, artillery and radar are trainable at first");
const before = enabledTrainCards(env).length;
this.assertEqual(before, 4, "only infantry, artillery, radar and missiles are trainable at first");
// The airport is completed server-side and a fresh snapshot arrives while
// the panel is still open.
@@ -554,7 +646,7 @@ export class GameScreenTest extends TestCase {
capital.buildings[airport] = 1;
screen.onState(state.snapshot(0));
this.assertGreater(
env.$("#city-train-list button").length,
enabledTrainCards(env).length,
before,
"the aircraft appears in the still-open panel"
);
@@ -579,7 +671,7 @@ export class GameScreenTest extends TestCase {
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-train-list .free-button"), 3, "a free button per trainable unit");
this.assertSize(env.$("#city-train-list .free-button"), 4, "a free button per trainable unit");
env.$("#city-train-list .free-button").first().click();
this.assertEqual(network.orders[0], {
type: "train",
@@ -753,6 +845,71 @@ export class GameScreenTest extends TestCase {
}
}
async test_chat_opens_with_t_and_sends_lines() {
const env = await setupDom();
try {
const network = stubNetwork();
const sent = [];
network.sendChat = (text) => sent.push(text);
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
this.assertTrue(env.$("#chat").hasClass("hidden"), "the chat starts closed");
env.$(env.window).trigger(env.$.Event("keydown", { key: "t" }));
this.assertFalse(env.$("#chat").hasClass("hidden"), "T opens the chat");
// The map must not pan while the chat input holds focus.
const cameraBefore = screen.map.camera.x;
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "d" }));
this.assertApprox(screen.map.camera.x, cameraBefore, 1e-9, "typing does not pan the map");
screen.addChat({ name: "Bob", civ: 1, text: "hello there" });
const line = env.$("#chat-log .chat-line").last();
this.assertTrue(line.find(".chat-author").text().includes("Bob"));
this.assertTrue(line.find(".chat-text").text().includes("hello there"));
env.$("#chat-input").val("good luck");
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Enter" }));
this.assertEqual(sent, ["good luck"], "the line is sent to the server");
this.assertEqual(env.$("#chat-input").val(), "", "the input clears after sending");
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Escape" }));
this.assertTrue(env.$("#chat").hasClass("hidden"), "Escape closes the chat");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_treaty_offer_becomes_a_clickable_feed_line() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
// The first snapshot only seeds the seen-news baseline.
screen.onState(state.snapshot(0));
state.requestProposeTreaty(1, 0, TREATY_PEACE);
screen.onState(state.snapshot(0));
const line = env.$("#game-feed .feed-line").last();
this.assertTrue(line.text().includes("proposes Peace"), "the offer hits the feed");
this.assertTrue(line.hasClass("feed-clickable"), "and can be clicked");
line.trigger("click");
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the offer dialog opens");
this.assertTrue(env.$("#confirm-ok").text().includes("Sign"), "with a Sign button");
env.$("#confirm-ok").trigger("click");
const order = network.orders.at(-1);
this.assertEqual(order.type, "respond_treaty");
this.assertTrue(order.accept, "Signing accepts the offer");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
@@ -1327,11 +1484,12 @@ export class GameScreenTest extends TestCase {
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
this.assertTrue(env.$("#tab-politics").hasClass("active"));
this.assertGreater(env.$("#politics-list .politics-card").length, 0);
env.$("#modal-nation .tab[data-tab='intelligence']").click();
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
showIntelligenceSubtab(env, "Propaganda");
this.assertGreater(env.$("#intelligence-propaganda-view .politics-card").length, 0);
const $start = env.$("#tab-politics button").filter(
const $start = env.$("#intelligence-propaganda-view button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($start, 1);
@@ -1342,6 +1500,7 @@ export class GameScreenTest extends TestCase {
this.assertEqual(campaign.targetKind, "government");
this.assertEqual(campaign.target, 1);
env.$("#modal-nation .tab[data-tab='politics']").click();
this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
const $new = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "New minority policy"
@@ -1365,6 +1524,263 @@ export class GameScreenTest extends TestCase {
}
}
async test_intelligence_tab_hosts_propaganda() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
showIntelligenceSubtab(env, "Propaganda");
const $launcher = env.$("#intelligence-propaganda-view button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($launcher, 1, "the propaganda launcher lives on the Intelligence tab");
const $onPolitics = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($onPolitics, 0, "and is no longer on the Politics tab");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_intelligence_subtabs_split_spies_and_propaganda() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $tabs = env.$("#intelligence-subtabs .subtab");
this.assertSize($tabs, 2, "the Intelligence tab has two sub-tabs");
this.assertEqual($tabs.eq(0).text(), "Spies");
this.assertEqual($tabs.eq(1).text(), "Propaganda");
// The spy controls open first, the campaigns stay out of the way.
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
showIntelligenceSubtab(env, "Propaganda");
this.assertTrue(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertFalse(env.$("#intelligence-propaganda-view").hasClass("hidden"));
showIntelligenceSubtab(env, "Spies");
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_intelligence_tab_lists_the_viewers_spies_and_locates_them() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $spies = env.$("#intelligence-spies-view .politics-card").filter(
(index, element) => env.$(element).find(".name").first().text() === "Your spies"
);
this.assertSize($spies, 1, "the roster card is on the Intelligence tab");
this.assertTrue($spies.text().includes("Spy 1"), "the spy is listed");
this.assertTrue($spies.text().includes(home.name), "with the city it stands in");
const $locate = $spies.find("button").filter(
(index, element) => env.$(element).text() === "Locate"
);
this.assertSize($locate, 1, "the roster offers a Locate control");
$locate.trigger("click");
this.assertTrue(screen.selectedUnitIds.includes(spy.id), "Locate selects the spy");
this.assertFalse(screen.nationModal.isShowing(), "and closes the nation panel");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_spy_actions_live_in_the_unit_panel() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
const soldier = state._spawnUnit(
home.coords,
0,
state.protoUnits.find((u) => u.id === "modern_infantry")
);
screen.onState(state.snapshot(0));
// A spy's covert operations are unit actions, so they appear in the unit
// panel once the spy is selected.
screen._selectUnits([spy.id]);
const $actions = env.$("#unit-spy-actions");
this.assertFalse($actions.hasClass("hidden"), "the spy actions row shows for a spy");
const $buttons = $actions.find("button");
this.assertSize($buttons, SPY_OPERATIONS.length, "one button per operation");
for (const operation of SPY_OPERATIONS) {
const $button = $buttons.filter(
(index, element) => env.$(element).attr("data-operation") === operation.id
);
this.assertSize($button, 1, `a button for ${operation.id}`);
this.assertTrue($button.prop("disabled"), `${operation.id} is not wired yet`);
}
// A soldier gets no spy actions.
screen._selectUnits([soldier.id]);
this.assertTrue(
env.$("#unit-spy-actions").hasClass("hidden"),
"hidden for a soldier"
);
// And the tab no longer lists the operations.
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $operations = env.$("#intelligence-spies-view .politics-card").filter(
(index, element) => env.$(element).find(".name").first().text() === "Covert operations"
);
this.assertSize($operations, 0, "the operations card is not on the Intelligence tab");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_sabotage_is_offered_on_foreign_land_and_sent() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const target = foreignPopulatedLand(state, 1);
this.assertNotNull(target, "the fixture has a populated foreign tile");
const spy = state._spawnUnit(target, 0, state.protoUnits.find((u) => u.id === "spy"));
screen.onState(state.snapshot(0));
screen._selectUnits([spy.id]);
const $sabotage = env.$("#unit-spy-actions").find("[data-operation='sabotage']");
this.assertSize($sabotage, 1, "the sabotage button exists");
this.assertFalse($sabotage.prop("disabled"), "sabotage is offered on foreign land");
$sabotage.trigger("click");
const order = network.orders.find((entry) => entry.type === "sabotage");
this.assertNotNull(order, "the sabotage order is sent");
this.assertTrue(order.units.includes(spy.id), "for the selected spy");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_city_spying_arms_targeting_and_sends() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 1);
const spy = state._spawnUnit(
{ x: city.coords.x, y: city.coords.y },
0,
state.protoUnits.find((u) => u.id === "spy")
);
screen.onState(state.snapshot(0));
screen._selectUnits([spy.id]);
const $button = env.$("#unit-spy-actions").find("[data-operation='city_spying']");
this.assertSize($button, 1);
this.assertFalse($button.prop("disabled"), "a city is in reach");
$button.trigger("click");
this.assertNotNull(screen._targeting, "picking a target is armed");
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
const order = network.orders.find((entry) => entry.type === "spy_city");
this.assertNotNull(order, "the city spy order is sent");
this.assertEqual(order.coords[0], city.coords.x);
this.assertTrue(order.units.includes(spy.id));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_launch_satellite_button_sends() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 0);
grantBuilding(state, city, "space_launch_center", 1);
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $launch = env.$("#intelligence-spies-view button").filter(
(index, element) => env.$(element).text() === "Launch spy satellite"
);
this.assertSize($launch, 1, "the launch control is on the Spies sub-tab");
this.assertFalse($launch.prop("disabled"), "a free pad is available");
$launch.trigger("click");
const order = network.orders.find((entry) => entry.type === "launch_satellite");
this.assertNotNull(order, "the launch order is sent");
this.assertEqual(order.city, city.id);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_spy_boards_a_launch_on_a_right_click() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
let land = null;
let sea = null;
for (const cell of state.landCells) {
if (state.civAt(cell) !== 0 || state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
const tile = state.tiles[`${neighbour.x},${neighbour.y}`];
if (tile && tile.terrainClass === "Sea" && !state.unitAt(neighbour)) {
land = cell;
sea = neighbour;
break;
}
}
if (land) break;
}
this.assertNotNull(land, "the nation has an empty coastal tile");
const spy = state._spawnUnit(land, 0, state.protoUnits.find((u) => u.id === "spy"));
const launch = state._spawnUnit(sea, 0, state.protoUnits.find((u) => u.id === "launch"));
screen.onState(state.snapshot(0));
await screen.map.onMoveOrdered([spy.id], { x: sea.x, y: sea.y });
const order = network.orders.find((entry) => entry.type === "embark");
this.assertNotNull(order, "the spy boards");
this.assertEqual(order.launch, launch.id);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_tab_survives_snapshot_refresh() {
const env = await setupDom();
try {
@@ -1407,7 +1823,8 @@ export class GameScreenTest extends TestCase {
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
// Inject a running campaign owned by the player.
const state = smallState();
@@ -1415,7 +1832,7 @@ export class GameScreenTest extends TestCase {
state.requestCampaign(0, 0, "government", 1, 1);
screen.onState(state.snapshot(0));
const $cancel = env.$("#tab-politics button").filter(
const $cancel = env.$("#intelligence-propaganda-view button").filter(
(index, element) => env.$(element).text() === "Cancel campaign"
);
this.assertSize($cancel, 1);
@@ -1423,7 +1840,7 @@ export class GameScreenTest extends TestCase {
this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign");
// The launcher now refuses to duplicate the running campaign.
const $start = env.$("#tab-politics button").filter(
const $start = env.$("#intelligence-propaganda-view button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertTrue($start.prop("disabled"), "duplicate campaign blocked");
@@ -1441,14 +1858,15 @@ export class GameScreenTest extends TestCase {
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
const state = smallState();
state.budgets.set(0, 1.0e15);
state.requestCampaign(0, 0, "government", 1, 1);
screen.onState(state.snapshot(0));
const $slider = env.$("#tab-politics .politics-slider");
const $slider = env.$("#intelligence-propaganda-view .politics-slider");
this.assertSize($slider, 1);
$slider.val(20000).trigger("change");
const spending = network.orders.find((order) => order.type === "campaign_spending");
@@ -2125,6 +2543,40 @@ export class GameScreenTest extends TestCase {
}
}
async test_a_missile_can_be_armed_with_a_nuclear_warhead() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const missileProto = state.protoUnits.find((p) => p.id === "missile");
const missile = state._spawnUnit(capital.coords, 0, missileProto);
const targetCoords = landAtDistance(state, capital.coords, 1);
state._spawnUnit(targetCoords, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const data = screen._unit(missile.id);
screen._selectUnits([missile.id]);
this.assertTrue(env.$("#unit-nuclear").hasClass("hidden"), "no warhead without the bomb");
// Research nuclear weapons and take a fresh snapshot.
const techIndex = state.technologies.findIndex((t) => t.id === "nuclear_weapons");
state.researched.get(0).add(techIndex);
screen.onState(state.snapshot(0));
this.assertFalse(env.$("#unit-nuclear").hasClass("hidden"), "the warhead toggle shows");
env.$("#unit-nuclear").click();
this.assertTrue(screen._nuclearPayload, "the toggle arms the next strike");
screen._sendMove([data], targetCoords, null, "attack", null);
const order = screen.network.orders.at(-1);
this.assertEqual(order.type, "attack");
this.assertTrue(order.nuclear, "the attack carries the nuclear payload");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_city_tile_panel_shows_current_production() {
const env = await setupDom();
try {
+78 -2
View File
@@ -203,6 +203,28 @@ export class GameServerTest extends TestCase {
this.assertNotNull(full.visible, "including the per-viewer visible set");
}
test_an_attack_with_a_missile_is_routed_to_the_missile_strike() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
const missile = state._spawnUnit(
capital.coords,
0,
state.protoUnits.find((proto) => proto.id === "missile")
);
const target = state.landCells.find(
(cell) => !state.unitAt(cell) && !state.cityAt(cell)
);
const enemy = state._spawnUnit(target, 1, state.protoUnits[0]);
this.assertTrue(
server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }),
"the server accepts the missile order"
);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched");
this.assertNull(state.findUnit(enemy.id), "and the target is destroyed");
}
test_move_order_for_own_unit_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
@@ -211,8 +233,7 @@ export class GameServerTest extends TestCase {
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
}
test_server_advances_movement_between_hourly_ticks() {
const { server } = makeServer();
test_server_advances_movement_between_hourly_ticks() { const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
@@ -333,6 +354,61 @@ export class GameServerTest extends TestCase {
this.assertFalse(server.handleOrder(1, { type: "train", city: enemy.id, proto: 0 }));
}
test_spy_city_order_for_own_spy_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const city = state.cities.find((c) => c.civ === 1);
const spy = state._spawnUnit(
{ x: city.coords.x, y: city.coords.y },
0,
state.protoUnits.find((u) => u.id === "spy")
);
this.assertTrue(
server.handleOrder(1, { type: "spy_city", units: [spy.id], coords: [city.coords.x, city.coords.y] })
);
this.assertSize(state.spyReports.get(0), 1, "the report reaches the spy's nation");
}
test_spy_order_for_another_nations_unit_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const enemySpy = state.units.find((u) => u.civ === 1);
this.assertFalse(
server.handleOrder(1, { type: "spy_comms", units: [enemySpy.id], coords: [enemySpy.coords.x, enemySpy.coords.y] })
);
}
test_launch_satellite_order_needs_the_centre() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const city = state.cities.find((c) => c.civ === 0);
this.assertFalse(
server.handleOrder(1, { type: "launch_satellite", city: city.id }),
"no centre, no launch"
);
grantBuilding(state, city, "space_launch_center", 1);
this.assertTrue(server.handleOrder(1, { type: "launch_satellite", city: city.id }));
this.assertSize(state.satellites, 1);
}
test_unlock_technology_is_a_testing_only_shortcut() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const tech = server.state.technologies.findIndex((t) => t.id === "nuclear_weapons");
this.assertFalse(
server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true }),
"refused outside a testing game"
);
server.testing = true;
this.assertTrue(
server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true })
);
this.assertTrue(server.state.hasTechnology(0, "nuclear_weapons"));
}
test_orders_from_unknown_peers_are_rejected() {
const { server } = makeServer();
this.assertFalse(server.handleOrder(42, { type: "move", unit: 1, coords: [0, 0] }));
+12
View File
@@ -202,6 +202,18 @@ export class GameStateTechnologyTest extends TestCase {
}
}
test_completing_a_repeatable_keeps_it_focused() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.complete(state, "infantry_weapons");
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done");
this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused");
// Fresh points keep flowing into the next level without another order.
state._addResearch(0, state.getTechnologyCost(0, index));
this.assertEqual(state.getTechnologyRepeatCount(0, index), 2, "the next level completes");
this.assertApprox(state.getTechnologyProgress(0, index), 0, 1e-9, "reset for the next level");
}
test_research_costs_a_hundred_times_the_catalogue_price() {
const state = smallState();
const index = this.index(state, "scientific_method");
+298
View File
@@ -0,0 +1,298 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, grantBuilding } from "./framework/helpers.js";
import { parseKey } from "../shared/hex.js";
import {
SABOTAGE,
SATELLITE,
SPY_REPORT_CITY,
SPY_REPORT_COMMS,
} from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
// The first populated foreign land tile in the fixture, for a spy to stand on.
function foreignLandTile(state, civ = 1) {
for (const [k, owner] of state.territory) {
if (owner !== civ) continue;
const coords = parseKey(k);
if (state.cityAt(coords)) continue;
if (state.unitsAt(coords).length > 0) continue;
const tile = state.tiles[k];
if (!tile || tile.terrainClass !== "Land") continue;
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
return coords;
}
return null;
}
function spyProto(state) {
return state.protoUnits.find((unit) => unit.id === "spy");
}
function research(state, civ, id) {
const index = state.technologies.findIndex((tech) => tech.id === id);
state.researched.get(civ).add(index);
return index;
}
// A spy watching the given city. A spy cannot legally stand on a hostile city
// tile, but the model lets it watch from that tile or a neighbour; the fixture
// simply drops it on the city so the distance is always in range.
function spyAtCity(state, city, civ = 0) {
return state._spawnUnit({ x: city.coords.x, y: city.coords.y }, civ, spyProto(state));
}
function sequence(state, values) {
let i = 0;
state._random = () => values[Math.min(i++, values.length - 1)];
}
export class IntelligenceTest extends TestCase {
// ------------------------------------------------------------ sabotage --
test_sabotage_cripples_a_foreign_tile_for_a_week() {
const state = smallState();
const coords = foreignLandTile(state, 1);
this.assertNotNull(coords, "the fixture has a populated British tile");
const spy = state._spawnUnit(coords, 0, spyProto(state));
const before = state.getTileGdpPerCapita(coords);
this.assertGreater(before, 0, "the tile produces something to cripple");
this.assertTrue(state.canSabotage([spy.id]), "a spy on foreign land may sabotage");
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
this.assertTrue(state.isTileSabotaged(coords), "the tile is crippled");
this.assertEqual(state.getTileGdpPerCapita(coords), 0, "its production is zeroed");
const news = state.news[state.news.length - 1];
this.assertEqual(news.type, "sabotage", "the sabotage is reported");
this.assertEqual(news.civ, 1, "to the nation whose tile was hit");
// A week later the block lapses and the tile's production returns.
state.totalHours += SABOTAGE.blockDays * HOURS_PER_DAY + 1;
state._tickSabotage();
this.assertFalse(state.isTileSabotaged(coords), "the week has run out");
this.assertGreater(state.getTileGdpPerCapita(coords), 0, "production is back");
}
test_sabotage_needs_a_spy_on_a_foreign_tile() {
const state = smallState();
const own = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(own.coords, 0, spyProto(state));
this.assertFalse(state.canSabotage([spy.id]), "a spy may not sabotage its own land");
const target = foreignLandTile(state, 1);
this.assertNotNull(target, "the fixture has a foreign land tile");
const soldier = state._spawnUnit(
target,
0,
state.protoUnits.find((unit) => unit.id === "modern_infantry")
);
this.assertFalse(state.canSabotage([soldier.id]), "only a spy sabotages");
this.assertFalse(state.requestSabotage([soldier.id]), "and the order is refused");
}
// ---------------------------------------------------------- blame -------
test_blame_names_the_author_when_detected() {
const state = smallState();
const coords = foreignLandTile(state, 1);
const spy = state._spawnUnit(coords, 0, spyProto(state));
sequence(state, [0]); // the identify roll succeeds
state.requestSabotage([spy.id]);
const news = state.news[state.news.length - 1];
this.assertEqual(news.suspect, 0, "the saboteur is blamed");
}
test_blame_can_land_on_a_framed_nation_or_no_one() {
const state = smallState(["france", "britain", "germany"]);
const coords = foreignLandTile(state, 1);
// Fail to identify the author, then let the false flag on Germany stick.
// An operation spends its spy, so each attempt sends a fresh one.
const first = state._spawnUnit(coords, 0, spyProto(state));
sequence(state, [0.99, 0]);
state.requestSabotage([first.id], { frame: 2 });
let news = state.news[state.news.length - 1];
this.assertEqual(news.suspect, 2, "the framed nation takes the blame");
// Fail to identify, and fail to blame the scapegoat.
const second = state._spawnUnit(coords, 0, spyProto(state));
sequence(state, [0.99, 0.99]);
state.requestSabotage([second.id], { frame: 2 });
news = state.news[state.news.length - 1];
this.assertNull(news.suspect, "no one is blamed");
}
test_counter_intelligence_makes_identification_more_likely() {
const state = smallState();
research(state, 1, "counter_intelligence");
const coords = foreignLandTile(state, 1);
const spy = state._spawnUnit(coords, 0, spyProto(state));
// 0.7 fails the base 0.5 roll but clears the counter-intelligence threshold.
sequence(state, [0.7]);
state.requestSabotage([spy.id]);
const news = state.news[state.news.length - 1];
this.assertEqual(news.suspect, 0, "the counter-intelligence service fingers the author");
}
// -------------------------------------------------------- single-use ----
test_an_operation_spends_the_acting_spies() {
const state = smallState();
const coords = foreignLandTile(state, 1);
const spy = state._spawnUnit(coords, 0, spyProto(state));
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
this.assertNull(state.findUnit(spy.id), "the saboteur is gone");
this.assertSize(state.unitsAt(coords), 0, "and leaves nothing behind");
}
test_only_the_acting_stack_is_spent() {
const state = smallState();
const coords = foreignLandTile(state, 1);
const actor = state._spawnUnit(coords, 0, spyProto(state));
const bystander = state._spawnUnit(coords, 0, spyProto(state));
this.assertTrue(state.requestSabotage([actor.id]), "the sabotage is accepted");
this.assertNull(state.findUnit(actor.id), "the saboteur is gone");
this.assertNotNull(state.findUnit(bystander.id), "the unselected spy stays");
}
test_a_bigger_team_escapes_the_blame() {
const state = smallState();
research(state, 1, "counter_intelligence");
const coords = foreignLandTile(state, 1);
const spies = [0, 1, 2].map(() => state._spawnUnit(coords, 0, spyProto(state)));
// A lone spy has a 0.75 chance of being fingered, so 0.7 would expose the
// author; three spies cut it to 0.3, so the same roll gets away.
sequence(state, [0.7]);
state.requestSabotage(spies.map((spy) => spy.id));
const news = state.news[state.news.length - 1];
this.assertNull(news.suspect, "the team gets away with it");
}
test_extra_spies_blunt_counter_intelligence() {
const state = smallState();
research(state, 1, "counter_intelligence");
this.assertEqual(state._effectiveCounterIntelligence(1, 1), 1, "one spy faces the full service");
this.assertEqual(state._effectiveCounterIntelligence(1, 2), 0.5, "a second spy halves it");
this.assertEqual(state._effectiveCounterIntelligence(1, 3), 0, "a third cancels it");
const city = state.cities.find((entry) => entry.civ === 1);
const spies = [0, 1, 2].map(() => spyAtCity(state, city));
// A roll of 0.5 falsifies a lone spy's report (0.5 < 0.7) but not a
// three-strong team's, whose falsify chance is zero.
sequence(state, [0.5]);
state.requestCitySpy(spies.map((spy) => spy.id), city.coords);
const report = state.spyReports.get(0)[0];
this.assertEqual(report.population, city.population, "the report is honest");
for (const spy of spies) this.assertNull(state.findUnit(spy.id), "every spy is spent");
}
// ------------------------------------------------------ city spying -----
test_city_spying_returns_an_accurate_report() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 1);
const spy = spyAtCity(state, city);
this.assertNotNull(spy, "the fixture has a tile beside the British city");
this.assertTrue(state.canCitySpy([spy.id], city.coords), "the spy may watch the city");
sequence(state, [0]); // no counter-intelligence: the report is honest
this.assertTrue(state.requestCitySpy([spy.id], city.coords));
const reports = state.spyReports.get(0);
this.assertSize(reports, 1);
this.assertEqual(reports[0].kind, SPY_REPORT_CITY);
this.assertEqual(reports[0].city, city.name);
this.assertEqual(reports[0].population, city.population, "the population is exact");
this.assertSize(state.snapshot(0).spyReports, 1, "the report reaches its owner");
this.assertSize(state.snapshot(1).spyReports, 0, "and no one else");
}
test_counter_intelligence_falsifies_a_city_report() {
const state = smallState();
research(state, 1, "counter_intelligence");
const city = state.cities.find((entry) => entry.civ === 1);
const spy = spyAtCity(state, city);
sequence(state, [0]); // the falsify roll succeeds, then values shrink
state.requestCitySpy([spy.id], city.coords);
const report = state.spyReports.get(0)[0];
this.assertLess(report.population, city.population, "the population is distorted");
}
// ----------------------------------------------------- comms spying -----
test_comms_spying_intercepts_messages() {
const state = smallState();
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
const spy = spyAtCity(state, capital);
this.assertTrue(state.canCommsSpy([spy.id], capital.coords), "the spy may tap the capital");
state.recordChat(1, 0, "meet me at dawn");
state.recordChat(0, 1, "agreed");
sequence(state, [0.99]); // no counter-intelligence: the messages are clean
this.assertTrue(state.requestCommsSpy([spy.id], capital.coords));
const report = state.spyReports.get(0)[0];
this.assertEqual(report.kind, SPY_REPORT_COMMS);
this.assertSize(report.messages, 2, "both recent messages are intercepted");
this.assertEqual(report.messages[1].text, "agreed");
}
test_counter_intelligence_garbles_intercepted_comms() {
const state = smallState();
research(state, 1, "counter_intelligence");
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
const spy = spyAtCity(state, capital);
state.recordChat(1, 0, "attack at dawn");
sequence(state, [0]); // garble fires, then every character is scrambled
state.requestCommsSpy([spy.id], capital.coords);
const report = state.spyReports.get(0)[0];
this.assertTrue(report.messages[0].garbled, "the message is marked garbled");
this.assertTrue(report.messages[0].text.includes("▒"), "and its text is ruined");
}
// ------------------------------------------------------- satellites -----
test_a_satellite_reveals_a_chunk_every_two_days() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 0);
grantBuilding(state, city, "space_launch_center", 1);
this.assertTrue(state.canLaunchSatellite(0), "a centre gives a launch pad");
this.assertTrue(state.requestSatellite(0, city), "the launch is accepted");
this.assertEqual(state.satellites.length, 1);
this.assertFalse(state.canLaunchSatellite(0), "one centre sustains one satellite");
const satellite = state.satellites[0];
this.assertEqual(satellite.civ, 0);
this.assertEqual(
satellite.nextRevealHour,
SATELLITE.revealEveryDays * HOURS_PER_DAY,
"the first sweep is two days out"
);
const news = state.news[state.news.length - 1];
this.assertEqual(news.type, "satellite", "everyone is told of the launch");
// Wipe the viewer's explored set so the reveal is measurable, then run the
// clock to the sweep.
const explored = state.explored.get(0);
explored.clear();
state._random = () => 0;
state.totalHours = satellite.nextRevealHour;
state._tickSatellites();
this.assertGreater(explored.size, 0, "the sweep uncovers tiles");
this.assertGreater(state.satellites[0].nextRevealHour, state.totalHours, "and schedules the next");
}
// --------------------------------------------------------- secrets ------
test_enrichment_can_leak_to_foreign_intelligence() {
const state = smallState();
state.news.length = 0;
sequence(state, [0]);
state._warnOfEnrichment(1);
this.assertEqual(state.news.length, 1, "the warning is published");
this.assertEqual(state.news[0].type, "enrichment");
this.assertEqual(state.news[0].civ, 1, "naming the enriching nation");
sequence(state, [0.99]);
state._warnOfEnrichment(1);
this.assertEqual(state.news.length, 1, "and sometimes it stays secret");
}
}
+68
View File
@@ -1,6 +1,20 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { POLICY_EXPEL, MIGRATION } from "../shared/data/politics.js";
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
// neighbour `to` is owned by another civilisation.
function borderPair(state, fromCiv, toCiv) {
for (const coords of state._territoryByCiv.get(fromCiv) || []) {
for (const neighbour of state._neighbours(coords)) {
if (state.territory.get(key(neighbour.x, neighbour.y)) === toCiv) {
return { from: coords, to: neighbour };
}
}
}
return null;
}
export class MigrationTest extends TestCase {
test_food_output_comes_from_the_land_not_the_headcount() {
@@ -145,4 +159,58 @@ export class MigrationTest extends TestCase {
const state = smallState();
this.assertEmpty(state.snapshot(0).migrationGraph.links, "the opening graph is empty");
}
test_no_migration_into_a_country_you_are_at_war_with() {
const state = smallState();
const border = borderPair(state, 0, 1);
this.assertNotNull(border, "the two nations share a border");
const fromK = key(border.from.x, border.from.y);
const toK = key(border.to.x, border.to.y);
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
state.tilePopulation.set(fromK, 1_000_000);
state.tilePopulation.set(toK, 1_000_000);
// Peacetime: the people cross the border.
this.assertGreater(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "migration in peace");
// War: the same move is refused.
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertEqual(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "no crossing into a hostile land");
}
test_expelling_a_people_keeps_them_from_moving_in() {
const state = smallState();
const border = borderPair(state, 0, 1);
this.assertNotNull(border, "the two nations share a border");
const fromK = key(border.from.x, border.from.y);
const toK = key(border.to.x, border.to.y);
state._setTileEthnicFractions(border.from, new Map([[1, 1]]));
state._setTileEthnicFractions(border.to, new Map([[1, 1]]));
state.tilePopulation.set(fromK, 1_000_000);
state.tilePopulation.set(toK, 1_000_000);
// Enact the expel policy directly: the request guard would refuse a people
// that is this large a share of the expelling nation.
state.policies.get(1).push({ id: 999, type: POLICY_EXPEL, ethnicity: 1 });
this.assertTrue(state._expelsEthnicity(1, 1), "nation 1 expels people 1");
this.assertEqual(state._transferEthnicity(fromK, toK, 1, 0.5), 0, "the expelled people stay out");
}
test_a_tile_cannot_lose_more_than_its_daily_migration_share() {
const state = smallState();
const cities = state.cities.filter((city) => city.civ === 0);
this.assertGreaterOrEqual(cities.length, 2, "two regions to move between");
const from = cities[0];
const to = cities[1];
for (const coords of state.regionTiles(from)) {
state.tilePopulation.set(key(coords.x, coords.y), 1_000_000);
}
// Open the daily budget the migration tick runs under.
state._migrationOutDay = new Map();
const before = state.regionPopulation(from);
const moved = state._movePopulation(from, to, 5_000_000, false);
const cap = before * MIGRATION.maxDailyOutShare;
this.assertTrue(moved <= cap + 1, `moved ${moved} within the ${cap} cap`);
state._migrationOutDay = null;
}
}
+102 -1
View File
@@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
import { buildingEffectText, describeNews } from "../client/js/modals/format.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost, TREATY_PEACE, TREATY_ALLIANCE, TREATY_GIVE_MONEY } from "../shared/data.js";
import { groupDigits } from "../shared/text_format.js";
import { setCurrency, setCurrencyBook } from "../client/js/currency.js";
import { formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../shared/resources.js";
@@ -845,6 +845,107 @@ export class NationModalTest extends TestCase {
}
}
async test_diplomacy_tab_names_each_nations_controller() {
const env = await setupDom();
try {
const modal = new NationModal();
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
const stats = [
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
];
const players = {
1: { id: 1, name: "Alice", admin: true, civ: 0 },
2: { id: 2, name: "Bob", admin: false, civ: 1 },
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, {
civs, localCiv: 0, relations: [], stats, players,
});
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
const byName = (name) =>
env.$("#diplomacy-list tbody tr").filter(
(_, el) => env.$(el).find(".name").text() === name
).first();
const britain = byName("Britain");
this.assertTrue(
britain.find("td.control img").attr("src").includes("icon_human"),
"a player-run nation shows the human icon"
);
this.assertEqual(britain.find("td.leader").text(), "Bob", "the leader's name shows");
const poland = byName("Poland");
this.assertTrue(
poland.find("td.control img").attr("src").includes("icon_computer"),
"an unclaimed nation shows the computer icon"
);
this.assertEqual(poland.find("td.leader").text(), "—", "it has no leader name");
modal.close();
} finally {
teardownDom(env);
}
}
async test_diplomacy_tab_proposes_answers_and_breaks_treaties() {
const env = await setupDom();
try {
const modal = new NationModal();
const civs = [{ name: "France" }, { name: "Britain" }];
const stats = [
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
];
const info = {
civs, localCiv: 0, relations: [], stats, players: {},
treaties: [{
id: 3, type: TREATY_ALLIANCE, a: 0, b: 1, payload: {},
signedHours: 0, signedDate: "Jan 2000",
}],
treatyProposals: [{
id: 7, type: TREATY_PEACE, from: 1, to: 0, payload: {},
proposedHours: 0, proposedDate: "Jan 2000",
}],
treatyHistory: [],
localCities: [{ id: 5, name: "Paris" }],
localUnits: [],
unitProtos: PROTO_UNITS,
resources: null,
};
const proposals = [];
const breaks = [];
modal.onProposeTreaty = (type, to, payload) => proposals.push([type, to, payload]);
modal.onRespondTreaty = (id, accept) => proposals.push([id, accept]);
modal.onCancelTreaty = (id) => breaks.push(id);
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, info);
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
// The pending offer addressed to us carries Sign and Reject.
const pending = env.$("#treaty-pending .treaty-row").first();
this.assertTrue(pending.text().includes("Britain proposes Peace"));
pending.find("button").first().trigger("click");
this.assertEqual(proposals.at(-1), [7, true], "Sign answers the offer");
// The active alliance offers a Break button.
const active = env.$("#treaty-active .treaty-row").first();
this.assertTrue(active.text().includes("Alliance"));
active.find("button").trigger("click");
this.assertEqual(breaks, [3], "the alliance can be broken");
// The compose row offers every type and sends the chosen one.
env.$("#treaty-type").val(TREATY_GIVE_MONEY).trigger("change");
env.$("#treaty-payload .treaty-money").val("5000");
env.$("#treaty-propose").trigger("click");
this.assertEqual(
proposals.at(-1),
[TREATY_GIVE_MONEY, 1, { amount: 5000 }],
"the money offer carries its amount"
);
modal.close();
} finally {
teardownDom(env);
}
}
async test_diplomacy_shows_a_foreign_figure_in_its_own_currency_with_ours_beside_it() {
const env = await setupDom();
try {
+70
View File
@@ -317,4 +317,74 @@ export class ServerProtocolTest extends TestCase {
guest.close();
});
}
async test_treaty_orders_flow_through_the_server() {
await withServer(async (port, server) => {
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
await host.open;
host.send({ t: "login", name: "Host", password: "pw" });
await host.next("assigned");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
await host.next("game_state");
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
await guest.open;
guest.send({ t: "login", name: "Carol", password: "pw" });
await guest.next("assigned");
await guest.next("choose_civ");
guest.send({ t: "claim_civ", civ: 1 });
await guest.next("game_state");
host.send({ t: "order", order: { type: "propose_treaty", to: 1, treaty: "peace" } });
await new Promise((resolve) => setTimeout(resolve, 20));
server.step(0.05);
const state = server.gameServer.state;
this.assertSize(state.treatyProposals, 1, "the offer reached the model");
const proposal = state.treatyProposals[0];
this.assertEqual(proposal.from, 0);
this.assertEqual(proposal.to, 1);
guest.send({
t: "order",
order: { type: "respond_treaty", proposal: proposal.id, accept: true },
});
await new Promise((resolve) => setTimeout(resolve, 20));
server.step(0.05);
this.assertSize(state.treaties, 1, "accepting signs the treaty");
this.assertEqual(state.treaties[0].type, "peace");
host.close();
guest.close();
});
}
async test_chat_relays_to_every_signed_in_player() {
await withServer(async (port) => {
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
await host.open;
host.send({ t: "login", name: "Host", password: "pw" });
await host.next("assigned");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
await host.next("game_state");
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
await guest.open;
guest.send({ t: "login", name: "Carol", password: "pw" });
await guest.next("assigned");
await guest.next("choose_civ");
guest.send({ t: "claim_civ", civ: 1 });
await guest.next("game_state");
host.send({ t: "chat", text: "hello world" });
const received = await guest.next("chat");
this.assertEqual(received.name, "Host", "the sender is named");
this.assertEqual(received.civ, 0, "and their nation travels too");
this.assertEqual(received.text, "hello world", "the line arrives intact");
host.close();
guest.close();
});
}
}
+83
View File
@@ -0,0 +1,83 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import {
PROTO_UNITS,
TRAINABLE_UNIT_IDS,
SPY_OPERATIONS,
SPY_SABOTAGE,
SPY_CITY,
SPY_COMMS,
technologyById,
} from "../shared/data.js";
import { key, parseKey } from "../shared/hex.js";
export class SpyTest extends TestCase {
test_the_spy_is_a_gated_trainable_unit() {
const proto = PROTO_UNITS.find((u) => u.id === "spy");
this.assertNotNull(proto, "the spy exists");
this.assertTrue(proto.spy, "it is marked a spy");
this.assertFalse(proto.military, "it does not fight");
this.assertEqual(proto.vision, 1, "it watches a single tile out");
this.assertEqual(proto.requiresTechnology, "intelligence_agencies", "it needs the agency");
this.assertTrue(TRAINABLE_UNIT_IDS.includes("spy"), "it can be trained");
this.assertTrue(proto.icon.endsWith(".svg"), "it carries an icon");
this.assertNotNull(technologyById("intelligence_agencies"), "the agency technology exists");
this.assertNotNull(technologyById("counter_intelligence"), "the counter-intelligence technology exists");
}
test_the_covert_operations_are_well_formed() {
this.assertNotEmpty(SPY_OPERATIONS);
const ids = new Set();
for (const operation of SPY_OPERATIONS) {
this.assertTrue(operation.id.length > 0, "every operation has an id");
this.assertFalse(ids.has(operation.id), `the id ${operation.id} is unique`);
ids.add(operation.id);
this.assertTrue(operation.name.length > 0, `name of ${operation.id}`);
this.assertTrue(operation.description.length > 0, `description of ${operation.id}`);
}
this.assertTrue(ids.has(SPY_SABOTAGE), "sabotage");
this.assertTrue(ids.has(SPY_CITY), "city spying");
this.assertTrue(ids.has(SPY_COMMS), "comms spying");
}
test_an_enemy_spy_is_hidden_from_every_other_nation() {
const state = smallState();
const home = state.cities.find((city) => city.civ === 1);
const spy = state._spawnUnit(home.coords, 1, state.protoUnits.find((u) => u.id === "spy"));
const seenByEnemy = (state.snapshot(0).units || []).map((unit) => unit.id);
this.assertFalse(seenByEnemy.includes(spy.id), "the enemy never sees the spy");
const seenByOwner = (state.snapshot(1).units || []).map((unit) => unit.id);
this.assertTrue(seenByOwner.includes(spy.id), "its own nation always sees it");
}
test_a_spy_slips_through_foreign_territory() {
const state = smallState();
const home = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
const infantry = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "modern_infantry"));
// A quiet, unowned British land tile, explored by the spy's nation.
let foreign = null;
for (const [k, civ] of state.territory) {
if (civ !== 1) continue;
const coords = parseKey(k);
if (state._cityByCoords.has(k)) continue;
if (state.unitsAt(coords).length > 0) continue;
const tile = state.tiles[k];
if (!tile || tile.terrainClass !== "Land") continue;
foreign = coords;
break;
}
this.assertNotNull(foreign, "the fixture has a British land tile");
state.explored.get(0).add(key(foreign.x, foreign.y));
this.assertTrue(
state._canUnitEnter(spy, foreign, false),
"a spy may cross foreign land on the way through"
);
this.assertFalse(
state._canUnitEnter(infantry, foreign, false),
"a soldier still treats foreign land as a wall"
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, seaTile } from "./framework/helpers.js";
import { PROTO_UNITS, TRAINABLE_UNIT_IDS } from "../shared/data.js";
export class SubmarineTest extends TestCase {
test_the_submarine_is_a_trainable_naval_unit() {
const proto = PROTO_UNITS.find((u) => u.id === "submarine");
this.assertNotNull(proto, "the submarine exists");
this.assertTrue(proto.naval, "it is a naval unit");
this.assertTrue(proto.submarine, "and is marked a submarine");
this.assertTrue(TRAINABLE_UNIT_IDS.includes("submarine"), "it can be trained");
this.assertTrue(proto.icon && proto.icon.length > 0, "it carries an icon");
}
test_an_enemy_submarine_is_hidden_until_a_naval_unit_is_beside_it() {
const state = smallState();
const spot = seaTile(state);
this.assertNotNull(spot, "the fixture has open sea");
const sub = state._spawnUnit(spot, 1, this.subProto(state));
// No French naval unit anywhere near: the submarine is invisible.
let ids = (state.snapshot(0).units || []).map((unit) => unit.id);
this.assertFalse(ids.includes(sub.id), "the submarine is hidden");
// A submarine not at war is still hidden: the vision rule is not about war.
ids = (state.snapshot(1).units || []).map((unit) => unit.id);
this.assertTrue(ids.includes(sub.id), "its own nation always sees it");
}
test_a_naval_unit_on_or_beside_the_submarine_reveals_it() {
const state = smallState();
const spot = seaTile(state);
const sub = state._spawnUnit(spot, 1, this.subProto(state));
const battleship = state.protoUnits.find((u) => u.id === "battleship");
// A French battleship on the same tile sees it.
state._spawnUnit(spot, 0, battleship);
let ids = (state.snapshot(0).units || []).map((unit) => unit.id);
this.assertTrue(ids.includes(sub.id), "a naval unit on the tile reveals it");
}
test_a_distant_naval_unit_does_not_reveal_it() {
const state = smallState();
const spot = seaTile(state);
const sub = state._spawnUnit(spot, 1, this.subProto(state));
const battleship = state.protoUnits.find((u) => u.id === "battleship");
// Find a sea tile two steps away, so it is out of the direct ring.
let distant = null;
for (const k in state.tiles) {
const parts = k.split(",").map(Number);
const coords = { x: parts[0], y: parts[1] };
if (state.tiles[k].terrainClass !== "Sea") continue;
if (state.topology.tileDistance(coords, spot) === 2) {
distant = coords;
break;
}
}
this.assertNotNull(distant, "a sea tile two steps away");
state._spawnUnit(distant, 0, battleship);
const ids = (state.snapshot(0).units || []).map((unit) => unit.id);
this.assertFalse(ids.includes(sub.id), "a unit two tiles away does not reveal it");
}
subProto(state) {
return state.protoUnits.find((u) => u.id === "submarine");
}
}
+222
View File
@@ -0,0 +1,222 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
TREATY_PEACE,
TREATY_ALLIANCE,
TREATY_FREE_TRADE,
TREATY_GIVE_CITY,
TREATY_GIVE_MILITARY,
TREATY_GIVE_RESOURCES,
TREATY_GIVE_MONEY,
TREATY_JOINT_RESEARCH,
TREATY_SIGNED,
TREATY_REJECTED,
} from "../shared/data/treaties.js";
import { NEWS_TREATY } from "../shared/data/relations.js";
// Signs a treaty directly between two nations, the way a UI flow would.
function sign(state, from, to, type, payload = {}) {
const before = state.treatyProposals.length;
if (!state.requestProposeTreaty(from, to, type, payload)) return false;
const proposal = state.treatyProposals[before];
return state.requestRespondTreaty(to, proposal.id, true);
}
export class TreatyTest extends TestCase {
test_a_proposal_waits_for_the_other_side_and_makes_news() {
const state = smallState();
this.assertTrue(state.requestProposeTreaty(0, 1, TREATY_PEACE));
this.assertSize(state.treatyProposals, 1, "the offer waits for an answer");
this.assertEmpty(state.treaties, "nothing is signed yet");
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
this.assertEqual(notice.stage, "proposed");
// The same offer is not repeated while it is pending.
this.assertFalse(state.requestProposeTreaty(0, 1, TREATY_PEACE));
}
test_only_the_named_receiver_may_answer_and_rejection_is_recorded() {
const state = smallState(["france", "britain", "poland"]);
state.requestProposeTreaty(0, 1, TREATY_PEACE);
const proposal = state.treatyProposals[0];
this.assertFalse(state.requestRespondTreaty(2, proposal.id, true), "a third party cannot sign");
this.assertFalse(state.requestRespondTreaty(0, proposal.id, true), "nor the proposer");
this.assertTrue(state.requestRespondTreaty(1, proposal.id, false), "the receiver rejects");
this.assertEmpty(state.treatyProposals);
this.assertEmpty(state.treaties);
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
this.assertEqual(notice.stage, TREATY_REJECTED);
}
test_signing_records_an_active_treaty_and_a_signed_notice() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
this.assertTrue(state.areAllied(0, 1));
this.assertTrue(state.hasTreaty(1, 0, TREATY_ALLIANCE), "symmetric lookup");
this.assertEqual(state.treaties[0].a, 0);
this.assertEqual(state.treaties[0].b, 1);
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
this.assertEqual(notice.stage, TREATY_SIGNED);
}
test_peace_ends_the_war_and_sends_troops_home() {
const state = smallState();
this.assertTrue(state.requestDeclareWar(0, 1));
// Put an invader deep on the enemy's soil.
const enemyTile = state._territoryByCiv.get(1)[0];
const unit = state.units.find((u) => u.civ === 0);
state._moveUnitTo(unit, { x: enemyTile.x, y: enemyTile.y });
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
this.assertFalse(state.isAtWar(0, 1), "the war is over");
const city = cityOf(state, 0);
this.assertEqual(unit.coords.x, city.coords.x, "the invader is back at a home city");
this.assertEqual(unit.coords.y, city.coords.y);
}
test_an_alliance_drags_allies_into_the_war() {
const state = smallState(["france", "britain", "poland"]);
this.assertTrue(sign(state, 1, 2, TREATY_ALLIANCE), "Britain and Poland ally");
this.assertTrue(state.requestDeclareWar(0, 1), "France attacks Britain");
this.assertTrue(state.isAtWar(0, 1), "the target is at war");
this.assertTrue(state.isAtWar(0, 2), "Poland is drawn in");
this.assertTrue(state.areAllied(1, 2), "the defenders' alliance holds");
}
test_going_to_war_with_an_ally_breaks_the_alliance() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
this.assertTrue(state.areAllied(0, 1));
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertFalse(state.areAllied(0, 1), "the alliance cannot survive the war");
this.assertTrue(state.isAtWar(0, 1));
}
test_renewed_war_breaks_a_peace_treaty() {
const state = smallState();
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
this.assertFalse(state.isAtWar(0, 1), "the peace holds");
this.assertTrue(state.hasTreaty(0, 1, TREATY_PEACE));
this.assertTrue(state.requestDeclareWar(0, 1), "going back to war");
this.assertFalse(state.hasTreaty(0, 1, TREATY_PEACE), "the peace treaty is broken");
this.assertTrue(state.isAtWar(0, 1));
}
test_free_trade_partners_are_exempt_from_tariffs() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_FREE_TRADE));
this.assertTrue(state.freeTradePartners(0).has(1));
this.assertTrue(state._tariffExempt(0, 1));
this.assertTrue(state._tariffExempt(1, 0));
this.assertFalse(state._tariffExempt(0, 2));
}
test_giving_money_moves_the_sum_between_treasuries() {
const state = smallState();
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 400_000 }));
this.assertApprox(state.getBudget(0), 600_000, 1e-6);
this.assertApprox(state.getBudget(1), 400_000, 1e-6);
this.assertFalse(
state.requestProposeTreaty(0, 1, TREATY_GIVE_MONEY, { amount: 0 }),
"a zero gift is refused"
);
}
test_giving_military_transfers_units_but_never_infantry() {
const state = smallState();
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
this.assertNotNull(infantry, "the fixture has an infantry unit");
this.assertFalse(
state.requestProposeTreaty(0, 1, TREATY_GIVE_MILITARY, { units: [infantry.id] }),
"infantry cannot be gifted"
);
const mobile = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
// The fixture standard army is all infantry, so raise a gun to gift.
if (!mobile) {
const artillery = state.protoUnits.findIndex((p) => p.id === "artillery");
state._spawnUnit(cityOf(state, 0).coords, 0, state.protoUnits[artillery]);
}
const gift = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
this.assertNotNull(gift, "a non-infantry unit is available");
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MILITARY, { units: [gift.id] }));
this.assertEqual(state.findUnit(gift.id).civ, 1, "the unit changed hands");
}
test_giving_a_city_transfers_it_and_its_land() {
const state = smallState();
const city = cityOf(state, 0);
const cityKey = key(city.coords.x, city.coords.y);
this.assertFalse(
state.requestProposeTreaty(0, 1, TREATY_GIVE_CITY, { city: 99999 }),
"an unknown city is refused"
);
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_CITY, { city: city.id }));
this.assertEqual(state.findCity(city.id).civ, 1, "the city changed hands");
this.assertEqual(state.territory.get(cityKey), 1, "its tile went with it");
}
test_giving_resources_moves_the_basket_to_the_receiver() {
const state = smallState();
const giver = cityOf(state, 0);
const receiver = cityOf(state, 1);
const giverStore = state.getCityResourceStock(giver);
giverStore.steel = 1000;
giverStore.food = 500;
const receiverStore = state.getCityResourceStock(receiver);
const receiverSteel = receiverStore.steel || 0;
const receiverFood = receiverStore.food || 0;
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_RESOURCES, {
resources: { steel: 300, food: 200 },
}));
this.assertApprox(giverStore.steel, 700, 1e-6, "the steel left the giver");
this.assertApprox(giverStore.food, 300, 1e-6, "and so did the food");
this.assertApprox(receiverStore.steel, receiverSteel + 300, 1e-6, "the receiver got the steel");
this.assertApprox(receiverStore.food, receiverFood + 200, 1e-6, "and the food");
}
test_joint_research_shares_points_but_not_military_ones() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH));
const civil = state.technologies.findIndex((t) => t.id === "scientific_method");
const military = state.technologies.findIndex((t) => t.id === "body_armour");
state.focusTechnology.set(0, civil);
state.focusTechnology.set(1, civil);
state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(1, civil), 5, 1e-6, "the partner shares the points");
// A military technology is not shared.
state.focusTechnology.set(0, military);
state.focusTechnology.set(1, civil);
const before = state.getTechnologyProgress(1, civil);
state._addResearch(0, 7);
this.assertApprox(
state.getTechnologyProgress(1, civil),
before,
1e-6,
"military research stays private"
);
}
test_ending_a_treaty_moves_it_to_the_history() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
const treaty = state.treaties[0];
this.assertTrue(state.requestCancelTreaty(1, treaty.id), "either party may end it");
this.assertEmpty(state.treaties);
this.assertSize(state.treatyHistory, 1);
this.assertEqual(state.treatyHistory[0].reason, "broken");
this.assertFalse(state.areAllied(0, 1));
}
test_the_snapshot_ships_treaties_proposals_and_history() {
const state = smallState();
state.requestProposeTreaty(0, 1, TREATY_PEACE);
const snapshot = state.snapshot(0);
this.assertSize(snapshot.treatyProposals, 1);
this.assertEqual(snapshot.treatyProposals[0].from, 0);
this.assertEqual(snapshot.treatyProposals[0].type, TREATY_PEACE);
this.assertEmpty(snapshot.treaties);
this.assertEmpty(snapshot.treatyHistory);
}
}
+31
View File
@@ -82,6 +82,8 @@ export class CaptureTest extends TestCase {
const pair = emptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// A gun is usually spiked when taken; force the one-in-five capture.
state._random = () => 0.9;
this.assertTrue(state.requestMove(infantry.id, pair.a));
state.advanceMovement(1000);
this.assertEqual(artillery.civ, 1, "the battery changed hands");
@@ -123,6 +125,9 @@ export class CaptureTest extends TestCase {
state.protoUnits.find((u) => u.id === "jet_fighter"),
city.id
);
// Aircraft are usually destroyed with the field; force the one-in-five
// chance that the plane is taken intact.
state._random = () => 0.9;
state._captureCity(city, 0);
this.assertEqual(jet.civ, 0, "the aircraft is captured with the city");
this.assertEqual(jet.homeCityId, city.id);
@@ -242,6 +247,32 @@ export class TransportTest extends TestCase {
this.assertEqual(launch.cargo.length, 1, "the infantry is cargo");
}
test_a_spy_can_board_a_launch_and_go_ashore() {
const state = smallState();
const spot = this.coastalSetup(state);
this.assertNotNull(spot, "the nation has a coastal tile");
const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch"));
const spyProto = state.protoUnits.find((u) => u.id === "spy");
const spy = state._spawnUnit(spot.land, 0, spyProto);
this.assertTrue(state.requestEmbark([spy.id], launch.id), "a spy may board");
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertNull(state.findUnit(spy.id), "the spy left the map");
this.assertEqual(launch.cargo.length, 1, "the spy is cargo");
this.assertTrue(state.requestDisembark([launch.id], spot.land), "the spy goes ashore");
this.assertTrue(
state.unitsAt(spot.land).some((unit) => unit.proto === state.protoUnits.indexOf(spyProto)),
"the spy stands on the shore"
);
}
test_a_worker_stays_ashore() {
const state = smallState();
const spot = this.coastalSetup(state);
const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch"));
const worker = state._spawnUnit(spot.land, 0, state.protoUnits.find((u) => u.id === "worker"));
this.assertFalse(state.requestEmbark([worker.id], launch.id), "only infantry and spies board");
}
test_a_loaded_launch_disembarks_its_stack() {
const state = smallState();
const spot = this.coastalSetup(state);
+208
View File
@@ -0,0 +1,208 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, defaultState, grantBuilding } from "./framework/helpers.js";
import { key, parseKey } from "../shared/hex.js";
const BIG_BUDGET = 1.0e15;
function protoOf(state, id) {
return state.protoUnits.find((unit) => unit.id === id);
}
function protoIndex(state, id) {
return state.protoUnits.findIndex((unit) => unit.id === id);
}
function techIndex(state, id) {
return state.technologies.findIndex((tech) => tech.id === id);
}
// Grants a nation a technology directly, the way a long research project would.
function research(state, civ, id) {
const index = techIndex(state, id);
if (!state.researched.has(civ)) state.researched.set(civ, new Set());
state.researched.get(civ).add(index);
}
function freeLandNear(state, center, maxDistance) {
let best = null;
let bestDistance = Infinity;
for (const k in state.tiles) {
const coords = parseKey(k);
if (state.tiles[k].terrainClass !== "Land") continue;
if (state.cityAt(coords) || state.unitAt(coords)) continue;
const distance = state.topology.tileDistance(center, coords);
if (distance <= 0 || distance > maxDistance) continue;
if (distance < bestDistance) {
bestDistance = distance;
best = coords;
}
}
return best;
}
function farLand(state, from, minDistance) {
for (const k in state.tiles) {
const coords = parseKey(k);
if (state.tiles[k].terrainClass !== "Land") continue;
if (state.cityAt(coords) || state.unitAt(coords)) continue;
if (state.topology.tileDistance(from, coords) <= minDistance) continue;
return coords;
}
return null;
}
function freeLand(state) {
for (const cell of state.landCells) {
if (state.cityAt(cell) || state.unitAt(cell)) continue;
return cell;
}
return null;
}
export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState();
const index = techIndex(state, "nuclear_weapons");
research(state, 0, "scientific_method");
this.assertFalse(
state.canResearchTechnology(0, index),
"the bomb is locked without a uranium enrichment centre"
);
const city = state.cities.find((entry) => entry.civ === 0);
grantBuilding(state, city, "uranium_enrichment_center");
this.assertTrue(
state.canResearchTechnology(0, index),
"the centre unlocks the research"
);
}
test_a_missile_is_one_use() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
const targetCoords = freeLandNear(state, capital.coords, 29);
this.assertNotNull(targetCoords, "a free tile within missile range exists");
const enemy = state._spawnUnit(targetCoords, 1, protoOf(state, "modern_infantry"));
this.assertTrue(
state.requestMissileStrike([missile.id], targetCoords),
"the strike is accepted"
);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed");
this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast");
}
test_a_theatre_missile_cannot_reach_anywhere_but_an_icbm_can() {
const state = defaultState();
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
const icbm = state._spawnUnit(capital.coords, 0, protoOf(state, "icbm"));
const far = farLand(state, capital.coords, 30);
if (!far) return; // the default map is wide enough, but never assume it.
const enemy = state._spawnUnit(far, 1, protoOf(state, "modern_infantry"));
this.assertFalse(
state.requestMissileStrike([missile.id], far),
"a theatre missile stops at 30 tiles"
);
this.assertNotNull(state.findUnit(missile.id), "the refused rocket is not lost");
// An ICBM is nuclear by design, so grant the bomb and launch it.
research(state, 0, "nuclear_weapons");
this.assertTrue(
state.requestMissileStrike([icbm.id], far),
"an ICBM reaches any tile"
);
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed");
this.assertNull(state.findUnit(enemy.id), "the target is destroyed");
}
test_a_nuclear_payload_needs_the_bomb() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
const targetCoords = freeLandNear(state, capital.coords, 29);
const enemy = state._spawnUnit(targetCoords, 1, protoOf(state, "modern_infantry"));
this.assertFalse(
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
"a nation without the bomb cannot arm a nuclear warhead"
);
this.assertNotNull(state.findUnit(missile.id), "the missile is still there");
this.assertNotNull(state.findUnit(enemy.id), "and no one died");
research(state, 0, "nuclear_weapons");
this.assertTrue(
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
"with the bomb the strike goes ahead"
);
this.assertNull(state.findUnit(missile.id), "the missile was fired");
this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out");
}
test_the_icbm_needs_the_bomb_and_the_space_program() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
const icbm = protoIndex(state, "icbm");
this.assertFalse(state.requestTrain(capital.id, icbm), "an ICBM needs the bomb first");
research(state, 0, "nuclear_weapons");
this.assertFalse(
state.requestTrain(capital.id, icbm),
"the bomb alone does not build a rocket that reaches orbit"
);
research(state, 0, "space_program");
this.assertTrue(state.requestTrain(capital.id, icbm), "both unlock the ICBM");
}
test_a_nuclear_blast_destroys_the_tile() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 0);
const k = key(city.coords.x, city.coords.y);
state.tilePopulation.set(k, 10_000);
grantBuilding(state, city, "barracks");
grantBuilding(state, city, "shelters");
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
const shelters = state.protoBuildings.findIndex((b) => b.id === "shelters");
state._applyNuclearBlast(city.coords, 0);
this.assertApprox(state.tilePopulation.get(k), 1_000, 1, "nine in ten died");
this.assertEqual(city.buildings[barracks], undefined, "the barracks is flattened");
this.assertEqual(city.buildings[shelters], 1, "the bunker survives");
}
test_a_nuclear_blast_spares_only_bunkers_and_fortifications() {
const state = smallState();
const fort = freeLand(state);
const mill = freeLandNear(state, fort, 5);
this.assertNotNull(fort, "a free tile for the fort");
this.assertNotNull(mill, "a free tile for the mill");
const fortKey = key(fort.x, fort.y);
const millKey = key(mill.x, mill.y);
state.tileImprovements.set(fortKey, "fortifications");
state.tileImprovements.set(millKey, "steel_mill");
state.roads.add(millKey);
state._applyNuclearBlast(fort, 0);
state._applyNuclearBlast(mill, 0);
this.assertEqual(state.tileImprovements.get(fortKey), "fortifications", "the fort stands");
this.assertEqual(state.tileImprovements.get(millKey), undefined, "the mill is gone");
this.assertFalse(state.roads.has(millKey), "and its road with it");
}
test_a_nuclear_blast_costs_every_nation_standing() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 0);
const before = state.civilisations.map((_, civ) => state.getCivPopularity(civ));
state._applyNuclearBlast(city.coords, 0);
for (let civ = 0; civ < state.civilisations.length; civ++) {
this.assertLess(
state.getCivPopularity(civ),
before[civ],
`nation ${civ} loses standing`
);
}
}
}