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:
-197
@@ -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).
|
|
||||||
@@ -6,18 +6,8 @@ context on purpose.
|
|||||||
|
|
||||||
| Doc | Area |
|
| 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. |
|
| `ICONS.md` | Replacing the placeholder "dummy icon" SVGs with real art. |
|
||||||
| `SIMPLIFICATIONS.md` | The `[~]` roadmap items: deliberate simplifications worth revisiting. |
|
| `SIMPLIFICATIONS.md` | The `[~]` roadmap items: deliberate simplifications worth revisiting. |
|
||||||
|
|
||||||
`ROADMAP.md` remains the authoritative task list; these docs add the *why* and
|
`ROADMAP.md` remains the authoritative task list; these docs add the *why* and
|
||||||
the design space around the open items.
|
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.
|
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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
|
|
||||||
@@ -40,15 +40,10 @@ Pure JavaScript, no build step, no runtime dependencies.
|
|||||||
|
|
||||||
## 2. Requirement (from `ROADMAP.md`)
|
## 2. Requirement (from `ROADMAP.md`)
|
||||||
|
|
||||||
> - [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
|
> Real art for the placeholder "dummy icon" SVGs.
|
||||||
> `icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
|
|
||||||
> `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
|
|
||||||
> `icon_wind_turbines.svg`.
|
|
||||||
|
|
||||||
Note: `icon_radar.svg`, `icon_coastal_cannon.svg`, `icon_chip_foundry.svg` and
|
The roadmap names a specific set of files, but most of those have since been
|
||||||
`icon_wind_turbines.svg` no longer contain the word "dummy" (they appear to have
|
drawn; **re-derive the live list** with the command below before starting.
|
||||||
been replaced already), so the list may be partly stale — **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).
|
tab; used at 20px).
|
||||||
- `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg` — power plants.
|
- `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg` — power plants.
|
||||||
- `icon_icbm.svg`, `icon_missile.svg` — missiles.
|
- `icon_icbm.svg`, `icon_missile.svg` — missiles.
|
||||||
- `icon_spy.svg` — the spy unit.
|
|
||||||
- `icon_submarine.svg` — the submarine.
|
- `icon_submarine.svg` — the submarine.
|
||||||
- `icon_uranium_enrichment.svg` — the nuclear building.
|
- `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):
|
The dummy template (all of them share it):
|
||||||
|
|
||||||
@@ -169,5 +167,4 @@ The dummy template (all of them share it):
|
|||||||
|
|
||||||
## 9. Related design docs
|
## 9. Related design docs
|
||||||
|
|
||||||
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
|
`SIMPLIFICATIONS.md` (repo root).
|
||||||
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md` (repo root).
|
|
||||||
|
|||||||
-602
@@ -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).
|
|
||||||
@@ -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
@@ -99,23 +99,7 @@ uses a nominal two-tile road delivery.*
|
|||||||
- Files: `shared/game_state/resources.js`, `shared/game_state/economy.js`,
|
- 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`.
|
`client/js/map_view/modes.js` (delivery graph), `client/js/map_view/economic.js`.
|
||||||
|
|
||||||
## 6. Give-money treaties bypass FX and reserves
|
## 6. Missiles / WMD
|
||||||
|
|
||||||
**`[~]`** *Give money is denominated in any currency but (simplified) the sum
|
|
||||||
moves straight between the two treasuries, with no FX or central-bank reserve
|
|
||||||
leg.*
|
|
||||||
|
|
||||||
- Today: `shared/game_state/treaties.js` moves the sum directly; the
|
|
||||||
"cannot get currency from a central bank at war with you" rule is not
|
|
||||||
modelled.
|
|
||||||
- Full model: draw from the giver's foreign reserves, otherwise buy from the
|
|
||||||
relevant central bank at the market rate; block a bank whose nation is at war.
|
|
||||||
- Questions: which FX rate and spread? Does a reserve shortage fail the treaty
|
|
||||||
or partially settle?
|
|
||||||
- Files: `shared/game_state/treaties.js`, `shared/game_state/` currency/central
|
|
||||||
bank mixins, `client/js/modals/nation.js` (treaty compose).
|
|
||||||
|
|
||||||
## 7. Missiles / WMD
|
|
||||||
|
|
||||||
**`[~]`** *Missiles* and *WMDs* are marked `[~]` while their sub-items are
|
**`[~]`** *Missiles* and *WMDs* are marked `[~]` while their sub-items are
|
||||||
largely `[x]`.
|
largely `[x]`.
|
||||||
@@ -125,15 +109,14 @@ largely `[x]`.
|
|||||||
design and needs `nuclear_weapons` + `space_program`. A nuclear blast kills
|
design and needs `nuclear_weapons` + `space_program`. A nuclear blast kills
|
||||||
~90% of a tile's people, destroys buildings/improvements except bunkers and
|
~90% of a tile's people, destroys buildings/improvements except bunkers and
|
||||||
fortifications, and costs every nation popularity.
|
fortifications, and costs every nation popularity.
|
||||||
- What is unfinished: the WMD follow-up "foreign intelligence may be warned that
|
- What is unfinished: further refinement of blast falloff / interception (the
|
||||||
you are enriching uranium" (see `INTELLIGENCE.md`), and any further
|
"foreign intelligence may be warned that you are enriching uranium" follow-up
|
||||||
refinement of blast falloff / interception.
|
is now implemented).
|
||||||
- Files: `shared/game_state/wmd.js`, `shared/data/units.js`,
|
- Files: `shared/game_state/wmd.js`, `shared/data/units.js`,
|
||||||
`shared/data/combat.js`, `client/js/game_screen/panels.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`,
|
`DESIGN.md` (index), `ICONS.md`.
|
||||||
`ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md`.
|
|
||||||
|
|||||||
@@ -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
@@ -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 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
<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>
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 473 B After Width: | Height: | Size: 4.0 KiB |
@@ -1936,6 +1936,15 @@ input:focus, select:focus { border-color: var(--accent); }
|
|||||||
.training-queue .queue-empty { padding: 4px 0; }
|
.training-queue .queue-empty { padding: 4px 0; }
|
||||||
.training-queue .queue-row { padding: 8px 12px; }
|
.training-queue .queue-row { padding: 8px 12px; }
|
||||||
.training-queue .queue-row .actions .aero { min-width: 110px; }
|
.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
|
/* 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
|
between train, buildings, people and budget never resizes the card. They match
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
// 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.
|
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||||
export const DEVLOG = [
|
export const DEVLOG = [
|
||||||
|
{
|
||||||
|
"hash": "afb23df",
|
||||||
|
"date": "2026-09-24",
|
||||||
|
"subject": "Cut the migration sweep's repeated lookups and population churn",
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"hash": "a219cf6",
|
"hash": "a219cf6",
|
||||||
"date": "2026-09-24",
|
"date": "2026-09-24",
|
||||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
|||||||
"date": "2026-09-23",
|
"date": "2026-09-23",
|
||||||
"subject": "Rolled the commodity index over ninety days and gave the market an equilibrium",
|
"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."
|
"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": ""
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1646,7 +1646,7 @@ export const panelMethods = {
|
|||||||
const building = entry.kind === "building";
|
const building = entry.kind === "building";
|
||||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||||
const name = proto ? proto.name : (building ? "Building" : "Unit");
|
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>");
|
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||||
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
|
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
|
||||||
$el.append(
|
$el.append(
|
||||||
@@ -2298,6 +2298,7 @@ export const panelMethods = {
|
|||||||
coastal: !!city.coastal,
|
coastal: !!city.coastal,
|
||||||
testing: !!this.testing,
|
testing: !!this.testing,
|
||||||
trainable: this._trainableUnits(city),
|
trainable: this._trainableUnits(city),
|
||||||
|
unitProtos: this.protoUnits,
|
||||||
buildings: BUILDINGS,
|
buildings: BUILDINGS,
|
||||||
levels: this._cityBuildings.get(city.id) || {},
|
levels: this._cityBuildings.get(city.id) || {},
|
||||||
queue: this._trainingQueue(city.id),
|
queue: this._trainingQueue(city.id),
|
||||||
@@ -2382,9 +2383,9 @@ export const panelMethods = {
|
|||||||
const building = entry.kind === "building";
|
const building = entry.kind === "building";
|
||||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||||
const name = proto ? proto.name : (building ? "building" : "unit");
|
const name = proto ? proto.name : (building ? "building" : "unit");
|
||||||
// A building order spends its first days buying its steel and high-tech, so
|
// An order -- building or unit -- spends its first days buying its steel and
|
||||||
// show that gathering rather than a construction clock that has not started.
|
// high-tech, so show that gathering rather than a clock that has not started.
|
||||||
if (building && entry.phase === "materials") {
|
if (entry.phase === "materials") {
|
||||||
this._trainingCityId = this.selectedCityId;
|
this._trainingCityId = this.selectedCityId;
|
||||||
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
|
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
|
||||||
return;
|
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.
|
// average of each storable material's share bought. 1 when nothing is needed.
|
||||||
_gatherFraction(entry) {
|
_gatherFraction(entry) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|||||||
+111
-14
@@ -3,7 +3,7 @@
|
|||||||
// city's production queue, so the train tab shows both kinds of order.
|
// city's production queue, so the train tab shows both kinds of order.
|
||||||
|
|
||||||
import { groupDigits, hours } from "../../../shared/text_format.js";
|
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 { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js";
|
||||||
import {
|
import {
|
||||||
buildingBuildCost,
|
buildingBuildCost,
|
||||||
@@ -59,6 +59,13 @@ export class CityModal {
|
|||||||
this.prices = {};
|
this.prices = {};
|
||||||
this._levels = {};
|
this._levels = {};
|
||||||
this.protoNames = new Map();
|
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
|
// Signature of the trainable set currently drawn, so a snapshot that did
|
||||||
// not change it leaves the list (and its scroll) alone.
|
// not change it leaves the list (and its scroll) alone.
|
||||||
this._trainableSignature = null;
|
this._trainableSignature = null;
|
||||||
@@ -100,6 +107,10 @@ export class CityModal {
|
|||||||
this.constructionSpeed = view.constructionSpeed || 0;
|
this.constructionSpeed = view.constructionSpeed || 0;
|
||||||
this.testing = !!view.testing;
|
this.testing = !!view.testing;
|
||||||
this.prices = view.prices || {};
|
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.$title.text(`${city.name} — City`);
|
||||||
this._renderTrainList(this.gdp, view.trainable || []);
|
this._renderTrainList(this.gdp, view.trainable || []);
|
||||||
this.renderQueue(view.queue || []);
|
this.renderQueue(view.queue || []);
|
||||||
@@ -469,11 +480,15 @@ export class CityModal {
|
|||||||
|
|
||||||
// Draws the shared production queue: one row per run of contiguous identical
|
// 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
|
// 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) {
|
renderQueue(queue) {
|
||||||
if (!this.$trainQueue || !this.$trainQueue.length) return;
|
if (!this.$trainQueue || !this.$trainQueue.length) return;
|
||||||
|
this._lastQueue = queue || null;
|
||||||
this.$trainQueue.empty();
|
this.$trainQueue.empty();
|
||||||
if (!queue || queue.length === 0) {
|
if (!queue || queue.length === 0) {
|
||||||
|
this._expandedQueue = null;
|
||||||
this.$trainQueue.append(
|
this.$trainQueue.append(
|
||||||
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
|
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
|
||||||
);
|
);
|
||||||
@@ -481,29 +496,31 @@ export class CityModal {
|
|||||||
}
|
}
|
||||||
let cumulative = 0;
|
let cumulative = 0;
|
||||||
groupQueue(queue).forEach((group, groupIndex) => {
|
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;
|
let firstReady = 0;
|
||||||
for (const index of group.indices) {
|
for (const index of group.indices) {
|
||||||
const entry = queue[index];
|
const at = queue[index];
|
||||||
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
|
const elapsed = index === 0 ? at.elapsedHours || 0 : 0;
|
||||||
// A building still gathering its materials has not started building, so
|
cumulative += gather ? 0 : Math.max(at.totalHours - elapsed, 0);
|
||||||
// it adds no time to the queue's readiness estimate.
|
if (index === first) firstReady = cumulative;
|
||||||
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 count = group.indices.length;
|
const count = group.indices.length;
|
||||||
const gathering = queue[group.indices[0]].kind === "building" &&
|
|
||||||
queue[group.indices[0]].phase === "materials";
|
|
||||||
const name = group.kind === "building"
|
const name = group.kind === "building"
|
||||||
? this.buildingNames.get(group.protoIndex) || "Building"
|
? this.buildingNames.get(group.protoIndex) || "Building"
|
||||||
: this.protoNames.get(group.protoIndex) || "Unit";
|
: this.protoNames.get(group.protoIndex) || "Unit";
|
||||||
const label = count > 1 ? `${name} ×${count}` : name;
|
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>");
|
const $info = $("<div class='info'></div>");
|
||||||
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
|
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
|
||||||
$info.append(
|
$info.append(
|
||||||
$("<div class='desc'></div>").text(
|
$("<div class='desc'></div>").text(
|
||||||
gathering
|
gather
|
||||||
? "Gathering materials"
|
? "Gathering materials"
|
||||||
: count > 1
|
: count > 1
|
||||||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||||||
@@ -512,13 +529,93 @@ export class CityModal {
|
|||||||
);
|
);
|
||||||
const $actions = $("<div class='actions'></div>");
|
const $actions = $("<div class='actions'></div>");
|
||||||
const $cancel = $("<button class='aero'></button>").text(count > 1 ? "Cancel last" : "Cancel");
|
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);
|
$actions.append($cancel);
|
||||||
$row.append($info, $actions);
|
$row.append($info, $actions);
|
||||||
|
$row.on("click", () => this._toggleQueueDetail(group, first));
|
||||||
this.$trainQueue.append($row);
|
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") {
|
beginTraining(name, totalHours, elapsedHours = 0, verb = "Training") {
|
||||||
this.$trainLabel.text(`${verb} ${name}…`);
|
this.$trainLabel.text(`${verb} ${name}…`);
|
||||||
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
|
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
|
||||||
|
|||||||
@@ -1071,11 +1071,11 @@ export const economyMethods = {
|
|||||||
completed.push(cityId);
|
completed.push(cityId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A queued building first gathers its materials, once a day, exactly as a
|
// A queued order -- building or unit -- first gathers its materials, once
|
||||||
// tile improvement's construction site does; only then does its
|
// a day, exactly as a tile improvement's construction site does; only then
|
||||||
// construction time begin.
|
// does its construction or training time begin.
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
if (entry.kind !== "building" || entry.phase !== "materials") continue;
|
if (entry.phase !== "materials") continue;
|
||||||
if (entry.lastGatherDay === day) continue;
|
if (entry.lastGatherDay === day) continue;
|
||||||
entry.lastGatherDay = day;
|
entry.lastGatherDay = day;
|
||||||
this._gatherBuildEntry(city, entry);
|
this._gatherBuildEntry(city, entry);
|
||||||
@@ -1143,6 +1143,9 @@ export const economyMethods = {
|
|||||||
overflow = budget - remaining;
|
overflow = budget - remaining;
|
||||||
if (kind === "building") this._completeBuilding(city, entry);
|
if (kind === "building") this._completeBuilding(city, entry);
|
||||||
else {
|
else {
|
||||||
|
// Hand back whatever material budget the unit did not spend, then
|
||||||
|
// place it.
|
||||||
|
this._releaseEntryBudget(city.civ, entry);
|
||||||
this._spawnTrainedUnit(city, proto);
|
this._spawnTrainedUnit(city, proto);
|
||||||
this._visibilityDirty = true;
|
this._visibilityDirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,22 +49,39 @@ export const orderMethods = {
|
|||||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
||||||
if (capacity <= 0) return false;
|
if (capacity <= 0) return false;
|
||||||
if (!this._spendBudget(city.civ, proto.cost, "training")) 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);
|
const materials = this.constructionResourceCost(proto, 0, proto.cost);
|
||||||
if (!this._payConstructionResources(city.civ, city.coords, materials, "training", proto.name)) {
|
const needed = {
|
||||||
this._refundBudget(city.civ, proto.cost, "training");
|
steel: Math.max(0, materials.steel || 0),
|
||||||
return false;
|
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 = {
|
const entry = {
|
||||||
kind: "unit",
|
kind: "unit",
|
||||||
protoIndex,
|
protoIndex,
|
||||||
capacity,
|
capacity,
|
||||||
|
phase: "materials",
|
||||||
|
budget: reserved,
|
||||||
|
budgetCategory: "training",
|
||||||
|
prices: this._lockedUnitPrices(),
|
||||||
|
needed,
|
||||||
|
bought: { steel: 0, hightech: 0 },
|
||||||
elapsedHours: 0,
|
elapsedHours: 0,
|
||||||
totalHours: (proto.cost / capacity) *
|
totalHours: (proto.cost / capacity) *
|
||||||
this.constructionApprovalMultiplier(city) *
|
this.constructionApprovalMultiplier(city) *
|
||||||
this.constructionSpeedMultiplier(city.civ),
|
this.constructionSpeedMultiplier(city.civ),
|
||||||
|
lastGatherDay: -1,
|
||||||
};
|
};
|
||||||
if (queue) queue.push(entry);
|
if (queue) queue.push(entry);
|
||||||
else this.training.set(cityId, [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();
|
this._emitChanged();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1705,8 +1705,12 @@ export const resourceMethods = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The people eat, wear and spend. Shortfalls are matched against reachable
|
// The people eat, wear and spend. Shortfalls are matched against reachable
|
||||||
// stores -- home regions first, then foreign ones.
|
// stores -- home regions first, then foreign ones. The storage nodes are
|
||||||
this._consumeCityResources(components, market, nodes);
|
// 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 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
|
// the prices are set. Count the appetite now, so the market's food price
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export const siteMethods = {
|
|||||||
_releaseEntryBudget(civ, entry) {
|
_releaseEntryBudget(civ, entry) {
|
||||||
if (!(entry.budget > 0)) return;
|
if (!(entry.budget > 0)) return;
|
||||||
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
|
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;
|
entry.budget = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,15 @@ export const treatyMethods = {
|
|||||||
if (type === TREATY_GIVE_MONEY) {
|
if (type === TREATY_GIVE_MONEY) {
|
||||||
const amount = Number(data.amount || 0);
|
const amount = Number(data.amount || 0);
|
||||||
if (!(amount > 0)) return null;
|
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.
|
// Peace, alliance, free trade and joint research carry no payload.
|
||||||
return {};
|
return {};
|
||||||
@@ -271,9 +279,7 @@ export const treatyMethods = {
|
|||||||
this._giftResources(treaty.a, treaty.b, treaty.payload.resources);
|
this._giftResources(treaty.a, treaty.b, treaty.payload.resources);
|
||||||
break;
|
break;
|
||||||
case TREATY_GIVE_MONEY: {
|
case TREATY_GIVE_MONEY: {
|
||||||
const amount = treaty.payload.amount;
|
this._payTreatyMoney(treaty.a, treaty.b, treaty.payload.amount, treaty.payload.currency);
|
||||||
this.budgets.set(treaty.a, this.getBudget(treaty.a) - amount);
|
|
||||||
this.budgets.set(treaty.b, this.getBudget(treaty.b) + amount);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
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
|
// 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
|
// 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
|
// records are written, so an alliance never coexists with a war. The broken
|
||||||
|
|||||||
@@ -563,4 +563,55 @@ export class ConstructionSiteTest extends TestCase {
|
|||||||
this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier");
|
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");
|
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
@@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js";
|
|||||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||||
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
|
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
|
||||||
import { buildingEffectText, describeNews } from "../client/js/modals/format.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 { groupDigits } from "../shared/text_format.js";
|
||||||
import { setCurrency, setCurrencyBook } from "../client/js/currency.js";
|
import { setCurrency, setCurrencyBook } from "../client/js/currency.js";
|
||||||
import { formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../shared/resources.js";
|
import { formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../shared/resources.js";
|
||||||
@@ -47,6 +47,7 @@ export class CityModalTest extends TestCase {
|
|||||||
budget: 1.0e9,
|
budget: 1.0e9,
|
||||||
gdp: 1.0e9,
|
gdp: 1.0e9,
|
||||||
trainable: [{ ...PROTO_UNITS[0], serverIndex: 0 }, { ...PROTO_UNITS[2], serverIndex: 2 }],
|
trainable: [{ ...PROTO_UNITS[0], serverIndex: 0 }, { ...PROTO_UNITS[2], serverIndex: 2 }],
|
||||||
|
unitProtos: PROTO_UNITS,
|
||||||
buildings: BUILDINGS,
|
buildings: BUILDINGS,
|
||||||
levels: {},
|
levels: {},
|
||||||
queue,
|
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() {
|
async test_buildings_tab_prices_rows_and_requests_build_and_demolish() {
|
||||||
const env = await setupDom();
|
const env = await setupDom();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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() {
|
test_giving_military_transfers_units_but_never_infantry() {
|
||||||
const state = smallState();
|
const state = smallState();
|
||||||
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
|
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TestCase } from "./framework/test_case.js";
|
import { TestCase } from "./framework/test_case.js";
|
||||||
import { smallState } from "./framework/helpers.js";
|
import { smallState } from "./framework/helpers.js";
|
||||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.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
|
// The opening settle: the server runs the world forward until commodity prices
|
||||||
// hold steady, so a fresh game is not served mid-shakeout.
|
// hold steady, so a fresh game is not served mid-shakeout.
|
||||||
@@ -36,4 +37,20 @@ export class WarmupTest extends TestCase {
|
|||||||
state.warmUp(5);
|
state.warmUp(5);
|
||||||
this.assertEqual(state.totalHours, 5 * HOURS_PER_DAY, "a fixed settle runs every day asked for");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user