Gave the world spies, treaties and nuclear weapons
@@ -50,13 +50,16 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
|
||||
- The server creates its game at startup; add `--testing` to
|
||||
`node server/server.js --testing` to make it a test game, where every city's
|
||||
train and build rows gain a second "Free & instant" button.
|
||||
train and build rows gain a second "Free & instant" button and every technology
|
||||
card an "Unlock now" button.
|
||||
- The free shortcut is gated on the server, never trusted from the client:
|
||||
`server/server.js` strips a `free` flag from any non-loopback order
|
||||
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), and `GameServer`
|
||||
honours it only while `this.testing` is set.
|
||||
`GameState.requestTrain/requestBuild(..., free)` then spawn or raise
|
||||
immediately without cost or queue (placement rules still apply).
|
||||
immediately without cost or queue (placement rules still apply), and
|
||||
`GameState.unlockTechnology(civ, index)` grants a technology outright
|
||||
(prerequisites included).
|
||||
- The `testing` flag is shipped in the snapshot so joiners see the same buttons.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Air movement — design brief
|
||||
|
||||
A working document for a session on making **aircraft fly in straight lines**
|
||||
instead of following the hex grid.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model; `server/game_server.js` owns one and validates orders;
|
||||
`server/server.js` drives the clocks (movement every tick, an in-game hour per
|
||||
`SECONDS_PER_HOUR`).
|
||||
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
|
||||
(no Node, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and `Object.assign`s its methods on. A new
|
||||
method file must be imported **and** added there.
|
||||
- **Movement is continuous** and the browser **predicts** it: the client
|
||||
extrapolates each unit along its snapshot path and mirrors `find_path` in
|
||||
`client/js/map_view/motion.js`. **Any pathfinding or movement-cost change must
|
||||
be mirrored on both sides**, or prediction fights the server.
|
||||
- **Roads** (`shared/roads.js`, `GameState.roads`) flatten tiles' movement cost
|
||||
on both sides via `_tileTravelHours` / `_stepHours`.
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- Server: `node server/server.js --port 27015 --bind 127.0.0.1`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>` when art is
|
||||
missing.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
|
||||
`tests/framework/helpers.js`. Force RNG with `state._random = () => value`.
|
||||
- Visual changes get a minimal standalone HTML preview linking
|
||||
`client/css/style.css`, opened in Firefox, before commit.
|
||||
|
||||
---
|
||||
|
||||
## 2. How air works today
|
||||
|
||||
Aircraft are ordinary tile-bound units that happen to ignore terrain obstacles:
|
||||
|
||||
- `shared/data/units.js`: `jet_fighter` and `bomber` have `air: true`,
|
||||
`speed: 5`, `traversableTerrains: ["Land", "Sea", "Ice"]`, `enduranceHours`
|
||||
(10 / 20) and `missionRange` (80 / 100), plus `requiresBuilding: "airport"`.
|
||||
`bomber.strike = true`. Missiles (`missile`, `icbm`) are `strike` units too but
|
||||
not `air`.
|
||||
- `shared/game_state/air.js`:
|
||||
- `_isAirProto` / `_isAirUnit`; `_isStrikeUnit` (any aircraft, or `strike`, or
|
||||
range > 1).
|
||||
- `_friendlyAirportAt`, `_returnAirport` (nearest friendly airport to the
|
||||
heading), airport-only rebasing.
|
||||
- `_pathHours`, `_canStrikeAndReturn` (whole round trip must fit remaining
|
||||
fuel), `_strikeReady` (parked, fuelled, off cooldown `strikeReadyHour`).
|
||||
- `requestAirStrike(unitIds, goal)`: a **sortie** — the plane flies to the
|
||||
target, bombs once on arrival in `advanceMovement`, then returns to a
|
||||
friendly airport, where landing arms the `AIR_STRIKE.cooldownHours` cooldown.
|
||||
- Hourly `_tickAir` (or equivalent) burns one hour of endurance per airborne
|
||||
aircraft, refuels/lands those parked at an airport, crashes those that ran
|
||||
dry, and sends idle planes home.
|
||||
- `shared/game_state/movement.js` `_canUnitEnter`: aircraft skip terrain-class
|
||||
and foreign-territory checks (they fly over fog and borders); only the tile's
|
||||
existence and terrain class constrain them. Ferry (rebasing) is bounded by
|
||||
fuel, not `missionRange`.
|
||||
- Client: `client/js/map_view/motion.js` extrapolates the snapshot path and
|
||||
predicts local move orders; `map_view/motion.js` already has `_nearestCopy`
|
||||
for the cylindrical wrap and strike-target line drawing.
|
||||
|
||||
The path is still a list of tile-centre waypoints (from `find_path`), so on the
|
||||
map a plane visibly zig-zags along hexes, and its fuel/time come from summed
|
||||
per-tile `_stepHours`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Requirement (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Airplanes move in straight lines as they are not bound by tiles.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design space (settle before coding)
|
||||
|
||||
1. **A straight-line path representation.** The simplest change: produce, for an
|
||||
air move/sortie, a path of **two world-pixel points** — origin centre to
|
||||
destination centre — instead of tile waypoints. The continuous mover already
|
||||
advances along path segments, so a 2-point path is a straight line for free.
|
||||
Where is the helper? It must be shared so the client prediction draws the
|
||||
same line: a pure function (e.g. `airPath(from, to, topology)` returning pixel
|
||||
points, using `topology.pixelDelta` for the shortest wrapped copy) in
|
||||
`shared/` (hex.js or air.js), called by both `GameState` and `motion.js`.
|
||||
2. **Distance and time on a straight line.** Fuel is in hours; convert pixel
|
||||
distance to tiles (`hypot(delta) / maxStepLength`) then to hours at the unit's
|
||||
speed, matching `_pathHours`'s units. Decide whether air ignores roads and
|
||||
terrain multipliers entirely (yes — it already effectively does, since it
|
||||
flies) and whether the speed is tiles/hour directly.
|
||||
3. **Destination semantics.** A straight line ends at the destination tile
|
||||
centre. For a move order, the plane ends on that tile (then `_tickAir` sends
|
||||
it home if it is not an airport — or must a move order only target an
|
||||
airport? Today rebasing is airport-only; confirm). For a sortie, the plane
|
||||
arrives at the target tile centre, bombs, turns for the nearest airport.
|
||||
4. **Interception / air defense.** Combat is tile-based: `AIR_DEFENSE` fires on
|
||||
aircraft "near" a tile. With a straight line, does air defense trigger when
|
||||
the segment passes within range of an AA/`aa_building`, or only when the
|
||||
endpoint is on/near it? This is the biggest open question — it changes both
|
||||
the model and the client's strike preview. Options: (a) check the tiles the
|
||||
segment crosses (sample the line at tile centres); (b) treat a strike as
|
||||
still resolved at the target tile only; (c) add a continuous "flak corridor".
|
||||
5. **Strike radius and range.** `missionRange` and `range` are tile counts;
|
||||
keep them, but measure them along the straight line (tile distance between
|
||||
endpoints) rather than path length. Round-trip fuel uses the straight-line
|
||||
distance to target plus straight-line distance home.
|
||||
6. **The cylindrical map.** A straight line must take the shortest wrapped copy
|
||||
and be drawn across the seam. `topology.pixelDelta` gives the shortest offset;
|
||||
the renderer needs the same wrapped copy (`_nearestCopy`) so the plane icon
|
||||
does not jump. A "straight line" across the seam may be a line that leaves one
|
||||
edge and re-enters the other.
|
||||
7. **Rendering.** The icon layer positions units along the path; a 2-point path
|
||||
is trivial, but the strike/bombard arc drawing and the dashed target line may
|
||||
need to follow the wrapped copy. Check `client/js/map_view/motion.js` and the
|
||||
entity icon code.
|
||||
8. **Zone of control / territory.** Irrelevant to aircraft (they already ignore
|
||||
both). Make sure the new path code does not accidentally reintroduce the
|
||||
tile-based `_canUnitEnter` checks for air.
|
||||
9. **Missiles.** `missile`/`icbm` are `strike` but not `air`; decide whether the
|
||||
straight-line rule applies to them too (they are "fired, not marched", so
|
||||
likely yes for their strike flight).
|
||||
10. **Endurance drift.** The hourly fuel burn is one hour per airborne plane
|
||||
regardless of distance. With straight lines the time and the fuel must agree;
|
||||
keep `_pathHours` (or its replacement) the single source for both.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested order of work
|
||||
|
||||
1. Add the shared straight-line path helper (with wrap) and unit tests for the
|
||||
distance/time/wrap maths.
|
||||
2. Use it for **ferrying** (rebasing) first — the simplest case, no combat.
|
||||
3. Use it for **sorties** and keep the round-trip fuel check.
|
||||
4. Decide and implement interception/air-defense semantics (question 4).
|
||||
5. Mirror in the client prediction and the icon/arc rendering.
|
||||
6. Tick the roadmap line.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add
|
||||
|
||||
- A straight-line flight between two tiles has the expected time/fuel, including
|
||||
a flight across the wrap seam (shortest copy).
|
||||
- A sortie whose round trip fits the fuel is accepted; one that does not is
|
||||
refused (server and client prediction agree).
|
||||
- The plane arrives and bombs at the target's hour (headless `advanceMovement`).
|
||||
- Client `planMove`/prediction yields the same endpoint hour as the server.
|
||||
- Air defense still triggers under the chosen rule (question 4).
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Mirror server and client.** The plane's position is extrapolated by the
|
||||
browser; if the client keeps the old tile path the icon will lag or jump.
|
||||
- **Fuel units.** `_pathHours` returns in-game hours; keep new distance maths in
|
||||
the same units or planes will run dry mid-flight.
|
||||
- **Wrap seam.** Any new geometry must use `topology.pixelDelta` / `_nearestCopy`,
|
||||
not raw `x2 - x1`.
|
||||
- **Snapshot path.** Paths are shipped to the client; a 2-point pixel path is
|
||||
smaller, not larger, but confirm the serialiser accepts non-integer points.
|
||||
- **`_tickAir` idle-return.** Planes that are not at an airport are sent home
|
||||
each hour; a straight-line move to a non-airport tile may be immediately
|
||||
reversed. Confirm the intended behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 8. Relevant files
|
||||
|
||||
- `shared/data/units.js` — aircraft/`strike`/`air`/missile protos.
|
||||
- `shared/game_state/air.js` — endurance, strikes, airports, cooldown.
|
||||
- `shared/game_state/movement.js` — `advanceMovement`, `_pathHours`,
|
||||
`_stepHours`, air exemption in `_canUnitEnter`.
|
||||
- `shared/hex.js` — `mapToLocal`, `COL_STEP`, `HEX_H`, `MapTopology.pixelDelta`.
|
||||
- `client/js/map_view/motion.js` — prediction, `_nearestCopy`, strike drawing.
|
||||
- `server/server.js` — the movement tick.
|
||||
- `shared/data/combat.js` — `AIR_DEFENSE`, `AIR_STRIKE`.
|
||||
- Tests: `tests/air_test.js`, `tests/bombing_test.js`, `tests/movement`-related
|
||||
suites, `tests/map_view_test.js`.
|
||||
|
||||
## 9. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `STACKS.md`, `ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`,
|
||||
`TRAINING_UI.md`, `ICONS.md` (repo root).
|
||||
@@ -0,0 +1,23 @@
|
||||
# Design docs — index
|
||||
|
||||
Standalone briefs for the open work, each written so a new session can pick up
|
||||
one area without re-reading the codebase. They duplicate the common project
|
||||
context on purpose.
|
||||
|
||||
| Doc | Area |
|
||||
| --- | --- |
|
||||
| `INTELLIGENCE.md` | Intelligence tab, spy unit, sabotage, city/comms spying, counter-intelligence, spy satellites, the uranium-enrichment warning. |
|
||||
| `STACKS.md` | Splitting unit stacks ergonomically; stacks pathfinding as one body with allied/at-war territory walkable. |
|
||||
| `AIR_MOVEMENT.md` | Airplanes moving in straight lines instead of along hex tiles. |
|
||||
| `ECONOMY_BALANCE.md` | Making luxury and high-tech world prices settle instead of plateauing. |
|
||||
| `POLITICS_PERFORMANCE.md` | Speeding up the hourly politics/migration tick with version-keyed memoisation. |
|
||||
| `TRAINING_UI.md` | Showing what a unit being trained is gathering when the player clicks it. |
|
||||
| `ICONS.md` | Replacing the placeholder "dummy icon" SVGs with real art. |
|
||||
| `SIMPLIFICATIONS.md` | The `[~]` roadmap items: deliberate simplifications worth revisiting. |
|
||||
|
||||
`ROADMAP.md` remains the authoritative task list; these docs add the *why* and
|
||||
the design space around the open items.
|
||||
|
||||
**Done but not yet ticked in `ROADMAP.md`** (as of writing): the ranged-attack
|
||||
button for a stack (roadmap §5) and the resource-cost line in the budget table
|
||||
(roadmap §7) are implemented in the working tree.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Economy balance — design brief
|
||||
|
||||
A working document for a session on the two commodities whose world-market
|
||||
prices fail to settle: **luxury** and **high-tech**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model; `server/game_server.js` owns one and validates orders.
|
||||
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
|
||||
(no Node, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and adds its methods to the prototype. A new
|
||||
method file must be imported **and** added there.
|
||||
- **The economy ticks hourly** (`shared/game_state/resources.js`
|
||||
`_tickResources`); the world can be **settled** without players via
|
||||
`GameState.warmUp(days)` and `warmUpToStability({ maxDays })`, which the
|
||||
server calls from `configureGame` (the `--warmup` flag). "Steady" means every
|
||||
commodity within 10% over the trailing seven days (`pricesAreSteady`).
|
||||
- **Snapshot performance.** The trade graph is cached across ticks and days
|
||||
under a stamp built from road/railway, territory and storage-node counters;
|
||||
`getCityEconomy` must not be memoised outside a snapshot. New per-day walks
|
||||
should be memoised against the counters that actually change them.
|
||||
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- A quick way to observe prices is the server's startup settle:
|
||||
`node server/server.js --port 27015 --bind 127.0.0.1` (logs how many days it
|
||||
settles, `--warmup 0` skips it). There is also `tests/warmup_test.js`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`. Force RNG with
|
||||
`state._random = () => value`.
|
||||
- Commit only when asked; messages are one imperative sentence; the pre-commit
|
||||
hook runs the whole suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. The problem (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Luxury and high-tech prices climb to a plateau far above their base (the
|
||||
> economy simulator's index reaches about 500-650 for luxury and 200-230
|
||||
> for high-tech) instead of settling; revisit the supply and demand balance
|
||||
> of the two rarer goods.
|
||||
|
||||
(Index 100 = the base price.) The two rare goods are in permanent structural
|
||||
shortage, so the equilibrium price is high and the easing never reaches steady.
|
||||
|
||||
---
|
||||
|
||||
## 3. How the market works today
|
||||
|
||||
- **Base prices** (`shared/data/resources.js` `RESOURCE_MARKET_BASE`):
|
||||
energy 0.051 /kWh, steel 10,200 /t, food 5,100 /t, **luxury 1,020 /carat**,
|
||||
**high-tech 13,600 /unit**.
|
||||
- **Market model** (`RESOURCE_MARKET`): each day the imbalance sets an
|
||||
*equilibrium* — the price that clears supply against demand — and the price
|
||||
eases toward it by `adjustment` (0.2). `maxRatio` (10) bounds how sharp a
|
||||
shortage may read (and is also the ratio a zero-supply day is treated as).
|
||||
- **Demand for the rare goods**:
|
||||
- luxury: `luxuryPerPersonPerDay: 0.01` carat per person per day.
|
||||
- high-tech: science buildings spend `highTechPerResearchPoint: 1` unit per
|
||||
research point, advanced buildings/units pay high-tech upkeep
|
||||
(`upkeepHighTechPerEuro`), and construction of science/advanced buildings
|
||||
costs `constructionHighTechPerLevel: 100` per level.
|
||||
- **Supply**: gold/diamond mines (`energyPerLuxuryCarat`), chip foundries
|
||||
(`energyPerHighTechUnit`). The opening works are generated from the world seed
|
||||
and are *fixed* per map; nothing in normal play adds supply except a player
|
||||
raising more works.
|
||||
- **Energy gates supply**: every unit of luxury/high-tech costs a large amount of
|
||||
electricity, and food synthesis competes for the same power
|
||||
(`foodSynthesisQuadratic`). So a tight electricity balance throttles rare-good
|
||||
supply from above.
|
||||
- **Food** is the one good a region synthesises from energy, with a reserve-based
|
||||
price signal (`_tickResources` grows toward
|
||||
`need + (stockpileTarget - store) - harvest`; `_consumeCityResources` bids the
|
||||
empty-pantry appetite). Rare goods have no such synthesis.
|
||||
|
||||
---
|
||||
|
||||
## 4. Why it plateaus (hypotheses to test)
|
||||
|
||||
1. **Demand scales with population/research, supply does not.** Luxury demand
|
||||
grows with every person; high-tech demand grows with research points and the
|
||||
advanced-building stock. The seeded mine/foundry supply is a fixed number, so
|
||||
a mid-game world is structurally short and `maxRatio` caps the price high.
|
||||
2. **The equilibrium is computed, but the price only eases 20% a day**, so if the
|
||||
equilibrium itself is far above base the plateau is simply the equilibrium.
|
||||
3. **No elasticity on demand.** A very high rare-good price should tempt someone
|
||||
to build more mines/foundries; the AI may not, and the player may not either,
|
||||
so supply never answers the signal.
|
||||
4. **Energy competition.** The power needed for more supply may be going to food
|
||||
synthesis, pinning supply.
|
||||
5. **Stockpile behaviour.** Cities aim to hold `stockpileDays: 30` of luxury and
|
||||
high-tech, so a one-off demand spike is absorbed then restocked, keeping
|
||||
demand elevated.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design space (settle before coding)
|
||||
|
||||
1. **What is the target?** Should luxury/high-tech settle near their base (index
|
||||
~100), or is a moderate premium intended (they *are* rare)? The roadmap says
|
||||
"settle", implying they should stop climbing; a stable plateau near, say,
|
||||
120-200 may be acceptable. Decide the target band first, then tune.
|
||||
2. **Make the equilibrium self-correcting.** Options: relax `maxRatio` so a
|
||||
shortage cannot pin the read; make the equilibrium elastic in supply
|
||||
(higher price → modelled extra production) so the price cannot run away;
|
||||
or add a slow mean-reversion term toward base when demand is met.
|
||||
3. **Add synthesis for the rare goods** (like food) so energy can always buy
|
||||
more at a rising marginal cost — the food model is the template
|
||||
(`foodSynthesisQuadratic`).
|
||||
4. **Retune the physical ratios** (`energyPerLuxuryCarat`,
|
||||
`energyPerHighTechUnit`, per-person luxury, upkeep/high-tech coefficients) so
|
||||
the world's seeded supply covers its steady-state demand.
|
||||
5. **Seed more supply** at world generation (more mines/foundries) — a blunt fix,
|
||||
but it changes the opening economy, so measure with the warm-up.
|
||||
6. **Cap the demand side**: e.g. research high-tech spend or advanced upkeep
|
||||
scaled down.
|
||||
7. **Warm-up as the acceptance test.** After tuning, `warmUpToStability` should
|
||||
stop well under the cap with luxury/high-tech within the 10%/7-day band.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested order of work
|
||||
|
||||
1. Write a small measurement harness (a test or script) that runs
|
||||
`warmUpToStability` on the standard map and prints the final price indices for
|
||||
all five goods and the day count. Use it for every change.
|
||||
2. Identify whether the equilibrium is the plateau (hypothesis 2) by logging
|
||||
supply/demand/equilibrium for the two goods over the settle.
|
||||
3. Pick the mechanism (synthesis, elastic equilibrium, retune, or reseed) and
|
||||
tune to the target band.
|
||||
4. Re-run `warmup_test.js` and the economy suites; add a regression test that the
|
||||
rare-good indices stay within a band after a fixed settle.
|
||||
5. Tick the roadmap line.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests to add / extend
|
||||
|
||||
- A test that settles the standard map and asserts each good's price index is
|
||||
within a stated band (e.g. luxury and high-tech both < some multiple of base)
|
||||
and that `pricesAreSteady` holds.
|
||||
- Unit tests for any new synthesis curve or elastic-equilibrium function
|
||||
(monotonic, as expected).
|
||||
- Keep `tests/warmup_test.js`, `economy_test.js`, `trade_graph_test.js`,
|
||||
`resources_test.js`, `stockpile_test.js`, `food_synthesis_test.js`,
|
||||
`food_power_test.js` green.
|
||||
|
||||
---
|
||||
|
||||
## 8. Gotchas
|
||||
|
||||
- **Warm-up cost.** `warmUpToStability` runs real simulation days; a tuning that
|
||||
makes it run to the cap will slow every server start and the tests. Keep the
|
||||
settle bounded.
|
||||
- **The trade graph cache** is keyed on network/territory/storage counters, not
|
||||
tile improvements; do not fold new counters into its stamp without reason.
|
||||
- **`getCityEconomy` must not be memoised outside a snapshot**; a test pins that
|
||||
a simulation read reflects a direct `tilePopulation` edit at once.
|
||||
- **Prices are global** — a change helps/hurts every nation at once; watch the
|
||||
affordability of construction/upkeep, which is priced off the same figures.
|
||||
- **Energy is deliberately cheap** (`base.energy: 0.051`); do not raise it
|
||||
casually, or every material's energy bill dwarfs its value.
|
||||
|
||||
---
|
||||
|
||||
## 9. Relevant files
|
||||
|
||||
- `shared/data/resources.js` — base prices, `RESOURCE_MARKET`, `RESOURCE_RULES`
|
||||
(all the ratios), `PRICE_INDEX_WINDOW_DAYS`, `FAMINE`.
|
||||
- `shared/game_state/resources.js` — `_tickResources`, `_consumeCityResources`,
|
||||
supply/demand/equilibrium, food synthesis, `payCombatResources`.
|
||||
- `shared/game_state/economy.js` — production, stockpiles, construction costs.
|
||||
- `server/server.js` / `server/game_server.js` — `configureGame` warm-up.
|
||||
- Tests: `tests/warmup_test.js`, `tests/economy_test.js`,
|
||||
`tests/resources_test.js`, `tests/stockpile_test.js`,
|
||||
`tests/trade_graph_test.js`, `tests/food_synthesis_test.js`.
|
||||
|
||||
## 10. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `POLITICS_PERFORMANCE.md`,
|
||||
`TRAINING_UI.md`, `ICONS.md` (repo root).
|
||||
@@ -0,0 +1,173 @@
|
||||
# Icons / art — design brief
|
||||
|
||||
A working document for a session on replacing the placeholder **"dummy icon"**
|
||||
SVGs with real art.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative**, but art is purely client-side: SVGs live in
|
||||
`client/assets/` and are referenced by filename from `shared/data/*.js`
|
||||
(`icon: "icon_<name>.svg"`) and a few `client/assets/` references in JS.
|
||||
- **No build step and no image pipeline** — dropping an `.svg` in
|
||||
`client/assets/` and referencing its filename is the whole process. The
|
||||
`nix/package.nix` `web` output copies `client/` and `shared/` verbatim.
|
||||
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- Server + client: `node server/server.js --port 27015 --bind 127.0.0.1`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- A missing icon gets a dummy SVG reading `dummy icon - <name>` (the standing
|
||||
rule in `AGENTS.md`/`ROADMAP.md`).
|
||||
- **Visual changes are reviewed by eye before commit.** For icons, build a small
|
||||
standalone HTML gallery that links `client/css/style.css` and shows the icons
|
||||
at the sizes the UI actually uses, then **open it in Firefox**
|
||||
(`firefox <path>`). Do not screenshot and do not just print the path.
|
||||
- 2-space indent for `.js`; no new libraries.
|
||||
- Commit only when asked; one imperative sentence; the hook runs the full suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirement (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
|
||||
> `icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
|
||||
> `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
|
||||
> `icon_wind_turbines.svg`.
|
||||
|
||||
Note: `icon_radar.svg`, `icon_coastal_cannon.svg`, `icon_chip_foundry.svg` and
|
||||
`icon_wind_turbines.svg` no longer contain the word "dummy" (they appear to have
|
||||
been replaced already), so the list may be partly stale — **re-derive the live
|
||||
list** with the command below before starting.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current placeholders
|
||||
|
||||
Find them with:
|
||||
|
||||
```sh
|
||||
rg -il "dummy" client/assets/*.svg
|
||||
```
|
||||
|
||||
As of this writing that lists (subject to change):
|
||||
|
||||
- `icon_computer.svg`, `icon_human.svg` — player-list control icons (diplomacy
|
||||
tab; used at 20px).
|
||||
- `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg` — power plants.
|
||||
- `icon_icbm.svg`, `icon_missile.svg` — missiles.
|
||||
- `icon_spy.svg` — the spy unit.
|
||||
- `icon_submarine.svg` — the submarine.
|
||||
- `icon_uranium_enrichment.svg` — the nuclear building.
|
||||
|
||||
The dummy template (all of them share it):
|
||||
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">spy</text>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Art direction
|
||||
|
||||
- **Canvas:** `viewBox="0 0 512 512"`, square. Keep the `style="height:512px;width:512px"`
|
||||
attribute the other assets use.
|
||||
- **Reads small.** Icons render at roughly **20-34 px** — `.stack-type-icon` 22,
|
||||
`.stack-unit-icon` 26, `.bt-top .building-icon` 34, `.row-card .building-icon`
|
||||
34, `.control-icon` 20, `.value .value-icon` 13. A single strong silhouette
|
||||
beats detail; avoid thin strokes, fine text and tiny gradients.
|
||||
- **Flat, high-contrast vector** in the game's palette (see the existing icons
|
||||
for the house style, e.g. `icon_battleship.svg`, `icon_jet_fighter.svg`,
|
||||
`icon_modern_infantry.svg`). A dark plate behind the subject helps it read on
|
||||
the map and in the panel, as the dummy does.
|
||||
- **No text** in real icons. Transparent or plate background, consistent with
|
||||
neighbours.
|
||||
- **Specifics to capture:** a nuclear trefoil for the enrichment centre; a
|
||||
country silhouette / masked figure for the spy; a fish-like hull for the
|
||||
submarine; a rocket with fins for the missile and an intercontinental variant
|
||||
for the ICBM; a globe/bust for human vs. a monitor/chip for computer; a
|
||||
geyser for geothermal; an offshore turbine on its platform vs. an onshore
|
||||
turbine.
|
||||
|
||||
---
|
||||
|
||||
## 5. Workflow per icon
|
||||
|
||||
1. Draw the SVG (any editor; keep it hand-editable, no embedded bitmaps).
|
||||
2. Save as `client/assets/icon_<name>.svg` — **same filename**, so no data file
|
||||
change is needed. If a filename must change, update the `icon` field in the
|
||||
matching `shared/data/*.js` entry (units, buildings, resources, improvements,
|
||||
technologies, ...).
|
||||
3. Build a small gallery HTML (below) linking `client/css/style.css`, showing
|
||||
every changed icon at the real sizes, on the real backgrounds.
|
||||
4. Open it in Firefox and show the user; only commit once approved.
|
||||
5. Run `node tests/run_tests.js --file data_test.js` (it asserts some icons are
|
||||
`.svg`) and any suite that references the icon.
|
||||
|
||||
### Gallery skeleton
|
||||
|
||||
```html
|
||||
<!doctype html><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="file:///home/adrien/strategy-game/client/css/style.css">
|
||||
<body style="background:#05070c;color:#e8eef7;font:14px sans-serif;padding:24px">
|
||||
<h1>Sizes: 20 / 22 / 26 / 34 px</h1>
|
||||
<div style="display:flex;gap:24px;align-items:flex-end">
|
||||
<!-- per icon: <img src="file:///home/.../client/assets/icon_x.svg" style="width:34px"> etc. -->
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add / extend
|
||||
|
||||
- `tests/data_test.js` already checks a few `icon` fields end in `.svg` and are
|
||||
non-empty; extend it to cover the newly drawn assets (units, buildings,
|
||||
resources) so a missing/renamed file is caught.
|
||||
- No pixel tests — icons are reviewed by eye, per the workflow above.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **The filename is the contract.** Most data entries reference the file by
|
||||
string; renaming without editing the data leaves a broken `<img>` that only
|
||||
shows in the browser, not in tests.
|
||||
- **Case and path.** Assets are served from the repo root as `/client/...`;
|
||||
`client/js` references them as `assets/icon_x.svg` relative to the page. Keep
|
||||
lowercase `snake_case` filenames.
|
||||
- **Small-size legibility** is the acceptance test, not how good it looks at
|
||||
512px. Zoom the gallery down to 20px before judging.
|
||||
- **Nix packaging** copies the tree, so no manifest needs updating — but a new
|
||||
file must be committed for the deployed build to include it.
|
||||
- A serving cache may hide a replaced file; hard-reload when checking in-game.
|
||||
|
||||
---
|
||||
|
||||
## 8. Relevant files
|
||||
|
||||
- `client/assets/` — all icons (this is where art goes).
|
||||
- `shared/data/units.js`, `buildings.js`, `resources.js`, `improvements.js`,
|
||||
`technologies.js` — the `icon:` references.
|
||||
- `client/css/style.css` — icon display sizes.
|
||||
- `client/index.html` — where icons appear in the HUD/modals.
|
||||
- `tests/data_test.js` — icon sanity checks.
|
||||
|
||||
## 9. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
|
||||
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md` (repo root).
|
||||
@@ -0,0 +1,602 @@
|
||||
# Intelligence — design brief
|
||||
|
||||
The **Intelligence** feature: its tab, the spy unit, covert actions (sabotage,
|
||||
city spying, comms spying), counter-intelligence and spy satellites. This
|
||||
document began as the design brief and now also records what was built. All
|
||||
phases are implemented; the status list in §2 is the authoritative summary and
|
||||
§5 records the design decisions each open question settled to.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model; `server/game_server.js` (`GameServer`) owns one and
|
||||
validates orders; `server/server.js` is the transport.
|
||||
- **Shared logic runs in both Node and the browser.** `shared/` must stay
|
||||
framework-free: no Node built-ins, no DOM. `server/websocket.js` is the only
|
||||
place Node built-ins are expected. The browser is a view
|
||||
(`client/js/map_view.js` and friends) that rebuilds terrain from the shared
|
||||
seed and renders snapshots.
|
||||
- **GameState is composed of mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and `Object.assign`s its methods onto the
|
||||
prototype (world, territory, economy, movement, combat, air, status,
|
||||
diplomacy, orders, visibility, serialization, politics, treaties, wmd, ...).
|
||||
A new method file must be imported **and** added to that `Object.assign`.
|
||||
- **Data-driven content** lives in `shared/data/` (one module per topic:
|
||||
`units.js`, `buildings.js`, `technologies.js`, `relations.js`, ...).
|
||||
`shared/data.js` is a barrel — import from it, edit the file under
|
||||
`shared/data/`.
|
||||
- **Client mirroring.** Movement, pathfinding and movement-cost changes must be
|
||||
mirrored between `shared/game_state/movement.js` and the browser's prediction
|
||||
in `client/js/map_view/motion.js`, or prediction fights the server.
|
||||
- **Snapshot performance.** `_broadcastState` rebuilds the snapshot whenever the
|
||||
state is dirty (orders, hourly ticks). Large collections are versioned in
|
||||
`DELTA_COLLECTIONS` (`server/game_server.js`) — if you add a big snapshot
|
||||
collection, pair it with a version counter or it is re-sent whole on every
|
||||
broadcast. Walks are memoised against version counters (`_populationVersion`,
|
||||
`_territoryVersion`, `_modifiersVersion`, `_tileImprovementVersion`,
|
||||
`_gdpEpoch`, `_visibleVersion`, ...).
|
||||
|
||||
### Commands
|
||||
|
||||
- Install dev deps once: `npm install` (only `c8` and `jsdom` for tests).
|
||||
- Run one suite: `node tests/run_tests.js --file <name>.js` (note the `.js`).
|
||||
**Never run the full suite during development** — the commit hook does that.
|
||||
- Server + client locally:
|
||||
`node server/server.js --port 27015 --bind 127.0.0.1`, then open
|
||||
`http://127.0.0.1:27015/`. Add `--testing` for the free/instant buttons.
|
||||
- If `node` is missing: `nix-shell -p nodejs --run "node tests/run_tests.js"`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent for `.js`. No new libraries (only vendored jQuery under
|
||||
`client/vendor/`).
|
||||
- Missing icon → add a dummy SVG reading `dummy icon - <name>`.
|
||||
- Tests live in `tests/<topic>_test.js`, extend `TestCase`
|
||||
(`tests/framework/`), and are discovered automatically. Fixtures:
|
||||
`tests/framework/helpers.js` (`smallState()`, `defaultState()`,
|
||||
`adjacentLand()`, `seaTile()`, `grantBuilding()`).
|
||||
- **Visual changes** are reviewed by eye before commit: build a minimal
|
||||
standalone HTML example, link `client/css/style.css`, copy in the few assets
|
||||
needed, and **open it in Firefox** (`firefox <path>`). Do not screenshot.
|
||||
- Commit messages are a single evocative sentence in the imperative.
|
||||
Only commit when the user asks; the pre-commit hook runs the whole suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the feature stands
|
||||
|
||||
Status legend: `[x]` done, `[~]` done with a simplification, `[ ]` open.
|
||||
|
||||
- [x] Intelligence tab exists, split into **Spies** and **Propaganda** sub-tabs.
|
||||
Propaganda owns the campaigns; Spies owns the viewer's spy **roster** (with
|
||||
a **Locate** control).
|
||||
- [x] Spy unit core: data, gating, invisibility, 1-tile vision, travel through
|
||||
foreign territory.
|
||||
- [x] Spy operation buttons live in the **unit stack panel** (one button per
|
||||
operation, from `SPY_OPERATIONS`), shown when a spy is selected.
|
||||
- [x] Sabotage action: a spy on **foreign land** zeroes that tile's production
|
||||
for a week. The victim is notified; blame falls on the author, a chosen
|
||||
scapegoat, or no one, and the victim cannot tell which.
|
||||
- [x] City spying: a spy beside a foreign city files a report on its people,
|
||||
buildings and garrison. Counter-intelligence can falsify it.
|
||||
- [x] Comms spying: a spy beside a foreign capital intercepts the last private
|
||||
messages sent from and to that nation. Counter-intelligence can garble
|
||||
them and swap the correspondents.
|
||||
- [x] Counter-intelligence mechanics: the unlock-only technology now shifts the
|
||||
blame roll and distorts incoming reports and intercepts.
|
||||
- [x] Spies are **single-use**: a covert operation spends the whole acting
|
||||
stack, and each spy past the first blunts the target's counter-
|
||||
intelligence, so a bigger team is the one lever against it.
|
||||
- [x] Spy satellites + space launch centre: the building gates a launch, everyone
|
||||
is told, and each satellite uncovers a random 2-tile patch every two days.
|
||||
- [x] Foreign intelligence may be warned that a nation is enriching uranium
|
||||
(a chance roll when the first enrichment centre is raised).
|
||||
|
||||
### What is already built
|
||||
|
||||
**Tab.** `client/index.html` has a `<button class="tab" data-tab="intelligence">
|
||||
Intelligence</button>` after Politics and a `#tab-intelligence` page with
|
||||
`#intelligence-sub`, `#intelligence-subtabs` and the two views. `client/js/modals/nation.js`
|
||||
builds and refreshes it (`_renderIntelligence`, `_buildIntelligence`,
|
||||
`_refreshIntelligence`), and `NationModal.show/refresh` take a trailing
|
||||
`intelligence` argument. `client/js/game_screen/panels.js` supplies it via
|
||||
`_intelligenceInfo()` → `{ civs, localCiv, totalHours, campaigns, opinions }`.
|
||||
|
||||
**Propaganda moved.** The campaign UI (`_fillCampaigns`, `_buildCampaignRow`,
|
||||
`_buildCampaignForm`, `_syncCampaignReadout`, `_syncCampaignForm`) now lives on
|
||||
the Intelligence tab's **Propaganda** sub-tab; the DOM state uses
|
||||
`this.$intelligenceCampaigns` and
|
||||
`this._intelligenceState.campaign`. Politics keeps populations, migrations,
|
||||
minority policies and the opinion breakdown. `#campaignSignature` /
|
||||
`#campaignRows` remain the rebuild guards.
|
||||
|
||||
**Sub-tabs (Phase A).** The Intelligence tab is split into two sub-tabs,
|
||||
**Spies** and **Propaganda**, mirroring the Economy and Resources tabs
|
||||
(`#intelligence-subtabs` `.subtabs`, `_buildIntelligenceSubtabs` /
|
||||
`_setIntelligenceView`, and the two `.modal-body` views
|
||||
`#intelligence-spies-view` / `#intelligence-propaganda-view` in
|
||||
`client/index.html`). Spies opens first; the campaigns moved into the
|
||||
Propaganda view. Switching only toggles `.hidden`, so a snapshot refresh never
|
||||
tears a view down.
|
||||
|
||||
**Spy controls (Phase A).** The Spies view holds one card, **Your spies**
|
||||
(`_buildSpiesCard`, `_fillSpies`, `_hasResearched` in
|
||||
`client/js/modals/nation.js`). The roster rebuilds only when a spy's id, tile or
|
||||
location label changes (`_spySignature`); it names each spy by position ("Spy 1")
|
||||
and its whereabouts ("at Paris", "in home territory", "in Britain", "in
|
||||
unclaimed territory"). A **Locate** button calls `onLocateUnit(id)`, wired to
|
||||
`_locateUnit` in `client/js/game_screen/panels.js`, which centres the map,
|
||||
selects the spy and closes the panel. `_intelligenceInfo()` supplies the roster
|
||||
as `spies`.
|
||||
|
||||
**Covert operations are unit actions, not tab content.** They live in the unit
|
||||
panel (`#unit-spy-actions`, a row under the pillage/trench/attack buttons),
|
||||
because an operation is something a *spy* does, tied to the selected stack.
|
||||
`_updateSpyActions(units)` in `client/js/game_screen/panels.js` shows the row
|
||||
only when the selection is all spies; `_buildSpyActionButtons()` fills it once
|
||||
from `SPY_OPERATIONS`. Sabotage enables when the stack stands on a foreign land
|
||||
tile (`_canSabotage`, mirroring `GameState.canSabotage`); the other two stay
|
||||
disabled ("Not yet available") until they are wired. The catalogue lives in
|
||||
`shared/data/intelligence.js` (`SPY_OPERATIONS`, with the id constants
|
||||
`SPY_SABOTAGE` / `SPY_CITY` / `SPY_COMMS`), re-exported from `shared/data.js`.
|
||||
|
||||
**Sabotage (Phase B).** A new model mixin `shared/game_state/intelligence.js`
|
||||
owns the covert operations; only `canSabotage` / `requestSabotage` /
|
||||
`isTileSabotaged` / `_tickSabotage` exist so far. The target is **the tile the
|
||||
spy stands on**, which must be foreign land (`civAt >= 0`, not the spy's own);
|
||||
no map target-picking is needed. `requestSabotage` records the tile in
|
||||
`GameState.tileSabotage` (key → the hour the block ends, `SABOTAGE.blockDays`
|
||||
= 7) and drops the tile's GDP memo. The block reaches the economy through
|
||||
`productionFactors`' new `sabotaged` flag: a last `×0` factor that zeroes the
|
||||
tile's per-head production (and the client's tile readout), mirroring how
|
||||
pillage and trenches already mark a tile. `_tickSabotage` runs in `tickHour` and
|
||||
drops lapsed blocks, restoring the tile. The block list ships as
|
||||
`tileSabotage: [[x, y, untilHour], ...]`, rebuilt client-side into
|
||||
`GameScreen._tileSabotage`. The victim gets a `NEWS_SABOTAGE` feed line
|
||||
(`describeNews`: "Saboteurs have crippled production in <region>") and, if the
|
||||
viewer is the victim, a notification; `suspect` names the author, a scapegoat, or is null.
|
||||
The order is `{ type: "sabotage", units, frame? }`, validated in
|
||||
`GameServer._handleSabotage` and sent by `_sabotageSelected`. The acting spies
|
||||
are spent (`_spendSpies`): a spy is single-use, so it is removed quietly (no
|
||||
news, no notification). A larger acting stack lowers the victim's chance of
|
||||
identifying the author (`_effectiveCounterIntelligence`).
|
||||
|
||||
**Counter-intelligence and blame (Phase C).** `intelligence.js` adds the two
|
||||
level helpers `_intelligenceLevel` / `_counterIntelligenceLevel` (0 or 1, read
|
||||
from the researched set via `hasTechnology`) and `_blameCulprit`. The blame roll
|
||||
starts at `ESPIONAGE.baseIdentifyChance`, rises with the victim's
|
||||
counter-intelligence and falls with the author's agencies; a `frame` scapegoat
|
||||
(another nation, chosen in the unit panel's **Blame** picker) sticks with
|
||||
`ESPIONAGE.frameChance` when the author is not identified. A correct blame and a
|
||||
successful false flag are the same `suspect` field, so the victim cannot tell
|
||||
them apart.
|
||||
|
||||
**City and comms spying (Phases B/D).** `canCitySpy`/`requestCitySpy` and
|
||||
`canCommsSpy`/`requestCommsSpy` share `_spyStackAtOneTile` and `_spyWatches`: a
|
||||
spy can never enter a hostile city, so it watches from the city's own tile or a
|
||||
neighbour. Reports are stored per nation in `GameState.spyReports` (capped at
|
||||
`ESPIONAGE.reportLimit`) and shipped only to their owner in `viewerSnapshot` as
|
||||
`spyReports`; the chat log they are drawn from never ships. A city report lists
|
||||
the population, building levels and garrison; with the target's
|
||||
counter-intelligence, values are jittered or nudged (`_jitterCount`). A comms
|
||||
report lists the last `ESPIONAGE.interceptCount` messages involving the target
|
||||
(`GameState.messages`, fed by `recordChat`); counter-intelligence garbles the
|
||||
text and swaps sender/receiver (`_garbleMessage`/`_scramble`). Both appear in
|
||||
the Intelligence tab's **Intelligence reports** card. Both spend the acting
|
||||
spies (a spy is single-use) and a larger stack blunts the target's
|
||||
counter-intelligence when it decides whether to falsify or garble the report.
|
||||
The target picker reuses
|
||||
the ranged **Attack** overlay: `_toggleSpyOperation` arms it with
|
||||
`operation: "spy_city"/"spy_comms"` and `_chooseTarget` dispatches on it.
|
||||
|
||||
**Comms store.** `server/server.js` `case "chat"` now takes an optional `to`
|
||||
nation, telling one peer (the public channel when absent), and records every
|
||||
line through `GameState.recordChat`. The client's chat gained a recipient
|
||||
`<select id="chat-target">`; private lines are marked in the log. This is the
|
||||
one piece of the design that needed a new server-side store (question 6).
|
||||
|
||||
**Spy satellites (Phase E).** A new `space_launch_center` building (`mechanic:
|
||||
"space_launch"`, extremely expensive, dummy icon) gives a nation one launch pad
|
||||
per level. `requestSatellite(civ, city)` checks the city holds the centre,
|
||||
pushes a `{ civ, launchedHours, nextRevealHour }` record, and publishes
|
||||
`NEWS_SATELLITE` to everyone. `_tickSatellites` runs in `tickHour` and calls
|
||||
`_revealSatelliteChunk`, which adds a random patch of `SATELLITE.revealRadius`
|
||||
tiles to the owner's `explored` set every `SATELLITE.revealEveryDays` days and
|
||||
marks visibility dirty. Launches and next-sweep times show in the tab's **Spy
|
||||
satellites** card.
|
||||
|
||||
**Uranium warning (Phase F).** `_completeBuilding` calls `_warnOfEnrichment`
|
||||
when a nation's first uranium enrichment centre is raised; with
|
||||
`ESPIONAGE.enrichmentWarningChance` it publishes `NEWS_ENRICHMENT`, which
|
||||
`describeNews` renders as an intelligence warning.
|
||||
|
||||
**Spy unit.** `shared/data/units.js`:
|
||||
|
||||
```js
|
||||
{
|
||||
id: "spy", advanced: true, name: "Spy", maxHp: 100, cost: 250_000_000,
|
||||
materialUpkeep: { steel: 0, energy: 2_000, hightech: 3 }, // mostly high-tech
|
||||
moveable: true, speed: 0.5, military: false, attack: 0, defense: 0,
|
||||
range: 0, vision: 1, spy: true,
|
||||
traversableTerrains: ["Land"],
|
||||
requiresTechnology: "intelligence_agencies",
|
||||
icon: "icon_spy.svg",
|
||||
}
|
||||
```
|
||||
|
||||
`spy` is in `TRAINABLE_UNIT_IDS`. Visibility: `shared/game_state/visibility.js`
|
||||
`_hiddenEnemyUnits` hides any unit with `proto.spy` from every viewer except its
|
||||
owner. Movement: `shared/game_state/movement.js` `_canUnitEnter` lets a spy
|
||||
cross foreign territory (mirrored in `client/js/map_view/motion.js`
|
||||
`_canEnter`), so the foreign-territory wall does not apply to it.
|
||||
|
||||
**Technologies.** `shared/data/technologies.js`:
|
||||
|
||||
```js
|
||||
{ id: "intelligence_agencies", name: "Intelligence Agencies", theme: "society",
|
||||
cost: 250, prerequisites: ["scientific_method"], unlocks: true, effects: [] },
|
||||
{ id: "counter_intelligence", name: "Counter-Intelligence", theme: "society",
|
||||
cost: 1_500, prerequisites: ["intelligence_agencies"], unlocks: true, effects: [] },
|
||||
```
|
||||
|
||||
`unlocks: true` marks a technology whose value is content, not a stat;
|
||||
`tests/data_test.js` exempts those from the "every tech has effects" rule.
|
||||
`counter_intelligence` currently has **no mechanics** — wiring it is part of
|
||||
this feature.
|
||||
|
||||
**Icon.** `client/assets/icon_spy.svg` is a placeholder (`dummy icon - spy`).
|
||||
|
||||
**Tests.** `tests/spy_test.js` (4) covers the proto/gating, the covert-operation
|
||||
catalogue, invisibility, and foreign-territory travel. `tests/game_screen_test.js` has
|
||||
`test_intelligence_tab_hosts_propaganda` and the campaign tests now drive
|
||||
`#tab-intelligence`. `data_test.js` knows about `unlocks`.
|
||||
|
||||
**Preview.** A standalone mock of the tab is at
|
||||
`/tmp/tismo-preview/intelligence.html` (links the real `style.css`, hand-filled
|
||||
representative data, a faux top-left summary to get the modal's 104px dock
|
||||
offset right); `/tmp/tismo-preview/intelligence-phase-a.html` adds the sub-tabs and the spies
|
||||
roster; `/tmp/tismo-preview/intelligence-spy-actions.html` shows the spy action
|
||||
buttons in the unit panel. All throwaway — do not commit them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Requirements (from `ROADMAP.md`, restated)
|
||||
|
||||
> `[ ]` Gets its own tab
|
||||
> - Move propaganda operations to intelligence
|
||||
> `[ ]` Spy unit
|
||||
> - Invisible to all other players, by default
|
||||
> - 1 tile-radius vision
|
||||
> - Mostly high-tech cost
|
||||
> - Can move into enemy territory
|
||||
> - `[ ]` Sabotage action:
|
||||
> - Blocks production on a given tile for a week, performed while in *any*
|
||||
> country
|
||||
> - Perpetrator is given a choice to try and blame someone else
|
||||
> - The victim gets a notification; depending on intelligence and
|
||||
> counter-intelligence level, someone or no one will be blamed.
|
||||
> - No way for the player to differentiate between a correct blame
|
||||
> assignment or a successful false flag
|
||||
> - `[ ]` City spying
|
||||
> - Perpetrator gets a report on a city's status, building levels, etc.
|
||||
> Counter-intelligence can make the report contain false information
|
||||
> - `[ ]` Comms spying
|
||||
> - Done in the capital of a country
|
||||
> - Reveals the N last private messages sent from and to that player
|
||||
> - Counter-intelligence may garble the messages, or change the sender and
|
||||
> receiver of messages
|
||||
> `[ ]` Counter-intelligence tech
|
||||
> - `[ ]` Extremely expensive
|
||||
> `[ ]` Spy satellites
|
||||
> - `[ ]` New space launch center
|
||||
> - Extremely expensive
|
||||
> - `[ ]` allow "send spy satellite" action
|
||||
> - Everyone is made aware that a satellite was launched
|
||||
> - Reveal a random 2-tile-radius chunk of the map every two days
|
||||
|
||||
Plus the WMD follow-up:
|
||||
|
||||
> `[ ]` Foreign intelligence may be warned that you are enriching uranium
|
||||
|
||||
---
|
||||
|
||||
## 4. Design decisions taken so far
|
||||
|
||||
1. **Propaganda lives on the Intelligence tab; Politics keeps the rest.**
|
||||
Campaigns are a covert action, so they belong with spies.
|
||||
2. **The spy is `military: false`** — it never joins a melee, never captures a
|
||||
city, and right-click/attack rules treat it like a worker. It fights nothing.
|
||||
3. **Spy invisibility is unconditional for now.** Unlike a submarine (revealed
|
||||
by an adjacent naval unit), a spy is hidden from every other nation. A
|
||||
detected/revealed state, driven by counter-intelligence, is deliberately left
|
||||
open (see §6).
|
||||
4. **A spy crosses foreign territory** as a through-route, not only as a goal.
|
||||
This is the one exception to the "foreign land is a wall" rule and is mirrored
|
||||
client-side so prediction agrees. A spy may also **board a friendly launch**
|
||||
like infantry (`requestEmbark` accepts `spy` as well as `infantry`, and
|
||||
`_tryTransportOrder` mirrors it), so a land-locked spy can reach an island.
|
||||
5. **New technologies may be unlock-only** (`unlocks: true`), so
|
||||
`intelligence_agencies` and `counter_intelligence` carry no numeric effect.
|
||||
The `data_test` rule was relaxed accordingly.
|
||||
6. **The spy is gated by `intelligence_agencies`** (not by a building), because
|
||||
the roadmap specifies no spy building. `counter_intelligence` is deliberately
|
||||
"extremely expensive" (1,500 base cost × the 100× research multiplier =
|
||||
150,000 points). A gated unit is not hidden from the city train list: every
|
||||
trainable unit shows, locked ones as a dimmed card carrying
|
||||
"Requires Intelligence Agencies" (`_trainableUnits` / `_unitRequirement` in
|
||||
`panels.js`, the `locked` branch of `city.js` `_renderTrainList`), so a player
|
||||
discovers the spy before researching the agency.
|
||||
7. **Spy operations are unit actions in the unit stack panel.** An operation
|
||||
belongs to a spy, so the buttons sit beside the ranged **Attack** in the unit
|
||||
panel (`#unit-spy-actions`), not in the Intelligence tab. The tab keeps only
|
||||
the roster (with **Locate**). Sabotage acts on the spy's own tile, so it needs
|
||||
no target picker; a future operation that points at a distant tile can still
|
||||
arm the same overlay the **Attack** uses. Unwired operations stay disabled
|
||||
("Not yet available").
|
||||
8. **Sabotage zeroes the tile's production**, using the same `productionFactors`
|
||||
mechanism as pillaging and trenches rather than a second production model.
|
||||
It lasts a week (`SABOTAGE.blockDays`). It leaves the physical resource trade
|
||||
alone for now — as pillaging does — and the culprit is not yet named; the
|
||||
blame roll and false-flag choice belong to counter-intelligence (Phase C).
|
||||
9. **A spy is single-use.** Every covert operation (sabotage, city spying, comms
|
||||
spying) removes the whole acting stack through `_spendSpies` — quietly, with
|
||||
no news line and no notice to the owner or the victim. The only reason to send
|
||||
more than one spy is to overwhelm counter-intelligence: each spy past the
|
||||
first cancels `ESPIONAGE.agentsPerCounter` of the target's service
|
||||
(`_effectiveCounterIntelligence`), so three spies beat a full service outright.
|
||||
The operation button's tooltip says the spies are spent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open design questions (settle these before coding)
|
||||
|
||||
1. ~~**Where do spy actions live in the UI?**~~ **Settled (Phase A): the unit
|
||||
stack panel, plus map targeting.** Operations are spy unit actions, so their
|
||||
buttons live in the unit panel (`#unit-spy-actions`) with the ranged
|
||||
**Attack**; Phase B arms the map's target-picking overlay (the **Attack**
|
||||
pattern: `_toggleTargeting`, `map.beginTargeting`, `#layer-targeting`,
|
||||
`map.onTargetChosen`) once an operation is chosen. The Intelligence tab keeps
|
||||
only the spy roster (decision 7).
|
||||
2. ~~**How is a job resolved and paid for?**~~ **Settled: every operation is
|
||||
free, instantaneous and spends the acting spies.** A spy is single-use: the
|
||||
whole selected stack is removed quietly, with no news and no notice. The cost
|
||||
is those spies and the risk of being blamed; a bigger stack blunts the
|
||||
target's counter-intelligence (`ESPIONAGE.agentsPerCounter`), so stacking is
|
||||
the one lever against it. No cooldown.
|
||||
3. ~~**Sabotage specifics.**~~ **Settled (Phase B):** the spy must stand **on**
|
||||
the target tile (foreign land); the block is the tile's production, zeroed
|
||||
for `SABOTAGE.blockDays` days through `productionFactors`. Stored as
|
||||
`GameState.tileSabotage` (key → end hour), pruned on the hourly tick and
|
||||
shipped in the snapshot, so it survives a reload. The physical resource trade
|
||||
is not (yet) affected — see decision 8.
|
||||
4. ~~**Blame model.**~~ **Settled (Phase C):** `_blameCulprit` returns the author
|
||||
(identified), the chosen scapegoat (false flag), or null. The message carries
|
||||
only `suspect`, so a correct blame and a successful false flag read
|
||||
identically. The framed nation has no special reaction yet — the blame is
|
||||
only a news line.
|
||||
5. ~~**City spying report.**~~ **Settled (Phase B):** a one-off report listing
|
||||
population, building levels and garrison, kept in the owner's report list.
|
||||
Counter-intelligence jitters the population and nudges building/garrison
|
||||
counts by one, never marking the report as suspect.
|
||||
6. ~~**Comms spying needs stored messages.**~~ **Settled (Phase D):** a server
|
||||
message store exists (`GameState.messages`, fed by `recordChat`, capped at
|
||||
`ESPIONAGE.messageLogLimit`) and chat gained private recipients. Comms spying
|
||||
reads the last `ESPIONAGE.interceptCount` messages involving the target.
|
||||
7. ~~**Counter-intelligence mechanics.**~~ **Settled (Phase C):** one permanent
|
||||
unlock tech, read through `_counterIntelligenceLevel(civ)`. It raises blame
|
||||
accuracy, falsifies city reports and garbles comms. It does not hide spies —
|
||||
they stay unconditionally invisible (decision 3).
|
||||
8. ~~**Satellites.**~~ **Settled (Phase E):** one satellite per centre level,
|
||||
launched from a city holding the centre, free once built; the launch is public
|
||||
news. Each satellite uncovers a random 2-tile patch of **permanently
|
||||
explored** land every two days, forever, and multiple satellites stack.
|
||||
9. ~~**Uranium warning.**~~ **Settled (Phase F):** an automatic chance roll when
|
||||
a nation's first enrichment centre is completed publishes a world news
|
||||
warning; no spy needs to be present.
|
||||
10. ~~**Spy visibility on the map for its owner.**~~ **Settled:** the owner always
|
||||
sees its own spy. A spy cannot enter a hostile city tile (the movement rules
|
||||
keep non-military units out), so city and comms spying watch from the city
|
||||
tile's own hex or a neighbour.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested implementation plan
|
||||
|
||||
**Phase A — tab completion. [done]** Added the spy-controls section to the
|
||||
Intelligence tab (the "Your spies" roster with Locate, and the "Covert
|
||||
operations" list) and settled question 1: the tab is the control surface, the map
|
||||
overlay will pick targets. Added `shared/data/intelligence.js` (the operation
|
||||
catalogue) and the `spies`/`spyOperations` fields of `_intelligenceInfo()`.
|
||||
|
||||
**Phase B — one covert action end to end. [done: sabotage]** Picked sabotage:
|
||||
it is self-contained (no private report UI) and its block is world state. Added
|
||||
the `sabotage` order + `GameServer._handleSabotage` validation, the
|
||||
`tileSabotage` snapshot field, the `NEWS_SABOTAGE` notification, the
|
||||
`productionFactors` block and the client mirror. City spying is next. The
|
||||
target-picking overlay was not needed because sabotage acts on the spy's own
|
||||
tile.
|
||||
|
||||
**Phase C — counter-intelligence. [done]** `_intelligenceLevel` /
|
||||
`_counterIntelligenceLevel` feed `_blameCulprit`, report falsification and
|
||||
comms garbling.
|
||||
|
||||
**Phase D — comms spying. [done]** The server records chat through
|
||||
`GameState.recordChat` (with private recipients), and `requestCommsSpy` files an
|
||||
interception report, garbled when the target has counter-intelligence.
|
||||
|
||||
**Phase E — satellites. [done]** `space_launch_center`, `requestSatellite`, the
|
||||
`_tickSatellites` reveal cadence and the `NEWS_SATELLITE` launch notice.
|
||||
|
||||
**Phase F — uranium warning. [done]** `_completeBuilding` → `_warnOfEnrichment`
|
||||
→ `NEWS_ENRICHMENT`, a chance roll on the first enrichment centre.
|
||||
|
||||
---
|
||||
|
||||
## 7. Data-model sketch
|
||||
|
||||
Names for discussion; keep them cheap to serialise.
|
||||
|
||||
- Operation catalogue: `shared/data/intelligence.js` `SPY_OPERATIONS`
|
||||
(`{ id, name, description }`), with the id constants `SPY_SABOTAGE`,
|
||||
`SPY_CITY`, `SPY_COMMS`. The server will validate orders against these ids.
|
||||
- `GameState.spies` — not needed; spies are ordinary units with `proto.spy`.
|
||||
Single-use is not stored either: an operation calls `_spendSpies` on the
|
||||
acting units at once.
|
||||
- Sabotage block (**done**): `GameState.tileSabotage` (key → end hour), pruned in
|
||||
`_tickSabotage` and read by `isTileSabotaged` from `productionFactors`. Shipped
|
||||
as `tileSabotage: [[x, y, untilHour], ...]`; small enough not to need a version
|
||||
counter.
|
||||
- Reports (**done**): `GameState.spyReports` (civ → capped list) holding city
|
||||
dossiers and comms intercepts; shipped to their owner only in `viewerSnapshot`.
|
||||
- Comms store (**done**): `GameState.messages`, `{ from, to, text, hours }`,
|
||||
capped at `ESPIONAGE.messageLogLimit`; never serialised.
|
||||
- Satellites (**done**): `GameState.satellites` as
|
||||
`{ civ, launchedHours, nextRevealHour }`; the reveal adds to `explored` and
|
||||
marks visibility dirty. Shipped whole (tiny) as `satellites`.
|
||||
- Counter-intelligence: `_counterIntelligenceLevel(civ)` read from the researched
|
||||
set; no stored state, one permanent tech. `_effectiveCounterIntelligence(civ,
|
||||
agents)` scales it down by the acting stack, and is the one place the team size
|
||||
touches the blame roll, report falsification and comms garbling.
|
||||
|
||||
No new large snapshot collection was added, so no `DELTA_COLLECTIONS` entry or
|
||||
version counter is needed. `covertOps` was never needed — each operation acts
|
||||
immediately rather than as a stored job.
|
||||
|
||||
---
|
||||
|
||||
## 8. Server / orders / client plumbing
|
||||
|
||||
- New orders go through `GameServer` validation in `server/game_server.js`
|
||||
(see how `_handleAttack`, `requestTrain`, treaties are handled) and land on
|
||||
`GameState` methods. Never trust client-only flags outside `--testing`.
|
||||
- News/notifications: add a `NEWS_<THING>` constant in
|
||||
`shared/data/relations.js` (the others live there) and dispatch it from the
|
||||
relevant `GameState` method; the client feed renders it via
|
||||
`client/js/modals/format.js` `describeNews`, and the game screen pushes alert
|
||||
lines in `client/js/game_screen/panels.js` `_updateFeed`/`_pushNews`.
|
||||
A clickable feed line (like a treaty offer) is the model for "someone blamed X
|
||||
for the sabotage".
|
||||
- Client actions: wire a `map.on<Action>Chosen` callback for the target-picking
|
||||
overlay (mirror the ranged Attack flow: `client/js/map_view/motion.js`
|
||||
`beginTargeting`/`_drawRangeBoundary`, `client/js/map_view/input.js`
|
||||
`_onLeftClick`/`_onRightClick`, `client/js/game_screen.js`
|
||||
`onTargetChosen`/`onTargetCancelled`, `client/js/game_screen/panels.js`
|
||||
`_toggleTargeting`/`_chooseTarget`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Tests
|
||||
|
||||
Done in Phase A:
|
||||
|
||||
- `tests/spy_test.js` `test_the_covert_operations_are_well_formed`: the
|
||||
`SPY_OPERATIONS` catalogue has unique, non-empty ids/names/descriptions and
|
||||
carries all three operation ids.
|
||||
- `tests/game_screen_test.js` `test_intelligence_subtabs_split_spies_and_propaganda`:
|
||||
the two sub-tabs exist, Spies opens first and switching toggles the views.
|
||||
- `tests/game_screen_test.js` `test_intelligence_tab_lists_the_viewers_spies_and_locates_them`:
|
||||
a spawned spy shows in the roster with its city and a Locate control, and
|
||||
clicking it selects the spy and closes the panel.
|
||||
- `tests/game_screen_test.js` `test_spy_actions_live_in_the_unit_panel`: selecting
|
||||
a spy reveals `#unit-spy-actions` with one button per `SPY_OPERATIONS` entry,
|
||||
selecting a soldier hides it, and the tab carries no operations card.
|
||||
- The campaign tests click the **Propaganda** sub-tab before reaching for a
|
||||
campaign control.
|
||||
|
||||
Done in Phases B–F:
|
||||
|
||||
- `tests/intelligence_test.js` (11 tests): sabotage zeroes and restores a foreign
|
||||
tile; own land and non-spies are refused; the blame roll pins the author, a
|
||||
framed nation, or no one (forced RNG sequences); counter-intelligence raises
|
||||
identification; city reports are accurate, then falsified when the target has
|
||||
counter-intelligence; comms are intercepted clean, then garbled; a satellite
|
||||
launches, is capped by centre levels, and uncovers explored tiles on its
|
||||
cadence; the enrichment warning fires on a chance roll.
|
||||
- `tests/game_screen_test.js` adds `test_city_spying_arms_targeting_and_sends`
|
||||
(the city-spy button arms the target overlay and sends the order) and
|
||||
`test_launch_satellite_button_sends` (the tab's launch control sends the order).
|
||||
- `tests/game_server_test.js` validates the `spy_city` and `launch_satellite`
|
||||
orders and rejects another nation's spy.
|
||||
- `tests/spy_test.js` grows: a spy cannot capture, cannot fight, and cannot
|
||||
enter a hostile city tile.
|
||||
- `tests/intelligence_test.js` covers single-use: an operation destroys the
|
||||
acting spies and no others; `_effectiveCounterIntelligence` steps 1 → 0.5 → 0
|
||||
as the team grows; a three-spy team escapes a blame roll a lone spy would
|
||||
fail; and a three-spy city report stays honest against a counter-intelligence
|
||||
service.
|
||||
- Client: `tests/map_view_input_test.js` for target-picking of a spy action.
|
||||
|
||||
Determinism: `state._random = () => value` is the established way to pin RNG
|
||||
outcomes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Gotchas
|
||||
|
||||
- **Chat is now stored** in `GameState.messages` (fed by `recordChat`); it is
|
||||
never serialised, so it stays on the server.
|
||||
- **Invisibility and snapshot size.** `_hiddenEnemyUnits` is consulted on the
|
||||
snapshot path; keep the check cheap and version-aware.
|
||||
- **Prediction mirroring** for any movement/visibility change (server
|
||||
`movement.js` ↔ client `motion.js`).
|
||||
- **Do not memoise `getCityEconomy` outside a snapshot** and keep new snapshot
|
||||
walks version-keyed (see the AGENTS.md notes on the snapshot path).
|
||||
- **Tech-tree ripple.** Adding technologies changes nothing count-based
|
||||
(`TECHNOLOGIES.length` is used by tests) but does change the tree; run
|
||||
`game_state_technology_test.js`, `data_test.js`, `modals_test.js` and
|
||||
`game_screen_test.js`.
|
||||
- **`unlocks` techs** are exempt from the effects rule; if you give
|
||||
`counter_intelligence` a numeric effect later, drop `unlocks`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Relevant files
|
||||
|
||||
- Tab / modal: `client/index.html` (`#tab-intelligence`, `#unit-spy-actions`),
|
||||
`client/js/modals/nation.js` (`_renderIntelligence`, campaign methods),
|
||||
`client/js/game_screen/panels.js` (`_intelligenceInfo`, `_updateSpyActions`,
|
||||
`_buildSpyActionButtons`, news feed).
|
||||
- Spy data/behaviour: `shared/data/units.js`, `shared/data/technologies.js`,
|
||||
`shared/data/intelligence.js` (`SPY_OPERATIONS`, `SABOTAGE`, `ESPIONAGE`,
|
||||
`SATELLITE`, report kinds), `shared/game_state/intelligence.js` (the whole
|
||||
covert-op mixin: sabotage, blame, city/comms reports, satellites, secrets),
|
||||
`shared/game_state/visibility.js`, `shared/game_state/movement.js`,
|
||||
`client/js/map_view/motion.js`.
|
||||
- Production block: `shared/rules.js` (`productionFactors` `sabotaged`),
|
||||
`shared/game_state/economy.js` (`getTileGdpPerCapita`, `_completeBuilding`),
|
||||
`shared/game_state/serialization.js` (`_serializeSabotage`, viewer
|
||||
`spyReports`/`satellites`).
|
||||
- Building: `shared/data/buildings.js` (`space_launch_center`,
|
||||
`BUILDING_MECHANIC.SPACE_LAUNCH`), `client/assets/icon_space_launch_center.svg`.
|
||||
- Orders/server/news: `server/game_server.js` (`_handleSabotage`,
|
||||
`_handleSpyCity`, `_handleSpyComms`, `_handleLaunchSatellite`),
|
||||
`server/server.js` (`case "chat"` with `to`, `recordChat`),
|
||||
`shared/game_state/orders.js`, `shared/data/relations.js` (`NEWS_SABOTAGE`,
|
||||
`NEWS_SATELLITE`, `NEWS_ENRICHMENT`), `client/js/modals/format.js`
|
||||
`describeNews`.
|
||||
- Client views: `client/js/modals/nation.js` (`_buildReportsCard`,
|
||||
`_fillReports`, `_buildSatellitesCard`, `_fillSatellites`, `onLaunchSatellite`),
|
||||
`client/js/game_screen.js` (`_chatTarget`, `_updateChatTargets`, `addChat`),
|
||||
`client/index.html` (`#chat-target`).
|
||||
- Target picking to imitate: `client/js/map_view/motion.js`,
|
||||
`client/js/map_view/input.js`, `client/js/game_screen.js`,
|
||||
`client/js/game_screen/panels.js`.
|
||||
- Chat (for comms spying): `server/server.js` `case "chat"`,
|
||||
`client/js/net.js`, `client/js/app.js`, `client/js/game_screen.js`
|
||||
(`openChat`/`addChat`).
|
||||
- Tests: `tests/intelligence_test.js`, `tests/spy_test.js`,
|
||||
`tests/game_screen_test.js`, `tests/game_server_test.js`, `tests/modals_test.js`,
|
||||
`tests/data_test.js`.
|
||||
- Previews: `/tmp/tismo-preview/intelligence-complete.html` (reports,
|
||||
satellites, private chat), `intelligence-phase-a.html`, `intelligence.html`,
|
||||
`intelligence-spy-actions.html`.
|
||||
|
||||
## 12. Related design docs
|
||||
|
||||
`STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`,
|
||||
`TRAINING_UI.md`, `ICONS.md` (siblings in the repo root).
|
||||
@@ -0,0 +1,163 @@
|
||||
# Politics tick performance — design brief
|
||||
|
||||
A working document for a session on making the hourly **politics/migration tick**
|
||||
cheap in a large, settled world.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model, ticked hourly by `server/server.js`; the browser only
|
||||
renders snapshots.
|
||||
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
|
||||
(no Node, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and adds its methods to the prototype.
|
||||
- **Version counters** are how expensive derived data is memoised on the snapshot
|
||||
and tick paths: `_gdpEpoch`, `_populationVersion`, `_territoryVersion`,
|
||||
`_modifiersVersion`, `_tileImprovementVersion`, `_visibleVersion`,
|
||||
`_ethnicityVersion`, `_regionVersion`, and the trade graph's own stamp.
|
||||
- **The trade graph is the reference pattern**: a walk from each city floods the
|
||||
ocean (a sea lane runs any distance), so `_tradeGraph` is cached across ticks
|
||||
*and* days under a stamp built from the road/railway, territory and
|
||||
storage-node counters. Any network change rebuilds lazily; a quiet world reuses
|
||||
it. The tile-improvement version is deliberately **not** in the stamp.
|
||||
- **Do not memoise `getCityEconomy` outside a snapshot** — a test pins that a
|
||||
simulation read reflects a direct `tilePopulation` edit immediately.
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- Time a settle: `node server/server.js --port 27015 --bind 127.0.0.1` logs how
|
||||
many days the warm-up took (and how long). `--warmup 0` skips it.
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`. Force RNG with
|
||||
`state._random = () => value`.
|
||||
- Commit only when asked; one imperative sentence; the hook runs the full suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. The problem (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Faster politics tick: with the trade-graph walks cached, `_tickPolitics`
|
||||
> is the largest remaining cost in a settled world — `_migrateForIncome`
|
||||
> through `_regionIncomePerCapita` and `_movePopulation`, and the repeated
|
||||
> `_cityMechanicLevel` lookups each of them makes. Memoise the per-region
|
||||
> income the way the trade graph is now kept, and let a migration sweep
|
||||
> visit only the regions whose pull actually changed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where the time goes
|
||||
|
||||
`shared/game_state/politics.js`:
|
||||
|
||||
- `_tickPolitics()` (line 71) runs hourly and calls, among others,
|
||||
`_migrateForIncome()`.
|
||||
- `_migrateForIncome()` (744) computes `_regionIncomePerCapita(this.cities)`
|
||||
(852) and then, per city, `_cityMechanicLevel(city, AIR_IMMIGRATION)` and
|
||||
nested loops over origin/destination cities, calling `_movePopulation` (865)
|
||||
which bumps `_populationVersion` (922).
|
||||
- `_regionIncomePerCapita` (852) is the expensive walk; it recomputes income for
|
||||
every region on every tick even when nothing that feeds it changed.
|
||||
- `_cityMechanicLevel` is called repeatedly for the same city/building within one
|
||||
tick.
|
||||
- Some politics walks are already memoised (`politics.js:222`, `:251` use
|
||||
`_ethnicityVersion:_populationVersion:_territoryVersion[: _regionVersion]`
|
||||
stamps), so there is a local idiom to follow.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design space (settle before coding)
|
||||
|
||||
1. **Memoise `_regionIncomePerCapita`** under a stamp of the counters that can
|
||||
change a region's per-capita income — likely
|
||||
`_populationVersion`, `_territoryVersion`, `_ethnicityVersion`,
|
||||
`_gdpEpoch`, `_modifiersVersion`, and the building/mechanic version (check
|
||||
what `_regionIncomePerCapita` actually reads). Keep the stamp minimal: the
|
||||
trade-graph lesson is that folding in a fast-changing counter throws the cache
|
||||
away constantly. Rebuild lazily on demand.
|
||||
2. **Visit only changed regions.** If a region's "pull" (income, or the
|
||||
migration allowance) did not change, skip its whole migration sweep. Needs a
|
||||
per-region pull signature / version and a place to keep it across ticks.
|
||||
3. **Hoist `_cityMechanicLevel`.** Compute each city's mechanic levels once per
|
||||
tick into a small map and pass it down, instead of calling it per city inside
|
||||
the loops.
|
||||
4. **Bound the inner loop.** `_migrateForIncome` is effectively all-pairs over
|
||||
cities in a region (or the world?). If it is world-wide, restrict to regions
|
||||
that are connected / have a pull difference, or use a nearest-first sweep.
|
||||
5. **Avoid `_populationVersion` churn from no-op moves.** `_movePopulation` bumps
|
||||
the version even for trivial/fractional moves; only bump when the population
|
||||
actually changes. (This also keeps every population-keyed memo alive longer.)
|
||||
6. **Measure first.** Build a benchmark that settles the standard map N days and
|
||||
times `_tickPolitics`, and/or count calls to `_regionIncomePerCapita` and
|
||||
`_cityMechanicLevel` per tick. Change nothing until the hot spot is confirmed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested order of work
|
||||
|
||||
1. Add a benchmark/profiling test (or temporary counters) that reports the
|
||||
per-tick politics cost and call counts on a settled standard map, so the
|
||||
before/after is objective.
|
||||
2. Memoise `_regionIncomePerCapita` with the right stamp (item 1). Re-measure.
|
||||
3. Hoist `_cityMechanicLevel` (item 3). Re-measure.
|
||||
4. Skip unchanged regions (item 2) if still hot. Re-measure.
|
||||
5. Keep the full politics/upkeep suites green; the migration tests pin the
|
||||
observable behaviour, so the refactor must not change outcomes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add / extend
|
||||
|
||||
- A performance-oriented test that asserts `_regionIncomePerCapita` is computed
|
||||
once per tick when nothing changed (e.g. count invocations via a spy or a
|
||||
counter), and is recomputed when a counter it depends on moves.
|
||||
- A correctness test that a direct `tilePopulation` edit is still reflected in a
|
||||
simulation read (do not break the `getCityEconomy` rule).
|
||||
- Keep `tests/politics_test.js`, `tests/migration_test.js`, `tests/growth_test.js`,
|
||||
`tests/warmup_test.js` green. `warmup_test.js` timing is a coarse guard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **A wrong stamp is worse than no cache.** If a counter that changes income is
|
||||
missing, migration reads stale data and tests will flake; if a fast counter is
|
||||
included, the cache is useless.
|
||||
- **Deltas.** Any new persisted/cached structure that is serialised must pair
|
||||
with a `DELTA_COLLECTIONS` entry and a version (see §1), or the snapshot grows.
|
||||
- **`_populationVersion` is load-bearing** for GDP/population memoisation; do not
|
||||
bump it in a hot loop for no reason, and do not *stop* bumping it when
|
||||
population truly changes.
|
||||
- **Same numbers, different cost.** The goal is identical simulation output at
|
||||
lower cost; verify by comparing settlement figures before/after, not just
|
||||
timing.
|
||||
|
||||
---
|
||||
|
||||
## 8. Relevant files
|
||||
|
||||
- `shared/game_state/politics.js` — `_tickPolitics`, `_migrateForIncome`,
|
||||
`_regionIncomePerCapita`, `_movePopulation`, `_cityMechanicLevel`, existing
|
||||
memoisation at lines ~222/~251.
|
||||
- `shared/game_state/resources.js` — `_tradeGraph` cache/stamp (the template),
|
||||
`_tickResources`.
|
||||
- `shared/game_state.js` — version counters, tick order.
|
||||
- `server/server.js` — the hourly tick and warm-up timing logs.
|
||||
- Tests: `tests/politics_test.js`, `tests/migration_test.js`,
|
||||
`tests/growth_test.js`, `tests/warmup_test.js`, `tests/trade_graph_test.js`.
|
||||
|
||||
## 9. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
|
||||
`TRAINING_UI.md`, `ICONS.md` (repo root).
|
||||
@@ -71,7 +71,7 @@ simplification worth revisiting. Anything unticked is still open.
|
||||
work already done.
|
||||
- [x] Repeatable technologies with a 2% cost growth per level, notified without
|
||||
flooding the news list.
|
||||
- [ ] Repeatable technologies, once researched, get automatically focused again
|
||||
- [x] Repeatable technologies, once researched, get automatically focused again
|
||||
- [x] Theme tabs (Science / Economy / Society / Military).
|
||||
- [x] Technology panel shows the research rate rather than a stockpile.
|
||||
- [x] Readable effect text for the military and repeatable effects.
|
||||
@@ -256,9 +256,109 @@ simplification worth revisiting. Anything unticked is still open.
|
||||
economy simulator's index reaches about 500-650 for luxury and 200-230 for
|
||||
high-tech) instead of settling; revisit the supply and demand balance of
|
||||
the two rarer goods.
|
||||
- [ ] A dedicated structure hit-point model for cities and tile improvements
|
||||
(see the `[~]` note above).
|
||||
- [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
|
||||
`icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
|
||||
`icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
|
||||
`icon_wind_turbines.svg`.
|
||||
|
||||
### Player interaction
|
||||
- [x] In-game chat
|
||||
- Access by pressing T
|
||||
- Shows a text input field in the bottom center
|
||||
- chat messages are ordered down to up, last to first
|
||||
|
||||
- [x] Player list
|
||||
- In the diplomacy tab, show who's in control of a nation: the AI or a player
|
||||
- Show it using an icon: human or computer
|
||||
- Also add a column with the leader's name
|
||||
|
||||
- [x] Diplomatic treaties, that can convey custom text
|
||||
- [x] In the diplomacy tab, show currently active and past treaties
|
||||
- [x] Players receiving a treaty proposal get a notification, clicking on it opens the treaty info
|
||||
- [x] Treaties have a "Sign" and "Reject" buttons
|
||||
- [x] The whole world is informed that a treaty is being sought, has been signed or has been rejected
|
||||
- Types of treaty effects:
|
||||
- [x] Peace: stops the state of war between two players. All battles cease, troops in enemy territory immediately move to their closest city.
|
||||
- Stranded troops on islands get teleported back, for now
|
||||
- Going back to war breaks the treaty and warns the player
|
||||
- [x] Alliance: signatory players cannot go to war with each other without breaking the treaty. Attacking a member of an alliance declares war on the entire alliance.
|
||||
- [x] Free trade: all tariffs to 0 between the signatory parties
|
||||
- [x] Give city
|
||||
- [x] Give military materials (cannot gift infantry)
|
||||
- [x] Give resources
|
||||
- [~] Give money
|
||||
- Denominated in any currency. Taken from the giving player's central bank foreign reserves if possible, otherwise, bought from the relevant central bank
|
||||
(simplified: the sum moves straight between the two treasuries, no FX or reserves leg)
|
||||
- Cannot get currency from a central bank from a country at war with you
|
||||
- [x] Joint research
|
||||
- Each players gets the sum of tech points produced by the signatory parties when they focus tech, except on military technology
|
||||
|
||||
### Military
|
||||
|
||||
- [x] Better troop management
|
||||
- [x] In the unit stack panel, can select all units of a type at once
|
||||
- [x] Show a table of icons instead of the current list
|
||||
- [x] Submarine unit
|
||||
- [x] Invisible to other players unless a naval unit is DIRECTLY adjacent
|
||||
- [~] Missiles
|
||||
- [x] One-use units.
|
||||
- [x] Types of missiles
|
||||
- [x] Conventional, 30 tiles range
|
||||
- [x] Nuclear (locked behind nuclear tech), 30 tiles range
|
||||
- [x] ICBM, nuclear (locked behind nuclear tech and space program), reaches any tile
|
||||
(a plain missile carries a conventional warhead unless the player arms it
|
||||
nuclear from the unit panel; the ICBM is nuclear by design)
|
||||
- [~] WMDs
|
||||
- [x] Nuclear program
|
||||
- [x] Build uranium enrichment centers in cities
|
||||
- [ ] Foreign intelligence may be warned that you are enriching uranium
|
||||
- [x] Once a uranium enrichment center exists, the nuclear weapons tech is unlocked
|
||||
- [x] Once the technology research is complete, players can choose nuclear payloads
|
||||
in their missiles
|
||||
- [x] Nuclear explosion effects:
|
||||
- [x] -90% population on a tile
|
||||
- [x] All buildings and improvements destroyed, except for bunkers and fortifications
|
||||
- [x] -1000 popularity across the board
|
||||
(any unit caught in the blast is destroyed too)
|
||||
- [ ] Stacks must behave as single units when it comes to pathfinding
|
||||
- [ ] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
|
||||
- [ ] Airplanes move in straight lines as they are not bound by tiles.
|
||||
* [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them
|
||||
|
||||
### Intelligence
|
||||
* [ ] Gets its own tab
|
||||
- Move propaganda operations to intelligence
|
||||
* [ ] Spy unit
|
||||
- Invisible to all other players, by default
|
||||
- 1 tile-radius vision
|
||||
- Mostly high-tech cost
|
||||
- Can move into enemy territory
|
||||
- [ ] Sabotage action:
|
||||
- Blocks production of on a given tile for a week, performed while in *any* country
|
||||
- Perpetrator is given a choice to try and blame someone else
|
||||
- The victim gets a notification, depending on intelligence and counterintelligence level, someone, or no one, will be blamed.
|
||||
- No way for the player to differentiate between a correct blame assignment or a successful false flag
|
||||
- [ ] City spying
|
||||
- Perpetrator gets a report on a city's status, building levels, etc. Counter-intelligence can make the report contain false information
|
||||
- [ ] Comms spying
|
||||
- Done in the capital of a country
|
||||
- Reveals the N last private messages sent from and to that player
|
||||
- Counter-intelligence may garble the messages, or change the sender and receiver of messages
|
||||
* [ ] Counter-intelligence tech
|
||||
- [ ] Extremely expensive
|
||||
* [ ] Spy satellites
|
||||
- [ ] New space launch center
|
||||
- Extremely expensive
|
||||
- [ ] allow "send spy satellite" action
|
||||
- Everyone is made aware that a satellite was launched
|
||||
- Reveal a random 2-tile-radius chunk of the map every two days
|
||||
|
||||
### Migrations
|
||||
|
||||
- [x] Populations cannot migrate from their country to a hostile one
|
||||
- [x] Expel [population] prevents that population from moving to the country
|
||||
- [x] Migration rate cannot be more than 0,01%/day from the original tile.
|
||||
|
||||
### Economics
|
||||
- [x] Remove the "1% per day budget increase". A construction site simply tries to get materials at current market prices.
|
||||
- [ ] Show that units being trained are gathering resources when the player clicks on training them
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Simplifications worth revisiting — design brief
|
||||
|
||||
The `[~]` items in `ROADMAP.md`: features that are implemented with a deliberate
|
||||
shortcut, kept honest so a later session can decide whether to build them out.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is the model;
|
||||
`server/game_server.js` validates orders; the browser renders snapshots.
|
||||
- **Shared logic runs in Node and the browser**; `shared/` stays framework-free
|
||||
(no Node, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and adds its methods to the prototype.
|
||||
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
|
||||
- **Version counters / memoisation** guard the snapshot and tick paths
|
||||
(`_populationVersion`, `_territoryVersion`, `_tileImprovementVersion`,
|
||||
`_gdpEpoch`, the trade-graph stamp). A new snapshot collection needs a
|
||||
`DELTA_COLLECTIONS` entry and a version.
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- Server: `node server/server.js --port 27015 --bind 127.0.0.1`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>`.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`; force RNG with
|
||||
`state._random = () => value`.
|
||||
- Commit only when asked; one imperative sentence; the hook runs the full suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structure damage is abstracted
|
||||
|
||||
**`[~]`** *Structure and city damage is modelled as city population/GDP loss
|
||||
plus improvement hit points, not a dedicated per-structure HP system.*
|
||||
|
||||
- Today: a city level or tile work has hit points / a repair debt
|
||||
(`RESOURCE_RULES.repair`, `shared/game_state/` combat/improvements), and its
|
||||
loss shows as population and GDP loss. There is no per-building HP.
|
||||
- Full model: each structure on a tile tracks its own condition, so a
|
||||
bombardment knocks out one specific building rather than degrading the city as
|
||||
a whole; repairs target the structure.
|
||||
- Questions: does per-structure HP add meaningful decisions, or just bookkeeping?
|
||||
How does it interact with the existing repair debt and the capture/destruction
|
||||
rules? Snapshot cost if every structure carries HP.
|
||||
- Files: `shared/game_state/resources.js` (repair rules), combat/improvements
|
||||
mixins, `shared/data/combat.js`.
|
||||
|
||||
## 3. Fossil fuels are not a resource
|
||||
|
||||
**`[~]`** *Fossil fuels are abstracted into the transformative plants' money
|
||||
upkeep rather than a stored fuel resource (the roadmap lists only the five
|
||||
groups: energy, steel, food, luxury, high-tech).*
|
||||
|
||||
- Today: coal/gas/oil plants pay their fuel as money upkeep; there is no coal,
|
||||
gas or oil stock to buy, store or blockade.
|
||||
- Full model: fossil fuels as storable resources with extraction buildings,
|
||||
market prices and a transport bill, making a blockade or a price spike bite.
|
||||
- Questions: does adding two-to-three more resources pay for the extra UI and
|
||||
simulation? The roadmap deliberately lists only five groups.
|
||||
- Files: `shared/data/resources.js`, `shared/data/buildings.js`,
|
||||
`shared/game_state/resources.js`.
|
||||
|
||||
## 4. Energy is a daily flow, not a store
|
||||
|
||||
**`[~]`** *Energy is modelled as a daily flow everywhere, so no city can bank
|
||||
it. Only the other four groups have per-tile storage.*
|
||||
|
||||
- Today: electricity is produced and consumed within the tick; no battery.
|
||||
- Full model: energy storage (batteries), so a surge can be banked and a
|
||||
brownout ridden out; possibly strategic reserves.
|
||||
- Questions: does storage break the "energy is cheap and everywhere" balance?
|
||||
Round-trip losses? Interaction with the delivery graph (10 GWh = 1 t of
|
||||
delivered energy).
|
||||
- Files: `shared/game_state/resources.js`, `shared/data/resources.js`,
|
||||
`shared/data/buildings.js`.
|
||||
|
||||
## 5. Cities buy from the cheapest source with a nominal delivery
|
||||
|
||||
**`[~]`** *A city always buys from the cheapest source: a nearby store whose
|
||||
delivered price beats the market, or the global market itself. Market buying
|
||||
uses a nominal two-tile road delivery.*
|
||||
|
||||
- Today: the choice is price-only; distance affects the delivered price but not
|
||||
which routes are physically available beyond the network rules.
|
||||
- Full model: a city bids across the transport network with real routing, lead
|
||||
times and possible stock-outs, instead of a nominal two-tile market leg.
|
||||
- Questions: does the simplification hide transport bottlenecks the delivery
|
||||
graph is meant to surface? How is the nominal leg calibrated?
|
||||
- Files: `shared/game_state/resources.js`, `shared/game_state/economy.js`,
|
||||
`client/js/map_view/modes.js` (delivery graph), `client/js/map_view/economic.js`.
|
||||
|
||||
## 6. Give-money treaties bypass FX and reserves
|
||||
|
||||
**`[~]`** *Give money is denominated in any currency but (simplified) the sum
|
||||
moves straight between the two treasuries, with no FX or central-bank reserve
|
||||
leg.*
|
||||
|
||||
- Today: `shared/game_state/treaties.js` moves the sum directly; the
|
||||
"cannot get currency from a central bank at war with you" rule is not
|
||||
modelled.
|
||||
- Full model: draw from the giver's foreign reserves, otherwise buy from the
|
||||
relevant central bank at the market rate; block a bank whose nation is at war.
|
||||
- Questions: which FX rate and spread? Does a reserve shortage fail the treaty
|
||||
or partially settle?
|
||||
- Files: `shared/game_state/treaties.js`, `shared/game_state/` currency/central
|
||||
bank mixins, `client/js/modals/nation.js` (treaty compose).
|
||||
|
||||
## 7. Missiles / WMD
|
||||
|
||||
**`[~]`** *Missiles* and *WMDs* are marked `[~]` while their sub-items are
|
||||
largely `[x]`.
|
||||
|
||||
- Today: a plain `missile` is conventional unless the player arms it nuclear
|
||||
from the unit panel (then it needs `nuclear_weapons`); the `icbm` is nuclear by
|
||||
design and needs `nuclear_weapons` + `space_program`. A nuclear blast kills
|
||||
~90% of a tile's people, destroys buildings/improvements except bunkers and
|
||||
fortifications, and costs every nation popularity.
|
||||
- What is unfinished: the WMD follow-up "foreign intelligence may be warned that
|
||||
you are enriching uranium" (see `INTELLIGENCE.md`), and any further
|
||||
refinement of blast falloff / interception.
|
||||
- Files: `shared/game_state/wmd.js`, `shared/data/units.js`,
|
||||
`shared/data/combat.js`, `client/js/game_screen/panels.js`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Related design docs
|
||||
|
||||
`DESIGN.md` (index), `INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`,
|
||||
`ECONOMY_BALANCE.md`, `POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md`.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Stacks & unit management — design brief
|
||||
|
||||
A working document for a session on **unit stacks**: how a player splits them
|
||||
ergonomically, and how a stack moves as one body through the world.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model; `server/game_server.js` (`GameServer`) owns one and
|
||||
validates orders; `server/server.js` is the transport.
|
||||
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
|
||||
(no Node built-ins, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and `Object.assign`s its methods onto the
|
||||
prototype. A new method file must be imported **and** added there.
|
||||
- **Client mirroring.** Any pathfinding or movement-cost change must be mirrored
|
||||
between `shared/game_state/movement.js` and the browser prediction in
|
||||
`client/js/map_view/motion.js`, or prediction fights the server.
|
||||
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never run the full
|
||||
suite during development** — the commit hook does that.
|
||||
- Server: `node server/server.js --port 27015 --bind 127.0.0.1` (`--testing`
|
||||
adds free/instant buttons).
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries (only vendored jQuery).
|
||||
- Missing icon → dummy SVG `dummy icon - <name>`.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
|
||||
`tests/framework/helpers.js` (`smallState()`, `defaultState()`,
|
||||
`adjacentLand()`, `grantBuilding()`). Force RNG with
|
||||
`state._random = () => value`.
|
||||
- Visual changes are previewed as a small standalone HTML page linking
|
||||
`client/css/style.css`, **opened in Firefox**, before commit.
|
||||
- Commit only when asked; messages are one imperative sentence.
|
||||
|
||||
---
|
||||
|
||||
## 2. What exists today
|
||||
|
||||
- The stack panel was just reworked (commit "Grouped the unit stack by type and
|
||||
let ranged units pick a target"): one line per unit type, tri-state group
|
||||
checkbox, opaque icon cards with healthbars, overlap-to-fit, cards to the left
|
||||
on top, deselected cards stay and sort to the back — `client/js/game_screen/panels.js`
|
||||
(`_renderStackList`, `_layoutStackDeck`, `_stackSelectionChanged`) and the
|
||||
`_stackMembers` whole-stack concept in `client/js/game_screen.js`.
|
||||
- Selecting a unit is by left-click on the map (`client/js/map_view/input.js`
|
||||
`_onLeftClick`) or from the right-click **stack menu**:
|
||||
`client/js/game_screen/panels.js` `_openStackMenu` lists each unit on a tile
|
||||
("Move here", per-unit select, "Select all").
|
||||
- Right-click **only moves** now (the ranged Attack action is a panel button);
|
||||
moving onto a hostile stack triggers the fight server-side.
|
||||
- Group movement: `_groupMovePlan` and `_groupSpeed` (slowest member sets the
|
||||
pace); `find_path` is called per unit with a per-type route cache.
|
||||
- Territory rules: `shared/game_state/movement.js` `_canUnitEnter` treats
|
||||
foreign territory as a wall on through-routes except the civilisation targeted
|
||||
by the move (`targetCiv` = owner of the goal). `_zoneOfControl` denies the ring
|
||||
around hostile ground units. Mirrored in `client/js/map_view/motion.js`
|
||||
`_canEnter` / `_zoneOfControl` / `_targetCivFor`.
|
||||
- The client has the diplomacy matrix in `this.snapshot.diplomacy` and a helper
|
||||
`_isAtWar(a, b)` in `client/js/game_screen/panels.js`, but the **MapView does
|
||||
not hold relations yet** (needed for prediction — see §5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Requirements (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Easy and ergonomic way to split unit stacks.
|
||||
> - [ ] Give stacks with range units a dedicated button to perform ranged
|
||||
> attacks, right-clicking will now ONLY move (obviously triggers a battle
|
||||
> when moving on the same tile)
|
||||
> *(the ranged button is done; this line can be ticked)*
|
||||
> - [ ] Stacks must behave as single units when it comes to pathfinding
|
||||
> - [ ] Cannot go through neutral third-party territory, allied and at-war
|
||||
> territory can be walked through
|
||||
> - [ ] Airplanes move in straight lines as they are not bound by tiles.
|
||||
> *(see `AIR_MOVEMENT.md`)*
|
||||
|
||||
---
|
||||
|
||||
## 4. Splitting a stack — design space (settle before coding)
|
||||
|
||||
The problem: a player wants two units out of a five-unit stack to go one way and
|
||||
the rest another, without hunting each one down in the right-click menu.
|
||||
|
||||
Candidate mechanics (pick one primary, maybe one shortcut):
|
||||
|
||||
1. **Split button + destination.** With a tile or stack selected, a "Split"
|
||||
action opens the stack panel with per-type count steppers ("move 2 of 3
|
||||
infantry"), then a target pick. Good for precise splits.
|
||||
2. **Shift/drag cards.** In the stack panel, drag a card (or shift-click) to
|
||||
detach it into a new sub-selection, then right-click to move the detached
|
||||
group. Fast, but needs clear feedback for "this card is now moving".
|
||||
3. **Halve / split by type.** Buttons "Split in half" and "Split by type" (each
|
||||
type into its own stack) — one-click, no destination.
|
||||
4. **Stack menu "Split off".** Extend `_openStackMenu`: checkboxes per row to
|
||||
pick the units to detach, then a "Move selected" action.
|
||||
|
||||
Open questions:
|
||||
- Does a "split" create a persistent sub-stack, or is it just a temporary
|
||||
multi-selection that disperses after one order? (There is no persistent stack
|
||||
entity today — units sharing a tile are the stack.)
|
||||
- How does splitting interact with the group-speed rule (slowest member sets the
|
||||
pace for the whole move)? If you split off a slow artillery and send the fast
|
||||
infantry alone, the infantry should move at its own speed — confirm the group
|
||||
move only groups the selected units.
|
||||
- Where does the split land if the destination is one tile (all units together)
|
||||
versus a route (do they arrive as a stack)?
|
||||
- Undo/confirm: splitting should not be a mis-click trap.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stack pathfinding as one unit
|
||||
|
||||
Goal: a stack routes as a body and never cuts through a neutral third party,
|
||||
while allied and at-war territory may be crossed.
|
||||
|
||||
Current rule (`_canUnitEnter`, through-route, non-air):
|
||||
```js
|
||||
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
|
||||
```
|
||||
Desired rule: block only **neutral** third parties; allow **allied** and **at-war**
|
||||
territory.
|
||||
|
||||
Open questions:
|
||||
1. **Where does the alliance/war state come from on the server?** `GameState`
|
||||
has relations and (with the treaties work) alliance treaties. A helper like
|
||||
`_canTraverseTerritory(civ, owner)` reading `_relation(a, b)` / alliance
|
||||
membership would centralise it. Decide whether "allied" means an alliance
|
||||
treaty specifically or also non-aggression/peace.
|
||||
2. **Client mirror.** The browser predicts paths, so `MapView` needs the same
|
||||
answer. `this.snapshot.diplomacy` is available when a snapshot is applied;
|
||||
add a relations/at-war/allied structure to `MapView` (set in `_setupWorld` or
|
||||
`onState`) and have `_canEnter` consult it. Without this, prediction will
|
||||
draw a route the server refuses.
|
||||
3. **"Stacks behave as single units."** Today each unit is pathfound separately
|
||||
with a shared route cache per type and the slowest speed wins. Decide whether
|
||||
the whole stack should share **one route** (computed once, followed by all)
|
||||
so units never fan out, and what happens if a unit is blocked mid-route by a
|
||||
zone of control that another unit is exempt from.
|
||||
4. **Zone of control vs stack.** Should a stack be able to force a crossing a
|
||||
single unit could not, or does the ZoC apply per unit? The roadmap is silent;
|
||||
keep per-unit unless asked.
|
||||
5. **War declaration side effect.** `_groupMovePlan` already records the owners a
|
||||
route crosses and `_orderMove` declares war on them. With allied/at-war
|
||||
traversal allowed, make sure a permitted crossing does **not** declare war on
|
||||
an ally (and that crossing an at-war nation is already legal).
|
||||
6. **Air and spies are exceptions.** Aircraft ignore territory entirely;
|
||||
spies cross foreign land (already implemented). The new rule should not
|
||||
regress either.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested order of work
|
||||
|
||||
1. Decide the split interaction (§4) and build it in the stack panel + menu,
|
||||
with client tests.
|
||||
2. Add the server helper for traversable territory and wire `_canUnitEnter`.
|
||||
3. Mirror it in `MapView` (`_canEnter`) with the relations the snapshot ships;
|
||||
add a prediction test in `tests/map_view_input_test.js` / `map_view_test.js`.
|
||||
4. Decide the one-route-per-stack question; if yes, change `_groupMovePlan` and
|
||||
the server's group move together.
|
||||
5. Tick the roadmap lines.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests to add
|
||||
|
||||
- Split: selecting a subset splits correctly; the split group moves at its own
|
||||
(slowest member) speed; a mis-click is recoverable.
|
||||
- Territory: a route crosses an ally's land but not a neutral's; crossing an
|
||||
ally's land does not declare war; crossing an at-war nation's land is allowed;
|
||||
client prediction matches the server for each case.
|
||||
- Regression: spies and aircraft keep their exceptions; zone of control still
|
||||
applies.
|
||||
|
||||
---
|
||||
|
||||
## 8. Gotchas
|
||||
|
||||
- **Mirror server and client** or prediction fights the server (this is the
|
||||
single most common bug in this area).
|
||||
- **Do not add a per-broadcast walk.** If relations are shipped, they are small;
|
||||
if you add a derived structure, memoise it against the diplomacy version.
|
||||
- The stack panel's `_stackMembers` (whole stack) vs `selectedUnitIds` (ticked)
|
||||
distinction is load-bearing; read `client/js/game_screen/panels.js`
|
||||
`_stackSelectionChanged` before changing selection semantics.
|
||||
- `_orderMove`/`_groupMovePlan` also handle transport (embark/disembark) and
|
||||
escort; check those paths when changing group movement.
|
||||
|
||||
---
|
||||
|
||||
## 9. Relevant files
|
||||
|
||||
- `client/js/game_screen/panels.js` — stack panel, stack menu, `_groupMovePlan`,
|
||||
`_groupSpeed`, `_isAtWar`.
|
||||
- `client/js/game_screen.js` — `_stackMembers`, target/selection state.
|
||||
- `client/js/map_view/input.js` — click handling, `_onRightClick`.
|
||||
- `client/js/map_view/motion.js` — prediction `_canEnter`, `_zoneOfControl`,
|
||||
`_targetCivFor`, `planMove`.
|
||||
- `client/js/map_view.js` — where relations would be stored for prediction.
|
||||
- `shared/game_state/movement.js` — `_canUnitEnter`, `_zoneOfControl`, pathfinding.
|
||||
- `shared/game_state/diplomacy.js`, `shared/game_state/treaties.js` — relations
|
||||
and alliances.
|
||||
- Tests: `tests/game_screen_test.js`, `tests/map_view_input_test.js`,
|
||||
`tests/map_view_test.js`, `tests/stacking_test.js`, `tests/orders_test.js`.
|
||||
|
||||
## 10. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
|
||||
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md` (repo root).
|
||||
@@ -0,0 +1,171 @@
|
||||
# Training / production UI — design brief
|
||||
|
||||
A working document for a session on showing **what a unit being trained is
|
||||
gathering** (and, relatedly, the materials a building or tile work is buying)
|
||||
when the player clicks it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared project context (read this first)
|
||||
|
||||
*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game.
|
||||
Pure JavaScript, no build step, no runtime dependencies.
|
||||
|
||||
- **Server-authoritative.** `shared/game_state.js` (`GameState`) is a
|
||||
framework-free model; `server/game_server.js` owns one and validates orders.
|
||||
- **Shared logic runs in Node and the browser.** `shared/` stays framework-free
|
||||
(no Node, no DOM).
|
||||
- **GameState is mixins.** `shared/game_state.js` imports each
|
||||
`shared/game_state/<topic>.js` and adds its methods to the prototype.
|
||||
- **The economy is hourly** (`shared/game_state/resources.js`
|
||||
`_tickResources`) and materials are bought at current market prices; there is
|
||||
no more "1% per day budget increase" (a construction site just buys what it
|
||||
needs).
|
||||
- **Data** lives in `shared/data/` (barrel: `shared/data.js`).
|
||||
|
||||
### Commands
|
||||
|
||||
- Install once: `npm install`.
|
||||
- One suite: `node tests/run_tests.js --file <name>.js`. **Never the full suite**
|
||||
during development — the commit hook runs it.
|
||||
- Server: `node server/server.js --port 27015 --bind 127.0.0.1` (`--testing`
|
||||
adds the free/instant train and build buttons).
|
||||
|
||||
### Conventions
|
||||
|
||||
- 2-space indent; no new libraries; dummy icon `dummy icon - <name>`.
|
||||
- Tests in `tests/<topic>_test.js` extending `TestCase`; fixtures in
|
||||
`tests/framework/helpers.js`. Force RNG with `state._random = () => value`.
|
||||
- Visual changes get a minimal standalone HTML preview linking
|
||||
`client/css/style.css`, opened in Firefox, before commit.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirement (from `ROADMAP.md`)
|
||||
|
||||
> - [ ] Show that units being trained are gathering resources when the player
|
||||
> clicks on training them
|
||||
|
||||
This sits directly under the Economics section, next to the construction-market
|
||||
change ("a construction site simply tries to get materials at current market
|
||||
prices"). The likely intent: the production queue should visibly account for the
|
||||
**materials phase** — as it already does for buildings — for **units** too, and
|
||||
clicking a queued/training item should reveal the resources it is drawing.
|
||||
|
||||
---
|
||||
|
||||
## 3. How production works today
|
||||
|
||||
- **City panel** is `client/js/modals/city.js`; DOM in `client/index.html`
|
||||
(`#city-train-list`, `#city-train-status`, `#city-train-label`,
|
||||
`#city-train-progress`, `#city-train-queue`). The train tab shows both units
|
||||
and buildings (the shared production queue).
|
||||
- **Queue rendering** `renderQueue(queue)` (city.js:467) groups contiguous
|
||||
identical entries, shows "Ready in …" and a Cancel button, and — for a
|
||||
**building** in the `phase === "materials"` phase — swaps the timing for
|
||||
"Gathering materials" and adds no time to the estimate.
|
||||
- **Training status** `beginTraining` / `setTrainingProgress` / `endTraining`
|
||||
(city.js:516-531); `setGatheringProgress` (536) shows "Gathering materials for
|
||||
X…" with a fraction, used for the head-of-queue building.
|
||||
- **Server side** (to confirm): `shared/game_state/orders.js` `requestTrain` /
|
||||
`requestBuild` enqueue orders; `shared/game_state/sites.js` runs the building
|
||||
materials phase (buying steel/high-tech at market, a `phase: "materials"`
|
||||
with a `fraction`); `shared/game_state/resources.js` does the buying. Units
|
||||
are trained from money plus a population draw (infantry) and may have a
|
||||
`materialUpkeep` but currently have **no materials-gathering phase** visible to
|
||||
the client.
|
||||
- **Testing mode** adds free/instant variants (`--testing`), which skip the
|
||||
queue; keep the display correct there too (or intentionally silent).
|
||||
|
||||
---
|
||||
|
||||
## 4. Open questions (settle before coding)
|
||||
|
||||
1. **What does "gathering resources" mean for a unit?** Options:
|
||||
(a) units get a real materials phase (buy steel/high-tech before the clock
|
||||
starts), mirroring buildings — a model change; (b) units are paid in money and
|
||||
population only, and the UI should instead show the **population draw** (the
|
||||
soldiers taken from the region) and the money cost as it is paid; (c) the UI
|
||||
should show the materials **upkeep** the unit will need once built.
|
||||
The roadmap wording ("gathering resources") suggests (a).
|
||||
2. **Click to inspect.** The requirement says *when the player clicks on training
|
||||
them*. Today queue rows are read-only (Cancel only). Decide whether clicking a
|
||||
queue row expands a detail panel (resources still to buy, bought so far, cost
|
||||
paid, population drawn, ready time) or opens a modal.
|
||||
3. **Where do the figures live?** The queue entries need to ship the per-entry
|
||||
materials state (needed/bought/phase/fraction) in the snapshot; confirm what
|
||||
the server already sends (`view.queue`) and extend it. Watch snapshot size —
|
||||
the queue is small, so it is probably fine, but pair it with the city view.
|
||||
4. **Cancel/refund.** Cancelling a building refunds nothing today. If units get a
|
||||
materials phase, does cancelling refund the materials? Keep it consistent with
|
||||
buildings.
|
||||
5. **Construction sites on tiles** share this materials model
|
||||
(`shared/game_state/sites.js`); decide whether tile works get the same
|
||||
click-to-inspect treatment (they are improved in `client/js/game_screen/panels.js`
|
||||
tile panel / `_openTileManageModal`).
|
||||
6. **Testing mode.** The free/instant button bypasses the queue; make sure the
|
||||
new display does not show a phantom materials phase.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested order of work
|
||||
|
||||
1. Read `shared/game_state/sites.js` and `orders.js` to pin down exactly what the
|
||||
building materials phase is and what the snapshot already carries for the
|
||||
queue.
|
||||
2. Decide question 1. If units stay money+population, the "gathering" UI is
|
||||
about the population draw and the money/materials cost; if they gain a
|
||||
materials phase, reuse the site phase.
|
||||
3. Extend the queue entry with the fields the detail view needs and render the
|
||||
click-to-inspect panel in `client/js/modals/city.js`.
|
||||
4. Add tests: the queue entry carries the right phase/fraction; clicking a
|
||||
training item shows the expected fields; testing mode does not show a
|
||||
materials phase.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add / extend
|
||||
|
||||
- Server: a queued unit's entry exposes its materials/population state (whatever
|
||||
question 1 decides).
|
||||
- Client: `tests/game_screen_test.js` / a city-modal test clicks a training queue
|
||||
row and asserts the detail is shown; the gathering label appears only in the
|
||||
materials phase.
|
||||
- Keep `tests/construction_site_test.js`, `tests/game_screen_test.js`,
|
||||
`tests/modals_test.js` green.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Snapshot churn.** The queue is rebuilt each refresh; keep the click-to-inspect
|
||||
selection keyed by queue index/run, not a DOM node that gets thrown away (the
|
||||
city panel already has this problem — see the "signature" guards in
|
||||
`_renderTrainList`/`renderQueue`).
|
||||
- **Testing mode** free/instant orders must not be given a fake gathering phase.
|
||||
- **Population draw correctness**: training draws people from the region at
|
||||
random; if you surface it, read the same code path the model uses.
|
||||
- **Market prices move**: a quoted cost is a snapshot; label it as an estimate
|
||||
the way the rest of the UI does (`quotedBuildCost`, `_quoted`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Relevant files
|
||||
|
||||
- `client/js/modals/city.js` — `renderQueue`, `beginTraining`,
|
||||
`setTrainingProgress`, `setGatheringProgress`, `_renderTrainList`.
|
||||
- `client/index.html` — the city modal and `#city-train-*` DOM.
|
||||
- `shared/game_state/sites.js` — the building/tile materials phase and fractions.
|
||||
- `shared/game_state/orders.js` — `requestTrain` / `requestBuild` /
|
||||
`cancel_train`.
|
||||
- `shared/game_state/resources.js` — buying and `payCombatResources`/
|
||||
construction spending.
|
||||
- `client/js/game_screen/panels.js` — the city view (`_cityView`) that fills the
|
||||
queue, and the tile-improvement panel.
|
||||
- Tests: `tests/construction_site_test.js`, `tests/game_screen_test.js`,
|
||||
`tests/modals_test.js`, `tests/testing_mode_test.js`.
|
||||
|
||||
## 9. Related design docs
|
||||
|
||||
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
|
||||
`POLITICS_PERFORMANCE.md`, `ICONS.md` (repo root).
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">computer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">human</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#5a3a1f" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">ICBM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -1 +1,5 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M18.78 25.97c2.6 25.278 9.316 48.72 19.532 69.75l-21.218-6.564L73.53 146.72l-.843-1.782c16.737 17.29 36.797 31.38 59.438 41.593-13.546-18.845-21.597-41.677-22.03-69.28 27.78.35 50.396 8.56 69.25 22.063-.627-1.4-1.284-2.776-1.94-4.157l.72.625-34.438-86.655-2.53 33.844c-30.974-31.598-73.044-52.084-122.376-57zm236.814 31.593L217.22 160.875c-30.663 7.52-54.566 32.236-60.907 63.344l-.188-.22-106.563 39.563 74.313 74.312 93-8.03.375-.033 23.03-2.468-77.31-94.938 13.28-13.28 89.406 109.75 10.875 13.374-17.124 1.844-28.22 3.03a448.92 448.92 0 0 0 47.22 47.907c7.62-56.784 52.175-102.042 108.625-110.717a443.59 443.59 0 0 0-47.75-46.688l-2.78 26.125-1.844 17.125L321.28 270l-107.874-88.03 13.25-13.25 93.094 75.905 2.22-20.844 7.905-91.936-74.28-74.28zM401.53 301.625a111.011 111.011 0 0 0-25.436 4.156l13.875 16.908a116.11 116.11 0 0 1 23.467-5.282c-3.863-5.344-7.814-10.62-11.906-15.78zm-43.78 11.25a110.843 110.843 0 0 0-20.5 13.063l15.75 17.28a117.217 117.217 0 0 1 19.125-12.812l-14.375-17.53zm68 22.438a98.919 98.919 0 0 0-22.97 3.03l14.5 17.688c6.108-1.82 12.447-3.043 18.97-3.686-3.387-5.76-6.865-11.445-10.5-17.03zm-102.03 3.53a110.815 110.815 0 0 0-16.845 25.344l17.313 14.125c4.28-7.958 9.44-15.37 15.375-22.093l-15.844-17.376zm60.467 6.282c-6.67 3.247-12.908 7.267-18.625 11.875l16.282 17.875a102.325 102.325 0 0 1 17.312-11.47l-14.97-18.28zm-32 24.938a98.164 98.164 0 0 0-13.25 20.28l17.97 14.688a102.341 102.341 0 0 1 11.624-17l-16.342-17.967zm94.094.218v.22c-45.915 0-82.96 36.985-83.06 82.875a444.741 444.741 0 0 0 127.06 44.313c-8.284-45.328-23.3-88.145-44-127.407zM299.94 382.626a111.035 111.035 0 0 0-3.97 26.938 448.277 448.277 0 0 0 15.063 11.343c.93-8.58 2.75-16.88 5.44-24.812l-16.533-13.47zm32.156 26.25a98.892 98.892 0 0 0-3.094 24.375 447.679 447.679 0 0 0 16.063 9.938 101.51 101.51 0 0 1 4.218-20.282l-17.186-14.03z" fill="#c9c9c9" stroke="#000000" stroke-opacity="1" stroke-width="5" fill-opacity="1"></path></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#4a2b2b" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">missile</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 477 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">space_launch_center</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">spy</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">submarine</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b4a35" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="251" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="301" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">uranium</text>
|
||||
<text x="256" y="351" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">enrichment</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 613 B |
@@ -157,6 +157,15 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.aero:disabled { opacity: 0.4; cursor: default; filter: none; }
|
||||
.aero.primary { background: linear-gradient(180deg, rgba(110, 175, 255, 0.7), rgba(40, 95, 175, 0.9)); }
|
||||
.aero.big { display: block; width: 100%; margin: 10px 0; padding: 14px; font-size: 17px; }
|
||||
/* A toggled-on action (an armed nuclear warhead) reads hot. */
|
||||
.aero.active {
|
||||
background: linear-gradient(180deg, rgba(255, 120, 90, 0.9), rgba(150, 30, 25, 0.95));
|
||||
border-color: rgba(255, 150, 120, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(255, 90, 70, 0.6), 0 0 14px rgba(255, 70, 50, 0.55);
|
||||
}
|
||||
.aero.active:hover { filter: brightness(1.1); }
|
||||
/* The ranged Attack action stands apart: amber while offered, hot while the
|
||||
player is picking a target. */
|
||||
.aero.attack { background: linear-gradient(180deg, rgba(255, 160, 90, 0.85), rgba(175, 80, 25, 0.95)); border-color: rgba(255, 180, 120, 0.8); }
|
||||
.aero.attack.active {
|
||||
background: linear-gradient(180deg, rgba(255, 120, 90, 0.95), rgba(150, 30, 25, 0.98));
|
||||
@@ -1125,6 +1134,20 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.tile-gdp-mods { margin-top: 2px; }
|
||||
.tile-gdp-mod { font-size: 12px; color: var(--muted); padding-left: 12px; font-variant-numeric: tabular-nums; }
|
||||
#tile-panel .row, #unit-panel .row { justify-content: flex-start; margin-top: 10px; }
|
||||
/* The spy action row can hold three operations with longer names than the other
|
||||
unit actions, so it wraps and its buttons sit tighter to fit the narrow panel. */
|
||||
#unit-panel #unit-spy-actions { flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
#unit-panel #unit-spy-actions .aero { padding: 5px 10px; font-size: 12px; }
|
||||
/* The "blame someone else" picker beside the spy actions. */
|
||||
#unit-panel #unit-spy-actions .spy-frame {
|
||||
flex: 1 1 100%;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(10, 14, 22, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.health-row { display: flex; align-items: center; gap: 8px; }
|
||||
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; }
|
||||
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
|
||||
@@ -1341,6 +1364,62 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
color: #ffcf7a;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- chat -- */
|
||||
|
||||
/* Bottom-centre in-game chat. The log is anchored to the bottom so new
|
||||
messages appear beside the input and older ones stack upward. */
|
||||
.chat {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
width: min(560px, 60vw);
|
||||
z-index: 65;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat.hidden { display: none; }
|
||||
.chat-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 14, 22, 0.72);
|
||||
border: 1px solid var(--panel-border);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.chat-line { font-size: 13px; line-height: 1.4; color: var(--text); }
|
||||
.chat-line .chat-author { color: #ffcf7a; font-weight: 600; margin-right: 6px; }
|
||||
.chat-line.chat-system .chat-author { color: var(--muted); }
|
||||
.chat-line.chat-private .chat-author { color: #c9a0ff; }
|
||||
.chat-compose { display: flex; gap: 6px; }
|
||||
.chat-target {
|
||||
flex: 0 0 auto;
|
||||
max-width: 130px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(10, 14, 22, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.chat-input {
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(10, 14, 22, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-input:focus { outline: none; border-color: #6eafff; }
|
||||
|
||||
/* --------------------------------------------------------------- modals -- */
|
||||
|
||||
.modal {
|
||||
@@ -1734,6 +1813,49 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.diplomacy-table tbody tr.local-nation td.name { font-weight: 600; }
|
||||
.diplomacy-table td.status { white-space: nowrap; }
|
||||
.diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; }
|
||||
/* Who commands a nation: a human or the computer, plus the leader's name. */
|
||||
.diplomacy-table td.control { width: 1%; white-space: nowrap; }
|
||||
.diplomacy-table .control-icon { width: 20px; height: 20px; vertical-align: middle; }
|
||||
.diplomacy-table td.leader { white-space: nowrap; }
|
||||
|
||||
/* Diplomacy tab: treaty offers, the compose row, and the active/past lists. */
|
||||
.treaty-panel { padding: 4px 10px 14px; }
|
||||
.treaty-heading { color: var(--muted); font-weight: 600; margin: 12px 0 6px; }
|
||||
.treaty-compose {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
.treaty-compose-title { color: var(--muted); font-weight: 600; }
|
||||
.treaty-compose select,
|
||||
.treaty-compose input[type="number"] {
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(10, 14, 22, 0.9);
|
||||
color: var(--text);
|
||||
}
|
||||
#treaty-payload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.treaty-resource-row,
|
||||
.treaty-unit-row { display: flex; align-items: center; gap: 4px; font-size: 13px; }
|
||||
.treaty-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.treaty-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.treaty-row .treaty-text { flex: 1; }
|
||||
.treaty-row .aero { padding: 4px 10px; }
|
||||
/* A news-feed line that opens a dialog when clicked. */
|
||||
.feed-clickable { cursor: pointer; text-decoration: underline dotted; }
|
||||
|
||||
/* A foreign figure's equivalent in our money, kept quiet beside it. */
|
||||
.conversion { color: var(--muted); font-size: 0.85em; }
|
||||
@@ -1903,6 +2025,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
font-size: 13px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
/* A row's explanatory sub-line, e.g. a covert operation's description. */
|
||||
.politics-row .desc { color: var(--muted); font-size: 12px; margin: 2px 0 0; }
|
||||
.politics-penalty { color: #f0b060; }
|
||||
.famine-warning { color: #e05a4a; font-weight: 600; }
|
||||
.ethnic-chart { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
@@ -174,6 +174,17 @@
|
||||
<!-- Right-click menu listing the units sharing a tile -->
|
||||
<div id="stack-menu" class="stack-menu hidden"></div>
|
||||
|
||||
<!-- In-game chat, opened with T. Messages read bottom to top, newest by
|
||||
the input, which sits at the bottom centre of the screen. -->
|
||||
<div id="chat" class="chat hidden">
|
||||
<div id="chat-log" class="chat-log"></div>
|
||||
<div class="chat-compose">
|
||||
<select id="chat-target" class="chat-target" title="Who hears this line"></select>
|
||||
<input id="chat-input" class="chat-input" type="text" maxlength="240"
|
||||
autocomplete="off" placeholder="Say something… (Enter to send, Esc to close)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lower-left dock: the news feed sits above the selected-unit panel. -->
|
||||
<div id="left-dock">
|
||||
<div class="game-corner">
|
||||
@@ -216,8 +227,11 @@
|
||||
<div class="row">
|
||||
<button id="unit-pillage" class="aero hidden">Pillage</button>
|
||||
<button id="unit-trench" class="aero hidden">Dig trench</button>
|
||||
<button id="unit-nuclear" class="aero hidden">Nuclear warhead</button>
|
||||
<button id="unit-attack-order" class="aero attack hidden">Attack</button>
|
||||
</div>
|
||||
<!-- Covert operations for a selected spy, built from SPY_OPERATIONS. -->
|
||||
<div id="unit-spy-actions" class="row hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,6 +356,7 @@
|
||||
<button class="tab" data-tab="technology">Technology</button>
|
||||
<button class="tab" data-tab="diplomacy">Diplomacy</button>
|
||||
<button class="tab" data-tab="politics">Politics</button>
|
||||
<button class="tab" data-tab="intelligence">Intelligence</button>
|
||||
<button class="tab" data-tab="cities">Cities</button>
|
||||
<button class="tab" data-tab="conflicts">Ongoing conflicts</button>
|
||||
<button class="tab" data-tab="budget">Budget</button>
|
||||
@@ -360,11 +375,33 @@
|
||||
<div class="tab-page" id="tab-diplomacy">
|
||||
<div id="diplomacy-sub" class="modal-sub"></div>
|
||||
<div id="diplomacy-list" class="modal-body"></div>
|
||||
<!-- Treaties live outside #diplomacy-list, which the snapshot
|
||||
refresh rebuilds, so the compose controls keep their input. -->
|
||||
<div id="treaty-panel" class="treaty-panel">
|
||||
<div class="treaty-compose">
|
||||
<span class="treaty-compose-title">Propose a treaty</span>
|
||||
<select id="treaty-type"></select>
|
||||
<select id="treaty-target"></select>
|
||||
<span id="treaty-payload"></span>
|
||||
<button id="treaty-propose" class="aero">Propose</button>
|
||||
</div>
|
||||
<div id="treaty-pending" class="treaty-list"></div>
|
||||
<div class="treaty-heading">Active treaties</div>
|
||||
<div id="treaty-active" class="treaty-list"></div>
|
||||
<div class="treaty-heading">Past treaties</div>
|
||||
<div id="treaty-past" class="treaty-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-politics">
|
||||
<div id="politics-sub" class="modal-sub"></div>
|
||||
<div id="politics-list" class="modal-body"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-intelligence">
|
||||
<div id="intelligence-sub" class="modal-sub"></div>
|
||||
<div id="intelligence-subtabs" class="subtabs"></div>
|
||||
<div id="intelligence-spies-view" class="modal-body"></div>
|
||||
<div id="intelligence-propaganda-view" class="modal-body hidden"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-cities">
|
||||
<div id="cities-sub" class="modal-sub"></div>
|
||||
<div id="cities-list" class="modal-body"></div>
|
||||
|
||||
@@ -278,6 +278,9 @@ network.on("game_list_received", (list) => renderGames(list));
|
||||
network.on("game_state_received", (state) => {
|
||||
if (gameScreen) gameScreen.onState(state);
|
||||
});
|
||||
network.on("chat_message", (message) => {
|
||||
if (gameScreen) gameScreen.addChat(message);
|
||||
});
|
||||
network.on("server_disconnected", () => {
|
||||
$("#disconnect-banner").text("Disconnected from the server.").removeClass("hidden");
|
||||
});
|
||||
|
||||
@@ -19,8 +19,18 @@ const EFFECT_SOURCES = {
|
||||
bomb: "assets/sounds/death_flash.flac",
|
||||
};
|
||||
|
||||
// Crashed Ship (Ted Kerr), looped softly under the world map.
|
||||
const MUSIC_SOURCE = "assets/sounds/crashed_ship.ogg";
|
||||
// Tracks played softly under the world map. They are shuffled into a playlist
|
||||
// and played one after another, so a long session hears both.
|
||||
const MUSIC_SOURCES = [
|
||||
// Crashed Ship, copyright Ted Kerr.
|
||||
"assets/sounds/crashed_ship.ogg",
|
||||
// Dark City, by Muncheybobo, used under CC BY 3.0.
|
||||
"assets/sounds/dark_city.ogg",
|
||||
// Constellation, by Trevor Lentz, used under CC BY-SA 3.0.
|
||||
"assets/sounds/constellation.ogg",
|
||||
// Patrol, by Alexandr Zhelanov, used under CC BY 3.0.
|
||||
"assets/sounds/patrol.ogg",
|
||||
];
|
||||
|
||||
const EFFECT_VOLUMES = { click: 0.35, complete: 0.6, notification: 0.6 };
|
||||
const MUSIC_VOLUME = 0.3;
|
||||
@@ -45,6 +55,17 @@ function clamp01(value) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
// Fisher-Yates over the track indexes, so each pass over the playlist visits
|
||||
// every track once in a different order.
|
||||
function shuffledIndexes(count, random) {
|
||||
const order = Array.from({ length: count }, (_, index) => index);
|
||||
for (let i = order.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// A real AudioContext, or null where Web Audio is unavailable (jsdom, an old
|
||||
// browser). Kept in a function so merely importing this module never touches it.
|
||||
function defaultContextFactory() {
|
||||
@@ -65,11 +86,15 @@ export class AudioManager {
|
||||
this.presets = Object.fromEntries(
|
||||
Object.entries(EFFECT_PRESETS).map(([name, preset]) => [name, { ...preset }])
|
||||
);
|
||||
this.musicSource = MUSIC_SOURCE;
|
||||
this.musicSources = [...MUSIC_SOURCES];
|
||||
// Whether the game has asked for music; kept apart from `enabled` so the
|
||||
// sound toggle can mute and unmute without losing the request.
|
||||
this._wanted = false;
|
||||
this._music = null;
|
||||
// The shuffled order of track indexes and how far into it playback is.
|
||||
this._musicOrder = [];
|
||||
this._musicCursor = 0;
|
||||
this._random = options.random || Math.random;
|
||||
this._retryArmed = false;
|
||||
// Web Audio context for the shaped effects, built on first use. `null`
|
||||
// after a failure so the plain element path is used from then on.
|
||||
@@ -259,10 +284,22 @@ export class AudioManager {
|
||||
|
||||
_startMusic() {
|
||||
if (!this.enabled || !this._wanted || !this.supported() || this._music) return;
|
||||
const source = this._nextMusicSource();
|
||||
if (!source) return;
|
||||
try {
|
||||
const music = new Audio(this.musicSource);
|
||||
music.loop = true;
|
||||
const music = new Audio(source);
|
||||
music.volume = MUSIC_VOLUME;
|
||||
if (this.musicSources.length <= 1) {
|
||||
// A lone track has nothing to hand over to, so it loops.
|
||||
music.loop = true;
|
||||
} else {
|
||||
// Otherwise the end of a track starts the next one in the shuffle.
|
||||
music.onended = () => {
|
||||
if (this._music !== music) return;
|
||||
this._music = null;
|
||||
this._startMusic();
|
||||
};
|
||||
}
|
||||
this._music = music;
|
||||
const playback = music.play();
|
||||
if (playback && typeof playback.catch === "function") {
|
||||
@@ -276,6 +313,18 @@ export class AudioManager {
|
||||
}
|
||||
}
|
||||
|
||||
// The next track of the shuffled playlist, reshuffling once a full pass ends.
|
||||
_nextMusicSource() {
|
||||
if (this.musicSources.length === 0) return null;
|
||||
if (this._musicCursor >= this._musicOrder.length) {
|
||||
this._musicOrder = shuffledIndexes(this.musicSources.length, this._random);
|
||||
this._musicCursor = 0;
|
||||
}
|
||||
const index = this._musicOrder[this._musicCursor];
|
||||
this._musicCursor += 1;
|
||||
return this.musicSources[index];
|
||||
}
|
||||
|
||||
_releaseMusic() {
|
||||
if (this._music) {
|
||||
try {
|
||||
|
||||
@@ -63,6 +63,18 @@ export const CREDIT_SECTIONS = [
|
||||
name: "Crashed Ship",
|
||||
detail: "in-game background music, copyright Ted Kerr",
|
||||
},
|
||||
{
|
||||
name: "Dark City",
|
||||
detail: "in-game background music, by Muncheybobo, used under CC BY 3.0",
|
||||
},
|
||||
{
|
||||
name: "Constellation",
|
||||
detail: "in-game background music, by Trevor Lentz, used under CC BY-SA 3.0",
|
||||
},
|
||||
{
|
||||
name: "Patrol",
|
||||
detail: "in-game background music, by Alexandr Zhelanov, used under CC BY 3.0",
|
||||
},
|
||||
{
|
||||
name: "UI Sound Library",
|
||||
detail: "button, completion and notification effects",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "54efd66",
|
||||
"date": "2026-09-24",
|
||||
"subject": "Grouped the unit stack by type and let ranged units pick a target",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "1c6f0ae",
|
||||
"date": "2026-09-24",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-23",
|
||||
"subject": "Turned unit upkeep into a resource bill and gave research the erlenmeyer icon",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "15d0c38",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Gave figures a unified signed display and templated the resource table",
|
||||
"body": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -48,6 +48,8 @@ export class GameScreen {
|
||||
this._regionVersion = undefined;
|
||||
this._tileProductionPenalties = new Map();
|
||||
this._tileBattleProductionDeficits = new Map();
|
||||
// Tiles crippled by sabotage, keyed "x,y".
|
||||
this._tileSabotage = new Set();
|
||||
this._territoryOwner = new Map();
|
||||
this._tileRegion = new Map();
|
||||
this._gdpExpanded = false;
|
||||
@@ -77,6 +79,9 @@ export class GameScreen {
|
||||
// Previous snapshot's strike state per unit, to spot a plane's bomb run
|
||||
// ending (see `_detectAirStrikes`).
|
||||
this._airStrikeState = null;
|
||||
// Whether the next missile attack should carry a nuclear warhead (see the
|
||||
// unit panel's toggle).
|
||||
this._nuclearPayload = false;
|
||||
// While set, the map is picking a ranged-strike target: { ids } of the
|
||||
// units that will fire. See the Attack action in the unit panel.
|
||||
this._targeting = null;
|
||||
@@ -117,9 +122,14 @@ export class GameScreen {
|
||||
this.$stackMenu = $("#stack-menu");
|
||||
this.$statTooltip = $("#stat-tooltip");
|
||||
this._hoveredStat = null;
|
||||
this.$chat = $("#chat");
|
||||
this.$chatLog = $("#chat-log");
|
||||
this.$chatInput = $("#chat-input");
|
||||
this.$chatTarget = $("#chat-target");
|
||||
|
||||
this._bindMap();
|
||||
this._bindHud();
|
||||
this._bindChat();
|
||||
}
|
||||
|
||||
enter() {
|
||||
@@ -158,6 +168,7 @@ export class GameScreen {
|
||||
this._hideStackMenu();
|
||||
this.mapModesModal.close();
|
||||
$(document).off("mousedown.stackmenu");
|
||||
this.closeChat();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- orders ---
|
||||
@@ -181,6 +192,7 @@ export class GameScreen {
|
||||
this.civilisations = state.civs.map(
|
||||
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
|
||||
);
|
||||
this._updateChatTargets(this.civilisations, this.localCiv);
|
||||
this.protoUnits = state.protos.map(
|
||||
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
|
||||
);
|
||||
@@ -225,6 +237,7 @@ export class GameScreen {
|
||||
this._tileBattleProductionDeficits = new Map(
|
||||
(state.tileBattleProductionDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
this._tileSabotage = new Set((state.tileSabotage || []).map((e) => `${e[0]},${e[1]}`));
|
||||
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
|
||||
this._cityBuildings = new Map();
|
||||
for (const city of state.cities) {
|
||||
@@ -369,15 +382,15 @@ export class GameScreen {
|
||||
return !!(proto && proto.transportCapacity > 0);
|
||||
}
|
||||
|
||||
// Right-click transport orders: infantry board a friendly launch they stand
|
||||
// beside, and a loaded launch puts its passengers ashore on an adjacent land
|
||||
// tile. Returns true when the click was consumed.
|
||||
// Right-click transport orders: infantry (and spies) board a friendly launch
|
||||
// they stand beside, and a loaded launch puts its passengers ashore on an
|
||||
// adjacent land tile. Returns true when the click was consumed.
|
||||
_tryTransportOrder(units, coords) {
|
||||
const infantry = units.every((unit) => {
|
||||
const embarkable = units.every((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return proto && proto.infantry;
|
||||
return proto && (proto.infantry || proto.spy);
|
||||
});
|
||||
if (infantry) {
|
||||
if (embarkable) {
|
||||
const launch = (this.snapshot.units || []).find(
|
||||
(unit) =>
|
||||
unit.civ === this.localCiv &&
|
||||
@@ -447,6 +460,7 @@ export class GameScreen {
|
||||
if (!proto) return false;
|
||||
if (striking) {
|
||||
const from = { x: unit.coords[0], y: unit.coords[1] };
|
||||
if (proto.globalRange) return false;
|
||||
return this.map.topology.tileDistance(from, coords) > (proto.range || 1);
|
||||
}
|
||||
const route = routes.get(unit.id);
|
||||
@@ -479,6 +493,16 @@ export class GameScreen {
|
||||
const order = { type, coords: [coords.x, coords.y] };
|
||||
if (ids.length === 1) order.unit = ids[0];
|
||||
else order.units = ids;
|
||||
// A missile volley carries the warhead the player armed: nuclear only when
|
||||
// the toggle is on and every unit in the stack is a missile.
|
||||
if (
|
||||
type === "attack" &&
|
||||
this._nuclearPayload &&
|
||||
units.length > 0 &&
|
||||
units.every((unit) => this._isMissileUnit(unit))
|
||||
) {
|
||||
order.nuclear = true;
|
||||
}
|
||||
if (type === "schedule") {
|
||||
order.goals = [[coords.x, coords.y]];
|
||||
order.append = true;
|
||||
@@ -515,10 +539,102 @@ export class GameScreen {
|
||||
this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- chat ---
|
||||
|
||||
// Wires the chat input: Enter sends the line, Escape closes the panel.
|
||||
_bindChat() {
|
||||
if (!this.$chatInput || !this.$chatInput.length) return;
|
||||
this.$chatInput.on("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const text = this.$chatInput.val();
|
||||
this.$chatInput.val("");
|
||||
this.network.sendChat(text, this._chatRecipient());
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.closeChat();
|
||||
}
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
// T opens the chat: the log fades in at the bottom centre and the input takes
|
||||
// focus so the player can type straight away.
|
||||
openChat() {
|
||||
if (!this.$chat || !this.$chat.length) return;
|
||||
this.$chat.removeClass("hidden");
|
||||
this.$chatInput.trigger("focus");
|
||||
}
|
||||
|
||||
closeChat() {
|
||||
if (!this.$chat || !this.$chat.length) return;
|
||||
this.$chat.addClass("hidden");
|
||||
this.$chatInput.trigger("blur");
|
||||
}
|
||||
|
||||
// The nation a chat line is addressed to, or null for the public channel.
|
||||
_chatRecipient() {
|
||||
if (!this.$chatTarget || !this.$chatTarget.length) return null;
|
||||
const value = this.$chatTarget.val();
|
||||
if (value === "" || value === null || value === undefined || value === "public") return null;
|
||||
const civ = Number(value);
|
||||
return Number.isInteger(civ) && civ >= 0 ? civ : null;
|
||||
}
|
||||
|
||||
// Rebuilds the chat recipient list: the public channel plus every other
|
||||
// nation. Rebuilt only when the roster or the viewer changes.
|
||||
_updateChatTargets(civs, localCiv) {
|
||||
if (!this.$chatTarget || !this.$chatTarget.length) return;
|
||||
const signature = `${localCiv}:${civs.map((civ) => civ.id).join(",")}`;
|
||||
if (this._chatTargetSignature === signature) return;
|
||||
this._chatTargetSignature = signature;
|
||||
const previous = this.$chatTarget.val();
|
||||
this.$chatTarget.empty();
|
||||
this.$chatTarget.append($("<option value='public'></option>").text("Everyone"));
|
||||
civs.forEach((civ, index) => {
|
||||
if (index === localCiv) return;
|
||||
this.$chatTarget.append($("<option></option>").attr("value", index).text(civ.name));
|
||||
});
|
||||
if (previous !== null && previous !== undefined) this.$chatTarget.val(previous);
|
||||
}
|
||||
|
||||
// Appends one line to the chat log. Messages are read bottom to top: the
|
||||
// newest sits by the input, the oldest scroll off the top, and the list is
|
||||
// capped so a long game cannot grow it without bound.
|
||||
addChat(message) {
|
||||
if (!this.$chatLog || !this.$chatLog.length) return;
|
||||
const system = !message.name;
|
||||
const $line = $("<div class='chat-line'></div>");
|
||||
if (system) $line.addClass("chat-system");
|
||||
const to = Number.isInteger(message.to) ? message.to : null;
|
||||
const $author = $("<span class='chat-author'></span>").text(system ? "·" : message.name);
|
||||
if (to !== null) {
|
||||
const target = this.civilisations[to];
|
||||
$line.addClass("chat-private");
|
||||
$author.text(`${message.name} → ${target ? target.name : `Nation ${to + 1}`}`);
|
||||
}
|
||||
$line.append($author);
|
||||
$line.append($("<span class='chat-text'></span>").text(message.text || ""));
|
||||
this.$chatLog.append($line);
|
||||
const maxLines = 100;
|
||||
const children = this.$chatLog.children();
|
||||
if (children.length > maxLines) {
|
||||
children.slice(0, children.length - maxLines).remove();
|
||||
}
|
||||
this.$chatLog.scrollTop(this.$chatLog[0].scrollHeight);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- input ---
|
||||
|
||||
_onKey(event) {
|
||||
if (!this._entered) return;
|
||||
// A text field owns its own keys, so typing never pans the map.
|
||||
if ($(event.target).is("input, textarea")) return;
|
||||
if (event.key === "t" || event.key === "T") {
|
||||
this.openChat();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const step = event.shiftKey ? 120 : 40;
|
||||
switch (event.key) {
|
||||
case "w": case "ArrowUp": this.map.panBy(0, step); break;
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
NEWS_TECHNOLOGY,
|
||||
NEWS_JOIN,
|
||||
NEWS_FAMINE,
|
||||
NEWS_SABOTAGE,
|
||||
NEWS_SATELLITE,
|
||||
NEWS_ENRICHMENT,
|
||||
NEWS_TREATY,
|
||||
TREATY_PROPOSED,
|
||||
treatyById,
|
||||
ECONOMY,
|
||||
EFFECT_PRODUCTION,
|
||||
APPROVAL,
|
||||
@@ -30,6 +36,11 @@ import {
|
||||
RESOURCE_RULES,
|
||||
resourceBuildingOutputAt,
|
||||
tileImprovementById,
|
||||
SPY_OPERATIONS,
|
||||
SPY_SABOTAGE,
|
||||
SPY_CITY,
|
||||
SPY_COMMS,
|
||||
BUILDING_MECHANIC,
|
||||
} from "../../../shared/data.js";
|
||||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||||
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||||
@@ -80,6 +91,8 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "government", government: index });
|
||||
this.nationModal.onFocusTechnology = (index) =>
|
||||
this.sendOrder({ type: "focus_technology", technology: index });
|
||||
this.nationModal.onUnlockTechnology = (index) =>
|
||||
this.sendOrder({ type: "unlock_technology", technology: index, free: true });
|
||||
this.nationModal.onDeclareWar = (target) =>
|
||||
this.sendOrder({ type: "declare_war", target });
|
||||
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
|
||||
@@ -88,6 +101,9 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "cancel_campaign", campaign });
|
||||
this.nationModal.onSetCampaignSpending = (campaign, amount) =>
|
||||
this.sendOrder({ type: "campaign_spending", campaign, amount });
|
||||
this.nationModal.onLocateUnit = (id) => this._locateUnit(id);
|
||||
this.nationModal.onLaunchSatellite = (cityId) =>
|
||||
this.sendOrder({ type: "launch_satellite", city: cityId });
|
||||
this.nationModal.onConfirm = (title, message, onConfirm) =>
|
||||
this.confirmModal.show(title, message, onConfirm);
|
||||
this.nationModal.onPolicy = (policy, ethnicity) =>
|
||||
@@ -98,6 +114,12 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "set_interest_rate", rate });
|
||||
this.nationModal.onSetTaxRate = (kind, rate, resource) =>
|
||||
this.sendOrder({ type: "set_tax_rate", kind, rate, resource });
|
||||
this.nationModal.onProposeTreaty = (type, to, payload) =>
|
||||
this.sendOrder({ type: "propose_treaty", treaty: type, to, payload });
|
||||
this.nationModal.onRespondTreaty = (proposal, accept) =>
|
||||
this.sendOrder({ type: "respond_treaty", proposal, accept });
|
||||
this.nationModal.onCancelTreaty = (treatyId) =>
|
||||
this.sendOrder({ type: "cancel_treaty", treatyId });
|
||||
|
||||
$("#unit-attack")
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
@@ -110,6 +132,7 @@ export const panelMethods = {
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
.on("mouseleave", () => this._hideStatTooltip());
|
||||
$("#unit-trench").on("click", () => this._digTrenchSelected());
|
||||
$("#unit-nuclear").on("click", () => this._toggleNuclearPayload());
|
||||
$("#unit-attack-order").on("click", () => this._toggleTargeting());
|
||||
|
||||
// Every summary stat explains itself on hover through the shared bubble.
|
||||
@@ -371,6 +394,180 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
|
||||
},
|
||||
|
||||
// A missile warhead is conventional unless the player arms it otherwise. The
|
||||
// toggle only shows for a nuclear power with missiles selected.
|
||||
_updateNuclearButton(units) {
|
||||
const $button = $("#unit-nuclear");
|
||||
if (!$button.length) return;
|
||||
const missiles = units.length > 0 && units.every((unit) => this._isMissileUnit(unit));
|
||||
const armed = missiles && this._hasTechnology("nuclear_weapons");
|
||||
$button.toggleClass("hidden", !armed);
|
||||
$button.toggleClass("active", armed && !!this._nuclearPayload);
|
||||
},
|
||||
|
||||
_toggleNuclearPayload() {
|
||||
this._nuclearPayload = !this._nuclearPayload;
|
||||
const units = this._selectedUnits();
|
||||
this._updateNuclearButton(units);
|
||||
},
|
||||
|
||||
// The covert operations a spy may run, as buttons in the unit panel. Built
|
||||
// once from the shared catalogue; disabled until each action is wired end to
|
||||
// end (see INTELLIGENCE.md), so the player sees where they will live.
|
||||
_buildSpyActionButtons() {
|
||||
const $actions = $("#unit-spy-actions");
|
||||
if (!$actions.length || $actions.data("built")) return;
|
||||
$actions.data("built", true);
|
||||
for (const operation of SPY_OPERATIONS) {
|
||||
const $button = $("<button class='aero'></button>")
|
||||
.attr("data-operation", operation.id)
|
||||
.attr("title", "Not yet available")
|
||||
.text(operation.name)
|
||||
.prop("disabled", true);
|
||||
$button.on("click", () => this._runSpyOperation(operation.id));
|
||||
$actions.append($button);
|
||||
}
|
||||
// A saboteur may try to pin the act on another nation. It is a bluff either
|
||||
// way: the victim cannot tell a true blame from a false flag.
|
||||
const $frame = $("<select class='spy-frame' id='unit-sabotage-frame'></select>")
|
||||
.attr("title", "Pin the sabotage on another nation");
|
||||
$frame.append($("<option value=''></option>").text("Blame no one"));
|
||||
this.civilisations.forEach((civ, index) => {
|
||||
if (index === this.localCiv) return;
|
||||
$frame.append($("<option></option>").attr("value", index).text(civ.name));
|
||||
});
|
||||
$actions.append($frame);
|
||||
},
|
||||
|
||||
// Spy actions show only for a stack of spies. Each is enabled when the spy is
|
||||
// in position; city and comms spying then pick a target on the map.
|
||||
_updateSpyActions(units) {
|
||||
const $actions = $("#unit-spy-actions");
|
||||
if (!$actions.length) return;
|
||||
this._buildSpyActionButtons();
|
||||
const spies = units.length > 0 && units.every((unit) => this._isSpy(unit));
|
||||
$actions.toggleClass("hidden", !spies);
|
||||
if (!spies) return;
|
||||
const armed = this._targeting ? this._targeting.operation : null;
|
||||
for (const operation of SPY_OPERATIONS) {
|
||||
const $button = $actions.find(`[data-operation="${operation.id}"]`);
|
||||
if (!$button.length) continue;
|
||||
let enabled = false;
|
||||
let hint = "Not yet available";
|
||||
if (operation.id === SPY_SABOTAGE) {
|
||||
enabled = this._canSabotage(units);
|
||||
hint = enabled
|
||||
? "Cripple this tile's production for a week (spends the spies)"
|
||||
: "Move the spy onto foreign land";
|
||||
} else if (operation.id === SPY_CITY) {
|
||||
enabled = this._spyActionTarget(SPY_CITY, units);
|
||||
hint = enabled
|
||||
? "Read a neighbouring foreign city (spends the spies)"
|
||||
: "Move the spy beside a foreign city";
|
||||
} else if (operation.id === SPY_COMMS) {
|
||||
enabled = this._spyActionTarget(SPY_COMMS, units);
|
||||
hint = enabled
|
||||
? "Intercept a neighbouring capital's messages (spends the spies)"
|
||||
: "Move the spy beside a foreign capital";
|
||||
}
|
||||
const active = armed === operation.id;
|
||||
$button
|
||||
.prop("disabled", !enabled)
|
||||
.attr("title", hint)
|
||||
.toggleClass("active", active)
|
||||
.text(active ? `${operation.name} — pick a target` : operation.name);
|
||||
}
|
||||
},
|
||||
|
||||
_runSpyOperation(operation) {
|
||||
if (operation === SPY_SABOTAGE) this._sabotageSelected();
|
||||
else if (operation === SPY_CITY || operation === SPY_COMMS) this._toggleSpyOperation(operation);
|
||||
},
|
||||
|
||||
// Whether the selected stack may sabotage the tile it stands on: all spies,
|
||||
// sharing one foreign land tile. Mirrors GameState.canSabotage.
|
||||
_canSabotage(units) {
|
||||
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return false;
|
||||
const coords = units[0].coords;
|
||||
if (!units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1])) {
|
||||
return false;
|
||||
}
|
||||
const k = `${coords[0]},${coords[1]}`;
|
||||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
|
||||
if (owner === null || owner === undefined || owner === this.localCiv) return false;
|
||||
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
|
||||
return !!tile && tile.terrainClass === "Land";
|
||||
},
|
||||
|
||||
_sabotageSelected() {
|
||||
const units = this._selectedUnits();
|
||||
if (!this._canSabotage(units)) return;
|
||||
const raw = $("#unit-sabotage-frame").val();
|
||||
const frame = raw === "" || raw === null || raw === undefined ? null : Number(raw);
|
||||
const order = { type: "sabotage", units: units.map((unit) => unit.id) };
|
||||
if (Number.isInteger(frame) && frame >= 0) order.frame = frame;
|
||||
this.sendOrder(order);
|
||||
},
|
||||
|
||||
// The city standing on `coords`, from the snapshot.
|
||||
_cityAtCoords(coords) {
|
||||
return (
|
||||
(this.snapshot.cities || []).find(
|
||||
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
|
||||
) || null
|
||||
);
|
||||
},
|
||||
|
||||
// Mirrors GameState.canCitySpy: a foreign city within one tile of the spy.
|
||||
_canCitySpy(units, coords) {
|
||||
const city = this._cityAtCoords(coords);
|
||||
if (!city || city.civ === this.localCiv) return false;
|
||||
const from = units[0].coords;
|
||||
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
|
||||
},
|
||||
|
||||
// Mirrors GameState.canCommsSpy: a foreign capital within one tile.
|
||||
_canCommsSpy(units, coords) {
|
||||
const city = this._cityAtCoords(coords);
|
||||
if (!city || !city.isCapital || city.civ === this.localCiv) return false;
|
||||
const from = units[0].coords;
|
||||
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
|
||||
},
|
||||
|
||||
// Whether any tile the spy can watch satisfies the operation's target.
|
||||
_spyActionTarget(operation, units) {
|
||||
if (units.length === 0) return false;
|
||||
const from = units[0].coords;
|
||||
const centre = { x: from[0], y: from[1] };
|
||||
const candidates = [centre, ...this.map.topology.neighbours(centre.x, centre.y)];
|
||||
return candidates.some((coords) =>
|
||||
operation === SPY_CITY
|
||||
? this._canCitySpy(units, coords)
|
||||
: this._canCommsSpy(units, coords)
|
||||
);
|
||||
},
|
||||
|
||||
// Arms target-picking for a spy operation that points at a neighbouring city.
|
||||
_toggleSpyOperation(operation) {
|
||||
const units = this._selectedUnits();
|
||||
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return;
|
||||
if (this._targeting && this._targeting.operation === operation) {
|
||||
this._endTargeting();
|
||||
return;
|
||||
}
|
||||
if (!this._spyActionTarget(operation, units)) return;
|
||||
const ids = units.map((unit) => unit.id);
|
||||
const coords = units[0].coords;
|
||||
this._targeting = { ids, operation };
|
||||
this.map.beginTargeting({
|
||||
unitIds: ids,
|
||||
origin: { x: coords[0], y: coords[1] },
|
||||
range: 1,
|
||||
globalRange: false,
|
||||
});
|
||||
this._updateSpyActions(units);
|
||||
},
|
||||
|
||||
// The Attack action, offered only when every selected unit is the same type
|
||||
// and that type strikes at range. It enters target-picking mode rather than
|
||||
// firing straight away.
|
||||
@@ -398,7 +595,7 @@ export const panelMethods = {
|
||||
if (!this._isRangedUnit(units[0])) return;
|
||||
const proto = this.protoUnits[units[0].proto];
|
||||
const ids = units.map((unit) => unit.id);
|
||||
this._targeting = { ids };
|
||||
this._targeting = { ids, operation: "attack" };
|
||||
this.map.beginTargeting({
|
||||
unitIds: ids,
|
||||
origin: { x: units[0].coords[0], y: units[0].coords[1] },
|
||||
@@ -413,20 +610,47 @@ export const panelMethods = {
|
||||
this._targeting = null;
|
||||
this.map.clearTargeting();
|
||||
this._updateAttackButton(this._selectedUnits());
|
||||
this._updateSpyActions(this._selectedUnits());
|
||||
},
|
||||
|
||||
// A left-click in target-picking mode: fire when the tile holds a hostile
|
||||
// military unit, otherwise just leave the mode.
|
||||
// A left-click in target-picking mode. A ranged attack fires when the tile
|
||||
// holds a hostile military unit; a spy operation files its report or opens its
|
||||
// bombardment when the tile holds the city it wants.
|
||||
_chooseTarget(coords) {
|
||||
const targeting = this._targeting;
|
||||
if (!targeting) return;
|
||||
const units = targeting.ids.map((id) => this._unit(id)).filter(Boolean);
|
||||
this._endTargeting();
|
||||
if (units.length === 0) return;
|
||||
if (targeting.operation === SPY_CITY) {
|
||||
if (this._canCitySpy(units, coords)) {
|
||||
this.sendOrder({
|
||||
type: "spy_city",
|
||||
units: units.map((unit) => unit.id),
|
||||
coords: [coords.x, coords.y],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (targeting.operation === SPY_COMMS) {
|
||||
if (this._canCommsSpy(units, coords)) {
|
||||
this.sendOrder({
|
||||
type: "spy_comms",
|
||||
units: units.map((unit) => unit.id),
|
||||
coords: [coords.x, coords.y],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this._isEnemyMilitaryAt(coords)) return;
|
||||
this._orderMove(units, coords, "attack");
|
||||
},
|
||||
|
||||
_isMissileUnit(unit) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && proto.missile);
|
||||
},
|
||||
|
||||
_isMilitaryUnit(unit) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && proto.military);
|
||||
@@ -771,7 +995,9 @@ export const panelMethods = {
|
||||
this._renderStackList(members);
|
||||
this._updatePillageButton(units);
|
||||
this._updateTrenchButton(units);
|
||||
this._updateNuclearButton(units);
|
||||
this._updateAttackButton(units);
|
||||
this._updateSpyActions(units);
|
||||
this._updateCargo(units);
|
||||
$("#unit-info").removeClass("hidden");
|
||||
this._refreshPanelVisibility();
|
||||
@@ -976,7 +1202,9 @@ export const panelMethods = {
|
||||
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
|
||||
this._updatePillageButton([unit]);
|
||||
this._updateTrenchButton([unit]);
|
||||
this._updateNuclearButton([unit]);
|
||||
this._updateAttackButton([unit]);
|
||||
this._updateSpyActions([unit]);
|
||||
this._updateCargo([unit]);
|
||||
$("#unit-info").removeClass("hidden");
|
||||
this._refreshPanelVisibility();
|
||||
@@ -1724,6 +1952,7 @@ export const panelMethods = {
|
||||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||||
pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
|
||||
sabotaged: this._tileSabotage.has(k),
|
||||
battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
|
||||
trench: this.map.trenches.has(k),
|
||||
regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
|
||||
@@ -1957,10 +2186,11 @@ export const panelMethods = {
|
||||
this._updateCityModal();
|
||||
},
|
||||
|
||||
// The units a city may train right now. Aircraft and warships only appear
|
||||
// once the city has the installation that trains them, so the order can never
|
||||
// be sent and rejected. Recomputed each snapshot so a building finished while
|
||||
// the modal is open unlocks its unit without reopening the panel.
|
||||
// The units a city may train. A unit whose building or research is missing is
|
||||
// still listed, marked `locked` with the requirement, so the player can see
|
||||
// what a city could build and what it needs; only the unlocked ones take an
|
||||
// order. Recomputed each snapshot so a building or technology finished while
|
||||
// the modal is open flips its card without reopening the panel.
|
||||
_trainableUnits(city) {
|
||||
const levels = this._cityBuildings.get(city.id) || {};
|
||||
const hasBuilding = (id) => {
|
||||
@@ -1969,8 +2199,42 @@ export const panelMethods = {
|
||||
};
|
||||
return TRAINABLE_UNIT_IDS.map((id) => {
|
||||
const index = this.protoUnits.findIndex((p) => p.id === id);
|
||||
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
|
||||
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
|
||||
if (index < 0) return null;
|
||||
const proto = this.protoUnits[index];
|
||||
const requirement = this._unitRequirement(proto, hasBuilding);
|
||||
return {
|
||||
...proto,
|
||||
serverIndex: index,
|
||||
locked: !!requirement,
|
||||
requiresLabel: requirement,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
// The first unmet requirement on a trainable unit, as a short label ("Requires
|
||||
// Airport"), or an empty string when nothing blocks it.
|
||||
_unitRequirement(proto, hasBuilding) {
|
||||
if (proto.requiresBuilding && !hasBuilding(proto.requiresBuilding)) {
|
||||
const building = BUILDINGS.find((entry) => entry.id === proto.requiresBuilding);
|
||||
return `Requires ${building ? building.name : proto.requiresBuilding}`;
|
||||
}
|
||||
const technologies = Array.isArray(proto.requiresTechnologies)
|
||||
? proto.requiresTechnologies
|
||||
: proto.requiresTechnology
|
||||
? [proto.requiresTechnology]
|
||||
: [];
|
||||
const missing = technologies.find((id) => !this._hasTechnology(id));
|
||||
if (missing) {
|
||||
const tech = TECHNOLOGIES.find((entry) => entry.id === missing);
|
||||
return `Requires ${tech ? tech.name : missing}`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
// Whether the nation the viewer controls has researched a technology, by id.
|
||||
_hasTechnology(id) {
|
||||
const index = TECHNOLOGIES.findIndex((tech) => tech.id === id);
|
||||
return index >= 0 && this._researched.has(index);
|
||||
},
|
||||
|
||||
_cityView(city) {
|
||||
@@ -2143,7 +2407,8 @@ export const panelMethods = {
|
||||
this._politicsInfo(),
|
||||
this._techInfo(),
|
||||
this._resourcesInfo(),
|
||||
this._moneyInfo()
|
||||
this._moneyInfo(),
|
||||
this._intelligenceInfo()
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2202,11 +2467,26 @@ export const panelMethods = {
|
||||
// Progress is kept per technology, so a focus that was switched away from
|
||||
// still shows what it had committed when it is selected again.
|
||||
const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
|
||||
// The buildings the viewer's cities hold, so a technology gated behind one
|
||||
// (nuclear weapons behind the uranium enrichment centre) shows as locked.
|
||||
const buildings = new Set();
|
||||
for (const levels of this._cityBuildings.values()) {
|
||||
for (const index of Object.keys(levels)) {
|
||||
if ((levels[index] || 0) > 0) {
|
||||
const proto = BUILDINGS[Number(index)];
|
||||
if (proto) buildings.add(proto.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
focus: focusIndex,
|
||||
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
|
||||
progressByTech: byTech,
|
||||
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
|
||||
buildings,
|
||||
// In a local testing game the technology cards gain an instant-unlock
|
||||
// button, like the free build and train buttons.
|
||||
testing: !!this.testing,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2225,16 +2505,33 @@ export const panelMethods = {
|
||||
this._politicsInfo(),
|
||||
this._techInfo(),
|
||||
this._resourcesInfo(),
|
||||
this._moneyInfo()
|
||||
this._moneyInfo(),
|
||||
this._intelligenceInfo()
|
||||
);
|
||||
},
|
||||
|
||||
_diplomacyInfo() {
|
||||
const snapshot = this.snapshot || {};
|
||||
const stats = snapshot.viewerStats || {};
|
||||
return {
|
||||
civs: this.civilisations,
|
||||
localCiv: this.localCiv,
|
||||
relations: (this.snapshot && this.snapshot.diplomacy) || [],
|
||||
stats: (this.snapshot && this.snapshot.civStats) || [],
|
||||
relations: (snapshot.diplomacy) || [],
|
||||
stats: snapshot.civStats || [],
|
||||
// Who is behind each nation: the network's player table, keyed by peer id,
|
||||
// each entry naming the nation they claimed. A nation with no player is AI.
|
||||
players: (this.network && this.network.players) || {},
|
||||
// Treaties: offers awaiting an answer, the agreements in force and the
|
||||
// ones that ended.
|
||||
treaties: snapshot.treaties || [],
|
||||
treatyProposals: snapshot.treatyProposals || [],
|
||||
treatyHistory: snapshot.treatyHistory || [],
|
||||
// What the viewer may offer: its own cities and non-infantry units, plus
|
||||
// the stores it can hand over.
|
||||
localCities: (snapshot.cities || []).filter((city) => city.civ === this.localCiv),
|
||||
localUnits: (snapshot.units || []).filter((unit) => unit.civ === this.localCiv),
|
||||
unitProtos: this.protoUnits,
|
||||
resources: stats.resources || null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2255,6 +2552,96 @@ export const panelMethods = {
|
||||
};
|
||||
},
|
||||
|
||||
// Everything the Intelligence tab needs from the snapshot: the viewer's spies
|
||||
// and where they stand, the reports they have brought home, the satellites in
|
||||
// orbit, the propaganda campaigns (moved off Politics) and the opinion
|
||||
// matrices their readout quotes. A spy's covert operations are unit actions,
|
||||
// wired in the unit panel rather than here.
|
||||
_intelligenceInfo() {
|
||||
const snapshot = this.snapshot || {};
|
||||
const localCiv = this.localCiv;
|
||||
const satellites = snapshot.satellites || [];
|
||||
const own = satellites.filter((satellite) => satellite.civ === localCiv);
|
||||
return {
|
||||
civs: this.civilisations,
|
||||
localCiv,
|
||||
totalHours: snapshot.totalHours || 0,
|
||||
campaigns: snapshot.campaigns || [],
|
||||
opinions: snapshot.opinions || { ethnic: [], government: [] },
|
||||
spies: (snapshot.units || [])
|
||||
.filter((unit) => unit.civ === localCiv && this._isSpy(unit))
|
||||
.map((unit) => ({
|
||||
id: unit.id,
|
||||
coords: unit.coords,
|
||||
location: this._unitLocation(unit.coords, localCiv),
|
||||
})),
|
||||
reports: snapshot.spyReports || [],
|
||||
satellites: own,
|
||||
otherSatellites: satellites.filter((satellite) => satellite.civ !== localCiv).length,
|
||||
spaceLaunchCapacity: this._spaceLaunchCapacity(),
|
||||
launchCity: this._launchCity(),
|
||||
};
|
||||
},
|
||||
|
||||
// Total levels of the viewer's space launch centres, and the city it would
|
||||
// launch from. Mirrors GameState._spaceLaunchCapacity. Only the viewer's own
|
||||
// cities count, though `_cityBuildings` carries every nation's.
|
||||
_spaceLaunchCapacity() {
|
||||
let total = 0;
|
||||
for (const city of this.snapshot.cities || []) {
|
||||
if (city.civ !== this.localCiv) continue;
|
||||
const levels = this._cityBuildings.get(city.id) || {};
|
||||
for (const [index, level] of Object.entries(levels)) {
|
||||
if ((level || 0) <= 0) continue;
|
||||
const proto = BUILDINGS[Number(index)];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) total += level;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
_launchCity() {
|
||||
for (const city of this.snapshot.cities || []) {
|
||||
if (city.civ !== this.localCiv) continue;
|
||||
const levels = this._cityBuildings.get(city.id) || {};
|
||||
for (const [index, level] of Object.entries(levels)) {
|
||||
if ((level || 0) <= 0) continue;
|
||||
const proto = BUILDINGS[Number(index)];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) return city;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_isSpy(unit) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && proto.spy);
|
||||
},
|
||||
|
||||
// A short, player-facing description of where a unit stands: the city it is
|
||||
// in, its own territory, a foreign nation, or unclaimed land.
|
||||
_unitLocation(coords, localCiv) {
|
||||
const city = (this.snapshot.cities || []).find(
|
||||
(entry) => entry.coords[0] === coords[0] && entry.coords[1] === coords[1]
|
||||
);
|
||||
if (city) return `at ${city.name}`;
|
||||
const k = `${coords[0]},${coords[1]}`;
|
||||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
|
||||
if (owner === null || owner === undefined) return "in unclaimed territory";
|
||||
if (owner === localCiv) return "in home territory";
|
||||
return `in ${this._civName(owner)}`;
|
||||
},
|
||||
|
||||
// Brings the map to one of the viewer's units and selects it, for the
|
||||
// Intelligence tab's spy roster.
|
||||
_locateUnit(id) {
|
||||
const unit = this._unit(id);
|
||||
if (!unit || unit.civ !== this.localCiv) return;
|
||||
this.map.centerOn({ x: unit.coords[0], y: unit.coords[1] });
|
||||
this._selectUnits([unit.id]);
|
||||
this.nationModal.close();
|
||||
},
|
||||
|
||||
_openNewsModal() {
|
||||
this.newsModal.show(this._newsForModal(), this.civilisations);
|
||||
},
|
||||
@@ -2289,6 +2676,21 @@ export const panelMethods = {
|
||||
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
|
||||
return;
|
||||
}
|
||||
// An offer addressed to the player stays put and can be clicked to review it.
|
||||
if (entry.type === NEWS_TREATY && entry.stage === TREATY_PROPOSED && entry.to === this.localCiv) {
|
||||
const proto = treatyById(entry.treaty);
|
||||
const label = proto ? proto.name : entry.treaty;
|
||||
audio.play("notification");
|
||||
const $line = this.feed.push(
|
||||
`${this._civName(entry.from)} proposes ${label} — click to review`,
|
||||
{ alert: true, ttl: -1 }
|
||||
);
|
||||
if ($line) {
|
||||
$line.addClass("feed-clickable");
|
||||
$line.on("click", () => this._openTreatyProposal(entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A technology the viewer finishes chimes like a building or unit; someone
|
||||
// else taking control of a nation is the neutral notice (Mouth_08).
|
||||
if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) {
|
||||
@@ -2299,12 +2701,71 @@ export const panelMethods = {
|
||||
audio.play("notification");
|
||||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||||
return;
|
||||
} else if (entry.type === NEWS_SABOTAGE && entry.civ === this.localCiv) {
|
||||
// The victim of a sabotage is warned; the culprit may or may not be named.
|
||||
audio.play("notification");
|
||||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||||
return;
|
||||
} else if (entry.type === NEWS_SATELLITE) {
|
||||
// Every launch is public knowledge.
|
||||
audio.play("notification");
|
||||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||||
return;
|
||||
} else if (entry.type === NEWS_ENRICHMENT) {
|
||||
// A secret the world's intelligence services have caught.
|
||||
audio.play("notification");
|
||||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||||
return;
|
||||
} else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
|
||||
audio.play("notification");
|
||||
}
|
||||
this.feed.push(describeNews(entry, this.civilisations));
|
||||
},
|
||||
|
||||
// Opens the terms of an incoming treaty offer with Sign and Reject buttons.
|
||||
// The proposal is looked up by the parties it names, so a click on the feed
|
||||
// line reaches the live record the server shipped.
|
||||
_openTreatyProposal(entry) {
|
||||
const proposals = (this.snapshot && this.snapshot.treatyProposals) || [];
|
||||
const sameTerms = (p) =>
|
||||
p.from === entry.from && p.to === this.localCiv && p.type === entry.treaty;
|
||||
const proposal = proposals.find(
|
||||
(p) => sameTerms(p) && (entry.totalHours === undefined || p.proposedHours === entry.totalHours)
|
||||
) || proposals.find(sameTerms);
|
||||
if (!proposal) return;
|
||||
const proto = treatyById(proposal.type);
|
||||
const label = proto ? proto.name : proposal.type;
|
||||
const terms = this._treatyTermsText(proposal);
|
||||
const message = `${proto ? proto.description : ""}${terms ? ` - ${terms}` : ""}`;
|
||||
this.confirmModal.show(
|
||||
`${this._civName(proposal.from)} proposes ${label}`,
|
||||
message,
|
||||
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: true }),
|
||||
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: false }),
|
||||
"Sign",
|
||||
"Reject"
|
||||
);
|
||||
},
|
||||
|
||||
// A plain-text summary of what a treaty hands over, for the offer dialog.
|
||||
_treatyTermsText(treaty) {
|
||||
const payload = treaty.payload || {};
|
||||
if (payload.city !== undefined) {
|
||||
const city = (this.snapshot && this.snapshot.cities || []).find((c) => c.id === payload.city);
|
||||
return `gives ${city ? city.name : "a city"}`;
|
||||
}
|
||||
if (payload.units) return `gives ${payload.units.length} unit(s)`;
|
||||
if (payload.resources) {
|
||||
const parts = Object.entries(payload.resources).map(([id, amount]) => {
|
||||
const proto = resourceById(id);
|
||||
return `${compact(amount)} ${proto ? proto.name : id}`;
|
||||
});
|
||||
return `gives ${parts.join(", ")}`;
|
||||
}
|
||||
if (payload.amount !== undefined) return `gives ${compact(payload.amount)} money`;
|
||||
return "";
|
||||
},
|
||||
|
||||
// Pops the viewer's own daily resource-shortage notices into the corner feed.
|
||||
// Each building, region and resource is announced once per game day.
|
||||
_updateResourceAlerts(stats) {
|
||||
|
||||
@@ -796,7 +796,11 @@ export const motionMethods = {
|
||||
return false;
|
||||
}
|
||||
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
|
||||
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
|
||||
// A spy slips through foreign territory at will; every other unit treats a
|
||||
// third party's land as a wall (the targeted civilisation is exempt).
|
||||
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv && !proto.spy) {
|
||||
return false;
|
||||
}
|
||||
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
|
||||
const hostile = occupants.filter((data) => data.civ !== civ);
|
||||
if (hostile.length > 0) {
|
||||
|
||||
@@ -103,6 +103,16 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
}
|
||||
}
|
||||
|
||||
// The real resource bills of the last day, as hourly averages, in their own
|
||||
// section so they are not confused with the forecast upkeep rows above.
|
||||
const resourceCosts = breakdown.resourceCosts || [];
|
||||
if (resourceCosts.length > 0) {
|
||||
$body.append(sectionRow("Resource costs"));
|
||||
for (const entry of resourceCosts) {
|
||||
$body.append(detailRow(entry.label, -entry.amount, "expense-detail"));
|
||||
}
|
||||
}
|
||||
|
||||
const $table = $("<table class='budget-table'></table>");
|
||||
$table.append($body);
|
||||
$table.append($("<tfoot></tfoot>").append(totalRow("TOTAL", net)));
|
||||
|
||||
@@ -367,24 +367,28 @@ export class CityModal {
|
||||
const proto = trainableUnits[index];
|
||||
if (!proto.cost && proto.cost !== 0) continue;
|
||||
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
|
||||
const locked = !!proto.locked;
|
||||
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
|
||||
signature.push(serverIndex);
|
||||
signature.push(`${serverIndex}:${locked ? 1 : 0}`);
|
||||
this.protoNames.set(serverIndex, proto.name);
|
||||
const card = buildCard({
|
||||
icon: proto.icon,
|
||||
name: proto.name,
|
||||
status: this._unitStatus(proto),
|
||||
locked,
|
||||
status: locked ? proto.requiresLabel : this._unitStatus(proto),
|
||||
note: this._unitNote(proto),
|
||||
action: {
|
||||
label: "Train",
|
||||
money: this._quotedCost(proto.cost, proto, 0),
|
||||
hours: hours(duration),
|
||||
},
|
||||
action: locked
|
||||
? { label: "Locked", disabled: true }
|
||||
: {
|
||||
label: "Train",
|
||||
money: this._quotedCost(proto.cost, proto, 0),
|
||||
hours: hours(duration),
|
||||
},
|
||||
tip: () => this._unitTip(proto),
|
||||
onAction: () => this.onTrain(this.cityId, serverIndex),
|
||||
onAction: locked ? null : () => this.onTrain(this.cityId, serverIndex),
|
||||
});
|
||||
// In a local test game, a second button trains the unit for free.
|
||||
if (this.testing) {
|
||||
if (this.testing && !locked) {
|
||||
const $free = $("<button class='aero bt-free free-button'></button>")
|
||||
.text(`Free · ${proto.name}`)
|
||||
.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
|
||||
@@ -456,7 +460,9 @@ export class CityModal {
|
||||
// finishes while the panel is open (an airport, say) unlocks its unit without
|
||||
// reopening the city and without discarding the list's scroll position.
|
||||
refreshTrainList(trainableUnits) {
|
||||
const signature = trainableUnits.map((proto) => proto.serverIndex).join(",");
|
||||
const signature = trainableUnits
|
||||
.map((proto) => `${proto.serverIndex}:${proto.locked ? 1 : 0}`)
|
||||
.join(",");
|
||||
if (signature === this._trainableSignature) return;
|
||||
this._renderTrainList(this.gdp, trainableUnits);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@ export class ConfirmModal {
|
||||
this.$modal = $("#modal-confirm");
|
||||
this.$title = $("#confirm-title");
|
||||
this.$message = $("#confirm-message");
|
||||
this.$ok = this.$modal.find("#confirm-ok");
|
||||
this.$cancel = this.$modal.find("#confirm-cancel");
|
||||
this._onConfirm = null;
|
||||
this._onCancel = null;
|
||||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||||
this.$modal.find("#confirm-cancel").on("click", () => this.close());
|
||||
this.$modal.find("#confirm-ok").on("click", () => {
|
||||
this.$cancel.on("click", () => {
|
||||
const callback = this._onCancel;
|
||||
this.close();
|
||||
if (callback) callback();
|
||||
});
|
||||
this.$ok.on("click", () => {
|
||||
const callback = this._onConfirm;
|
||||
this.close();
|
||||
if (callback) callback();
|
||||
@@ -20,15 +27,21 @@ export class ConfirmModal {
|
||||
return !this.$modal.hasClass("hidden");
|
||||
}
|
||||
|
||||
show(title, message, onConfirm) {
|
||||
// Shows a confirmation. `onCancel` (and the button labels) are optional; a
|
||||
// plain confirm keeps the default "Confirm"/"Cancel" wording.
|
||||
show(title, message, onConfirm, onCancel = null, okLabel = null, cancelLabel = null) {
|
||||
this.$title.text(title);
|
||||
this.$message.text(message);
|
||||
this.$ok.text(okLabel || "Confirm");
|
||||
this.$cancel.text(cancelLabel || "Cancel");
|
||||
this._onConfirm = onConfirm || null;
|
||||
this._onCancel = onCancel || null;
|
||||
this.$modal.removeClass("hidden");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$modal.addClass("hidden");
|
||||
this._onConfirm = null;
|
||||
this._onCancel = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared formatting helpers for the modal panels.
|
||||
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { policyById, resourceById } from "../../../shared/data.js";
|
||||
import { policyById, resourceById, treatyById } from "../../../shared/data.js";
|
||||
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
|
||||
import { formatMoneySigned, currencySymbol } from "../currency.js";
|
||||
import { researchNode } from "../ui/card.js";
|
||||
@@ -193,6 +193,33 @@ export function describeNews(entry, civs) {
|
||||
}
|
||||
case "famine":
|
||||
return `${people(entry.civ)} are starving in ${entry.region}: a famine has begun`;
|
||||
case "sabotage": {
|
||||
const base = `Saboteurs have crippled production in ${entry.region}`;
|
||||
if (entry.suspect === null || entry.suspect === undefined) {
|
||||
return `${base} — no one has been blamed`;
|
||||
}
|
||||
return `${base} — suspicion falls on ${name(entry.suspect)}`;
|
||||
}
|
||||
case "satellite":
|
||||
return `${adjective(entry.civ)} rocket has placed a spy satellite in orbit`;
|
||||
case "enrichment":
|
||||
return `Intelligence agencies warn that ${name(entry.civ)} has begun enriching uranium`;
|
||||
case "treaty": {
|
||||
const proto = treatyById(entry.treaty);
|
||||
const label = proto ? proto.name : entry.treaty;
|
||||
switch (entry.stage) {
|
||||
case "proposed":
|
||||
return `${name(entry.from)} proposes ${label} to ${name(entry.to)}`;
|
||||
case "signed":
|
||||
return `${name(entry.from)} and ${name(entry.to)} sign ${label}`;
|
||||
case "rejected":
|
||||
return `${name(entry.to)} rejects ${name(entry.from)}'s ${label} offer`;
|
||||
case "broken":
|
||||
return `${name(entry.from)} and ${name(entry.to)} break their ${label}`;
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return entry.type;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
technologyCost,
|
||||
RESOURCES,
|
||||
RESOURCE_IDS,
|
||||
STORABLE_RESOURCE_IDS,
|
||||
resourceById,
|
||||
PRICE_INDEX_WINDOW_DAYS,
|
||||
TREATIES,
|
||||
treatyById,
|
||||
} from "../../../shared/data.js";
|
||||
import { formatResourceAmount } from "../../../shared/resources.js";
|
||||
import { renderMonthlyBudget } from "./budget.js";
|
||||
@@ -82,6 +85,12 @@ export class NationModal {
|
||||
this.$conflictsList = $("#conflicts-list");
|
||||
this.$politicsSub = $("#politics-sub");
|
||||
this.$politicsList = $("#politics-list");
|
||||
this.$intelligenceSub = $("#intelligence-sub");
|
||||
this.$intelligenceSubtabs = $("#intelligence-subtabs");
|
||||
this.$intelligenceSpyView = $("#intelligence-spies-view");
|
||||
this.$intelligencePropagandaView = $("#intelligence-propaganda-view");
|
||||
// Which Intelligence sub-tab is open: the spy controls or the campaigns.
|
||||
this.intelligenceView = "spies";
|
||||
this.governments = [];
|
||||
this.technologies = [];
|
||||
this.currentGovernment = 0;
|
||||
@@ -110,12 +119,21 @@ export class NationModal {
|
||||
this._politicsCiv = -1;
|
||||
this._politicsState = null;
|
||||
this._politicsWizardOpen = false;
|
||||
this.intelligence = null;
|
||||
// The Intelligence tab, like Politics, is built once so the propaganda
|
||||
// controls survive an arriving snapshot; only its lists refresh.
|
||||
this._intelligenceBuilt = false;
|
||||
this._intelligenceCiv = -1;
|
||||
this._intelligenceState = null;
|
||||
this.onGovernment = () => {};
|
||||
this.onFocusTechnology = () => {};
|
||||
this.onUnlockTechnology = () => {};
|
||||
this.onDeclareWar = () => {};
|
||||
this.onCampaign = () => {};
|
||||
this.onCancelCampaign = () => {};
|
||||
this.onSetCampaignSpending = () => {};
|
||||
this.onLocateUnit = () => {};
|
||||
this.onLaunchSatellite = () => {};
|
||||
this.onPolicy = () => {};
|
||||
this.onRevokePolicy = () => {};
|
||||
this.onSetInterestRate = () => {};
|
||||
@@ -147,12 +165,16 @@ export class NationModal {
|
||||
return quotedMoney(amount, this._prices());
|
||||
}
|
||||
|
||||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) {
|
||||
this.governments = governments;
|
||||
this.technologies = technologies;
|
||||
// Needed before the technology cards are built: a testing game adds the
|
||||
// instant-unlock button when each card is created.
|
||||
if (techInfo) this.techInfo = techInfo;
|
||||
this.diplomacy = diplomacy;
|
||||
this.conflicts = conflicts || [];
|
||||
this.politics = politics;
|
||||
this.intelligence = intelligence;
|
||||
this.resources = resourcesInfo;
|
||||
this.money = moneyInfo;
|
||||
// Rebuild the rows from scratch: reopening the panel must not stack a
|
||||
@@ -165,13 +187,17 @@ export class NationModal {
|
||||
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
|
||||
this._buildTechnologyThemes();
|
||||
this._buildResourceSubtabs();
|
||||
this._buildIntelligenceSubtabs();
|
||||
// Reopening the panel shows the server's rates, not an unsent edit left
|
||||
// over from a previous open.
|
||||
this.$economyTaxes.find(".tax-input").each((_, element) =>
|
||||
this._clearTaxDirty($(element))
|
||||
);
|
||||
this._buildEconomySubtabs();
|
||||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
|
||||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo, intelligence);
|
||||
// Force the treaty compose controls to rebuild their options for this open.
|
||||
$("#treaty-type").data("lastType", null);
|
||||
$("#treaty-target").removeAttr("data-signature");
|
||||
this.$modal.removeClass("hidden");
|
||||
}
|
||||
|
||||
@@ -1301,9 +1327,27 @@ export class NationModal {
|
||||
row.$label = card.$label;
|
||||
row.$meta = card.$meta;
|
||||
row.$fill = card.$progressFill;
|
||||
// A local testing game gains a second button that grants the technology at
|
||||
// once, ignoring its cost and prerequisites.
|
||||
if (this.techInfo && this.techInfo.testing) {
|
||||
const $free = $("<button class='aero bt-free free-button'></button>")
|
||||
.text("Unlock now")
|
||||
.on("click", (event) => {
|
||||
event.stopPropagation();
|
||||
this.onUnlockTechnology(index);
|
||||
});
|
||||
card.$card.append($free);
|
||||
row.$free = $free;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// Whether the viewer's nation holds the building a technology is gated
|
||||
// behind, such as the uranium enrichment centre that unlocks nuclear weapons.
|
||||
_hasTechBuilding(id) {
|
||||
return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id));
|
||||
}
|
||||
|
||||
// The hover detail for a technology card: its description, what its next
|
||||
// level yields, and what it still needs before it can be focused.
|
||||
_technologyTip(row) {
|
||||
@@ -1326,7 +1370,7 @@ export class NationModal {
|
||||
row.$action &&
|
||||
row.$action.prop("disabled") &&
|
||||
!this.researched.has(row.index) &&
|
||||
!prerequisitesMet(proto, this.researched, this.technologies)
|
||||
!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))
|
||||
) {
|
||||
const names = (proto.prerequisites || [])
|
||||
.map((id) => {
|
||||
@@ -1339,7 +1383,7 @@ export class NationModal {
|
||||
return sections;
|
||||
}
|
||||
|
||||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) {
|
||||
this.currentGovernment = currentGovernment;
|
||||
// A snapshot rebuilds the open panels in place; hold each scroll container
|
||||
// where the player left it so the panel does not jump back to the top under
|
||||
@@ -1353,6 +1397,7 @@ export class NationModal {
|
||||
if (diplomacy !== null) this.diplomacy = diplomacy;
|
||||
if (conflicts !== null) this.conflicts = conflicts || [];
|
||||
if (politics !== null) this.politics = politics;
|
||||
if (intelligence !== null) this.intelligence = intelligence;
|
||||
this.$governmentSub.text(
|
||||
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`
|
||||
);
|
||||
@@ -1409,7 +1454,7 @@ export class NationModal {
|
||||
row.$label.text("Researched");
|
||||
row.$action.prop("disabled", true);
|
||||
setCardMeta(row.$meta, {});
|
||||
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
|
||||
} else if (!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))) {
|
||||
row.$status.text("Locked");
|
||||
row.$card.addClass("locked");
|
||||
row.$label.text("Locked");
|
||||
@@ -1437,12 +1482,18 @@ export class NationModal {
|
||||
setCardMeta(row.$meta, {});
|
||||
row.$meta.append(researchNode(cost));
|
||||
}
|
||||
// The testing unlock disappears once a one-off technology is held; a
|
||||
// repeatable keeps it, to grant another level.
|
||||
if (row.$free) {
|
||||
row.$free.toggleClass("hidden", this.researched.has(row.index) && !proto.repeatable);
|
||||
}
|
||||
}
|
||||
this._applyTechnologyFilter();
|
||||
|
||||
this._renderDiplomacy();
|
||||
this._renderConflicts();
|
||||
this._renderPolitics();
|
||||
this._renderIntelligence();
|
||||
for (const entry of scrolls) entry.el.scrollTop = entry.top;
|
||||
}
|
||||
|
||||
@@ -1459,6 +1510,13 @@ export class NationModal {
|
||||
if (!info) return;
|
||||
const civs = info.civs || [];
|
||||
const localCiv = info.localCiv;
|
||||
// Which nations a connected player commands. Everything else is run by the
|
||||
// computer, so the table can say who is human and name the leader.
|
||||
const controllers = new Map();
|
||||
for (const player of Object.values(info.players || {})) {
|
||||
if (!player || !Number.isInteger(player.civ) || player.civ < 0) continue;
|
||||
controllers.set(player.civ, player.name || "");
|
||||
}
|
||||
const wars = new Set();
|
||||
for (const entry of info.relations || []) {
|
||||
if (entry[2] !== "war") continue;
|
||||
@@ -1478,6 +1536,7 @@ export class NationModal {
|
||||
for (let index = 0; index < civs.length; index++) {
|
||||
const stats = (info.stats || [])[index] || {};
|
||||
const self = index === localCiv;
|
||||
const human = controllers.has(index);
|
||||
rows.push({
|
||||
index,
|
||||
name: civs[index].name,
|
||||
@@ -1487,12 +1546,16 @@ export class NationModal {
|
||||
approval: stats.approval || 0,
|
||||
atWar: self ? false : wars.has(index),
|
||||
self,
|
||||
human,
|
||||
leader: human ? controllers.get(index) : "",
|
||||
});
|
||||
}
|
||||
this._sortDiplomacyRows(rows);
|
||||
|
||||
const columns = [
|
||||
{ key: "name", label: "Nation" },
|
||||
{ key: "control", label: "Control" },
|
||||
{ key: "leader", label: "Leader" },
|
||||
{ key: "gdp", label: "GDP", numeric: true },
|
||||
{ key: "gdpPerCapita", label: "GDP /\u00a0capita", numeric: true },
|
||||
{ key: "population", label: "Population", numeric: true },
|
||||
@@ -1522,6 +1585,14 @@ export class NationModal {
|
||||
const $tr = $("<tr></tr>");
|
||||
if (row.self) $tr.addClass("local-nation");
|
||||
$tr.append($("<td class='name'></td>").text(row.name));
|
||||
const $control = $("<td class='control'></td>");
|
||||
const $icon = $("<img class='control-icon'>")
|
||||
.attr("src", row.human ? "assets/icon_human.svg" : "assets/icon_computer.svg")
|
||||
.attr("alt", row.human ? "Player" : "Computer")
|
||||
.attr("title", row.human ? "Played by a human" : "Run by the computer");
|
||||
$control.append($icon);
|
||||
$tr.append($control);
|
||||
$tr.append($("<td class='leader'></td>").text(row.leader || "—"));
|
||||
$tr.append(this._moneyCell(row.gdp, row.index, row.self));
|
||||
$tr.append(this._moneyCell(row.gdpPerCapita, row.index, row.self));
|
||||
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
|
||||
@@ -1547,6 +1618,190 @@ export class NationModal {
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$diplomacyList.append($table);
|
||||
this._renderTreaties(info);
|
||||
}
|
||||
|
||||
// The treaty controls: the compose row (type, target, payload), the offers
|
||||
// waiting for an answer, and the active and past treaties. The compose row
|
||||
// lives outside the table the snapshot refresh rebuilds, so a half-filled
|
||||
// offer is not wiped by the next broadcast.
|
||||
_renderTreaties(info) {
|
||||
if (!info) return;
|
||||
this._bindTreatyCompose(info);
|
||||
const name = (index) => (info.civs[index] ? info.civs[index].name : `Nation ${index + 1}`);
|
||||
const localCiv = info.localCiv;
|
||||
const labelOf = (type) => {
|
||||
const proto = treatyById(type);
|
||||
return proto ? proto.name : type;
|
||||
};
|
||||
|
||||
const $pending = $("#treaty-pending").empty();
|
||||
for (const proposal of info.treatyProposals || []) {
|
||||
const $row = $("<div class='treaty-row'></div>");
|
||||
if (proposal.to === localCiv) {
|
||||
$row.append($("<span class='treaty-text'></span>").text(
|
||||
`${name(proposal.from)} proposes ${labelOf(proposal.type)}`
|
||||
));
|
||||
const $sign = $("<button class='aero'>Sign</button>");
|
||||
$sign.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, true));
|
||||
const $reject = $("<button class='aero'>Reject</button>");
|
||||
$reject.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, false));
|
||||
$row.append($sign, $reject);
|
||||
} else if (proposal.from === localCiv) {
|
||||
$row.append($("<span class='treaty-text'></span>").text(
|
||||
`You proposed ${labelOf(proposal.type)} to ${name(proposal.to)} — awaiting reply`
|
||||
));
|
||||
} else {
|
||||
$row.append($("<span class='treaty-text'></span>").text(
|
||||
`${name(proposal.from)} proposes ${labelOf(proposal.type)} to ${name(proposal.to)}`
|
||||
));
|
||||
}
|
||||
$pending.append($row);
|
||||
}
|
||||
|
||||
const $active = $("#treaty-active").empty();
|
||||
const active = info.treaties || [];
|
||||
if (active.length === 0) $active.append($("<div class='desc'></div>").text("No treaties in force."));
|
||||
for (const treaty of active) {
|
||||
const $row = $("<div class='treaty-row'></div>");
|
||||
$row.append($("<span class='treaty-text'></span>").text(
|
||||
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (signed ${treaty.signedDate})`
|
||||
));
|
||||
if (treaty.a === localCiv || treaty.b === localCiv) {
|
||||
const $break = $("<button class='aero'>Break</button>");
|
||||
$break.on("click", () => this.onCancelTreaty && this.onCancelTreaty(treaty.id));
|
||||
$row.append($break);
|
||||
}
|
||||
$active.append($row);
|
||||
}
|
||||
|
||||
const $past = $("#treaty-past").empty();
|
||||
const past = (info.treatyHistory || []).slice().reverse();
|
||||
if (past.length === 0) $past.append($("<div class='desc'></div>").text("No past treaties."));
|
||||
for (const treaty of past) {
|
||||
$past.append($("<div class='treaty-row'><span class='treaty-text'></span></div>").each(function () {
|
||||
$(this).find(".treaty-text").text(
|
||||
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (${treaty.reason} ${treaty.endedDate})`
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Populates the compose controls once per opened panel. The target list only
|
||||
// needs rebuilding when the nations themselves change.
|
||||
_bindTreatyCompose(info) {
|
||||
const $type = $("#treaty-type");
|
||||
if (!$type.length) return;
|
||||
if ($type.children().length === 0) {
|
||||
for (const proto of TREATIES) {
|
||||
$type.append($("<option></option>").attr("value", proto.id).text(proto.name));
|
||||
}
|
||||
}
|
||||
const signature = (info.civs || []).map((civ) => civ.name).join("|");
|
||||
const $target = $("#treaty-target");
|
||||
if ($target.attr("data-signature") !== signature) {
|
||||
$target.empty();
|
||||
for (let index = 0; index < (info.civs || []).length; index++) {
|
||||
if (index === info.localCiv) continue;
|
||||
$target.append($("<option></option>").attr("value", index).text(info.civs[index].name));
|
||||
}
|
||||
$target.attr("data-signature", signature);
|
||||
}
|
||||
if (!$type.data("treatyBound")) {
|
||||
$type.data("treatyBound", true);
|
||||
$type.on("change", () => {
|
||||
$type.data("lastType", $type.val());
|
||||
this._renderTreatyPayload(info);
|
||||
});
|
||||
$("#treaty-propose").on("click", () => this._treatyPayloadFromForm(info));
|
||||
}
|
||||
// Only rebuild the payload controls when the type changes, so the snapshot
|
||||
// refresh does not wipe a partly filled offer on every broadcast.
|
||||
const current = $type.val();
|
||||
if ($type.data("lastType") !== current) {
|
||||
$type.data("lastType", current);
|
||||
this._renderTreatyPayload(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuilds the per-type payload controls: a city picker, a money amount, a
|
||||
// resource basket or a checklist of giftable units.
|
||||
_renderTreatyPayload(info) {
|
||||
const type = $("#treaty-type").val();
|
||||
const $payload = $("#treaty-payload").empty();
|
||||
if (type === "give_city") {
|
||||
const $select = $("<select class='treaty-city'></select>");
|
||||
for (const city of info.localCities || []) {
|
||||
$select.append($("<option></option>").attr("value", city.id).text(city.name));
|
||||
}
|
||||
if (!(info.localCities || []).length) {
|
||||
$select.append($("<option></option>").attr("value", "").text("No city to give"));
|
||||
}
|
||||
$payload.append($select);
|
||||
} else if (type === "give_money") {
|
||||
$payload.append(
|
||||
$("<input class='treaty-money' type='number' min='0' step='1000' placeholder='amount'>")
|
||||
);
|
||||
} else if (type === "give_resources") {
|
||||
for (const id of STORABLE_RESOURCE_IDS) {
|
||||
const proto = resourceById(id);
|
||||
const $label = $("<label class='treaty-resource-row'></label>");
|
||||
$label.append($("<span></span>").text(proto ? proto.name : id));
|
||||
$label.append(
|
||||
$("<input class='treaty-resource' type='number' min='0' step='1'>").attr("data-id", id)
|
||||
);
|
||||
$payload.append($label);
|
||||
}
|
||||
} else if (type === "give_military") {
|
||||
const options = (info.localUnits || []).filter(
|
||||
(unit) => !((info.unitProtos || {})[unit.proto] || {}).infantry
|
||||
);
|
||||
for (const unit of options) {
|
||||
const proto = (info.unitProtos || {})[unit.proto] || {};
|
||||
const $label = $("<label class='treaty-unit-row'></label>");
|
||||
$label.append($("<input class='treaty-unit' type='checkbox'>").attr("value", unit.id));
|
||||
$label.append($("<span></span>").text(proto.name || `Unit ${unit.id}`));
|
||||
$payload.append($label);
|
||||
}
|
||||
if (options.length === 0) {
|
||||
$payload.append($("<span class='desc'></span>").text("No non-infantry unit to give"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the compose controls into a treaty order. Returns null when the offer
|
||||
// is incomplete, so an empty form sends nothing.
|
||||
_treatyPayloadFromForm(info) {
|
||||
const type = $("#treaty-type").val();
|
||||
const to = Number($("#treaty-target").val());
|
||||
if (!info || Number.isNaN(to) || to === info.localCiv) return null;
|
||||
const payload = {};
|
||||
if (type === "give_city") {
|
||||
const city = Number($("#treaty-payload .treaty-city").val());
|
||||
if (!city) return null;
|
||||
payload.city = city;
|
||||
} else if (type === "give_money") {
|
||||
const amount = Number($("#treaty-payload .treaty-money").val());
|
||||
if (!(amount > 0)) return null;
|
||||
payload.amount = amount;
|
||||
} else if (type === "give_resources") {
|
||||
const resources = {};
|
||||
$("#treaty-payload .treaty-resource").each((_, element) => {
|
||||
const amount = Number($(element).val());
|
||||
if (amount > 0) resources[$(element).data("id")] = amount;
|
||||
});
|
||||
if (Object.keys(resources).length === 0) return null;
|
||||
payload.resources = resources;
|
||||
} else if (type === "give_military") {
|
||||
const units = [];
|
||||
$("#treaty-payload .treaty-unit:checked").each((_, element) => {
|
||||
units.push(Number($(element).val()));
|
||||
});
|
||||
if (units.length === 0) return null;
|
||||
payload.units = units;
|
||||
}
|
||||
if (this.onProposeTreaty) this.onProposeTreaty(type, to, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// A nation's money figure, in that nation's own currency, with the viewer's
|
||||
@@ -1585,6 +1840,9 @@ export class NationModal {
|
||||
rows.sort((a, b) => {
|
||||
let result;
|
||||
if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
else if (key === "leader") {
|
||||
result = (a.leader || "").localeCompare(b.leader || "");
|
||||
} else if (key === "control") result = (a.human ? 1 : 0) - (b.human ? 1 : 0);
|
||||
else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
|
||||
else result = a[key] - b[key];
|
||||
if (result === 0) result = a.index - b.index;
|
||||
@@ -1680,24 +1938,13 @@ export class NationModal {
|
||||
this.$politicsList.empty();
|
||||
this._politicsWizardOpen = false;
|
||||
this._politicsState = {
|
||||
campaign: {
|
||||
observer: localCiv,
|
||||
kind: "government",
|
||||
target: this._otherCiv(localCiv, civs),
|
||||
direction: 1,
|
||||
},
|
||||
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
|
||||
opinionObserver: localCiv,
|
||||
};
|
||||
this._campaignSignature = null;
|
||||
this._campaignRows = new Map();
|
||||
this.$politicsMakeup = $("<div class='politics-card'></div>");
|
||||
this.$politicsCampaigns = $("<div class='politics-card'></div>");
|
||||
this.$politicsMigrations = $("<div class='politics-card'></div>");
|
||||
this.$politicsList.append(
|
||||
this.$politicsMakeup,
|
||||
this.$politicsCampaigns,
|
||||
this._buildCampaignForm(civs),
|
||||
this.$politicsMigrations,
|
||||
this._buildPolicies(civs),
|
||||
this._buildOpinions(civs)
|
||||
@@ -1720,15 +1967,284 @@ export class NationModal {
|
||||
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
|
||||
else this.$politicsMakeup.append($("<div class='desc'></div>").text("No population data."));
|
||||
|
||||
this._fillCampaigns(info, localCiv);
|
||||
this._syncCampaignReadout();
|
||||
this._syncCampaignForm();
|
||||
this._fillMigrations(info, civs, localCiv);
|
||||
this._fillActivePolicies(info, civs, localCiv);
|
||||
this._syncPolicyWizard();
|
||||
this._fillOpinions();
|
||||
}
|
||||
|
||||
// The Intelligence tab's sub-tabs, built once per open: the spy controls and
|
||||
// the propaganda campaigns. Switching only shows and hides the two views.
|
||||
_buildIntelligenceSubtabs() {
|
||||
if (!this.$intelligenceSubtabs || !this.$intelligenceSubtabs.length) return;
|
||||
this.$intelligenceSubtabs.empty();
|
||||
const views = [
|
||||
{ id: "spies", label: "Spies" },
|
||||
{ id: "propaganda", label: "Propaganda" },
|
||||
];
|
||||
for (const view of views) {
|
||||
const $button = $("<button type='button' class='subtab'></button>")
|
||||
.attr("data-view", view.id)
|
||||
.text(view.label);
|
||||
if (view.id === this.intelligenceView) $button.addClass("active");
|
||||
$button.on("click", () => this._setIntelligenceView(view.id));
|
||||
this.$intelligenceSubtabs.append($button);
|
||||
}
|
||||
this._setIntelligenceView(this.intelligenceView);
|
||||
}
|
||||
|
||||
_setIntelligenceView(view) {
|
||||
this.intelligenceView = view;
|
||||
if (this.$intelligenceSubtabs) {
|
||||
this.$intelligenceSubtabs.find(".subtab").removeClass("active");
|
||||
this.$intelligenceSubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
|
||||
}
|
||||
if (this.$intelligenceSpyView) {
|
||||
this.$intelligenceSpyView.toggleClass("hidden", view !== "spies");
|
||||
}
|
||||
if (this.$intelligencePropagandaView) {
|
||||
this.$intelligencePropagandaView.toggleClass("hidden", view !== "propaganda");
|
||||
}
|
||||
}
|
||||
|
||||
// The Intelligence tab: the spy roster and the propaganda campaigns. Spies'
|
||||
// covert operations are unit actions and live in the unit panel, not here.
|
||||
// Built once per open like Politics, so its controls survive the snapshots
|
||||
// that arrive while the tab is open.
|
||||
_renderIntelligence() {
|
||||
if (!this.$intelligenceSpyView || !this.$intelligenceSpyView.length) return;
|
||||
const info = this.intelligence;
|
||||
if (!info) return;
|
||||
const civs = info.civs || [];
|
||||
const localCiv = info.localCiv;
|
||||
if (!this._intelligenceBuilt || this._intelligenceCiv !== localCiv) {
|
||||
this._buildIntelligence(info, civs, localCiv);
|
||||
this._intelligenceBuilt = true;
|
||||
this._intelligenceCiv = localCiv;
|
||||
}
|
||||
this._refreshIntelligence(info, civs, localCiv);
|
||||
}
|
||||
|
||||
_buildIntelligence(info, civs, localCiv) {
|
||||
this.$intelligenceSpyView.empty();
|
||||
this.$intelligencePropagandaView.empty();
|
||||
this._intelligenceState = {
|
||||
campaign: {
|
||||
observer: localCiv,
|
||||
kind: "government",
|
||||
target: this._otherCiv(localCiv, civs),
|
||||
direction: 1,
|
||||
},
|
||||
};
|
||||
this._campaignSignature = null;
|
||||
this._campaignRows = new Map();
|
||||
this._spySignature = null;
|
||||
this._spyRows = new Map();
|
||||
this._reportsSignature = null;
|
||||
this.$intelligenceSpies = $("<div class='politics-card'></div>");
|
||||
this.$intelligenceCampaigns = $("<div class='politics-card'></div>");
|
||||
this.$intelligenceSpyView.append(
|
||||
this._buildSpiesCard(),
|
||||
this._buildReportsCard(),
|
||||
this._buildSatellitesCard()
|
||||
);
|
||||
this.$intelligencePropagandaView.append(
|
||||
this.$intelligenceCampaigns,
|
||||
this._buildCampaignForm(civs)
|
||||
);
|
||||
this._setIntelligenceView(this.intelligenceView);
|
||||
}
|
||||
|
||||
_refreshIntelligence(info, civs, localCiv) {
|
||||
this.$intelligenceSub.text(
|
||||
"Your spies and the propaganda campaigns they serve."
|
||||
);
|
||||
this._fillSpies(info, localCiv);
|
||||
this._fillReports(info);
|
||||
this._fillSatellites(info);
|
||||
this._fillCampaigns(info, localCiv);
|
||||
this._syncCampaignReadout();
|
||||
this._syncCampaignForm();
|
||||
}
|
||||
|
||||
// The spy roster: one row per spy in the field, with a Locate button that
|
||||
// selects it and brings the map to it. Rows are only rebuilt when the roster
|
||||
// (or a spy's whereabouts) changes, so an arriving snapshot does not yank a
|
||||
// button out from under the cursor.
|
||||
_buildSpiesCard() {
|
||||
this.$intelligenceSpies.empty();
|
||||
this.$intelligenceSpies.append($("<div class='name'></div>").text("Your spies"));
|
||||
this.$intelligenceSpyList = $("<div></div>");
|
||||
this.$intelligenceSpies.append(this.$intelligenceSpyList);
|
||||
return this.$intelligenceSpies;
|
||||
}
|
||||
|
||||
_fillSpies(info, localCiv) {
|
||||
if (!this.$intelligenceSpyList) return;
|
||||
const spies = info.spies || [];
|
||||
const signature = spies
|
||||
.map((spy) => `${spy.id}:${spy.coords[0]},${spy.coords[1]}:${spy.location}`)
|
||||
.join("|");
|
||||
if (signature === this._spySignature) return;
|
||||
this._spySignature = signature;
|
||||
this.$intelligenceSpyList.empty();
|
||||
this._spyRows = new Map();
|
||||
if (spies.length === 0) {
|
||||
this.$intelligenceSpyList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
this._hasResearched("intelligence_agencies")
|
||||
? "No spies in the field. Train one in a city."
|
||||
: "Research Intelligence Agencies to train spies."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
spies.forEach((spy, index) => {
|
||||
const $row = $("<div class='politics-row politics-policy-row'></div>");
|
||||
$row.append($("<div></div>").text(`Spy ${index + 1} — ${spy.location}`));
|
||||
const $locate = $("<button class='aero'></button>").text("Locate");
|
||||
$locate.on("click", () => this.onLocateUnit(spy.id));
|
||||
$row.append($("<div class='politics-row-actions'></div>").append($locate));
|
||||
this.$intelligenceSpyList.append($row);
|
||||
this._spyRows.set(spy.id, $row);
|
||||
});
|
||||
}
|
||||
|
||||
// The reports the viewer's spies have brought home: city dossiers and
|
||||
// intercepted communications. Rebuilt only when a report id changes.
|
||||
_buildReportsCard() {
|
||||
this.$intelligenceReports = $("<div class='politics-card'></div>");
|
||||
this.$intelligenceReports.append($("<div class='name'></div>").text("Intelligence reports"));
|
||||
this.$intelligenceReportsList = $("<div></div>");
|
||||
this.$intelligenceReports.append(this.$intelligenceReportsList);
|
||||
return this.$intelligenceReports;
|
||||
}
|
||||
|
||||
_fillReports(info) {
|
||||
if (!this.$intelligenceReportsList) return;
|
||||
const reports = (info.reports || []).slice().reverse();
|
||||
const signature = reports.map((report) => report.id).join(",");
|
||||
if (signature === this._reportsSignature) return;
|
||||
this._reportsSignature = signature;
|
||||
this.$intelligenceReportsList.empty();
|
||||
if (reports.length === 0) {
|
||||
this.$intelligenceReportsList.append(
|
||||
$("<div class='desc'></div>").text("No reports yet. Send a spy to watch a foreign city.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const report of reports) this._buildReportRow(report);
|
||||
}
|
||||
|
||||
_buildReportRow(report) {
|
||||
const owner = this._civName(report.civ);
|
||||
const $row = $("<div class='politics-row spy-report'></div>");
|
||||
if (report.kind === "comms") {
|
||||
$row.append($("<div></div>").text(`${owner} communications from ${report.capital}`));
|
||||
if ((report.messages || []).length === 0) {
|
||||
$row.append($("<div class='desc'></div>").text("No messages intercepted."));
|
||||
}
|
||||
for (const message of report.messages || []) {
|
||||
const from = message.from === null ? "public" : this._civName(message.from);
|
||||
const to = message.to === null ? "public" : this._civName(message.to);
|
||||
$row.append(
|
||||
$("<div class='desc'></div>").text(`${from} → ${to}: ${message.text}`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$row.append($("<div></div>").text(`${owner} city of ${report.city}`));
|
||||
$row.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
`Population ${groupDigits(Math.round(report.population || 0))}`
|
||||
)
|
||||
);
|
||||
if ((report.buildings || []).length > 0) {
|
||||
$row.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
`Buildings: ${report.buildings.map((b) => `${b.name} ${b.level}`).join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if ((report.garrison || []).length > 0) {
|
||||
$row.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
`Garrison: ${report.garrison.map((g) => `${g.name} ×${g.count}`).join(", ")}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$row.append($("<div class='desc'></div>").text("Garrison: none seen."));
|
||||
}
|
||||
}
|
||||
this.$intelligenceReportsList.append($row);
|
||||
}
|
||||
|
||||
// The spy satellites in orbit, and the launch control for a nation that holds
|
||||
// a space launch centre.
|
||||
_buildSatellitesCard() {
|
||||
this.$intelligenceSatellites = $("<div class='politics-card'></div>");
|
||||
this.$intelligenceSatellites.append($("<div class='name'></div>").text("Spy satellites"));
|
||||
this.$intelligenceSatelliteStatus = $("<div class='desc'></div>");
|
||||
this.$intelligenceSatelliteList = $("<div></div>");
|
||||
this.$intelligenceSatelliteLaunch = $("<button class='aero'></button>").text(
|
||||
"Launch spy satellite"
|
||||
);
|
||||
this.$intelligenceSatelliteLaunch.on("click", () => {
|
||||
const city = this.intelligence && this.intelligence.launchCity;
|
||||
if (city) this.onLaunchSatellite(city.id);
|
||||
});
|
||||
this.$intelligenceSatellites.append(
|
||||
this.$intelligenceSatelliteStatus,
|
||||
this.$intelligenceSatelliteList,
|
||||
$("<div class='politics-row-actions'></div>").append(this.$intelligenceSatelliteLaunch)
|
||||
);
|
||||
return this.$intelligenceSatellites;
|
||||
}
|
||||
|
||||
_fillSatellites(info) {
|
||||
if (!this.$intelligenceSatelliteList) return;
|
||||
const satellites = info.satellites || [];
|
||||
const capacity = info.spaceLaunchCapacity || 0;
|
||||
const totalHours = info.totalHours || 0;
|
||||
this.$intelligenceSatelliteList.empty();
|
||||
if (satellites.length === 0) {
|
||||
this.$intelligenceSatelliteList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
capacity > 0
|
||||
? "No satellites in orbit."
|
||||
: "Build a space launch centre to launch spy satellites."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
satellites.forEach((satellite, index) => {
|
||||
const hours = Math.max(0, (satellite.nextRevealHour || 0) - totalHours);
|
||||
const days = Math.ceil(hours / 24);
|
||||
this.$intelligenceSatelliteList.append(
|
||||
$("<div class='politics-row'></div>").text(
|
||||
`Satellite ${index + 1} — next sweep in ${days} day${days === 1 ? "" : "s"}`
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
this.$intelligenceSatelliteStatus.text(
|
||||
info.otherSatellites
|
||||
? `${info.otherSatellites} foreign satellite${info.otherSatellites === 1 ? "" : "s"} in orbit.`
|
||||
: ""
|
||||
);
|
||||
const can = capacity > satellites.length && !!info.launchCity;
|
||||
this.$intelligenceSatelliteLaunch
|
||||
.prop("disabled", !can)
|
||||
.attr(
|
||||
"title",
|
||||
can ? "Everyone will see the launch" : "No free launch pad"
|
||||
);
|
||||
}
|
||||
|
||||
// Whether the viewer has researched the technology with the given id.
|
||||
_hasResearched(id) {
|
||||
const index = this.technologies.findIndex((tech) => tech.id === id);
|
||||
return index >= 0 && this.researched.has(index);
|
||||
}
|
||||
|
||||
// Campaigns the player is running or being targeted by. The rows are only
|
||||
// rebuilt when the set of campaigns (or a spend level) changes, so a slider
|
||||
// being dragged is not yanked away by an arriving snapshot; the cost text is
|
||||
@@ -1740,7 +2256,7 @@ export class NationModal {
|
||||
campaign.observer === localCiv ||
|
||||
campaign.target === localCiv
|
||||
);
|
||||
this.$politicsCampaigns.toggleClass("hidden", campaigns.length === 0);
|
||||
this.$intelligenceCampaigns.toggleClass("hidden", campaigns.length === 0);
|
||||
const signature = campaigns
|
||||
.map((campaign) =>
|
||||
[
|
||||
@@ -1756,10 +2272,10 @@ export class NationModal {
|
||||
.join("|");
|
||||
if (signature !== this._campaignSignature) {
|
||||
this._campaignSignature = signature;
|
||||
this.$politicsCampaigns.empty();
|
||||
this.$intelligenceCampaigns.empty();
|
||||
this._campaignRows = new Map();
|
||||
if (campaigns.length > 0) {
|
||||
this.$politicsCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
|
||||
this.$intelligenceCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
|
||||
for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign);
|
||||
}
|
||||
}
|
||||
@@ -1836,13 +2352,13 @@ export class NationModal {
|
||||
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
|
||||
$row.append($("<div class='politics-row-actions'></div>").append($cancel));
|
||||
}
|
||||
this.$politicsCampaigns.append($row);
|
||||
this.$intelligenceCampaigns.append($row);
|
||||
this._campaignRows.set(campaign.id, row);
|
||||
}
|
||||
|
||||
// The campaign launcher, built once so its selects survive a snapshot.
|
||||
_buildCampaignForm(civs) {
|
||||
const campaign = this._politicsState.campaign;
|
||||
const campaign = this._intelligenceState.campaign;
|
||||
const $card = $("<div class='politics-card'></div>");
|
||||
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
|
||||
$card.append(
|
||||
@@ -1873,7 +2389,7 @@ export class NationModal {
|
||||
const $readout = $("<span class='politics-readout'></span>");
|
||||
this.$campaignReadout = $readout;
|
||||
const sync = () => {
|
||||
this._politicsState.campaign = {
|
||||
this._intelligenceState.campaign = {
|
||||
observer: Number($observer.val()),
|
||||
kind: String($kind.val()),
|
||||
target: Number($target.val()),
|
||||
@@ -1896,7 +2412,7 @@ export class NationModal {
|
||||
const $start = $("<button class='aero'></button>").text("Start campaign");
|
||||
this.$campaignStart = $start;
|
||||
$start.on("click", () => {
|
||||
const state = this._politicsState.campaign;
|
||||
const state = this._intelligenceState.campaign;
|
||||
this.onCampaign(state.observer, state.kind, state.target, state.direction);
|
||||
});
|
||||
this.$campaignDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
|
||||
@@ -1907,9 +2423,9 @@ export class NationModal {
|
||||
|
||||
_syncCampaignReadout() {
|
||||
if (!this.$campaignReadout) return;
|
||||
const info = this.politics;
|
||||
const info = this.intelligence;
|
||||
if (!info) return;
|
||||
const state = this._politicsState.campaign;
|
||||
const state = this._intelligenceState.campaign;
|
||||
const matrix =
|
||||
state.kind === "government" ? info.opinions.government : info.opinions.ethnic;
|
||||
const row = (matrix && matrix[state.observer]) || [];
|
||||
@@ -1920,9 +2436,9 @@ export class NationModal {
|
||||
// Blocks a second campaign on the same population and target.
|
||||
_syncCampaignForm() {
|
||||
if (!this.$campaignStart) return;
|
||||
const info = this.politics;
|
||||
const info = this.intelligence;
|
||||
if (!info) return;
|
||||
const state = this._politicsState.campaign;
|
||||
const state = this._intelligenceState.campaign;
|
||||
const duplicate = (info.campaigns || []).some(
|
||||
(campaign) =>
|
||||
campaign.civ === info.localCiv &&
|
||||
|
||||
@@ -131,6 +131,14 @@ export class Network {
|
||||
this._send({ t: "order", order });
|
||||
}
|
||||
|
||||
sendChat(text, to = null) {
|
||||
const value = String(text || "").trim();
|
||||
if (value.length === 0) return;
|
||||
const message = { t: "chat", text: value };
|
||||
if (Number.isInteger(to) && to >= 0) message.to = to;
|
||||
this._send(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) return;
|
||||
const socket = this.socket;
|
||||
@@ -189,6 +197,14 @@ export class Network {
|
||||
this.players = message.players || {};
|
||||
this._emit("players_updated", this.players);
|
||||
break;
|
||||
case "chat":
|
||||
this._emit("chat_message", {
|
||||
name: message.name || "",
|
||||
civ: message.civ,
|
||||
to: Number.isInteger(message.to) ? message.to : null,
|
||||
text: message.text || "",
|
||||
});
|
||||
break;
|
||||
case "game_list":
|
||||
this._emit("game_list_received", message.games || []);
|
||||
break;
|
||||
|
||||
@@ -205,6 +205,14 @@ export class GameServer extends EventEmitter {
|
||||
return this._handleSchedule(civ, order);
|
||||
case "pillage":
|
||||
return this._handlePillage(civ, order);
|
||||
case "sabotage":
|
||||
return this._handleSabotage(civ, order);
|
||||
case "spy_city":
|
||||
return this._handleSpyCity(civ, order);
|
||||
case "spy_comms":
|
||||
return this._handleSpyComms(civ, order);
|
||||
case "launch_satellite":
|
||||
return this._handleLaunchSatellite(civ, order);
|
||||
case "train":
|
||||
return this._handleTrain(peerId, civ, order);
|
||||
case "cancel_train":
|
||||
@@ -233,8 +241,16 @@ export class GameServer extends EventEmitter {
|
||||
return this._handleGovernment(civ, order);
|
||||
case "focus_technology":
|
||||
return this._handleFocusTechnology(civ, order);
|
||||
case "unlock_technology":
|
||||
return this._handleUnlockTechnology(peerId, civ, order);
|
||||
case "declare_war":
|
||||
return this._handleDeclareWar(civ, order);
|
||||
case "propose_treaty":
|
||||
return this._handleProposeTreaty(civ, order);
|
||||
case "respond_treaty":
|
||||
return this._handleRespondTreaty(civ, order);
|
||||
case "cancel_treaty":
|
||||
return this._handleCancelTreaty(civ, order);
|
||||
case "campaign":
|
||||
return this._handleCampaign(civ, order);
|
||||
case "cancel_campaign":
|
||||
@@ -266,6 +282,11 @@ export class GameServer extends EventEmitter {
|
||||
const units = this._ownUnitIds(civ, order);
|
||||
if (units.length === 0) return false;
|
||||
const coords = coordsOf(order.coords);
|
||||
// A missile is a one-use rocket: firing it consumes the unit and lands its
|
||||
// warhead where it lands, whether that is conventional or nuclear.
|
||||
if (units.every((id) => this.state._isMissileUnit(this.state.findUnit(id)))) {
|
||||
return this.state.requestMissileStrike(units, coords, { nuclear: !!order.nuclear });
|
||||
}
|
||||
// Aircraft fly to a hostile unit and bomb it on arrival, then return to a
|
||||
// friendly airport. A ground/naval battery (artillery, warships) instead
|
||||
// opens a sustained hourly bombardment from where it stands. Anything else
|
||||
@@ -315,6 +336,31 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestPillage(units);
|
||||
}
|
||||
|
||||
_handleSabotage(civ, order) {
|
||||
const units = this._ownUnitIds(civ, order, true);
|
||||
if (!units) return false;
|
||||
const frame = Number.isInteger(order.frame) ? order.frame : null;
|
||||
return this.state.requestSabotage(units, { frame });
|
||||
}
|
||||
|
||||
_handleSpyCity(civ, order) {
|
||||
const units = this._ownUnitIds(civ, order, true);
|
||||
if (!units) return false;
|
||||
return this.state.requestCitySpy(units, coordsOf(order.coords));
|
||||
}
|
||||
|
||||
_handleSpyComms(civ, order) {
|
||||
const units = this._ownUnitIds(civ, order, true);
|
||||
if (!units) return false;
|
||||
return this.state.requestCommsSpy(units, coordsOf(order.coords));
|
||||
}
|
||||
|
||||
_handleLaunchSatellite(civ, order) {
|
||||
const city = this.state.findCity(Number(order.city));
|
||||
if (!city || city.civ !== civ) return false;
|
||||
return this.state.requestSatellite(civ, city);
|
||||
}
|
||||
|
||||
_handleTrain(peerId, civ, order) {
|
||||
const city = this.state.findCity(Number(order.city));
|
||||
if (!city || city.civ !== civ) return false;
|
||||
@@ -400,11 +446,33 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestFocusTechnology(civ, Number(order.technology));
|
||||
}
|
||||
|
||||
// The testing shortcut: grants a technology outright. Honoured only for a
|
||||
// local page or a `--testing` game, exactly like the free build and train
|
||||
// orders; a normal client's `free` flag is ignored.
|
||||
_handleUnlockTechnology(peerId, civ, order) {
|
||||
if (!(!!order.free && this._freeAllowed(peerId))) return false;
|
||||
return this.state.unlockTechnology(civ, Number(order.technology));
|
||||
}
|
||||
|
||||
_handleDeclareWar(civ, order) {
|
||||
const target = Number(order.target !== undefined ? order.target : order.civ);
|
||||
return this.state.requestDeclareWar(civ, target);
|
||||
}
|
||||
|
||||
_handleProposeTreaty(civ, order) {
|
||||
const to = Number(order.to);
|
||||
const type = String(order.treaty || order.treatyType || "");
|
||||
return this.state.requestProposeTreaty(civ, to, type, order.payload || {});
|
||||
}
|
||||
|
||||
_handleRespondTreaty(civ, order) {
|
||||
return this.state.requestRespondTreaty(civ, Number(order.proposal), !!order.accept);
|
||||
}
|
||||
|
||||
_handleCancelTreaty(civ, order) {
|
||||
return this.state.requestCancelTreaty(civ, Number(order.treatyId));
|
||||
}
|
||||
|
||||
_handleCampaign(civ, order) {
|
||||
const observer = Number(order.observer);
|
||||
const target = Number(order.target);
|
||||
@@ -612,6 +680,14 @@ export class GameServer extends EventEmitter {
|
||||
// by one as they change rather than as a whole army every broadcast. The first
|
||||
// snapshot to a peer (and after a reconnect) is always full.
|
||||
_deltaState(peerId, snapshot, unitSignatures) {
|
||||
// A viewer does not necessarily see every unit -- a hidden submarine is
|
||||
// omitted from its snapshot -- so the per-peer bookkeeping is keyed to the
|
||||
// units in this viewer's own snapshot.
|
||||
const viewerSignatures = new Map();
|
||||
for (const unit of snapshot.units || []) {
|
||||
const signature = unitSignatures.get(unit.id);
|
||||
if (signature !== undefined) viewerSignatures.set(unit.id, signature);
|
||||
}
|
||||
const versions = snapshot.versions;
|
||||
const previous = this._peerSnapshotVersions.get(peerId);
|
||||
// A viewer that changed civilisation sees different per-viewer collections
|
||||
@@ -620,7 +696,7 @@ export class GameServer extends EventEmitter {
|
||||
this._peerSnapshotVersions.set(peerId, versions);
|
||||
this._peerExploredSent.set(peerId, (snapshot.explored || []).length);
|
||||
this._peerCivSent.set(peerId, snapshot.viewer);
|
||||
this._peerUnitSignatures.set(peerId, unitSignatures);
|
||||
this._peerUnitSignatures.set(peerId, viewerSignatures);
|
||||
return { ...snapshot, delta: false };
|
||||
}
|
||||
const state = { ...snapshot, delta: true };
|
||||
@@ -639,17 +715,17 @@ export class GameServer extends EventEmitter {
|
||||
const units = snapshot.units || [];
|
||||
const updated = [];
|
||||
for (const unit of units) {
|
||||
if (sentUnits.get(unit.id) !== unitSignatures.get(unit.id)) updated.push(unit);
|
||||
if (sentUnits.get(unit.id) !== viewerSignatures.get(unit.id)) updated.push(unit);
|
||||
}
|
||||
const removed = [];
|
||||
for (const id of sentUnits.keys()) {
|
||||
if (!unitSignatures.has(id)) removed.push(id);
|
||||
if (!viewerSignatures.has(id)) removed.push(id);
|
||||
}
|
||||
delete state.units;
|
||||
if (updated.length > 0) state.unitsUpdated = updated;
|
||||
if (removed.length > 0) state.unitsRemoved = removed;
|
||||
}
|
||||
this._peerUnitSignatures.set(peerId, unitSignatures);
|
||||
this._peerUnitSignatures.set(peerId, viewerSignatures);
|
||||
this._peerSnapshotVersions.set(peerId, versions);
|
||||
this._peerCivSent.set(peerId, snapshot.viewer);
|
||||
return state;
|
||||
|
||||
@@ -144,6 +144,7 @@ export function startServer({
|
||||
id: peerId,
|
||||
name: connection.name,
|
||||
admin: connection.name === gameServer.adminName,
|
||||
civ: gameServer.peerCiv.has(peerId) ? gameServer.peerCiv.get(peerId) : -1,
|
||||
};
|
||||
}
|
||||
const message = JSON.stringify({ t: "players", players });
|
||||
@@ -169,13 +170,50 @@ export function startServer({
|
||||
admin: result.admin,
|
||||
name: result.name,
|
||||
}));
|
||||
broadcastPlayers();
|
||||
gameServer.onPeerJoined(connection.id, result.name);
|
||||
// Broadcast after the join, so a reclaimed nation shows its player
|
||||
// immediately in everyone's diplomacy tab.
|
||||
broadcastPlayers();
|
||||
break;
|
||||
}
|
||||
case "claim_civ":
|
||||
gameServer.claimCiv(connection.id, message.civ);
|
||||
broadcastPlayers();
|
||||
break;
|
||||
case "chat": {
|
||||
// Relay a short line of chat. `to` names a nation index for a private
|
||||
// line (delivered to that nation and echoed to the sender); without it
|
||||
// the line goes to every signed-in player. The line is recorded in the
|
||||
// game state so a spy can intercept it later. Empty lines and anonymous
|
||||
// senders are dropped.
|
||||
if (!connection.name) break;
|
||||
const text = String(message.text || "").trim().slice(0, 240);
|
||||
if (text.length === 0) break;
|
||||
const fromCiv = gameServer.peerCiv.has(connection.id)
|
||||
? gameServer.peerCiv.get(connection.id)
|
||||
: -1;
|
||||
const toCiv = Number.isInteger(message.to) && message.to >= 0 ? message.to : null;
|
||||
const chat = JSON.stringify({
|
||||
t: "chat",
|
||||
name: connection.name,
|
||||
civ: fromCiv,
|
||||
to: toCiv,
|
||||
text,
|
||||
});
|
||||
for (const peer of connections.values()) {
|
||||
if (!peer.name) continue;
|
||||
if (toCiv === null) {
|
||||
peer.send(chat);
|
||||
} else {
|
||||
const peerCiv = gameServer.peerCiv.has(peer.id)
|
||||
? gameServer.peerCiv.get(peer.id)
|
||||
: -1;
|
||||
if (peer.id === connection.id || peerCiv === toCiv) peer.send(chat);
|
||||
}
|
||||
}
|
||||
if (gameServer.state) gameServer.state.recordChat(fromCiv, toCiv, text);
|
||||
break;
|
||||
}
|
||||
case "order": {
|
||||
const order = message.order || {};
|
||||
// The free shortcut is only honoured for a page on the local machine,
|
||||
|
||||
@@ -19,6 +19,10 @@ export const BUILDING_MECHANIC = {
|
||||
PORT_PRODUCTION: "port_production",
|
||||
// Lets people fly in from any other city with an airport.
|
||||
AIR_IMMIGRATION: "air_immigration",
|
||||
// Unlocks the Nuclear Weapons research for the nation that raises one.
|
||||
NUCLEAR_ENRICHMENT: "nuclear_enrichment",
|
||||
// Each level lets the nation launch one spy satellite (see INTELLIGENCE.md).
|
||||
SPACE_LAUNCH: "space_launch",
|
||||
};
|
||||
|
||||
export const BUILDINGS = [
|
||||
@@ -46,6 +50,8 @@ export const BUILDINGS = [
|
||||
// Multiplies the odds of the city losing a level of this building to a
|
||||
// bombardment: at a tenth of the normal chance it is very hard to destroy.
|
||||
destructionResistance: 0.1,
|
||||
// A bunker survives a nuclear blast that flattens everything around it.
|
||||
nuclearProof: true,
|
||||
icon: "icon_shelters.svg",
|
||||
},
|
||||
{
|
||||
@@ -148,6 +154,33 @@ export const BUILDINGS = [
|
||||
coastal: true,
|
||||
requiresBuilding: "port",
|
||||
},
|
||||
{
|
||||
id: "uranium_enrichment_center",
|
||||
advanced: true,
|
||||
name: "Uranium enrichment centre",
|
||||
description: "Uranium centrifuges. Raising one unlocks the Nuclear Weapons research for the whole nation.",
|
||||
baseCost: 2_000_000_000,
|
||||
costAlpha: 2,
|
||||
refundFraction: 0.5,
|
||||
effects: [],
|
||||
mechanic: "nuclear_enrichment",
|
||||
nuclear: true,
|
||||
materialUpkeep: { steel: 3, energy: 4000, hightech: 1 },
|
||||
icon: "icon_uranium_enrichment.svg",
|
||||
},
|
||||
{
|
||||
id: "space_launch_center",
|
||||
advanced: true,
|
||||
name: "Space launch centre",
|
||||
description: "A rocket complex. Each level lets the nation launch one spy satellite, which uncovers a random patch of the world every two days. Extremely expensive.",
|
||||
baseCost: 10_000_000_000,
|
||||
costAlpha: 2,
|
||||
refundFraction: 0.5,
|
||||
effects: [],
|
||||
mechanic: "space_launch",
|
||||
materialUpkeep: { steel: 5, energy: 8000, hightech: 4 },
|
||||
icon: "icon_space_launch_center.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export function buildingById(id) {
|
||||
|
||||
@@ -69,6 +69,20 @@ export const AIR_DEFENSE = {
|
||||
damagePerLevel: 150,
|
||||
};
|
||||
|
||||
// Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five
|
||||
// the crew spikes or scuttles it rather than let it be turned around.
|
||||
export const CAPTURE = {
|
||||
destroyChance: 0.8,
|
||||
};
|
||||
|
||||
// A nuclear detonation: nine people in ten on the tile are killed, every
|
||||
// building and improvement there is flattened except the ones built to survive
|
||||
// a bomb (bunkers, fortifications), and the horror costs every nation standing.
|
||||
export const WMD = {
|
||||
populationSurvivorFraction: 0.1,
|
||||
popularityPenalty: 1000,
|
||||
};
|
||||
|
||||
// The result of pillaging a tile with `count` units in sequence, starting from
|
||||
// its current population. Each step removes a tenth of what is left and heals
|
||||
// for the people it removed; the production multiplier compounds. Shared so
|
||||
|
||||
@@ -11,6 +11,7 @@ export const EFFECT_CULTURE = "culture";
|
||||
export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
|
||||
export const EFFECT_INFANTRY_DEFENSE = "infantry_defense";
|
||||
export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
|
||||
export const EFFECT_MISSILE_ATTACK = "missile_attack";
|
||||
export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
|
||||
export const EFFECT_RAIL_SPEED = "rail_speed";
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ export const TILE_IMPROVEMENTS = [
|
||||
defense: { melee: 2.0, artillery: 4.0 },
|
||||
permanentControl: true,
|
||||
takesCombatDamage: true,
|
||||
// Earthworks and bunkers shrug off a nuclear blast.
|
||||
nuclearProof: true,
|
||||
icon: "icon_fortifications.svg",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,8 +13,10 @@ export * from "./statuses.js";
|
||||
export * from "./combat.js";
|
||||
export * from "./economy.js";
|
||||
export * from "./politics.js";
|
||||
export * from "./intelligence.js";
|
||||
export * from "./map_config.js";
|
||||
export * from "./terrain.js";
|
||||
export * from "./roads.js";
|
||||
export * from "./resources.js";
|
||||
export * from "./improvements.js";
|
||||
export * from "./treaties.js";
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Covert operations a spy may run abroad. The ids are shared by the client
|
||||
// (to label the Intelligence tab's operations) and, in time, the server (to
|
||||
// validate the orders), so they live here rather than in the view.
|
||||
//
|
||||
// Intelligence Agencies unlocks the spy and every operation below;
|
||||
// Counter-Intelligence is the defence against them.
|
||||
|
||||
export const SPY_SABOTAGE = "sabotage";
|
||||
export const SPY_CITY = "city_spying";
|
||||
export const SPY_COMMS = "comms_spying";
|
||||
|
||||
// Covert-operation tuning. A sabotage cripples the tile the spy stands on for a
|
||||
// week; the tile's production is zeroed, not the physical resource trade, which
|
||||
// mirrors how pillaging already marks a tile (see `productionFactors`).
|
||||
export const SABOTAGE = {
|
||||
blockDays: 7,
|
||||
};
|
||||
|
||||
// Espionage tuning: how the intelligence agencies and their counter-intelligence
|
||||
// rivals decide who gets blamed, what a spy report really contains, and how
|
||||
// easily a secret leaks.
|
||||
export const ESPIONAGE = {
|
||||
// The chance a victim correctly pins a covert act on its author, before the
|
||||
// two nations' services shift it. A counter-intelligence service raises it;
|
||||
// the author's own agencies lower it.
|
||||
baseIdentifyChance: 0.5,
|
||||
counterIntelligenceWeight: 0.45,
|
||||
agencyWeight: 0.2,
|
||||
// When the author is not identified and chose a scapegoat, how often the
|
||||
// victim swallows the false flag. A correct blame and a successful frame are
|
||||
// deliberately indistinguishable to the victim.
|
||||
frameChance: 0.6,
|
||||
// The extra chance, per level of the target's counter-intelligence, that a
|
||||
// spy's city report is quietly falsified.
|
||||
falsifyChance: 0.7,
|
||||
// The extra chance, per level, that intercepted comms come back garbled.
|
||||
garbleChance: 0.6,
|
||||
// Covert operations spend their operatives, so the only way to stack the odds
|
||||
// against a counter-intelligence service is to send more spies: each operative
|
||||
// beyond the first cancels this much of the target's counter-intelligence.
|
||||
// Three spies (two extra) beat a full service outright.
|
||||
agentsPerCounter: 0.5,
|
||||
// How many recent messages comms spying reveals.
|
||||
interceptCount: 8,
|
||||
// How many reports a nation keeps for its Intelligence tab.
|
||||
reportLimit: 20,
|
||||
// How many chat lines the server keeps for comms spying.
|
||||
messageLogLimit: 400,
|
||||
// The chance a nation with Intelligence Agencies is warned when another nation
|
||||
// raises its first uranium enrichment centre.
|
||||
enrichmentWarningChance: 0.5,
|
||||
};
|
||||
|
||||
// Spy satellites: a launch is free once the (very expensive) space launch centre
|
||||
// stands, and each satellite uncovers a random patch of the world on a cadence.
|
||||
export const SATELLITE = {
|
||||
revealEveryDays: 2,
|
||||
revealRadius: 2,
|
||||
};
|
||||
|
||||
// `description` is the reference text the Intelligence tab shows, not a
|
||||
// rules summary: the mechanics are applied server-side (see INTELLIGENCE.md).
|
||||
// The kinds of report a spy brings home. Kept as ids so the model and the view
|
||||
// can name them without magic strings.
|
||||
export const SPY_REPORT_CITY = "city";
|
||||
export const SPY_REPORT_COMMS = "comms";
|
||||
|
||||
// Every operation spends the acting spies: a spy is single-use, so only one
|
||||
// operation is ever run per operative. A larger stack improves the odds against
|
||||
// counter-intelligence (see ESPIONAGE.agentsPerCounter).
|
||||
export const SPY_OPERATIONS = [
|
||||
{
|
||||
id: SPY_SABOTAGE,
|
||||
name: "Sabotage",
|
||||
description:
|
||||
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
|
||||
},
|
||||
{
|
||||
id: SPY_CITY,
|
||||
name: "Spy on a city",
|
||||
description:
|
||||
"Read a foreign city's buildings, garrison and stores. A counter-intelligence service can feed back false figures. Spends the spies.",
|
||||
},
|
||||
{
|
||||
id: SPY_COMMS,
|
||||
name: "Intercept communications",
|
||||
description:
|
||||
"Read a nation's recent private messages from its capital. Counter-intelligence can garble or redirect them. Spends the spies.",
|
||||
},
|
||||
];
|
||||
@@ -182,6 +182,9 @@ export const MIGRATION = {
|
||||
// most this many people per day for each airport level on the route.
|
||||
airHours: 6,
|
||||
airPeoplePerDayPerLevel: 1000,
|
||||
// No tile loses more than this share of its people to migration in one day,
|
||||
// whichever mechanism carries them.
|
||||
maxDailyOutShare: 0.0001,
|
||||
|
||||
// At generation, regions of a nation trade people until their production per
|
||||
// head is level. `equalizeTolerance` is the residual spread accepted and
|
||||
|
||||
@@ -21,3 +21,15 @@ export const NEWS_JOIN = "join";
|
||||
export const NEWS_TARIFF = "tariff";
|
||||
// A region's food ran out: a famine has begun. Reported to every nation.
|
||||
export const NEWS_FAMINE = "famine";
|
||||
// A diplomatic treaty was proposed, signed, rejected or broken. The stage
|
||||
// travels in the record so one feed entry covers each step.
|
||||
export const NEWS_TREATY = "treaty";
|
||||
// A spy sabotaged a tile abroad: its production is crippled for a week. The
|
||||
// record may name a `suspect`, which counter-intelligence may have got right or
|
||||
// wrong.
|
||||
export const NEWS_SABOTAGE = "sabotage";
|
||||
// A spy satellite was launched. Everyone is made aware of the launch.
|
||||
export const NEWS_SATELLITE = "satellite";
|
||||
// A nation's intelligence services have learned that another nation has begun
|
||||
// enriching uranium.
|
||||
export const NEWS_ENRICHMENT = "enrichment";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
EFFECT_INFANTRY_ATTACK,
|
||||
EFFECT_INFANTRY_DEFENSE,
|
||||
EFFECT_ARTILLERY_ATTACK,
|
||||
EFFECT_MISSILE_ATTACK,
|
||||
EFFECT_CONSTRUCTION_SPEED,
|
||||
EFFECT_RAIL_SPEED,
|
||||
EFFECT_ENERGY_EFFICIENCY,
|
||||
@@ -89,6 +90,42 @@ export const TECHNOLOGIES = [
|
||||
prerequisites: ["space_program", "quantum_computing"],
|
||||
effects: [effect(EFFECT_RESEARCH, 8.0, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "nuclear_weapons",
|
||||
name: "Nuclear Weapons",
|
||||
description: "Fission warheads for the nation's missiles. Only a nation enriching uranium may begin the research.",
|
||||
theme: "military",
|
||||
// 300,000 research points: a project a whole economy bends toward.
|
||||
cost: 3_000,
|
||||
prerequisites: ["scientific_method"],
|
||||
// The technology is unlocked by the nation's first uranium enrichment
|
||||
// centre; without one it cannot be focused at all.
|
||||
requiresBuilding: "uranium_enrichment_center",
|
||||
effects: [effect(EFFECT_MISSILE_ATTACK, 0.25, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "intelligence_agencies",
|
||||
name: "Intelligence Agencies",
|
||||
description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.",
|
||||
theme: "society",
|
||||
cost: 250,
|
||||
prerequisites: ["scientific_method"],
|
||||
// Unlocks the spy unit rather than carrying a numeric effect of its own.
|
||||
unlocks: true,
|
||||
effects: [],
|
||||
},
|
||||
{
|
||||
id: "counter_intelligence",
|
||||
name: "Counter-Intelligence",
|
||||
description: "Screening, ciphers and deception to shield the nation's secrets from foreign spies. Extremely expensive.",
|
||||
theme: "society",
|
||||
// 150,000 research points: the most a nation can spend on its secrets.
|
||||
cost: 1_500,
|
||||
prerequisites: ["intelligence_agencies"],
|
||||
// Shields the nation against spy operations rather than raising a stat.
|
||||
unlocks: true,
|
||||
effects: [],
|
||||
},
|
||||
|
||||
// Repeatable levels. Each raises a single stat by 1% of its base value.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Diplomatic treaties. A treaty is proposed by one nation, accepted by another,
|
||||
// and then lives on until it is broken or superseded. The catalogue is
|
||||
// data-driven so a new treaty type only needs an entry here and a case in
|
||||
// `_applyTreatyEffect`.
|
||||
//
|
||||
// `payload` names the field a proposal must carry (a city id, a unit list, a
|
||||
// resource basket or a sum of money), and the UI uses `needs` to decide which
|
||||
// picker to show.
|
||||
|
||||
export const TREATY_PEACE = "peace";
|
||||
export const TREATY_ALLIANCE = "alliance";
|
||||
export const TREATY_FREE_TRADE = "free_trade";
|
||||
export const TREATY_GIVE_CITY = "give_city";
|
||||
export const TREATY_GIVE_MILITARY = "give_military";
|
||||
export const TREATY_GIVE_RESOURCES = "give_resources";
|
||||
export const TREATY_GIVE_MONEY = "give_money";
|
||||
export const TREATY_JOINT_RESEARCH = "joint_research";
|
||||
|
||||
export const TREATIES = [
|
||||
{
|
||||
id: TREATY_PEACE,
|
||||
name: "Peace",
|
||||
description: "Ends the war between the two signatories and sends their troops home.",
|
||||
},
|
||||
{
|
||||
id: TREATY_ALLIANCE,
|
||||
name: "Alliance",
|
||||
description:
|
||||
"The signatories cannot go to war with each other; attacking one declares war on all.",
|
||||
},
|
||||
{
|
||||
id: TREATY_FREE_TRADE,
|
||||
name: "Free trade",
|
||||
description: "Lifts every tariff between the two signatories.",
|
||||
},
|
||||
{
|
||||
id: TREATY_GIVE_CITY,
|
||||
name: "Give city",
|
||||
description: "Transfers one of the proposer's cities to the other signatory.",
|
||||
payload: "city",
|
||||
},
|
||||
{
|
||||
id: TREATY_GIVE_MILITARY,
|
||||
name: "Give military materials",
|
||||
description: "Transfers units to the other signatory. Infantry cannot be gifted.",
|
||||
payload: "units",
|
||||
},
|
||||
{
|
||||
id: TREATY_GIVE_RESOURCES,
|
||||
name: "Give resources",
|
||||
description: "Transfers stores of steel, high-tech, luxury, food or energy.",
|
||||
payload: "resources",
|
||||
},
|
||||
{
|
||||
id: TREATY_GIVE_MONEY,
|
||||
name: "Give money",
|
||||
description: "Transfers a sum of money from the proposer's treasury.",
|
||||
payload: "money",
|
||||
},
|
||||
{
|
||||
id: TREATY_JOINT_RESEARCH,
|
||||
name: "Joint research",
|
||||
description:
|
||||
"Each signatory also earns the research points the others produce, except on military technology.",
|
||||
},
|
||||
];
|
||||
|
||||
export function treatyById(id) {
|
||||
return TREATIES.find((treaty) => treaty.id === id) || null;
|
||||
}
|
||||
|
||||
// News stages, so one feed entry can announce a treaty at each step.
|
||||
export const TREATY_PROPOSED = "proposed";
|
||||
export const TREATY_SIGNED = "signed";
|
||||
export const TREATY_REJECTED = "rejected";
|
||||
export const TREATY_BROKEN = "broken";
|
||||
@@ -122,6 +122,28 @@ export const PROTO_UNITS = [
|
||||
requiresBuilding: "shipyard",
|
||||
icon: "icon_battleship.svg",
|
||||
},
|
||||
{
|
||||
id: "submarine",
|
||||
advanced: true,
|
||||
materialUpkeep: { steel: 4, energy: 4000, hightech: 1 },
|
||||
name: "Submarine",
|
||||
maxHp: 400,
|
||||
cost: 350_000_000,
|
||||
moveable: true,
|
||||
speed: 0.5,
|
||||
military: true,
|
||||
attack: 350,
|
||||
defense: 60,
|
||||
// Strikes from two tiles out like a warship.
|
||||
range: 2,
|
||||
naval: true,
|
||||
// Hidden from every other nation unless one of its naval units is on the
|
||||
// submarine's tile or directly beside it (see `_hiddenEnemyUnits`).
|
||||
submarine: true,
|
||||
traversableTerrains: ["Sea"],
|
||||
requiresBuilding: "shipyard",
|
||||
icon: "icon_submarine.svg",
|
||||
},
|
||||
{
|
||||
id: "mobile_radar",
|
||||
advanced: true,
|
||||
@@ -142,6 +164,29 @@ export const PROTO_UNITS = [
|
||||
traversableTerrains: ["Land"],
|
||||
icon: "icon_radar.svg",
|
||||
},
|
||||
{
|
||||
id: "spy",
|
||||
advanced: true,
|
||||
name: "Spy",
|
||||
maxHp: 100,
|
||||
cost: 250_000_000,
|
||||
// Mostly a high-tech bill: a spy is equipment and networks, not steel.
|
||||
materialUpkeep: { steel: 0, energy: 2_000, hightech: 3 },
|
||||
moveable: true,
|
||||
speed: 0.5,
|
||||
military: false,
|
||||
attack: 0,
|
||||
defense: 0,
|
||||
// A spy fights nothing and strikes nothing; it watches one tile out.
|
||||
range: 0,
|
||||
vision: 1,
|
||||
// Hidden from every other nation by default (see `_hiddenEnemyUnits`), and
|
||||
// free to slip through foreign territory (see `_canUnitEnter`).
|
||||
spy: true,
|
||||
traversableTerrains: ["Land"],
|
||||
requiresTechnology: "intelligence_agencies",
|
||||
icon: "icon_spy.svg",
|
||||
},
|
||||
{
|
||||
id: "launch",
|
||||
name: "Launch",
|
||||
@@ -162,6 +207,54 @@ export const PROTO_UNITS = [
|
||||
requiresBuilding: "port",
|
||||
icon: "icon_launch.svg",
|
||||
},
|
||||
{
|
||||
id: "missile",
|
||||
name: "Missile",
|
||||
maxHp: 100,
|
||||
cost: 150_000_000,
|
||||
materialUpkeep: { steel: 2, energy: 2000, hightech: 1 },
|
||||
// Missiles are fired, not marched: `moveable: false` keeps them out of every
|
||||
// movement order, and `oneUse` means the rocket is gone once launched.
|
||||
moveable: false,
|
||||
oneUse: true,
|
||||
speed: 10,
|
||||
military: true,
|
||||
attack: 900,
|
||||
defense: 0,
|
||||
// An intercontinental ballistic missile of the theatre: 30 tiles of reach.
|
||||
range: 30,
|
||||
// A missile flies like a strike aircraft but needs no airfield; a nuclear
|
||||
// payload additionally needs the nuclear-weapons research.
|
||||
strike: true,
|
||||
missile: true,
|
||||
traversableTerrains: ["Land", "Sea", "Ice"],
|
||||
icon: "icon_missile.svg",
|
||||
},
|
||||
{
|
||||
id: "icbm",
|
||||
name: "ICBM",
|
||||
maxHp: 100,
|
||||
cost: 800_000_000,
|
||||
materialUpkeep: { steel: 4, energy: 4000, hightech: 2 },
|
||||
moveable: false,
|
||||
oneUse: true,
|
||||
speed: 10,
|
||||
military: true,
|
||||
attack: 900,
|
||||
defense: 0,
|
||||
// A global rocket: `globalRange` lifts the 30-tile ceiling so it may be
|
||||
// aimed at any tile on the map.
|
||||
range: 0,
|
||||
globalRange: true,
|
||||
strike: true,
|
||||
missile: true,
|
||||
// Nuclear by design, and gated behind both the bomb and the orbit that
|
||||
// carries it.
|
||||
nuclear: true,
|
||||
requiresTechnologies: ["nuclear_weapons", "space_program"],
|
||||
traversableTerrains: ["Land", "Sea", "Ice"],
|
||||
icon: "icon_icbm.svg",
|
||||
},
|
||||
];
|
||||
|
||||
// Only units in this list can be trained from the city screen.
|
||||
@@ -171,8 +264,12 @@ export const TRAINABLE_UNIT_IDS = [
|
||||
"jet_fighter",
|
||||
"bomber",
|
||||
"battleship",
|
||||
"submarine",
|
||||
"mobile_radar",
|
||||
"spy",
|
||||
"launch",
|
||||
"missile",
|
||||
"icbm",
|
||||
];
|
||||
|
||||
export function protoUnitById(id) {
|
||||
|
||||
@@ -40,6 +40,9 @@ import { combatMethods } from "./game_state/combat.js";
|
||||
import { airMethods } from "./game_state/air.js";
|
||||
import { statusMethods } from "./game_state/statuses.js";
|
||||
import { diplomacyMethods } from "./game_state/diplomacy.js";
|
||||
import { treatyMethods } from "./game_state/treaties.js";
|
||||
import { wmdMethods } from "./game_state/wmd.js";
|
||||
import { intelligenceMethods } from "./game_state/intelligence.js";
|
||||
import { politicsMethods } from "./game_state/politics.js";
|
||||
import { orderMethods } from "./game_state/orders.js";
|
||||
import { visibilityMethods } from "./game_state/visibility.js";
|
||||
@@ -147,6 +150,14 @@ export class GameState {
|
||||
// recovery may begin. A tile leaves both maps once it has recovered.
|
||||
this.tileBattleProductionDeficit = new Map();
|
||||
this.tileBattleRecoveryStart = new Map();
|
||||
// Tiles crippled by a spy's sabotage, each mapped to the hour its week ends.
|
||||
this.tileSabotage = new Map();
|
||||
// Espionage: reports each nation's spies have brought home (per civ, capped),
|
||||
// the chat log comms spying reads, and the spy satellites in orbit.
|
||||
this.spyReports = new Map();
|
||||
this._nextSpyReportId = 1;
|
||||
this.messages = [];
|
||||
this.satellites = [];
|
||||
this.budgets = new Map();
|
||||
this.training = new Map();
|
||||
this.culture = new Map();
|
||||
@@ -169,6 +180,14 @@ export class GameState {
|
||||
// One record per war, keyed like `wars`: when it started and the military
|
||||
// and civilian casualties each side has suffered so far.
|
||||
this.conflicts = new Map();
|
||||
// Diplomatic treaties: offers waiting for an answer, the agreements in
|
||||
// force, and the ones that ended (broken, rejected, or made obsolete).
|
||||
this.treaties = [];
|
||||
this.treatyProposals = [];
|
||||
this.treatyHistory = [];
|
||||
this._nextTreatyId = 1;
|
||||
this._nextTreatyProposalId = 1;
|
||||
this._treatyHistoryLimit = 40;
|
||||
this.news = [];
|
||||
|
||||
// Politics: the ethnic makeup of every tile, the opinions each ethnicity
|
||||
@@ -499,9 +518,13 @@ export class GameState {
|
||||
this._tickTraining();
|
||||
this._tickAirUnits();
|
||||
this._tickStrikes();
|
||||
this._tickSabotage();
|
||||
this._tickSatellites();
|
||||
this._tickGarrisonHealing();
|
||||
// Popularity drifts toward neutral every hour, not just daily.
|
||||
this._tickOpinionDrift();
|
||||
// Stale treaty offers fall out of the receiver's inbox.
|
||||
this._expireTreatyProposals();
|
||||
if (this._visibilityDirty) {
|
||||
this._refreshVisibility();
|
||||
this._visibilityDirty = false;
|
||||
@@ -547,6 +570,9 @@ Object.assign(
|
||||
airMethods,
|
||||
statusMethods,
|
||||
diplomacyMethods,
|
||||
treatyMethods,
|
||||
wmdMethods,
|
||||
intelligenceMethods,
|
||||
politicsMethods,
|
||||
orderMethods,
|
||||
visibilityMethods,
|
||||
|
||||
@@ -356,7 +356,7 @@ export const airMethods = {
|
||||
// Declares war on the target if the two were at peace.
|
||||
requestBombard(unitIds, goal) {
|
||||
const units = this._unitsForOrder(unitIds).filter(
|
||||
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit)
|
||||
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit) && !this._isMissileUnit(unit)
|
||||
);
|
||||
if (units.length === 0) return false;
|
||||
const civ = units[0].civ;
|
||||
@@ -438,7 +438,7 @@ export const airMethods = {
|
||||
_tickStrikes() {
|
||||
const damage = new Map();
|
||||
for (const unit of this.units) {
|
||||
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
|
||||
if (!unit.strikeTarget || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
|
||||
const goal = unit.strikeTarget;
|
||||
const target = this._strikeTargetAt(goal, unit.civ);
|
||||
if (!target) {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
// and the lasting scars a battlefield leaves on a tile.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { BATTLE } from "../data/combat.js";
|
||||
import { BATTLE, CAPTURE } from "../data/combat.js";
|
||||
import { POPULARITY } from "../data/politics.js";
|
||||
import {
|
||||
EFFECT_INFANTRY_ATTACK,
|
||||
EFFECT_INFANTRY_DEFENSE,
|
||||
EFFECT_ARTILLERY_ATTACK,
|
||||
EFFECT_MISSILE_ATTACK,
|
||||
} from "../data/effects.js";
|
||||
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
|
||||
import { defenseMultiplier } from "../rules.js";
|
||||
@@ -28,11 +29,13 @@ export const combatMethods = {
|
||||
_unitAttack(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
|
||||
if (proto && (proto.infantry || proto.artillery)) {
|
||||
if (proto && (proto.infantry || proto.artillery || proto.missile)) {
|
||||
const modifiers = this.getCivModifiers(unit.civ);
|
||||
const bonus = proto.infantry
|
||||
? modifiers[EFFECT_INFANTRY_ATTACK]
|
||||
: modifiers[EFFECT_ARTILLERY_ATTACK];
|
||||
: proto.artillery
|
||||
? modifiers[EFFECT_ARTILLERY_ATTACK]
|
||||
: modifiers[EFFECT_MISSILE_ATTACK];
|
||||
attack *= 1 + (bonus || 0);
|
||||
}
|
||||
return attack;
|
||||
@@ -86,7 +89,7 @@ export const combatMethods = {
|
||||
// or null. With every combatant on one tile this is simply the weakest
|
||||
// hostile military unit there, so a stack focuses its fire.
|
||||
_combatTarget(unit) {
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit)) return null;
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) return null;
|
||||
const enemies = this.unitsAt(unit.coords).filter(
|
||||
(other) =>
|
||||
other.id !== unit.id &&
|
||||
@@ -124,8 +127,9 @@ export const combatMethods = {
|
||||
}
|
||||
const byTile = new Map();
|
||||
for (const unit of this.units) {
|
||||
// Aircraft fly over a battlefield without taking part in the melee.
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
||||
// Aircraft fly over a battlefield without taking part in the melee, and a
|
||||
// missile is a rocket waiting for a launch order, not a combatant.
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
|
||||
const k = key(unit.coords.x, unit.coords.y);
|
||||
let entry = byTile.get(k);
|
||||
if (!entry) {
|
||||
@@ -251,7 +255,7 @@ export const combatMethods = {
|
||||
_resolveCaptures() {
|
||||
const claimed = new Set();
|
||||
for (const unit of this.units) {
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit) || this._isMissileUnit(unit)) continue;
|
||||
const coords = unit.coords;
|
||||
// Support units and unaccompanied artillery are taken from underfoot.
|
||||
this._captureSupportUnits(unit);
|
||||
@@ -268,7 +272,7 @@ export const combatMethods = {
|
||||
// tile changes hands, or is scuttled when the captor is a warship. Artillery
|
||||
// escapes capture while any of its own infantry stands beside it.
|
||||
_captureSupportUnits(captor) {
|
||||
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
|
||||
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor) || this._isMissileUnit(captor)) return false;
|
||||
const captorProto = this.unitProto(captor);
|
||||
const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
|
||||
if (enemies.length === 0) return false;
|
||||
@@ -290,8 +294,13 @@ export const combatMethods = {
|
||||
}
|
||||
changed = true;
|
||||
} else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
|
||||
enemy.civ = captor.civ;
|
||||
enemy.strikeTarget = null;
|
||||
// A non-melee gun is more often spiked than taken.
|
||||
if (this._random() < CAPTURE.destroyChance) {
|
||||
this._destroyUnit(enemy);
|
||||
} else {
|
||||
enemy.civ = captor.civ;
|
||||
enemy.strikeTarget = null;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -305,15 +314,20 @@ export const combatMethods = {
|
||||
return changed;
|
||||
},
|
||||
|
||||
// Enemy aircraft caught on a captured city's airfield change allegiance.
|
||||
// Enemy aircraft caught on a captured city's airfield are usually destroyed
|
||||
// rather than turned around; only a fifth change allegiance with the field.
|
||||
_captureCityAircraft(city, newCiv) {
|
||||
let changed = false;
|
||||
for (const unit of this.unitsAt(city.coords)) {
|
||||
for (const unit of this.unitsAt(city.coords).slice()) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto || !proto.air || unit.civ === newCiv) continue;
|
||||
unit.civ = newCiv;
|
||||
unit.homeCityId = city.id;
|
||||
unit.strikeTarget = null;
|
||||
if (this._random() < CAPTURE.destroyChance) {
|
||||
this._destroyUnit(unit);
|
||||
} else {
|
||||
unit.civ = newCiv;
|
||||
unit.homeCityId = city.id;
|
||||
unit.strikeTarget = null;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (changed) this._invalidateUnitCaches();
|
||||
|
||||
@@ -31,26 +31,32 @@ export const diplomacyMethods = {
|
||||
},
|
||||
|
||||
// Records the war and its news item without emitting; the caller decides when
|
||||
// to publish. Returns false when the pair is already at war.
|
||||
// to publish. Returns false when the pair is already at war. An alliance
|
||||
// between the two is broken first, and the target's allies are drawn in.
|
||||
_declareWar(actor, target) {
|
||||
if (actor === target) return false;
|
||||
const k = warKey(actor, target);
|
||||
if (this.wars.has(k)) return false;
|
||||
this.wars.add(k);
|
||||
this._invalidateSpatialCaches();
|
||||
this.conflicts.set(k, {
|
||||
a: Math.min(actor, target),
|
||||
b: Math.max(actor, target),
|
||||
startHours: this.totalHours,
|
||||
startDate: dateString(this.totalHours),
|
||||
military: new Map(),
|
||||
civilians: new Map(),
|
||||
});
|
||||
// The world watches: a war on a well-liked government costs the aggressor
|
||||
// its standing with onlookers, a war on a loathed one can be popular.
|
||||
this._applyWarPopularity(actor, target);
|
||||
this._addNews({ type: NEWS_WAR, actor, target });
|
||||
return true;
|
||||
const enemies = this._handleWarAlliances(actor, target);
|
||||
let declared = false;
|
||||
for (const enemy of enemies) {
|
||||
const k = warKey(actor, enemy);
|
||||
if (this.wars.has(k)) continue;
|
||||
this.wars.add(k);
|
||||
this._invalidateSpatialCaches();
|
||||
this.conflicts.set(k, {
|
||||
a: Math.min(actor, enemy),
|
||||
b: Math.max(actor, enemy),
|
||||
startHours: this.totalHours,
|
||||
startDate: dateString(this.totalHours),
|
||||
military: new Map(),
|
||||
civilians: new Map(),
|
||||
});
|
||||
// The world watches: a war on a well-liked government costs the aggressor
|
||||
// its standing with onlookers, a war on a loathed one can be popular.
|
||||
this._applyWarPopularity(actor, enemy);
|
||||
this._addNews({ type: NEWS_WAR, actor, target: enemy });
|
||||
declared = true;
|
||||
}
|
||||
return declared;
|
||||
},
|
||||
|
||||
// The ongoing war record between two civilisations, or null when they are at
|
||||
|
||||
@@ -118,6 +118,8 @@ export const economyMethods = {
|
||||
pillagePenalty: this.tileProductionPenalty.get(k) !== undefined
|
||||
? this.tileProductionPenalty.get(k)
|
||||
: 1,
|
||||
// A spy's sabotage zeroes the tile outright for a week.
|
||||
sabotaged: this.isTileSabotaged(coords),
|
||||
// Fighting scars the tile too, but that loss recovers over time.
|
||||
battleDeficit: this.tileBattleProductionDeficit.get(k) || 0,
|
||||
trench: this.trenches.has(k),
|
||||
@@ -460,6 +462,18 @@ export const economyMethods = {
|
||||
const resourceMoney = RESOURCE_IDS
|
||||
.map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
|
||||
.filter((entry) => entry.bought > 0 || entry.sold > 0);
|
||||
// What the nation actually paid for materials, power and freight over the
|
||||
// last day, as hourly averages. The upkeep rows above are a forecast at
|
||||
// market prices; these are the real bills the stores and the market settled,
|
||||
// shown as their own line so the two are never confused.
|
||||
const resourceCosts = [
|
||||
{ id: "energy", label: "Electric imports", amount: (record.energy || 0) / HOURS_PER_DAY },
|
||||
{ id: "goods", label: "Goods", amount: (record.goods || 0) / HOURS_PER_DAY },
|
||||
{ id: "transport", label: "Freight", amount: (record.transport || 0) / HOURS_PER_DAY },
|
||||
{ id: "upkeep", label: "Material upkeep", amount: (record.upkeep || 0) / HOURS_PER_DAY },
|
||||
{ id: "construction", label: "Construction", amount: (record.construction || 0) / HOURS_PER_DAY },
|
||||
{ id: "combat", label: "Munitions", amount: (record.combat || 0) / HOURS_PER_DAY },
|
||||
].filter((entry) => entry.amount > 0);
|
||||
return {
|
||||
income: { rate: 0, base: 0, modifier: 0, total: income, sources: incomeSources, regions },
|
||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||
@@ -472,6 +486,7 @@ export const economyMethods = {
|
||||
groups: tileImprovementSources,
|
||||
},
|
||||
resourceMoney,
|
||||
resourceCosts,
|
||||
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
|
||||
};
|
||||
},
|
||||
@@ -741,7 +756,22 @@ export const economyMethods = {
|
||||
const tech = this.technologies[index];
|
||||
// A repeatable technology may be researched again; any other is done once.
|
||||
if (!tech.repeatable && this.isResearched(civ, index)) return false;
|
||||
return prerequisitesMet(tech, this.researched.get(civ) || new Set(), this.technologies);
|
||||
return prerequisitesMet(
|
||||
tech,
|
||||
this.researched.get(civ) || new Set(),
|
||||
this.technologies,
|
||||
(id) => this._civHasBuilding(civ, id)
|
||||
);
|
||||
},
|
||||
|
||||
// Whether any of a nation's cities has at least one level of the named
|
||||
// building. Gates a technology that a building unlocks, such as the uranium
|
||||
// enrichment centre opening nuclear weapons.
|
||||
_civHasBuilding(civ, id) {
|
||||
for (const city of this.cities) {
|
||||
if (city.civ === civ && this.hasCityBuilding(city, id)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// The technology a nation is currently focusing its research on, or -1.
|
||||
@@ -797,11 +827,36 @@ export const economyMethods = {
|
||||
|
||||
// Points a nation creates flow straight into its focused technology; there is
|
||||
// no bank, so time spent with nothing focused is simply time not advancing.
|
||||
// A joint-research treaty shares the points with the partners' own focused
|
||||
// technology, except when the technology being researched is military.
|
||||
_addResearch(civ, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const index = this.getFocusedTechnology(civ);
|
||||
if (index < 0) return;
|
||||
this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
|
||||
const tech = this.technologies[index];
|
||||
if (!tech || tech.theme === "military") return;
|
||||
// The partners' copies must not echo back to the origin, so one sharing
|
||||
// pass is guarded from re-entering.
|
||||
if (this._sharingResearch) return;
|
||||
const partners = this.jointResearchPartners(civ);
|
||||
if (partners.length === 0) return;
|
||||
this._sharingResearch = true;
|
||||
try {
|
||||
for (const partner of partners) {
|
||||
const partnerIndex = this.getFocusedTechnology(partner);
|
||||
if (partnerIndex < 0) continue;
|
||||
const partnerTech = this.technologies[partnerIndex];
|
||||
if (partnerTech && partnerTech.theme === "military") continue;
|
||||
this._applyTechnologyProgress(
|
||||
partner,
|
||||
partnerIndex,
|
||||
this.getTechnologyProgress(partner, partnerIndex) + amount
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this._sharingResearch = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Records the points committed to one technology.
|
||||
@@ -827,7 +882,14 @@ export const economyMethods = {
|
||||
// The completed level spends its points; a repeatable starts the next level
|
||||
// from zero. Other technologies keep whatever they had banked.
|
||||
this._setTechnologyProgress(civ, index, 0);
|
||||
this._clearFocus(civ);
|
||||
// A repeatable technology that was just completed comes straight back into
|
||||
// focus, so its next level keeps accruing without the player having to ask
|
||||
// again. Anything else clears the focus as before.
|
||||
if (this.technologies[index] && this.technologies[index].repeatable) {
|
||||
this.focusTechnology.set(civ, index);
|
||||
} else {
|
||||
this._clearFocus(civ);
|
||||
}
|
||||
this._clearTileGdpCache();
|
||||
this._emitChanged();
|
||||
},
|
||||
@@ -1110,6 +1172,15 @@ export const economyMethods = {
|
||||
city.buildings[entry.protoIndex] = level + 1;
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
// A nation's first uranium enrichment centre may be noticed abroad.
|
||||
const proto = this.protoBuildings[entry.protoIndex];
|
||||
if (
|
||||
level === 0 &&
|
||||
proto &&
|
||||
proto.mechanic === BUILDING_MECHANIC.NUCLEAR_ENRICHMENT
|
||||
) {
|
||||
this._warnOfEnrichment(city.civ);
|
||||
}
|
||||
},
|
||||
|
||||
// Units resting inside one of their own cities recover a little health each
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Espionage: what a spy unit does once it reaches a foreign tile. The spy
|
||||
// itself is an ordinary unit marked `proto.spy`, so its invisibility lives in
|
||||
// visibility.js and its freedom to cross foreign land in movement.js; this
|
||||
// module owns the covert operations ordered against it, the reports they bring
|
||||
// back, the counter-intelligence that fights them, and the spy satellites that
|
||||
// watch the world from orbit.
|
||||
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import {
|
||||
SABOTAGE,
|
||||
ESPIONAGE,
|
||||
SATELLITE,
|
||||
SPY_REPORT_CITY,
|
||||
SPY_REPORT_COMMS,
|
||||
} from "../data/intelligence.js";
|
||||
import {
|
||||
NEWS_SABOTAGE,
|
||||
NEWS_SATELLITE,
|
||||
NEWS_ENRICHMENT,
|
||||
} from "../data/relations.js";
|
||||
import { BUILDING_MECHANIC } from "../data/buildings.js";
|
||||
import { HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
export const intelligenceMethods = {
|
||||
// ------------------------------------------------- intelligence levels --
|
||||
|
||||
// Whether a nation has professional intelligence services. This is the
|
||||
// technology that unlocks the spy, so any nation with a spy has it.
|
||||
_hasIntelligenceAgencies(civ) {
|
||||
return this.hasTechnology(civ, "intelligence_agencies");
|
||||
},
|
||||
|
||||
_hasCounterIntelligence(civ) {
|
||||
return this.hasTechnology(civ, "counter_intelligence");
|
||||
},
|
||||
|
||||
// The strength of a nation's offensive intelligence, 0 or 1 for now (one
|
||||
// unlock-only technology). Read by the blame roll.
|
||||
_intelligenceLevel(civ) {
|
||||
return this._hasIntelligenceAgencies(civ) ? 1 : 0;
|
||||
},
|
||||
|
||||
// The strength of a nation's defence against spies, 0 or 1. It makes blame
|
||||
// land on the true culprit more often and makes incoming reports less honest.
|
||||
_counterIntelligenceLevel(civ) {
|
||||
return this._hasCounterIntelligence(civ) ? 1 : 0;
|
||||
},
|
||||
|
||||
// A covert operation spends its operatives, so a larger team is the only way
|
||||
// to out-match a counter-intelligence service: each spy beyond the first
|
||||
// cancels `ESPIONAGE.agentsPerCounter` of the target's defence.
|
||||
_effectiveCounterIntelligence(civ, agents = 1) {
|
||||
const level = this._counterIntelligenceLevel(civ);
|
||||
const extra = Math.max(0, (Number(agents) || 1) - 1);
|
||||
return Math.max(0, level - extra * ESPIONAGE.agentsPerCounter);
|
||||
},
|
||||
|
||||
// Removes the operatives an operation spent. A spy is single-use: it vanishes
|
||||
// quietly, with no news line and no notice to its owner or its victim.
|
||||
_spendSpies(units) {
|
||||
for (const unit of units) this._destroyUnit(unit);
|
||||
this._visibilityDirty = true;
|
||||
},
|
||||
|
||||
// --------------------------------------------------------- sabotage ------
|
||||
|
||||
// Whether a stack may sabotage the tile it stands on: every unit is a spy,
|
||||
// they share one tile, and that tile is foreign land.
|
||||
canSabotage(unitIds) {
|
||||
const units = this._unitsForOrder(unitIds);
|
||||
if (units.length === 0) return false;
|
||||
if (!units.every((unit) => this._isSpy(unit))) return false;
|
||||
const coords = units[0].coords;
|
||||
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
|
||||
return false;
|
||||
}
|
||||
const owner = this.civAt(coords);
|
||||
if (owner < 0 || owner === units[0].civ) return false;
|
||||
const tile = this.tiles[key(coords.x, coords.y)];
|
||||
return !!tile && tile.terrainClass === "Land";
|
||||
},
|
||||
|
||||
// Sabotages the tile the spies stand on: its production is zeroed until a week
|
||||
// has passed. `frame` is an optional third nation the author tries to pin it
|
||||
// on; the victim cannot tell a correct blame from a successful false flag. The
|
||||
// acting spies are spent.
|
||||
requestSabotage(unitIds, { frame = null } = {}) {
|
||||
const units = this._unitsForOrder(unitIds);
|
||||
if (!this.canSabotage(unitIds)) return false;
|
||||
const actor = units[0].civ;
|
||||
const coords = units[0].coords;
|
||||
const k = key(coords.x, coords.y);
|
||||
const owner = this.civAt(coords);
|
||||
const until = this.totalHours + SABOTAGE.blockDays * HOURS_PER_DAY;
|
||||
this.tileSabotage.set(k, until);
|
||||
this._dropTileGdpCache(k);
|
||||
const region = this.regionCityAt(coords);
|
||||
this._addNews({
|
||||
type: NEWS_SABOTAGE,
|
||||
civ: owner,
|
||||
region: region ? region.name : "the frontier",
|
||||
suspect: this._blameCulprit(actor, owner, frame, units.length),
|
||||
});
|
||||
this._spendSpies(units);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Who the victim pins a covert act on: the true author, a chosen scapegoat, or
|
||||
// no one. A counter-intelligence service raises the chance of a true
|
||||
// identification; the author's own agencies lower it, and a larger team of
|
||||
// `agents` blunts the service in proportion.
|
||||
_blameCulprit(actor, victim, frame, agents = 1) {
|
||||
const chance = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
ESPIONAGE.baseIdentifyChance +
|
||||
ESPIONAGE.counterIntelligenceWeight * this._effectiveCounterIntelligence(victim, agents) -
|
||||
ESPIONAGE.agencyWeight * this._intelligenceLevel(actor)
|
||||
)
|
||||
);
|
||||
if (this._random() < chance) return actor;
|
||||
const scapegoat = Number.isInteger(frame) && frame >= 0 && frame < this.civilisations.length
|
||||
? frame
|
||||
: null;
|
||||
if (scapegoat !== null && scapegoat !== actor && scapegoat !== victim) {
|
||||
if (this._random() < ESPIONAGE.frameChance) return scapegoat;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Whether a tile's production is currently crippled by sabotage.
|
||||
isTileSabotaged(coords) {
|
||||
const k = key(coords.x, coords.y);
|
||||
return (this.tileSabotage.get(k) || 0) > this.totalHours;
|
||||
},
|
||||
|
||||
// Drops the sabotage blocks whose week has run out and forgets the GDP figures
|
||||
// they cached as zero, so a tile's production returns when the block ends.
|
||||
_tickSabotage() {
|
||||
if (!this.tileSabotage || this.tileSabotage.size === 0) return;
|
||||
for (const [k, until] of this.tileSabotage) {
|
||||
if (until > this.totalHours) continue;
|
||||
this.tileSabotage.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
}
|
||||
},
|
||||
|
||||
// --------------------------------------------------- city/comms spying ---
|
||||
|
||||
// A spy may read a foreign city from the tile it stands on or the six around
|
||||
// it: it can never enter a hostile city, so it watches from the doorstep.
|
||||
_spyWatches(units, city) {
|
||||
if (!city || city.civ === units[0].civ) return false;
|
||||
const from = units[0].coords;
|
||||
return this.topology.tileDistance(from, city.coords) <= 1;
|
||||
},
|
||||
|
||||
_spyStackAtOneTile(unitIds) {
|
||||
const units = this._unitsForOrder(unitIds);
|
||||
if (units.length === 0) return null;
|
||||
if (!units.every((unit) => this._isSpy(unit) && unit.civ === units[0].civ)) return null;
|
||||
const coords = units[0].coords;
|
||||
for (const unit of units) {
|
||||
if (unit.coords.x !== coords.x || unit.coords.y !== coords.y) return null;
|
||||
}
|
||||
return units;
|
||||
},
|
||||
|
||||
canCitySpy(unitIds, goal) {
|
||||
const units = this._spyStackAtOneTile(unitIds);
|
||||
if (!units) return false;
|
||||
return this._spyWatches(units, this.cityAt(goal));
|
||||
},
|
||||
|
||||
// Reads a foreign city: its people, building levels and garrison. A
|
||||
// counter-intelligence service can quietly falsify the report. The spying
|
||||
// spies are spent.
|
||||
requestCitySpy(unitIds, goal) {
|
||||
const units = this._spyStackAtOneTile(unitIds);
|
||||
if (!this.canCitySpy(unitIds, goal)) return false;
|
||||
const actor = units[0].civ;
|
||||
this._addSpyReport(actor, this._buildCityReport(this.cityAt(goal), units.length));
|
||||
this._spendSpies(units);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
_buildCityReport(city, agents = 1) {
|
||||
const target = city.civ;
|
||||
const falsify =
|
||||
this._random() < ESPIONAGE.falsifyChance * this._effectiveCounterIntelligence(target, agents);
|
||||
const buildings = [];
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const n = Number(level) || 0;
|
||||
if (n <= 0) continue;
|
||||
const proto = this.protoBuildings[index];
|
||||
buildings.push({
|
||||
name: proto ? proto.name : `Building ${index}`,
|
||||
level: falsify ? Math.max(0, n + (this._random() < 0.5 ? -1 : 1)) : n,
|
||||
});
|
||||
}
|
||||
const counts = new Map();
|
||||
for (const unit of this.unitsAt(city.coords)) {
|
||||
if (unit.civ !== target) continue;
|
||||
const proto = this.unitProto(unit);
|
||||
const name = proto ? proto.name : "Unit";
|
||||
counts.set(name, (counts.get(name) || 0) + 1);
|
||||
}
|
||||
const garrison = [];
|
||||
for (const [name, count] of counts) {
|
||||
garrison.push({
|
||||
name,
|
||||
count: falsify ? Math.max(0, count + (this._random() < 0.5 ? -1 : 1)) : count,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: this._nextSpyReportId++,
|
||||
kind: SPY_REPORT_CITY,
|
||||
hours: this.totalHours,
|
||||
city: city.name,
|
||||
civ: target,
|
||||
coords: [city.coords.x, city.coords.y],
|
||||
population: falsify ? this._jitterCount(city.population) : city.population,
|
||||
buildings,
|
||||
garrison,
|
||||
};
|
||||
},
|
||||
|
||||
canCommsSpy(unitIds, goal) {
|
||||
const units = this._spyStackAtOneTile(unitIds);
|
||||
if (!units) return false;
|
||||
const city = this.cityAt(goal);
|
||||
if (!city || !city.isCapital) return false;
|
||||
return this._spyWatches(units, city);
|
||||
},
|
||||
|
||||
// Intercepts the target capital's recent messages. A counter-intelligence
|
||||
// service can garble the text and swap the correspondents. The spying spies
|
||||
// are spent.
|
||||
requestCommsSpy(unitIds, goal) {
|
||||
const units = this._spyStackAtOneTile(unitIds);
|
||||
if (!this.canCommsSpy(unitIds, goal)) return false;
|
||||
const actor = units[0].civ;
|
||||
this._addSpyReport(actor, this._buildCommsReport(this.cityAt(goal), units.length));
|
||||
this._spendSpies(units);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
_buildCommsReport(capital, agents = 1) {
|
||||
const target = capital.civ;
|
||||
const garble =
|
||||
this._random() < ESPIONAGE.garbleChance * this._effectiveCounterIntelligence(target, agents);
|
||||
const involving = this.messages.filter(
|
||||
(message) => message.from === target || message.to === target
|
||||
);
|
||||
const recent = involving.slice(-ESPIONAGE.interceptCount);
|
||||
const messages = recent.map((message) =>
|
||||
garble ? this._garbleMessage(message) : { ...message, garbled: false }
|
||||
);
|
||||
return {
|
||||
id: this._nextSpyReportId++,
|
||||
kind: SPY_REPORT_COMMS,
|
||||
hours: this.totalHours,
|
||||
civ: target,
|
||||
capital: capital.name,
|
||||
messages,
|
||||
};
|
||||
},
|
||||
|
||||
_garbleMessage(message) {
|
||||
const swap = this._random() < 0.5;
|
||||
return {
|
||||
from: swap ? message.to : message.from,
|
||||
to: swap ? message.from : message.to,
|
||||
text: this._scramble(message.text),
|
||||
hours: message.hours,
|
||||
garbled: true,
|
||||
};
|
||||
},
|
||||
|
||||
_scramble(text) {
|
||||
let out = "";
|
||||
for (const character of String(text)) {
|
||||
out += this._random() < 0.3 ? "▒" : character;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
_jitterCount(value) {
|
||||
const base = Number(value) || 0;
|
||||
if (base <= 0) return 0;
|
||||
const factor = 0.6 + this._random() * 0.8;
|
||||
return Math.round(base * factor);
|
||||
},
|
||||
|
||||
_addSpyReport(civ, report) {
|
||||
if (!this.spyReports.has(civ)) this.spyReports.set(civ, []);
|
||||
const list = this.spyReports.get(civ);
|
||||
list.push(report);
|
||||
while (list.length > ESPIONAGE.reportLimit) list.shift();
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------ comms ------
|
||||
|
||||
// Records a line of chat for the message log comms spying reads. `to` is a
|
||||
// nation index for a private line, or null for the public channel.
|
||||
recordChat(from, to, text) {
|
||||
if (!Number.isInteger(from) || from < 0) return;
|
||||
const message = {
|
||||
from,
|
||||
to: Number.isInteger(to) && to >= 0 ? to : null,
|
||||
text: String(text || "").slice(0, 240),
|
||||
hours: this.totalHours,
|
||||
};
|
||||
this.messages.push(message);
|
||||
while (this.messages.length > ESPIONAGE.messageLogLimit) this.messages.shift();
|
||||
},
|
||||
|
||||
// --------------------------------------------------------- satellites ----
|
||||
|
||||
// How many satellites a nation's space launch centres can sustain: one per
|
||||
// centre level.
|
||||
_spaceLaunchCapacity(civ) {
|
||||
let total = 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
|
||||
total += Number(level) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
_satelliteCount(civ) {
|
||||
let total = 0;
|
||||
for (const satellite of this.satellites) {
|
||||
if (satellite.civ === civ) total += 1;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
canLaunchSatellite(civ) {
|
||||
return this._spaceLaunchCapacity(civ) > this._satelliteCount(civ);
|
||||
},
|
||||
|
||||
// Launches a spy satellite from `city`, which must hold a space launch centre.
|
||||
// Everyone is told; the satellite then uncovers a patch of the world every two
|
||||
// days for as long as the game runs.
|
||||
requestSatellite(civ, city) {
|
||||
if (!city || city.civ !== civ) return false;
|
||||
if (!this.canLaunchSatellite(civ)) return false;
|
||||
let holdsCentre = false;
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH && (Number(level) || 0) > 0) {
|
||||
holdsCentre = true;
|
||||
}
|
||||
}
|
||||
if (!holdsCentre) return false;
|
||||
this.satellites.push({
|
||||
civ,
|
||||
launchedHours: this.totalHours,
|
||||
nextRevealHour: this.totalHours + SATELLITE.revealEveryDays * HOURS_PER_DAY,
|
||||
});
|
||||
this._addNews({ type: NEWS_SATELLITE, civ });
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Uncovers each satellite's next patch once its cadence comes round.
|
||||
_tickSatellites() {
|
||||
if (!this.satellites || this.satellites.length === 0) return;
|
||||
for (const satellite of this.satellites) {
|
||||
let guard = 0;
|
||||
while (satellite.nextRevealHour <= this.totalHours && guard < 64) {
|
||||
this._revealSatelliteChunk(satellite.civ);
|
||||
satellite.nextRevealHour += SATELLITE.revealEveryDays * HOURS_PER_DAY;
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Adds a random patch of the world to `civ`'s explored set, the same set the
|
||||
// fog of war is drawn from.
|
||||
_revealSatelliteChunk(civ) {
|
||||
const explored = this.explored.get(civ);
|
||||
if (!explored) return;
|
||||
const pool = this.landCells.length > 0 ? this.landCells : Object.keys(this.tiles).map(parseKey);
|
||||
if (pool.length === 0) return;
|
||||
const centre = pool[Math.floor(this._random() * pool.length)];
|
||||
const queue = [{ x: centre.x, y: centre.y, d: 0 }];
|
||||
const seen = new Set([key(centre.x, centre.y)]);
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift();
|
||||
const k = key(node.x, node.y);
|
||||
if (this.tiles[k]) explored.add(k);
|
||||
if (node.d >= SATELLITE.revealRadius) continue;
|
||||
for (const neighbour of this.topology.neighbours(node.x, node.y)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (seen.has(nk)) continue;
|
||||
seen.add(nk);
|
||||
queue.push({ x: neighbour.x, y: neighbour.y, d: node.d + 1 });
|
||||
}
|
||||
}
|
||||
this._visibilityDirty = true;
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- secrets ------
|
||||
|
||||
// A newly raised enrichment centre may be noticed by foreign intelligence.
|
||||
_warnOfEnrichment(civ) {
|
||||
if (this._random() >= ESPIONAGE.enrichmentWarningChance) return;
|
||||
this._addNews({ type: NEWS_ENRICHMENT, civ });
|
||||
},
|
||||
|
||||
_isSpy(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
return !!(proto && proto.spy);
|
||||
},
|
||||
};
|
||||
@@ -410,7 +410,11 @@ export const movementMethods = {
|
||||
return false;
|
||||
}
|
||||
const owner = this.civAt(coords);
|
||||
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
|
||||
// A spy slips through foreign territory at will; every other unit treats a
|
||||
// third party's land as a wall (the targeted civilisation is exempt).
|
||||
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv && !proto.spy) {
|
||||
return false;
|
||||
}
|
||||
const occupants = occupancy
|
||||
? (occupancy.get(key(coords.x, coords.y)) || [])
|
||||
: this.unitsAt(coords).filter((other) => other !== unit);
|
||||
|
||||
@@ -21,6 +21,15 @@ export const orderMethods = {
|
||||
const proto = this.protoUnits[protoIndex];
|
||||
// Aircraft and warships need the right installation in the city first.
|
||||
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
|
||||
// A unit may also be gated behind research, a single technology or several
|
||||
// (an ICBM needs both the bomb and the rocket).
|
||||
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
|
||||
if (
|
||||
Array.isArray(proto.requiresTechnologies) &&
|
||||
!proto.requiresTechnologies.every((id) => this.hasTechnology(city.civ, id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// The local testing shortcut: no cost, no production time, no queue.
|
||||
if (free) {
|
||||
this._spawnTrainedUnit(city, proto);
|
||||
@@ -259,6 +268,22 @@ export const orderMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Debug/testing shortcut: grants a technology at once, skipping cost,
|
||||
// progress and prerequisites. Reached only through the server's testing gate
|
||||
// (a local page or a `--testing` game), never from a normal client.
|
||||
unlockTechnology(civ, index) {
|
||||
if (civ < 0 || civ >= this.civilisations.length) return false;
|
||||
const tech = this.technologies[index];
|
||||
if (!tech) return false;
|
||||
if (!tech.repeatable && this.isResearched(civ, index)) return false;
|
||||
this._discoverTechnology(civ, index);
|
||||
this._setTechnologyProgress(civ, index, 0);
|
||||
if (!tech.repeatable && this.getFocusedTechnology(civ) === index) this._clearFocus(civ);
|
||||
this._clearTileGdpCache();
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Whether the units may pillage the tile they stand on: all of them are on
|
||||
// one hostile tile where no battle is raging.
|
||||
canPillage(unitIds) {
|
||||
|
||||
@@ -70,8 +70,15 @@ export const politicsMethods = {
|
||||
|
||||
_tickPolitics() {
|
||||
this._tickCampaigns();
|
||||
this._tickMigration();
|
||||
this._migrateForIncome();
|
||||
// One day's out-migration budget per tile, shared by every mechanism that
|
||||
// moves people below, so no tile sheds more than its daily share.
|
||||
this._migrationOutDay = new Map();
|
||||
try {
|
||||
this._tickMigration();
|
||||
this._migrateForIncome();
|
||||
} finally {
|
||||
this._migrationOutDay = null;
|
||||
}
|
||||
this._tickUprisings();
|
||||
this._tickCultureImpact();
|
||||
this._expireGrudges();
|
||||
@@ -694,6 +701,39 @@ export const politicsMethods = {
|
||||
this._migrateByAir();
|
||||
},
|
||||
|
||||
// Whether migration of `eth` from one country into `toCiv` is forbidden:
|
||||
// people never move into a country they are at war with, and a nation that
|
||||
// expels a people does not let them back in.
|
||||
_migrationBlocked(fromCiv, toCiv, eth) {
|
||||
if (fromCiv >= 0 && toCiv >= 0 && fromCiv !== toCiv && this.isAtWar(fromCiv, toCiv)) {
|
||||
return true;
|
||||
}
|
||||
return toCiv >= 0 && this._expelsEthnicity(toCiv, eth);
|
||||
},
|
||||
|
||||
// Whether a nation has an active expel policy against a people.
|
||||
_expelsEthnicity(civ, eth) {
|
||||
const record = this.policies.get(civ);
|
||||
if (!record) return false;
|
||||
return record.some(
|
||||
(policy) => policy.type === POLICY_EXPEL && policy.ethnicity === eth
|
||||
);
|
||||
},
|
||||
|
||||
// Clamps the `people` leaving one tile to the day's remaining out-migration
|
||||
// budget and books what is spent. With no daily budget active (a direct call
|
||||
// outside the tick) nothing is clamped.
|
||||
_takeMigrationAllowance(fromK, people) {
|
||||
if (!(people > 0)) return 0;
|
||||
if (!this._migrationOutDay) return people;
|
||||
const population = this.tilePopulation.get(fromK) || 0;
|
||||
const cap = population * MIGRATION.maxDailyOutShare;
|
||||
const used = this._migrationOutDay.get(fromK) || 0;
|
||||
const allowed = Math.max(0, Math.min(people, cap - used));
|
||||
if (allowed > 0) this._migrationOutDay.set(fromK, used + allowed);
|
||||
return allowed;
|
||||
},
|
||||
|
||||
// ------------------------------------------------- income migration --
|
||||
|
||||
// People drift from poorer regions to richer ones, but only to places clearly
|
||||
@@ -833,7 +873,18 @@ export const politicsMethods = {
|
||||
if (source.length === 0 || dest.length === 0) return 0;
|
||||
const totalFrom = source.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
|
||||
if (totalFrom <= 0) return 0;
|
||||
const moved = Math.min(amount, totalFrom);
|
||||
let moved = Math.min(amount, totalFrom);
|
||||
// No tile may shed more than its daily out-migration share, so the move is
|
||||
// bounded by the budget still open across the source region.
|
||||
if (this._migrationOutDay) {
|
||||
let allowance = 0;
|
||||
for (const entry of source) {
|
||||
const people = this.tilePopulation.get(entry.k) || 0;
|
||||
const cap = people * MIGRATION.maxDailyOutShare;
|
||||
allowance += Math.max(0, cap - (this._migrationOutDay.get(entry.k) || 0));
|
||||
}
|
||||
if (moved > allowance) moved = allowance;
|
||||
}
|
||||
if (!(moved > 0)) return 0;
|
||||
|
||||
// The aggregate ethnic mix of the people leaving.
|
||||
@@ -849,7 +900,11 @@ export const politicsMethods = {
|
||||
|
||||
for (const entry of source) {
|
||||
const people = this.tilePopulation.get(entry.k) || 0;
|
||||
this.tilePopulation.set(entry.k, people - moved * people / totalFrom);
|
||||
const leave = moved * people / totalFrom;
|
||||
this.tilePopulation.set(entry.k, people - leave);
|
||||
if (this._migrationOutDay && leave > 0) {
|
||||
this._migrationOutDay.set(entry.k, (this._migrationOutDay.get(entry.k) || 0) + leave);
|
||||
}
|
||||
}
|
||||
const totalTo = dest.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
|
||||
for (const entry of dest) {
|
||||
@@ -1112,7 +1167,9 @@ export const politicsMethods = {
|
||||
},
|
||||
|
||||
// Moves `share` of one ethnicity's people from tile `fromK` to tile `toK`,
|
||||
// renormalises both tiles' shares and returns how many people moved.
|
||||
// renormalises both tiles' shares and returns how many people moved. Migration
|
||||
// into a hostile country, or a people the destination expels, is refused, and
|
||||
// the move is clamped to the tile's daily out-migration budget.
|
||||
_transferEthnicity(fromK, toK, eth, share) {
|
||||
const fromShares = this.tileEthnicity.get(fromK);
|
||||
const toShares = this.tileEthnicity.get(toK);
|
||||
@@ -1122,14 +1179,23 @@ export const politicsMethods = {
|
||||
const fromPopulation = this.tilePopulation.get(fromK) || 0;
|
||||
const toPopulation = this.tilePopulation.get(toK) || 0;
|
||||
if (fromPopulation <= 0 || toPopulation <= 0) return 0;
|
||||
const movedFraction = fromFraction * share;
|
||||
const fromCiv = this.territory.has(fromK) ? this.territory.get(fromK) : -1;
|
||||
const toCiv = this.territory.has(toK) ? this.territory.get(toK) : -1;
|
||||
if (this._migrationBlocked(fromCiv, toCiv, eth)) return 0;
|
||||
let movedFraction = fromFraction * share;
|
||||
let movedPeople = movedFraction * fromPopulation;
|
||||
const allowed = this._takeMigrationAllowance(fromK, movedPeople);
|
||||
if (!(allowed > 0)) return 0;
|
||||
if (allowed < movedPeople) {
|
||||
movedPeople = allowed;
|
||||
movedFraction = movedPeople / fromPopulation;
|
||||
}
|
||||
const remaining = Math.max(0, fromFraction - movedFraction);
|
||||
if (remaining <= 0.0001) fromShares.delete(eth);
|
||||
else fromShares.set(eth, remaining);
|
||||
toShares.set(eth, (toShares.get(eth) || 0) + movedFraction * (fromPopulation / toPopulation));
|
||||
this._setTileEthnicFractions(parseKey(toK), new Map(toShares));
|
||||
this._setTileEthnicFractions(parseKey(fromK), new Map(fromShares));
|
||||
const movedPeople = movedFraction * fromPopulation;
|
||||
this._trackMigration(fromK, toK, eth, movedPeople);
|
||||
this._touchPolitics();
|
||||
return movedPeople;
|
||||
|
||||
@@ -2272,8 +2272,10 @@ export const resourceMethods = {
|
||||
// its central bank. Freight rides with the goods.
|
||||
if (foreign) {
|
||||
// The seller's government charges the foreign buyer an export tariff,
|
||||
// in the seller's currency, on top of the goods.
|
||||
const exportTax = value * this.getTaxRate(supplier.civ, "export", id);
|
||||
// in the seller's currency, on top of the goods. A free-trade treaty
|
||||
// between the two governments lifts it.
|
||||
const exemption = this._tariffExempt(supplier.civ, city.civ);
|
||||
const exportTax = exemption ? 0 : value * this.getTaxRate(supplier.civ, "export", id);
|
||||
// The central bank pays part of the bill from its reserves, so a region
|
||||
// or converter imports food and energy more cheaply than the market.
|
||||
const subsidy = this._centralBankImportSubsidy(city.civ, id, value + exportTax);
|
||||
@@ -2295,7 +2297,9 @@ export const resourceMethods = {
|
||||
// The buyer's own government taxes the national currency it had to spend
|
||||
// to buy the foreign currency (nothing held means nothing spent).
|
||||
if (!payer && acquired.spent > 0) {
|
||||
const importRate = this.getTaxRate(city.civ, "import", id);
|
||||
const importRate = this._tariffExempt(supplier.civ, city.civ)
|
||||
? 0
|
||||
: this.getTaxRate(city.civ, "import", id);
|
||||
if (importRate > 0) {
|
||||
const importTax = acquired.spent * importRate;
|
||||
charge(importTax);
|
||||
|
||||
@@ -59,6 +59,8 @@ export const serializationMethods = {
|
||||
// production per head without shipping the whole breakdown for every tile.
|
||||
tileProductionPenalties: this._serializeTileValues(this.tileProductionPenalty),
|
||||
tileBattleProductionDeficits: this._serializeTileValues(this.tileBattleProductionDeficit),
|
||||
// Tiles a spy has crippled, with the hour each block ends.
|
||||
tileSabotage: this._serializeSabotage(),
|
||||
civStats: this._serializeCivStats(),
|
||||
cityStats: cityStats,
|
||||
budgets: this._serializeCivValues(this.budgets),
|
||||
@@ -93,6 +95,11 @@ export const serializationMethods = {
|
||||
repeatCounts: this._serializeRepeatCounts(),
|
||||
diplomacy: this._serializeDiplomacy(),
|
||||
conflicts: this._serializeConflicts(),
|
||||
// Diplomatic treaties: offers awaiting an answer, the agreements in
|
||||
// force, and the ones that ended.
|
||||
treaties: this._serializeTreaties(),
|
||||
treatyProposals: this._serializeTreatyProposals(),
|
||||
treatyHistory: this._serializeTreatyHistory(),
|
||||
news: this._serializeNews(),
|
||||
battles: this._serializeBattles(),
|
||||
// The global resource market: today's price and the supply and demand
|
||||
@@ -142,6 +149,12 @@ export const serializationMethods = {
|
||||
result.viewerStats = stats || this.viewerStats(viewerCiv);
|
||||
result.explored = this._cachedExplored(viewerCiv);
|
||||
result.visible = this._cachedVisible(viewerCiv);
|
||||
// Enemy submarines stay off the map until one of the viewer's naval units
|
||||
// is on or beside them.
|
||||
const hidden = this._hiddenEnemyUnits(viewerCiv);
|
||||
if (hidden.size > 0) {
|
||||
result.units = (shared.units || []).filter((unit) => !hidden.has(unit.id));
|
||||
}
|
||||
// What a city is producing is its owner's business: every other viewer sees
|
||||
// an empty queue, so neither the map bar nor the city panel can reveal
|
||||
// whether a foreign city is busy.
|
||||
@@ -149,6 +162,15 @@ export const serializationMethods = {
|
||||
// The delivery graph is the viewer's own network: its storage nodes and the
|
||||
// legs that carried goods into its cities. Only the economic map reveals it.
|
||||
result.resourceGraph = this._serializeResourceGraph(viewerCiv);
|
||||
// The spy reports brought home for this viewer. The chat log they are drawn
|
||||
// from stays on the server and never ships.
|
||||
result.spyReports = (this.spyReports && this.spyReports.get(viewerCiv)) || [];
|
||||
// Satellites are few and their launches are public knowledge.
|
||||
result.satellites = (this.satellites || []).map((satellite) => ({
|
||||
civ: satellite.civ,
|
||||
launchedHours: satellite.launchedHours,
|
||||
nextRevealHour: satellite.nextRevealHour,
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -344,6 +366,18 @@ export const serializationMethods = {
|
||||
return result;
|
||||
},
|
||||
|
||||
// Tiles crippled by sabotage, as [x, y, untilHour]. The map only ever holds
|
||||
// live blocks (`_tickSabotage` drops them as they lapse), so the list is tiny.
|
||||
_serializeSabotage() {
|
||||
const result = [];
|
||||
for (const [k, until] of this.tileSabotage) {
|
||||
if (until <= this.totalHours) continue;
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y, until]);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_serializeTileImprovements() {
|
||||
const result = [];
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
@@ -608,6 +642,43 @@ export const serializationMethods = {
|
||||
return this.news.map((entry) => ({ ...entry }));
|
||||
},
|
||||
|
||||
// A treaty as it travels on the wire: the parties, the terms' payload, and
|
||||
// the signing (and, in the history, ending) dates.
|
||||
_serializeTreaty(treaty) {
|
||||
return {
|
||||
id: treaty.id,
|
||||
type: treaty.type,
|
||||
a: treaty.a,
|
||||
b: treaty.b,
|
||||
payload: treaty.payload ? JSON.parse(JSON.stringify(treaty.payload)) : {},
|
||||
signedHours: treaty.signedHours,
|
||||
signedDate: treaty.signedDate,
|
||||
endedHours: treaty.endedHours,
|
||||
endedDate: treaty.endedDate,
|
||||
reason: treaty.reason,
|
||||
};
|
||||
},
|
||||
|
||||
_serializeTreaties() {
|
||||
return this.treaties.map((treaty) => this._serializeTreaty(treaty));
|
||||
},
|
||||
|
||||
_serializeTreatyHistory() {
|
||||
return this.treatyHistory.map((treaty) => this._serializeTreaty(treaty));
|
||||
},
|
||||
|
||||
_serializeTreatyProposals() {
|
||||
return this.treatyProposals.map((proposal) => ({
|
||||
id: proposal.id,
|
||||
type: proposal.type,
|
||||
from: proposal.from,
|
||||
to: proposal.to,
|
||||
payload: proposal.payload ? JSON.parse(JSON.stringify(proposal.payload)) : {},
|
||||
proposedHours: proposal.proposedHours,
|
||||
proposedDate: proposal.proposedDate,
|
||||
}));
|
||||
},
|
||||
|
||||
// One entry per battlefield: [x, y, defenderCiv, attackerCiv]. The tile's
|
||||
// owner is the defender when present; otherwise the lowest-numbered civ on
|
||||
// the tile defends. The browser uses this to draw the two stacks facing each
|
||||
|
||||
@@ -3,19 +3,15 @@
|
||||
// Ordering a tile improvement no longer pays for it and raises it at once.
|
||||
// Instead the state hands the site a forecast budget and the tile becomes an
|
||||
// economic node that first gathers the materials it needs -- buying them from
|
||||
// the reachable stores at the prices that stood when the work was decided -- and
|
||||
// only then builds, drawing power from the grid as it goes. Leftover money stays
|
||||
// with the improvement; a bill the site cannot cover is taken from the public
|
||||
// treasury.
|
||||
// the reachable stores at whatever the market currently asks -- and only then
|
||||
// builds, drawing power from the grid as it goes. Leftover money stays with the
|
||||
// improvement; a bill the site cannot cover is taken from the public treasury.
|
||||
//
|
||||
// A site stalls when a whole day passes without buying a needed material at the
|
||||
// locked price (the market moved against it). Rather than waiting for a manual
|
||||
// top-up, it raises its budgeted prices -- and the money behind them -- each
|
||||
// day, 1% at first and doubling daily, until it can buy; only when no supplier
|
||||
// is within reach at all does it keep a lasting warning. The owner may still
|
||||
// cancel the work, losing everything already spent. Production buildings that
|
||||
// finish become private agents and step through the same phases again when they
|
||||
// upgrade.
|
||||
// A site stalls only when no supplier of a needed material is within reach;
|
||||
// there is no daily budget rise any more, the work simply buys at the current
|
||||
// market price. The owner may still cancel the work, losing everything already
|
||||
// spent. Production buildings that finish become private agents and step
|
||||
// through the same phases again when they upgrade.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
@@ -26,14 +22,6 @@ import {
|
||||
} from "../resources.js";
|
||||
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
// A supplier whose delivered price has risen more than this much over the
|
||||
// locked price is not bought from: the site waits for the market or raises its
|
||||
// own budget.
|
||||
const PRICE_TOLERANCE = 0.01;
|
||||
// The first day a stalled site cannot buy, its budgeted prices and the matching
|
||||
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
|
||||
// it bids its way up to the market unaided.
|
||||
const BUDGET_DAILY_RISE = 0.01;
|
||||
const MATERIAL_IDS = ["steel", "hightech"];
|
||||
const RESOURCE_IDS = ["steel", "hightech", "energy"];
|
||||
|
||||
@@ -219,10 +207,13 @@ export const siteMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Tries to buy every material the site still needs at its locked prices. A day
|
||||
// that buys nothing while materials are still missing stalls the work, and the
|
||||
// budget below starts climbing to meet the market.
|
||||
// Tries to buy every material the site still needs at the current market
|
||||
// prices. A day that buys nothing while materials are still missing stalls the
|
||||
// work because no supplier is within reach.
|
||||
_gatherSiteMaterials(site) {
|
||||
// Keep the site's reported prices pointed at the live market rather than a
|
||||
// price frozen when the work was ordered.
|
||||
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
|
||||
let boughtAnything = false;
|
||||
for (const id of MATERIAL_IDS) {
|
||||
const missing = site.needed[id] - site.bought[id];
|
||||
@@ -250,22 +241,19 @@ export const siteMethods = {
|
||||
if (!stalled) site.budgetRaises = 0;
|
||||
},
|
||||
|
||||
// A day that bought nothing while a supplier is within reach raises the site's
|
||||
// budgeted prices and moves the matching money out of the treasury, then tries
|
||||
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
|
||||
// 8%, ...), so a stubborn price is met within a few days. A supplier still
|
||||
// above the raised cap simply stalls it once more; the count resets as soon as
|
||||
// it buys. When no supplier can reach the site at any price the rise cannot
|
||||
// help, so it is left alone. A private site raises its own agent's account
|
||||
// instead of the treasury: the money is borrowed, not subsidised.
|
||||
// A day that bought nothing while a supplier is within reach prices what is
|
||||
// still missing at the current market and moves the difference into the site's
|
||||
// account, then tries again. There is no daily percentage rise any more: the
|
||||
// site simply buys at whatever the market currently asks. When no supplier can
|
||||
// reach the site at any price the top-up cannot help, so it is left alone. A
|
||||
// private site raises its own agent's account instead of the treasury: the
|
||||
// money is borrowed, not subsidised.
|
||||
_raiseSiteBudgetIfStalled(site) {
|
||||
if (!site.stalled || site.phase !== "materials") return;
|
||||
if (this._siteUnreachableMaterials(site).length > 0) return;
|
||||
const raises = site.budgetRaises || 0;
|
||||
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
|
||||
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
|
||||
site.budgetRaises = raises + 1;
|
||||
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
|
||||
const add = this._siteTopUp(site, site.prices);
|
||||
if (!(add > 0)) return;
|
||||
if (!site.private) this._reserveSiteBudget(site.civ, add);
|
||||
site.budget += add;
|
||||
this._gatherSiteMaterials(site);
|
||||
@@ -274,8 +262,8 @@ export const siteMethods = {
|
||||
|
||||
// Buys `amount` of one resource for the site: first from the allied city the
|
||||
// route hauls to, then from the reachable stores, paying from the site's own
|
||||
// budget first and the public treasury after. A supplier quoting above the
|
||||
// locked price is skipped.
|
||||
// budget first and the public treasury after. The site does not hold out for a
|
||||
// locked price -- it buys at whatever the cheapest reachable supplier asks.
|
||||
_siteBuy(site, id, amount) {
|
||||
if (!(amount > 1e-9)) return 0;
|
||||
if (!site.cityId) return 0;
|
||||
@@ -298,11 +286,10 @@ export const siteMethods = {
|
||||
const nodes = this._resourceNodes();
|
||||
const temp = emptyResources();
|
||||
const allowSea = this._cityMarketAccess(city);
|
||||
const cap = site.prices[id] * (1 + PRICE_TOLERANCE);
|
||||
this._procureFromNeighbours(city, id, remaining, temp, nodes, {
|
||||
payer,
|
||||
allowSea,
|
||||
maxUnitCost: cap,
|
||||
maxUnitCost: Infinity,
|
||||
});
|
||||
bought += temp[id] || 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
// Diplomatic treaties: proposals that one nation makes to another, the active
|
||||
// agreements they become when signed, and the history of those that ended. The
|
||||
// type catalogue lives in `data/treaties.js`; this module owns the state machine
|
||||
// (propose -> sign/reject -> break) and the effects each treaty carries.
|
||||
//
|
||||
// A signed treaty is a record `{ id, type, a, b, signedHours, signedDate,
|
||||
// payload }` in `this.treaties`. A proposal is `{ id, type, from, to, payload,
|
||||
// proposedHours, proposedDate }` in `this.treatyProposals`. Ended treaties move
|
||||
// to `this.treatyHistory` with the reason and hour they ended. Everything is
|
||||
// public, so it all travels in the shared snapshot.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import {
|
||||
TREATY_PEACE,
|
||||
TREATY_ALLIANCE,
|
||||
TREATY_FREE_TRADE,
|
||||
TREATY_GIVE_CITY,
|
||||
TREATY_GIVE_MILITARY,
|
||||
TREATY_GIVE_RESOURCES,
|
||||
TREATY_GIVE_MONEY,
|
||||
TREATY_JOINT_RESEARCH,
|
||||
treatyById,
|
||||
TREATY_PROPOSED,
|
||||
TREATY_SIGNED,
|
||||
TREATY_REJECTED,
|
||||
TREATY_BROKEN,
|
||||
} from "../data/treaties.js";
|
||||
import { NEWS_TREATY } from "../data/relations.js";
|
||||
import { STORABLE_RESOURCE_IDS } from "../data/resources.js";
|
||||
import { warKey } from "./helpers.js";
|
||||
import { dateString } from "../game_clock.js";
|
||||
|
||||
// A treaty proposal older than this many hours is dropped, so a stale offer
|
||||
// does not sit in the receiver's inbox for ever.
|
||||
const PROPOSAL_TTL_HOURS = 24 * 30;
|
||||
|
||||
export const treatyMethods = {
|
||||
// ------------------------------------------------------------ lifecycle --
|
||||
|
||||
// Offers a treaty from one nation to another. The type must be known, the
|
||||
// parties distinct and valid, and any payload (a city, units, a resource
|
||||
// basket or money) must belong to the proposer. A duplicate offer for the
|
||||
// same type between the same two nations is refused.
|
||||
requestProposeTreaty(from, to, type, payload = {}) {
|
||||
if (!this._validCiv(from) || !this._validCiv(to) || from === to) return false;
|
||||
const proto = treatyById(type);
|
||||
if (!proto) return false;
|
||||
if (this._pendingTreaty(from, to, type)) return false;
|
||||
if (this.hasTreaty(from, to, type)) return false;
|
||||
const clean = this._validateTreatyPayload(from, to, type, payload);
|
||||
if (clean === null) return false;
|
||||
const proposal = {
|
||||
id: this._nextTreatyProposalId++,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
payload: clean,
|
||||
proposedHours: this.totalHours,
|
||||
proposedDate: dateString(this.totalHours),
|
||||
};
|
||||
this.treatyProposals.push(proposal);
|
||||
this._addNews({
|
||||
type: NEWS_TREATY, stage: TREATY_PROPOSED, treaty: type, from, to,
|
||||
});
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Accepts or rejects a proposal addressed to `civ`. Only the nation the
|
||||
// proposal names as its receiver may answer, and only once.
|
||||
requestRespondTreaty(civ, proposalId, accept) {
|
||||
const index = this.treatyProposals.findIndex((p) => p.id === proposalId);
|
||||
if (index < 0) return false;
|
||||
const proposal = this.treatyProposals[index];
|
||||
if (proposal.to !== civ) return false;
|
||||
this.treatyProposals.splice(index, 1);
|
||||
if (!accept) {
|
||||
this._addNews({
|
||||
type: NEWS_TREATY, stage: TREATY_REJECTED, treaty: proposal.type,
|
||||
from: proposal.from, to: proposal.to,
|
||||
});
|
||||
this._emitChanged();
|
||||
return true;
|
||||
}
|
||||
const payload = this._validateTreatyPayload(proposal.from, proposal.to, proposal.type, proposal.payload);
|
||||
if (payload === null) return false;
|
||||
const treaty = {
|
||||
id: this._nextTreatyId++,
|
||||
type: proposal.type,
|
||||
a: proposal.from,
|
||||
b: proposal.to,
|
||||
payload,
|
||||
signedHours: this.totalHours,
|
||||
signedDate: dateString(this.totalHours),
|
||||
};
|
||||
// Free trade is a standing exemption, not a one-off effect.
|
||||
if (proposal.type !== TREATY_FREE_TRADE) this._applyTreatyEffect(treaty);
|
||||
this.treaties.push(treaty);
|
||||
this._addNews({
|
||||
type: NEWS_TREATY, stage: TREATY_SIGNED, treaty: treaty.type,
|
||||
from: treaty.a, to: treaty.b,
|
||||
});
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Ends an active treaty, whoever asks. Used when a player breaks an
|
||||
// agreement and when a war cancels an alliance.
|
||||
_endTreaty(treaty, reason = TREATY_BROKEN) {
|
||||
const index = this.treaties.indexOf(treaty);
|
||||
if (index < 0) return false;
|
||||
this.treaties.splice(index, 1);
|
||||
treaty.endedHours = this.totalHours;
|
||||
treaty.endedDate = dateString(this.totalHours);
|
||||
treaty.reason = reason;
|
||||
this.treatyHistory.push(treaty);
|
||||
while (this.treatyHistory.length > this._treatyHistoryLimit) this.treatyHistory.shift();
|
||||
this._addNews({
|
||||
type: NEWS_TREATY, stage: reason, treaty: treaty.type,
|
||||
from: treaty.a, to: treaty.b,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
// A player cancels one of their own active treaties.
|
||||
requestCancelTreaty(civ, treatyId) {
|
||||
const treaty = this.treaties.find((t) => t.id === treatyId);
|
||||
if (!treaty || (treaty.a !== civ && treaty.b !== civ)) return false;
|
||||
this._endTreaty(treaty, TREATY_BROKEN);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------- queries --
|
||||
|
||||
hasTreaty(a, b, type) {
|
||||
return this.getTreaty(a, b, type) !== null;
|
||||
},
|
||||
|
||||
getTreaty(a, b, type) {
|
||||
if (a < 0 || b < 0 || a === b) return null;
|
||||
for (const treaty of this.treaties) {
|
||||
if (treaty.type !== type) continue;
|
||||
if ((treaty.a === a && treaty.b === b) || (treaty.a === b && treaty.b === a)) {
|
||||
return treaty;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
areAllied(a, b) {
|
||||
return this.hasTreaty(a, b, TREATY_ALLIANCE);
|
||||
},
|
||||
|
||||
freeTradePartners(civ) {
|
||||
const partners = new Set();
|
||||
for (const treaty of this.treaties) {
|
||||
if (treaty.type !== TREATY_FREE_TRADE) continue;
|
||||
if (treaty.a === civ) partners.add(treaty.b);
|
||||
else if (treaty.b === civ) partners.add(treaty.a);
|
||||
}
|
||||
return partners;
|
||||
},
|
||||
|
||||
// Whether a tariff between a seller and a buyer is lifted by a free-trade
|
||||
// treaty. Used by the resource market when it charges the tax.
|
||||
_tariffExempt(seller, buyer) {
|
||||
if (seller < 0 || buyer < 0) return false;
|
||||
return this.freeTradePartners(seller).has(buyer);
|
||||
},
|
||||
|
||||
// Every nation allied with `civ`.
|
||||
_alliesOf(civ) {
|
||||
const allies = [];
|
||||
for (const treaty of this.treaties) {
|
||||
if (treaty.type !== TREATY_ALLIANCE) continue;
|
||||
if (treaty.a === civ) allies.push(treaty.b);
|
||||
else if (treaty.b === civ) allies.push(treaty.a);
|
||||
}
|
||||
return allies;
|
||||
},
|
||||
|
||||
// Nations that share a joint-research treaty with `civ`.
|
||||
jointResearchPartners(civ) {
|
||||
const partners = [];
|
||||
for (const treaty of this.treaties) {
|
||||
if (treaty.type !== TREATY_JOINT_RESEARCH) continue;
|
||||
if (treaty.a === civ) partners.push(treaty.b);
|
||||
else if (treaty.b === civ) partners.push(treaty.a);
|
||||
}
|
||||
return partners;
|
||||
},
|
||||
|
||||
_pendingTreaty(from, to, type) {
|
||||
return this.treatyProposals.some(
|
||||
(p) => p.type === type && p.from === from && p.to === to
|
||||
);
|
||||
},
|
||||
|
||||
// Expires old proposals; called from the hourly tick. Returns true when the
|
||||
// inbox changed.
|
||||
_expireTreatyProposals() {
|
||||
if (this.treatyProposals.length === 0) return false;
|
||||
const before = this.treatyProposals.length;
|
||||
this.treatyProposals = this.treatyProposals.filter(
|
||||
(p) => this.totalHours - p.proposedHours < PROPOSAL_TTL_HOURS
|
||||
);
|
||||
return this.treatyProposals.length !== before;
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------ validation --
|
||||
|
||||
// Returns the cleaned payload for a treaty, or null when it cannot be
|
||||
// honoured. The `from` nation must own whatever it is giving away.
|
||||
_validateTreatyPayload(from, to, type, payload) {
|
||||
const data = payload || {};
|
||||
if (type === TREATY_GIVE_CITY) {
|
||||
const city = this.findCity(Number(data.city));
|
||||
if (!city || city.civ !== from) return null;
|
||||
return { city: city.id };
|
||||
}
|
||||
if (type === TREATY_GIVE_MILITARY) {
|
||||
const ids = Array.isArray(data.units) ? data.units.map(Number) : [];
|
||||
const units = [];
|
||||
for (const id of ids) {
|
||||
const unit = this.findUnit(id);
|
||||
if (!unit || unit.civ !== from) return null;
|
||||
const proto = this.unitProto(unit);
|
||||
if (proto && proto.infantry) return null;
|
||||
units.push(unit.id);
|
||||
}
|
||||
if (units.length === 0) return null;
|
||||
return { units };
|
||||
}
|
||||
if (type === TREATY_GIVE_RESOURCES) {
|
||||
const basket = {};
|
||||
let any = false;
|
||||
for (const id of STORABLE_RESOURCE_IDS) {
|
||||
const amount = Number((data.resources || {})[id] || 0);
|
||||
if (!(amount > 0)) continue;
|
||||
basket[id] = amount;
|
||||
any = true;
|
||||
}
|
||||
return any ? { resources: basket } : null;
|
||||
}
|
||||
if (type === TREATY_GIVE_MONEY) {
|
||||
const amount = Number(data.amount || 0);
|
||||
if (!(amount > 0)) return null;
|
||||
return { amount };
|
||||
}
|
||||
// Peace, alliance, free trade and joint research carry no payload.
|
||||
return {};
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- effects --
|
||||
|
||||
_applyTreatyEffect(treaty) {
|
||||
switch (treaty.type) {
|
||||
case TREATY_PEACE:
|
||||
this._makePeace(treaty.a, treaty.b);
|
||||
break;
|
||||
case TREATY_GIVE_CITY: {
|
||||
const city = this.findCity(treaty.payload.city);
|
||||
if (city && city.civ === treaty.a) this._captureCity(city, treaty.b);
|
||||
break;
|
||||
}
|
||||
case TREATY_GIVE_MILITARY:
|
||||
this._giftUnits(treaty.a, treaty.b, treaty.payload.units);
|
||||
break;
|
||||
case TREATY_GIVE_RESOURCES:
|
||||
this._giftResources(treaty.a, treaty.b, treaty.payload.resources);
|
||||
break;
|
||||
case TREATY_GIVE_MONEY: {
|
||||
const amount = treaty.payload.amount;
|
||||
this.budgets.set(treaty.a, this.getBudget(treaty.a) - amount);
|
||||
this.budgets.set(treaty.b, this.getBudget(treaty.b) + amount);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// Peace ends the war, forgets the conflict record only in so far as combat
|
||||
// stops, and sends each side's troops back to their nearest city.
|
||||
_makePeace(a, b) {
|
||||
const k = warKey(a, b);
|
||||
if (this.wars.has(k)) {
|
||||
this.wars.delete(k);
|
||||
this.conflicts.delete(k);
|
||||
this._invalidateSpatialCaches();
|
||||
}
|
||||
this._sendTroopsHome(a, b);
|
||||
this._sendTroopsHome(b, a);
|
||||
},
|
||||
|
||||
// Teleports every unit of `civ` standing in `enemy`'s territory to its
|
||||
// nearest own city. An island unit with no reachable home simply appears at
|
||||
// the nearest city by tile distance, mirroring the "for now" simplification.
|
||||
_sendTroopsHome(civ, enemy) {
|
||||
const home = this.cities.filter((city) => city.civ === civ);
|
||||
if (home.length === 0) return;
|
||||
for (const unit of this.units.slice()) {
|
||||
if (unit.civ !== civ) continue;
|
||||
const k = key(unit.coords.x, unit.coords.y);
|
||||
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
|
||||
if (owner !== enemy) continue;
|
||||
let best = home[0];
|
||||
let bestDistance = Infinity;
|
||||
for (const city of home) {
|
||||
const distance = this.topology.tileDistance(unit.coords, city.coords);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = city;
|
||||
}
|
||||
}
|
||||
unit.path = [];
|
||||
unit.pathIndex = 0;
|
||||
unit.strikeTarget = null;
|
||||
this._moveUnitTo(unit, { x: best.coords.x, y: best.coords.y });
|
||||
}
|
||||
this._invalidateUnitCaches();
|
||||
},
|
||||
|
||||
_giftUnits(from, to, ids) {
|
||||
let moved = 0;
|
||||
for (const id of ids) {
|
||||
const unit = this.findUnit(id);
|
||||
if (!unit || unit.civ !== from) continue;
|
||||
this._unindexUnit(unit);
|
||||
unit.civ = to;
|
||||
this._indexUnit(unit);
|
||||
moved += 1;
|
||||
}
|
||||
if (moved > 0) {
|
||||
this._invalidateUnitCaches();
|
||||
this._touchModifiers();
|
||||
}
|
||||
},
|
||||
|
||||
// Moves a resource basket from one nation to another, draining the giver's
|
||||
// cities in proportion to what each holds and dropping it all on the
|
||||
// receiver's capital (or first city). Non-storable goods such as energy are
|
||||
// skipped.
|
||||
_giftResources(from, to, basket) {
|
||||
const givers = this.cities.filter((city) => city.civ === from);
|
||||
const takers = this.cities.filter((city) => city.civ === to);
|
||||
if (givers.length === 0 || takers.length === 0) return;
|
||||
const sink = takers.find((city) => city.isCapital) || takers[0];
|
||||
const sinkStore = this.getCityResourceStock(sink);
|
||||
for (const [id, wanted] of Object.entries(basket || {})) {
|
||||
let remaining = wanted;
|
||||
for (const city of givers) {
|
||||
if (remaining <= 0) break;
|
||||
const store = this.getCityResourceStock(city);
|
||||
const take = Math.min(store[id] || 0, remaining);
|
||||
if (take <= 0) continue;
|
||||
store[id] -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
const moved = wanted - remaining;
|
||||
if (moved > 0) sinkStore[id] = (sinkStore[id] || 0) + moved;
|
||||
}
|
||||
},
|
||||
|
||||
// Declaring war tears up any alliance or peace between the two and drags the
|
||||
// target's allies into the fight. Called from `_declareWar` before the war
|
||||
// records are written, so an alliance never coexists with a war. The broken
|
||||
// treaty's news entry is the warning the players get.
|
||||
_handleWarAlliances(actor, target) {
|
||||
const defenders = new Set([target]);
|
||||
for (const ally of this._alliesOf(target)) defenders.add(ally);
|
||||
for (const defender of defenders) {
|
||||
this._breakTreaty(actor, defender, TREATY_ALLIANCE);
|
||||
this._breakTreaty(actor, defender, TREATY_PEACE);
|
||||
}
|
||||
return Array.from(defenders).filter((civ) => civ !== actor);
|
||||
},
|
||||
|
||||
_breakTreaty(a, b, type) {
|
||||
const treaty = this.getTreaty(a, b, type);
|
||||
if (!treaty) return false;
|
||||
this._endTreaty(treaty, TREATY_BROKEN);
|
||||
return true;
|
||||
},
|
||||
|
||||
_breakAlliance(a, b) {
|
||||
return this._breakTreaty(a, b, TREATY_ALLIANCE);
|
||||
},
|
||||
};
|
||||
@@ -47,8 +47,39 @@ export const visibilityMethods = {
|
||||
this._visibleVersion += 1;
|
||||
},
|
||||
|
||||
_revealAll() {
|
||||
for (let i = 0; i < this.civilisations.length; i++) {
|
||||
// The enemy units a nation cannot see: a submarine stays hidden unless one of
|
||||
// the viewer's naval units sits on its tile or directly beside it, and a spy
|
||||
// is hidden from every other nation outright. A spectator (viewer -1) sees
|
||||
// everything.
|
||||
_hiddenEnemyUnits(viewerCiv) {
|
||||
const hidden = new Set();
|
||||
if (viewerCiv < 0) return hidden;
|
||||
for (const unit of this.units) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto || unit.civ === viewerCiv) continue;
|
||||
if (proto.spy) {
|
||||
hidden.add(unit.id);
|
||||
continue;
|
||||
}
|
||||
if (proto.submarine && !this._submarineDetectedBy(unit.coords, viewerCiv)) {
|
||||
hidden.add(unit.id);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
},
|
||||
|
||||
_submarineDetectedBy(coords, viewerCiv) {
|
||||
for (const spot of [coords, ...this._neighbours(coords)]) {
|
||||
for (const other of this.unitsAt(spot)) {
|
||||
if (other.civ !== viewerCiv) continue;
|
||||
const proto = this.unitProto(other);
|
||||
if (proto && proto.naval) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_revealAll() { for (let i = 0; i < this.civilisations.length; i++) {
|
||||
this.explored.set(i, new Set(Object.keys(this.tiles)));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -212,32 +212,34 @@ export const warfareMethods = {
|
||||
|
||||
// ------------------------------------------------------ transports --
|
||||
|
||||
// Infantry board a friendly launch. They must stand on the shore beside it;
|
||||
// the boarding takes EMBARK_HOURS in-game hours to complete.
|
||||
requestEmbark(infantryIds, launchId) {
|
||||
// Infantry and spies board a friendly launch. They must stand on the shore
|
||||
// beside it; the boarding takes EMBARK_HOURS in-game hours to complete. A spy
|
||||
// rides a launch to reach an island its land legs cannot.
|
||||
requestEmbark(passengerIds, launchId) {
|
||||
const launch = this.findUnit(Number(launchId));
|
||||
if (!launch) return false;
|
||||
const proto = this.unitProto(launch);
|
||||
if (!proto || !(proto.transportCapacity > 0)) return false;
|
||||
if (launch.embarking) return false;
|
||||
const cargo = launch.cargo || [];
|
||||
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
|
||||
const passengers = this._unitsForOrder(passengerIds).filter((unit) => {
|
||||
const unitProto = this.unitProto(unit);
|
||||
return unitProto && unitProto.infantry && unit.civ === launch.civ;
|
||||
const boards = unitProto && (unitProto.infantry || unitProto.spy);
|
||||
return boards && unit.civ === launch.civ;
|
||||
});
|
||||
if (infantry.length === 0) return false;
|
||||
if (cargo.length + infantry.length > proto.transportCapacity) return false;
|
||||
if (passengers.length === 0) return false;
|
||||
if (cargo.length + passengers.length > proto.transportCapacity) return false;
|
||||
// The troops must be on the shore next to the launch.
|
||||
if (!infantry.every((unit) =>
|
||||
if (!passengers.every((unit) =>
|
||||
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
|
||||
return false;
|
||||
}
|
||||
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
|
||||
launch.embarking = { ids: passengers.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Finishes any boarding: at zero hours the infantry leave the map and become
|
||||
// Finishes any boarding: at zero hours the passengers leave the map and become
|
||||
// the launch's cargo, and are carried until put ashore.
|
||||
_tickEmbark() {
|
||||
// Iterate a copy: boarding destroys the infantry, and splicing the live
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Weapons of mass destruction: one-use missiles and the nuclear warhead they
|
||||
// can carry. A missile is trained like any other unit but never marches; firing
|
||||
// it consumes the rocket and lands its warhead -- conventional or nuclear -- on
|
||||
// a tile within reach. An intercontinental ballistic missile carries its
|
||||
// nuclear payload to any tile on the map.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { WMD } from "../data/combat.js";
|
||||
import { tileImprovementById } from "../data/improvements.js";
|
||||
|
||||
export const wmdMethods = {
|
||||
// ------------------------------------------------------------- missiles --
|
||||
|
||||
_isMissileProto(proto) {
|
||||
return !!(proto && proto.missile);
|
||||
},
|
||||
|
||||
_isMissileUnit(unit) {
|
||||
return this._isMissileProto(this.unitProto(unit));
|
||||
},
|
||||
|
||||
// How far a missile reaches: a global rocket reaches anywhere.
|
||||
_missileRange(proto) {
|
||||
if (!proto) return 0;
|
||||
return proto.globalRange ? Infinity : (proto.range || 0);
|
||||
},
|
||||
|
||||
// Whether a nation may arm a missile with a nuclear warhead: it needs the
|
||||
// bomb, which in turn only became researchable once a uranium enrichment
|
||||
// centre stood somewhere in the country.
|
||||
_canUseNuclearPayload(civ) {
|
||||
return this.hasTechnology(civ, "nuclear_weapons");
|
||||
},
|
||||
|
||||
// Fires one or more missiles at `goal`. A conventional warhead strikes the
|
||||
// unit or city there; a nuclear one flattens the whole tile. `nuclear` is
|
||||
// honoured only for a nation that has researched the bomb, and an ICBM is
|
||||
// always nuclear. Every missile is consumed, so the stack is gone afterwards.
|
||||
requestMissileStrike(unitIds, goal, { nuclear = false } = {}) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
const civ = units[0].civ;
|
||||
if (units.some((unit) => unit.civ !== civ)) return false;
|
||||
// An ICBM carries a nuclear warhead by design; a plain missile may be
|
||||
// up-armed only by a nuclear power.
|
||||
const anyNuclear = units.some((unit) => {
|
||||
const proto = this.unitProto(unit);
|
||||
return !!(proto && proto.nuclear);
|
||||
});
|
||||
const useNuclear = nuclear || anyNuclear;
|
||||
if (useNuclear && !this._canUseNuclearPayload(civ)) return false;
|
||||
const target = this._strikeTargetAt(goal, civ);
|
||||
const city = target ? null : this._strikeCityAt(goal, civ);
|
||||
if (!target && !city) return false;
|
||||
if (units.some((unit) => !this._missileInRange(unit, goal))) return false;
|
||||
this._declareWar(civ, target ? target.civ : city.civ);
|
||||
for (const unit of units) {
|
||||
if (useNuclear) this._applyNuclearBlast(goal, civ);
|
||||
else this._resolveConventionalMissile(unit, goal);
|
||||
this._destroyUnit(unit);
|
||||
}
|
||||
this._invalidateUnitCaches();
|
||||
this._visibilityDirty = true;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
_missileInRange(unit, goal) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) return false;
|
||||
const distance = this.topology.tileDistance(unit.coords, goal);
|
||||
return distance <= this._missileRange(proto) + 1e-9;
|
||||
},
|
||||
|
||||
// A conventional warhead is simply a very heavy ranged strike: it hits the
|
||||
// garrison if one is there, otherwise the city itself.
|
||||
_resolveConventionalMissile(unit, goal) {
|
||||
const target = this._strikeTargetAt(goal, unit.civ);
|
||||
if (target) {
|
||||
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
|
||||
this._applyStrikeDamage(target, damage);
|
||||
return;
|
||||
}
|
||||
const city = this._strikeCityAt(goal, unit.civ);
|
||||
if (city) this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------ WMD --
|
||||
|
||||
// A nuclear detonation: nine people in ten on the tile die, every building
|
||||
// and work there is flattened except the ones built to survive a bomb, and
|
||||
// the world recoils -- every nation, the attacker included, loses a thousand
|
||||
// points of standing.
|
||||
_applyNuclearBlast(coords, attackerCiv = -1) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const population = this.tilePopulation.get(k) || 0;
|
||||
if (population > 0) {
|
||||
this.tilePopulation.set(k, population * WMD.populationSurvivorFraction);
|
||||
this._populationVersion += 1;
|
||||
}
|
||||
this._dropTileGdpCache(k);
|
||||
const city = this.cityAt(coords);
|
||||
if (city) this._nuclearRazeCity(city);
|
||||
this._nuclearDestroyImprovement(coords);
|
||||
// No unit caught on the ground at the point of detonation survives, whatever
|
||||
// flag it flies.
|
||||
for (const unit of this.unitsAt(coords).slice()) this._destroyUnit(unit);
|
||||
this._invalidateUnitCaches();
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
this._shiftOwnPopularity(civ, -WMD.popularityPenalty);
|
||||
}
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// Every building in the city is destroyed outright, except the bomb-proof
|
||||
// ones (bunkers). The levels are gone for good, not queued for repair.
|
||||
_nuclearRazeCity(city) {
|
||||
let razed = false;
|
||||
for (const index of Object.keys(city.buildings || {})) {
|
||||
const level = city.buildings[index];
|
||||
if (level <= 0) continue;
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.nuclearProof) continue;
|
||||
delete city.buildings[index];
|
||||
razed = true;
|
||||
}
|
||||
if (razed) {
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
}
|
||||
},
|
||||
|
||||
// The works and roads on a nuked tile are destroyed, save the fortifications
|
||||
// (and any other nuclear-proof work) that were built to survive one.
|
||||
_nuclearDestroyImprovement(coords) {
|
||||
const k = key(coords.x, coords.y);
|
||||
if (this.tileImprovements.has(k)) {
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = tileImprovementById(id);
|
||||
if (!(proto && proto.nuclearProof)) {
|
||||
this.tileImprovements.delete(k);
|
||||
this.tileImprovementHp.delete(k);
|
||||
this.tileImprovementOwner.delete(k);
|
||||
this.tileRepairDebt.delete(k);
|
||||
}
|
||||
}
|
||||
// Roads and railways do not survive a direct hit.
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
this._improvementVersion += 1;
|
||||
},
|
||||
};
|
||||
@@ -66,12 +66,17 @@ export function technologyEffects(proto) {
|
||||
|
||||
// True when every prerequisite technology is in the researched set. The set
|
||||
// holds catalogue indices, so each prerequisite id is first resolved against
|
||||
// the catalogue.
|
||||
export function prerequisitesMet(technology, researchedSet, catalogue) {
|
||||
// the catalogue. A technology may also carry a `requiresBuilding` gate -- the
|
||||
// uranium enrichment centre that unlocks nuclear weapons -- checked through the
|
||||
// optional `hasBuilding` predicate, so the rule stays free of any world state.
|
||||
export function prerequisitesMet(technology, researchedSet, catalogue, hasBuilding = null) {
|
||||
for (const id of technology.prerequisites) {
|
||||
const index = catalogue ? catalogue.findIndex((t) => t.id === id) : -1;
|
||||
if (index < 0 || !researchedSet.has(index)) return false;
|
||||
}
|
||||
if (technology.requiresBuilding && hasBuilding && !hasBuilding(technology.requiresBuilding)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,6 +123,7 @@ export function productionFactors(tile, {
|
||||
nationalMultiplier = 1,
|
||||
portProduction = 0,
|
||||
naturalGrowth = 0,
|
||||
sabotaged = false,
|
||||
} = {}) {
|
||||
const factors = [];
|
||||
const terrainMultiplier = tile ? tile.productionMultiplier || 0 : 0;
|
||||
@@ -163,6 +169,11 @@ export function productionFactors(tile, {
|
||||
if (battleDeficit > 0) {
|
||||
factors.push({ label: "Battle damage", amount: -battleDeficit });
|
||||
}
|
||||
// A sabotaged tile produces nothing for a week. The zero multiplier is last,
|
||||
// so it zeroes the absolute terms above (port output, natural growth) too.
|
||||
if (sabotaged) {
|
||||
factors.push({ label: "Sabotaged", multiplier: 0 });
|
||||
}
|
||||
return factors;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ export class AudioTest extends TestCase {
|
||||
this.assertEqual(manager.effects.complete, "assets/sounds/build_complete.mp3");
|
||||
this.assertEqual(manager.effects.notification, "assets/sounds/notification.mp3");
|
||||
this.assertEqual(manager.effects.bomb, "assets/sounds/death_flash.flac");
|
||||
this.assertEqual(manager.musicSource, "assets/sounds/crashed_ship.ogg");
|
||||
this.assertEqual(manager.musicSources, [
|
||||
"assets/sounds/crashed_ship.ogg",
|
||||
"assets/sounds/dark_city.ogg",
|
||||
"assets/sounds/constellation.ogg",
|
||||
"assets/sounds/patrol.ogg",
|
||||
]);
|
||||
}
|
||||
|
||||
async test_plays_effects_and_mutes() {
|
||||
@@ -108,16 +113,63 @@ export class AudioTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_music_loops_and_stops() {
|
||||
async test_music_plays_a_shuffled_playlist() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
FakeAudio.reset();
|
||||
const manager = new AudioManager();
|
||||
// random() near 1 leaves the shuffle order untouched: crashed_ship first.
|
||||
const manager = new AudioManager({ random: () => 0.999 });
|
||||
manager.requestMusic();
|
||||
const music = FakeAudio.instances.find((audio) => audio.src === manager.musicSource);
|
||||
|
||||
const first = FakeAudio.instances.find(
|
||||
(audio) => audio.src === "assets/sounds/crashed_ship.ogg"
|
||||
);
|
||||
this.assertNotNull(first, "the first track was created");
|
||||
this.assertFalse(first.loop, "a playlist track does not loop itself");
|
||||
this.assertEqual(first.plays, 1, "the first track was started once");
|
||||
this.assertTrue(typeof first.onended === "function", "the track knows what follows");
|
||||
|
||||
// A finished track hands over to the next one in the playlist.
|
||||
first.onended();
|
||||
const second = FakeAudio.instances.find(
|
||||
(audio) => audio.src === "assets/sounds/dark_city.ogg"
|
||||
);
|
||||
this.assertNotNull(second, "the second track follows");
|
||||
this.assertEqual(second.plays, 1, "the second track was started once");
|
||||
|
||||
second.onended();
|
||||
const third = FakeAudio.instances.find(
|
||||
(audio) => audio.src === "assets/sounds/constellation.ogg"
|
||||
);
|
||||
this.assertNotNull(third, "the third track follows");
|
||||
this.assertEqual(third.plays, 1, "the third track was started once");
|
||||
|
||||
third.onended();
|
||||
const fourth = FakeAudio.instances.find(
|
||||
(audio) => audio.src === "assets/sounds/patrol.ogg"
|
||||
);
|
||||
this.assertNotNull(fourth, "the fourth track follows");
|
||||
this.assertEqual(fourth.plays, 1, "the fourth track was started once");
|
||||
|
||||
// After the last track the playlist reshuffles instead of falling silent.
|
||||
const before = FakeAudio.instances.length;
|
||||
fourth.onended();
|
||||
this.assertGreater(FakeAudio.instances.length, before, "playback wraps around");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_music_stops_and_mutes() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
FakeAudio.reset();
|
||||
const manager = new AudioManager({ random: () => 0.999 });
|
||||
manager.requestMusic();
|
||||
const music = FakeAudio.instances.find(
|
||||
(audio) => audio.src === "assets/sounds/crashed_ship.ogg"
|
||||
);
|
||||
this.assertNotNull(music, "the music element was created");
|
||||
this.assertTrue(music.loop, "the music loops");
|
||||
this.assertEqual(music.plays, 1, "the music was started once");
|
||||
|
||||
manager.stopMusic();
|
||||
this.assertTrue(music.paused, "stopping pauses the music");
|
||||
|
||||
@@ -103,8 +103,20 @@ export class BudgetTest extends TestCase {
|
||||
this.assertSize(state.budgetHistory, BUDGET_HISTORY_MONTHS, "only two years are kept");
|
||||
}
|
||||
|
||||
test_resource_trade_is_mirrored_into_the_month() {
|
||||
test_resource_costs_are_their_own_budget_line() {
|
||||
const state = smallState();
|
||||
state._recordResourceSpend(0, "energy", 2_400);
|
||||
state._recordResourceSpend(0, "goods", 480, "steel");
|
||||
state._recordResourceSpend(0, "transport", 240);
|
||||
const breakdown = state.budgetBreakdown(0);
|
||||
const byId = new Map(breakdown.resourceCosts.map((entry) => [entry.id, entry]));
|
||||
this.assertApprox(byId.get("energy").amount, 100, 1e-9, "the day's power bill, hourly");
|
||||
this.assertApprox(byId.get("goods").amount, 20, 1e-9);
|
||||
this.assertApprox(byId.get("transport").amount, 10, 1e-9);
|
||||
this.assertEqual(byId.get("combat"), undefined, "an unused category is left out");
|
||||
}
|
||||
|
||||
test_resource_trade_is_mirrored_into_the_month() { const state = smallState();
|
||||
state._recordResourceSpend(0, "goods", 5_000, "steel");
|
||||
state._recordResourceEarn(0, "steel", 2_000);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
|
||||
@@ -389,6 +389,8 @@ export class CombatTest extends TestCase {
|
||||
const pair = findEmptyLandPair(state);
|
||||
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
||||
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
||||
// A gun is usually spiked when taken; force the one-in-five capture.
|
||||
state._random = () => 0.9;
|
||||
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
|
||||
state.advanceMovement(1000);
|
||||
// With no friendly infantry beside it, the battery is taken instead of
|
||||
@@ -397,8 +399,30 @@ export class CombatTest extends TestCase {
|
||||
this.assertNull(state._combatTarget(infantry), "no melee follows the capture");
|
||||
}
|
||||
|
||||
test_artillery_sheltered_by_infantry_still_fights() {
|
||||
test_a_charging_infantry_usually_destroys_an_unaccompanied_artillery() {
|
||||
const state = smallState();
|
||||
const pair = findEmptyLandPair(state);
|
||||
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
||||
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
||||
// The 80% case: the crew scuttles the gun rather than hand it over.
|
||||
state._random = () => 0.1;
|
||||
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
|
||||
state.advanceMovement(1000);
|
||||
this.assertNull(state.findUnit(artillery.id), "the artillery is destroyed, not captured");
|
||||
this.assertNull(state._combatTarget(infantry), "no melee follows");
|
||||
}
|
||||
|
||||
test_capturing_an_airfield_usually_destroys_its_aircraft() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((c) => c.civ === 1 && !c.isCapital) || state.cities.find((c) => c.civ === 1);
|
||||
const jet = state.protoUnits.findIndex((p) => p.id === "jet_fighter");
|
||||
const aircraft = state._spawnUnit(city.coords, 1, state.protoUnits[jet], city.id);
|
||||
state._random = () => 0.1;
|
||||
this.assertTrue(state._captureCity(city, 0), "the city falls");
|
||||
this.assertNull(state.findUnit(aircraft.id), "the aircraft is destroyed with the field");
|
||||
}
|
||||
|
||||
test_artillery_sheltered_by_infantry_still_fights() { const state = smallState();
|
||||
const pair = findEmptyLandPair(state);
|
||||
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
||||
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
||||
|
||||
@@ -99,18 +99,22 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertNotNull(site.cityId, "an allied city was found to haul to");
|
||||
}
|
||||
|
||||
test_a_site_locks_its_prices_when_decided() {
|
||||
test_a_site_does_not_freeze_a_price_when_decided() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const site = state.constructionSiteAt(coords);
|
||||
const locked = site.prices.steel;
|
||||
const before = state._marketUnitCost("steel");
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
|
||||
this.assertApprox(site.prices.steel, locked, 1e-9, "the site keeps its locked price");
|
||||
this.assertTrue(
|
||||
state._marketUnitCost("steel") > locked,
|
||||
"the market moved after the work was decided"
|
||||
this.assertTrue(state._marketUnitCost("steel") > before, "the market moved");
|
||||
// The stored figure is only a running quote; the next gather refreshes it.
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
state._marketUnitCost("steel"),
|
||||
1e-6,
|
||||
"the site follows the market"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +179,7 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertEqual(site.phase, "materials", "it never reached construction");
|
||||
}
|
||||
|
||||
test_a_stalled_budget_climbs_on_its_own() {
|
||||
test_a_stalled_site_tops_up_to_the_current_market() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
@@ -187,23 +191,22 @@ export class ConstructionSiteTest extends TestCase {
|
||||
stalled: true,
|
||||
});
|
||||
// A token of stock in the hub city keeps the material reachable, so the
|
||||
// automatic rise applies; it is nowhere near enough to finish the work.
|
||||
// top-up applies; it is nowhere near enough to finish the work.
|
||||
state.getCityResourceStock(city).steel = 1;
|
||||
const treasury = 1e9;
|
||||
state.budgets.set(0, treasury);
|
||||
const locked = site.prices.steel;
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.01,
|
||||
state._marketUnitCost("steel"),
|
||||
1e-6,
|
||||
"the first day's budgeted price rises 1%"
|
||||
"the site prices at the live market, with no daily rise"
|
||||
);
|
||||
this.assertGreater(site.budget, 0, "the matching money moved into the site");
|
||||
this.assertGreater(site.budget, 0, "the money needed for the rest moved into the site");
|
||||
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
|
||||
}
|
||||
|
||||
test_the_budget_rise_doubles_every_day() {
|
||||
test_a_site_buys_at_the_live_market_not_a_stale_price() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
@@ -213,29 +216,35 @@ export class ConstructionSiteTest extends TestCase {
|
||||
budget: 0,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
// A supplier far out of reach in price keeps the site stalling as it climbs.
|
||||
// A stale, cheap price does not stop the site buying at today's market.
|
||||
site.prices.steel = state._marketUnitCost("steel") * 0.001;
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
|
||||
const nodes = state._resourceNodes();
|
||||
const suppliers = state._nearbySuppliers(city, "steel", nodes);
|
||||
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
|
||||
suppliers[0].stock.steel = 1e9;
|
||||
state.budgets.set(0, 1e15);
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "the locked price is too low to buy at");
|
||||
// The rises compound: 1%, then 2%, then 4%, ...
|
||||
let prev = site.prices.steel;
|
||||
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
const expected = prev * (1 + 0.01 * 2 ** day);
|
||||
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
|
||||
prev = site.prices.steel;
|
||||
}
|
||||
this.assertFalse(site.stalled, "the site did not hold out for the stale price");
|
||||
this.assertEqual(site.phase, "construction", "it bought at the live market");
|
||||
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
|
||||
}
|
||||
|
||||
test_a_site_reports_the_live_market_price() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const site = state.constructionSiteAt(coords);
|
||||
const before = site.prices.steel;
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
state._marketUnitCost("steel"),
|
||||
1e-6,
|
||||
"the site tracks the market"
|
||||
);
|
||||
this.assertGreater(site.prices.steel, before, "and follows it up");
|
||||
}
|
||||
|
||||
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
|
||||
test_a_reachable_supplier_is_bought_from_at_any_price() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
@@ -245,8 +254,6 @@ export class ConstructionSiteTest extends TestCase {
|
||||
budget: 0,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
// A supplier sits within reach, but its delivered price is far above the
|
||||
// site's locked price, so it takes several days of rises to afford it.
|
||||
site.prices.steel = state._marketUnitCost("steel") * 0.2;
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
|
||||
const nodes = state._resourceNodes();
|
||||
@@ -256,20 +263,7 @@ export class ConstructionSiteTest extends TestCase {
|
||||
suppliers[0].stock.steel = 1e9;
|
||||
state.budgets.set(0, 1e15);
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "the locked price is too low to buy at");
|
||||
const locked = site.prices.steel;
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.01,
|
||||
1e-6,
|
||||
"the first day's budgeted price rises 1%"
|
||||
);
|
||||
// Keep letting the budget climb; it must eventually reach the supplier.
|
||||
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
}
|
||||
this.assertEqual(site.phase, "construction", "the site eventually bought at the market");
|
||||
this.assertEqual(site.phase, "construction", "the site bought at the market");
|
||||
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ export class CreditsTest extends TestCase {
|
||||
"game-icons.net",
|
||||
"opengameart.org",
|
||||
"Ted Kerr",
|
||||
"Muncheybobo",
|
||||
"Trevor Lentz",
|
||||
"Alexandr Zhelanov",
|
||||
"Delapouite",
|
||||
"Lorc",
|
||||
"Mapbox Maki",
|
||||
|
||||
@@ -88,7 +88,9 @@ export class DataTest extends TestCase {
|
||||
const ids = new Set(TECHNOLOGIES.map((t) => t.id));
|
||||
for (const tech of TECHNOLOGIES) {
|
||||
this.assertGreater(tech.cost, 0, `${tech.id} cost`);
|
||||
this.assertNotEmpty(tech.effects, `${tech.id} effects`);
|
||||
// Most technologies carry a numeric effect; a few exist only to unlock
|
||||
// content (the spy behind Intelligence Agencies), so they opt out.
|
||||
if (!tech.unlocks) this.assertNotEmpty(tech.effects, `${tech.id} effects`);
|
||||
for (const prerequisite of tech.prerequisites) {
|
||||
this.assertTrue(ids.has(prerequisite), `${tech.id} prerequisite ${prerequisite} exists`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { GameScreen } from "../client/js/game_screen.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, SPY_OPERATIONS, TRANSPORT_IMPROVEMENTS, TREATY_PEACE } from "../shared/data.js";
|
||||
import { hoursInNextMonth } from "../shared/game_clock.js";
|
||||
import { compact, groupDigits } from "../shared/text_format.js";
|
||||
import { tileImprovementResourceCost } from "../shared/resources.js";
|
||||
@@ -29,6 +29,37 @@ function makeConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
// Switches the open Intelligence tab to one of its sub-tabs, by label.
|
||||
function showIntelligenceSubtab(env, label) {
|
||||
env
|
||||
.$("#intelligence-subtabs .subtab")
|
||||
.filter((index, element) => env.$(element).text() === label)
|
||||
.trigger("click");
|
||||
}
|
||||
|
||||
// The first populated foreign land tile in the fixture, for a spy to stand on.
|
||||
function foreignPopulatedLand(state, civ = 1) {
|
||||
for (const [k, owner] of state.territory) {
|
||||
if (owner !== civ) continue;
|
||||
const coords = parseKey(k);
|
||||
if (state.cityAt(coords)) continue;
|
||||
if (state.unitsAt(coords).length > 0) continue;
|
||||
const tile = state.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") continue;
|
||||
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
|
||||
return coords;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The city-training cards whose action is live (a locked card's Train button is
|
||||
// disabled), so counts ignore the gated units shown for reference.
|
||||
function enabledTrainCards(env) {
|
||||
return env.$("#city-train-list .building-tile").filter(
|
||||
(index, element) => !env.$(element).find(".bt-upgrade").prop("disabled")
|
||||
);
|
||||
}
|
||||
|
||||
function landAtDistance(state, center, distance) {
|
||||
for (const k in state.tiles) {
|
||||
const parts = k.split(",").map(Number);
|
||||
@@ -90,8 +121,8 @@ export class GameScreenTest extends TestCase {
|
||||
|
||||
env.$("#city-manage").click();
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||||
this.assertSize(env.$("#city-train-list button"), 3, "infantry, artillery and radar can be trained");
|
||||
env.$("#city-train-list button").first().click();
|
||||
this.assertSize(enabledTrainCards(env), 4, "infantry, artillery, radar and missiles can be trained");
|
||||
enabledTrainCards(env).first().find(".bt-upgrade").click();
|
||||
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
|
||||
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
@@ -245,6 +276,67 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_spies_are_trainable_once_the_agency_is_researched() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
|
||||
const spyCard = () =>
|
||||
env.$("#city-train-list .building-tile").filter(
|
||||
(index, element) => env.$(element).find(".bt-name").first().text() === "Spy"
|
||||
);
|
||||
this.assertSize(spyCard(), 1, "the spy is listed even before the agency");
|
||||
this.assertTrue(spyCard().find(".bt-upgrade").prop("disabled"), "but its card is locked");
|
||||
this.assertTrue(
|
||||
spyCard().text().includes("Requires Intelligence Agencies"),
|
||||
"and names what it needs"
|
||||
);
|
||||
|
||||
const tech = state.technologies.findIndex((t) => t.id === "intelligence_agencies");
|
||||
state.researched.get(0).add(tech);
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertFalse(
|
||||
spyCard().find(".bt-upgrade").prop("disabled"),
|
||||
"the spy is trainable after the agency"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_testing_mode_offers_instant_technology_unlock() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.testing = true;
|
||||
screen.onState(state.snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='technology']").click();
|
||||
|
||||
const $unlock = env.$("#technology-list .free-button").filter(
|
||||
(index, element) => env.$(element).text() === "Unlock now"
|
||||
);
|
||||
this.assertGreater($unlock.length, 0, "a testing game offers an instant unlock");
|
||||
$unlock.first().trigger("click");
|
||||
const order = network.orders.find((entry) => entry.type === "unlock_technology");
|
||||
this.assertNotNull(order, "the unlock order is sent");
|
||||
this.assertTrue(order.free, "flagged as the testing shortcut");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_training_modal_tracks_progress() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
@@ -545,8 +637,8 @@ export class GameScreenTest extends TestCase {
|
||||
screen.onState(state.snapshot(0));
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
const before = env.$("#city-train-list button").length;
|
||||
this.assertEqual(before, 3, "only infantry, artillery and radar are trainable at first");
|
||||
const before = enabledTrainCards(env).length;
|
||||
this.assertEqual(before, 4, "only infantry, artillery, radar and missiles are trainable at first");
|
||||
|
||||
// The airport is completed server-side and a fresh snapshot arrives while
|
||||
// the panel is still open.
|
||||
@@ -554,7 +646,7 @@ export class GameScreenTest extends TestCase {
|
||||
capital.buildings[airport] = 1;
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertGreater(
|
||||
env.$("#city-train-list button").length,
|
||||
enabledTrainCards(env).length,
|
||||
before,
|
||||
"the aircraft appears in the still-open panel"
|
||||
);
|
||||
@@ -579,7 +671,7 @@ export class GameScreenTest extends TestCase {
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||||
this.assertSize(env.$("#city-train-list .free-button"), 3, "a free button per trainable unit");
|
||||
this.assertSize(env.$("#city-train-list .free-button"), 4, "a free button per trainable unit");
|
||||
env.$("#city-train-list .free-button").first().click();
|
||||
this.assertEqual(network.orders[0], {
|
||||
type: "train",
|
||||
@@ -753,6 +845,71 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_chat_opens_with_t_and_sends_lines() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const sent = [];
|
||||
network.sendChat = (text) => sent.push(text);
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(smallState().snapshot(0));
|
||||
|
||||
this.assertTrue(env.$("#chat").hasClass("hidden"), "the chat starts closed");
|
||||
env.$(env.window).trigger(env.$.Event("keydown", { key: "t" }));
|
||||
this.assertFalse(env.$("#chat").hasClass("hidden"), "T opens the chat");
|
||||
|
||||
// The map must not pan while the chat input holds focus.
|
||||
const cameraBefore = screen.map.camera.x;
|
||||
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "d" }));
|
||||
this.assertApprox(screen.map.camera.x, cameraBefore, 1e-9, "typing does not pan the map");
|
||||
|
||||
screen.addChat({ name: "Bob", civ: 1, text: "hello there" });
|
||||
const line = env.$("#chat-log .chat-line").last();
|
||||
this.assertTrue(line.find(".chat-author").text().includes("Bob"));
|
||||
this.assertTrue(line.find(".chat-text").text().includes("hello there"));
|
||||
|
||||
env.$("#chat-input").val("good luck");
|
||||
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Enter" }));
|
||||
this.assertEqual(sent, ["good luck"], "the line is sent to the server");
|
||||
this.assertEqual(env.$("#chat-input").val(), "", "the input clears after sending");
|
||||
|
||||
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Escape" }));
|
||||
this.assertTrue(env.$("#chat").hasClass("hidden"), "Escape closes the chat");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_treaty_offer_becomes_a_clickable_feed_line() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
// The first snapshot only seeds the seen-news baseline.
|
||||
screen.onState(state.snapshot(0));
|
||||
state.requestProposeTreaty(1, 0, TREATY_PEACE);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
const line = env.$("#game-feed .feed-line").last();
|
||||
this.assertTrue(line.text().includes("proposes Peace"), "the offer hits the feed");
|
||||
this.assertTrue(line.hasClass("feed-clickable"), "and can be clicked");
|
||||
line.trigger("click");
|
||||
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the offer dialog opens");
|
||||
this.assertTrue(env.$("#confirm-ok").text().includes("Sign"), "with a Sign button");
|
||||
env.$("#confirm-ok").trigger("click");
|
||||
const order = network.orders.at(-1);
|
||||
this.assertEqual(order.type, "respond_treaty");
|
||||
this.assertTrue(order.accept, "Signing accepts the offer");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
@@ -1327,11 +1484,12 @@ export class GameScreenTest extends TestCase {
|
||||
screen.onState(smallState().snapshot(0));
|
||||
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||||
this.assertTrue(env.$("#tab-politics").hasClass("active"));
|
||||
this.assertGreater(env.$("#politics-list .politics-card").length, 0);
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
|
||||
showIntelligenceSubtab(env, "Propaganda");
|
||||
this.assertGreater(env.$("#intelligence-propaganda-view .politics-card").length, 0);
|
||||
|
||||
const $start = env.$("#tab-politics button").filter(
|
||||
const $start = env.$("#intelligence-propaganda-view button").filter(
|
||||
(index, element) => env.$(element).text() === "Start campaign"
|
||||
);
|
||||
this.assertSize($start, 1);
|
||||
@@ -1342,6 +1500,7 @@ export class GameScreenTest extends TestCase {
|
||||
this.assertEqual(campaign.targetKind, "government");
|
||||
this.assertEqual(campaign.target, 1);
|
||||
|
||||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||||
this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
|
||||
const $new = env.$("#tab-politics button").filter(
|
||||
(index, element) => env.$(element).text() === "New minority policy"
|
||||
@@ -1365,6 +1524,263 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_intelligence_tab_hosts_propaganda() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
|
||||
showIntelligenceSubtab(env, "Propaganda");
|
||||
const $launcher = env.$("#intelligence-propaganda-view button").filter(
|
||||
(index, element) => env.$(element).text() === "Start campaign"
|
||||
);
|
||||
this.assertSize($launcher, 1, "the propaganda launcher lives on the Intelligence tab");
|
||||
const $onPolitics = env.$("#tab-politics button").filter(
|
||||
(index, element) => env.$(element).text() === "Start campaign"
|
||||
);
|
||||
this.assertSize($onPolitics, 0, "and is no longer on the Politics tab");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_intelligence_subtabs_split_spies_and_propaganda() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
|
||||
const $tabs = env.$("#intelligence-subtabs .subtab");
|
||||
this.assertSize($tabs, 2, "the Intelligence tab has two sub-tabs");
|
||||
this.assertEqual($tabs.eq(0).text(), "Spies");
|
||||
this.assertEqual($tabs.eq(1).text(), "Propaganda");
|
||||
// The spy controls open first, the campaigns stay out of the way.
|
||||
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
|
||||
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
|
||||
|
||||
showIntelligenceSubtab(env, "Propaganda");
|
||||
this.assertTrue(env.$("#intelligence-spies-view").hasClass("hidden"));
|
||||
this.assertFalse(env.$("#intelligence-propaganda-view").hasClass("hidden"));
|
||||
|
||||
showIntelligenceSubtab(env, "Spies");
|
||||
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
|
||||
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_intelligence_tab_lists_the_viewers_spies_and_locates_them() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const home = state.cities.find((city) => city.civ === 0);
|
||||
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
|
||||
screen.onState(state.snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
|
||||
const $spies = env.$("#intelligence-spies-view .politics-card").filter(
|
||||
(index, element) => env.$(element).find(".name").first().text() === "Your spies"
|
||||
);
|
||||
this.assertSize($spies, 1, "the roster card is on the Intelligence tab");
|
||||
this.assertTrue($spies.text().includes("Spy 1"), "the spy is listed");
|
||||
this.assertTrue($spies.text().includes(home.name), "with the city it stands in");
|
||||
|
||||
const $locate = $spies.find("button").filter(
|
||||
(index, element) => env.$(element).text() === "Locate"
|
||||
);
|
||||
this.assertSize($locate, 1, "the roster offers a Locate control");
|
||||
$locate.trigger("click");
|
||||
this.assertTrue(screen.selectedUnitIds.includes(spy.id), "Locate selects the spy");
|
||||
this.assertFalse(screen.nationModal.isShowing(), "and closes the nation panel");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_spy_actions_live_in_the_unit_panel() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const home = state.cities.find((city) => city.civ === 0);
|
||||
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
|
||||
const soldier = state._spawnUnit(
|
||||
home.coords,
|
||||
0,
|
||||
state.protoUnits.find((u) => u.id === "modern_infantry")
|
||||
);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
// A spy's covert operations are unit actions, so they appear in the unit
|
||||
// panel once the spy is selected.
|
||||
screen._selectUnits([spy.id]);
|
||||
const $actions = env.$("#unit-spy-actions");
|
||||
this.assertFalse($actions.hasClass("hidden"), "the spy actions row shows for a spy");
|
||||
const $buttons = $actions.find("button");
|
||||
this.assertSize($buttons, SPY_OPERATIONS.length, "one button per operation");
|
||||
for (const operation of SPY_OPERATIONS) {
|
||||
const $button = $buttons.filter(
|
||||
(index, element) => env.$(element).attr("data-operation") === operation.id
|
||||
);
|
||||
this.assertSize($button, 1, `a button for ${operation.id}`);
|
||||
this.assertTrue($button.prop("disabled"), `${operation.id} is not wired yet`);
|
||||
}
|
||||
|
||||
// A soldier gets no spy actions.
|
||||
screen._selectUnits([soldier.id]);
|
||||
this.assertTrue(
|
||||
env.$("#unit-spy-actions").hasClass("hidden"),
|
||||
"hidden for a soldier"
|
||||
);
|
||||
|
||||
// And the tab no longer lists the operations.
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
const $operations = env.$("#intelligence-spies-view .politics-card").filter(
|
||||
(index, element) => env.$(element).find(".name").first().text() === "Covert operations"
|
||||
);
|
||||
this.assertSize($operations, 0, "the operations card is not on the Intelligence tab");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_sabotage_is_offered_on_foreign_land_and_sent() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const target = foreignPopulatedLand(state, 1);
|
||||
this.assertNotNull(target, "the fixture has a populated foreign tile");
|
||||
const spy = state._spawnUnit(target, 0, state.protoUnits.find((u) => u.id === "spy"));
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([spy.id]);
|
||||
|
||||
const $sabotage = env.$("#unit-spy-actions").find("[data-operation='sabotage']");
|
||||
this.assertSize($sabotage, 1, "the sabotage button exists");
|
||||
this.assertFalse($sabotage.prop("disabled"), "sabotage is offered on foreign land");
|
||||
$sabotage.trigger("click");
|
||||
const order = network.orders.find((entry) => entry.type === "sabotage");
|
||||
this.assertNotNull(order, "the sabotage order is sent");
|
||||
this.assertTrue(order.units.includes(spy.id), "for the selected spy");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_city_spying_arms_targeting_and_sends() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 1);
|
||||
const spy = state._spawnUnit(
|
||||
{ x: city.coords.x, y: city.coords.y },
|
||||
0,
|
||||
state.protoUnits.find((u) => u.id === "spy")
|
||||
);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([spy.id]);
|
||||
|
||||
const $button = env.$("#unit-spy-actions").find("[data-operation='city_spying']");
|
||||
this.assertSize($button, 1);
|
||||
this.assertFalse($button.prop("disabled"), "a city is in reach");
|
||||
$button.trigger("click");
|
||||
this.assertNotNull(screen._targeting, "picking a target is armed");
|
||||
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
|
||||
const order = network.orders.find((entry) => entry.type === "spy_city");
|
||||
this.assertNotNull(order, "the city spy order is sent");
|
||||
this.assertEqual(order.coords[0], city.coords.x);
|
||||
this.assertTrue(order.units.includes(spy.id));
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_launch_satellite_button_sends() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 0);
|
||||
grantBuilding(state, city, "space_launch_center", 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
|
||||
const $launch = env.$("#intelligence-spies-view button").filter(
|
||||
(index, element) => env.$(element).text() === "Launch spy satellite"
|
||||
);
|
||||
this.assertSize($launch, 1, "the launch control is on the Spies sub-tab");
|
||||
this.assertFalse($launch.prop("disabled"), "a free pad is available");
|
||||
$launch.trigger("click");
|
||||
const order = network.orders.find((entry) => entry.type === "launch_satellite");
|
||||
this.assertNotNull(order, "the launch order is sent");
|
||||
this.assertEqual(order.city, city.id);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_spy_boards_a_launch_on_a_right_click() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
let land = null;
|
||||
let sea = null;
|
||||
for (const cell of state.landCells) {
|
||||
if (state.civAt(cell) !== 0 || state.unitAt(cell) || state.cityAt(cell)) continue;
|
||||
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
||||
const tile = state.tiles[`${neighbour.x},${neighbour.y}`];
|
||||
if (tile && tile.terrainClass === "Sea" && !state.unitAt(neighbour)) {
|
||||
land = cell;
|
||||
sea = neighbour;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (land) break;
|
||||
}
|
||||
this.assertNotNull(land, "the nation has an empty coastal tile");
|
||||
const spy = state._spawnUnit(land, 0, state.protoUnits.find((u) => u.id === "spy"));
|
||||
const launch = state._spawnUnit(sea, 0, state.protoUnits.find((u) => u.id === "launch"));
|
||||
screen.onState(state.snapshot(0));
|
||||
await screen.map.onMoveOrdered([spy.id], { x: sea.x, y: sea.y });
|
||||
const order = network.orders.find((entry) => entry.type === "embark");
|
||||
this.assertNotNull(order, "the spy boards");
|
||||
this.assertEqual(order.launch, launch.id);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_politics_tab_survives_snapshot_refresh() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
@@ -1407,7 +1823,8 @@ export class GameScreenTest extends TestCase {
|
||||
screen.enter();
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
showIntelligenceSubtab(env, "Propaganda");
|
||||
|
||||
// Inject a running campaign owned by the player.
|
||||
const state = smallState();
|
||||
@@ -1415,7 +1832,7 @@ export class GameScreenTest extends TestCase {
|
||||
state.requestCampaign(0, 0, "government", 1, 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
const $cancel = env.$("#tab-politics button").filter(
|
||||
const $cancel = env.$("#intelligence-propaganda-view button").filter(
|
||||
(index, element) => env.$(element).text() === "Cancel campaign"
|
||||
);
|
||||
this.assertSize($cancel, 1);
|
||||
@@ -1423,7 +1840,7 @@ export class GameScreenTest extends TestCase {
|
||||
this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign");
|
||||
|
||||
// The launcher now refuses to duplicate the running campaign.
|
||||
const $start = env.$("#tab-politics button").filter(
|
||||
const $start = env.$("#intelligence-propaganda-view button").filter(
|
||||
(index, element) => env.$(element).text() === "Start campaign"
|
||||
);
|
||||
this.assertTrue($start.prop("disabled"), "duplicate campaign blocked");
|
||||
@@ -1441,14 +1858,15 @@ export class GameScreenTest extends TestCase {
|
||||
screen.enter();
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||||
env.$("#modal-nation .tab[data-tab='intelligence']").click();
|
||||
showIntelligenceSubtab(env, "Propaganda");
|
||||
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
state.requestCampaign(0, 0, "government", 1, 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
const $slider = env.$("#tab-politics .politics-slider");
|
||||
const $slider = env.$("#intelligence-propaganda-view .politics-slider");
|
||||
this.assertSize($slider, 1);
|
||||
$slider.val(20000).trigger("change");
|
||||
const spending = network.orders.find((order) => order.type === "campaign_spending");
|
||||
@@ -2125,6 +2543,40 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_missile_can_be_armed_with_a_nuclear_warhead() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
const missileProto = state.protoUnits.find((p) => p.id === "missile");
|
||||
const missile = state._spawnUnit(capital.coords, 0, missileProto);
|
||||
const targetCoords = landAtDistance(state, capital.coords, 1);
|
||||
state._spawnUnit(targetCoords, 1, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
const data = screen._unit(missile.id);
|
||||
screen._selectUnits([missile.id]);
|
||||
this.assertTrue(env.$("#unit-nuclear").hasClass("hidden"), "no warhead without the bomb");
|
||||
|
||||
// Research nuclear weapons and take a fresh snapshot.
|
||||
const techIndex = state.technologies.findIndex((t) => t.id === "nuclear_weapons");
|
||||
state.researched.get(0).add(techIndex);
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertFalse(env.$("#unit-nuclear").hasClass("hidden"), "the warhead toggle shows");
|
||||
env.$("#unit-nuclear").click();
|
||||
this.assertTrue(screen._nuclearPayload, "the toggle arms the next strike");
|
||||
|
||||
screen._sendMove([data], targetCoords, null, "attack", null);
|
||||
const order = screen.network.orders.at(-1);
|
||||
this.assertEqual(order.type, "attack");
|
||||
this.assertTrue(order.nuclear, "the attack carries the nuclear payload");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_city_tile_panel_shows_current_production() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -203,6 +203,28 @@ export class GameServerTest extends TestCase {
|
||||
this.assertNotNull(full.visible, "including the per-viewer visible set");
|
||||
}
|
||||
|
||||
test_an_attack_with_a_missile_is_routed_to_the_missile_strike() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const state = server.state;
|
||||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||||
const missile = state._spawnUnit(
|
||||
capital.coords,
|
||||
0,
|
||||
state.protoUnits.find((proto) => proto.id === "missile")
|
||||
);
|
||||
const target = state.landCells.find(
|
||||
(cell) => !state.unitAt(cell) && !state.cityAt(cell)
|
||||
);
|
||||
const enemy = state._spawnUnit(target, 1, state.protoUnits[0]);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }),
|
||||
"the server accepts the missile order"
|
||||
);
|
||||
this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched");
|
||||
this.assertNull(state.findUnit(enemy.id), "and the target is destroyed");
|
||||
}
|
||||
|
||||
test_move_order_for_own_unit_is_accepted() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
@@ -211,8 +233,7 @@ export class GameServerTest extends TestCase {
|
||||
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
|
||||
}
|
||||
|
||||
test_server_advances_movement_between_hourly_ticks() {
|
||||
const { server } = makeServer();
|
||||
test_server_advances_movement_between_hourly_ticks() { const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const unit = server.state.units[0];
|
||||
const goal = adjacentLand(server.state, unit);
|
||||
@@ -333,6 +354,61 @@ export class GameServerTest extends TestCase {
|
||||
this.assertFalse(server.handleOrder(1, { type: "train", city: enemy.id, proto: 0 }));
|
||||
}
|
||||
|
||||
test_spy_city_order_for_own_spy_is_accepted() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const state = server.state;
|
||||
const city = state.cities.find((c) => c.civ === 1);
|
||||
const spy = state._spawnUnit(
|
||||
{ x: city.coords.x, y: city.coords.y },
|
||||
0,
|
||||
state.protoUnits.find((u) => u.id === "spy")
|
||||
);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "spy_city", units: [spy.id], coords: [city.coords.x, city.coords.y] })
|
||||
);
|
||||
this.assertSize(state.spyReports.get(0), 1, "the report reaches the spy's nation");
|
||||
}
|
||||
|
||||
test_spy_order_for_another_nations_unit_is_rejected() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const state = server.state;
|
||||
const enemySpy = state.units.find((u) => u.civ === 1);
|
||||
this.assertFalse(
|
||||
server.handleOrder(1, { type: "spy_comms", units: [enemySpy.id], coords: [enemySpy.coords.x, enemySpy.coords.y] })
|
||||
);
|
||||
}
|
||||
|
||||
test_launch_satellite_order_needs_the_centre() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const state = server.state;
|
||||
const city = state.cities.find((c) => c.civ === 0);
|
||||
this.assertFalse(
|
||||
server.handleOrder(1, { type: "launch_satellite", city: city.id }),
|
||||
"no centre, no launch"
|
||||
);
|
||||
grantBuilding(state, city, "space_launch_center", 1);
|
||||
this.assertTrue(server.handleOrder(1, { type: "launch_satellite", city: city.id }));
|
||||
this.assertSize(state.satellites, 1);
|
||||
}
|
||||
|
||||
test_unlock_technology_is_a_testing_only_shortcut() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const tech = server.state.technologies.findIndex((t) => t.id === "nuclear_weapons");
|
||||
this.assertFalse(
|
||||
server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true }),
|
||||
"refused outside a testing game"
|
||||
);
|
||||
server.testing = true;
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true })
|
||||
);
|
||||
this.assertTrue(server.state.hasTechnology(0, "nuclear_weapons"));
|
||||
}
|
||||
|
||||
test_orders_from_unknown_peers_are_rejected() {
|
||||
const { server } = makeServer();
|
||||
this.assertFalse(server.handleOrder(42, { type: "move", unit: 1, coords: [0, 0] }));
|
||||
|
||||
@@ -202,6 +202,18 @@ export class GameStateTechnologyTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
test_completing_a_repeatable_keeps_it_focused() {
|
||||
const state = smallState();
|
||||
state.researched.get(0).add(this.index(state, "scientific_method"));
|
||||
const index = this.complete(state, "infantry_weapons");
|
||||
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done");
|
||||
this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused");
|
||||
// Fresh points keep flowing into the next level without another order.
|
||||
state._addResearch(0, state.getTechnologyCost(0, index));
|
||||
this.assertEqual(state.getTechnologyRepeatCount(0, index), 2, "the next level completes");
|
||||
this.assertApprox(state.getTechnologyProgress(0, index), 0, 1e-9, "reset for the next level");
|
||||
}
|
||||
|
||||
test_research_costs_a_hundred_times_the_catalogue_price() {
|
||||
const state = smallState();
|
||||
const index = this.index(state, "scientific_method");
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, grantBuilding } from "./framework/helpers.js";
|
||||
import { parseKey } from "../shared/hex.js";
|
||||
import {
|
||||
SABOTAGE,
|
||||
SATELLITE,
|
||||
SPY_REPORT_CITY,
|
||||
SPY_REPORT_COMMS,
|
||||
} from "../shared/data.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
|
||||
// The first populated foreign land tile in the fixture, for a spy to stand on.
|
||||
function foreignLandTile(state, civ = 1) {
|
||||
for (const [k, owner] of state.territory) {
|
||||
if (owner !== civ) continue;
|
||||
const coords = parseKey(k);
|
||||
if (state.cityAt(coords)) continue;
|
||||
if (state.unitsAt(coords).length > 0) continue;
|
||||
const tile = state.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") continue;
|
||||
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
|
||||
return coords;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spyProto(state) {
|
||||
return state.protoUnits.find((unit) => unit.id === "spy");
|
||||
}
|
||||
|
||||
function research(state, civ, id) {
|
||||
const index = state.technologies.findIndex((tech) => tech.id === id);
|
||||
state.researched.get(civ).add(index);
|
||||
return index;
|
||||
}
|
||||
|
||||
// A spy watching the given city. A spy cannot legally stand on a hostile city
|
||||
// tile, but the model lets it watch from that tile or a neighbour; the fixture
|
||||
// simply drops it on the city so the distance is always in range.
|
||||
function spyAtCity(state, city, civ = 0) {
|
||||
return state._spawnUnit({ x: city.coords.x, y: city.coords.y }, civ, spyProto(state));
|
||||
}
|
||||
|
||||
function sequence(state, values) {
|
||||
let i = 0;
|
||||
state._random = () => values[Math.min(i++, values.length - 1)];
|
||||
}
|
||||
|
||||
export class IntelligenceTest extends TestCase {
|
||||
// ------------------------------------------------------------ sabotage --
|
||||
|
||||
test_sabotage_cripples_a_foreign_tile_for_a_week() {
|
||||
const state = smallState();
|
||||
const coords = foreignLandTile(state, 1);
|
||||
this.assertNotNull(coords, "the fixture has a populated British tile");
|
||||
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
||||
const before = state.getTileGdpPerCapita(coords);
|
||||
this.assertGreater(before, 0, "the tile produces something to cripple");
|
||||
|
||||
this.assertTrue(state.canSabotage([spy.id]), "a spy on foreign land may sabotage");
|
||||
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
|
||||
this.assertTrue(state.isTileSabotaged(coords), "the tile is crippled");
|
||||
this.assertEqual(state.getTileGdpPerCapita(coords), 0, "its production is zeroed");
|
||||
|
||||
const news = state.news[state.news.length - 1];
|
||||
this.assertEqual(news.type, "sabotage", "the sabotage is reported");
|
||||
this.assertEqual(news.civ, 1, "to the nation whose tile was hit");
|
||||
|
||||
// A week later the block lapses and the tile's production returns.
|
||||
state.totalHours += SABOTAGE.blockDays * HOURS_PER_DAY + 1;
|
||||
state._tickSabotage();
|
||||
this.assertFalse(state.isTileSabotaged(coords), "the week has run out");
|
||||
this.assertGreater(state.getTileGdpPerCapita(coords), 0, "production is back");
|
||||
}
|
||||
|
||||
test_sabotage_needs_a_spy_on_a_foreign_tile() {
|
||||
const state = smallState();
|
||||
const own = state.cities.find((city) => city.civ === 0);
|
||||
const spy = state._spawnUnit(own.coords, 0, spyProto(state));
|
||||
this.assertFalse(state.canSabotage([spy.id]), "a spy may not sabotage its own land");
|
||||
|
||||
const target = foreignLandTile(state, 1);
|
||||
this.assertNotNull(target, "the fixture has a foreign land tile");
|
||||
const soldier = state._spawnUnit(
|
||||
target,
|
||||
0,
|
||||
state.protoUnits.find((unit) => unit.id === "modern_infantry")
|
||||
);
|
||||
this.assertFalse(state.canSabotage([soldier.id]), "only a spy sabotages");
|
||||
this.assertFalse(state.requestSabotage([soldier.id]), "and the order is refused");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- blame -------
|
||||
|
||||
test_blame_names_the_author_when_detected() {
|
||||
const state = smallState();
|
||||
const coords = foreignLandTile(state, 1);
|
||||
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
||||
sequence(state, [0]); // the identify roll succeeds
|
||||
state.requestSabotage([spy.id]);
|
||||
const news = state.news[state.news.length - 1];
|
||||
this.assertEqual(news.suspect, 0, "the saboteur is blamed");
|
||||
}
|
||||
|
||||
test_blame_can_land_on_a_framed_nation_or_no_one() {
|
||||
const state = smallState(["france", "britain", "germany"]);
|
||||
const coords = foreignLandTile(state, 1);
|
||||
|
||||
// Fail to identify the author, then let the false flag on Germany stick.
|
||||
// An operation spends its spy, so each attempt sends a fresh one.
|
||||
const first = state._spawnUnit(coords, 0, spyProto(state));
|
||||
sequence(state, [0.99, 0]);
|
||||
state.requestSabotage([first.id], { frame: 2 });
|
||||
let news = state.news[state.news.length - 1];
|
||||
this.assertEqual(news.suspect, 2, "the framed nation takes the blame");
|
||||
|
||||
// Fail to identify, and fail to blame the scapegoat.
|
||||
const second = state._spawnUnit(coords, 0, spyProto(state));
|
||||
sequence(state, [0.99, 0.99]);
|
||||
state.requestSabotage([second.id], { frame: 2 });
|
||||
news = state.news[state.news.length - 1];
|
||||
this.assertNull(news.suspect, "no one is blamed");
|
||||
}
|
||||
|
||||
test_counter_intelligence_makes_identification_more_likely() {
|
||||
const state = smallState();
|
||||
research(state, 1, "counter_intelligence");
|
||||
const coords = foreignLandTile(state, 1);
|
||||
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
||||
// 0.7 fails the base 0.5 roll but clears the counter-intelligence threshold.
|
||||
sequence(state, [0.7]);
|
||||
state.requestSabotage([spy.id]);
|
||||
const news = state.news[state.news.length - 1];
|
||||
this.assertEqual(news.suspect, 0, "the counter-intelligence service fingers the author");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- single-use ----
|
||||
|
||||
test_an_operation_spends_the_acting_spies() {
|
||||
const state = smallState();
|
||||
const coords = foreignLandTile(state, 1);
|
||||
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
||||
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
|
||||
this.assertNull(state.findUnit(spy.id), "the saboteur is gone");
|
||||
this.assertSize(state.unitsAt(coords), 0, "and leaves nothing behind");
|
||||
}
|
||||
|
||||
test_only_the_acting_stack_is_spent() {
|
||||
const state = smallState();
|
||||
const coords = foreignLandTile(state, 1);
|
||||
const actor = state._spawnUnit(coords, 0, spyProto(state));
|
||||
const bystander = state._spawnUnit(coords, 0, spyProto(state));
|
||||
this.assertTrue(state.requestSabotage([actor.id]), "the sabotage is accepted");
|
||||
this.assertNull(state.findUnit(actor.id), "the saboteur is gone");
|
||||
this.assertNotNull(state.findUnit(bystander.id), "the unselected spy stays");
|
||||
}
|
||||
|
||||
test_a_bigger_team_escapes_the_blame() {
|
||||
const state = smallState();
|
||||
research(state, 1, "counter_intelligence");
|
||||
const coords = foreignLandTile(state, 1);
|
||||
const spies = [0, 1, 2].map(() => state._spawnUnit(coords, 0, spyProto(state)));
|
||||
// A lone spy has a 0.75 chance of being fingered, so 0.7 would expose the
|
||||
// author; three spies cut it to 0.3, so the same roll gets away.
|
||||
sequence(state, [0.7]);
|
||||
state.requestSabotage(spies.map((spy) => spy.id));
|
||||
const news = state.news[state.news.length - 1];
|
||||
this.assertNull(news.suspect, "the team gets away with it");
|
||||
}
|
||||
|
||||
test_extra_spies_blunt_counter_intelligence() {
|
||||
const state = smallState();
|
||||
research(state, 1, "counter_intelligence");
|
||||
this.assertEqual(state._effectiveCounterIntelligence(1, 1), 1, "one spy faces the full service");
|
||||
this.assertEqual(state._effectiveCounterIntelligence(1, 2), 0.5, "a second spy halves it");
|
||||
this.assertEqual(state._effectiveCounterIntelligence(1, 3), 0, "a third cancels it");
|
||||
|
||||
const city = state.cities.find((entry) => entry.civ === 1);
|
||||
const spies = [0, 1, 2].map(() => spyAtCity(state, city));
|
||||
// A roll of 0.5 falsifies a lone spy's report (0.5 < 0.7) but not a
|
||||
// three-strong team's, whose falsify chance is zero.
|
||||
sequence(state, [0.5]);
|
||||
state.requestCitySpy(spies.map((spy) => spy.id), city.coords);
|
||||
const report = state.spyReports.get(0)[0];
|
||||
this.assertEqual(report.population, city.population, "the report is honest");
|
||||
for (const spy of spies) this.assertNull(state.findUnit(spy.id), "every spy is spent");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ city spying -----
|
||||
|
||||
test_city_spying_returns_an_accurate_report() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 1);
|
||||
const spy = spyAtCity(state, city);
|
||||
this.assertNotNull(spy, "the fixture has a tile beside the British city");
|
||||
this.assertTrue(state.canCitySpy([spy.id], city.coords), "the spy may watch the city");
|
||||
|
||||
sequence(state, [0]); // no counter-intelligence: the report is honest
|
||||
this.assertTrue(state.requestCitySpy([spy.id], city.coords));
|
||||
const reports = state.spyReports.get(0);
|
||||
this.assertSize(reports, 1);
|
||||
this.assertEqual(reports[0].kind, SPY_REPORT_CITY);
|
||||
this.assertEqual(reports[0].city, city.name);
|
||||
this.assertEqual(reports[0].population, city.population, "the population is exact");
|
||||
this.assertSize(state.snapshot(0).spyReports, 1, "the report reaches its owner");
|
||||
this.assertSize(state.snapshot(1).spyReports, 0, "and no one else");
|
||||
}
|
||||
|
||||
test_counter_intelligence_falsifies_a_city_report() {
|
||||
const state = smallState();
|
||||
research(state, 1, "counter_intelligence");
|
||||
const city = state.cities.find((entry) => entry.civ === 1);
|
||||
const spy = spyAtCity(state, city);
|
||||
sequence(state, [0]); // the falsify roll succeeds, then values shrink
|
||||
state.requestCitySpy([spy.id], city.coords);
|
||||
const report = state.spyReports.get(0)[0];
|
||||
this.assertLess(report.population, city.population, "the population is distorted");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- comms spying -----
|
||||
|
||||
test_comms_spying_intercepts_messages() {
|
||||
const state = smallState();
|
||||
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
|
||||
const spy = spyAtCity(state, capital);
|
||||
this.assertTrue(state.canCommsSpy([spy.id], capital.coords), "the spy may tap the capital");
|
||||
|
||||
state.recordChat(1, 0, "meet me at dawn");
|
||||
state.recordChat(0, 1, "agreed");
|
||||
sequence(state, [0.99]); // no counter-intelligence: the messages are clean
|
||||
this.assertTrue(state.requestCommsSpy([spy.id], capital.coords));
|
||||
const report = state.spyReports.get(0)[0];
|
||||
this.assertEqual(report.kind, SPY_REPORT_COMMS);
|
||||
this.assertSize(report.messages, 2, "both recent messages are intercepted");
|
||||
this.assertEqual(report.messages[1].text, "agreed");
|
||||
}
|
||||
|
||||
test_counter_intelligence_garbles_intercepted_comms() {
|
||||
const state = smallState();
|
||||
research(state, 1, "counter_intelligence");
|
||||
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
|
||||
const spy = spyAtCity(state, capital);
|
||||
state.recordChat(1, 0, "attack at dawn");
|
||||
sequence(state, [0]); // garble fires, then every character is scrambled
|
||||
state.requestCommsSpy([spy.id], capital.coords);
|
||||
const report = state.spyReports.get(0)[0];
|
||||
this.assertTrue(report.messages[0].garbled, "the message is marked garbled");
|
||||
this.assertTrue(report.messages[0].text.includes("▒"), "and its text is ruined");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- satellites -----
|
||||
|
||||
test_a_satellite_reveals_a_chunk_every_two_days() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 0);
|
||||
grantBuilding(state, city, "space_launch_center", 1);
|
||||
this.assertTrue(state.canLaunchSatellite(0), "a centre gives a launch pad");
|
||||
this.assertTrue(state.requestSatellite(0, city), "the launch is accepted");
|
||||
this.assertEqual(state.satellites.length, 1);
|
||||
this.assertFalse(state.canLaunchSatellite(0), "one centre sustains one satellite");
|
||||
|
||||
const satellite = state.satellites[0];
|
||||
this.assertEqual(satellite.civ, 0);
|
||||
this.assertEqual(
|
||||
satellite.nextRevealHour,
|
||||
SATELLITE.revealEveryDays * HOURS_PER_DAY,
|
||||
"the first sweep is two days out"
|
||||
);
|
||||
const news = state.news[state.news.length - 1];
|
||||
this.assertEqual(news.type, "satellite", "everyone is told of the launch");
|
||||
|
||||
// Wipe the viewer's explored set so the reveal is measurable, then run the
|
||||
// clock to the sweep.
|
||||
const explored = state.explored.get(0);
|
||||
explored.clear();
|
||||
state._random = () => 0;
|
||||
state.totalHours = satellite.nextRevealHour;
|
||||
state._tickSatellites();
|
||||
this.assertGreater(explored.size, 0, "the sweep uncovers tiles");
|
||||
this.assertGreater(state.satellites[0].nextRevealHour, state.totalHours, "and schedules the next");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- secrets ------
|
||||
|
||||
test_enrichment_can_leak_to_foreign_intelligence() {
|
||||
const state = smallState();
|
||||
state.news.length = 0;
|
||||
sequence(state, [0]);
|
||||
state._warnOfEnrichment(1);
|
||||
this.assertEqual(state.news.length, 1, "the warning is published");
|
||||
this.assertEqual(state.news[0].type, "enrichment");
|
||||
this.assertEqual(state.news[0].civ, 1, "naming the enriching nation");
|
||||
|
||||
sequence(state, [0.99]);
|
||||
state._warnOfEnrichment(1);
|
||||
this.assertEqual(state.news.length, 1, "and sometimes it stays secret");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { POLICY_EXPEL, MIGRATION } from "../shared/data/politics.js";
|
||||
|
||||
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
|
||||
// neighbour `to` is owned by another civilisation.
|
||||
function borderPair(state, fromCiv, toCiv) {
|
||||
for (const coords of state._territoryByCiv.get(fromCiv) || []) {
|
||||
for (const neighbour of state._neighbours(coords)) {
|
||||
if (state.territory.get(key(neighbour.x, neighbour.y)) === toCiv) {
|
||||
return { from: coords, to: neighbour };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class MigrationTest extends TestCase {
|
||||
test_food_output_comes_from_the_land_not_the_headcount() {
|
||||
@@ -145,4 +159,58 @@ export class MigrationTest extends TestCase {
|
||||
const state = smallState();
|
||||
this.assertEmpty(state.snapshot(0).migrationGraph.links, "the opening graph is empty");
|
||||
}
|
||||
|
||||
test_no_migration_into_a_country_you_are_at_war_with() {
|
||||
const state = smallState();
|
||||
const border = borderPair(state, 0, 1);
|
||||
this.assertNotNull(border, "the two nations share a border");
|
||||
const fromK = key(border.from.x, border.from.y);
|
||||
const toK = key(border.to.x, border.to.y);
|
||||
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
|
||||
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
|
||||
state.tilePopulation.set(fromK, 1_000_000);
|
||||
state.tilePopulation.set(toK, 1_000_000);
|
||||
// Peacetime: the people cross the border.
|
||||
this.assertGreater(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "migration in peace");
|
||||
// War: the same move is refused.
|
||||
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
|
||||
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
|
||||
this.assertTrue(state.requestDeclareWar(0, 1));
|
||||
this.assertEqual(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "no crossing into a hostile land");
|
||||
}
|
||||
|
||||
test_expelling_a_people_keeps_them_from_moving_in() {
|
||||
const state = smallState();
|
||||
const border = borderPair(state, 0, 1);
|
||||
this.assertNotNull(border, "the two nations share a border");
|
||||
const fromK = key(border.from.x, border.from.y);
|
||||
const toK = key(border.to.x, border.to.y);
|
||||
state._setTileEthnicFractions(border.from, new Map([[1, 1]]));
|
||||
state._setTileEthnicFractions(border.to, new Map([[1, 1]]));
|
||||
state.tilePopulation.set(fromK, 1_000_000);
|
||||
state.tilePopulation.set(toK, 1_000_000);
|
||||
// Enact the expel policy directly: the request guard would refuse a people
|
||||
// that is this large a share of the expelling nation.
|
||||
state.policies.get(1).push({ id: 999, type: POLICY_EXPEL, ethnicity: 1 });
|
||||
this.assertTrue(state._expelsEthnicity(1, 1), "nation 1 expels people 1");
|
||||
this.assertEqual(state._transferEthnicity(fromK, toK, 1, 0.5), 0, "the expelled people stay out");
|
||||
}
|
||||
|
||||
test_a_tile_cannot_lose_more_than_its_daily_migration_share() {
|
||||
const state = smallState();
|
||||
const cities = state.cities.filter((city) => city.civ === 0);
|
||||
this.assertGreaterOrEqual(cities.length, 2, "two regions to move between");
|
||||
const from = cities[0];
|
||||
const to = cities[1];
|
||||
for (const coords of state.regionTiles(from)) {
|
||||
state.tilePopulation.set(key(coords.x, coords.y), 1_000_000);
|
||||
}
|
||||
// Open the daily budget the migration tick runs under.
|
||||
state._migrationOutDay = new Map();
|
||||
const before = state.regionPopulation(from);
|
||||
const moved = state._movePopulation(from, to, 5_000_000, false);
|
||||
const cap = before * MIGRATION.maxDailyOutShare;
|
||||
this.assertTrue(moved <= cap + 1, `moved ${moved} within the ${cap} cap`);
|
||||
state._migrationOutDay = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
|
||||
import { buildingEffectText, describeNews } from "../client/js/modals/format.js";
|
||||
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js";
|
||||
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost, TREATY_PEACE, TREATY_ALLIANCE, TREATY_GIVE_MONEY } from "../shared/data.js";
|
||||
import { groupDigits } from "../shared/text_format.js";
|
||||
import { setCurrency, setCurrencyBook } from "../client/js/currency.js";
|
||||
import { formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../shared/resources.js";
|
||||
@@ -845,6 +845,107 @@ export class NationModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_diplomacy_tab_names_each_nations_controller() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
|
||||
const stats = [
|
||||
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
|
||||
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
|
||||
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
|
||||
];
|
||||
const players = {
|
||||
1: { id: 1, name: "Alice", admin: true, civ: 0 },
|
||||
2: { id: 2, name: "Bob", admin: false, civ: 1 },
|
||||
};
|
||||
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, {
|
||||
civs, localCiv: 0, relations: [], stats, players,
|
||||
});
|
||||
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
|
||||
|
||||
const byName = (name) =>
|
||||
env.$("#diplomacy-list tbody tr").filter(
|
||||
(_, el) => env.$(el).find(".name").text() === name
|
||||
).first();
|
||||
const britain = byName("Britain");
|
||||
this.assertTrue(
|
||||
britain.find("td.control img").attr("src").includes("icon_human"),
|
||||
"a player-run nation shows the human icon"
|
||||
);
|
||||
this.assertEqual(britain.find("td.leader").text(), "Bob", "the leader's name shows");
|
||||
const poland = byName("Poland");
|
||||
this.assertTrue(
|
||||
poland.find("td.control img").attr("src").includes("icon_computer"),
|
||||
"an unclaimed nation shows the computer icon"
|
||||
);
|
||||
this.assertEqual(poland.find("td.leader").text(), "—", "it has no leader name");
|
||||
modal.close();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_diplomacy_tab_proposes_answers_and_breaks_treaties() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const civs = [{ name: "France" }, { name: "Britain" }];
|
||||
const stats = [
|
||||
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
|
||||
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
|
||||
];
|
||||
const info = {
|
||||
civs, localCiv: 0, relations: [], stats, players: {},
|
||||
treaties: [{
|
||||
id: 3, type: TREATY_ALLIANCE, a: 0, b: 1, payload: {},
|
||||
signedHours: 0, signedDate: "Jan 2000",
|
||||
}],
|
||||
treatyProposals: [{
|
||||
id: 7, type: TREATY_PEACE, from: 1, to: 0, payload: {},
|
||||
proposedHours: 0, proposedDate: "Jan 2000",
|
||||
}],
|
||||
treatyHistory: [],
|
||||
localCities: [{ id: 5, name: "Paris" }],
|
||||
localUnits: [],
|
||||
unitProtos: PROTO_UNITS,
|
||||
resources: null,
|
||||
};
|
||||
const proposals = [];
|
||||
const breaks = [];
|
||||
modal.onProposeTreaty = (type, to, payload) => proposals.push([type, to, payload]);
|
||||
modal.onRespondTreaty = (id, accept) => proposals.push([id, accept]);
|
||||
modal.onCancelTreaty = (id) => breaks.push(id);
|
||||
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, info);
|
||||
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
|
||||
|
||||
// The pending offer addressed to us carries Sign and Reject.
|
||||
const pending = env.$("#treaty-pending .treaty-row").first();
|
||||
this.assertTrue(pending.text().includes("Britain proposes Peace"));
|
||||
pending.find("button").first().trigger("click");
|
||||
this.assertEqual(proposals.at(-1), [7, true], "Sign answers the offer");
|
||||
|
||||
// The active alliance offers a Break button.
|
||||
const active = env.$("#treaty-active .treaty-row").first();
|
||||
this.assertTrue(active.text().includes("Alliance"));
|
||||
active.find("button").trigger("click");
|
||||
this.assertEqual(breaks, [3], "the alliance can be broken");
|
||||
|
||||
// The compose row offers every type and sends the chosen one.
|
||||
env.$("#treaty-type").val(TREATY_GIVE_MONEY).trigger("change");
|
||||
env.$("#treaty-payload .treaty-money").val("5000");
|
||||
env.$("#treaty-propose").trigger("click");
|
||||
this.assertEqual(
|
||||
proposals.at(-1),
|
||||
[TREATY_GIVE_MONEY, 1, { amount: 5000 }],
|
||||
"the money offer carries its amount"
|
||||
);
|
||||
modal.close();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_diplomacy_shows_a_foreign_figure_in_its_own_currency_with_ours_beside_it() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -317,4 +317,74 @@ export class ServerProtocolTest extends TestCase {
|
||||
guest.close();
|
||||
});
|
||||
}
|
||||
|
||||
async test_treaty_orders_flow_through_the_server() {
|
||||
await withServer(async (port, server) => {
|
||||
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await host.open;
|
||||
host.send({ t: "login", name: "Host", password: "pw" });
|
||||
await host.next("assigned");
|
||||
await host.next("choose_civ");
|
||||
host.send({ t: "claim_civ", civ: 0 });
|
||||
await host.next("game_state");
|
||||
|
||||
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await guest.open;
|
||||
guest.send({ t: "login", name: "Carol", password: "pw" });
|
||||
await guest.next("assigned");
|
||||
await guest.next("choose_civ");
|
||||
guest.send({ t: "claim_civ", civ: 1 });
|
||||
await guest.next("game_state");
|
||||
|
||||
host.send({ t: "order", order: { type: "propose_treaty", to: 1, treaty: "peace" } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
server.step(0.05);
|
||||
const state = server.gameServer.state;
|
||||
this.assertSize(state.treatyProposals, 1, "the offer reached the model");
|
||||
const proposal = state.treatyProposals[0];
|
||||
this.assertEqual(proposal.from, 0);
|
||||
this.assertEqual(proposal.to, 1);
|
||||
|
||||
guest.send({
|
||||
t: "order",
|
||||
order: { type: "respond_treaty", proposal: proposal.id, accept: true },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
server.step(0.05);
|
||||
this.assertSize(state.treaties, 1, "accepting signs the treaty");
|
||||
this.assertEqual(state.treaties[0].type, "peace");
|
||||
|
||||
host.close();
|
||||
guest.close();
|
||||
});
|
||||
}
|
||||
|
||||
async test_chat_relays_to_every_signed_in_player() {
|
||||
await withServer(async (port) => {
|
||||
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await host.open;
|
||||
host.send({ t: "login", name: "Host", password: "pw" });
|
||||
await host.next("assigned");
|
||||
await host.next("choose_civ");
|
||||
host.send({ t: "claim_civ", civ: 0 });
|
||||
await host.next("game_state");
|
||||
|
||||
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await guest.open;
|
||||
guest.send({ t: "login", name: "Carol", password: "pw" });
|
||||
await guest.next("assigned");
|
||||
await guest.next("choose_civ");
|
||||
guest.send({ t: "claim_civ", civ: 1 });
|
||||
await guest.next("game_state");
|
||||
|
||||
host.send({ t: "chat", text: "hello world" });
|
||||
const received = await guest.next("chat");
|
||||
this.assertEqual(received.name, "Host", "the sender is named");
|
||||
this.assertEqual(received.civ, 0, "and their nation travels too");
|
||||
this.assertEqual(received.text, "hello world", "the line arrives intact");
|
||||
|
||||
host.close();
|
||||
guest.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import {
|
||||
PROTO_UNITS,
|
||||
TRAINABLE_UNIT_IDS,
|
||||
SPY_OPERATIONS,
|
||||
SPY_SABOTAGE,
|
||||
SPY_CITY,
|
||||
SPY_COMMS,
|
||||
technologyById,
|
||||
} from "../shared/data.js";
|
||||
import { key, parseKey } from "../shared/hex.js";
|
||||
|
||||
export class SpyTest extends TestCase {
|
||||
test_the_spy_is_a_gated_trainable_unit() {
|
||||
const proto = PROTO_UNITS.find((u) => u.id === "spy");
|
||||
this.assertNotNull(proto, "the spy exists");
|
||||
this.assertTrue(proto.spy, "it is marked a spy");
|
||||
this.assertFalse(proto.military, "it does not fight");
|
||||
this.assertEqual(proto.vision, 1, "it watches a single tile out");
|
||||
this.assertEqual(proto.requiresTechnology, "intelligence_agencies", "it needs the agency");
|
||||
this.assertTrue(TRAINABLE_UNIT_IDS.includes("spy"), "it can be trained");
|
||||
this.assertTrue(proto.icon.endsWith(".svg"), "it carries an icon");
|
||||
this.assertNotNull(technologyById("intelligence_agencies"), "the agency technology exists");
|
||||
this.assertNotNull(technologyById("counter_intelligence"), "the counter-intelligence technology exists");
|
||||
}
|
||||
|
||||
test_the_covert_operations_are_well_formed() {
|
||||
this.assertNotEmpty(SPY_OPERATIONS);
|
||||
const ids = new Set();
|
||||
for (const operation of SPY_OPERATIONS) {
|
||||
this.assertTrue(operation.id.length > 0, "every operation has an id");
|
||||
this.assertFalse(ids.has(operation.id), `the id ${operation.id} is unique`);
|
||||
ids.add(operation.id);
|
||||
this.assertTrue(operation.name.length > 0, `name of ${operation.id}`);
|
||||
this.assertTrue(operation.description.length > 0, `description of ${operation.id}`);
|
||||
}
|
||||
this.assertTrue(ids.has(SPY_SABOTAGE), "sabotage");
|
||||
this.assertTrue(ids.has(SPY_CITY), "city spying");
|
||||
this.assertTrue(ids.has(SPY_COMMS), "comms spying");
|
||||
}
|
||||
|
||||
test_an_enemy_spy_is_hidden_from_every_other_nation() {
|
||||
const state = smallState();
|
||||
const home = state.cities.find((city) => city.civ === 1);
|
||||
const spy = state._spawnUnit(home.coords, 1, state.protoUnits.find((u) => u.id === "spy"));
|
||||
const seenByEnemy = (state.snapshot(0).units || []).map((unit) => unit.id);
|
||||
this.assertFalse(seenByEnemy.includes(spy.id), "the enemy never sees the spy");
|
||||
const seenByOwner = (state.snapshot(1).units || []).map((unit) => unit.id);
|
||||
this.assertTrue(seenByOwner.includes(spy.id), "its own nation always sees it");
|
||||
}
|
||||
|
||||
test_a_spy_slips_through_foreign_territory() {
|
||||
const state = smallState();
|
||||
const home = state.cities.find((city) => city.civ === 0);
|
||||
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
|
||||
const infantry = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "modern_infantry"));
|
||||
|
||||
// A quiet, unowned British land tile, explored by the spy's nation.
|
||||
let foreign = null;
|
||||
for (const [k, civ] of state.territory) {
|
||||
if (civ !== 1) continue;
|
||||
const coords = parseKey(k);
|
||||
if (state._cityByCoords.has(k)) continue;
|
||||
if (state.unitsAt(coords).length > 0) continue;
|
||||
const tile = state.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") continue;
|
||||
foreign = coords;
|
||||
break;
|
||||
}
|
||||
this.assertNotNull(foreign, "the fixture has a British land tile");
|
||||
state.explored.get(0).add(key(foreign.x, foreign.y));
|
||||
|
||||
this.assertTrue(
|
||||
state._canUnitEnter(spy, foreign, false),
|
||||
"a spy may cross foreign land on the way through"
|
||||
);
|
||||
this.assertFalse(
|
||||
state._canUnitEnter(infantry, foreign, false),
|
||||
"a soldier still treats foreign land as a wall"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, seaTile } from "./framework/helpers.js";
|
||||
import { PROTO_UNITS, TRAINABLE_UNIT_IDS } from "../shared/data.js";
|
||||
|
||||
export class SubmarineTest extends TestCase {
|
||||
test_the_submarine_is_a_trainable_naval_unit() {
|
||||
const proto = PROTO_UNITS.find((u) => u.id === "submarine");
|
||||
this.assertNotNull(proto, "the submarine exists");
|
||||
this.assertTrue(proto.naval, "it is a naval unit");
|
||||
this.assertTrue(proto.submarine, "and is marked a submarine");
|
||||
this.assertTrue(TRAINABLE_UNIT_IDS.includes("submarine"), "it can be trained");
|
||||
this.assertTrue(proto.icon && proto.icon.length > 0, "it carries an icon");
|
||||
}
|
||||
|
||||
test_an_enemy_submarine_is_hidden_until_a_naval_unit_is_beside_it() {
|
||||
const state = smallState();
|
||||
const spot = seaTile(state);
|
||||
this.assertNotNull(spot, "the fixture has open sea");
|
||||
const sub = state._spawnUnit(spot, 1, this.subProto(state));
|
||||
// No French naval unit anywhere near: the submarine is invisible.
|
||||
let ids = (state.snapshot(0).units || []).map((unit) => unit.id);
|
||||
this.assertFalse(ids.includes(sub.id), "the submarine is hidden");
|
||||
// A submarine not at war is still hidden: the vision rule is not about war.
|
||||
ids = (state.snapshot(1).units || []).map((unit) => unit.id);
|
||||
this.assertTrue(ids.includes(sub.id), "its own nation always sees it");
|
||||
}
|
||||
|
||||
test_a_naval_unit_on_or_beside_the_submarine_reveals_it() {
|
||||
const state = smallState();
|
||||
const spot = seaTile(state);
|
||||
const sub = state._spawnUnit(spot, 1, this.subProto(state));
|
||||
const battleship = state.protoUnits.find((u) => u.id === "battleship");
|
||||
// A French battleship on the same tile sees it.
|
||||
state._spawnUnit(spot, 0, battleship);
|
||||
let ids = (state.snapshot(0).units || []).map((unit) => unit.id);
|
||||
this.assertTrue(ids.includes(sub.id), "a naval unit on the tile reveals it");
|
||||
}
|
||||
|
||||
test_a_distant_naval_unit_does_not_reveal_it() {
|
||||
const state = smallState();
|
||||
const spot = seaTile(state);
|
||||
const sub = state._spawnUnit(spot, 1, this.subProto(state));
|
||||
const battleship = state.protoUnits.find((u) => u.id === "battleship");
|
||||
// Find a sea tile two steps away, so it is out of the direct ring.
|
||||
let distant = null;
|
||||
for (const k in state.tiles) {
|
||||
const parts = k.split(",").map(Number);
|
||||
const coords = { x: parts[0], y: parts[1] };
|
||||
if (state.tiles[k].terrainClass !== "Sea") continue;
|
||||
if (state.topology.tileDistance(coords, spot) === 2) {
|
||||
distant = coords;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.assertNotNull(distant, "a sea tile two steps away");
|
||||
state._spawnUnit(distant, 0, battleship);
|
||||
const ids = (state.snapshot(0).units || []).map((unit) => unit.id);
|
||||
this.assertFalse(ids.includes(sub.id), "a unit two tiles away does not reveal it");
|
||||
}
|
||||
|
||||
subProto(state) {
|
||||
return state.protoUnits.find((u) => u.id === "submarine");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import {
|
||||
TREATY_PEACE,
|
||||
TREATY_ALLIANCE,
|
||||
TREATY_FREE_TRADE,
|
||||
TREATY_GIVE_CITY,
|
||||
TREATY_GIVE_MILITARY,
|
||||
TREATY_GIVE_RESOURCES,
|
||||
TREATY_GIVE_MONEY,
|
||||
TREATY_JOINT_RESEARCH,
|
||||
TREATY_SIGNED,
|
||||
TREATY_REJECTED,
|
||||
} from "../shared/data/treaties.js";
|
||||
import { NEWS_TREATY } from "../shared/data/relations.js";
|
||||
|
||||
// Signs a treaty directly between two nations, the way a UI flow would.
|
||||
function sign(state, from, to, type, payload = {}) {
|
||||
const before = state.treatyProposals.length;
|
||||
if (!state.requestProposeTreaty(from, to, type, payload)) return false;
|
||||
const proposal = state.treatyProposals[before];
|
||||
return state.requestRespondTreaty(to, proposal.id, true);
|
||||
}
|
||||
|
||||
export class TreatyTest extends TestCase {
|
||||
test_a_proposal_waits_for_the_other_side_and_makes_news() {
|
||||
const state = smallState();
|
||||
this.assertTrue(state.requestProposeTreaty(0, 1, TREATY_PEACE));
|
||||
this.assertSize(state.treatyProposals, 1, "the offer waits for an answer");
|
||||
this.assertEmpty(state.treaties, "nothing is signed yet");
|
||||
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
||||
this.assertEqual(notice.stage, "proposed");
|
||||
// The same offer is not repeated while it is pending.
|
||||
this.assertFalse(state.requestProposeTreaty(0, 1, TREATY_PEACE));
|
||||
}
|
||||
|
||||
test_only_the_named_receiver_may_answer_and_rejection_is_recorded() {
|
||||
const state = smallState(["france", "britain", "poland"]);
|
||||
state.requestProposeTreaty(0, 1, TREATY_PEACE);
|
||||
const proposal = state.treatyProposals[0];
|
||||
this.assertFalse(state.requestRespondTreaty(2, proposal.id, true), "a third party cannot sign");
|
||||
this.assertFalse(state.requestRespondTreaty(0, proposal.id, true), "nor the proposer");
|
||||
this.assertTrue(state.requestRespondTreaty(1, proposal.id, false), "the receiver rejects");
|
||||
this.assertEmpty(state.treatyProposals);
|
||||
this.assertEmpty(state.treaties);
|
||||
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
||||
this.assertEqual(notice.stage, TREATY_REJECTED);
|
||||
}
|
||||
|
||||
test_signing_records_an_active_treaty_and_a_signed_notice() {
|
||||
const state = smallState();
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
||||
this.assertTrue(state.areAllied(0, 1));
|
||||
this.assertTrue(state.hasTreaty(1, 0, TREATY_ALLIANCE), "symmetric lookup");
|
||||
this.assertEqual(state.treaties[0].a, 0);
|
||||
this.assertEqual(state.treaties[0].b, 1);
|
||||
const notice = state.news.filter((entry) => entry.type === NEWS_TREATY).at(-1);
|
||||
this.assertEqual(notice.stage, TREATY_SIGNED);
|
||||
}
|
||||
|
||||
test_peace_ends_the_war_and_sends_troops_home() {
|
||||
const state = smallState();
|
||||
this.assertTrue(state.requestDeclareWar(0, 1));
|
||||
// Put an invader deep on the enemy's soil.
|
||||
const enemyTile = state._territoryByCiv.get(1)[0];
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
state._moveUnitTo(unit, { x: enemyTile.x, y: enemyTile.y });
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
|
||||
this.assertFalse(state.isAtWar(0, 1), "the war is over");
|
||||
const city = cityOf(state, 0);
|
||||
this.assertEqual(unit.coords.x, city.coords.x, "the invader is back at a home city");
|
||||
this.assertEqual(unit.coords.y, city.coords.y);
|
||||
}
|
||||
|
||||
test_an_alliance_drags_allies_into_the_war() {
|
||||
const state = smallState(["france", "britain", "poland"]);
|
||||
this.assertTrue(sign(state, 1, 2, TREATY_ALLIANCE), "Britain and Poland ally");
|
||||
this.assertTrue(state.requestDeclareWar(0, 1), "France attacks Britain");
|
||||
this.assertTrue(state.isAtWar(0, 1), "the target is at war");
|
||||
this.assertTrue(state.isAtWar(0, 2), "Poland is drawn in");
|
||||
this.assertTrue(state.areAllied(1, 2), "the defenders' alliance holds");
|
||||
}
|
||||
|
||||
test_going_to_war_with_an_ally_breaks_the_alliance() {
|
||||
const state = smallState();
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
||||
this.assertTrue(state.areAllied(0, 1));
|
||||
this.assertTrue(state.requestDeclareWar(0, 1));
|
||||
this.assertFalse(state.areAllied(0, 1), "the alliance cannot survive the war");
|
||||
this.assertTrue(state.isAtWar(0, 1));
|
||||
}
|
||||
|
||||
test_renewed_war_breaks_a_peace_treaty() {
|
||||
const state = smallState();
|
||||
this.assertTrue(state.requestDeclareWar(0, 1));
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_PEACE));
|
||||
this.assertFalse(state.isAtWar(0, 1), "the peace holds");
|
||||
this.assertTrue(state.hasTreaty(0, 1, TREATY_PEACE));
|
||||
this.assertTrue(state.requestDeclareWar(0, 1), "going back to war");
|
||||
this.assertFalse(state.hasTreaty(0, 1, TREATY_PEACE), "the peace treaty is broken");
|
||||
this.assertTrue(state.isAtWar(0, 1));
|
||||
}
|
||||
|
||||
test_free_trade_partners_are_exempt_from_tariffs() {
|
||||
const state = smallState();
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_FREE_TRADE));
|
||||
this.assertTrue(state.freeTradePartners(0).has(1));
|
||||
this.assertTrue(state._tariffExempt(0, 1));
|
||||
this.assertTrue(state._tariffExempt(1, 0));
|
||||
this.assertFalse(state._tariffExempt(0, 2));
|
||||
}
|
||||
|
||||
test_giving_money_moves_the_sum_between_treasuries() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1_000_000);
|
||||
state.budgets.set(1, 0);
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MONEY, { amount: 400_000 }));
|
||||
this.assertApprox(state.getBudget(0), 600_000, 1e-6);
|
||||
this.assertApprox(state.getBudget(1), 400_000, 1e-6);
|
||||
this.assertFalse(
|
||||
state.requestProposeTreaty(0, 1, TREATY_GIVE_MONEY, { amount: 0 }),
|
||||
"a zero gift is refused"
|
||||
);
|
||||
}
|
||||
|
||||
test_giving_military_transfers_units_but_never_infantry() {
|
||||
const state = smallState();
|
||||
const infantry = state.units.find((u) => u.civ === 0 && state.unitProto(u).infantry);
|
||||
this.assertNotNull(infantry, "the fixture has an infantry unit");
|
||||
this.assertFalse(
|
||||
state.requestProposeTreaty(0, 1, TREATY_GIVE_MILITARY, { units: [infantry.id] }),
|
||||
"infantry cannot be gifted"
|
||||
);
|
||||
const mobile = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
|
||||
// The fixture standard army is all infantry, so raise a gun to gift.
|
||||
if (!mobile) {
|
||||
const artillery = state.protoUnits.findIndex((p) => p.id === "artillery");
|
||||
state._spawnUnit(cityOf(state, 0).coords, 0, state.protoUnits[artillery]);
|
||||
}
|
||||
const gift = state.units.find((u) => u.civ === 0 && !state.unitProto(u).infantry);
|
||||
this.assertNotNull(gift, "a non-infantry unit is available");
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_MILITARY, { units: [gift.id] }));
|
||||
this.assertEqual(state.findUnit(gift.id).civ, 1, "the unit changed hands");
|
||||
}
|
||||
|
||||
test_giving_a_city_transfers_it_and_its_land() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const cityKey = key(city.coords.x, city.coords.y);
|
||||
this.assertFalse(
|
||||
state.requestProposeTreaty(0, 1, TREATY_GIVE_CITY, { city: 99999 }),
|
||||
"an unknown city is refused"
|
||||
);
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_CITY, { city: city.id }));
|
||||
this.assertEqual(state.findCity(city.id).civ, 1, "the city changed hands");
|
||||
this.assertEqual(state.territory.get(cityKey), 1, "its tile went with it");
|
||||
}
|
||||
|
||||
test_giving_resources_moves_the_basket_to_the_receiver() {
|
||||
const state = smallState();
|
||||
const giver = cityOf(state, 0);
|
||||
const receiver = cityOf(state, 1);
|
||||
const giverStore = state.getCityResourceStock(giver);
|
||||
giverStore.steel = 1000;
|
||||
giverStore.food = 500;
|
||||
const receiverStore = state.getCityResourceStock(receiver);
|
||||
const receiverSteel = receiverStore.steel || 0;
|
||||
const receiverFood = receiverStore.food || 0;
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_GIVE_RESOURCES, {
|
||||
resources: { steel: 300, food: 200 },
|
||||
}));
|
||||
this.assertApprox(giverStore.steel, 700, 1e-6, "the steel left the giver");
|
||||
this.assertApprox(giverStore.food, 300, 1e-6, "and so did the food");
|
||||
this.assertApprox(receiverStore.steel, receiverSteel + 300, 1e-6, "the receiver got the steel");
|
||||
this.assertApprox(receiverStore.food, receiverFood + 200, 1e-6, "and the food");
|
||||
}
|
||||
|
||||
test_joint_research_shares_points_but_not_military_ones() {
|
||||
const state = smallState();
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH));
|
||||
const civil = state.technologies.findIndex((t) => t.id === "scientific_method");
|
||||
const military = state.technologies.findIndex((t) => t.id === "body_armour");
|
||||
state.focusTechnology.set(0, civil);
|
||||
state.focusTechnology.set(1, civil);
|
||||
state._addResearch(0, 5);
|
||||
this.assertApprox(state.getTechnologyProgress(1, civil), 5, 1e-6, "the partner shares the points");
|
||||
// A military technology is not shared.
|
||||
state.focusTechnology.set(0, military);
|
||||
state.focusTechnology.set(1, civil);
|
||||
const before = state.getTechnologyProgress(1, civil);
|
||||
state._addResearch(0, 7);
|
||||
this.assertApprox(
|
||||
state.getTechnologyProgress(1, civil),
|
||||
before,
|
||||
1e-6,
|
||||
"military research stays private"
|
||||
);
|
||||
}
|
||||
|
||||
test_ending_a_treaty_moves_it_to_the_history() {
|
||||
const state = smallState();
|
||||
this.assertTrue(sign(state, 0, 1, TREATY_ALLIANCE));
|
||||
const treaty = state.treaties[0];
|
||||
this.assertTrue(state.requestCancelTreaty(1, treaty.id), "either party may end it");
|
||||
this.assertEmpty(state.treaties);
|
||||
this.assertSize(state.treatyHistory, 1);
|
||||
this.assertEqual(state.treatyHistory[0].reason, "broken");
|
||||
this.assertFalse(state.areAllied(0, 1));
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_treaties_proposals_and_history() {
|
||||
const state = smallState();
|
||||
state.requestProposeTreaty(0, 1, TREATY_PEACE);
|
||||
const snapshot = state.snapshot(0);
|
||||
this.assertSize(snapshot.treatyProposals, 1);
|
||||
this.assertEqual(snapshot.treatyProposals[0].from, 0);
|
||||
this.assertEqual(snapshot.treatyProposals[0].type, TREATY_PEACE);
|
||||
this.assertEmpty(snapshot.treaties);
|
||||
this.assertEmpty(snapshot.treatyHistory);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export class CaptureTest extends TestCase {
|
||||
const pair = emptyLandPair(state);
|
||||
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
||||
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
||||
// A gun is usually spiked when taken; force the one-in-five capture.
|
||||
state._random = () => 0.9;
|
||||
this.assertTrue(state.requestMove(infantry.id, pair.a));
|
||||
state.advanceMovement(1000);
|
||||
this.assertEqual(artillery.civ, 1, "the battery changed hands");
|
||||
@@ -123,6 +125,9 @@ export class CaptureTest extends TestCase {
|
||||
state.protoUnits.find((u) => u.id === "jet_fighter"),
|
||||
city.id
|
||||
);
|
||||
// Aircraft are usually destroyed with the field; force the one-in-five
|
||||
// chance that the plane is taken intact.
|
||||
state._random = () => 0.9;
|
||||
state._captureCity(city, 0);
|
||||
this.assertEqual(jet.civ, 0, "the aircraft is captured with the city");
|
||||
this.assertEqual(jet.homeCityId, city.id);
|
||||
@@ -242,6 +247,32 @@ export class TransportTest extends TestCase {
|
||||
this.assertEqual(launch.cargo.length, 1, "the infantry is cargo");
|
||||
}
|
||||
|
||||
test_a_spy_can_board_a_launch_and_go_ashore() {
|
||||
const state = smallState();
|
||||
const spot = this.coastalSetup(state);
|
||||
this.assertNotNull(spot, "the nation has a coastal tile");
|
||||
const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch"));
|
||||
const spyProto = state.protoUnits.find((u) => u.id === "spy");
|
||||
const spy = state._spawnUnit(spot.land, 0, spyProto);
|
||||
this.assertTrue(state.requestEmbark([spy.id], launch.id), "a spy may board");
|
||||
for (let i = 0; i < 24; i++) state.advanceHour();
|
||||
this.assertNull(state.findUnit(spy.id), "the spy left the map");
|
||||
this.assertEqual(launch.cargo.length, 1, "the spy is cargo");
|
||||
this.assertTrue(state.requestDisembark([launch.id], spot.land), "the spy goes ashore");
|
||||
this.assertTrue(
|
||||
state.unitsAt(spot.land).some((unit) => unit.proto === state.protoUnits.indexOf(spyProto)),
|
||||
"the spy stands on the shore"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_worker_stays_ashore() {
|
||||
const state = smallState();
|
||||
const spot = this.coastalSetup(state);
|
||||
const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch"));
|
||||
const worker = state._spawnUnit(spot.land, 0, state.protoUnits.find((u) => u.id === "worker"));
|
||||
this.assertFalse(state.requestEmbark([worker.id], launch.id), "only infantry and spies board");
|
||||
}
|
||||
|
||||
test_a_loaded_launch_disembarks_its_stack() {
|
||||
const state = smallState();
|
||||
const spot = this.coastalSetup(state);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, defaultState, grantBuilding } from "./framework/helpers.js";
|
||||
import { key, parseKey } from "../shared/hex.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
function protoOf(state, id) {
|
||||
return state.protoUnits.find((unit) => unit.id === id);
|
||||
}
|
||||
|
||||
function protoIndex(state, id) {
|
||||
return state.protoUnits.findIndex((unit) => unit.id === id);
|
||||
}
|
||||
|
||||
function techIndex(state, id) {
|
||||
return state.technologies.findIndex((tech) => tech.id === id);
|
||||
}
|
||||
|
||||
// Grants a nation a technology directly, the way a long research project would.
|
||||
function research(state, civ, id) {
|
||||
const index = techIndex(state, id);
|
||||
if (!state.researched.has(civ)) state.researched.set(civ, new Set());
|
||||
state.researched.get(civ).add(index);
|
||||
}
|
||||
|
||||
function freeLandNear(state, center, maxDistance) {
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const k in state.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (state.tiles[k].terrainClass !== "Land") continue;
|
||||
if (state.cityAt(coords) || state.unitAt(coords)) continue;
|
||||
const distance = state.topology.tileDistance(center, coords);
|
||||
if (distance <= 0 || distance > maxDistance) continue;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = coords;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function farLand(state, from, minDistance) {
|
||||
for (const k in state.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (state.tiles[k].terrainClass !== "Land") continue;
|
||||
if (state.cityAt(coords) || state.unitAt(coords)) continue;
|
||||
if (state.topology.tileDistance(from, coords) <= minDistance) continue;
|
||||
return coords;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function freeLand(state) {
|
||||
for (const cell of state.landCells) {
|
||||
if (state.cityAt(cell) || state.unitAt(cell)) continue;
|
||||
return cell;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class WmdTest extends TestCase {
|
||||
test_nuclear_weapons_research_needs_an_enrichment_centre() {
|
||||
const state = smallState();
|
||||
const index = techIndex(state, "nuclear_weapons");
|
||||
research(state, 0, "scientific_method");
|
||||
this.assertFalse(
|
||||
state.canResearchTechnology(0, index),
|
||||
"the bomb is locked without a uranium enrichment centre"
|
||||
);
|
||||
const city = state.cities.find((entry) => entry.civ === 0);
|
||||
grantBuilding(state, city, "uranium_enrichment_center");
|
||||
this.assertTrue(
|
||||
state.canResearchTechnology(0, index),
|
||||
"the centre unlocks the research"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_missile_is_one_use() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||||
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
|
||||
const targetCoords = freeLandNear(state, capital.coords, 29);
|
||||
this.assertNotNull(targetCoords, "a free tile within missile range exists");
|
||||
const enemy = state._spawnUnit(targetCoords, 1, protoOf(state, "modern_infantry"));
|
||||
|
||||
this.assertTrue(
|
||||
state.requestMissileStrike([missile.id], targetCoords),
|
||||
"the strike is accepted"
|
||||
);
|
||||
this.assertNull(state.findUnit(missile.id), "the rocket is consumed");
|
||||
this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast");
|
||||
}
|
||||
|
||||
test_a_theatre_missile_cannot_reach_anywhere_but_an_icbm_can() {
|
||||
const state = defaultState();
|
||||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||||
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
|
||||
const icbm = state._spawnUnit(capital.coords, 0, protoOf(state, "icbm"));
|
||||
const far = farLand(state, capital.coords, 30);
|
||||
if (!far) return; // the default map is wide enough, but never assume it.
|
||||
const enemy = state._spawnUnit(far, 1, protoOf(state, "modern_infantry"));
|
||||
this.assertFalse(
|
||||
state.requestMissileStrike([missile.id], far),
|
||||
"a theatre missile stops at 30 tiles"
|
||||
);
|
||||
this.assertNotNull(state.findUnit(missile.id), "the refused rocket is not lost");
|
||||
// An ICBM is nuclear by design, so grant the bomb and launch it.
|
||||
research(state, 0, "nuclear_weapons");
|
||||
this.assertTrue(
|
||||
state.requestMissileStrike([icbm.id], far),
|
||||
"an ICBM reaches any tile"
|
||||
);
|
||||
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed");
|
||||
this.assertNull(state.findUnit(enemy.id), "the target is destroyed");
|
||||
}
|
||||
|
||||
test_a_nuclear_payload_needs_the_bomb() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||||
const missile = state._spawnUnit(capital.coords, 0, protoOf(state, "missile"));
|
||||
const targetCoords = freeLandNear(state, capital.coords, 29);
|
||||
const enemy = state._spawnUnit(targetCoords, 1, protoOf(state, "modern_infantry"));
|
||||
|
||||
this.assertFalse(
|
||||
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
|
||||
"a nation without the bomb cannot arm a nuclear warhead"
|
||||
);
|
||||
this.assertNotNull(state.findUnit(missile.id), "the missile is still there");
|
||||
this.assertNotNull(state.findUnit(enemy.id), "and no one died");
|
||||
|
||||
research(state, 0, "nuclear_weapons");
|
||||
this.assertTrue(
|
||||
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
|
||||
"with the bomb the strike goes ahead"
|
||||
);
|
||||
this.assertNull(state.findUnit(missile.id), "the missile was fired");
|
||||
this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out");
|
||||
}
|
||||
|
||||
test_the_icbm_needs_the_bomb_and_the_space_program() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||||
const icbm = protoIndex(state, "icbm");
|
||||
this.assertFalse(state.requestTrain(capital.id, icbm), "an ICBM needs the bomb first");
|
||||
research(state, 0, "nuclear_weapons");
|
||||
this.assertFalse(
|
||||
state.requestTrain(capital.id, icbm),
|
||||
"the bomb alone does not build a rocket that reaches orbit"
|
||||
);
|
||||
research(state, 0, "space_program");
|
||||
this.assertTrue(state.requestTrain(capital.id, icbm), "both unlock the ICBM");
|
||||
}
|
||||
|
||||
test_a_nuclear_blast_destroys_the_tile() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 0);
|
||||
const k = key(city.coords.x, city.coords.y);
|
||||
state.tilePopulation.set(k, 10_000);
|
||||
grantBuilding(state, city, "barracks");
|
||||
grantBuilding(state, city, "shelters");
|
||||
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
const shelters = state.protoBuildings.findIndex((b) => b.id === "shelters");
|
||||
|
||||
state._applyNuclearBlast(city.coords, 0);
|
||||
|
||||
this.assertApprox(state.tilePopulation.get(k), 1_000, 1, "nine in ten died");
|
||||
this.assertEqual(city.buildings[barracks], undefined, "the barracks is flattened");
|
||||
this.assertEqual(city.buildings[shelters], 1, "the bunker survives");
|
||||
}
|
||||
|
||||
test_a_nuclear_blast_spares_only_bunkers_and_fortifications() {
|
||||
const state = smallState();
|
||||
const fort = freeLand(state);
|
||||
const mill = freeLandNear(state, fort, 5);
|
||||
this.assertNotNull(fort, "a free tile for the fort");
|
||||
this.assertNotNull(mill, "a free tile for the mill");
|
||||
const fortKey = key(fort.x, fort.y);
|
||||
const millKey = key(mill.x, mill.y);
|
||||
state.tileImprovements.set(fortKey, "fortifications");
|
||||
state.tileImprovements.set(millKey, "steel_mill");
|
||||
state.roads.add(millKey);
|
||||
|
||||
state._applyNuclearBlast(fort, 0);
|
||||
state._applyNuclearBlast(mill, 0);
|
||||
|
||||
this.assertEqual(state.tileImprovements.get(fortKey), "fortifications", "the fort stands");
|
||||
this.assertEqual(state.tileImprovements.get(millKey), undefined, "the mill is gone");
|
||||
this.assertFalse(state.roads.has(millKey), "and its road with it");
|
||||
}
|
||||
|
||||
test_a_nuclear_blast_costs_every_nation_standing() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((entry) => entry.civ === 0);
|
||||
const before = state.civilisations.map((_, civ) => state.getCivPopularity(civ));
|
||||
state._applyNuclearBlast(city.coords, 0);
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
this.assertLess(
|
||||
state.getCivPopularity(civ),
|
||||
before[civ],
|
||||
`nation ${civ} loses standing`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||