Made training gather materials, fixed the trade graph, and banked money gifts

Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.

The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.

Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.

Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
This commit is contained in:
2026-09-24 21:20:47 +02:00
parent afb23df669
commit 49613f2a1c
24 changed files with 407 additions and 1667 deletions
-197
View File
@@ -1,197 +0,0 @@
# 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).
-10
View File
@@ -6,18 +6,8 @@ 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
@@ -1,195 +0,0 @@
# 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).
-3
View File
@@ -1,3 +0,0 @@
* In some circumstances, it seems likes aircraft cannot bomb within their own range, as per an error message reported by a user.
* War crime (pillaging) seems to have a permanent effect on popularity that I just cannot get rid off. War crime popularity debuff should be instant and only once per occurence.
* Double airplanes range
+8 -11
View File
@@ -40,15 +40,10 @@ Pure JavaScript, no build step, no runtime dependencies.
## 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`.
> Real art for the placeholder "dummy icon" SVGs.
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.
The roadmap names a specific set of files, but most of those have since been
drawn; **re-derive the live list** with the command below before starting.
---
@@ -66,9 +61,12 @@ As of this writing that lists (subject to change):
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.
- `icon_space_launch_center.svg` — the spy-satellite building.
`icon_spy.svg` and the roadside `icon_radar.svg`, `icon_coastal_cannon.svg`,
`icon_chip_foundry.svg`, `icon_wind_turbines.svg` have already been drawn.
The dummy template (all of them share it):
@@ -169,5 +167,4 @@ The dummy template (all of them share it):
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md` (repo root).
`SIMPLIFICATIONS.md` (repo root).
-602
View File
@@ -1,602 +0,0 @@
# 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
@@ -1,163 +0,0 @@
# 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).
+6 -23
View File
@@ -99,23 +99,7 @@ uses a nominal two-tile road delivery.*
- 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
## 6. Missiles / WMD
**`[~]`** *Missiles* and *WMDs* are marked `[~]` while their sub-items are
largely `[x]`.
@@ -125,15 +109,14 @@ largely `[x]`.
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.
- What is unfinished: further refinement of blast falloff / interception (the
"foreign intelligence may be warned that you are enriching uranium" follow-up
is now implemented).
- Files: `shared/game_state/wmd.js`, `shared/data/units.js`,
`shared/data/combat.js`, `client/js/game_screen/panels.js`.
---
## 8. Related design docs
## 7. Related design docs
`DESIGN.md` (index), `INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`,
`ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md`.
`DESIGN.md` (index), `ICONS.md`.
-246
View File
@@ -1,246 +0,0 @@
# 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).
## 11. Decisions taken (implemented)
- **Split (§4): shift-click detach.** A plain left-click still takes the whole
stack; shift + left-click on a map unit (or a panel card, which already toggled)
adds or removes just that unit from the moving group. Right-click moves the
ticked subset at its own slowest-member pace; unticked cards stay in the panel
so a mis-click is recoverable. No persistent sub-stack entity was introduced.
`client/js/map_view/input.js` (`_onLeftClick`) forwards the modifier and
`client/js/game_screen.js` (`map.onUnitToggled`) edits `selectedUnitIds`
in place.
- **Territory (§5): neutral third parties are the only wall.**
`GameState._canTraverseTerritory(civ, owner, targetCiv)` allows a nation's own
land, the land of the nation a route is aimed at, an ally's, and a nation
already at war; `_canUnitEnter` uses it and `_orderMove` no longer declares war
on an ally or an at-war nation it crosses. "Allied" means an **alliance
treaty** specifically (`areAllied`). The browser mirrors this in
`map_view/motion.js` `_canTraverseTerritory`, with the alliance/war pairs read
from the snapshot in `map_view/entities.js` `_applyRelations`.
- **One route per stack (§5.3): deliberately not changed.** Mixed stacks (land
and naval, say) have different traversable terrain, so a single shared path
would be wrong; the existing per-type route cache already collapses identical
searches, and every member is pinned to the slowest speed. "Behave as a single
unit" is therefore the shared traversal policy plus formation speed, not a
literal one-path-fits-all.
-171
View File
@@ -1,171 +0,0 @@
# 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).
+1 -5
View File
@@ -1,5 +1 @@
<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>
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><circle cx="256" cy="256" r="254" fill="#9b9b9b" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></circle><g class="" style="" transform="translate(0,0)"><path d="M218 19c-1 0-2.76.52-5.502 3.107-2.742 2.589-6.006 7.021-9.191 12.76-6.37 11.478-12.527 28.033-17.666 45.653-4.33 14.844-7.91 30.457-10.616 44.601 54.351 24.019 107.599 24.019 161.95 0-2.706-14.144-6.286-29.757-10.616-44.601-5.139-17.62-11.295-34.175-17.666-45.653-3.185-5.739-6.45-10.171-9.191-12.76C296.76 19.52 295 19 294 19c-6.5 0-9.092 1.375-10.822 2.85-1.73 1.474-3.02 3.81-4.358 7.34-1.338 3.53-2.397 8.024-5.55 12.783C270.116 46.73 263.367 51 256 51c-7.433 0-14.24-4.195-17.455-8.988-3.214-4.794-4.26-9.335-5.576-12.881-1.316-3.546-2.575-5.867-4.254-7.315C227.035 20.37 224.5 19 218 19zm-46.111 124.334c-1.41 9.278-2.296 17.16-2.57 22.602 6.61 5.087 17.736 10.007 31.742 13.302C217.18 183.031 236.6 185 256 185s38.82-1.969 54.94-5.762c14.005-3.295 25.13-8.215 31.742-13.302-.275-5.443-1.161-13.324-2.57-22.602-55.757 23.332-112.467 23.332-168.223 0zM151.945 155.1c-19.206 3.36-36.706 7.385-51.918 11.63-19.879 5.548-35.905 11.489-46.545 16.57-5.32 2.542-9.312 4.915-11.494 6.57-.37.28-.247.306-.445.546.333.677.82 1.456 1.73 2.479 1.973 2.216 5.564 4.992 10.627 7.744 10.127 5.504 25.944 10.958 45.725 15.506C139.187 225.24 194.703 231 256 231s116.813-5.76 156.375-14.855c19.78-4.548 35.598-10.002 45.725-15.506 5.063-2.752 8.653-5.528 10.627-7.744.91-1.023 1.397-1.802 1.73-2.479-.198-.24-.075-.266-.445-.547-2.182-1.654-6.174-4.027-11.494-6.568-10.64-5.082-26.666-11.023-46.545-16.57-15.212-4.246-32.712-8.272-51.918-11.631.608 5.787.945 10.866.945 14.9v3.729l-2.637 2.634c-10.121 10.122-25.422 16.191-43.302 20.399C297.18 200.969 276.6 203 256 203s-41.18-2.031-59.06-6.238c-17.881-4.208-33.182-10.277-43.303-20.399L151 173.73V170c0-4.034.337-9.113.945-14.9zm1.094 88.205C154.558 308.17 200.64 359 256 359s101.442-50.83 102.96-115.695a748.452 748.452 0 0 1-19.284 2.013c-1.33 5.252-6.884 25.248-15.676 30.682-13.61 8.412-34.006 7.756-48 0-7.986-4.426-14.865-19.196-18.064-27.012-.648.002-1.287.012-1.936.012-.65 0-1.288-.01-1.936-.012-3.2 7.816-10.078 22.586-18.064 27.012-13.994 7.756-34.39 8.412-48 0-8.792-5.434-14.346-25.43-15.676-30.682a748.452 748.452 0 0 1-19.285-2.013zM137.4 267.209c-47.432 13.23-77.243 32.253-113.546 61.082 42.575 4.442 67.486 21.318 101.265 48.719l16.928 13.732-21.686 2.211c-13.663 1.393-28.446 8.622-39.3 17.3-5.925 4.738-10.178 10.06-12.957 14.356 44.68 5.864 73.463 10.086 98.011 20.147 18.603 7.624 34.81 18.89 53.737 35.781l5.304-23.576c-1.838-9.734-4.134-19.884-6.879-30.3-5.12-7.23-9.698-14.866-13.136-22.007C201.612 397.326 199 391 199 384c0-3.283.936-6.396 2.428-9.133a480.414 480.414 0 0 0-6.942-16.863c-29.083-19.498-50.217-52.359-57.086-90.795zm237.2 0c-6.87 38.436-28.003 71.297-57.086 90.795a480.521 480.521 0 0 0-6.942 16.861c1.493 2.737 2.428 5.851 2.428 9.135 0 7-2.612 13.326-6.14 20.654-3.44 7.142-8.019 14.78-13.14 22.01-2.778 10.547-5.099 20.82-6.949 30.666l5.14 23.42c19.03-17.01 35.293-28.338 53.974-35.994 24.548-10.06 53.33-14.283 98.011-20.147-2.78-4.297-7.032-9.618-12.957-14.355-10.854-8.679-25.637-15.908-39.3-17.3l-21.686-2.212 16.928-13.732c33.779-27.4 58.69-44.277 101.265-48.719-36.303-28.829-66.114-47.851-113.546-61.082zM256 377c-8 0-19.592.098-28.234 1.826-4.321.864-7.8 2.222-9.393 3.324-1.592 1.103-1.373.85-1.373 1.85s1.388 6.674 4.36 12.846c2.971 6.172 7.247 13.32 11.964 19.924 4.717 6.604 9.925 12.699 14.465 16.806 4.075 3.687 7.842 5.121 8.211 5.377.37-.256 4.136-1.69 8.21-5.377 4.54-4.107 9.749-10.202 14.466-16.806 4.717-6.605 8.993-13.752 11.965-19.924C293.612 390.674 295 385 295 384s.22-.747-1.373-1.85c-1.593-1.102-5.072-2.46-9.393-3.324C275.592 377.098 264 377 256 377zm0 61.953c-.042.03-.051.047 0 .047s.042-.018 0-.047zm-11.648 14.701L235.047 495h41.56l-9.058-41.285C264.162 455.71 260.449 457 256 457c-4.492 0-8.235-1.316-11.648-3.346z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 4.0 KiB

+9
View File
@@ -1936,6 +1936,15 @@ input:focus, select:focus { border-color: var(--accent); }
.training-queue .queue-empty { padding: 4px 0; }
.training-queue .queue-row { padding: 8px 12px; }
.training-queue .queue-row .actions .aero { min-width: 110px; }
.training-queue .queue-row.expandable { cursor: pointer; }
.training-queue .queue-row.expandable:hover { background: rgba(255, 255, 255, 0.09); }
.training-queue .queue-row.expanded { border-color: rgba(127, 196, 255, 0.4); }
.queue-detail {
padding: 2px 14px 10px;
margin: -4px 0 8px;
border-left: 2px solid rgba(127, 196, 255, 0.35);
}
.queue-detail .gather-spinner { margin-top: 6px; }
/* The city and tile panels keep a fixed size whatever tab is open, so switching
between train, buildings, people and budget never resizes the card. They match
+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": "afb23df",
"date": "2026-09-24",
"subject": "Cut the migration sweep's repeated lookups and population churn",
"body": ""
},
{
"hash": "a219cf6",
"date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Rolled the commodity index over ninety days and gave the market an equilibrium",
"body": "The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put."
},
{
"hash": "7dbbe86",
"date": "2026-09-23",
"subject": "Gave the tax boxes a stepper and kept edits from the snapshot",
"body": ""
}
];
+6 -5
View File
@@ -1646,7 +1646,7 @@ export const panelMethods = {
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "Building" : "Unit");
if (building && entry.phase === "materials") {
if (entry.phase === "materials") {
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
$el.append(
@@ -2298,6 +2298,7 @@ export const panelMethods = {
coastal: !!city.coastal,
testing: !!this.testing,
trainable: this._trainableUnits(city),
unitProtos: this.protoUnits,
buildings: BUILDINGS,
levels: this._cityBuildings.get(city.id) || {},
queue: this._trainingQueue(city.id),
@@ -2382,9 +2383,9 @@ export const panelMethods = {
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "building" : "unit");
// A building order spends its first days buying its steel and high-tech, so
// show that gathering rather than a construction clock that has not started.
if (building && entry.phase === "materials") {
// An order -- building or unit -- spends its first days buying its steel and
// high-tech, so show that gathering rather than a clock that has not started.
if (entry.phase === "materials") {
this._trainingCityId = this.selectedCityId;
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
return;
@@ -2398,7 +2399,7 @@ export const panelMethods = {
}
},
// How far a building order has got through gathering its material bill, as the
// How far an order has got through gathering its material bill, as the
// average of each storable material's share bought. 1 when nothing is needed.
_gatherFraction(entry) {
let sum = 0;
+111 -14
View File
@@ -3,7 +3,7 @@
// city's production queue, so the train tab shows both kinds of order.
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS, RESOURCE_RULES } from "../../../shared/data.js";
import { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js";
import {
buildingBuildCost,
@@ -59,6 +59,13 @@ export class CityModal {
this.prices = {};
this._levels = {};
this.protoNames = new Map();
// Unit protos by server index, so a queued unit's material bill and
// population draw can be read in the queue detail.
this.unitProtos = {};
// The queue row whose detail is expanded, keyed by its run so a snapshot
// rebuild keeps it open, and the queue last drawn so a click can redraw.
this._expandedQueue = null;
this._lastQueue = null;
// Signature of the trainable set currently drawn, so a snapshot that did
// not change it leaves the list (and its scroll) alone.
this._trainableSignature = null;
@@ -100,6 +107,10 @@ export class CityModal {
this.constructionSpeed = view.constructionSpeed || 0;
this.testing = !!view.testing;
this.prices = view.prices || {};
this.unitProtos = view.unitProtos || {};
// A different city's queue keys have different meanings, so collapse any
// open detail when the panel is reopened.
this._expandedQueue = null;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
this.renderQueue(view.queue || []);
@@ -469,11 +480,15 @@ export class CityModal {
// Draws the shared production queue: one row per run of contiguous identical
// entries, with the cumulated hours until each run is ready and a button to
// cancel its last entry. Cancelling refunds nothing.
// cancel its last entry. Cancelling refunds nothing. Clicking an order opens
// its detail -- the steel and high-tech it is gathering, its reserved budget
// and, for a unit, the soldiers it will draw from the region.
renderQueue(queue) {
if (!this.$trainQueue || !this.$trainQueue.length) return;
this._lastQueue = queue || null;
this.$trainQueue.empty();
if (!queue || queue.length === 0) {
this._expandedQueue = null;
this.$trainQueue.append(
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
);
@@ -481,29 +496,31 @@ export class CityModal {
}
let cumulative = 0;
groupQueue(queue).forEach((group, groupIndex) => {
const first = group.indices[0];
const entry = queue[first];
// An order still gathering its materials has not started building or
// training, so it adds no time to the queue's readiness estimate.
const gather = entry.phase === "materials";
let firstReady = 0;
for (const index of group.indices) {
const entry = queue[index];
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
// A building still gathering its materials has not started building, so
// it adds no time to the queue's readiness estimate.
const gather = entry.kind === "building" && entry.phase === "materials";
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
if (index === group.indices[0]) firstReady = cumulative;
const at = queue[index];
const elapsed = index === 0 ? at.elapsedHours || 0 : 0;
cumulative += gather ? 0 : Math.max(at.totalHours - elapsed, 0);
if (index === first) firstReady = cumulative;
}
const count = group.indices.length;
const gathering = queue[group.indices[0]].kind === "building" &&
queue[group.indices[0]].phase === "materials";
const name = group.kind === "building"
? this.buildingNames.get(group.protoIndex) || "Building"
: this.protoNames.get(group.protoIndex) || "Unit";
const label = count > 1 ? `${name} ×${count}` : name;
const $row = $("<div class='row-card queue-row'></div>");
const expanded = this._isExpanded(group, first);
const $row = $("<div class='row-card queue-row expandable'></div>");
if (expanded) $row.addClass("expanded");
const $info = $("<div class='info'></div>");
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
$info.append(
$("<div class='desc'></div>").text(
gathering
gather
? "Gathering materials"
: count > 1
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
@@ -512,13 +529,93 @@ export class CityModal {
);
const $actions = $("<div class='actions'></div>");
const $cancel = $("<button class='aero'></button>").text(count > 1 ? "Cancel last" : "Cancel");
$cancel.on("click", () => this.onCancel(this.cityId, group.indices[group.indices.length - 1]));
$cancel.on("click", (event) => {
event.stopPropagation();
this.onCancel(this.cityId, group.indices[group.indices.length - 1]);
});
$actions.append($cancel);
$row.append($info, $actions);
$row.on("click", () => this._toggleQueueDetail(group, first));
this.$trainQueue.append($row);
if (expanded) this.$trainQueue.append(this._queueDetail(entry, name));
});
}
// Whether the run starting at `first` is the one whose detail is open.
_isExpanded(group, first) {
const open = this._expandedQueue;
return !!open && open.kind === group.kind &&
open.protoIndex === group.protoIndex && open.first === first;
}
// Opens or closes a queue row's detail, then redraws from the last queue.
_toggleQueueDetail(group, first) {
if (this._isExpanded(group, first)) this._expandedQueue = null;
else this._expandedQueue = { kind: group.kind, protoIndex: group.protoIndex, first };
this.renderQueue(this._lastQueue);
}
// What an order is using while it waits: its gathered materials with a bar
// each, the budget reserved for them and, for a unit, the soldiers the region
// will give up.
_queueDetail(entry, name) {
const $detail = $("<div class='queue-detail'></div>");
if (entry.phase === "materials") {
$detail.append(
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`)
);
for (const id of ["steel", "hightech"]) {
const needed = (entry.needed && entry.needed[id]) || 0;
if (!(needed > 0)) continue;
const bought = Math.min((entry.bought && entry.bought[id]) || 0, needed);
const pct = Math.max(0, Math.min(100, (bought / needed) * 100));
const $bar = $("<div class='progress city-production-bar site-gather'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`));
$detail.append(
$("<div class='site-material-label'></div>").text(
`${resourceById(id).name} — ${formatResourceAmount(id, bought)} / ${formatResourceAmount(id, needed)}`
),
$bar
);
}
if (entry.budget > 0) {
$detail.append(
$("<div class='site-material-label'></div>").text(`Budget ${formatMoney(entry.budget)}`)
);
}
const soldiers = this._soldiersFor(entry);
if (soldiers > 0) {
$detail.append(
$("<div class='site-material-label'></div>").text(
`${groupDigits(soldiers)} soldiers will be drawn from the region`
)
);
}
$detail.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
} else {
const verb = entry.kind === "building" ? "Building" : "Training";
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${Math.max(0, Math.min(100, (elapsed / total) * 100))}%`));
$detail.append(
$("<div class='city-production-label'></div>").text(`${verb} ${name} — ${remaining} h left`),
$bar
);
}
return $detail;
}
// The people a unit order will draw when it completes, matching the model's
// own default for a type that does not state one.
_soldiersFor(entry) {
if (entry.kind !== "unit") return 0;
const proto = this.unitProtos && this.unitProtos[entry.protoIndex];
const population = proto && proto.population;
return population || RESOURCE_RULES.unitPopulation;
}
beginTraining(name, totalHours, elapsedHours = 0, verb = "Training") {
this.$trainLabel.text(`${verb} ${name}…`);
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
+7 -4
View File
@@ -1071,11 +1071,11 @@ export const economyMethods = {
completed.push(cityId);
continue;
}
// A queued building first gathers its materials, once a day, exactly as a
// tile improvement's construction site does; only then does its
// construction time begin.
// A queued order -- building or unit -- first gathers its materials, once
// a day, exactly as a tile improvement's construction site does; only then
// does its construction or training time begin.
for (const entry of queue) {
if (entry.kind !== "building" || entry.phase !== "materials") continue;
if (entry.phase !== "materials") continue;
if (entry.lastGatherDay === day) continue;
entry.lastGatherDay = day;
this._gatherBuildEntry(city, entry);
@@ -1143,6 +1143,9 @@ export const economyMethods = {
overflow = budget - remaining;
if (kind === "building") this._completeBuilding(city, entry);
else {
// Hand back whatever material budget the unit did not spend, then
// place it.
this._releaseEntryBudget(city.civ, entry);
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
}
+21 -4
View File
@@ -49,22 +49,39 @@ export const orderMethods = {
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
if (!this._spendBudget(city.civ, proto.cost, "training")) return false;
// The unit's steel and high-tech are gathered over the days before its
// training clock starts, exactly as a building's are: the order reserves a
// material budget and buys against it a little at a time.
const materials = this.constructionResourceCost(proto, 0, proto.cost);
if (!this._payConstructionResources(city.civ, city.coords, materials, "training", proto.name)) {
this._refundBudget(city.civ, proto.cost, "training");
return false;
}
const needed = {
steel: Math.max(0, materials.steel || 0),
hightech: Math.max(0, materials.hightech || 0),
};
const reserved = needed.steel * this.getResourcePrice("steel") +
needed.hightech * this.getResourcePrice("hightech");
this._reserveSiteBudget(city.civ, reserved, "training");
const entry = {
kind: "unit",
protoIndex,
capacity,
phase: "materials",
budget: reserved,
budgetCategory: "training",
prices: this._lockedUnitPrices(),
needed,
bought: { steel: 0, hightech: 0 },
elapsedHours: 0,
totalHours: (proto.cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
lastGatherDay: -1,
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
// Make the first purchase at once, so a well-supplied city starts training
// right away instead of waiting for the first day boundary.
entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY);
this._gatherBuildEntry(city, entry);
this._emitChanged();
return true;
},
+6 -2
View File
@@ -1705,8 +1705,12 @@ export const resourceMethods = {
}
// The people eat, wear and spend. Shortfalls are matched against reachable
// stores -- home regions first, then foreign ones.
this._consumeCityResources(components, market, nodes);
// stores -- home regions first, then foreign ones. The storage nodes are
// re-read now that the day's production has filled the producers' stores:
// the list built before production is missing every building whose store it
// created this day, and the trade graph caches under a stamp that already
// reflects the larger set, so the new works would stay hidden from buyers.
this._consumeCityResources(components, market, this._resourceNodes());
// The army eats too, and its rations are drawn from the same stores after
// the prices are set. Count the appetite now, so the market's food price
+1 -1
View File
@@ -408,7 +408,7 @@ export const siteMethods = {
_releaseEntryBudget(civ, entry) {
if (!(entry.budget > 0)) return;
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
this._recordBudgetCash(civ, "construction", entry.budget);
this._recordBudgetCash(civ, entry.budgetCategory || "construction", entry.budget);
entry.budget = 0;
},
+49 -4
View File
@@ -246,7 +246,15 @@ export const treatyMethods = {
if (type === TREATY_GIVE_MONEY) {
const amount = Number(data.amount || 0);
if (!(amount > 0)) return null;
return { amount };
const payload = { amount };
// The gift is denominated in any nation's currency; it defaults to the
// receiver's own. An unknown code is refused.
if (data.currency) {
const code = String(data.currency);
if (this._civByCurrencyCode().get(code) === undefined) return null;
payload.currency = code;
}
return payload;
}
// Peace, alliance, free trade and joint research carry no payload.
return {};
@@ -271,9 +279,7 @@ export const treatyMethods = {
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);
this._payTreatyMoney(treaty.a, treaty.b, treaty.payload.amount, treaty.payload.currency);
break;
}
default:
@@ -363,6 +369,45 @@ export const treatyMethods = {
}
},
// Settles a money gift. The amount is denominated in a currency (the
// receiver's by default): the giver's central bank spends its foreign
// reserves in that currency first, then buys the rest from the issuing bank,
// which keeps the giver's currency as a reserve and issues its own against
// the matching debt, exactly as an import does. A nation cannot buy from a
// bank it is at war with, so a blocked gift settles only as far as the
// reserves already reach. The receiver is credited the gift's market value in
// its own currency, and the giver's treasury carries it at the same value.
// Returns the amount actually delivered, in the denomination.
_payTreatyMoney(from, to, amount, code = null) {
const denom = code || this.currencyOf(to).code;
const issuer = this._civByCurrencyCode().get(denom);
const vDenom = this.currencyValueOf(denom);
const vFrom = this.currencyValue(from);
const vTo = this.currencyValue(to);
const reserves = this.centralBankReserves ? this.centralBankReserves.get(from) : null;
const held = (reserves && reserves.get(denom)) || 0;
// Buying the denomination needs a reachable issuing bank.
const ownMoney = issuer === from;
const reachable = issuer === undefined || ownMoney || !this.isAtWar(from, issuer);
const sold = ownMoney ? 0 : Math.min(held, amount);
const bought = reachable ? amount - sold : 0;
const paid = sold + bought;
if (!(paid > 0)) return 0;
if (sold > 0) reserves.set(denom, held - sold);
if (bought > 0 && issuer !== undefined && !ownMoney) {
const cost = (bought * vDenom) / vFrom;
this._addReserve(issuer, this.currencyOf(from).code, cost);
this._addCentralBankDebt(issuer, denom, bought);
// Paying with our own money is what the issuing bank holds as a reserve.
this._recordCurrencyDemand(from, -cost);
this._recordCurrencyDemand(issuer, bought * vDenom);
}
const value = paid * vDenom;
this.budgets.set(from, this.getBudget(from) - value / vFrom);
this.budgets.set(to, this.getBudget(to) + value / vTo);
return paid;
},
// 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
+51
View File
@@ -563,4 +563,55 @@ export class ConstructionSiteTest extends TestCase {
this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier");
this.assertEqual("topUp" in entry, false, "no manual top-up is shipped any more");
}
test_a_unit_order_gathers_its_materials_before_training() {
const state = smallState();
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
city.buildings[barracks] = 1;
state.budgets.set(0, BIG_BUDGET);
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length;
// Nothing to buy from anywhere, so the order can only gather, never finish.
clearMaterials(state);
this.assertTrue(state.requestTrain(city.id, infantry), "the order is accepted");
const entry = state.training.get(city.id)[0];
this.assertEqual(entry.kind, "unit");
this.assertEqual(entry.phase, "materials", "a unit starts by gathering, not training");
this.assertGreater(entry.needed.steel, 0, "it names the steel it must gather");
this.assertEqual(entry.bought.steel, 0, "no steel could be bought");
this.assertGreater(entry.budget, 0, "a material budget is reserved");
// A day passes and it is still gathering, with no new unit to show for it.
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertEqual(state.training.get(city.id)[0].phase, "materials", "still gathering");
this.assertEqual(
state.units.filter((u) => u.civ === 0 && u.proto === infantry).length,
before,
"no unit was trained while the materials are missing"
);
}
test_a_supplied_unit_order_gathers_then_trains() {
const state = smallState();
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
city.buildings[barracks] = 1;
state.budgets.set(0, BIG_BUDGET);
for (const other of state.cities) {
if (other.civ !== 0) continue;
const stock = state.getCityResourceStock(other);
stock.steel = 1e9;
stock.hightech = 1e6;
}
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length;
this.assertTrue(state.requestTrain(city.id, infantry));
for (let i = 0; i < 200000 && state.training.size > 0; i++) state.advanceHour();
this.assertFalse(state.training.has(city.id), "the order finished");
this.assertEqual(
state.units.filter((u) => u.civ === 0 && u.proto === infantry).length,
before + 1,
"the unit was trained once its materials were in"
);
}
}
+38 -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, TREATY_PEACE, TREATY_ALLIANCE, TREATY_GIVE_MONEY } from "../shared/data.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, RESOURCE_RULES, 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";
@@ -47,6 +47,7 @@ export class CityModalTest extends TestCase {
budget: 1.0e9,
gdp: 1.0e9,
trainable: [{ ...PROTO_UNITS[0], serverIndex: 0 }, { ...PROTO_UNITS[2], serverIndex: 2 }],
unitProtos: PROTO_UNITS,
buildings: BUILDINGS,
levels: {},
queue,
@@ -158,6 +159,42 @@ export class CityModalTest extends TestCase {
}
}
async test_clicking_a_gathering_order_shows_its_materials() {
const env = await setupDom();
try {
const modal = new CityModal();
const queue = [
{
kind: "unit",
protoIndex: 0,
phase: "materials",
elapsedHours: 0,
totalHours: 10,
needed: { steel: 100, hightech: 4 },
bought: { steel: 25, hightech: 0 },
budget: 123456,
},
];
this.showTrain(modal, queue);
const row = env.$("#city-train-queue .queue-row").first();
this.assertTrue(row.text().includes("Gathering materials"), "the queue says it is gathering");
this.assertSize(env.$("#city-train-queue .queue-detail"), 0, "the detail is closed to start");
row.click();
this.assertSize(env.$("#city-train-queue .queue-detail"), 1, "clicking a queued unit opens its detail");
this.assertTrue(env.$("#city-train-queue .queue-detail").text().includes("Steel"), "the detail names the steel it gathers");
this.assertTrue(env.$("#city-train-queue .queue-detail").text().includes("High-tech"), "and the high-tech");
this.assertTrue(
env.$("#city-train-queue .queue-detail").text().includes(groupDigits(RESOURCE_RULES.unitPopulation)),
"and the soldiers the region will give up"
);
// The row is rebuilt on toggle, so click the new node to close it.
env.$("#city-train-queue .queue-row").first().click();
this.assertSize(env.$("#city-train-queue .queue-detail"), 0, "clicking again closes it");
} finally {
teardownDom(env);
}
}
async test_buildings_tab_prices_rows_and_requests_build_and_demolish() {
const env = await setupDom();
try {
+70
View File
@@ -138,6 +138,76 @@ export class TreatyTest extends TestCase {
);
}
test_a_gift_is_converted_into_the_receivers_currency_at_the_market_rate() {
const state = smallState();
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
// The receiver's currency is worth half, so its money is twice as dear.
state.setCurrencyValue(1, 0.5);
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 400_000 }));
// 400,000 of the receiver's currency is 200,000 of global value: the giver
// pays that in its own (par) money and the receiver is credited it back as
// 400,000 of its dearer currency.
this.assertApprox(state.getBudget(0), 800_000, 1e-6);
this.assertApprox(state.getBudget(1), 400_000, 1e-6);
}
test_a_gift_spends_the_banks_reserves_before_buying_the_currency() {
const state = smallState();
const code = state.currencyOf(1).code;
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
state._addReserve(0, code, 300);
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000 }));
this.assertApprox(
state.getCentralBankReserves(0).get(code), 0, 1e-6,
"the bank's reserves are spent first"
);
this.assertApprox(state.getBudget(1), 1_000, 1e-6, "the receiver gets the full gift");
this.assertApprox(state.getBudget(0), 999_000, 1e-6, "the giver pays the full value");
}
test_a_gift_may_be_denominated_in_a_third_currency() {
const state = smallState(["france", "britain", "poland"]);
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
const code = state.currencyOf(2).code;
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 500_000, currency: code }));
this.assertApprox(state.getBudget(1), 500_000, 1e-6);
this.assertFalse(
state.requestProposeTreaty(0, 1, TREATY_GIVE_MONEY, { amount: 1, currency: "ZZZ" }),
"an unknown currency is refused"
);
}
test_a_war_with_the_issuer_blocks_buying_and_limits_the_gift() {
const state = smallState(["france", "britain", "poland"]);
const code = state.currencyOf(2).code;
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
state.requestDeclareWar(0, 2);
// At war with the issuing bank and holding none of its money, nothing can be
// bought, so the gift moves nothing at all.
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000, currency: code }));
this.assertApprox(state.getBudget(0), 1_000_000, 1e-6, "the giver paid nothing");
this.assertApprox(state.getBudget(1), 0, 1e-6, "the receiver got nothing");
}
test_a_blocked_gift_still_moves_the_reserves_already_held() {
const state = smallState(["france", "britain", "poland"]);
const code = state.currencyOf(2).code;
state.budgets.set(0, 1_000_000);
state.budgets.set(1, 0);
state.requestDeclareWar(0, 2);
state._addReserve(0, code, 250);
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 1_000, currency: code }));
this.assertApprox(
state.getCentralBankReserves(0).get(code), 0, 1e-6,
"the reserves in hand are still handed over"
);
this.assertApprox(state.getBudget(1), 250, 1e-6, "the receiver gets only what was held");
}
test_giving_military_transfers_units_but_never_infantry() {
const state = smallState();
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
+17
View File
@@ -1,6 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { RESOURCE_MARKET_BASE } from "../shared/data.js";
// The opening settle: the server runs the world forward until commodity prices
// hold steady, so a fresh game is not served mid-shakeout.
@@ -36,4 +37,20 @@ export class WarmupTest extends TestCase {
state.warmUp(5);
this.assertEqual(state.totalHours, 5 * HOURS_PER_DAY, "a fixed settle runs every day asked for");
}
// The rare goods used to settle at a huge multiple of their base because the
// seeded mines were cached as unreachable and never sold, so a regression here
// is what a producer-less trade graph looks like.
test_the_settled_world_opens_with_the_rare_goods_near_their_base() {
const state = smallState();
state.warmUpToStability({ maxDays: 150 });
for (const id of ["luxury", "hightech"]) {
const index = state.getResourcePrice(id) / RESOURCE_MARKET_BASE[id];
this.assertTrue(
index > 0.5 && index < 2.5,
`${id} settles near its base price (index ${index.toFixed(2)})`
);
}
this.assertTrue(state.pricesAreSteady(7, 0.1), "and the market is steady");
}
}