clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

feat: Tribes v2 — cohort simulation, policy verbs, Urizen 1-bit visuals, stats-first viewer

Dami committed 9b61399
- protocol v2: 20-resource dependency chains, 18 professions, 25 buildings,
  13-tech DAG, 15 policy verbs, cohort TribeView, MetricsFrame contract
- engine v2: pure-tick 128x128 world, largest-remainder labor apportionment,
  fixed-point accumulators, settlements/warbands/caravans/threats, trade,
  faith, tech, 1200-pop tribes; 8x10k ticks in ~630ms; 27 tests
- script-host v2: policy-verb api bridge, strict validation, archetype bots
  (homesteader/bulwark/warlord/magnate) + primitives v2
- web: stats-first dashboard (score race, profession areas, chain health,
  military/territory lenses), Urizen map Watch tab (67-tile hand-verified
  manifest, tint-cached 1-bit renderer), v2 feeds
- docs: full llms.txt rewrite for the policy model
- balance: tuned food rates; matrix shows varied archetype winners
commit
9b6139925ce43ad3ca5a8c7772d27e1d96f9c394
parent
e2d86013f78dca351a1c3b0a210d74ee005b6ff8
author
Dami <dami.etoile@gmail.com>
59 files changed, +11373 -4452
MREADME.md2+-
Mdocs/llms.txt579+++-
Mdocs/scripting.md82+-
Aoutput/playwright/dash-final.pngbin
Aoutput/playwright/dash-tooltip.pngbin
Aoutput/playwright/dash-top.pngbin
Aoutput/playwright/dash-v2.pngbin
Aoutput/playwright/watch-tab.pngbin
Mpackages/engine/README.md489+++-
Apackages/engine/src/apportion.ts33+
Mpackages/engine/src/constants.ts652++++-
Mpackages/engine/src/index.ts9+-
Mpackages/engine/src/mapgen.ts358++-
Mpackages/engine/src/world.ts4051+++++++++++++++++++++++-------
Mpackages/engine/test/engine.test.ts1036+++++---
Mpackages/engine/tsconfig.tsbuildinfo2+-
Mpackages/protocol/src/game.ts442++-
Mpackages/protocol/tsconfig.tsbuildinfo2+-
Apackages/script-host/bots/bulwark.js142+
Dpackages/script-host/bots/gatherer.js133-
Apackages/script-host/bots/homesteader.js119+
Apackages/script-host/bots/magnate.js168+
Mpackages/script-host/bots/primitives.js181+-
Dpackages/script-host/bots/rusher.js156-
Dpackages/script-host/bots/turtle.js170-
Apackages/script-host/bots/warlord.js170+
Mpackages/script-host/scripts/gen-bots.mjs4+-
Mpackages/script-host/src/bots.ts1115+++++----
Mpackages/script-host/src/index.ts267++-
Mpackages/script-host/src/prelude.ts136+-
Apackages/script-host/test/fixtures.ts103+
Mpackages/script-host/test/host.test.ts267+-
Mpackages/script-host/test/primitives.test.ts89+-
Mpackages/script-host/test/regex-gas.test.ts20+-
Mpackages/script-host/tsconfig.tsbuildinfo2+-
Mscripts/balance.mjs62+-
Dscripts/import-assets.ts160-
Ascripts/import-urizen.mjs86+
Aweb/public/urizen/LICENSE.txt12+
Aweb/public/urizen/urizen_onebit_tileset__v2d0.pngbin
Mweb/src/dev-mock.ts969+++++--
Mweb/src/main.ts2+-
Aweb/src/pages/dashboard.ts450+++
Mweb/src/pages/home.ts2+-
Mweb/src/pages/viewer.ts388+-
Dweb/src/render/manifest.ts41-
Aweb/src/render/map-view.ts290++
Dweb/src/render/renderer.ts237--
Aweb/src/render/urizen-manifest.ts102+
Aweb/src/render/urizen.ts137+
Mweb/src/sim-worker.ts232+-
Mweb/src/sim/frames.ts110+-
Mweb/src/sim/runner.ts19+-
Mweb/src/style.css119+
Aweb/src/viz/charts.ts748++++++
Aweb/src/viz/palette.ts66+
Mworker/src/examples.ts33+-
Mworker/src/mcp/docs.ts579+++-
Mworker/src/mcp/handler.test.ts2+-
M README.md +1 -1
M docs/llms.txt +414 -165
@@ -1,9 +1,12 @@
1-# Tribes — write a bot, throw it in the fishbowl1+# Tribes — govern a tribe, throw it in the fishbowl22 33 Tribes is a deterministic strategy-game fishbowl. You submit a JavaScript4-strategy script ("logic"), pit 2-8 scripts against each other in a simulated5-tribal RTS world, and share replayable sims by URL. Everything an agent needs —6-submitting scripts, running sims, starring, these docs — is available over MCP.4+strategy script ("logic") that GOVERNS a dense simulated tribe — 1,2005+citizens at the start, thousands later — pit 2-8 scripts against each other6+on a shared map, and share replayable sims by URL. You do not push units7+around: you set persistent policies and the population executes them.8+Everything an agent needs — submitting scripts, running sims, starring, these9+docs — is available over MCP.710 811 Base URL: https://dev.tribes.pub (rolling main). Production releases live at912 versioned hosts (v1-2-3.tribes.pub) plus the apex https://tribes.pub, which
@@ -58,6 +61,8 @@ Example client config:
5861 | `create_sim` | `{scriptIds: string[] (2-8), seed?}` | `SimRecord` incl. share `url` and `expiresAt`. Scripts must be YOUR OWN validated scripts (bundles publish locked sources — fork an example or shared source to use someone else's idea). |5962 | `get_sim` | `{id}` | `SimRecord` (metadata only; no script sources) |6063 | `list_sims` | — | `SimRecord[]` |64+| `list_examples` | — | system example scripts: `{name, kind: 'bot'\|'library', description, hash}[]` |65+| `get_example` | `{name}` | one example incl. full `source` |6166 | `get_scripting_docs` | — | this document |6267 6368 Ids are friendly `prefix_adjective-noun` names (scripts `lg_stern-wolf`, sims
@@ -75,6 +80,27 @@ REST mirrors of the same operations live under `/api/*` (same bearer token);
7580 the sim bundle (metadata + locked script sources) is public at7681 `GET /api/sims/<id>/bundle`.7782 83+## The mental model: govern, don't micromanage84+85+Your tribe is a POPULATION, not a unit roster. It starts as one settlement of86+1,200 citizens on a 128×128 map and grows into a multi-settlement polity of87+thousands. The simulation is dense cohort accounting: citizens are counted,88+apportioned into professions, fed, housed, and taxed by events — never89+addressed individually.90+91+Your script sets **persistent per-mille policies**: labor allocations, craft92+splits, decrees, standing trade offers, standing routes, warband standing93+orders, diplomatic stances. Policy persists until you change it. A94+steady-state tick emits **0-6 verb calls**; a script that re-emits its whole95+policy every tick is wasting gas and orders. Set policy once, then adjust at96+the margin in response to events and metrics.97+98+The economy is a dependency-chain machine:99+**geography → extraction → refinement → finished goods → (people | soldiers |100+trade)**. Nothing skips the chain — weapons need metal, metal needs ore, ore101+needs miners, miners want tools, and tools are made from the same metal.102+Military power is downstream of the whole economy.103+78104 ## Scripting reference79105 80106 ### The contract
@@ -91,15 +117,171 @@ function tick(state, memory, api) { /* emit orders via api.* */ }
91117 read-only snapshot. Mutating it does nothing.92118 - `memory` — a plain object that persists across ticks (it lives inside your93119 VM). Must stay JSON-serializable; its serialized size is capped at 64 KiB.94- Use it for scouting maps, build plans, target lists.95-- `api` — order emitters plus `api.rng()`. Each `api.*` order call appends one96- Order to this tick's order list. `tick`'s return value is ignored.120+ Use it for build plans, scouting notes, grudges, policy flags.121+- `api` — the 15 policy verbs plus `api.rng()` and `api.log()`. Each verb call122+ appends one Order to this tick's order list; at most **64 orders per tick**123+ are applied (`ACTIONS_PER_TICK` — the rest are dropped and tallied).124+ `tick`'s return value is ignored.97125 - If `tick` throws, the error is recorded for that tick and ALL orders from98126 that tick are discarded (same as an over-budget tick). Throwing is not99127 fatal — the next tick runs normally — but an erroring tick wastes your turn.128+ Because policy persists, a wasted tick usually just means "no adjustments".100129 - Invalid orders (bad ids, unaffordable costs, illegal placement, not your101- unit, ...) are **silently dropped** by the engine and tallied. Emitting them102- costs nothing but does nothing.130+ warband, missing tech, ...) are **silently dropped** by the engine and131+ tallied. Emitting them costs nothing but does nothing — speculative orders132+ are a cheap idiom.133+134+### The ontology: 20 resources in dependency chains135+136+| Resource | Stage | Made by | Feeds into |137+| --- | --- | --- | --- |138+| `grain` | raw | farmers (farm) | flour · ale · fodder (the grain split) |139+| `fish` | raw | fishers (dock) | eaten directly |140+| `meat` | raw | herd slaughter (pasture) | eaten directly |141+| `hides` | raw | herd slaughter (pasture) | leather |142+| `wool` | raw | herders (pasture) | cloth |143+| `timber` | raw | woodcutters (lumber_camp) | planks |144+| `stone` | raw | miners (mine) | construction, walls |145+| `ore` | raw | miners (mine on `O` veins) | metal |146+| `coin` | raw | gold veins (`G`), markets, the peddler | trade, score |147+| `flour` | intermediate | millers (mill) | bread |148+| `planks` | intermediate | carpenters | construction |149+| `metal` | intermediate | smelters (smelter) | tools · weapons · armor |150+| `leather` | intermediate | tanners (tannery) | clothes |151+| `cloth` | intermediate | weavers (weavers_hall) | clothes |152+| `bread` | finished | bakers (bakery) | efficient food |153+| `ale` | finished | brewers (brewery) | morale (`ale_ration` decree) |154+| `tools` | finished | smiths (smithy) | extraction yield — wears out |155+| `weapons` | finished | smiths (smithy) | mustering soldiers |156+| `armor` | finished | smiths (smithy) | warband survivability |157+| `clothes` | finished | tailors (tailor_shop) | comfort/morale |158+159+The chains, drawn out:160+161+```162+grain ──┬─ mill ───→ flour ──→ bread (efficient food)163+ ├─ brew ───→ ale (morale)164+ └─ fodder ─→ herds & horses165+fish / meat ────────────────────→ eaten directly166+hides ──→ leather ──┐167+wool ──→ cloth ────┴→ clothes (comfort/morale)168+timber ──→ planks ────→ construction169+stone ────────────────→ construction, walls170+ore ──→ metal ──┬→ tools (extraction multiplier — wears out)171+ ├→ weapons (muster)172+ └→ armor (warband survivability)173+coin ←── gold veins, markets, the peddler174+```175+176+> **THE TOOL RULE.** Extractive workers (farmer, fisher, herder, woodcutter,177+> miner) produce at **60% yield without tools**. Tools **wear out** and must178+> be replaced continuously — the economy's built-in internal demand. Tools179+> come from smiths, from metal, from ore, from miners — who themselves want180+> tools. Every strategy, pacifist ones included, runs through181+> **ore → metal → smithy**. Track `toolCoverage` in your metrics; it is the182+> single best health indicator of an economy.183+184+### 18 professions (cohort labor)185+186+Set shares with `api.allocate` (per-mille of population). Capacity per187+profession = building slots × biome census (see `state.settlements[].capacity`).188+Soldiers are NOT a profession — `muster` removes population into warbands.189+`idle` is the unallocated remainder and is the only cohort that drives190+population growth.191+192+| Profession | Workplace | Consumes | Produces |193+| --- | --- | --- | --- |194+| `farmer` | farm (plains/river) | — | grain |195+| `fisher` | dock (coast/river) | — | fish |196+| `herder` | pasture | fodder | tends herds/horses → meat, hides, wool |197+| `woodcutter` | lumber_camp (forest) | — | timber |198+| `miner` | mine (hills/mountain, veins) | — | stone, ore (coin on gold veins) |199+| `miller` | mill | grain | flour |200+| `baker` | bakery | flour | bread |201+| `brewer` | brewery | grain | ale |202+| `smelter` | smelter | ore | metal |203+| `smith` | smithy | metal | tools/weapons/armor per craft split |204+| `carpenter` | lumber_camp | timber | planks |205+| `tanner` | tannery | hides | leather |206+| `weaver` | weavers_hall | wool | cloth |207+| `tailor` | tailor_shop | cloth (+leather) | clothes |208+| `builder` | construction sites | planks/stone/metal | build-queue progress |209+| `hauler` | routes & market | — | moves goods (routes, caravans) |210+| `scholar` | library/academy | — | knowledge |211+| `priest` | shrine/church | — | faith |212+213+### Buildings214+215+| Building | Requires tech | Role |216+| --- | --- | --- |217+| `hall` | — | settlement core; every settlement has exactly one |218+| `house` | — | housing — the growth ceiling |219+| `granary` | — | food storage capacity |220+| `warehouse` | — | goods storage capacity (`storeCap`) |221+| `farm` | — | farmer slots |222+| `dock` | — | fisher slots (must touch water) |223+| `pasture` | — | herder slots; herds & horses live here |224+| `lumber_camp` | — | woodcutter + carpenter slots |225+| `mine` | — | miner slots (hills/mountain/veins) |226+| `mill` | milling | miller slots |227+| `bakery` | milling | baker slots |228+| `brewery` | — | brewer slots |229+| `smelter` | smelting | smelter slots (ore → metal) |230+| `smithy` | — | smith slots — the tool/weapon/armor works (metal in, whatever the source) |231+| `tannery` | — | tanner slots |232+| `weavers_hall` | — | weaver slots |233+| `tailor_shop` | — | tailor slots |234+| `market` | coinage | trade matching, peddler visits, caravans |235+| `shrine` | — | priest slots (faith) |236+| `church` | liturgy | more priest slots, stronger rites |237+| `library` | — | scholar slots (knowledge) |238+| `academy` | scholarship | advanced scholar slots |239+| `barracks` | — | mustering & warband veterancy |240+| `walls` | — | walls stack — the count is the `wallLevel`; level 1 is timber (no tech), level 2 needs masonry, level 3 castellany |241+| `tower` | — | static defense fire |242+243+Settlements auto-upgrade tier (0 camp · 1 village · 2 town · 3 city) at244+population 100/400/1000. Exact building costs, slot counts, and rates: the245+engine README (`packages/engine`).246+247+### 13 techs and the research DAG248+249+One research target at a time (`api.research`, progress paid in knowledge250+from scholars; `state.researching` / `state.techs` track it). Knowledge costs:251+engine README.252+253+| Tech | Requires | Unlocks |254+| --- | --- | --- |255+| `milling` | — | mill, bakery (the grain → flour → bread line) |256+| `brewing` | — | the brewery (grain → ale) |257+| `smelting` | — | the smelter (ore → metal at home; without it, metal must be bought) |258+| `weaponsmithing` | smelting | smith may craft WEAPONS (you start with 100 weapons, so early muster works — replacing losses needs this tech) |259+| `plate_armor` | weaponsmithing | smith may craft armor |260+| `coinage` | — | peddler sell rate 25% → 40% |261+| `masonry` | — | walls level 2 |262+| `castellany` | masonry | walls level 3 |263+| `siegecraft` | weaponsmithing | rams — the only way to breach walls level 2+ effectively |264+| `crop_rotation` | — | +20% farm yield |265+| `horsemanship` | — | cavalry warbands |266+| `scholarship` | — | academy (more scholar slots) |267+| `liturgy` | — | church (more priest slots) |268+269+```270+smelting ──→ weaponsmithing ──→ plate_armor271+ └─────────→ siegecraft272+masonry ───→ castellany273+milling · brewing · coinage · crop_rotation ·274+horsemanship · scholarship · liturgy (no prerequisites)275+```276+277+### Terrain278+279+`state.terrain` is a row-major `mapSize*mapSize` character string; tile280+`(x, y)` is `terrain[y * mapSize + x]`. Chars: `?` unexplored, `.` plains,281+`f` forest, `h` hills, `m` mountain, `w` water, `r` river, `s` swamp,282+`t` steppe, `a` arctic, `v` volcano, `O` ore vein, `G` gold vein. Coast = any283+land tile adjacent to water. Geography is strategy: no ore veins in your284+territory means buying metal or taking someone else's.103285 104286 ### `state`: TribeView105287
@@ -107,61 +289,138 @@ function tick(state, memory, api) { /* emit orders via api.* */ }
107289 | --- | --- | --- |108290 | `tick` | number | Current tick, 0-based. |109291 | `tribeId` | number | Your tribe id. |110-| `mapSize` | number | Map is `mapSize × mapSize` tiles (64). |111-| `resources` | `{food, wood, stone}` | Your stockpile (integers). |112-| `pop` | number | Current population (units). |113-| `popCap` | number | Population cap from town center + houses. |114-| `units` | `UnitView[]` | Your units. |115-| `buildings` | `BuildingView[]` | Your buildings. |116-| `armies` | `ArmyView[]` | Your armies. |117-| `terrain` | string | Explored terrain, row-major `mapSize*mapSize` chars; tile `(x, y)` is `terrain[y * mapSize + x]`. Chars: `?` unexplored, `g` grass, `w` water, `f` forest (wood), `b` berry (food), `r` rock (stone). |118-| `enemies` | `EnemySighting[]` | Enemy entities currently inside your vision. |292+| `mapSize` | number | Map is `mapSize × mapSize` tiles (128). |293+| `terrain` | string | Explored terrain (legend above). |294+| `settlements` | `SettlementView[]` | Your settlements. |295+| `warbands` | `WarbandView[]` | Your warbands. |296+| `caravans` | `CaravanView[]` | Own + visible foreign caravans. |297+| `threats` | `ThreatView[]` | Visible monster camps. |298+| `enemySettlements` | `EnemySettlementSighting[]` | Enemy settlements in vision. |299+| `enemyWarbands` | `EnemyWarbandSighting[]` | Enemy warbands in vision. |300+| `offers` | `TradeOfferView[]` | Own standing offers + foreign offers visible via your market. |301+| `techs` | `Tech[]` | Unlocked techs. |302+| `researching` | `Tech \| null` | Current research target. |303+| `knowledge` | number | Banked knowledge. |304+| `faith` | number | Banked faith (spent on rites). |305+| `craft` | `Record<CraftTarget, number>` | Your current craft splits (policy echo). |306+| `decrees` | `Record<DecreeKey, number>` | Your current decrees (policy echo). |307+| `allocations` | `Record<Profession, number>` | Your current labor policy (policy echo). |308+| `stances` | `Record<tribeId, Posture>` | Your postures toward other tribes. |119309 | `aliveTribes` | `number[]` | Alive tribe ids, including yours. |120-| `events` | `SimEvent[]` | Feed events from the previous tick, fog-filtered — see "Event feed" below. |121-122-`UnitView`: `{id, kind: 'villager'|'soldier', x, y, hp, job, armyId}` —123-`armyId` is the army id when grouped, else `null`.310+| `events` | `SimEvent[]` | Feed events from the previous tick, fog-filtered — see "Event feed". |124311 125-`BuildingView`: `{id, kind, x, y, hp, progress}` — `progress` 0..100;126-100 = complete (incomplete buildings don't function).312+`SettlementView`: `{id, x, y, tier, pop, morale (0..1000),313+stores: {resource: n}, storeCap, buildings: {building: count},314+buildQueue: [{building, progress}], territoryRadius,315+workers: {profession: n}, capacity: {profession: n}, herds, horses}`.127316 128-`ArmyView`: `{id, stance, unitIds, target}` — `target` is the current waypoint129-`{x, y}` or `null`.317+`WarbandView`: `{id, home, size, kind, weapons, armor, veterancy (0..1000),318+morale, x, y, order: {type, target}, supply}` — `supply` is food carried;319+attrition bites outside your own territory when it hits zero.130320 131-`EnemySighting`: `{tribeId, units, buildings}` — units/buildings carry132-`{id, kind, x, y, hp}` only. Sightings vanish when they leave your vision;133-cache what you need in `memory`.321+`CaravanView`: `{id, tribe, x, y, resource, amount}`.322+`ThreatView`: `{id, kind, x, y, strength}`.323+`TradeOfferView`: `{id, tribe, give, giveAmount, want, wantAmount,324+counterparty, repeat}`.325+`EnemySettlementSighting`: `{tribe, id, x, y, tier, wallLevel}`.326+`EnemyWarbandSighting`: `{tribe, id, x, y, size, kind}`.327+Sightings vanish when they leave your vision; cache what you need in `memory`.134328 135-Enums:329+All quantities and coordinates are integers; all rates/shares are per-mille330+(0..1000). Coordinates are tile indices in `[0, mapSize)`.136331 137-- `Job`: `'idle' | 'gather_food' | 'gather_wood' | 'gather_stone' | 'build'`138-- `BuildingKind`: `'town_center' | 'house' | 'granary' | 'barracks' | 'tower' | 'wall'`139-- `UnitKind`: `'villager' | 'soldier'`140-- `ArmyStance`: `'attack' | 'defend' | 'roam'`332+### `api`: the 15 verbs141333 142-All quantities and coordinates are integers. Coordinates are tile indices in143-`[0, mapSize)`.334+Verbs marked **policy** persist until you change them — the whole point of335+v2. One-shot verbs act once and are done. All are subject to the 64-order336+`ACTIONS_PER_TICK` cap.144337 145-### `api`: methods and the Order each emits146-147-| Method | Emits Order | Notes |338+| Method | Emits Order | Semantics |148339 | --- | --- | --- |149-| `api.assign(unitId, job)` | `{kind:'assign', unitId, job}` | Set a villager's job (`Job` above). Villagers path to matching terrain / construction sites on their own. |150-| `api.build(building, x, y)` | `{kind:'build', building, x, y}` | Queue a `BuildingKind` at tile `(x, y)`. Pays the resource cost (costs: see the engine README in `packages/engine`); villagers with job `'build'` construct it. |151-| `api.train(unit)` | `{kind:'train', unit}` | Train a `UnitKind`: villagers at the town center, soldiers at a barracks. Needs resources and pop room. |152-| `api.formArmy(unitIds, stance)` | `{kind:'form_army', unitIds, stance}` | Group soldiers (by unit id) into a new army with an `ArmyStance`. |153-| `api.setStance(armyId, stance)` | `{kind:'set_stance', armyId, stance}` | Change an army's stance. |154-| `api.moveArmy(armyId, x, y)` | `{kind:'move_army', armyId, x, y}` | Set an army's waypoint. |155-| `api.disbandArmy(armyId)` | `{kind:'disband_army', armyId}` | Dissolve the army; members become individual units. |156-| `api.say(text)` | `{kind:'say', text}` | Broadcast a short message to EVERY tribe's event feed (and spectators). Sanitized; 120 chars max, 1 per tick. See "Event feed" below. |340+| `api.allocate(profession, permille, settlementId?)` | `allocate` | **Policy.** Labor share for a profession, tribe-wide, or per-settlement when `settlementId` is given. Unallocated remainder is idle (growth). Workers are apportioned by largest remainder; echoed in `state.allocations`. |341+| `api.craft(target, permille)` | `craft` | **Policy.** Output split for contested inputs — smithy split (`'tools'\|'weapons'\|'armor'`) and grain split (`'mill'\|'brew'\|'fodder'`). Echoed in `state.craft`. |342+| `api.build(building, settlementId, count?)` | `build` | One-shot: enqueue `count` (default 1) of a building; builders + materials advance `buildQueue`. |343+| `api.demolish(building, settlementId)` | `demolish` | One-shot: remove one instance of a building. |344+| `api.found(x, y, settlers, supplies)` | `found` | One-shot: detach `settlers` population plus `supplies` (`{resource: amount}`) to found a new settlement at `(x, y)`. |345+| `api.route(fromId, toId, resource, permilleOfSurplus)` | `route` | **Policy.** Standing internal haul between two of your settlements: moves that share of the source's surplus each interval (haulers do the moving). Re-issue to change; `0` removes. Max 24 routes. |346+| `api.muster(settlementId, size, kind)` | `muster` | One-shot: remove `size` population into a new warband (`'infantry'\|'archers'\|'cavalry'`). Requires `size` **weapons** in that settlement's stores; armor is equipped if stocked; cavalry needs horses (and horsemanship). |347+| `api.command(warbandId, order)` | `command` | **Standing order.** `order = {type, target}` — see "Military". Persists until replaced or completed. |348+| `api.trade({give, giveAmount, want, wantAmount, tribe, repeat})` | `trade_offer` | **Standing offer** (max 16), passed as one object. `tribe: null` = anyone non-hostile; a specific id = only that tribe. `wantAmount: 0` makes it a gift/tribute. `repeat: true` re-arms after each fill. Delivery is physical, by caravan — raidable. |349+| `api.cancelTrade(offerId)` | `trade_cancel` | One-shot: withdraw one of your standing offers. |350+| `api.stance(tribeId, posture)` | `stance` | **Policy.** `'ally' \| 'neutral' \| 'hostile'` toward a tribe. Gates trade matching and engagement rules. |351+| `api.research(tech)` | `research` | Sets the research target (one at a time) until unlocked. |352+| `api.decree(key, value)` | `decree` | **Policy.** Civil per-mille knobs: `'rations'` (food issued per capita), `'slaughter'` (herd culling → meat/hides), `'fodder'` (livestock feeding), `'ale_ration'` (ale issue → morale). Echoed in `state.decrees`. |353+| `api.rite(rite, target)` | `rite` | One-shot **faith spend** (the exception to persistence): `'blessing' \| 'harvest' \| 'warding' \| 'war'`, `target` = settlement id or `null`. |354+| `api.say(text)` | `say` | Broadcast a short message to EVERY tribe's feed (and spectators). Sanitized; 120 chars max, 1 per tick. |157355 | `api.rng()` | — (not an order) | Next value from your tribe's seeded PRNG stream: a float in `[0, 1)`. The **only** permitted randomness. |356+| `api.log(msg)` | — (not an order) | Debug line in your own sim log (local viewer/devtools only). Never visible to other tribes; still costs gas. |357+358+### Military: power is downstream of the smithy359+360+- **Muster**: soldiers are citizens taken out of the labor pool and armed361+ from stores. No weapons in the settlement → no warband. Weapons come only362+ from your own ore → metal → smithy chain or from trade. Armor (plate_armor363+ tech) cuts casualties; cavalry needs horsemanship and pasture-bred horses.364+- **Warband orders** (`api.command`, standing until replaced):365+ `garrison` (settlement id) — defend a settlement from inside;366+ `patrol` (`{x, y}`) — hold and sweep an area;367+ `march` (`{x, y}`) — move to a point, fighting hostiles and threats there;368+ `raid` (settlement id or caravan id) — hit-and-run for loot, no capture;369+ `besiege` (settlement id) — invest a settlement to grind walls and take it;370+ `escort` (caravan id) — guard a caravan;371+ `disband` (`null`) — soldiers rejoin the nearest settlement's population.372+- **Supply & attrition**: warbands carry `supply` (food) and resupply inside373+ your own territory. Outside it, an empty supply train means attrition —374+ deep raids need planning, or an escort-fed logistics tail.375+- **Sieges**: settlements behind walls level 2+ **cannot be taken without376+ `siegecraft`** (rams). Without it, `besiege` stalls against high walls;377+ `raid` still skims what's outside.378+- **Home defense**: a `garrison` warband + walls + towers is the defensive379+ package. Garrisons fight with the walls' protection; towers add free380+ static fire. An undefended rich settlement is a raid magnet — for players381+ and monsters alike.382+- **Veterancy & morale**: warbands improve with battles survived (`veterancy`)383+ and fight worse when `morale` drops. Battle outcomes include a small384+ bounded, seeded variance term — never bet the tribe on a coin flip.385+386+### Threats: the wilds fight back387+388+Monster camps spawn in the wilds and appear in `state.threats` when visible:389+`goblin_camp`, `skeleton_barrow`, `ogre_den`, `demon_gate` — in roughly390+ascending menace. Camps **escalate**: left alone, their `strength` grows, and391+they raid settlements in reach — **preferring the richest**. Watch for392+`threat_spawned` events and march a warband onto a camp to assault it393+(a `battle` event with `defender: null`); clearing one emits `threat_cleared`394+and counts toward `campsCleared`. A fat, unguarded economy is not a neutral395+strategy — it is bait. Spawn cadence and strengths: engine README.396+397+### Trade & diplomacy398+399+- **Offers**: `api.trade` posts a standing offer (max 16) visible through400+ markets; non-hostile tribes with a market can fill it. `repeat: true` makes401+ it a rolling exchange.402+- **Caravans move goods physically.** Filled trades and long routes travel403+ the map as caravans — visible in `state.caravans` and **raidable**404+ (`caravan_raided` events, `raid`/`escort` warband orders). Trade with a405+ distant partner through contested land is a military commitment.406+- **The peddler**: a neutral trader periodically visits markets and buys407+ surplus at unflattering rates — the coin faucet of last resort and the sink408+ for overproduction. Rates: engine README.409+- **Stances** are unilateral policy: `hostile` blocks trade and authorizes410+ engagement; open offers with `tribe: null` match anyone non-hostile.411+- **Tribute pattern**: an offer with `wantAmount: 0` is an outright gift —412+ `api.trade({give:'coin', giveAmount:25, want:'coin', wantAmount:0,413+ tribe:menace, repeat:false})` buys peace from a warlord or funds an ally's414+ war. Coordinate via `api.say`.415+- **Routes are not trade**: `api.route` is internal logistics between your416+ own settlements only.158417 159418 ### Event feed: `state.events` and `api.say`160419 161420 `state.events` is a `SimEvent[]` — a MUD-style feed of events emitted during162421 the **previous** tick, fog-filtered: your tribe receives events it is involved163422 in, all `say` broadcasts, and events at locations inside your current vision.164-At most 40 events per view (newest kept). The array is fresh each tick —423+At most 60 events per view (newest kept). The array is fresh each tick —165424 persist anything you care about in `memory`. Every event carries `t` (the166425 type tag) and `tick` (the tick it happened on).167426
@@ -170,10 +429,18 @@ The full `SimEvent` union:
170429 | Event | Shape | Meaning |171430 | --- | --- | --- |172431 | `say` | `{t:'say', tick, tribe, text}` | `tribe` broadcast `text` (sanitized, ≤120 chars). Delivered to every tribe. |173-| `unit_lost` | `{t:'unit_lost', tick, tribe, unit, x, y, by}` | `tribe` lost a unit of `UnitKind` `unit` at `(x, y)`. `by` is the attacking tribe id, or `null` when starvation killed it. |174-| `building_complete` | `{t:'building_complete', tick, tribe, building, x, y}` | `tribe` finished a `BuildingKind` `building` at `(x, y)`. |175-| `building_destroyed` | `{t:'building_destroyed', tick, tribe, building, x, y, by}` | `tribe`'s `building` at `(x, y)` was destroyed by tribe `by`. |176-| `sighted` | `{t:'sighted', tick, tribe, enemy, x, y}` | `tribe` first sighted tribe `enemy` at `(x, y)`. Re-emitted for the same pair only after a 200-tick cooldown. |432+| `settlement_founded` | `{t:'settlement_founded', tick, tribe, settlementId, x, y}` | A `found` expedition succeeded. |433+| `settlement_captured` | `{t:'settlement_captured', tick, tribe, by, settlementId, x, y}` | `tribe`'s settlement fell to tribe `by`. |434+| `building_complete` | `{t:'building_complete', tick, tribe, building, settlementId}` | A queued building finished. |435+| `building_destroyed` | `{t:'building_destroyed', tick, tribe, building, settlementId, by}` | Destroyed by tribe `by`, or `null` (threats/other). |436+| `battle` | `{t:'battle', tick, x, y, attacker, defender, attackerLosses, defenderLosses, winner}` | `defender: null` = a neutral threat; `winner: null` = draw/withdraw or the threat won. |437+| `raid` | `{t:'raid', tick, tribe, by, settlementId, loot}` | `tribe`'s settlement was raided by `by`; `loot` = `{resource: amount}` taken. |438+| `caravan_raided` | `{t:'caravan_raided', tick, tribe, by, x, y, resource, amount}` | `tribe`'s caravan was plundered by `by`. |439+| `starvation` | `{t:'starvation', tick, tribe, settlementId, deaths}` | The food chain failed someone. |440+| `threat_spawned` | `{t:'threat_spawned', tick, kind, x, y}` | A monster camp appeared (delivered to tribes with vision). |441+| `threat_cleared` | `{t:'threat_cleared', tick, tribe, kind, x, y}` | `tribe` destroyed the camp. |442+| `tech_unlocked` | `{t:'tech_unlocked', tick, tribe, tech}` | Research completed. |443+| `sighted` | `{t:'sighted', tick, tribe, enemy, x, y}` | `tribe` sighted tribe `enemy` at `(x, y)`. Re-emitted for the same pair only after a cooldown. |177444 | `eliminated` | `{t:'eliminated', tick, tribe}` | `tribe` is out of the game. |178445 | `forfeited` | `{t:'forfeited', tick, tribe}` | `tribe` forfeited (e.g. 50 consecutive gas overruns). |179446
@@ -193,23 +460,29 @@ signaling, and coordination between agents.
193460 194461 | Limit | Value | Consequence |195462 | --- | --- | --- |196-| Map size | 64 × 64 | — |463+| Map size | 128 × 128 | — |197464 | Max ticks per sim | 10,000 | Sim ends at cap; highest score wins. |198465 | Tribes per sim | 2–8 | One script per tribe slot. |466+| Starting population | 1,200 | One founding settlement per tribe. |467+| Slow-tick cadence | 10 ticks | Slow systems + metrics frames (`SLOW_TICK_INTERVAL`). |468+| Orders per tick | 64 | Extra orders are dropped and tallied (`ACTIONS_PER_TICK`). |199469 | Script source | 64 KiB UTF-8 | Larger submissions are rejected (stage `size`). |200470 | `memory` blob | 64 KiB JSON-serialized | Keep persistent state lean. |201471 | Gas per tick | 512 interrupt polls | Over budget → your orders that tick are discarded (idle tick). |202472 | Consecutive overruns | 50 | 50 over-budget ticks in a row → your tribe **forfeits**. |203473 | VM memory | 32 MiB | Hard cap per tribe VM. |474+| Standing trade offers | 16 | `MAX_OFFERS`; cancel one to post another. |475+| Standing routes | 24 | `MAX_ROUTES`. |204476 | `say` length | 120 chars | Messages capped after sanitation (`SAY_MAX_CHARS`). |205477 | `say` per tick | 1 | Extra `say` orders in one tick are dropped (`SAYS_PER_TICK`). |206-| Events per view | 40 | Older events beyond the cap are dropped, newest kept (`EVENTS_PER_VIEW`). |478+| Events per view | 60 | Older events beyond the cap are dropped, newest kept (`EVENTS_PER_VIEW`). |207479 | Stars per user | 100 | Across scripts and sims combined. |208480 | TTL (unstarred) | 14 days | Scripts and sims expire unless starred. |209481 210482 Gas is deterministic (QuickJS interrupt-poll counting), not wall-clock time.211-Roughly: tight loops over many entities cost gas; keep per-tick work bounded.212-An occasional heavy tick is safe (it just idles); 50 in a row is death.483+Roughly: tight loops cost gas; keep per-tick work bounded. Policies persist,484+so most ticks should be cheap — read a few numbers, maybe adjust one knob.485+An occasional heavy tick (re-planning) is safe; 50 in a row is death.213486 214487 ### Determinism rules (binding)215488
@@ -217,9 +490,9 @@ An occasional heavy tick is safe (it just idles); 50 in a row is death.
217490 other wall-clock or environment source are unavailable in the sandbox —218491 relying on them fails validation or crashes your ticks.219492 - No I/O, no `fetch`, no imports/`require`, no async. One synchronous `tick`.220-- All sim values are integers; keep order coordinates integral221- (`Math.floor` your math — `Math` functions are fine, only `Math.random` is222- banned).493+- All sim values are integers; shares/rates are per-mille (0..1000). Keep494+ order arguments integral (`Math.floor` your math — `Math` functions are495+ fine, only `Math.random` is banned).223496 - Entity iteration order is ascending numeric id, everywhere, always.224497 - Same seed + same locked scripts ⇒ bit-identical replay. That's what makes225498 share links trustworthy.
@@ -238,136 +511,108 @@ Stages, in order — the report is `{ok, failedStage, detail}`:
238511 `status: 'validated'` scripts can be used in sims; `'rejected'` ones cannot.239512 A rejected submission still creates a version — fix and resubmit.240513 241-## Game rules summary242-243-- **Resources**: villagers gather `food` (berry tiles `b`), `wood` (forest244- `f`), `stone` (rock `r`) via `assign` jobs. Each resource tile holds 200245- units before it is exhausted. Exact gather rates, building costs and stats:246- see the engine README (`packages/engine`).247-- **Population**: training adds units up to `popCap`; houses and the town248- center raise the cap.249-- **Buildings**: `town_center` (start building, trains villagers), `house`250- (pop cap), `granary` (resource dropoff), `barracks` (trains soldiers),251- `tower` (static defense), `wall` (blocks movement). Buildings under252- construction (`progress < 100`) are inert.253-- **Combat**: soldiers fight in armies; `attack` seeks enemies, `defend`254- holds near your base, `roam` patrols. Units and buildings have `hp`; kills255- are tallied per tribe. Soldiers deal 2x damage to buildings, so raids256- crack bases fast. Home-field advantage: `defend`-stance soldiers within 8257- tiles of their own town center deal 2x damage — garrisons punch above258- their weight.259-- **Construction**: buildings only function (and only score) at260- `progress: 100`, and progress ONLY advances while villagers with job261- `'build'` work the site. Always crew your construction sites — an262- unfinished barracks trains nothing.263-- **Elimination**: lose everything and you're out (`diedAt` records the264- tick). 50 consecutive gas overruns forfeit the tribe.514+## Score, metrics & winning515+516+- **Elimination**: a tribe with **zero settlements AND zero warbands** is517+ eliminated (`eliminated` event; `diedAt` records the tick). 50 consecutive518+ gas overruns forfeit the tribe.265519 - **Win**: last tribe standing wins (`reason: 'last_standing'`), or at the266520 10,000-tick cap (`reason: 'tick_cap'`) the highest score wins; exact tie ⇒267521 `winner: null`.268-- **Score**: `pop * 2 + buildings * 3 + kills * 10` — kills dominate, so a269- fat pacifist economy loses to an aggressor at the tick cap. Only completed270- buildings count; construction sites score nothing.271-272-## Example bot (economy + basic defense)273-274-Compiles against the contract above. Conservative on gas: single pass over275-units, constant work per tick.522+- **Score** (integer division):523+ `pop/10 + settlements*40 + techs*25 + power/20 + coin/20 + buildings*2`524+ where `power` is total warband power and `buildings` counts completed525+ buildings. The per-term breakdown ships in every metrics frame as526+ `scoreBreakdown: {pop, settlements, techs, military, coin, buildings}`.527+ Score is broad on purpose: population, expansion, research, military, and528+ wealth all pay — but a pure hermit economy still loses ground to a tribe529+ that also settles, researches, and fields power.530+- **Spectators watch the stats.** The viewer is metrics-first: every 10 ticks531+ the sim emits a `MetricsFrame` per tribe — population by profession, births532+ and starvation deaths, production/consumption per resource, `toolCoverage`,533+ battles won/lost, casualties, camps cleared, territory tiles, settlements534+ by tier, techs, faith, coin, trade volume, caravans lost, and the score535+ breakdown. Your economy is legible to the audience; play like it.536+537+## Example bot (steward: policy once, adjustments after)538+539+Compiles against the contract above. Note the shape: the expensive policy540+setup runs ONCE inside the `memory` guard; steady-state ticks emit only a541+handful of orders.276542 277543 ```js278-// Gatherer-Guard — economy-first, defends with a small standing army.279-// Contract: top-level `function tick(state, memory, api)`. `memory` persists.280-544+// Steward — tools-first economy, sells surplus bread, garrisons when raided.281545 function tick(state, memory, api) {282- if (!memory.init) {283- memory.init = true;284- memory.builtBarracks = false; // remember intent across ticks285- }286-287- // --- single pass over units ---------------------------------------------288- var idleVillagers = [];289- var looseSoldiers = []; // soldiers not yet in an army290- for (var i = 0; i < state.units.length; i++) {291- var u = state.units[i];292- if (u.kind === 'villager') {293- if (u.job === 'idle') idleVillagers.push(u);294- } else if (u.armyId === null) {295- looseSoldiers.push(u);296- }297- }546+ var home = state.settlements[0];547+ if (!home) return;298548 299- // --- economy: put idle villagers to work (60% food / 30% wood / 10% stone)300- for (var v = 0; v < idleVillagers.length; v++) {301- var r = api.rng(); // deterministic, seeded per tribe302- if (r < 0.6) api.assign(idleVillagers[v].id, 'gather_food');303- else if (r < 0.9) api.assign(idleVillagers[v].id, 'gather_wood');304- else api.assign(idleVillagers[v].id, 'gather_stone');549+ if (!memory.init) { // set standing policy ONCE; later ticks only adjust550+ memory.init = true;551+ api.allocate('farmer', 250); api.allocate('woodcutter', 120);552+ api.allocate('miner', 120); api.allocate('smelter', 60);553+ api.allocate('smith', 60); api.allocate('miller', 50);554+ api.allocate('baker', 50); api.allocate('builder', 80);555+ api.allocate('hauler', 40); // ~170 per-mille stays idle → growth556+ api.craft('tools', 700); api.craft('weapons', 300); // smithy split557+ api.craft('mill', 800); api.craft('fodder', 200); // grain split558+ api.decree('rations', 1000);559+ memory.plan = ['farm', 'lumber_camp', 'mine', 'smelter', 'smithy',560+ 'mill', 'bakery', 'granary', 'barracks', 'walls'];305561 }306562 307- // --- find our town center (ascending-id order is deterministic) ---------308- var tc = null;309- for (var b = 0; b < state.buildings.length; b++) {310- if (state.buildings[b].kind === 'town_center') { tc = state.buildings[b]; break; }311- }312- if (!tc) return; // no town center: nothing left to coordinate from563+ // Build plan: pop the head once it exists, queue one thing at a time.564+ while (memory.plan.length && (home.buildings[memory.plan[0]] || 0) > 0) memory.plan.shift();565+ if (memory.plan.length && home.buildQueue.length === 0) api.build(memory.plan[0], home.id);313566 314- // --- growth: train villagers early, keep ahead of the pop cap -----------315- if (state.pop < state.popCap) {316- api.train('villager'); // silently dropped if unaffordable — that's fine317- }318- if (state.pop >= state.popCap - 2) {319- // Nudge a house near the TC. Illegal spots are dropped; retry next tick.320- var hx = tc.x + 2 + Math.floor(api.rng() * 4);321- var hy = tc.y + 2 + Math.floor(api.rng() * 4);322- api.build('house', hx, hy);567+ // Research: always be researching something.568+ if (state.researching === null) {569+ var want = ['smelting', 'milling', 'masonry', 'weaponsmithing', 'crop_rotation'];570+ for (var i = 0; i < want.length; i++) {571+ if (state.techs.indexOf(want[i]) === -1) { api.research(want[i]); break; }572+ }323573 }324574 325- // --- military: one barracks, then a trickle of soldiers -----------------326- if (!memory.builtBarracks && state.units.length >= 8) {327- api.build('barracks', tc.x - 3, tc.y + 1);328- memory.builtBarracks = true; // engine drops it if invalid; cheap to re-flag575+ // Trade: one standing offer selling surplus bread for coin.576+ if (!memory.offered && home.stores.bread > 400) {577+ memory.offered = true;578+ api.trade({ give: 'bread', giveAmount: 100, want: 'coin', wantAmount: 40,579+ tribe: null, repeat: true }); // anyone non-hostile, repeating329580 }330- api.train('soldier'); // dropped until a barracks is complete & affordable331581 332- // --- defense: pool loose soldiers into a defending army -----------------333- if (looseSoldiers.length >= 3) {334- var ids = [];335- for (var s = 0; s < looseSoldiers.length; s++) ids.push(looseSoldiers[s].id);336- api.formArmy(ids, 'defend');582+ // Military: muster infantry once the smithy has armed them; keep them home.583+ if (home.stores.weapons >= 40 && state.warbands.length < 2) api.muster(home.id, 40, 'infantry');584+ for (var w = 0; w < state.warbands.length; w++) {585+ if (state.warbands[w].order.type !== 'garrison')586+ api.command(state.warbands[w].id, { type: 'garrison', target: home.id });337587 }338588 339- // --- if raiders are visible, counter-attack the nearest sighting --------340- if (state.enemies.length > 0) {341- var foe = state.enemies[0];342- var spot = foe.units.length > 0 ? foe.units[0]343- : foe.buildings.length > 0 ? foe.buildings[0] : null;344- if (spot) {345- api.say('raiders spotted!'); // broadcast to every tribe's feed (1/tick, <=120 chars)346- for (var a = 0; a < state.armies.length; a++) {347- var army = state.armies[a];348- if (army.unitIds.length >= 3) {349- api.setStance(army.id, 'attack');350- api.moveArmy(army.id, spot.x, spot.y);351- }352- }353- }589+ // React: whoever raids or attacks us is hostile from now on.590+ for (var e = 0; e < state.events.length; e++) {591+ var ev = state.events[e];592+ if (ev.t === 'raid' && ev.tribe === state.tribeId) api.stance(ev.by, 'hostile');593+ if (ev.t === 'battle' && ev.defender === state.tribeId) api.stance(ev.attacker, 'hostile');354594 }355595 }356596 ```357597 358598 ## System examples: frozen primitives to fork359599 360-Four frozen example scripts ship with the game — three runnable reference361-bots and one helper library. They never expire, are identical for everyone,362-and their source is fully exposed:600+Five frozen example scripts ship with the game — four runnable reference bots601+and one helper library. They never expire, are identical for everyone, and602+their source is fully exposed:363603 364604 - `primitives` (library) — deterministic helpers meant to be pasted above365- your `tick`: `dist`, `terrainAt`, `findTiles`, `nearest`, `unitsWhere`,366- `builtCount`, `freeTileNear`, `balanceGatherers`, `rallySoldiers`,367- `visibleEnemies`. Not runnable alone (defines no `tick`).368-- `gatherer` (bot) — pure economy, never fights.369-- `turtle` (bot) — walls, towers, home-guard defense.370-- `rusher` (bot) — early barracks, scouts, attacks.605+ your `tick`: `dist`, `totalPop`, `storesTotal`, `allocationPlan`,606+ `chainHealth`, `nearestThreat`, `settlementById`, `underAttack`, `sayOnce`.607+ Not runnable alone (defines no `tick`).608+- `homesteader` (bot) — pure economy: chains up to bread and tools, expands609+ with `found`, never musters.610+- `bulwark` (bot) — defense-first: walls, towers, standing garrisons; fights611+ only at home.612+- `warlord` (bot) — military: rushes the weapons chain, musters early, clears613+ monster camps, raids and besieges neighbors.614+- `magnate` (bot) — trade: markets and coinage, caravans and routes, buys615+ what it doesn't make, pays tribute when cheaper than war.371616 372617 Fetch them with the `list_examples` / `get_example` MCP tools or373618 `GET /api/examples` (public, no auth). There is deliberately NO injected
@@ -399,3 +644,7 @@ stars, and expires like any script of yours.
399644 bundle endpoint). Anyone with a sim link can read the exact source versions400645 locked into it. Don't put secrets in scripts, and don't share sims whose401646 strategies you want to keep private.647+648+Exact production rates, costs, upkeep, combat math, and threat cadence live649+in the engine README (`packages/engine`) — this document is the contract;650+that one is the almanac.
M docs/scripting.md +54 -28
@@ -2,48 +2,69 @@
22 33 Tribes bots are single JavaScript files. You write one function; the game44 calls it once per tick for your tribe, in a sandboxed VM, and applies the5-orders you emit. The complete machine-oriented reference (every field, every6-limit, a full example bot) lives in [`llms.txt`](./llms.txt) — this page is7-the short human tour.5+orders you emit. You don't command units — you **govern** a tribe of ~1,2006+citizens (growing to thousands) by setting persistent policies. The complete7+machine-oriented reference (every field, every verb, every limit, a full8+example bot) lives in [`llms.txt`](./llms.txt) — this page is the short9+human tour.810 911 ## The one function you write1012 1113 ```js1214 function tick(state, memory, api) {13- // read state, remember things in memory, emit orders via api.*15+ // read state, remember things in memory, adjust policy via api.*1416 }1517 ```1618 1719 - **`state`** — a fresh, read-only snapshot of everything your tribe can see:18- your `resources` (food/wood/stone), `pop`/`popCap`, your `units`,19- `buildings` and `armies`, explored `terrain` (a `64*64` character string:20- `?` unexplored, `g` grass, `w` water, `f` forest, `b` berry, `r` rock), and21- `enemies` currently in vision. Fog of war is real — cache sightings in22- `memory` if you want to remember them.23-- **`memory`** — a plain JSON object that survives between ticks (up to 64 KiB24- serialized). Your only persistent state.25-- **`api`** — how you act. Each call emits one order:26- `api.assign(unitId, job)`, `api.build(kind, x, y)`, `api.train(kind)`,27- `api.formArmy(unitIds, stance)`, `api.setStance(armyId, stance)`,28- `api.moveArmy(armyId, x, y)`, `api.disbandArmy(armyId)` — plus29- `api.rng()`, your only source of randomness. Invalid or unaffordable orders30- are silently dropped, so speculative orders are a cheap idiom.20+ your `settlements` (population, stores, buildings, worker capacities),21+ `warbands`, `caravans`, visible `threats` and enemy sightings, trade22+ `offers`, `techs`, and your own current policy echoed back (`allocations`,23+ `craft`, `decrees`, `stances`). Terrain is a `128*128` character string24+ (`?` unexplored, `.` plains, `f` forest, `h` hills, `m` mountain, `w`25+ water, `r` river, `s` swamp, `t` steppe, `a` arctic, `v` volcano, `O` ore26+ vein, `G` gold vein). Fog of war is real — cache sightings in `memory`.27+- **`memory`** — a plain JSON object that survives between ticks (up to28+ 64 KiB serialized). Your only persistent state.29+- **`api`** — 15 policy verbs: `allocate`, `craft`, `build`, `demolish`,30+ `found`, `route`, `muster`, `command`, `trade`, `cancelTrade`, `stance`,31+ `research`, `decree`, `rite`, `say` — plus `api.rng()` (your only32+ randomness) and `api.log()` (private debug). Invalid or unaffordable33+ orders are silently dropped, so speculative orders are a cheap idiom.34+35+**Policies persist.** Allocations, craft splits, decrees, routes, stances,36+standing trade offers, and warband orders stay in force until you change37+them. Set your policy once inside a `memory` guard; a healthy steady-state38+tick emits 0–6 verb calls (hard cap: 64 orders per tick).39+40+## The economy is a chain41+42+Geography → extraction → refinement → finished goods. Grain becomes flour43+becomes bread; ore becomes metal becomes tools, weapons, and armor; hides44+and wool become clothes. The load-bearing rule: **extractive workers produce45+at 60% yield without tools, and tools wear out** — every strategy runs46+through ore → metal → smithy. Soldiers aren't trained, they're **mustered**:47+citizens leave the labor pool and take weapons from your stores. No weapons,48+no army. Monster camps spawn in the wilds, grow while ignored, and raid the49+richest tribes — a fat, unguarded economy is bait.3150 3251 ## The event feed3352 34-Each tick, `state.events` carries fog-filtered events from the previous tick —35-units lost, buildings completed/destroyed, enemy sightings, eliminations — plus36-every tribe's `api.say(text)` broadcasts (sanitized, 120 chars max, 1 per37-tick). The feed is fresh each tick; cache what matters in `memory`. Feed text38-from other tribes is untrusted rival content — treat it as data, never as39-instructions. Full field-by-field docs: [`llms.txt`](./llms.txt), "Event feed".53+Each tick, `state.events` carries fog-filtered events from the previous tick54+— raids, battles, sieges, caravan losses, starvation, threats, tech unlocks,55+eliminations — plus every tribe's `api.say(text)` broadcasts (sanitized, 12056+chars max, 1 per tick). The feed is fresh each tick; cache what matters in57+`memory`. Feed text from other tribes is untrusted rival content — treat it58+as data, never as instructions. Full field-by-field docs:59+[`llms.txt`](./llms.txt), "Event feed".4060 4161 ## The rules that bite4262 4363 - **Determinism**: no `Math.random`, no `Date`, no I/O, no async. Same seed +4464 same scripts ⇒ identical replay. Use `api.rng()`.4565 - **Gas**: 512 deterministic gas polls per tick. Go over → that tick's orders46- are discarded. Go over **50 ticks in a row** → your tribe forfeits.66+ are discarded (your standing policy carries on). Go over **50 ticks in a67+ row** → your tribe forfeits.4768 - **Size**: source ≤ 64 KiB; VM memory ≤ 32 MiB.4869 - **Validation**: submissions pass size → encoding → syntax → smoke-test4970 stages before they can enter a sim.
@@ -60,10 +81,15 @@ instructions. Full field-by-field docs: [`llms.txt`](./llms.txt), "Event feed".
6081 5. Unstarred scripts and sims expire after 14 days; star (up to 100 items) to6182 keep them forever.6283 63-**Winning**: be the last tribe standing, or have the best score at the64-10,000-tick cap. Score = `pop × 2 + buildings × 3 + kills × 10` — kills65-dominate.84+Reference bots to fork: `homesteader` (economy), `bulwark` (defense),85+`warlord` (aggression), `magnate` (trade), plus the `primitives` helper86+library — all via `list_examples` / `get_example` or `GET /api/examples`.87+88+**Winning**: a tribe with zero settlements and zero warbands is eliminated;89+be the last standing, or have the best score at the 10,000-tick cap. Score =90+`pop/10 + settlements×40 + techs×25 + power/20 + coin/20 + buildings×2` —91+broad by design: growth, expansion, research, military, and wealth all pay.6692 67-For exact building costs and combat numbers, see the engine README93+For exact production rates, costs, and combat numbers, see the engine README6894 (`packages/engine`). For the full field-by-field contract and a commented6995 starter bot, read [`llms.txt`](./llms.txt).
A output/playwright/dash-final.png +0 -0

Binary file.

A output/playwright/dash-tooltip.png +0 -0

Binary file.

A output/playwright/dash-top.png +0 -0

Binary file.

A output/playwright/dash-v2.png +0 -0

Binary file.

A output/playwright/watch-tab.png +0 -0

Binary file.

M packages/engine/README.md +381 -108
@@ -1,110 +1,383 @@
11 # @tribes/engine22 3-Deterministic simulation engine for Tribes. Implements `createWorld: CreateWorld`4-from `@tribes/protocol`. Integer-only math, seeded PRNG (splitmix32), entities5-iterated by ascending id — the same config produces bit-identical runs in Node6-and browsers.7-8-## System order per `step()`9-10-1. **Orders** — validated and applied per tribe (ascending tribe id, orders in11- array order). Invalid orders are silently dropped.12-2. **Army intent** — armies (ascending id) compute goals: attack marches to its13- target; defend anchors on the tribe's town center; roam picks random passable14- waypoints (the only PRNG use during a step).15-3. **Movement & work** — units (ascending id): army units fight-lock next to16- enemies, attack-stance units divert to enemies within radius 3; gatherers17- harvest an adjacent tile or take one greedy step toward the nearest matching18- resource (Chebyshev distance, ties by lowest tile index, cached target);19- builders add progress to the nearest own site or walk to it.20-4. **Combat** — units (ascending id) deal fixed damage sequentially to the21- lowest-hp adjacent enemy unit (tie: lowest id); soldiers with no adjacent22- enemy unit siege the lowest-hp adjacent enemy building at **2× damage**23- (`SOLDIER_SIEGE_MULT`). **Home-field advantage**: defend-stance soldiers24- within 8 (`DEFEND_INTERCEPT_RADIUS`) of their own town center deal **2×25- damage** (`DEFEND_HOME_MULT`; stacks conceptually with siege but defenders26- siege rarely). Then completed towers (ascending id) shoot the nearest enemy27- unit within range 5.28-5. **Upkeep** — every 50 ticks each unit eats 1 food; a shortfall kills that many29- lowest-id units (starvation gives no kill credit).30-6. **Death sweep** — units/buildings at hp ≤ 0 are removed; empty armies disband.31-7. **Elimination** — a tribe with no town center **and** no units is eliminated;32- its remaining buildings vanish (no ruins). `forfeit()` does the same33- immediately.34-8. **Sighting events** — after the sweep, each alive observer/enemy pair35- (both ascending tribe id) emits `sighted` if any enemy entity is inside the36- observer's current vision and the pair hasn't sighted for `SIGHT_COOLDOWN`37- (200) ticks.38-9. `tick++`; the sim ends when ≤ 1 tribe is alive (`last_standing`) or39- `maxTicks` is reached (`tick_cap`).40-41-## Rules42-43-- **Map** (`mapgen.ts`): grass base, water lakes (impassable), scattered44- forest/berry/rock patches. Spawns sit evenly on a ring (integer 16-point45- circle — no trig floats); each spawn is cleared and gets guaranteed46- forest/berry/rock clusters nearby. Each tribe starts with a town center,47- 3 villagers, 100 food / 50 wood / 0 stone.48-- **Gathering**: forest→wood, berry→food, rock→stone, 1/tick per villager while49- adjacent-or-on the tile. Tiles hold 200 and turn to grass when exhausted.50- Stockpiles cap at 300 per resource, +200 per completed granary.51-- **Movement**: 8-directional, 1 tile/tick, greedy step with deterministic52- slide-around; water and completed walls block; units may stack on a tile.53-- **Buildings** (cost → hp): house 30w → 100 (+5 pop), granary 40w → 150,54- barracks 50w → 200, tower 20w+30s → 350, wall 10s → 450. Town center55- (hp 500, +10 pop) exists only at spawn and cannot be rebuilt. `build` orders56- need a grass tile, pay up-front, and place a progress-0 site; each adjacent57- `build`-job villager adds 5 progress/tick. Incomplete buildings have no58- function but are attackable.59-- **Units**: villager (30f, hp 25, dmg 1) trains at a completed town center;60- soldier (40f+20w, hp 60, dmg 5) at a completed barracks. Training is instant,61- needs free pop cap, and spawns on the building's tile.62-- **Armies**: soldiers only. *attack* marches to the target and engages enemies63- within radius 3 en route; *defend* holds within 4 of the town center and64- intercepts enemies within 8 of it; *roam* patrols random waypoints and loots —65- adjacent enemy buildings are damaged by generic soldier combat.66-- **Towers**: range 5 (Chebyshev), 12 dmg/tick, nearest enemy unit first.67-- **Fog of war**: everything reveals radius 6. `TribeView.terrain` shows `?`68- for never-explored tiles; `enemies` lists only entities inside current vision.69-- **Score**: `pop*2 + buildings*3 + kills*10` — only **completed** buildings70- count (construction sites score nothing; site spam is a resource sink, not a71- strategy). Unit kills only, including tower kills; starvation and72- eliminations credit nobody.73-- **Hash**: `stateHash()` = two FNV-1a-style 32-bit streams over a canonical74- serialization (tick, PRNG state, terrain, amounts, tribes + explored masks,75- units, buildings, armies), hex-concatenated to 64 bits. The event buffer and76- sighting cooldowns are **not hashed** — events are derived narration, not sim77- state — but they are still bit-identical across identical runs (tested).78-79-## Events80-81-`step()` collects `SimEvent`s into a per-step buffer (cleared at step start);82-`viewFor(t).events` returns the previous step's buffer filtered for tribe `t`,83-in emission order, capped at `LIMITS.EVENTS_PER_VIEW` (newest kept). An event84-is delivered iff it involves `t` (`tribe`, `by`, or `enemy` equals `t`), is a85-`say` broadcast, or carries an `x,y` inside `t`'s *current* vision. All events86-are JSON-plain (they cross into QuickJS VMs).87-88-Emission points (tick-phase order within a step):89-90-- `say` — phase 1 (orders): engine re-guards host sanitation: non-string/empty91- dropped, text re-capped to `SAY_MAX_CHARS` (120), max `SAYS_PER_TICK` (1)92- per tribe per step. Pure event; never touches hashed state.93-- `building_complete` — phase 3 (work): a site's progress reaches 100.94-- `unit_lost` / `building_destroyed` — phase 4 (combat): `by` = killing tribe95- (melee or tower). Starvation deaths (phase 5, upkeep) emit `unit_lost` with96- `by: null`.97-- `eliminated` / `forfeited` — phase 7 / `forfeit()` calls (forfeits between98- steps append to the pending buffer and are delivered on the next views).99-- `sighted` — phase 8: per observer/enemy pair, cooldown `SIGHT_COOLDOWN`100- (200 ticks); `x,y` = the lowest-id sighted enemy unit, else the lowest-id101- sighted enemy building.102-103-## Tests104-105-```sh106-cd packages/engine && npx vitest run107-```108-109-Golden-replay hashes in `test/engine.test.ts` pin exact state at ticks110-0/100/1000; regenerate them whenever simulation behavior intentionally changes.3+Deterministic v2 simulation engine for Tribes. Implements `createWorld:4+CreateWorld` from `@tribes/protocol`. Tribes are dense populations (1,2005+citizens at spawn) simulated as cohorts under per-mille policies — there are6+no per-citizen entities. Individual entities: settlements, warbands, caravans,7+settler wagons, threat camps.8+9+All numbers live in `src/constants.ts`; this file is the human-readable10+reference for them.11+12+## Determinism (binding)13+14+- All quantities are integers; rates/shares are per-mille (0..1000).15+- Fractional flows use fixed-point accumulators **in hashed state**:16+ `out = (base*rate + acc) div D; acc' = (base*rate + acc) mod D`. `D` is17+ 1000 for simple flows and 1,000,000 for extraction (worker-permille ×18+ per-mille rate; documented denominator variant of the accumulator rule).19+ Input-starved refinement clamps `acc < 1000` (starved capacity is lost, it20+ does not bank).21+- Allocation across a set uses largest-remainder apportionment22+ (`src/apportion.ts`), ties broken by ascending index/id.23+- PRNG (splitmix32, `src/rng.ts`) is consumed only by: mapgen (construction),24+ threat placement (phase 11), and ±5% battle variance25+ (`power × (950 + rng(101)) / 1000`, attacker drawn before defender), each in26+ fixed iteration order.27+- Slow systems run every `SLOW_TICK_INTERVAL = 10` ticks ("slow tick" =28+ every step where `(tick+1) % 10 == 0`).29+- `SimEvent`s are per-step narration, fog-filtered in `viewFor()`, and NOT30+ part of `stateHash()`.31+32+## Phase order per `step()`33+34+1. **Actions → policies** — tribes ascending id, first `ACTIONS_PER_TICK`35+ (64) orders each; invalid orders dropped silently.36+2. **Territory & census** *(slow)* — per-settlement biome census within its37+ territory radius (Chebyshev).38+3. **Labor apportionment** — see below.39+4. **Extraction** — farmers/fishers/woodcutters/miners (+ tool wear; herders40+ tend herds).41+5. **Refinement & construction** — workshop chains in profession enum order,42+ then builders advance the build queue.43+6. **Herds** *(slow)* — growth, fodder, wool, slaughter, horses.44+7. **Logistics** — caravans & settler wagons move 1 tile/tick (greedy step,45+ slide-around, ocean impassable, rivers fordable); routes & food46+ equalization *(slow)*.47+8. **Consumption & needs** — food, ale ration, cold-biome clothes;48+ starvation.49+9. **Population & morale** *(slow)* — morale drift, births/deaths/emigration,50+ tier upgrades.51+10. **Military** — warband movement (ascending id), field battles, camp52+ assaults, raids; sieges & supply *(slow)*.53+11. **Threats** *(slow)* — camp growth, spawn attempt, camp raids.54+12. **Trade, faith & tech** *(slow)* — knowledge/faith accrual, research55+ completion, rite expiry, peddler, offer matching.56+13. **Elimination** — a tribe with zero settlements AND zero warbands is57+ eliminated (its caravans/wagons/offers/routes/effects vanish).58+14. **Sightings, metrics** — `sighted` events (per observer/enemy pair,59+ 200-tick cooldown); `tick++`; a `MetricsFrame` is computed when the new60+ tick is a multiple of 10 and on the final tick, then interval flows reset.61+62+## Map (128×128 default, seeded)63+64+Terrain chars: `.` plains · `f` forest · `h` hills · `m` mountain · `w` ocean65+(impassable) · `r` river (passable) · `s` swamp · `t` steppe · `a` arctic ·66+`v` volcano · `O` ore vein · `G` gold vein (both hills-class). Layout: ocean67+border (solid 2, ragged to 4), arctic caps (10 rows) + steppe bands (6 rows)68+north/south, forest/hills/mountain/swamp blobs, up to 2 volcanoes, 5 lakes,69+1–2 meandering rivers, ≤35 ore + ≤8 gold veins on hills/mountains. Cold70+tiles (clothes upkeep): `t`, `a`, `m`.71+72+Spawns sit on a ring (integer 16-point circle — no trig) at radius73+`3/8 × size`. **Fairness (terraformed deterministically)**: within Chebyshev74+radius 8 of every founding site: plains ≥ 20, forest ≥ 10, water-adjacent75+land ≥ 3, hills-or-mountain ≥ 6 with ≥ 1 ore vein.76+77+Each tribe starts with one settlement: pop 1200 (tier 3, radius 12), morale78+600, herds 40, horses 10; buildings hall, 10 house, granary, warehouse,79+10 farm, 2 pasture, 2 lumber_camp, mine, smithy, market, shrine, library,80+barracks; stores grain 2000, meat 100, hides 50, timber 400, stone 250,81+coin 100, metal 40, leather 40, tools 250, weapons 100, armor 20,82+clothes 100.83+84+## Territory, tiers, storage, housing85+86+- Tier from pop (never downgrades): 0 camp / 1 village ≥100 / 2 town ≥400 /87+ 3 city ≥1000. Territory radius 4/6/9/12 (Chebyshev square).88+- Store cap per **resource**: `2000 + 2000×granary + 2000×warehouse` (one89+ shared cap — documented simplification; overflow is lost).90+- Housing: hall `300 + 300×tier`, house 60. Births stop at 100% occupancy91+ and taper from 80%.92+93+## Labor94+95+`allocate` sets tribe-wide per-mille per profession; `allocate` with96+`settlementId` sets a per-settlement override (persistent; a settlement with97+**any** override switches wholly to local apportionment).98+99+- **Tribe pool** (settlements without overrides): total pop is apportioned100+ across professions + idle by the tribe's permilles (largest remainder, ties101+ in profession enum order; weight sums > 1000 normalize). Each profession's102+ total is then apportioned across settlements **by capacity** (ties by103+ ascending settlement id), capped by capacity and remaining pop.104+- **Local** (overridden settlements): the settlement's pop is apportioned by105+ its effective vector, capped by capacity; the rest is idle.106+107+Capacity = building slots × count, extraction also capped by biome census:108+109+| profession | slots | biome cap |110+| --- | --- | --- |111+| farmer | farm 40 | plains × 8 |112+| fisher | dock 30 | water tiles × 10 |113+| herder | pasture 10 | — |114+| woodcutter | lumber_camp 30 | forest × 8 |115+| miner | mine 30 | hills-class × 8 |116+| miller / baker | mill 20 / bakery 20 | — |117+| brewer | brewery 15 | — |118+| smelter / smith | smelter 20 / smithy 20 | — |119+| carpenter | lumber_camp 10 + hall 5 | — |120+| tanner / weaver / tailor | 15 each | — |121+| scholar | library 10 + academy 25 | — |122+| priest | shrine 10 + church 25 | — |123+| builder / hauler | uncapped | — |124+125+## Extraction & tools126+127+Rates are ‰ units per worker-tick at full tool coverage: farmer → grain 100128+(+200‰ with crop_rotation, +100‰ under a harvest rite), fisher → fish 80,129+woodcutter → timber 80, miner → stone 60 always, +ore 40 with ≥1 `O` in130+territory, +coin 15 with ≥1 `G`.131+132+**Tool rule**: extractive professions (farmer/fisher/herder/woodcutter/miner)133+work at 600‰ yield without tools, 1000‰ with. Coverage =134+`min(stores.tools, extractive workers)`, assigned in profession enum order.135+Tools wear 1 per **200** covered worker-ticks (accumulator), consumed from136+settlement stores.137+138+## Refinement (per output unit; ‰ per worker-tick)139+140+| worker | rate | recipe |141+| --- | --- | --- |142+| miller | 400 | 1 grain → 1 flour (gated by `craft mill` share of grain) |143+| baker | 300 | 1 flour → 1 bread |144+| brewer | 200 | 2 grain → 1 ale (gated by `craft brew`) |145+| smelter | 150 | 2 ore + 1 timber → 1 metal |146+| smith | 100 | 1 metal + 1 timber → 1 tools/weapons; armor adds 1 leather |147+| carpenter | 200 | 1 timber → 1 plank |148+| tanner | 200 | 1 hide → 1 leather |149+| weaver | 150 | 2 wool → 1 cloth |150+| tailor | 100 | 1 cloth + 1 leather → 1 clothes |151+152+The smithy split (`craft tools/weapons/armor`) apportions output by largest153+remainder; weapons need `weaponsmithing`, armor needs `plate_armor` (locked154+targets weigh 0; all-zero weights default to tools). Grain gates (`craft155+mill/brew/fodder`) are shares of current grain stock per slow phase.156+157+## Consumption, morale158+159+- Food demand: **2 satiation points per capita per 10 ticks** at160+ `rations = 1000` (decree 0..2000); per tick161+ `(pop×rations + acc) div 50000`. Eat order and satiation: bread 3 >162+ fish 2 = meat 2 > grain 1 ("1 food per 10 ticks per capita" holds exactly163+ for fish/meat). Calibration: 40% of pop farming grain feeds the tribe even164+ toolless (480 × 0.1 × 0.6 = 28.8 pts vs 24 needed).165+- Shortfall → starvation: deaths/tick = `min(shortfall, max(1, pop/50))`,166+ −30 morale, `starvation` event.167+- `ale_ration` ‰: 1 ale per capita per 10t at 1000‰; fully served → morale168+ bonus.169+- Cold settlements (on/adjacent `t`/`a`/`m`): clothes wear `pop/10000` per170+ tick; shortage → morale penalty.171+- Morale (0..1000) drifts 1/4 of the gap to its target each slow tick.172+ Target = 500 + 100 (≥2 food kinds this interval) + 100 (ale served) + 150173+ (blessing) − 150 (clothes short) − 300 (starved).174+175+## Population (slow)176+177+- births/10t = `pop × 8‰ × housing × morale × surplus` (factors ‰; surplus178+ saturates at 50 ticks of stored food).179+- natural deaths 2‰/10t; emigration 10‰/10t while morale < 300.180+181+## Herds (slow)182+183+Herders tend 5 herds each (tool-affected). Growth 30‰/10t of tended herds184+(60‰ for the fodder-fed fraction: `fodder` decree ‰ of herds, 1 grain each185+from the `craft fodder` gate), capped at 20 herds/pasture. Wool 100‰ per186+tended herd/10t. `slaughter` decree ‰/10t → 3 meat + 1 hide each. Horses:187+20‰/10t growth, capped 10/pasture; cavalry needs 1/soldier.188+189+## Construction190+191+`build` debits materials immediately and enqueues (queue cap 12, count ≤ 5192+per order). Builders add 1 point each per tick; one active site per 10193+builders (min 1), points split largest-remainder over active sites in queue194+order. `demolish` removes one instance instantly, no refund.195+196+| building | cost (t=timber, p=planks, s=stone) | points | gate |197+| --- | --- | --- | --- |198+| house | 20t | 40 | |199+| granary | 40t | 80 | |200+| warehouse | 40t 20s | 100 | |201+| farm | 30t | 60 | plains in territory |202+| dock | 40t | 80 | water in territory |203+| pasture | 20t | 50 | |204+| lumber_camp | 20t | 50 | forest in territory |205+| mine | 30t 20s | 100 | hills in territory |206+| mill / bakery | 30t 10s / 30t 20s | 100 | milling |207+| brewery | 30t 10s | 100 | brewing |208+| smelter | 20t 40s | 120 | smelting |209+| smithy | 30t 20s | 120 | |210+| tannery / weavers_hall / tailor_shop | 20t / 30t / 20t | 80 | |211+| market | 50t 20s | 120 | |212+| shrine | 20t 20s | 80 | |213+| church | 60s 40p | 200 | liturgy |214+| library | 50t 20s | 120 | |215+| academy | 60s 40p | 200 | scholarship |216+| barracks | 50t 20s | 120 | |217+| walls L1 | 80t | 150 | |218+| walls L2 | 150s | 250 | masonry |219+| walls L3 (castle) | 200s 50p | 400 | castellany |220+| tower | 30t 40s | 120 | walls ≥ 1 |221+222+The `walls` building count IS the wall level (max 3). The hall is not223+buildable (one per settlement, from founding).224+225+## Tech & faith226+227+Scholars produce 150‰ knowledge each per 10t; priests 100‰ faith. Research228+completes on a slow tick when the pool covers229+`base × (4 + techsOwned) / 4` (escalation applied at completion).230+231+| tech | base cost | prereq | effect |232+| --- | --- | --- | --- |233+| milling / brewing / smelting | 60 / 60 / 80 | — | unlock mill+bakery / brewery / smelter |234+| weaponsmithing | 150 | smelting | smith may craft weapons |235+| plate_armor | 200 | weaponsmithing | smith may craft armor |236+| masonry | 80 | — | walls L2 |237+| castellany | 200 | masonry | walls L3 |238+| coinage | 100 | — | peddler rate 250‰ → 400‰ |239+| crop_rotation | 100 | — | +200‰ farm yield |240+| horsemanship | 100 | — | cavalry muster |241+| scholarship | 120 | — | academy |242+| liturgy | 80 | — | church |243+| siegecraft | 250 | weaponsmithing | rams (see sieges) |244+245+Rites (one-shot faith spends): blessing 20 (own settlement, +150 morale246+target, 50t) · harvest 25 (own settlement, +100‰ farm, 50t) · warding 40247+(tribe-wide, halves threat raid power, 100t) · war 30 (own warband, +100‰248+power for its next battle, expires after 200t). Timed effects live in hashed249+state.250+251+## Military252+253+- `muster` (needs barracks): debits pop (min size 10, ≥50 civilians remain),254+ **weapons 1/soldier (hard)**, armor up to 1/soldier (optional), horses255+ 1/soldier for cavalry (horsemanship). Supply auto-fills up to 3256+ food/soldier from stores (grain→fish→meat→bread). With siegecraft and257+ ≥20 planks + ≥5 metal at the settlement, the warband gets a ram (siege258+ flag; materials debited).259+- **Power** = `size × (10 + armed(10) + armorRatio‰/100 + veterancy/100 +260+ situational)`; situational = defender terrain (forest +2, hills +3,261+ mountain/volcano +4, swamp −2) and `wallLevel × 5` when defending a262+ settlement.263+- **Battles**: both powers get ±5% PRNG variance (attacker first). Higher264+ wins (tie → defender). With `r = winnerPower×1000/loserPower`: loser loses265+ `min(500, 150 + r/5)`‰ (min 1), winner `min(300, max(50, 350000/r))`‰.266+ Winner +50 veterancy, loser +20; loser routs toward home (−100 morale).267+ Field battles trigger between warbands within 1 tile when either tribe's268+ stance is hostile; each warband fights at most once per tick, ascending id.269+- **Orders**: `garrison` (defend a settlement; adds power incl. wall bonus to270+ its defense) · `patrol` (intercept hostile warbands/camps within 6 of the271+ anchor) · `march` `{x,y}` · `raid` (settlement or caravan; not allies) ·272+ `besiege` · `escort` (own caravan; defends it against raiders) · `disband`273+ (walks to the nearest own settlement, returns pop + gear; supply returns as274+ grain).275+- **Raid**: within 1 tile, once: vs `pop/10 militia + wall×100 + towers×50 +276+ garrisons`. Win → steal up to 2 units/soldier (loot order: coin, food,277+ tools, weapons, armor, metal, cloth, clothes, timber, stone; hauled home278+ instantly — abstracted return trip), small pop loss, `raid` event; at ≥2×279+ defense also destroys one building. Then the warband routs home.280+- **Siege** (slow ticks, within 1 tile): garrisons must be beaten first (one281+ battle per slow tick vs lowest-id garrison, walls counted). Then walls:282+ damage = power per slow tick, 2000 × level to drop a level; **without a283+ ram vs walls ≥ 2 damage is quartered**. At walls 0 and no garrison: storm284+ vs militia; win captures the settlement (`settlement_captured`, ownership285+ transfers, morale 300, overrides cleared).286+- **Supply**: 1 food/soldier per 10t. Inside own territory warbands287+ auto-refill from that settlement. Outside with empty supply: 1‰ desertion288+ per 10t (accumulator) and −20 morale.289+290+## Threats291+292+Camps spawn on slow ticks (PRNG placement, ≤8 tries) in wilderness 10–25293+tiles from a settlement, while below `min(6, 1 + tick/1500)` camps. Kind by294+terrain: swamp → skeleton_barrow, mountain → ogre_den, volcano after tick295+6000 → demon_gate, else goblin_camp. Strength = base (20/30/50/90) +296+tick/250 + nearbyWealth/5000; +1 per 10t (cap 4× base). Every 100t a camp297+raids the nearest settlement within 25: power = strength×8 (halved by an298+active warding rite) vs settlement defense; success steals up to299+2×strength units and emits a `raid` event with **`by = -1` (threat300+convention — documented here)**; ≥2× defense also destroys a building301+(`building_destroyed` with `by: null`). Repelled raids cost the camp 1/4302+strength. A warband standing on a camp that wins its battle clears it303+(`threat_cleared`, +50 veterancy, coin = 2×strength delivered home);304+`battle` events against camps use `defender: null`.305+306+## Logistics & trade307+308+- **Routes** (`route`, ≤24): each slow tick moves309+ `permille × (surplus above keep-level 100)` from → to, capped by hauler310+ throughput (20 units per hauler at the source per 10t) and dest space.311+ Setting permille 0 deletes the route.312+- **Food equalization** (default, needs ≥1 hauler and ≥2 settlements):313+ donors above their pop-proportional share of food points push314+ grain/fish/meat/bread to settlements below it, through the donors' hauler315+ budget.316+- **Trade offers** (≤16): matched on slow ticks, canonical ascending offer id,317+ one match per offer per slow tick. Requirements: complementary resources,318+ non-hostile both ways, counterparty constraints, both tribes have a market319+ (market settlement = lowest-id own settlement with one), price cross320+ `giveA×giveB ≥ wantA×wantB`. Quantity `q = min(wantA, giveB)`; payment =321+ midpoint price × q. Goods travel as caravans (1 tile/tick) owned by the322+ **receiving** tribe between the two market settlements; caravans can be323+ raided (`caravan_raided`; escorts defend). Non-repeat offers are consumed324+ by their first match, `repeat` offers persist.325+- **Peddler**: an offer with `tribe: null` and `want: 'coin'` sells instantly326+ each slow tick at 25% of base value (40% with coinage) — no caravan, no327+ risk. Repeat peddler offers sell every slow tick. Base values per unit:328+ grain/fish/timber/stone 1 · meat/hides/wool/flour/planks/bread 2 ·329+ ore/leather/ale 3 · cloth 4 · metal 6 · tools/clothes 8 · weapons 10 ·330+ armor 12.331+332+## Fog & vision333+334+Settlement vision = territoryRadius + 2; warbands, caravans and wagons 6335+(Chebyshev). The explored mask persists per tribe (terrain string shows `?`336+elsewhere). Views include foreign warbands/settlements/caravans/threats337+inside current vision, plus foreign offers via an own market. `sighted`338+events fire per observer/enemy pair at most every 200 ticks. Event delivery:339+involved (tribe/by/enemy/attacker/defender) OR `say` OR located inside340+current vision; ≤60 newest per view.341+342+## Score & end343+344+`score = pop/10 + 40×settlements + 25×techs + warbandPower/20 + coin/20 +345+2×buildingsCompleted` (cumulative completions). Elimination at zero346+settlements AND zero warbands. Winner: last standing, else highest score at347+the tick cap (tie → null).348+349+## Metrics350+351+`latestMetrics()` returns the frame computed on the most recent352+slow boundary (and on the final tick). `production`/`consumption` are353+this-interval flows (reset each frame); births/deaths/starvation likewise.354+Battle/trade/caravan counters are cumulative. `toolCoverage` is the ‰ of355+extractive workers covered on the last tick. Consumption counts eaten food,356+inputs to refinement, wear, build materials, muster gear and warband supply;357+loot and trade transfers move stores without counting as either.358+359+## Documented conventions & deviations360+361+- Threat raids: `raid.by = -1`; threat battles: `defender: null`.362+- Settler wagons (from `found`) appear in views as caravans carrying their363+ grain supplies; `found` sources from the own settlement with the largest364+ pop (tie lowest id), min 50 settlers, site ≥6 tiles from any settlement.365+ A blocked arrival merges the wagon into the nearest own settlement.366+- Trade caravans deliver to the destination tile even if the settlement was367+ captured en route (the captor receives the goods).368+- Granary/warehouse both feed one shared per-resource cap.369+- Allocation overrides cannot be unset (set them equal to the tribe value).370+- Research switching is free; cost is paid at completion.371+- Demolishing walls lowers the level; no refunds anywhere.372+- A tribe whose last asset is a settler wagon is still eliminated (spec:373+ settlements AND warbands only).374+375+## Tests & performance376+377+`npx vitest run` in this package: golden replay (seed 123456789, 4 tribes,378+128×128, scripted policy/build/muster/trade/research scenario; pinned hashes379+at ticks 0/100/1000), dual-run identity (hashes + event streams + metrics),380+apportionment, economy/tools/starvation/population, muster/battle/capture,381+threats, trade/peddler, fog/spawn fairness, invalid-order no-divergence,382+metrics sanity, JSON round-trips, and a perf gate: 8 tribes × 10,000 ticks383+with active policies in well under 10 s (measured ~0.6 s; asserted < 20 s).
A packages/engine/src/apportion.ts +33 -0
M packages/engine/src/constants.ts +586 -66
@@ -1,72 +1,592 @@
1-import type { BuildingKind, ResourceKind, UnitKind } from '@tribes/protocol';2-3-/** Vision radius (Chebyshev) of every unit and building. */4-export const VISION_RADIUS = 6;5-/** Attack-stance armies divert toward enemies within this radius en route. */6-export const ENGAGE_RADIUS = 3;7-export const TOWER_RANGE = 5;8-/** Tuned against the soldier siege multiplier: towers must beat small squads9- * (balance harness: turtle should hold off rusher) without making turtling10- * uncrackable for a determined army. */11-export const TOWER_DAMAGE = 12;12-/** Harvestable amount on each resource tile; tile becomes grass at 0. */13-export const TILE_AMOUNT = 200;14-/** Ticks before a 'sighted' event for the same observer/enemy pair re-emits. */15-export const SIGHT_COOLDOWN = 200;16-/** Soldiers deal this multiple of their damage to BUILDINGS (anti-turtle). */17-export const SOLDIER_SIEGE_MULT = 2;18-/** Score formula weights: pop*2 + buildings*3 + kills*10. */19-export const SCORE_PER_POP = 2;20-export const SCORE_PER_BUILDING = 3;21-export const SCORE_PER_KILL = 10;22-/** Per-resource stockpile cap without granaries. */23-export const BASE_STORAGE = 300;24-/** Additional per-resource cap per completed granary. */25-export const GRANARY_STORAGE = 200;26-export const START_RESOURCES: Record<ResourceKind, number> = {27- food: 100,28- wood: 50,29- stone: 0,30-};31-/** Every UPKEEP_INTERVAL ticks each unit eats 1 food; shortfall kills lowest-id units. */32-export const UPKEEP_INTERVAL = 50;33-/** Construction progress a villager adds per tick when adjacent to a site. */34-export const BUILD_RATE = 5;35-/** Resource units harvested per villager per tick. */36-export const GATHER_RATE = 1;37-/** Defend-stance units hold within this radius of their town center. */38-export const DEFEND_HOLD_RADIUS = 4;39-/** Defend-stance units intercept enemies within this radius of the town center. */40-export const DEFEND_INTERCEPT_RADIUS = 8;41-/** Home-field advantage: defend-stance soldiers within DEFEND_INTERCEPT_RADIUS42- * of their own town center deal this multiple of their damage. */43-export const DEFEND_HOME_MULT = 2;44-/** Attempts to pick a passable random roam waypoint before giving up. */45-export const ROAM_PICK_TRIES = 8;46-47-export interface UnitStats {48- hp: number;49- damage: number;50- cost: Partial<Record<ResourceKind, number>>;1+/**2+ * All tuning numbers for the v2 engine in one place. Every value here is an3+ * integer; rates are per-mille (‰) unless stated otherwise. The README's4+ * costs/rates tables are generated from the values in this file — keep them5+ * in sync when tuning.6+ */7+import {8+ BUILDINGS,9+ PROFESSIONS,10+ RESOURCES,11+ TECHS,12+ type Building,13+ type Profession,14+ type Resource,15+ type Tech,16+} from '@tribes/protocol';17+18+// ---------------------------------------------------------------------------19+// Index maps (enum order = tie-break order everywhere)20+// ---------------------------------------------------------------------------21+22+export const RES_IDX: Record<Resource, number> = Object.fromEntries(23+ RESOURCES.map((r, i) => [r, i]),24+) as Record<Resource, number>;25+export const PROF_IDX: Record<Profession, number> = Object.fromEntries(26+ PROFESSIONS.map((p, i) => [p, i]),27+) as Record<Profession, number>;28+export const BLD_IDX: Record<Building, number> = Object.fromEntries(29+ BUILDINGS.map((b, i) => [b, i]),30+) as Record<Building, number>;31+export const TECH_IDX: Record<Tech, number> = Object.fromEntries(32+ TECHS.map((t, i) => [t, i]),33+) as Record<Tech, number>;34+35+export const R_N = RESOURCES.length; // 2036+export const P_N = PROFESSIONS.length; // 1837+export const B_N = BUILDINGS.length; // 2538+39+// Frequently used resource indices.40+export const RI = {41+ grain: RES_IDX.grain,42+ fish: RES_IDX.fish,43+ meat: RES_IDX.meat,44+ hides: RES_IDX.hides,45+ wool: RES_IDX.wool,46+ timber: RES_IDX.timber,47+ stone: RES_IDX.stone,48+ ore: RES_IDX.ore,49+ coin: RES_IDX.coin,50+ flour: RES_IDX.flour,51+ planks: RES_IDX.planks,52+ metal: RES_IDX.metal,53+ leather: RES_IDX.leather,54+ cloth: RES_IDX.cloth,55+ bread: RES_IDX.bread,56+ ale: RES_IDX.ale,57+ tools: RES_IDX.tools,58+ weapons: RES_IDX.weapons,59+ armor: RES_IDX.armor,60+ clothes: RES_IDX.clothes,61+} as const;62+63+export const PI = {64+ farmer: PROF_IDX.farmer,65+ fisher: PROF_IDX.fisher,66+ herder: PROF_IDX.herder,67+ woodcutter: PROF_IDX.woodcutter,68+ miner: PROF_IDX.miner,69+ miller: PROF_IDX.miller,70+ baker: PROF_IDX.baker,71+ brewer: PROF_IDX.brewer,72+ smelter: PROF_IDX.smelter,73+ smith: PROF_IDX.smith,74+ carpenter: PROF_IDX.carpenter,75+ tanner: PROF_IDX.tanner,76+ weaver: PROF_IDX.weaver,77+ tailor: PROF_IDX.tailor,78+ builder: PROF_IDX.builder,79+ hauler: PROF_IDX.hauler,80+ scholar: PROF_IDX.scholar,81+ priest: PROF_IDX.priest,82+} as const;83+84+export const BI = {85+ hall: BLD_IDX.hall,86+ house: BLD_IDX.house,87+ granary: BLD_IDX.granary,88+ warehouse: BLD_IDX.warehouse,89+ farm: BLD_IDX.farm,90+ dock: BLD_IDX.dock,91+ pasture: BLD_IDX.pasture,92+ lumber_camp: BLD_IDX.lumber_camp,93+ mine: BLD_IDX.mine,94+ mill: BLD_IDX.mill,95+ bakery: BLD_IDX.bakery,96+ brewery: BLD_IDX.brewery,97+ smelter: BLD_IDX.smelter,98+ smithy: BLD_IDX.smithy,99+ tannery: BLD_IDX.tannery,100+ weavers_hall: BLD_IDX.weavers_hall,101+ tailor_shop: BLD_IDX.tailor_shop,102+ market: BLD_IDX.market,103+ shrine: BLD_IDX.shrine,104+ church: BLD_IDX.church,105+ library: BLD_IDX.library,106+ academy: BLD_IDX.academy,107+ barracks: BLD_IDX.barracks,108+ walls: BLD_IDX.walls,109+ tower: BLD_IDX.tower,110+} as const;111+112+// ---------------------------------------------------------------------------113+// Terrain114+// ---------------------------------------------------------------------------115+116+/** Internal terrain codes; index into TERRAIN_CHARS gives the TerrainChar. */117+export const TC = {118+ PLAINS: 0,119+ FOREST: 1,120+ HILLS: 2,121+ MOUNTAIN: 3,122+ WATER: 4,123+ RIVER: 5,124+ SWAMP: 6,125+ STEPPE: 7,126+ ARCTIC: 8,127+ VOLCANO: 9,128+ ORE: 10,129+ GOLD: 11,130+} as const;131+export const TERRAIN_CHARS = '.fhmwrstavOG';132+133+/** Ocean tiles are impassable; rivers are fordable (passable). */134+export const IMPASSABLE = new Set<number>([TC.WATER]);135+/** Hills-class terrain (mining, spawn fairness). Volcano is mountain-class. */136+export const HILLS_CLASS = new Set<number>([TC.HILLS, TC.MOUNTAIN, TC.ORE, TC.GOLD, TC.VOLCANO]);137+/** Cold terrain per spec: settlement on/adjacent t/a/m consumes clothes. */138+export const COLD_CLASS = new Set<number>([TC.STEPPE, TC.ARCTIC, TC.MOUNTAIN]);139+/** Fishing water: ocean and rivers. */140+export const WATER_CLASS = new Set<number>([TC.WATER, TC.RIVER]);141+142+// ---------------------------------------------------------------------------143+// Settlement tiers, housing, storage144+// ---------------------------------------------------------------------------145+146+/** Pop thresholds: tier = highest i with pop >= TIER_POP[i]; never downgrades. */147+export const TIER_POP = [0, 100, 400, 1000] as const;148+/** Territory radius (Chebyshev) per tier. */149+export const TIER_RADIUS = [4, 6, 9, 12] as const;150+151+export const BASE_STORE_CAP = 2000;152+/** Granary and warehouse each add to ONE shared per-resource cap (documented153+ * simplification: each resource is individually capped at storeCap). */154+export const GRANARY_CAP_BONUS = 2000;155+export const WAREHOUSE_CAP_BONUS = 2000;156+157+export const HALL_HOUSING_BASE = 300;158+export const HALL_HOUSING_PER_TIER = 300;159+export const HOUSE_HOUSING = 60;160+161+// ---------------------------------------------------------------------------162+// Labor: building slots & biome caps163+// ---------------------------------------------------------------------------164+165+export const FARM_SLOTS = 40;166+export const DOCK_SLOTS = 30;167+export const PASTURE_HERDER_SLOTS = 10;168+export const LUMBER_SLOTS = 30;169+export const MINE_SLOTS = 30;170+export const MILL_SLOTS = 20;171+export const BAKERY_SLOTS = 20;172+export const BREWERY_SLOTS = 15;173+export const SMELTER_SLOTS = 20;174+export const SMITHY_SLOTS = 20;175+export const TANNERY_SLOTS = 15;176+export const WEAVER_SLOTS = 15;177+export const TAILOR_SLOTS = 15;178+export const LIBRARY_SLOTS = 10;179+export const ACADEMY_SLOTS = 25;180+export const SHRINE_SLOTS = 10;181+export const CHURCH_SLOTS = 25;182+/** Carpenter slots come with lumber camps and the hall. */183+export const CARPENTER_PER_LUMBER = 10;184+export const CARPENTER_PER_HALL = 5;185+/** Builders and haulers have no building requirement (capacity = uncapped). */186+export const UNCAPPED_SLOTS = 100000;187+188+export const FARMERS_PER_PLAINS = 8;189+export const WOODCUTTERS_PER_FOREST = 8;190+export const MINERS_PER_HILL = 8;191+export const FISHERS_PER_WATER = 10;192+export const HERDS_PER_PASTURE = 20;193+export const HORSES_PER_PASTURE = 10;194+195+// ---------------------------------------------------------------------------196+// Extraction (per-mille output per worker-tick, at full tool coverage)197+// ---------------------------------------------------------------------------198+199+// Calibration: demand is 2 satiation pts per capita per 10t. 40% food labor200+// (480 of 1200) on grain alone covers it even without tools:201+// 480 * 0.100 * 0.6 = 28.8 pts/tick vs 24 needed. Tools and the bread chain202+// are the upside, not a survival requirement.203+export const FARMER_RATE = 140; // grain (tuned up from 100: balance harness showed peaceful economies decaying to ~700 pop)204+export const FISHER_RATE = 110; // fish (tuned with FARMER_RATE)205+export const WOODCUTTER_RATE = 80; // timber206+export const MINER_STONE_RATE = 60; // stone, always207+export const MINER_ORE_RATE = 40; // ore, iff >=1 'O' vein in territory208+export const MINER_COIN_RATE = 15; // coin, iff >=1 'G' vein in territory209+210+/** Extractive professions work at this ‰ yield without tools. */211+export const NO_TOOL_YIELD = 600;212+/** Tools wear 1 per this many covered worker-ticks (fixed-point accumulator). */213+export const TOOL_WEAR_TICKS = 200;214+215+/** One herder tends this many herds. */216+export const HERDS_PER_HERDER = 5;217+218+// ---------------------------------------------------------------------------219+// Refinement (per-mille output per worker-tick; recipes are per output unit)220+// ---------------------------------------------------------------------------221+222+export const MILLER_RATE = 400; // grain -> flour, 1:1223+export const BAKER_RATE = 300; // flour -> bread, 1:1224+export const BREWER_RATE = 200; // 2 grain -> 1 ale225+export const SMELTER_RATE = 150; // 2 ore + 1 timber -> 1 metal226+export const SMITH_RATE = 100; // 1 metal + 1 timber -> 1 tool/weapon; +1 leather for armor227+export const CARPENTER_RATE = 200; // 1 timber -> 1 plank228+export const TANNER_RATE = 200; // 1 hide -> 1 leather229+export const WEAVER_RATE = 150; // 2 wool -> 1 cloth230+export const TAILOR_RATE = 100; // 1 cloth + 1 leather -> 1 clothes231+232+export const ALE_GRAIN_COST = 2;233+export const METAL_ORE_COST = 2;234+export const METAL_TIMBER_COST = 1;235+export const CLOTH_WOOL_COST = 2;236+237+// ---------------------------------------------------------------------------238+// Consumption & morale239+// ---------------------------------------------------------------------------240+241+/**242+ * Food demand: 2 satiation points per capita per SLOW_TICK_INTERVAL at243+ * rations=1000 -> per-tick points = (pop * rations + acc) div 50_000.244+ * "1 food per 10 ticks per capita" holds exactly for fish/meat (satiation 2).245+ */246+export const FOOD_DEMAND_DIV = 50000;247+/** Satiation points per unit, eat order bread > fish > meat > grain. */248+export const SATIATION: ReadonlyArray<readonly [number, number]> = [249+ [RI.bread, 3],250+ [RI.fish, 2],251+ [RI.meat, 2],252+ [RI.grain, 1],253+];254+/** Starvation deaths per tick capped at max(1, pop div 50). */255+export const STARVATION_DEATH_CAP_DIV = 50;256+export const STARVATION_MORALE_HIT = 30;257+258+/** Ale demand: 1 ale per capita per 10 ticks at ale_ration=1000. */259+export const ALE_DEMAND_DIV = 10000;260+/** Cold-biome clothes demand: (pop + acc) div 10_000 per tick. */261+export const CLOTHES_DEMAND_DIV = 10000;262+263+/** Morale drifts 1/4 of the gap toward its target every slow tick. */264+export const MORALE_BASE_TARGET = 500;265+export const MORALE_VARIETY_BONUS = 100;266+export const MORALE_ALE_BONUS = 100;267+export const MORALE_CLOTHES_PENALTY = 150;268+export const MORALE_STARVED_PENALTY = 300;269+export const MORALE_BLESSING_BONUS = 150;270+271+// ---------------------------------------------------------------------------272+// Population (slow tick)273+// ---------------------------------------------------------------------------274+275+export const BIRTH_RATE = 8; // ‰ per 10t, x housing x morale x surplus factors276+export const NATURAL_DEATH_RATE = 2; // ‰ per 10t277+export const EMIGRATION_MORALE = 300; // below this, people leave278+export const EMIGRATION_RATE = 10; // ‰ per 10t279+/** Housing factor: 1000 below 80% occupancy, linear to 0 at 100%. */280+export const HOUSING_SOFT_PERMILLE = 800;281+/** Surplus factor saturates when stored food covers this many ticks. */282+export const SURPLUS_TICKS = 50;283+284+// ---------------------------------------------------------------------------285+// Herds (slow tick)286+// ---------------------------------------------------------------------------287+288+export const HERD_GROWTH = 30; // ‰ per 10t of tended herds (x2 when fodder-fed)289+export const HORSE_GROWTH = 20; // ‰ per 10t290+export const WOOL_PER_HERD = 100; // ‰ per tended herd per 10t291+export const MEAT_PER_SLAUGHTER = 3;292+export const HIDES_PER_SLAUGHTER = 1;293+export const FODDER_GRAIN_PER_HERD = 1; // grain per fed herd per 10t294+295+// ---------------------------------------------------------------------------296+// Construction297+// ---------------------------------------------------------------------------298+299+export interface BuildCost {300+ timber?: number;301+ planks?: number;302+ stone?: number;303+}304+305+export interface BuildingSpec {306+ cost: BuildCost | null; // null = not buildable (hall)307+ points: number;308+ /** Tech index gate, or -1. */309+ tech: number;51310 }52311 53-export const UNIT_STATS: Record<UnitKind, UnitStats> = {54- villager: { hp: 25, damage: 1, cost: { food: 30 } },55- soldier: { hp: 60, damage: 5, cost: { food: 40, wood: 20 } },312+export const BUILD_SPECS: Record<Building, BuildingSpec> = {313+ hall: { cost: null, points: 0, tech: -1 },314+ house: { cost: { timber: 20 }, points: 40, tech: -1 },315+ granary: { cost: { timber: 40 }, points: 80, tech: -1 },316+ warehouse: { cost: { timber: 40, stone: 20 }, points: 100, tech: -1 },317+ farm: { cost: { timber: 30 }, points: 60, tech: -1 },318+ dock: { cost: { timber: 40 }, points: 80, tech: -1 },319+ pasture: { cost: { timber: 20 }, points: 50, tech: -1 },320+ lumber_camp: { cost: { timber: 20 }, points: 50, tech: -1 },321+ mine: { cost: { timber: 30, stone: 20 }, points: 100, tech: -1 },322+ mill: { cost: { timber: 30, stone: 10 }, points: 100, tech: TECH_IDX.milling },323+ bakery: { cost: { timber: 30, stone: 20 }, points: 100, tech: TECH_IDX.milling },324+ brewery: { cost: { timber: 30, stone: 10 }, points: 100, tech: TECH_IDX.brewing },325+ smelter: { cost: { timber: 20, stone: 40 }, points: 120, tech: TECH_IDX.smelting },326+ smithy: { cost: { timber: 30, stone: 20 }, points: 120, tech: -1 },327+ tannery: { cost: { timber: 20 }, points: 80, tech: -1 },328+ weavers_hall: { cost: { timber: 30 }, points: 80, tech: -1 },329+ tailor_shop: { cost: { timber: 20 }, points: 80, tech: -1 },330+ market: { cost: { timber: 50, stone: 20 }, points: 120, tech: -1 },331+ shrine: { cost: { timber: 20, stone: 20 }, points: 80, tech: -1 },332+ church: { cost: { stone: 60, planks: 40 }, points: 200, tech: TECH_IDX.liturgy },333+ library: { cost: { timber: 50, stone: 20 }, points: 120, tech: -1 },334+ academy: { cost: { stone: 60, planks: 40 }, points: 200, tech: TECH_IDX.scholarship },335+ barracks: { cost: { timber: 50, stone: 20 }, points: 120, tech: -1 },336+ walls: { cost: { timber: 80 }, points: 150, tech: -1 }, // level 1; see WALL_LEVELS337+ tower: { cost: { timber: 30, stone: 40 }, points: 120, tech: -1 },56338 };57339 58-export interface BuildingStats {59- hp: number;60- popCap: number;61- /** null = cannot be built via orders (town_center exists only at spawn). */62- cost: Partial<Record<ResourceKind, number>> | null;63-}340+/** Wall upgrade path: cost/points per target level (index = level - 1). */341+export const WALL_LEVELS: ReadonlyArray<{ cost: BuildCost; points: number; tech: number }> = [342+ { cost: { timber: 80 }, points: 150, tech: -1 }, // L1 stockade343+ { cost: { stone: 150 }, points: 250, tech: TECH_IDX.masonry }, // L2 stone344+ { cost: { stone: 200, planks: 50 }, points: 400, tech: TECH_IDX.castellany }, // L3 castle345+];346+export const MAX_WALL_LEVEL = 3;347+348+/** One build point per builder-tick; one active site per 10 builders (min 1). */349+export const BUILDERS_PER_SITE = 10;350+export const BUILD_QUEUE_MAX = 12;351+352+// ---------------------------------------------------------------------------353+// Tech & faith354+// ---------------------------------------------------------------------------355+356+/** Knowledge/faith per worker per slow tick, ‰. */357+export const SCHOLAR_RATE = 150;358+export const PRIEST_RATE = 100;359+360+export const TECH_COSTS: Record<Tech, number> = {361+ milling: 60,362+ brewing: 60,363+ smelting: 80,364+ weaponsmithing: 150,365+ plate_armor: 200,366+ masonry: 80,367+ castellany: 200,368+ coinage: 100,369+ crop_rotation: 100,370+ horsemanship: 100,371+ scholarship: 120,372+ liturgy: 80,373+ siegecraft: 250,374+};375+376+/** Prereq DAG (tech -> prereq techs). */377+export const TECH_PREREQS: Record<Tech, Tech[]> = {378+ milling: [],379+ brewing: [],380+ smelting: [],381+ weaponsmithing: ['smelting'],382+ plate_armor: ['weaponsmithing'],383+ masonry: [],384+ castellany: ['masonry'],385+ coinage: [],386+ crop_rotation: [],387+ horsemanship: [],388+ scholarship: [],389+ liturgy: [],390+ siegecraft: ['weaponsmithing'],391+};392+393+/** Escalation: effective cost = base * (4 + techsOwned) / 4. */394+export const TECH_ESCALATION_DIV = 4;395+396+export const CROP_ROTATION_BONUS = 200; // ‰ extra farm yield397+export const HARVEST_RITE_BONUS = 100; // ‰ extra farm yield while active398+399+export const RITE_COSTS = { blessing: 20, harvest: 25, warding: 40, war: 30 } as const;400+export const RITE_DURATION = { blessing: 50, harvest: 50, warding: 100 } as const;401+export const WAR_RITE_POWER_BONUS = 100; // ‰, one battle402+403+// ---------------------------------------------------------------------------404+// Military405+// ---------------------------------------------------------------------------406+407+export const MUSTER_MIN_SIZE = 10;408+/** A settlement keeps at least this many civilians after muster/found. */409+export const MIN_REMAINING_POP = 50;410+export const SUPPLY_CAP_PER_SOLDIER = 3;411+export const SUPPLY_PER_SOLDIER_10T = 1;412+export const STARTING_WARBAND_MORALE = 800;413+/** Rams: siegecraft + these stores at the mustering settlement set siege=true. */414+export const RAM_PLANKS = 20;415+export const RAM_METAL = 5;416+417+/** power = size * (10 + armed(10) + armor*10/size + vet/100 + situational). */418+export const POWER_BASE = 10;419+export const POWER_ARMED = 10;420+/** Defender terrain modifiers (per-soldier additive). */421+export const TERRAIN_DEF_MOD: Readonly<Record<number, number>> = {422+ [TC.FOREST]: 2,423+ [TC.HILLS]: 3,424+ [TC.ORE]: 3,425+ [TC.GOLD]: 3,426+ [TC.MOUNTAIN]: 4,427+ [TC.VOLCANO]: 4,428+ [TC.SWAMP]: -2,429+};430+export const WALL_DEF_PER_SOLDIER = 5; // x wallLevel, garrison defending a siege431+432+/** Battle variance: power * (950 + rng.int(101)) / 1000, attacker then defender. */433+export const VARIANCE_BASE = 950;434+export const VARIANCE_SPAN = 101;435+436+/** Casualty ‰: loser min(500, 150 + r/5); winner min(300, max(50, 350000/r));437+ * r = winnerPower*1000/loserPower. */438+export const LOSER_LOSS_BASE = 150;439+export const LOSER_LOSS_CAP = 500;440+export const WINNER_LOSS_CAP = 300;441+export const WINNER_LOSS_MIN = 50;442+443+export const VET_WIN = 50;444+export const VET_LOSS = 20;445+export const ROUT_MORALE_HIT = 100;64446 65-export const BUILDING_STATS: Record<BuildingKind, BuildingStats> = {66- town_center: { hp: 500, popCap: 10, cost: null },67- house: { hp: 100, popCap: 5, cost: { wood: 30 } },68- granary: { hp: 150, popCap: 0, cost: { wood: 40 } },69- barracks: { hp: 200, popCap: 0, cost: { wood: 50 } },70- tower: { hp: 350, popCap: 0, cost: { wood: 20, stone: 30 } },71- wall: { hp: 450, popCap: 0, cost: { stone: 10 } },447+export const PATROL_RADIUS = 6;448+/** Settlement passive defense: pop/10 militia + wallLevel*100 + towers*50. */449+export const MILITIA_POP_DIV = 10;450+export const WALL_DEFENSE = 100;451+export const TOWER_DEFENSE = 50;452+/** Raid carry capacity: units of loot per soldier. */453+export const RAID_CARRY_PER_SOLDIER = 2;454+/** A building is destroyed when attacker power >= 2x defense. */455+export const RAID_DESTROY_RATIO = 2;456+/** Siege: wall damage per slow tick = attacker power; a wall level falls at457+ * wallLevel * WALL_HP damage. Without rams vs walls >= 2: quarter rate. */458+export const WALL_HP = 2000;459+export const NO_RAM_DIV = 4;460+/** Desertion outside own territory with empty supply: 1‰ per 10t. */461+export const DESERTION_RATE = 1;462+export const NO_SUPPLY_MORALE_HIT = 20;463+464+// ---------------------------------------------------------------------------465+// Threats466+// ---------------------------------------------------------------------------467+468+export const THREAT_MIN_DIST = 10;469+/** Camps only spawn within this distance of some settlement, which equals470+ * THREAT_RAID_RANGE so every camp can actually raid. */471+export const THREAT_MAX_DIST = 25;472+export const THREAT_SPAWN_TRIES = 8;473+/** maxCamps = min(6, 1 + tick div 1500). */474+export const THREAT_MAX_CAMPS_CAP = 6;475+export const THREAT_RAMP_TICKS = 1500;476+export const THREAT_BASE_STRENGTH: readonly number[] = [20, 30, 50, 90]; // by kind idx477+export const THREAT_STRENGTH_TICK_DIV = 250;478+export const THREAT_WEALTH_DIV = 5000;479+export const THREAT_WEALTH_RADIUS = 20;480+export const THREAT_GROWTH_PER_10T = 1;481+export const THREAT_STRENGTH_CAP_MULT = 4;482+export const THREAT_RAID_RANGE = 25;483+export const THREAT_RAID_COOLDOWN = 100;484+export const THREAT_FIRST_RAID_DELAY = 100;485+export const CAMP_POWER_MULT = 10;486+export const THREAT_RAID_POWER_MULT = 8;487+export const DEMON_GATE_MIN_TICK = 6000;488+/** Loot for clearing a camp: coin = strength * 2 (credited to home settlement). */489+export const CAMP_CLEAR_COIN_MULT = 2;490+491+// ---------------------------------------------------------------------------492+// Logistics & trade493+// ---------------------------------------------------------------------------494+495+/** Route/equalization throughput: units per hauler per slow tick. */496+export const HAULER_THROUGHPUT = 20;497+/** Routes only move surplus above this keep-level of the resource. */498+export const ROUTE_KEEP_LEVEL = 100;499+export const MIN_SETTLE_DIST = 6;500+export const FOUND_MIN_SETTLERS = 50;501+502+/** Peddler: instant sale at ‰ of base value; coinage improves it. */503+export const PEDDLER_RATE = 250;504+export const PEDDLER_RATE_COINAGE = 400;505+506+/** Base coin value per unit (peddler & trade-volume accounting). */507+export const RESOURCE_VALUE: Record<Resource, number> = {508+ grain: 1,509+ fish: 1,510+ meat: 2,511+ hides: 2,512+ wool: 2,513+ timber: 1,514+ stone: 1,515+ ore: 3,516+ coin: 1,517+ flour: 2,518+ planks: 2,519+ metal: 6,520+ leather: 3,521+ cloth: 4,522+ bread: 2,523+ ale: 3,524+ tools: 8,525+ weapons: 10,526+ armor: 12,527+ clothes: 8,528+};529+530+// ---------------------------------------------------------------------------531+// Vision & events532+// ---------------------------------------------------------------------------533+534+/** Settlement vision = territoryRadius + this. */535+export const SETTLEMENT_VISION_BONUS = 2;536+export const UNIT_VISION = 6; // warbands & caravans537+export const SIGHT_COOLDOWN = 200;538+539+// ---------------------------------------------------------------------------540+// Score541+// ---------------------------------------------------------------------------542+543+export const SCORE_POP_DIV = 10;544+export const SCORE_PER_SETTLEMENT = 40;545+export const SCORE_PER_TECH = 25;546+export const SCORE_POWER_DIV = 20;547+export const SCORE_COIN_DIV = 20;548+export const SCORE_PER_BUILDING = 2;549+550+// ---------------------------------------------------------------------------551+// Starting state (per tribe, single founding settlement)552+// ---------------------------------------------------------------------------553+554+export const START_MORALE = 600;555+export const START_HERDS = 40;556+export const START_HORSES = 10;557+558+export const START_BUILDINGS: Partial<Record<Building, number>> = {559+ hall: 1,560+ house: 10,561+ granary: 1,562+ warehouse: 1,563+ farm: 10,564+ pasture: 2,565+ lumber_camp: 2,566+ mine: 1,567+ smithy: 1,568+ market: 1,569+ shrine: 1,570+ library: 1,571+ barracks: 1,572+};573+574+export const START_STORES: Partial<Record<Resource, number>> = {575+ grain: 2000,576+ meat: 100,577+ hides: 50,578+ timber: 400,579+ stone: 250,580+ coin: 100,581+ metal: 40,582+ leather: 40,583+ tools: 250,584+ weapons: 100,585+ armor: 20,586+ clothes: 100,72587 };588+589+/** Default craft splits (tools/weapons/armor; mill/brew/fodder). */590+export const DEFAULT_CRAFT: readonly number[] = [600, 250, 150, 500, 100, 200];591+/** Default decrees (rations/slaughter/fodder/ale_ration). */592+export const DEFAULT_DECREES: readonly number[] = [1000, 0, 0, 0];
M packages/engine/src/index.ts +2 -7
M packages/engine/src/mapgen.ts +267 -91
@@ -1,27 +1,20 @@
11 /**2- * Seed-deterministic map generation. Integer math only.2+ * Seed-deterministic v2 map generation. Integer math only (the spawn ring3+ * reuses the v1 integer 16-point circle table — no trig floats).34 *4- * Layout: grass base; a handful of water lakes (movement blockers); scattered5- * forest/berry/rock patches; tribe spawns evenly spaced on a ring around the6- * map center (integer 16-point circle table — no trig floats). Each spawn area7- * is cleared to grass and gets guaranteed forest/berry/rock clusters at fixed8- * offsets so every tribe has a playable start.5+ * Layout: plains base; ocean border ring; arctic caps and steppe bands at the6+ * north/south edges; forest / hills / mountain / swamp blobs; 1-2 volcano7+ * tiles inside mountains; lakes; 1-2 meandering rivers to the ocean; ore ('O')8+ * and rarer gold ('G') veins on hills/mountain tiles.9+ *10+ * Spawn fairness (terraformed, deterministic): within Chebyshev radius 8 of11+ * each founding site every tribe is guaranteed plains >= 20, forest >= 10,12+ * water-adjacent land tiles >= 3, hills-or-mountain >= 6 with >= 1 ore vein.913 */10-import { TILE_AMOUNT } from './constants.js';14+import { HILLS_CLASS, TC, WATER_CLASS } from './constants.js';1115 import type { Rng } from './rng.js';1216 13-export const T = {14- GRASS: 0,15- WATER: 1,16- FOREST: 2,17- BERRY: 3,18- ROCK: 4,19-} as const;20-21-/** Terrain code -> TribeView char ('?' handled by fog). */22-export const T_CHAR = 'gwfbr';23-24-/** 16 points on a radius-1000 circle, integer-rounded (index k = k * 22.5deg). */17+/** 16 points on a radius-1000 circle, integer-rounded (k = k * 22.5deg). */2518 const CIRCLE_16: ReadonlyArray<readonly [number, number]> = [2619 [1000, 0],2720 [924, 383],
@@ -41,32 +34,8 @@ const CIRCLE_16: ReadonlyArray<readonly [number, number]> = [
4134 [924, -383],4235 ];4336 44-/** Guaranteed resource clusters, as offsets from each spawn point. */45-const SPAWN_FOREST: ReadonlyArray<readonly [number, number]> = [46- [3, -1],47- [3, 0],48- [3, 1],49- [4, -1],50- [4, 0],51- [4, 1],52-];53-const SPAWN_BERRY: ReadonlyArray<readonly [number, number]> = [54- [-3, -1],55- [-3, 0],56- [-3, 1],57- [-4, 0],58-];59-const SPAWN_ROCK: ReadonlyArray<readonly [number, number]> = [60- [-1, 3],61- [0, 3],62- [1, 3],63- [0, 4],64-];65-6637 export interface GeneratedMap {6738 terrain: Uint8Array;68- /** Remaining harvestable amount per tile (0 for non-resource tiles). */69- amount: Uint16Array;7039 spawns: Array<{ x: number; y: number }>;7140 }7241
@@ -77,7 +46,7 @@ function blob(
7746 cy: number,7847 r: number,7948 t: number,80- overwrite: boolean,49+ onlyOver: number | -1,8150 ): void {8251 for (let dy = -r; dy <= r; dy++) {8352 for (let dx = -r; dx <= r; dx++) {
@@ -86,74 +55,281 @@ function blob(
8655 const y = cy + dy;8756 if (x < 0 || y < 0 || x >= size || y >= size) continue;8857 const i = y * size + x;89- if (overwrite || terrain[i] === T.GRASS) terrain[i] = t;58+ if (onlyOver === -1 || terrain[i] === onlyOver) terrain[i] = t;59+ }60+ }61+}62+63+const isLand = (t: number): boolean => t !== TC.WATER && t !== TC.RIVER;64+65+function waterAdjacentLand(terrain: Uint8Array, size: number, x: number, y: number): boolean {66+ const t = terrain[y * size + x]!;67+ if (!isLand(t)) return false;68+ for (let dy = -1; dy <= 1; dy++) {69+ for (let dx = -1; dx <= 1; dx++) {70+ if (dx === 0 && dy === 0) continue;71+ const nx = x + dx;72+ const ny = y + dy;73+ if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;74+ if (WATER_CLASS.has(terrain[ny * size + nx]!)) return true;9075 }9176 }77+ return false;9278 }9379 80+/**81+ * Deterministic offsets ordered by (Chebyshev distance, dy, dx) out to82+ * radius 8 — used to count and terraform spawn surroundings in a fixed order.83+ */84+function ringOffsets(maxR: number): Array<[number, number]> {85+ const offs: Array<[number, number]> = [];86+ for (let r = 0; r <= maxR; r++) {87+ for (let dy = -r; dy <= r; dy++) {88+ for (let dx = -r; dx <= r; dx++) {89+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;90+ offs.push([dx, dy]);91+ }92+ }93+ }94+ return offs;95+}96+const SPAWN_OFFS = ringOffsets(8);97+9498 export function generateMap(size: number, tribeCount: number, rng: Rng): GeneratedMap {95- const terrain = new Uint8Array(size * size); // all grass96- const margin = 6;97- const span = size - margin * 2;99+ const terrain = new Uint8Array(size * size).fill(TC.PLAINS);100+ const idx = (x: number, y: number): number => y * size + x;101+102+ // --- Ocean border: solid 2 deep, ragged out to 4. ---103+ for (let y = 0; y < size; y++) {104+ for (let x = 0; x < size; x++) {105+ const edge = Math.min(x, y, size - 1 - x, size - 1 - y);106+ if (edge < 2) terrain[idx(x, y)] = TC.WATER;107+ else if (edge === 2) {108+ if (rng.int(2) === 0) terrain[idx(x, y)] = TC.WATER;109+ } else if (edge === 3) {110+ if (rng.int(4) === 0) terrain[idx(x, y)] = TC.WATER;111+ }112+ }113+ }98114 99- // Water lakes.100- const lakes = 3 + rng.int(3);101- for (let i = 0; i < lakes; i++) {102- const cx = margin + rng.int(span);103- const cy = margin + rng.int(span);104- const r = 2 + rng.int(3);105- blob(terrain, size, cx, cy, r, T.WATER, true);115+ // --- Arctic caps and steppe bands (north & south). ---116+ for (let y = 0; y < size; y++) {117+ const fromEdge = Math.min(y, size - 1 - y);118+ let t = -1;119+ if (fromEdge < 10) t = TC.ARCTIC;120+ else if (fromEdge < 16) t = TC.STEPPE;121+ if (t === -1) continue;122+ for (let x = 0; x < size; x++) {123+ const i = idx(x, y);124+ if (terrain[i] === TC.PLAINS) terrain[i] = t;125+ }106126 }107127 108- // Scattered resource patches (only over grass).109- const scatter = (t: number, count: number, rMax: number): void => {128+ // --- Biome blobs (over plains only, so bands/ocean survive). ---129+ const scatter = (t: number, count: number, rMin: number, rMax: number, over: number): void => {110130 for (let i = 0; i < count; i++) {111- const cx = 2 + rng.int(size - 4);112- const cy = 2 + rng.int(size - 4);113- const r = 1 + rng.int(rMax);114- blob(terrain, size, cx, cy, r, t, false);131+ const cx = 4 + rng.int(size - 8);132+ const cy = 4 + rng.int(size - 8);133+ const r = rMin + rng.int(rMax - rMin + 1);134+ blob(terrain, size, cx, cy, r, t, over);115135 }116136 };117- scatter(T.FOREST, 12, 2);118- scatter(T.BERRY, 8, 1);119- scatter(T.ROCK, 8, 1);137+ scatter(TC.FOREST, 40, 2, 4, TC.PLAINS);138+ scatter(TC.HILLS, 25, 2, 3, TC.PLAINS);139+ // Mountains over hills (cores), plus a few standalone ranges over plains.140+ scatter(TC.MOUNTAIN, 12, 1, 2, TC.HILLS);141+ scatter(TC.MOUNTAIN, 4, 1, 2, TC.PLAINS);142+ scatter(TC.SWAMP, 10, 1, 2, TC.PLAINS);120143 121- // Spawns on a ring; clear the area and stamp guaranteed clusters.144+ // --- Lakes. ---145+ for (let i = 0; i < 5; i++) {146+ const cx = 8 + rng.int(size - 16);147+ const cy = 8 + rng.int(size - 16);148+ blob(terrain, size, cx, cy, 2 + rng.int(2), TC.WATER, -1);149+ }150+151+ // --- Rivers: 1-2, meandering from an interior point to the nearest edge. ---152+ const rivers = 1 + rng.int(2);153+ for (let i = 0; i < rivers; i++) {154+ let x = (size >> 2) + rng.int(size >> 1);155+ let y = (size >> 2) + rng.int(size >> 1);156+ // Main direction: toward the nearest edge.157+ const toLeft = x;158+ const toRight = size - 1 - x;159+ const toTop = y;160+ const toBottom = size - 1 - y;161+ const m = Math.min(toLeft, toRight, toTop, toBottom);162+ let mdx = 0;163+ let mdy = 0;164+ if (m === toLeft) mdx = -1;165+ else if (m === toRight) mdx = 1;166+ else if (m === toTop) mdy = -1;167+ else mdy = 1;168+ for (let step = 0; step < size * 2; step++) {169+ if (x < 0 || y < 0 || x >= size || y >= size) break;170+ const t = terrain[idx(x, y)]!;171+ if (t === TC.WATER) break; // reached ocean/lake172+ terrain[idx(x, y)] = TC.RIVER;173+ // Advance: main direction, with lateral jitter every other step.174+ const j = rng.int(3) - 1; // -1 | 0 | 1175+ if (mdx !== 0) {176+ x += mdx;177+ y += j;178+ } else {179+ y += mdy;180+ x += j;181+ }182+ }183+ }184+185+ // --- Volcanoes: up to 2, on mountain tiles. ---186+ let volcanoes = 0;187+ for (let tries = 0; tries < 60 && volcanoes < 2; tries++) {188+ const x = rng.int(size);189+ const y = rng.int(size);190+ if (terrain[idx(x, y)] === TC.MOUNTAIN) {191+ terrain[idx(x, y)] = TC.VOLCANO;192+ volcanoes++;193+ }194+ }195+196+ // --- Ore & gold veins on hills/mountain. ---197+ let ore = 0;198+ for (let tries = 0; tries < 200 && ore < 35; tries++) {199+ const x = rng.int(size);200+ const y = rng.int(size);201+ const t = terrain[idx(x, y)]!;202+ if (t === TC.HILLS || t === TC.MOUNTAIN) {203+ terrain[idx(x, y)] = TC.ORE;204+ ore++;205+ }206+ }207+ let gold = 0;208+ for (let tries = 0; tries < 100 && gold < 8; tries++) {209+ const x = rng.int(size);210+ const y = rng.int(size);211+ const t = terrain[idx(x, y)]!;212+ if (t === TC.HILLS || t === TC.MOUNTAIN) {213+ terrain[idx(x, y)] = TC.GOLD;214+ gold++;215+ }216+ }217+218+ // --- Spawns on a ring, then deterministic fairness terraforming. ---122219 const c = size >> 1;123- const ringR = Math.floor((size * 3) / 8);220+ const ringR = Math.trunc((size * 3) / 8);124221 const spawns: Array<{ x: number; y: number }> = [];125222 for (let i = 0; i < tribeCount; i++) {126- const p = CIRCLE_16[Math.floor((i * 16) / tribeCount) % 16]!;223+ const p = CIRCLE_16[Math.trunc((i * 16) / tribeCount) % 16]!;127224 const x = c + Math.trunc((p[0] * ringR) / 1000);128225 const y = c + Math.trunc((p[1] * ringR) / 1000);129226 spawns.push({ x, y });130- // Clear a Chebyshev-radius-3 square to grass.131- for (let dy = -3; dy <= 3; dy++) {132- for (let dx = -3; dx <= 3; dx++) {133- const px = x + dx;134- const py = y + dy;135- if (px < 0 || py < 0 || px >= size || py >= size) continue;136- terrain[py * size + px] = T.GRASS;137- }227+ terraformSpawn(terrain, size, x, y);228+ }229+230+ return { terrain, spawns };231+}232+233+/** Guarantee the fairness quotas around one spawn. Fixed scan order. */234+function terraformSpawn(terrain: Uint8Array, size: number, sx: number, sy: number): void {235+ const idx = (x: number, y: number): number => y * size + x;236+ const inMap = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < size && y < size;237+238+ // Clear a 3x3 core to plains (the hall site).239+ for (let dy = -1; dy <= 1; dy++) {240+ for (let dx = -1; dx <= 1; dx++) {241+ if (inMap(sx + dx, sy + dy)) terrain[idx(sx + dx, sy + dy)] = TC.PLAINS;242+ }243+ }244+245+ const count = (pred: (t: number, x: number, y: number) => boolean): number => {246+ let n = 0;247+ for (const [dx, dy] of SPAWN_OFFS) {248+ const x = sx + dx;249+ const y = sy + dy;250+ if (!inMap(x, y)) continue;251+ if (pred(terrain[idx(x, y)]!, x, y)) n++;252+ }253+ return n;254+ };255+256+ // Plains >= 20: convert nearest non-water, non-plains tiles.257+ let plains = count((t) => t === TC.PLAINS);258+ for (const [dx, dy] of SPAWN_OFFS) {259+ if (plains >= 20) break;260+ const x = sx + dx;261+ const y = sy + dy;262+ if (!inMap(x, y)) continue;263+ const t = terrain[idx(x, y)]!;264+ if (t !== TC.PLAINS && isLand(t)) {265+ terrain[idx(x, y)] = TC.PLAINS;266+ plains++;138267 }139- const stamp = (offs: ReadonlyArray<readonly [number, number]>, t: number): void => {140- for (const [dx, dy] of offs) {141- const px = x + dx;142- const py = y + dy;143- if (px < 0 || py < 0 || px >= size || py >= size) continue;144- terrain[py * size + px] = t;145- }146- };147- stamp(SPAWN_FOREST, T.FOREST);148- stamp(SPAWN_BERRY, T.BERRY);149- stamp(SPAWN_ROCK, T.ROCK);150268 }151269 152- const amount = new Uint16Array(size * size);153- for (let i = 0; i < terrain.length; i++) {154- const t = terrain[i]!;155- if (t === T.FOREST || t === T.BERRY || t === T.ROCK) amount[i] = TILE_AMOUNT;270+ // Forest >= 10: convert plains tiles at distance >= 3 (nearest first).271+ let forest = count((t) => t === TC.FOREST);272+ for (const [dx, dy] of SPAWN_OFFS) {273+ if (forest >= 10) break;274+ if (Math.max(Math.abs(dx), Math.abs(dy)) < 3) continue;275+ const x = sx + dx;276+ const y = sy + dy;277+ if (!inMap(x, y)) continue;278+ if (terrain[idx(x, y)] === TC.PLAINS) {279+ terrain[idx(x, y)] = TC.FOREST;280+ forest++;281+ }156282 }157283 158- return { terrain, amount, spawns };284+ // Hills-or-mountain >= 6: convert plains at distance >= 4 (farthest first,285+ // to keep the near field farmable).286+ let hills = count((t) => HILLS_CLASS.has(t));287+ for (let k = SPAWN_OFFS.length - 1; k >= 0 && hills < 6; k--) {288+ const [dx, dy] = SPAWN_OFFS[k]!;289+ if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) continue;290+ const x = sx + dx;291+ const y = sy + dy;292+ if (!inMap(x, y)) continue;293+ if (terrain[idx(x, y)] === TC.PLAINS) {294+ terrain[idx(x, y)] = TC.HILLS;295+ hills++;296+ }297+ }298+299+ // Ore vein >= 1: promote the first hills-class tile (fixed order).300+ let hasOre = count((t) => t === TC.ORE || t === TC.GOLD) > 0;301+ if (!hasOre) {302+ for (const [dx, dy] of SPAWN_OFFS) {303+ const x = sx + dx;304+ const y = sy + dy;305+ if (!inMap(x, y)) continue;306+ const t = terrain[idx(x, y)]!;307+ if (t === TC.HILLS || t === TC.MOUNTAIN) {308+ terrain[idx(x, y)] = TC.ORE;309+ hasOre = true;310+ break;311+ }312+ }313+ }314+315+ // Water-adjacent land >= 3: stamp a 2-tile pond at distance ~6 if missing.316+ let waterAdj = count((_, x, y) => waterAdjacentLand(terrain, size, x, y));317+ if (waterAdj < 3) {318+ const spots: ReadonlyArray<readonly [number, number]> = [319+ [6, 6],320+ [7, 6],321+ [-6, 6],322+ [-7, 6],323+ [6, -6],324+ [7, -6],325+ ];326+ for (const [dx, dy] of spots) {327+ const x = sx + dx;328+ const y = sy + dy;329+ if (!inMap(x, y)) continue;330+ terrain[idx(x, y)] = TC.WATER;331+ waterAdj = count((_, cx, cy) => waterAdjacentLand(terrain, size, cx, cy));332+ if (waterAdj >= 3) break;333+ }334+ }159335 }
M packages/engine/src/world.ts +3065 -986
@@ -1,163 +1,313 @@
11 /**2- * Tribes simulation engine. Deterministic, integer-only.2+ * Tribes v2 simulation engine. Deterministic, integer-only.33 *4- * System order per step() (documented in packages/engine/README.md):5- * 1. Order validation & application (tribes ascending id; orders in array order)6- * 2. Army intent (armies ascending id; roam waypoints may consume PRNG)7- * 3. Movement & work (units ascending id: fight-lock / gather / build / march)8- * 4. Combat (units ascending id, sequential damage; then towers ascending id)9- * 5. Upkeep & starvation (every UPKEEP_INTERVAL ticks)10- * 6. Death sweep (units/buildings hp<=0 removed; empty armies disbanded)11- * 7. Elimination check (no town_center AND no units -> tribe removed)12- * 7.5. Sighting events ('sighted' per observer/enemy pair, cooldown-gated)13- * 8. tick++, end-condition check4+ * Phase order per step() (documented in packages/engine/README.md):5+ * 1 Actions -> policies (tribes ascending id, first ACTIONS_PER_TICK each)6+ * 2 Territory & census (slow ticks)7+ * 3 Labor apportionment (largest remainder)8+ * 4 Extraction (+ tool wear)9+ * 5 Refinement, crafts & construction10+ * 6 Herds (slow ticks)11+ * 7 Logistics: caravan/wagon movement every tick; routes & food12+ * equalization on slow ticks13+ * 8 Consumption & needs14+ * 9 Population & morale (slow ticks)15+ * 10 Military: warband movement, field battles, camps, raids, sieges16+ * (sieges & supply on slow ticks)17+ * 11 Threats: spawn/growth/raids (slow ticks)18+ * 12 Trade, faith & tech (slow ticks)19+ * 13 Elimination & end check20+ * 14 Sightings, metrics frame (slow boundary), tick++1421 *15- * SimEvents are collected into a per-step buffer (cleared at step start) and16- * delivered fog-filtered via viewFor(). They are NOT part of stateHash().22+ * PRNG is consumed only by mapgen (construction), threat placement (phase 11)23+ * and battle variance (phases 10/11), each in fixed iteration order.24+ * SimEvents are per-step narration, fog-filtered in viewFor(), NOT hashed.1725 */1826 import type {19- ArmyStance,20- ArmyView,21- BuildingKind,22- BuildingView,27+ Building,28+ CaravanView,29+ CraftTarget,2330 CreateWorld,31+ DecreeKey,2432 EngineWorld,25- EnemySighting,26- Job,33+ EnemySettlementSighting,34+ EnemyWarbandSighting,35+ MetricsFrame,2736 Order,28- ResourceKind,29- Resources,37+ Posture,38+ Profession,39+ Resource,40+ RiteKind,41+ SettlementView,3042 SimConfig,3143 SimEvent,3244 SimResult,45+ Tech,46+ ThreatKind,47+ ThreatView,48+ TradeOfferView,49+ TribeMetrics,3350 TribeResult,3451 TribeView,35- UnitKind,36- UnitView,52+ WarbandKind,53+ WarbandOrder,54+ WarbandView,3755 } from '@tribes/protocol';38-import { LIMITS } from '@tribes/protocol';39-import {40- BASE_STORAGE,41- BUILDING_STATS,42- BUILD_RATE,43- DEFEND_HOLD_RADIUS,44- DEFEND_HOME_MULT,45- DEFEND_INTERCEPT_RADIUS,46- ENGAGE_RADIUS,47- GATHER_RATE,48- GRANARY_STORAGE,49- ROAM_PICK_TRIES,50- SCORE_PER_BUILDING,51- SCORE_PER_KILL,52- SCORE_PER_POP,53- SIGHT_COOLDOWN,54- SOLDIER_SIEGE_MULT,55- START_RESOURCES,56- TOWER_DAMAGE,57- TOWER_RANGE,58- UNIT_STATS,59- UPKEEP_INTERVAL,60- VISION_RADIUS,61-} from './constants.js';56+import { BUILDINGS, LIMITS, PROFESSIONS, RESOURCES, TECHS } from '@tribes/protocol';57+import { apportion } from './apportion.js';58+import * as C from './constants.js';59+import { BI, PI, RI, TC } from './constants.js';6260 import { Hasher } from './hash.js';63-import { generateMap, T, T_CHAR } from './mapgen.js';61+import { generateMap } from './mapgen.js';6462 import { Rng } from './rng.js';6563 64+const SLOW = LIMITS.SLOW_TICK_INTERVAL;65+const R_N = C.R_N;66+const P_N = C.P_N;67+const B_N = C.B_N;68+69+const CRAFT_KEYS: readonly CraftTarget[] = ['tools', 'weapons', 'armor', 'mill', 'brew', 'fodder'];70+const DECREE_KEYS: readonly DecreeKey[] = ['rations', 'slaughter', 'fodder', 'ale_ration'];71+const THREAT_KINDS: readonly ThreatKind[] = [72+ 'goblin_camp',73+ 'skeleton_barrow',74+ 'ogre_den',75+ 'demon_gate',76+];77+const WB_KINDS: readonly WarbandKind[] = ['infantry', 'archers', 'cavalry'];78+const RITE_KINDS: readonly RiteKind[] = ['blessing', 'harvest', 'warding', 'war'];79+80+/** Warband order type codes. */81+const WO = { garrison: 0, patrol: 1, march: 2, raid: 3, besiege: 4, escort: 5, disband: 6 } as const;82+const WO_NAMES = ['garrison', 'patrol', 'march', 'raid', 'besiege', 'escort', 'disband'] as const;83+84+/** Effect kind codes. */85+const FX = { blessing: 0, harvest: 1, warding: 2, war: 3 } as const;86+87+/** Posture codes. */88+const POSTURES: readonly Posture[] = ['ally', 'neutral', 'hostile'];89+const P_ALLY = 0;90+const P_HOSTILE = 2;91+92+/** Extraction/refinement accumulator slots (per settlement). */93+const ACC = {94+ FARM: 0,95+ FISH: 1,96+ WOOD: 2,97+ STONE: 3,98+ ORE: 4,99+ COIN: 5,100+ MILL: 6,101+ BAKE: 7,102+ BREW: 8,103+ SMELT: 9,104+ SMITH: 10,105+ CARP: 11,106+ TAN: 12,107+ WEAVE: 13,108+ TAILOR: 14,109+ FOOD: 15,110+ ALE: 16,111+ CLOTHES: 17,112+ TOOL_WEAR: 18,113+ BIRTH: 19,114+ DEATH: 20,115+ EMIG: 21,116+ HERD: 22,117+ WOOL: 23,118+ SLAUGHTER: 24,119+ HORSE: 25,120+} as const;121+const ACC_N = 26;122+123+/** Interval-flag bits (per settlement, hashed; reset each slow tick). */124+const FLAG_ALE_SHORT = 1;125+const FLAG_CLOTHES_SHORT = 2;126+const FLAG_STARVED = 4;127+128+/** Fixed raid-loot priority: valuables, then provisions, then materiel. */129+const LOOT_ORDER: readonly number[] = [130+ RI.coin,131+ RI.bread,132+ RI.ale,133+ RI.grain,134+ RI.fish,135+ RI.meat,136+ RI.tools,137+ RI.weapons,138+ RI.armor,139+ RI.metal,140+ RI.cloth,141+ RI.clothes,142+ RI.timber,143+ RI.stone,144+];145+/** Food resources taken for warband supply / equalization, in order. */146+const SUPPLY_ORDER: readonly number[] = [RI.grain, RI.fish, RI.meat, RI.bread];147+/** Raid building-destruction priority (skip hall & walls). */148+const DESTROY_ORDER: readonly number[] = BUILDINGS.map((_, i) => i).filter(149+ (i) => i !== BI.hall && i !== BI.walls,150+);151+152+const cheb = (ax: number, ay: number, bx: number, by: number): number =>153+ Math.max(Math.abs(ax - bx), Math.abs(ay - by));154+66155 // ---------------------------------------------------------------------------67-// Internal state types156+// Internal state68157 // ---------------------------------------------------------------------------69158 70-interface Unit {159+interface Census {160+ plains: number;161+ forest: number;162+ hills: number;163+ water: number;164+ ore: number;165+ gold: number;166+ cold: boolean;167+}168+169+interface Settlement {71170 id: number;72171 tribe: number;73- kind: UnitKind;74172 x: number;75173 y: number;76- hp: number;77- job: Job;78- /** -1 = not in an army. */79- armyId: number;80- /** Cached gather-target tile (-1 = none). */81- tx: number;82- ty: number;174+ tier: number;175+ pop: number;176+ morale: number;177+ radius: number;178+ herds: number;179+ horses: number;180+ wallDamage: number;181+ stores: Int32Array; // R_N182+ buildings: Int32Array; // B_N (walls slot = wall level)183+ queue: Array<{ b: number; progress: number; req: number }>;184+ acc: Int32Array; // ACC_N185+ kindsEaten: number; // bitmask over SATIATION order186+ flags: number;187+ allocOverride: Int32Array; // P_N, -1 = no override188+ // Derived caches (recomputed deterministically; not hashed):189+ census: Census;190+ workers: Int32Array; // P_N, last apportionment191+ capacity: Int32Array; // P_N192+ herderEff1000: number;83193 }84194 85-interface Building {195+interface Warband {86196 id: number;87197 tribe: number;88- kind: BuildingKind;198+ home: number;199+ size: number;200+ kind: number; // WB_KINDS index201+ weapons: number;202+ armor: number;203+ horses: number;204+ vet: number;205+ morale: number;89206 x: number;90207 y: number;91- hp: number;92- /** 0..100; 100 = complete. */93- progress: number;208+ supply: number;209+ siege: boolean;210+ order: { type: number; targetId: number; tx: number; ty: number };211+ desertAcc: number;212+ // per-tick scratch:213+ engaged: boolean;214+ moved: boolean;94215 }95216 96-interface Army {217+interface Caravan {97218 id: number;98219 tribe: number;99- stance: ArmyStance;100- /** Sorted ascending. */101- unitIds: number[];102- /** Waypoint (-1 = none). */220+ x: number;221+ y: number;222+ res: number;223+ amount: number;224+ destId: number; // settlement id225+}226+227+interface Wagon {228+ id: number; // shares the caravan id space229+ tribe: number;230+ x: number;231+ y: number;103232 tx: number;104233 ty: number;234+ settlers: number;235+ supplies: Int32Array; // R_N236+}237+238+interface Camp {239+ id: number;240+ kind: number; // THREAT_KINDS index241+ x: number;242+ y: number;243+ strength: number;244+ nextRaidAt: number;245+}246+247+interface Effect {248+ kind: number; // FX249+ tribe: number;250+ target: number; // settlement id / warband id / -1251+ expiresAt: number;252+}253+254+interface Offer {255+ id: number;256+ tribe: number;257+ give: number;258+ giveAmount: number;259+ want: number;260+ wantAmount: number;261+ counterparty: number; // -1 = anyone262+ repeat: boolean;263+}264+265+interface Route {266+ fromId: number;267+ toId: number;268+ res: number;269+ permille: number;105270 }106271 107-interface TribeState {272+interface Tribe {108273 id: number;109274 alive: boolean;110- /** -1 = still alive. */111275 diedAt: number;112- res: Resources;113- kills: number;276+ alloc: Int32Array; // P_N permille277+ craft: Int32Array; // 6 (CRAFT_KEYS order)278+ decrees: Int32Array; // 4 (DECREE_KEYS order)279+ stances: Int8Array; // tribeCount, POSTURES index280+ techMask: number;281+ researching: number; // TECHS index or -1282+ knowledge: number;283+ faith: number;284+ knowAcc: number;285+ faithAcc: number;286+ routes: Route[];114287 explored: Uint8Array;288+ exploredDirty: boolean;289+ terrainCache: string | null;290+ // Counters (buildingsCompleted feeds score and is hashed; the rest are291+ // metrics narration — cumulative unless noted).292+ buildingsCompleted: number;293+ battlesWon: number;294+ battlesLost: number;295+ casDealt: number;296+ casTaken: number;297+ campsCleared: number;298+ tradeVolume: number;299+ caravansLost: number;300+ // Interval flows (reset after each metrics frame):301+ prodI: Int32Array; // R_N302+ consI: Int32Array; // R_N303+ birthsI: number;304+ deathsI: number;305+ starvI: number;306+ // per-tick derived:307+ coveredExtractors: number;308+ totalExtractors: number;115309 }116310 117-const JOBS: ReadonlySet<string> = new Set([118- 'idle',119- 'gather_food',120- 'gather_wood',121- 'gather_stone',122- 'build',123-]);124-const STANCES: ReadonlySet<string> = new Set(['attack', 'defend', 'roam']);125-const UNIT_KIND_CODE: Record<UnitKind, number> = { villager: 0, soldier: 1 };126-const BUILDING_KIND_CODE: Record<BuildingKind, number> = {127- town_center: 0,128- house: 1,129- granary: 2,130- barracks: 3,131- tower: 4,132- wall: 5,133-};134-const JOB_CODE: Record<Job, number> = {135- idle: 0,136- gather_food: 1,137- gather_wood: 2,138- gather_stone: 3,139- build: 4,140-};141-const STANCE_CODE: Record<ArmyStance, number> = { attack: 0, defend: 1, roam: 2 };142-143-/** gather job -> terrain code it harvests. */144-const JOB_TERRAIN: Partial<Record<Job, number>> = {145- gather_food: T.BERRY,146- gather_wood: T.FOREST,147- gather_stone: T.ROCK,148-};149-/** terrain code -> stockpile resource it yields. */150-const TERRAIN_RES: Record<number, ResourceKind> = {151- [T.BERRY]: 'food',152- [T.FOREST]: 'wood',153- [T.ROCK]: 'stone',154-};155-156-const cheb = (ax: number, ay: number, bx: number, by: number): number =>157- Math.max(Math.abs(ax - bx), Math.abs(ay - by));158-159-// ---------------------------------------------------------------------------160-// World161311 // ---------------------------------------------------------------------------162312 163313 class World implements EngineWorld {
@@ -167,35 +317,28 @@ class World implements EngineWorld {
167317 private readonly rng: Rng;168318 private readonly size: number;169319 private readonly terrain: Uint8Array;170- private readonly amount: Uint16Array;171- private readonly tribes: TribeState[] = [];172- /** All kept sorted ascending by id (ids are handed out monotonically). */173- private readonly units: Unit[] = [];174- private readonly buildings: Building[] = [];175- private readonly armies: Army[] = [];176- /** One building per tile: tile index -> building. */177- private readonly buildingAt = new Map<number, Building>();178- /** Tile index -> live resource-tile candidates, pruned lazily. */179- private readonly resTiles: Record<number, number[]>;180- /** Rebuilt each step: tile index -> units on it. */181- private grid = new Map<number, Unit[]>();182-183- private nextUnitId = 1;184- private nextBuildingId = 1;185- private nextArmyId = 1;320+ private readonly tribes: Tribe[] = [];321+ private readonly settlements: Settlement[] = []; // ascending id322+ private readonly warbands: Warband[] = [];323+ private readonly caravans: Caravan[] = [];324+ private readonly wagons: Wagon[] = [];325+ private readonly camps: Camp[] = [];326+ private effects: Effect[] = [];327+ private readonly offers: Offer[] = []; // ascending id328+329+ private nextSettlementId = 1;330+ private nextWarbandId = 1;331+ private nextCaravanId = 1; // shared with wagons332+ private nextCampId = 1;333+ private nextOfferId = 1;334+186335 private over = false;187336 private endReason: 'last_standing' | 'tick_cap' = 'tick_cap';188-189- /**190- * Events emitted during the most recent step() (plus any forfeit() calls191- * since). Cleared at the start of each step; viewFor() delivers a192- * fog-filtered slice. NOT part of stateHash() — derived narration only.193- */194337 private events: SimEvent[] = [];195- /** Per-tribe 'say' orders applied this step (belt-and-braces cap). */196338 private readonly saysThisStep: number[];197- /** lastSighted[observer * tribeCount + enemy]; -SIGHT_COOLDOWN = never. */198339 private readonly lastSighted: Int32Array;340+ private metrics: MetricsFrame | null = null;341+ private readonly scratchMask: Uint8Array;199342 200343 constructor(config: SimConfig) {201344 if (
@@ -205,7 +348,7 @@ class World implements EngineWorld {
205348 ) {206349 throw new Error(`tribeCount out of range: ${config.tribeCount}`);207350 }208- if (!Number.isInteger(config.mapSize) || config.mapSize < 24 || config.mapSize > 256) {351+ if (!Number.isInteger(config.mapSize) || config.mapSize < 48 || config.mapSize > 256) {209352 throw new Error(`mapSize out of range: ${config.mapSize}`);210353 }211354 if (!Number.isInteger(config.maxTicks) || config.maxTicks < 1) {
@@ -216,161 +359,331 @@ class World implements EngineWorld {
216359 this.rng = new Rng(config.seed);217360 this.saysThisStep = new Array<number>(config.tribeCount).fill(0);218361 this.lastSighted = new Int32Array(config.tribeCount * config.tribeCount).fill(219- -SIGHT_COOLDOWN,362+ -C.SIGHT_COOLDOWN,220363 );364+ this.scratchMask = new Uint8Array(this.size * this.size);221365 222366 const map = generateMap(this.size, config.tribeCount, this.rng);223367 this.terrain = map.terrain;224- this.amount = map.amount;225- this.resTiles = { [T.FOREST]: [], [T.BERRY]: [], [T.ROCK]: [] };226- for (let i = 0; i < this.terrain.length; i++) {227- const t = this.terrain[i]!;228- if (t === T.FOREST || t === T.BERRY || t === T.ROCK) this.resTiles[t]!.push(i);229- }230368 231369 for (let t = 0; t < config.tribeCount; t++) {370+ const alloc = new Int32Array(P_N);371+ const craft = Int32Array.from(C.DEFAULT_CRAFT);372+ const decrees = Int32Array.from(C.DEFAULT_DECREES);373+ const stances = new Int8Array(config.tribeCount).fill(1); // neutral232374 this.tribes.push({233375 id: t,234376 alive: true,235377 diedAt: -1,236- res: { ...START_RESOURCES },237- kills: 0,378+ alloc,379+ craft,380+ decrees,381+ stances,382+ techMask: 0,383+ researching: -1,384+ knowledge: 0,385+ faith: 0,386+ knowAcc: 0,387+ faithAcc: 0,388+ routes: [],238389 explored: new Uint8Array(this.size * this.size),390+ exploredDirty: true,391+ terrainCache: null,392+ buildingsCompleted: 0,393+ battlesWon: 0,394+ battlesLost: 0,395+ casDealt: 0,396+ casTaken: 0,397+ campsCleared: 0,398+ tradeVolume: 0,399+ caravansLost: 0,400+ prodI: new Int32Array(R_N),401+ consI: new Int32Array(R_N),402+ birthsI: 0,403+ deathsI: 0,404+ starvI: 0,405+ coveredExtractors: 0,406+ totalExtractors: 0,239407 });240408 }241- // Spawn: town_center + 3 villagers per tribe (fixed creation order = ids).242- const VILLAGER_OFFSETS: ReadonlyArray<readonly [number, number]> = [243- [-1, 0],244- [1, 0],245- [0, 1],246- ];247409 for (let t = 0; t < config.tribeCount; t++) {248- const s = map.spawns[t]!;249- this.addBuilding(t, 'town_center', s.x, s.y, 100);250- for (const [dx, dy] of VILLAGER_OFFSETS) {251- this.addUnit(t, 'villager', s.x + dx, s.y + dy);410+ const sp = map.spawns[t]!;411+ const s = this.addSettlement(t, sp.x, sp.y, LIMITS.START_POP);412+ s.morale = C.START_MORALE;413+ s.herds = C.START_HERDS;414+ s.horses = C.START_HORSES;415+ for (const [b, n] of Object.entries(C.START_BUILDINGS)) {416+ s.buildings[C.BLD_IDX[b as Building]] = n;417+ }418+ for (const [r, n] of Object.entries(C.START_STORES)) {419+ s.stores[C.RES_IDX[r as Resource]] = n;252420 }253421 }254422 }255423 256- // -- entity helpers -------------------------------------------------------424+ // -- small helpers --------------------------------------------------------257425 258- private addBuilding(259- tribe: number,260- kind: BuildingKind,261- x: number,262- y: number,263- progress: number,264- ): Building {265- const b: Building = {266- id: this.nextBuildingId++,267- tribe,268- kind,269- x,270- y,271- hp: BUILDING_STATS[kind].hp,272- progress,273- };274- this.buildings.push(b);275- this.buildingAt.set(y * this.size + x, b);276- this.reveal(tribe, x, y);277- return b;426+ private inMap(x: number, y: number): boolean {427+ return x >= 0 && y >= 0 && x < this.size && y < this.size;278428 }279429 280- private addUnit(tribe: number, kind: UnitKind, x: number, y: number): Unit {281- const u: Unit = {282- id: this.nextUnitId++,283- tribe,284- kind,285- x,286- y,287- hp: UNIT_STATS[kind].hp,288- job: 'idle',289- armyId: -1,290- tx: -1,291- ty: -1,292- };293- this.units.push(u);294- this.reveal(tribe, x, y);295- return u;296- }297-298- private findUnit(id: number): Unit | undefined {299- // Binary search over the ascending-id array.300- let lo = 0;301- let hi = this.units.length - 1;302- while (lo <= hi) {303- const mid = (lo + hi) >> 1;304- const u = this.units[mid]!;305- if (u.id === id) return u;306- if (u.id < id) lo = mid + 1;307- else hi = mid - 1;308- }430+ private terrainAt(x: number, y: number): number {431+ return this.terrain[y * this.size + x]!;432+ }433+434+ private passable(x: number, y: number): boolean {435+ return this.inMap(x, y) && this.terrainAt(x, y) !== TC.WATER;436+ }437+438+ private hasTech(tribe: number, tech: number): boolean {439+ return tech >= 0 && (this.tribes[tribe]!.techMask & (1 << tech)) !== 0;440+ }441+442+ private findSettlement(id: number): Settlement | undefined {443+ for (const s of this.settlements) if (s.id === id) return s;309444 return undefined;310445 }311446 312- private findArmy(id: number): Army | undefined {313- return this.armies.find((a) => a.id === id);447+ private findWarband(id: number): Warband | undefined {448+ for (const w of this.warbands) if (w.id === id) return w;449+ return undefined;314450 }315451 316- private reveal(tribe: number, x: number, y: number): void {317- const ex = this.tribes[tribe]!.explored;318- const s = this.size;319- const x0 = Math.max(0, x - VISION_RADIUS);320- const x1 = Math.min(s - 1, x + VISION_RADIUS);321- const y0 = Math.max(0, y - VISION_RADIUS);322- const y1 = Math.min(s - 1, y + VISION_RADIUS);323- for (let yy = y0; yy <= y1; yy++) {324- const row = yy * s;325- for (let xx = x0; xx <= x1; xx++) ex[row + xx] = 1;452+ private findCaravan(id: number): Caravan | undefined {453+ for (const c of this.caravans) if (c.id === id) return c;454+ return undefined;455+ }456+457+ private settlementsOf(tribe: number): Settlement[] {458+ return this.settlements.filter((s) => s.tribe === tribe);459+ }460+461+ private nearestOwnSettlement(tribe: number, x: number, y: number): Settlement | null {462+ let best: Settlement | null = null;463+ let bestD = 0x7fffffff;464+ for (const s of this.settlements) {465+ if (s.tribe !== tribe) continue;466+ const d = cheb(x, y, s.x, s.y);467+ if (d < bestD) {468+ best = s;469+ bestD = d;470+ }326471 }472+ return best;327473 }328474 329- private passable(x: number, y: number): boolean {330- if (x < 0 || y < 0 || x >= this.size || y >= this.size) return false;331- const i = y * this.size + x;332- if (this.terrain[i] === T.WATER) return false;333- const b = this.buildingAt.get(i);334- return !(b !== undefined && b.kind === 'wall' && b.progress >= 100);475+ private storeCapOf(s: Settlement): number {476+ return (477+ C.BASE_STORE_CAP +478+ s.buildings[BI.granary]! * C.GRANARY_CAP_BONUS +479+ s.buildings[BI.warehouse]! * C.WAREHOUSE_CAP_BONUS480+ );481+ }482+483+ private addStore(s: Settlement, res: number, amount: number): number {484+ if (amount <= 0) return 0;485+ const cap = this.storeCapOf(s);486+ const space = cap - s.stores[res]!;487+ const added = Math.max(0, Math.min(space, amount));488+ s.stores[res]! += added;489+ return added;490+ }491+492+ private prod(tribe: number, res: number, n: number): void {493+ if (n > 0) this.tribes[tribe]!.prodI[res]! += n;494+ }495+496+ private cons(tribe: number, res: number, n: number): void {497+ if (n > 0) this.tribes[tribe]!.consI[res]! += n;335498 }336499 337- private popOf(tribe: number): number {338- let n = 0;339- for (const u of this.units) if (u.tribe === tribe && u.hp > 0) n++;340- return n;500+ private effectActive(kind: number, tribe: number, target: number): boolean {501+ for (const e of this.effects) {502+ if (e.kind === kind && e.tribe === tribe && e.target === target && e.expiresAt > this.tick) {503+ return true;504+ }505+ }506+ return false;341507 }342508 343- private popCapOf(tribe: number): number {344- let cap = 0;345- for (const b of this.buildings) {346- if (b.tribe !== tribe || b.progress < 100 || b.hp <= 0) continue;347- cap += BUILDING_STATS[b.kind].popCap;509+ /** Consume a one-battle war rite on this warband, returning its ‰ bonus. */510+ private consumeWarRite(w: Warband): number {511+ for (let i = 0; i < this.effects.length; i++) {512+ const e = this.effects[i]!;513+ if (e.kind === FX.war && e.tribe === w.tribe && e.target === w.id && e.expiresAt > this.tick) {514+ this.effects.splice(i, 1);515+ return C.WAR_RITE_POWER_BONUS;516+ }348517 }349- return cap;518+ return 0;350519 }351520 352- private storageCapOf(tribe: number): number {353- let cap = BASE_STORAGE;354- for (const b of this.buildings) {355- if (b.tribe === tribe && b.kind === 'granary' && b.progress >= 100 && b.hp > 0) {356- cap += GRANARY_STORAGE;521+ private computeCensus(s: Settlement): void {522+ const r = s.radius;523+ let plains = 0;524+ let forest = 0;525+ let hills = 0;526+ let water = 0;527+ let ore = 0;528+ let gold = 0;529+ const x0 = Math.max(0, s.x - r);530+ const x1 = Math.min(this.size - 1, s.x + r);531+ const y0 = Math.max(0, s.y - r);532+ const y1 = Math.min(this.size - 1, s.y + r);533+ for (let y = y0; y <= y1; y++) {534+ for (let x = x0; x <= x1; x++) {535+ const t = this.terrainAt(x, y);536+ if (t === TC.PLAINS) plains++;537+ else if (t === TC.FOREST) forest++;538+ else if (C.HILLS_CLASS.has(t)) {539+ hills++;540+ if (t === TC.ORE) ore++;541+ else if (t === TC.GOLD) gold++;542+ } else if (C.WATER_CLASS.has(t)) water++;543+ }544+ }545+ let cold = false;546+ for (let dy = -1; dy <= 1 && !cold; dy++) {547+ for (let dx = -1; dx <= 1; dx++) {548+ const x = s.x + dx;549+ const y = s.y + dy;550+ if (this.inMap(x, y) && C.COLD_CLASS.has(this.terrainAt(x, y))) {551+ cold = true;552+ break;553+ }357554 }358555 }359- return cap;556+ s.census = { plains, forest, hills, water, ore, gold, cold };557+ }558+559+ private computeCapacity(s: Settlement): void {560+ const b = s.buildings;561+ const c = s.census;562+ const cap = s.capacity;563+ cap.fill(0);564+ cap[PI.farmer] = Math.min(b[BI.farm]! * C.FARM_SLOTS, c.plains * C.FARMERS_PER_PLAINS);565+ cap[PI.fisher] = Math.min(b[BI.dock]! * C.DOCK_SLOTS, c.water * C.FISHERS_PER_WATER);566+ cap[PI.herder] = b[BI.pasture]! * C.PASTURE_HERDER_SLOTS;567+ cap[PI.woodcutter] = Math.min(568+ b[BI.lumber_camp]! * C.LUMBER_SLOTS,569+ c.forest * C.WOODCUTTERS_PER_FOREST,570+ );571+ cap[PI.miner] = Math.min(b[BI.mine]! * C.MINE_SLOTS, c.hills * C.MINERS_PER_HILL);572+ cap[PI.miller] = b[BI.mill]! * C.MILL_SLOTS;573+ cap[PI.baker] = b[BI.bakery]! * C.BAKERY_SLOTS;574+ cap[PI.brewer] = b[BI.brewery]! * C.BREWERY_SLOTS;575+ cap[PI.smelter] = b[BI.smelter]! * C.SMELTER_SLOTS;576+ cap[PI.smith] = b[BI.smithy]! * C.SMITHY_SLOTS;577+ cap[PI.carpenter] =578+ b[BI.lumber_camp]! * C.CARPENTER_PER_LUMBER + b[BI.hall]! * C.CARPENTER_PER_HALL;579+ cap[PI.tanner] = b[BI.tannery]! * C.TANNERY_SLOTS;580+ cap[PI.weaver] = b[BI.weavers_hall]! * C.WEAVER_SLOTS;581+ cap[PI.tailor] = b[BI.tailor_shop]! * C.TAILOR_SLOTS;582+ cap[PI.builder] = C.UNCAPPED_SLOTS;583+ cap[PI.hauler] = C.UNCAPPED_SLOTS;584+ cap[PI.scholar] = b[BI.library]! * C.LIBRARY_SLOTS + b[BI.academy]! * C.ACADEMY_SLOTS;585+ cap[PI.priest] = b[BI.shrine]! * C.SHRINE_SLOTS + b[BI.church]! * C.CHURCH_SLOTS;360586 }361587 362- private canAfford(res: Resources, cost: Partial<Record<ResourceKind, number>>): boolean {588+ private housingOf(s: Settlement): number {363589 return (364- res.food >= (cost.food ?? 0) &&365- res.wood >= (cost.wood ?? 0) &&366- res.stone >= (cost.stone ?? 0)590+ s.buildings[BI.hall]! * (C.HALL_HOUSING_BASE + C.HALL_HOUSING_PER_TIER * s.tier) +591+ s.buildings[BI.house]! * C.HOUSE_HOUSING367592 );368593 }369594 370- private pay(res: Resources, cost: Partial<Record<ResourceKind, number>>): void {371- res.food -= cost.food ?? 0;372- res.wood -= cost.wood ?? 0;373- res.stone -= cost.stone ?? 0;595+ private addSettlement(tribe: number, x: number, y: number, pop: number): Settlement {596+ const s: Settlement = {597+ id: this.nextSettlementId++,598+ tribe,599+ x,600+ y,601+ tier: 0,602+ pop,603+ morale: 500,604+ radius: C.TIER_RADIUS[0],605+ herds: 0,606+ horses: 0,607+ wallDamage: 0,608+ stores: new Int32Array(R_N),609+ buildings: new Int32Array(B_N),610+ queue: [],611+ acc: new Int32Array(ACC_N),612+ kindsEaten: 0,613+ flags: 0,614+ allocOverride: new Int32Array(P_N).fill(-1),615+ census: { plains: 0, forest: 0, hills: 0, water: 0, ore: 0, gold: 0, cold: false },616+ workers: new Int32Array(P_N),617+ capacity: new Int32Array(P_N),618+ herderEff1000: 0,619+ };620+ s.buildings[BI.hall] = 1;621+ this.settlements.push(s);622+ this.updateTier(s);623+ this.computeCensus(s);624+ this.reveal(tribe, x, y, s.radius + C.SETTLEMENT_VISION_BONUS);625+ return s;626+ }627+628+ /** Tier never downgrades. Updates radius, census and vision on upgrade. */629+ private updateTier(s: Settlement): void {630+ let t = 0;631+ for (let i = C.TIER_POP.length - 1; i >= 0; i--) {632+ if (s.pop >= C.TIER_POP[i]!) {633+ t = i;634+ break;635+ }636+ }637+ if (t > s.tier || s.radius === 0) {638+ s.tier = Math.max(s.tier, t);639+ const r = C.TIER_RADIUS[s.tier]!;640+ if (r !== s.radius) {641+ s.radius = r;642+ this.computeCensus(s);643+ this.reveal(s.tribe, s.x, s.y, r + C.SETTLEMENT_VISION_BONUS);644+ }645+ }646+ }647+648+ private reveal(tribe: number, x: number, y: number, radius: number): void {649+ const tr = this.tribes[tribe]!;650+ const ex = tr.explored;651+ const s = this.size;652+ const x0 = Math.max(0, x - radius);653+ const x1 = Math.min(s - 1, x + radius);654+ const y0 = Math.max(0, y - radius);655+ const y1 = Math.min(s - 1, y + radius);656+ for (let yy = y0; yy <= y1; yy++) {657+ const row = yy * s;658+ for (let xx = x0; xx <= x1; xx++) {659+ if (ex[row + xx] === 0) {660+ ex[row + xx] = 1;661+ tr.exploredDirty = true;662+ }663+ }664+ }665+ }666+667+ /** One greedy step toward (tx,ty), sliding around blockers (v1 technique). */668+ private stepEntity(e: { x: number; y: number }, tx: number, ty: number): boolean {669+ const dx = Math.sign(tx - e.x);670+ const dy = Math.sign(ty - e.y);671+ if (dx === 0 && dy === 0) return false;672+ const tryStep = (sx: number, sy: number): boolean => {673+ if (sx === 0 && sy === 0) return false;674+ if (!this.passable(e.x + sx, e.y + sy)) return false;675+ e.x += sx;676+ e.y += sy;677+ return true;678+ };679+ return (680+ tryStep(dx, dy) ||681+ tryStep(dx, 0) ||682+ tryStep(0, dy) ||683+ (dx !== 0 && dy !== 0 && (tryStep(dx, -dy) || tryStep(-dx, dy))) ||684+ (dx === 0 && (tryStep(1, dy) || tryStep(-1, dy))) ||685+ (dy === 0 && (tryStep(dx, 1) || tryStep(dx, -1)))686+ );374687 }375688 376689 // -- step -----------------------------------------------------------------
@@ -379,61 +692,87 @@ class World implements EngineWorld {
379692 if (this.over) return;380693 this.events = [];381694 this.saysThisStep.fill(0);695+ const slow = (this.tick + 1) % SLOW === 0;382696 383- // 1. Orders.697+ // 1. Actions.384698 for (const tribe of this.tribes) {385699 if (!tribe.alive) continue;386700 const orders = ordersByTribe.get(tribe.id);387701 if (!Array.isArray(orders)) continue;388- for (const order of orders) this.applyOrder(tribe, order);702+ const n = Math.min(orders.length, LIMITS.ACTIONS_PER_TICK);703+ for (let i = 0; i < n; i++) this.applyOrder(tribe, orders[i] as Order);389704 }390705 391- // 2. Army intent.392- this.rebuildGrid();393- const goals = this.decideArmyIntent();706+ // 2. Territory & census.707+ if (slow) for (const s of this.settlements) this.computeCensus(s);394708 395- // 3. Movement & work.396- for (const u of this.units) {397- if (u.hp <= 0) continue;398- this.actUnit(u, goals);399- }709+ // 3. Labor.710+ this.phaseLabor();711+712+ // 4. Extraction.713+ this.phaseExtraction();714+715+ // 5. Refinement & construction.716+ this.phaseRefinement();717+ this.phaseConstruction();400718 401- // 4. Combat.402- this.rebuildGrid();403- this.combat();719+ // 6. Herds.720+ if (slow) this.phaseHerds();404721 405- // 5. Upkeep.406- if ((this.tick + 1) % UPKEEP_INTERVAL === 0) this.upkeep();722+ // 7. Logistics.723+ this.phaseLogisticsMove();724+ if (slow) this.phaseRoutes();407725 408- // 6. Death sweep.409- this.sweepDead();726+ // 8. Consumption.727+ this.phaseConsumption();410728 411- // 7. Eliminations.729+ // 9. Population & morale.730+ if (slow) this.phasePopulation();731+732+ // 10. Military.733+ this.phaseMilitary(slow);734+735+ // 11. Threats.736+ if (slow) this.phaseThreats();737+738+ // 12. Trade, faith & tech.739+ if (slow) this.phaseTradeFaithTech();740+741+ // 13. Elimination & end.412742 for (const tribe of this.tribes) {413743 if (!tribe.alive) continue;414- let hasTc = false;415- for (const b of this.buildings) {416- if (b.tribe === tribe.id && b.kind === 'town_center') {417- hasTc = true;744+ let hasS = false;745+ for (const s of this.settlements) {746+ if (s.tribe === tribe.id) {747+ hasS = true;418748 break;419749 }420750 }421- let hasUnit = false;422- for (const u of this.units) {423- if (u.tribe === tribe.id) {424- hasUnit = true;751+ if (hasS) continue;752+ let hasW = false;753+ for (const w of this.warbands) {754+ if (w.tribe === tribe.id) {755+ hasW = true;425756 break;426757 }427758 }428- if (!hasTc && !hasUnit) this.eliminate(tribe.id, 'eliminated');759+ if (!hasW) this.eliminate(tribe.id, 'eliminated');429760 }430761 431- // 7.5. Sighting events (after death sweep & eliminations: final positions).762+ // 14. Sightings, metrics, advance.432763 this.emitSightings();433-434- // 8. Advance & end check.435764 this.tick++;436765 this.checkEnd();766+ if (this.tick % SLOW === 0 || this.over) {767+ this.metrics = this.computeMetrics();768+ for (const t of this.tribes) {769+ t.prodI.fill(0);770+ t.consI.fill(0);771+ t.birthsI = 0;772+ t.deathsI = 0;773+ t.starvI = 0;774+ }775+ }437776 }438777 439778 private checkEnd(): void {
@@ -447,12 +786,12 @@ class World implements EngineWorld {
447786 }448787 }449788 450- /** Forfeit: the tribe's units and buildings vanish immediately. */451789 forfeit(tribeId: number): void {452790 const tribe = this.tribes[tribeId];453791 if (tribe === undefined || !tribe.alive || this.over) return;454792 this.eliminate(tribeId, 'forfeited');455793 this.checkEnd();794+ if (this.over) this.metrics = this.computeMetrics();456795 }457796 458797 private eliminate(tribeId: number, cause: 'eliminated' | 'forfeited'): void {
@@ -460,125 +799,163 @@ class World implements EngineWorld {
460799 tribe.alive = false;461800 tribe.diedAt = this.tick;462801 this.events.push({ t: cause, tick: this.tick, tribe: tribeId });463- for (let i = this.units.length - 1; i >= 0; i--) {464- if (this.units[i]!.tribe === tribeId) this.units.splice(i, 1);465- }466- for (let i = this.buildings.length - 1; i >= 0; i--) {467- const b = this.buildings[i]!;468- if (b.tribe === tribeId) {469- this.buildingAt.delete(b.y * this.size + b.x);470- this.buildings.splice(i, 1);471- }802+ const drop = <T extends { tribe: number }>(arr: T[]): void => {803+ for (let i = arr.length - 1; i >= 0; i--) if (arr[i]!.tribe === tribeId) arr.splice(i, 1);804+ };805+ drop(this.caravans);806+ drop(this.wagons);807+ drop(this.warbands);808+ for (let i = this.settlements.length - 1; i >= 0; i--) {809+ if (this.settlements[i]!.tribe === tribeId) this.settlements.splice(i, 1);472810 }473- for (let i = this.armies.length - 1; i >= 0; i--) {474- if (this.armies[i]!.tribe === tribeId) this.armies.splice(i, 1);811+ for (let i = this.offers.length - 1; i >= 0; i--) {812+ if (this.offers[i]!.tribe === tribeId) this.offers.splice(i, 1);475813 }814+ this.effects = this.effects.filter((e) => e.tribe !== tribeId);815+ tribe.routes.length = 0;476816 }477817 478- // -- orders ---------------------------------------------------------------818+ // -- phase 1: actions -----------------------------------------------------479819 480- private applyOrder(tribe: TribeState, order: Order): void {820+ private applyOrder(tribe: Tribe, order: Order): void {481821 if (order === null || typeof order !== 'object') return;482822 switch (order.kind) {483- case 'assign': {484- if (!Number.isInteger(order.unitId) || !JOBS.has(order.job as string)) return;485- const u = this.findUnit(order.unitId);486- if (u === undefined || u.tribe !== tribe.id || u.kind !== 'villager') return;487- if (u.armyId !== -1) return;488- u.job = order.job;489- u.tx = -1;490- u.ty = -1;823+ case 'allocate': {824+ const p = C.PROF_IDX[order.profession as Profession] as number | undefined;825+ if (p === undefined) return;826+ const v = order.permille;827+ if (!Number.isInteger(v) || v < 0 || v > 1000) return;828+ if (order.settlementId === undefined) {829+ tribe.alloc[p] = v;830+ return;831+ }832+ const s = this.findSettlement(order.settlementId);833+ if (s === undefined || s.tribe !== tribe.id) return;834+ s.allocOverride[p] = v;491835 return;492836 }493- case 'build': {494- const stats = BUILDING_STATS[order.building as BuildingKind] as495- | (typeof BUILDING_STATS)[BuildingKind]496- | undefined;497- if (stats === undefined || stats.cost === null) return;498- const { x, y } = order;499- if (!Number.isInteger(x) || !Number.isInteger(y)) return;500- if (x < 0 || y < 0 || x >= this.size || y >= this.size) return;501- const i = y * this.size + x;502- if (this.terrain[i] !== T.GRASS) return;503- if (this.buildingAt.has(i)) return;504- if (!this.canAfford(tribe.res, stats.cost)) return;505- this.pay(tribe.res, stats.cost);506- this.addBuilding(tribe.id, order.building, x, y, 0);837+ case 'craft': {838+ const i = CRAFT_KEYS.indexOf(order.target as CraftTarget);839+ if (i === -1) return;840+ const v = order.permille;841+ if (!Number.isInteger(v) || v < 0 || v > 1000) return;842+ tribe.craft[i] = v;507843 return;508844 }509- case 'train': {510- const stats = UNIT_STATS[order.unit as UnitKind] as511- | (typeof UNIT_STATS)[UnitKind]512- | undefined;513- if (stats === undefined) return;514- const siteKind: BuildingKind = order.unit === 'soldier' ? 'barracks' : 'town_center';515- let site: Building | undefined;516- for (const b of this.buildings) {517- if (b.tribe === tribe.id && b.kind === siteKind && b.progress >= 100 && b.hp > 0) {518- site = b;519- break;520- }845+ case 'decree': {846+ const i = DECREE_KEYS.indexOf(order.key as DecreeKey);847+ if (i === -1) return;848+ const v = order.value;849+ const max = order.key === 'rations' ? 2000 : 1000;850+ if (!Number.isInteger(v) || v < 0 || v > max) return;851+ tribe.decrees[i] = v;852+ return;853+ }854+ case 'build':855+ this.orderBuild(tribe, order);856+ return;857+ case 'demolish': {858+ const b = C.BLD_IDX[order.building as Building] as number | undefined;859+ if (b === undefined || b === BI.hall) return;860+ const s = this.findSettlement(order.settlementId);861+ if (s === undefined || s.tribe !== tribe.id || s.buildings[b]! <= 0) return;862+ s.buildings[b]!--;863+ return;864+ }865+ case 'found':866+ this.orderFound(tribe, order);867+ return;868+ case 'route': {869+ const res = C.RES_IDX[order.resource as Resource] as number | undefined;870+ if (res === undefined) return;871+ const v = order.permilleOfSurplus;872+ if (!Number.isInteger(v) || v < 0 || v > 1000) return;873+ const from = this.findSettlement(order.fromId);874+ const to = this.findSettlement(order.toId);875+ if (from === undefined || to === undefined) return;876+ if (from.tribe !== tribe.id || to.tribe !== tribe.id || from.id === to.id) return;877+ const existing = tribe.routes.findIndex(878+ (r) => r.fromId === order.fromId && r.toId === order.toId && r.res === res,879+ );880+ if (v === 0) {881+ if (existing !== -1) tribe.routes.splice(existing, 1);882+ return;883+ }884+ if (existing !== -1) {885+ tribe.routes[existing]!.permille = v;886+ return;521887 }522- if (site === undefined) return;523- if (this.popOf(tribe.id) >= this.popCapOf(tribe.id)) return;524- if (!this.canAfford(tribe.res, stats.cost)) return;525- this.pay(tribe.res, stats.cost);526- this.addUnit(tribe.id, order.unit, site.x, site.y);888+ if (tribe.routes.length >= LIMITS.MAX_ROUTES) return;889+ tribe.routes.push({ fromId: order.fromId, toId: order.toId, res, permille: v });527890 return;528891 }529- case 'form_army': {530- if (!Array.isArray(order.unitIds) || !STANCES.has(order.stance as string)) return;531- const members: Unit[] = [];532- const seen = new Set<number>();533- for (const id of order.unitIds) {534- if (!Number.isInteger(id) || seen.has(id)) continue;535- seen.add(id);536- const u = this.findUnit(id);892+ case 'muster':893+ this.orderMuster(tribe, order);894+ return;895+ case 'command':896+ this.orderCommand(tribe, order);897+ return;898+ case 'trade_offer': {899+ const give = C.RES_IDX[order.give as Resource] as number | undefined;900+ const want = C.RES_IDX[order.want as Resource] as number | undefined;901+ if (give === undefined || want === undefined || give === want) return;902+ const ga = order.giveAmount;903+ const wa = order.wantAmount;904+ if (!Number.isInteger(ga) || !Number.isInteger(wa) || ga < 1 || wa < 1) return;905+ if (ga > 1_000_000 || wa > 1_000_000) return;906+ let cp = -1;907+ if (order.tribe !== null) {537908 if (538- u !== undefined &&539- u.tribe === tribe.id &&540- u.kind === 'soldier' &&541- u.armyId === -1 &&542- u.hp > 0909+ !Number.isInteger(order.tribe) ||910+ order.tribe === tribe.id ||911+ order.tribe < 0 ||912+ order.tribe >= this.tribes.length543913 ) {544- members.push(u);914+ return;545915 }916+ cp = order.tribe;546917 }547- if (members.length === 0) return;548- members.sort((a, b) => a.id - b.id);549- const army: Army = {550- id: this.nextArmyId++,918+ let own = 0;919+ for (const o of this.offers) if (o.tribe === tribe.id) own++;920+ if (own >= LIMITS.MAX_OFFERS) return;921+ this.offers.push({922+ id: this.nextOfferId++,551923 tribe: tribe.id,552- stance: order.stance,553- unitIds: members.map((m) => m.id),554- tx: -1,555- ty: -1,556- };557- for (const m of members) m.armyId = army.id;558- this.armies.push(army);924+ give,925+ giveAmount: ga,926+ want,927+ wantAmount: wa,928+ counterparty: cp,929+ repeat: order.repeat === true,930+ });931+ return;932+ }933+ case 'trade_cancel': {934+ const i = this.offers.findIndex((o) => o.id === order.offerId && o.tribe === tribe.id);935+ if (i !== -1) this.offers.splice(i, 1);559936 return;560937 }561- case 'set_stance': {562- if (!Number.isInteger(order.armyId) || !STANCES.has(order.stance as string)) return;563- const army = this.findArmy(order.armyId);564- if (army === undefined || army.tribe !== tribe.id) return;565- army.stance = order.stance;938+ case 'stance': {939+ const p = POSTURES.indexOf(order.posture as Posture);940+ if (p === -1) return;941+ const t = order.tribeId;942+ if (!Number.isInteger(t) || t === tribe.id || t < 0 || t >= this.tribes.length) return;943+ tribe.stances[t] = p;566944 return;567945 }568- case 'move_army': {569- if (!Number.isInteger(order.armyId)) return;570- const { x, y } = order;571- if (!Number.isInteger(x) || !Number.isInteger(y)) return;572- if (x < 0 || y < 0 || x >= this.size || y >= this.size) return;573- const army = this.findArmy(order.armyId);574- if (army === undefined || army.tribe !== tribe.id) return;575- army.tx = x;576- army.ty = y;946+ case 'research': {947+ const t = C.TECH_IDX[order.tech as Tech] as number | undefined;948+ if (t === undefined || this.hasTech(tribe.id, t)) return;949+ for (const pre of C.TECH_PREREQS[TECHS[t]!]!) {950+ if (!this.hasTech(tribe.id, C.TECH_IDX[pre])) return;951+ }952+ tribe.researching = t;577953 return;578954 }955+ case 'rite':956+ this.orderRite(tribe, order);957+ return;579958 case 'say': {580- // Host sanitizes; the engine re-guards (belt and braces). Pure event —581- // never touches sim state, so it cannot affect stateHash().582959 if (typeof order.text !== 'string' || order.text.length === 0) return;583960 if (this.saysThisStep[tribe.id]! >= LIMITS.SAYS_PER_TICK) return;584961 this.saysThisStep[tribe.id] = this.saysThisStep[tribe.id]! + 1;
@@ -590,600 +967,1960 @@ class World implements EngineWorld {
590967 });591968 return;592969 }593- case 'disband_army': {594- if (!Number.isInteger(order.armyId)) return;595- const idx = this.armies.findIndex((a) => a.id === order.armyId);596- if (idx === -1 || this.armies[idx]!.tribe !== tribe.id) return;597- for (const id of this.armies[idx]!.unitIds) {598- const u = this.findUnit(id);599- if (u !== undefined) u.armyId = -1;600- }601- this.armies.splice(idx, 1);602- return;603- }604970 default:605971 return;606972 }607973 }608974 609- // -- army intent ----------------------------------------------------------610-611- /** Per-army marching goal; defend armies also get an intruder list. */612- private decideArmyIntent(): Map<number, { x: number; y: number; intruders: Unit[] | null }> {613- const goals = new Map<number, { x: number; y: number; intruders: Unit[] | null }>();614- for (const army of this.armies) {615- if (army.stance === 'defend') {616- let tc: Building | undefined;617- for (const b of this.buildings) {618- if (b.tribe === army.tribe && b.kind === 'town_center') {619- tc = b;620- break;621- }622- }623- if (tc === undefined) continue; // hold position624- const intruders: Unit[] = [];625- for (const u of this.units) {626- if (u.tribe === army.tribe || u.hp <= 0) continue;627- if (cheb(u.x, u.y, tc.x, tc.y) <= DEFEND_INTERCEPT_RADIUS) intruders.push(u);628- }629- goals.set(army.id, { x: tc.x, y: tc.y, intruders });630- continue;975+ private orderBuild(tribe: Tribe, order: Extract<Order, { kind: 'build' }>): void {976+ const b = C.BLD_IDX[order.building as Building] as number | undefined;977+ if (b === undefined) return;978+ const spec = C.BUILD_SPECS[order.building as Building];979+ if (spec.cost === null) return; // hall980+ const s = this.findSettlement(order.settlementId);981+ if (s === undefined || s.tribe !== tribe.id) return;982+ let count = order.count === undefined ? 1 : order.count;983+ if (!Number.isInteger(count) || count < 1) return;984+ count = Math.min(count, 5);985+ for (let k = 0; k < count; k++) {986+ if (s.queue.length >= C.BUILD_QUEUE_MAX) return;987+ let cost = spec.cost;988+ let points = spec.points;989+ let tech = spec.tech;990+ if (b === BI.walls) {991+ const queued = s.queue.filter((q) => q.b === BI.walls).length;992+ const target = s.buildings[BI.walls]! + queued + 1;993+ if (target > C.MAX_WALL_LEVEL) return;994+ const lvl = C.WALL_LEVELS[target - 1]!;995+ cost = lvl.cost;996+ points = lvl.points;997+ tech = lvl.tech;631998 }632- if (army.stance === 'roam') {633- // New waypoint when none, or when the reference (lowest-id) unit arrives.634- const ref = this.findUnit(army.unitIds[0]!);635- const arrived =636- ref !== undefined && army.tx !== -1 && cheb(ref.x, ref.y, army.tx, army.ty) <= 1;637- if (army.tx === -1 || arrived) {638- for (let i = 0; i < ROAM_PICK_TRIES; i++) {639- const x = this.rng.int(this.size);640- const y = this.rng.int(this.size);641- if (this.passable(x, y)) {642- army.tx = x;643- army.ty = y;644- break;645- }646- }647- }999+ if (tech !== -1 && !this.hasTech(tribe.id, tech)) return;1000+ if (b === BI.tower && s.buildings[BI.walls]! < 1) return;1001+ if (b === BI.farm && s.census.plains < 1) return;1002+ if (b === BI.dock && s.census.water < 1) return;1003+ if (b === BI.lumber_camp && s.census.forest < 1) return;1004+ if (b === BI.mine && s.census.hills < 1) return;1005+ const timber = cost.timber ?? 0;1006+ const planks = cost.planks ?? 0;1007+ const stone = cost.stone ?? 0;1008+ if (1009+ s.stores[RI.timber]! < timber ||1010+ s.stores[RI.planks]! < planks ||1011+ s.stores[RI.stone]! < stone1012+ ) {1013+ return;6481014 }649- if (army.tx !== -1) goals.set(army.id, { x: army.tx, y: army.ty, intruders: null });1015+ s.stores[RI.timber]! -= timber;1016+ s.stores[RI.planks]! -= planks;1017+ s.stores[RI.stone]! -= stone;1018+ this.cons(tribe.id, RI.timber, timber);1019+ this.cons(tribe.id, RI.planks, planks);1020+ this.cons(tribe.id, RI.stone, stone);1021+ s.queue.push({ b, progress: 0, req: points });6501022 }651- return goals;6521023 }6531024 654- // -- movement & work ------------------------------------------------------655-656- private unitsAt(x: number, y: number): Unit[] | undefined {657- return this.grid.get(y * this.size + x);1025+ private orderFound(tribe: Tribe, order: Extract<Order, { kind: 'found' }>): void {1026+ const { x, y, settlers } = order;1027+ if (!Number.isInteger(x) || !Number.isInteger(y) || !this.inMap(x, y)) return;1028+ if (!this.passable(x, y) || this.terrainAt(x, y) === TC.VOLCANO) return;1029+ if (!Number.isInteger(settlers) || settlers < C.FOUND_MIN_SETTLERS) return;1030+ for (const s of this.settlements) {1031+ if (cheb(x, y, s.x, s.y) < C.MIN_SETTLE_DIST) return;1032+ }1033+ // Source: own settlement with the largest pop (tie: lowest id).1034+ let src: Settlement | null = null;1035+ for (const s of this.settlements) {1036+ if (s.tribe !== tribe.id) continue;1037+ if (src === null || s.pop > src.pop) src = s;1038+ }1039+ if (src === null || src.pop < settlers + C.MIN_REMAINING_POP) return;1040+ if (order.supplies === null || typeof order.supplies !== 'object') return;1041+ const supplies = new Int32Array(R_N);1042+ for (const [rName, amt] of Object.entries(order.supplies)) {1043+ const r = C.RES_IDX[rName as Resource] as number | undefined;1044+ if (r === undefined || !Number.isInteger(amt) || (amt as number) < 0) return;1045+ supplies[r] = amt as number;1046+ }1047+ for (let r = 0; r < R_N; r++) if (src.stores[r]! < supplies[r]!) return;1048+ src.pop -= settlers;1049+ for (let r = 0; r < R_N; r++) src.stores[r]! -= supplies[r]!;1050+ this.wagons.push({1051+ id: this.nextCaravanId++,1052+ tribe: tribe.id,1053+ x: src.x,1054+ y: src.y,1055+ tx: x,1056+ ty: y,1057+ settlers,1058+ supplies,1059+ });6581060 }6591061 660- private rebuildGrid(): void {661- this.grid = new Map();662- for (const u of this.units) {663- if (u.hp <= 0) continue;664- const i = u.y * this.size + u.x;665- const list = this.grid.get(i);666- if (list === undefined) this.grid.set(i, [u]);667- else list.push(u);1062+ private orderMuster(tribe: Tribe, order: Extract<Order, { kind: 'muster' }>): void {1063+ const kind = WB_KINDS.indexOf(order.unitKind as WarbandKind);1064+ if (kind === -1) return;1065+ const s = this.findSettlement(order.settlementId);1066+ if (s === undefined || s.tribe !== tribe.id) return;1067+ if (s.buildings[BI.barracks]! < 1) return;1068+ const size = order.size;1069+ if (!Number.isInteger(size) || size < C.MUSTER_MIN_SIZE) return;1070+ if (s.pop < size + C.MIN_REMAINING_POP) return;1071+ if (s.stores[RI.weapons]! < size) return; // hard requirement1072+ if (kind === 2) {1073+ if (!this.hasTech(tribe.id, C.TECH_IDX.horsemanship)) return;1074+ if (s.horses < size) return;1075+ }1076+ s.pop -= size;1077+ s.stores[RI.weapons]! -= size;1078+ this.cons(tribe.id, RI.weapons, size);1079+ const armor = Math.min(size, s.stores[RI.armor]!);1080+ s.stores[RI.armor]! -= armor;1081+ this.cons(tribe.id, RI.armor, armor);1082+ let horses = 0;1083+ if (kind === 2) {1084+ horses = size;1085+ s.horses -= size;1086+ }1087+ let siege = false;1088+ if (1089+ this.hasTech(tribe.id, C.TECH_IDX.siegecraft) &&1090+ s.stores[RI.planks]! >= C.RAM_PLANKS &&1091+ s.stores[RI.metal]! >= C.RAM_METAL1092+ ) {1093+ s.stores[RI.planks]! -= C.RAM_PLANKS;1094+ s.stores[RI.metal]! -= C.RAM_METAL;1095+ this.cons(tribe.id, RI.planks, C.RAM_PLANKS);1096+ this.cons(tribe.id, RI.metal, C.RAM_METAL);1097+ siege = true;6681098 }1099+ const w: Warband = {1100+ id: this.nextWarbandId++,1101+ tribe: tribe.id,1102+ home: s.id,1103+ size,1104+ kind,1105+ weapons: size,1106+ armor,1107+ horses,1108+ vet: 0,1109+ morale: C.STARTING_WARBAND_MORALE,1110+ x: s.x,1111+ y: s.y,1112+ supply: 0,1113+ siege,1114+ order: { type: WO.garrison, targetId: s.id, tx: s.x, ty: s.y },1115+ desertAcc: 0,1116+ engaged: false,1117+ moved: false,1118+ };1119+ w.supply = this.takeFood(s, size * C.SUPPLY_CAP_PER_SOLDIER);1120+ this.warbands.push(w);6691121 }6701122 671- /** Nearest live enemy unit within Chebyshev radius r (nearest, then lowest id). */672- private nearestEnemyNear(u: Unit, r: number): Unit | null {673- let best: Unit | null = null;674- let bestD = r + 1;675- const x0 = Math.max(0, u.x - r);676- const x1 = Math.min(this.size - 1, u.x + r);677- const y0 = Math.max(0, u.y - r);678- const y1 = Math.min(this.size - 1, u.y + r);679- for (let yy = y0; yy <= y1; yy++) {680- for (let xx = x0; xx <= x1; xx++) {681- const list = this.unitsAt(xx, yy);682- if (list === undefined) continue;683- for (const e of list) {684- if (e.tribe === u.tribe || e.hp <= 0) continue;685- const d = cheb(u.x, u.y, e.x, e.y);686- if (d < bestD || (d === bestD && (best === null || e.id < best.id))) {687- best = e;688- bestD = d;689- }690- }691- }1123+ /** Take up to `amount` food units (grain/fish/meat/bread) from stores. */1124+ private takeFood(s: Settlement, amount: number): number {1125+ let left = amount;1126+ for (const r of SUPPLY_ORDER) {1127+ if (left <= 0) break;1128+ const take = Math.min(left, s.stores[r]!);1129+ s.stores[r]! -= take;1130+ this.cons(s.tribe, r, take);1131+ left -= take;6921132 }693- return best;1133+ return amount - left;6941134 }6951135 696- /** True if an enemy unit — or (for soldiers) enemy building — is in contact range. */697- private inContactWithEnemy(u: Unit): boolean {698- for (let dy = -1; dy <= 1; dy++) {699- for (let dx = -1; dx <= 1; dx++) {700- const x = u.x + dx;701- const y = u.y + dy;702- if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;703- const list = this.unitsAt(x, y);704- if (list !== undefined) {705- for (const e of list) if (e.tribe !== u.tribe && e.hp > 0) return true;706- }707- if (u.kind === 'soldier') {708- const b = this.buildingAt.get(y * this.size + x);709- if (b !== undefined && b.tribe !== u.tribe && b.hp > 0) return true;1136+ private orderCommand(tribe: Tribe, order: Extract<Order, { kind: 'command' }>): void {1137+ const w = this.findWarband(order.warbandId);1138+ if (w === undefined || w.tribe !== tribe.id) return;1139+ const o = order.order as WarbandOrder;1140+ if (o === null || typeof o !== 'object') return;1141+ const type = WO[o.type as keyof typeof WO] as number | undefined;1142+ if (type === undefined) return;1143+ const tgt = o.target;1144+ switch (type) {1145+ case WO.march:1146+ case WO.patrol: {1147+ if (tgt === null || typeof tgt !== 'object') return;1148+ const { x, y } = tgt;1149+ if (!Number.isInteger(x) || !Number.isInteger(y) || !this.inMap(x, y)) return;1150+ w.order = { type, targetId: -1, tx: x, ty: y };1151+ return;1152+ }1153+ case WO.garrison: {1154+ if (typeof tgt !== 'number') return;1155+ const s = this.findSettlement(tgt);1156+ if (s === undefined || s.tribe !== tribe.id) return;1157+ w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1158+ return;1159+ }1160+ case WO.besiege: {1161+ if (typeof tgt !== 'number') return;1162+ const s = this.findSettlement(tgt);1163+ if (s === undefined || s.tribe === tribe.id) return;1164+ if (tribe.stances[s.tribe] === P_ALLY) return;1165+ w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1166+ return;1167+ }1168+ case WO.raid: {1169+ if (typeof tgt !== 'number') return;1170+ const s = this.findSettlement(tgt);1171+ if (s !== undefined) {1172+ if (s.tribe === tribe.id || tribe.stances[s.tribe] === P_ALLY) return;1173+ w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1174+ return;7101175 }1176+ const c = this.findCaravan(tgt);1177+ if (c === undefined || c.tribe === tribe.id) return;1178+ if (tribe.stances[c.tribe] === P_ALLY) return;1179+ w.order = { type, targetId: c.id, tx: c.x, ty: c.y };1180+ return;1181+ }1182+ case WO.escort: {1183+ if (typeof tgt !== 'number') return;1184+ const c = this.findCaravan(tgt);1185+ if (c === undefined || c.tribe !== tribe.id) return;1186+ w.order = { type, targetId: c.id, tx: c.x, ty: c.y };1187+ return;1188+ }1189+ case WO.disband: {1190+ if (tgt !== null) return;1191+ w.order = { type, targetId: -1, tx: w.x, ty: w.y };1192+ return;7111193 }1194+ default:1195+ return;7121196 }713- return false;7141197 }7151198 716- /** One greedy step toward (tx,ty), sliding around blockers. Deterministic. */717- private moveTowards(u: Unit, tx: number, ty: number): void {718- const dx = Math.sign(tx - u.x);719- const dy = Math.sign(ty - u.y);720- if (dx === 0 && dy === 0) return;721- let nx = 0;722- let ny = 0;723- const tryStep = (sx: number, sy: number): boolean => {724- if (sx === 0 && sy === 0) return false;725- const px = u.x + sx;726- const py = u.y + sy;727- if (!this.passable(px, py)) return false;728- nx = px;729- ny = py;730- return true;731- };732- const moved =733- tryStep(dx, dy) ||734- tryStep(dx, 0) ||735- tryStep(0, dy) ||736- (dx !== 0 && dy !== 0 && (tryStep(dx, -dy) || tryStep(-dx, dy))) ||737- (dx === 0 && (tryStep(1, dy) || tryStep(-1, dy))) ||738- (dy === 0 && (tryStep(dx, 1) || tryStep(dx, -1)));739- if (moved) {740- u.x = nx;741- u.y = ny;742- this.reveal(u.tribe, nx, ny);1199+ private orderRite(tribe: Tribe, order: Extract<Order, { kind: 'rite' }>): void {1200+ const kind = RITE_KINDS.indexOf(order.rite as RiteKind);1201+ if (kind === -1) return;1202+ const cost = C.RITE_COSTS[order.rite as RiteKind];1203+ if (tribe.faith < cost) return;1204+ let target = -1;1205+ let expiresAt: number;1206+ if (kind === FX.blessing || kind === FX.harvest) {1207+ if (typeof order.target !== 'number') return;1208+ const s = this.findSettlement(order.target);1209+ if (s === undefined || s.tribe !== tribe.id) return;1210+ target = s.id;1211+ expiresAt =1212+ this.tick + (kind === FX.blessing ? C.RITE_DURATION.blessing : C.RITE_DURATION.harvest);1213+ } else if (kind === FX.warding) {1214+ if (order.target !== null) return;1215+ expiresAt = this.tick + C.RITE_DURATION.warding;1216+ } else {1217+ // war: one battle, or 200 ticks, whichever first1218+ if (typeof order.target !== 'number') return;1219+ const w = this.findWarband(order.target);1220+ if (w === undefined || w.tribe !== tribe.id) return;1221+ target = w.id;1222+ expiresAt = this.tick + 200;7431223 }1224+ tribe.faith -= cost;1225+ this.effects.push({ kind, tribe: tribe.id, target, expiresAt });7441226 }7451227 746- private actUnit(747- u: Unit,748- goals: Map<number, { x: number; y: number; intruders: Unit[] | null }>,749- ): void {750- if (u.armyId !== -1) {751- this.actArmyUnit(u, goals);752- return;753- }754- if (u.kind !== 'villager') return; // idle soldiers hold (they still fight in combat)755- if (u.job === 'build') {756- this.actBuilder(u);757- return;758- }759- const wanted = JOB_TERRAIN[u.job];760- if (wanted !== undefined) this.actGatherer(u, wanted);761- }762-763- private actArmyUnit(764- u: Unit,765- goals: Map<number, { x: number; y: number; intruders: Unit[] | null }>,766- ): void {767- // Fight-lock: never walk away from an adjacent enemy.768- if (this.inContactWithEnemy(u)) return;769- const army = this.findArmy(u.armyId);770- if (army === undefined) return;771- const goal = goals.get(u.armyId);772- if (army.stance === 'attack') {773- const e = this.nearestEnemyNear(u, ENGAGE_RADIUS);774- if (e !== null) {775- this.moveTowards(u, e.x, e.y);776- return;777- }778- if (goal !== undefined) this.moveTowards(u, goal.x, goal.y);779- return;780- }781- if (army.stance === 'defend') {782- if (goal === undefined) return; // no town center: hold783- const intruders = goal.intruders!;784- if (intruders.length > 0) {785- let best: Unit = intruders[0]!;786- let bestD = cheb(u.x, u.y, best.x, best.y);787- for (const e of intruders) {788- const d = cheb(u.x, u.y, e.x, e.y);789- if (d < bestD || (d === bestD && e.id < best.id)) {790- best = e;791- bestD = d;1228+ // -- phase 3: labor -------------------------------------------------------1229+1230+ private phaseLabor(): void {1231+ for (const s of this.settlements) this.computeCapacity(s);1232+ for (const tribe of this.tribes) {1233+ if (!tribe.alive) continue;1234+ const own = this.settlementsOf(tribe.id);1235+ if (own.length === 0) continue;1236+ const poolS: Settlement[] = [];1237+ for (const s of own) {1238+ s.workers.fill(0);1239+ let hasOverride = false;1240+ for (let p = 0; p < P_N; p++) {1241+ if (s.allocOverride[p]! >= 0) {1242+ hasOverride = true;1243+ break;7921244 }7931245 }794- this.moveTowards(u, best.x, best.y);795- return;1246+ if (hasOverride) this.laborLocal(tribe, s);1247+ else poolS.push(s);7961248 }797- if (cheb(u.x, u.y, goal.x, goal.y) > DEFEND_HOLD_RADIUS) {798- this.moveTowards(u, goal.x, goal.y);1249+ if (poolS.length === 0) continue;1250+ // Tribe-pool: apportion total pop across professions (+idle), then each1251+ // profession across settlements by capacity (largest remainder, ties1252+ // ascending settlement id = array order).1253+ let popTotal = 0;1254+ for (const s of poolS) popTotal += s.pop;1255+ const weights: number[] = new Array(P_N + 1);1256+ let sum = 0;1257+ for (let p = 0; p < P_N; p++) {1258+ weights[p] = tribe.alloc[p]!;1259+ sum += tribe.alloc[p]!;1260+ }1261+ weights[P_N] = Math.max(0, 1000 - sum);1262+ const totals = apportion(popTotal, weights);1263+ const remaining = poolS.map((s) => s.pop);1264+ const caps = new Array<number>(poolS.length);1265+ for (let p = 0; p < P_N; p++) {1266+ const total = totals[p]!;1267+ if (total === 0) continue;1268+ for (let i = 0; i < poolS.length; i++) caps[i] = poolS[i]!.capacity[p]!;1269+ const shares = apportion(total, caps);1270+ for (let i = 0; i < poolS.length; i++) {1271+ const s = poolS[i]!;1272+ const wk = Math.min(shares[i]!, s.capacity[p]!, remaining[i]!);1273+ s.workers[p] = wk;1274+ remaining[i]! -= wk;1275+ }7991276 }800- return;8011277 }802- // roam803- if (goal !== undefined) this.moveTowards(u, goal.x, goal.y);8041278 }8051279 806- private actGatherer(u: Unit, wanted: number): void {807- // Harvest if a matching tile is adjacent-or-underfoot (lowest tile index).808- let harvestTile = -1;809- for (let dy = -1; dy <= 1; dy++) {810- for (let dx = -1; dx <= 1; dx++) {811- const x = u.x + dx;812- const y = u.y + dy;813- if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;814- const i = y * this.size + x;815- if (this.terrain[i] !== wanted || this.amount[i] === 0) continue;816- if (harvestTile === -1 || i < harvestTile) harvestTile = i;817- }818- }819- if (harvestTile !== -1) {820- const tribe = this.tribes[u.tribe]!;821- const res = TERRAIN_RES[wanted]!;822- if (tribe.res[res] < this.storageCapOf(u.tribe)) {823- tribe.res[res] += GATHER_RATE;824- const left = this.amount[harvestTile]! - GATHER_RATE;825- this.amount[harvestTile] = Math.max(0, left);826- if (left <= 0) this.terrain[harvestTile] = T.GRASS; // pruned from resTiles lazily827- }828- return;1280+ /** Per-settlement scheme for settlements with any allocation override. */1281+ private laborLocal(tribe: Tribe, s: Settlement): void {1282+ const weights: number[] = new Array(P_N + 1);1283+ let sum = 0;1284+ for (let p = 0; p < P_N; p++) {1285+ const v = s.allocOverride[p]! >= 0 ? s.allocOverride[p]! : tribe.alloc[p]!;1286+ weights[p] = v;1287+ sum += v;8291288 }830- // Walk toward the cached target, re-searching when it is gone.831- const cachedIdx = u.tx === -1 ? -1 : u.ty * this.size + u.tx;832- const cachedValid =833- cachedIdx !== -1 && this.terrain[cachedIdx] === wanted && this.amount[cachedIdx]! > 0;834- if (!cachedValid) {835- const found = this.findNearestResource(wanted, u.x, u.y);836- if (found === -1) return; // nothing left of this kind837- u.tx = found % this.size;838- u.ty = Math.floor(found / this.size);1289+ weights[P_N] = Math.max(0, 1000 - sum);1290+ const totals = apportion(s.pop, weights);1291+ for (let p = 0; p < P_N; p++) {1292+ s.workers[p] = Math.min(totals[p]!, s.capacity[p]!);8391293 }840- this.moveTowards(u, u.tx, u.ty);8411294 }8421295 843- /** Nearest live tile of terrain kind (Chebyshev; tie -> lowest tile index). Prunes dead entries. */844- private findNearestResource(wanted: number, x: number, y: number): number {845- const list = this.resTiles[wanted]!;846- let best = -1;847- let bestD = 0x7fffffff;848- let w = 0;849- for (let r = 0; r < list.length; r++) {850- const i = list[r]!;851- if (this.terrain[i] !== wanted || this.amount[i] === 0) continue; // prune852- list[w++] = i;853- const d = cheb(x, y, i % this.size, Math.floor(i / this.size));854- if (d < bestD || (d === bestD && i < best)) {855- best = i;856- bestD = d;1296+ // -- phase 4: extraction --------------------------------------------------1297+1298+ /**1299+ * Fixed-point flows use out = (effWorkers1000 * rate + acc) div 1_000_0001300+ * where effWorkers1000 = covered*1000 + uncovered*NO_TOOL_YIELD (the1301+ * documented 1e6-denominator variant of the accumulator rule).1302+ */1303+ private flow(s: Settlement, slot: number, effW1000: number, rate: number): number {1304+ const total = effW1000 * rate + s.acc[slot]!;1305+ const out = Math.trunc(total / 1_000_000);1306+ s.acc[slot] = total - out * 1_000_000;1307+ return out;1308+ }1309+1310+ private phaseExtraction(): void {1311+ for (const t of this.tribes) {1312+ t.coveredExtractors = 0;1313+ t.totalExtractors = 0;1314+ }1315+ for (const s of this.settlements) {1316+ const tribe = this.tribes[s.tribe]!;1317+ const wk = s.workers;1318+ const extractive =1319+ wk[PI.farmer]! + wk[PI.fisher]! + wk[PI.herder]! + wk[PI.woodcutter]! + wk[PI.miner]!;1320+ let coverLeft = Math.min(s.stores[RI.tools]!, extractive);1321+ const covered = coverLeft;1322+ tribe.coveredExtractors += covered;1323+ tribe.totalExtractors += extractive;1324+ const eff = (p: number): number => {1325+ const w = wk[p]!;1326+ const cov = Math.min(w, coverLeft);1327+ coverLeft -= cov;1328+ return cov * 1000 + (w - cov) * C.NO_TOOL_YIELD;1329+ };1330+ // Coverage assignment in profession enum order.1331+ const farmerEff = eff(PI.farmer);1332+ const fisherEff = eff(PI.fisher);1333+ s.herderEff1000 = eff(PI.herder);1334+ const woodEff = eff(PI.woodcutter);1335+ const minerEff = eff(PI.miner);1336+1337+ let farmRate = C.FARMER_RATE;1338+ let bonus = 0;1339+ if (this.hasTech(s.tribe, C.TECH_IDX.crop_rotation)) bonus += C.CROP_ROTATION_BONUS;1340+ if (this.effectActive(FX.harvest, s.tribe, s.id)) bonus += C.HARVEST_RITE_BONUS;1341+ if (bonus > 0) farmRate = Math.trunc((farmRate * (1000 + bonus)) / 1000);1342+1343+ const outputs: Array<[number, number]> = [1344+ [RI.grain, this.flow(s, ACC.FARM, farmerEff, farmRate)],1345+ [RI.fish, this.flow(s, ACC.FISH, fisherEff, C.FISHER_RATE)],1346+ [RI.timber, this.flow(s, ACC.WOOD, woodEff, C.WOODCUTTER_RATE)],1347+ [RI.stone, this.flow(s, ACC.STONE, minerEff, C.MINER_STONE_RATE)],1348+ [RI.ore, s.census.ore > 0 ? this.flow(s, ACC.ORE, minerEff, C.MINER_ORE_RATE) : 0],1349+ [RI.coin, s.census.gold > 0 ? this.flow(s, ACC.COIN, minerEff, C.MINER_COIN_RATE) : 0],1350+ ];1351+ for (const [res, n] of outputs) {1352+ if (n > 0) this.prod(s.tribe, res, this.addStore(s, res, n));1353+ }1354+1355+ // Tool wear: 1 per TOOL_WEAR_TICKS covered worker-ticks.1356+ const wearTotal = covered + s.acc[ACC.TOOL_WEAR]!;1357+ const wear = Math.trunc(wearTotal / C.TOOL_WEAR_TICKS);1358+ s.acc[ACC.TOOL_WEAR] = wearTotal - wear * C.TOOL_WEAR_TICKS;1359+ if (wear > 0) {1360+ const worn = Math.min(wear, s.stores[RI.tools]!);1361+ s.stores[RI.tools]! -= worn;1362+ this.cons(s.tribe, RI.tools, worn);8571363 }8581364 }859- list.length = w;860- return best;8611365 }8621366 863- private actBuilder(u: Unit): void {864- // Nearest own construction site (Chebyshev; tie -> lowest building id).865- let site: Building | null = null;866- let bestD = 0x7fffffff;867- for (const b of this.buildings) {868- if (b.tribe !== u.tribe || b.progress >= 100 || b.hp <= 0) continue;869- const d = cheb(u.x, u.y, b.x, b.y);870- if (d < bestD) {871- site = b;872- bestD = d;1367+ // -- phase 5: refinement & construction -----------------------------------1368+1369+ /** potential-limited output with input caps; starved capacity is discarded1370+ * (accumulator clamped below 1000 worker-permille). */1371+ private refine(s: Settlement, slot: number, workers: number, rate: number, maxIn: number): number {1372+ const total = workers * rate + s.acc[slot]!;1373+ const byLabor = Math.trunc(total / 1000);1374+ const out = Math.max(0, Math.min(byLabor, maxIn));1375+ s.acc[slot] = Math.min(total - out * 1000, 999);1376+ return out;1377+ }1378+1379+ private phaseRefinement(): void {1380+ for (const s of this.settlements) {1381+ const tribe = this.tribes[s.tribe]!;1382+ const wk = s.workers;1383+ const st = s.stores;1384+1385+ // Grain gates (of current stock, per the craft split).1386+ const grainMill = Math.trunc((st[RI.grain]! * tribe.craft[3]!) / 1000);1387+ const grainBrew = Math.trunc((st[RI.grain]! * tribe.craft[4]!) / 1000);1388+1389+ // miller: grain -> flour 1:11390+ let out = this.refine(s, ACC.MILL, wk[PI.miller]!, C.MILLER_RATE, grainMill);1391+ if (out > 0) {1392+ st[RI.grain]! -= out;1393+ this.cons(s.tribe, RI.grain, out);1394+ this.prod(s.tribe, RI.flour, this.addStore(s, RI.flour, out));8731395 }874- }875- if (site === null) return;876- if (bestD <= 1) {877- site.progress = Math.min(100, site.progress + BUILD_RATE);878- if (site.progress >= 100) {879- this.events.push({880- t: 'building_complete',881- tick: this.tick,882- tribe: site.tribe,883- building: site.kind,884- x: site.x,885- y: site.y,886- });1396+ // baker: flour -> bread 1:11397+ out = this.refine(s, ACC.BAKE, wk[PI.baker]!, C.BAKER_RATE, st[RI.flour]!);1398+ if (out > 0) {1399+ st[RI.flour]! -= out;1400+ this.cons(s.tribe, RI.flour, out);1401+ this.prod(s.tribe, RI.bread, this.addStore(s, RI.bread, out));8871402 }888- } else {889- this.moveTowards(u, site.x, site.y);890- }891- }892-893- // -- combat ---------------------------------------------------------------894-895- private combat(): void {896- // Units, ascending id, sequential damage (a unit killed before its turn897- // does not strike back this tick).898- for (const u of this.units) {899- if (u.hp <= 0) continue;900- let dmg = UNIT_STATS[u.kind].damage;901- // Home-field advantage: defend-stance soldiers fighting near their own902- // town center strike at DEFEND_HOME_MULT x — static defense must be903- // worth garrisoning (see balance harness).904- if (u.kind === 'soldier' && u.armyId !== -1) {905- const army = this.findArmy(u.armyId);906- if (army !== undefined && army.stance === 'defend') {907- const tc = this.buildings.find(908- (b) => b.tribe === u.tribe && b.kind === 'town_center' && b.hp > 0,909- );910- if (911- tc !== undefined &&912- Math.max(Math.abs(u.x - tc.x), Math.abs(u.y - tc.y)) <= DEFEND_INTERCEPT_RADIUS913- ) {914- dmg *= DEFEND_HOME_MULT;915- }916- }1403+ // brewer: 2 grain -> 1 ale1404+ out = this.refine(1405+ s,1406+ ACC.BREW,1407+ wk[PI.brewer]!,1408+ C.BREWER_RATE,1409+ Math.trunc(grainBrew / C.ALE_GRAIN_COST),1410+ );1411+ if (out > 0) {1412+ st[RI.grain]! -= out * C.ALE_GRAIN_COST;1413+ this.cons(s.tribe, RI.grain, out * C.ALE_GRAIN_COST);1414+ this.prod(s.tribe, RI.ale, this.addStore(s, RI.ale, out));9171415 }918- // Lowest-hp adjacent enemy unit (tie -> lowest id).919- let target: Unit | null = null;920- for (let dy = -1; dy <= 1; dy++) {921- for (let dx = -1; dx <= 1; dx++) {922- const x = u.x + dx;923- const y = u.y + dy;924- if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;925- const list = this.unitsAt(x, y);926- if (list === undefined) continue;927- for (const e of list) {928- if (e.tribe === u.tribe || e.hp <= 0) continue;929- if (930- target === null ||931- e.hp < target.hp ||932- (e.hp === target.hp && e.id < target.id)933- ) {934- target = e;935- }1416+ // smelter: 2 ore + 1 timber -> 1 metal1417+ out = this.refine(1418+ s,1419+ ACC.SMELT,1420+ wk[PI.smelter]!,1421+ C.SMELTER_RATE,1422+ Math.min(Math.trunc(st[RI.ore]! / C.METAL_ORE_COST), st[RI.timber]!),1423+ );1424+ if (out > 0) {1425+ st[RI.ore]! -= out * C.METAL_ORE_COST;1426+ st[RI.timber]! -= out * C.METAL_TIMBER_COST;1427+ this.cons(s.tribe, RI.ore, out * C.METAL_ORE_COST);1428+ this.cons(s.tribe, RI.timber, out * C.METAL_TIMBER_COST);1429+ this.prod(s.tribe, RI.metal, this.addStore(s, RI.metal, out));1430+ }1431+ // smith: 1 metal + 1 timber -> tool/weapon; armor additionally 1 leather.1432+ out = this.refine(1433+ s,1434+ ACC.SMITH,1435+ wk[PI.smith]!,1436+ C.SMITH_RATE,1437+ Math.min(st[RI.metal]!, st[RI.timber]!),1438+ );1439+ if (out > 0) {1440+ const wWeapons = this.hasTech(s.tribe, C.TECH_IDX.weaponsmithing) ? tribe.craft[1]! : 0;1441+ const wArmor = this.hasTech(s.tribe, C.TECH_IDX.plate_armor) ? tribe.craft[2]! : 0;1442+ let split = apportion(out, [tribe.craft[0]!, wWeapons, wArmor]);1443+ if (split[0]! + split[1]! + split[2]! === 0) split = [out, 0, 0];1444+ const armorMade = Math.min(split[2]!, st[RI.leather]!);1445+ const made: Array<[number, number]> = [1446+ [RI.tools, split[0]!],1447+ [RI.weapons, split[1]!],1448+ [RI.armor, armorMade],1449+ ];1450+ for (const [res, n] of made) {1451+ if (n <= 0) continue;1452+ st[RI.metal]! -= n;1453+ st[RI.timber]! -= n;1454+ this.cons(s.tribe, RI.metal, n);1455+ this.cons(s.tribe, RI.timber, n);1456+ if (res === RI.armor) {1457+ st[RI.leather]! -= n;1458+ this.cons(s.tribe, RI.leather, n);9361459 }1460+ this.prod(s.tribe, res, this.addStore(s, res, n));9371461 }9381462 }939- if (target !== null) {940- target.hp -= dmg;941- if (target.hp <= 0) {942- this.tribes[u.tribe]!.kills++;943- this.events.push({944- t: 'unit_lost',945- tick: this.tick,946- tribe: target.tribe,947- unit: target.kind,948- x: target.x,949- y: target.y,950- by: u.tribe,951- });1463+ // carpenter: 1 timber -> 1 plank1464+ out = this.refine(s, ACC.CARP, wk[PI.carpenter]!, C.CARPENTER_RATE, st[RI.timber]!);1465+ if (out > 0) {1466+ st[RI.timber]! -= out;1467+ this.cons(s.tribe, RI.timber, out);1468+ this.prod(s.tribe, RI.planks, this.addStore(s, RI.planks, out));1469+ }1470+ // tanner: 1 hide -> 1 leather1471+ out = this.refine(s, ACC.TAN, wk[PI.tanner]!, C.TANNER_RATE, st[RI.hides]!);1472+ if (out > 0) {1473+ st[RI.hides]! -= out;1474+ this.cons(s.tribe, RI.hides, out);1475+ this.prod(s.tribe, RI.leather, this.addStore(s, RI.leather, out));1476+ }1477+ // weaver: 2 wool -> 1 cloth1478+ out = this.refine(1479+ s,1480+ ACC.WEAVE,1481+ wk[PI.weaver]!,1482+ C.WEAVER_RATE,1483+ Math.trunc(st[RI.wool]! / C.CLOTH_WOOL_COST),1484+ );1485+ if (out > 0) {1486+ st[RI.wool]! -= out * C.CLOTH_WOOL_COST;1487+ this.cons(s.tribe, RI.wool, out * C.CLOTH_WOOL_COST);1488+ this.prod(s.tribe, RI.cloth, this.addStore(s, RI.cloth, out));1489+ }1490+ // tailor: 1 cloth + 1 leather -> 1 clothes1491+ out = this.refine(1492+ s,1493+ ACC.TAILOR,1494+ wk[PI.tailor]!,1495+ C.TAILOR_RATE,1496+ Math.min(st[RI.cloth]!, st[RI.leather]!),1497+ );1498+ if (out > 0) {1499+ st[RI.cloth]! -= out;1500+ st[RI.leather]! -= out;1501+ this.cons(s.tribe, RI.cloth, out);1502+ this.cons(s.tribe, RI.leather, out);1503+ this.prod(s.tribe, RI.clothes, this.addStore(s, RI.clothes, out));1504+ }1505+ }1506+ }1507+1508+ private phaseConstruction(): void {1509+ for (const s of this.settlements) {1510+ if (s.queue.length === 0) continue;1511+ const builders = s.workers[PI.builder]!;1512+ if (builders === 0) continue;1513+ const active = Math.min(s.queue.length, Math.max(1, Math.trunc(builders / C.BUILDERS_PER_SITE)));1514+ const shares = apportion(builders, new Array<number>(active).fill(1));1515+ let completedAny = false;1516+ for (let k = 0; k < active; k++) {1517+ const site = s.queue[k]!;1518+ site.progress += shares[k]!;1519+ if (site.progress >= site.req) completedAny = true;1520+ }1521+ if (!completedAny) continue;1522+ for (let k = s.queue.length - 1; k >= 0; k--) {1523+ const site = s.queue[k]!;1524+ if (site.progress < site.req) continue;1525+ s.queue.splice(k, 1);1526+ s.buildings[site.b]!++;1527+ this.tribes[s.tribe]!.buildingsCompleted++;1528+ this.events.push({1529+ t: 'building_complete',1530+ tick: this.tick,1531+ tribe: s.tribe,1532+ building: BUILDINGS[site.b]!,1533+ settlementId: s.id,1534+ });1535+ }1536+ }1537+ }1538+1539+ // -- phase 6: herds -------------------------------------------------------1540+1541+ private phaseHerds(): void {1542+ for (const s of this.settlements) {1543+ const tribe = this.tribes[s.tribe]!;1544+ const pastures = s.buildings[BI.pasture]!;1545+ const capHerds = pastures * C.HERDS_PER_PASTURE;1546+ const capHorses = pastures * C.HORSES_PER_PASTURE;1547+ const tended = Math.min(1548+ s.herds,1549+ Math.trunc((s.herderEff1000 * C.HERDS_PER_HERDER) / 1000),1550+ );1551+ // Fodder: decree ‰ of herds, gated by the grain craft split.1552+ const fodderGate = Math.trunc((s.stores[RI.grain]! * tribe.craft[5]!) / 1000);1553+ const fedTarget = Math.min(tended, Math.trunc((s.herds * tribe.decrees[2]!) / 1000));1554+ const fed = Math.min(fedTarget, Math.trunc(fodderGate / C.FODDER_GRAIN_PER_HERD));1555+ if (fed > 0) {1556+ const grain = fed * C.FODDER_GRAIN_PER_HERD;1557+ s.stores[RI.grain]! -= grain;1558+ this.cons(s.tribe, RI.grain, grain);1559+ }1560+ // Growth (fodder-fed herds grow at double rate), capped by pasture.1561+ const growTotal = (tended - fed) * C.HERD_GROWTH + fed * C.HERD_GROWTH * 2 + s.acc[ACC.HERD]!;1562+ const grown = Math.trunc(growTotal / 1000);1563+ s.acc[ACC.HERD] = growTotal - grown * 1000;1564+ // Pasture cap; herds above cap (lost pastures) cull immediately.1565+ s.herds = Math.max(0, Math.min(capHerds, s.herds + grown));1566+ // Wool.1567+ const woolTotal = tended * C.WOOL_PER_HERD + s.acc[ACC.WOOL]!;1568+ const wool = Math.trunc(woolTotal / 1000);1569+ s.acc[ACC.WOOL] = woolTotal - wool * 1000;1570+ if (wool > 0) this.prod(s.tribe, RI.wool, this.addStore(s, RI.wool, wool));1571+ // Slaughter.1572+ const slTotal = s.herds * tribe.decrees[1]! + s.acc[ACC.SLAUGHTER]!;1573+ let sl = Math.trunc(slTotal / 1000);1574+ s.acc[ACC.SLAUGHTER] = slTotal - sl * 1000;1575+ sl = Math.min(sl, s.herds);1576+ if (sl > 0) {1577+ s.herds -= sl;1578+ this.prod(s.tribe, RI.meat, this.addStore(s, RI.meat, sl * C.MEAT_PER_SLAUGHTER));1579+ this.prod(s.tribe, RI.hides, this.addStore(s, RI.hides, sl * C.HIDES_PER_SLAUGHTER));1580+ }1581+ // Horses (pasture-capped separately).1582+ const hTotal = s.horses * C.HORSE_GROWTH + s.acc[ACC.HORSE]!;1583+ const h = Math.trunc(hTotal / 1000);1584+ s.acc[ACC.HORSE] = hTotal - h * 1000;1585+ s.horses = Math.min(capHorses, s.horses + h);1586+ if (s.horses < 0) s.horses = 0;1587+ }1588+ }1589+1590+ // -- phase 7: logistics ---------------------------------------------------1591+1592+ private phaseLogisticsMove(): void {1593+ // Caravans (ascending id).1594+ for (let i = 0; i < this.caravans.length; i++) {1595+ const c = this.caravans[i]!;1596+ // Deliveries land at the destination settlement even if it changed1597+ // hands en route (the captor gets the goods).1598+ let dest = this.findSettlement(c.destId);1599+ if (dest === undefined) {1600+ const near = this.nearestOwnSettlement(c.tribe, c.x, c.y);1601+ if (near === null) {1602+ this.caravans.splice(i, 1);1603+ i--;1604+ continue;9521605 }1606+ c.destId = near.id;1607+ dest = near;1608+ }1609+ if (c.x === dest.x && c.y === dest.y) {1610+ this.addStore(dest, c.res, c.amount); // overflow above cap is lost1611+ this.caravans.splice(i, 1);1612+ i--;9531613 continue;9541614 }955- // Soldiers siege adjacent enemy buildings (lowest hp, tie -> lowest id).956- if (u.kind !== 'soldier') continue;957- let bTarget: Building | null = null;958- for (let dy = -1; dy <= 1; dy++) {959- for (let dx = -1; dx <= 1; dx++) {960- const x = u.x + dx;961- const y = u.y + dy;962- if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;963- const b = this.buildingAt.get(y * this.size + x);964- if (b === undefined || b.tribe === u.tribe || b.hp <= 0) continue;965- if (966- bTarget === null ||967- b.hp < bTarget.hp ||968- (b.hp === bTarget.hp && b.id < bTarget.id)969- ) {970- bTarget = b;1615+ this.stepEntity(c, dest.x, dest.y);1616+ this.reveal(c.tribe, c.x, c.y, C.UNIT_VISION);1617+ if (c.x === dest.x && c.y === dest.y) {1618+ this.addStore(dest, c.res, c.amount);1619+ this.caravans.splice(i, 1);1620+ i--;1621+ }1622+ }1623+ // Settler wagons (ascending id).1624+ for (let i = 0; i < this.wagons.length; i++) {1625+ const wg = this.wagons[i]!;1626+ if (wg.x !== wg.tx || wg.y !== wg.ty) {1627+ this.stepEntity(wg, wg.tx, wg.ty);1628+ this.reveal(wg.tribe, wg.x, wg.y, C.UNIT_VISION);1629+ }1630+ if (wg.x !== wg.tx || wg.y !== wg.ty) continue;1631+ // Arrived: found if the site is still clear, else merge into own town.1632+ let blocked = false;1633+ for (const s of this.settlements) {1634+ if (cheb(wg.x, wg.y, s.x, s.y) < C.MIN_SETTLE_DIST) {1635+ blocked = true;1636+ break;1637+ }1638+ }1639+ if (!blocked) {1640+ const s = this.addSettlement(wg.tribe, wg.x, wg.y, wg.settlers);1641+ for (let r = 0; r < R_N; r++) {1642+ if (wg.supplies[r]! > 0) this.addStore(s, r, wg.supplies[r]!);1643+ }1644+ this.events.push({1645+ t: 'settlement_founded',1646+ tick: this.tick,1647+ tribe: wg.tribe,1648+ settlementId: s.id,1649+ x: wg.x,1650+ y: wg.y,1651+ });1652+ } else {1653+ const near = this.nearestOwnSettlement(wg.tribe, wg.x, wg.y);1654+ if (near !== null) {1655+ near.pop += wg.settlers;1656+ for (let r = 0; r < R_N; r++) {1657+ if (wg.supplies[r]! > 0) this.addStore(near, r, wg.supplies[r]!);9711658 }9721659 }1660+ // No own settlement: the wagon is lost.1661+ }1662+ this.wagons.splice(i, 1);1663+ i--;1664+ }1665+ }1666+1667+ private phaseRoutes(): void {1668+ for (const tribe of this.tribes) {1669+ if (!tribe.alive) continue;1670+ const own = this.settlementsOf(tribe.id);1671+ if (own.length === 0) continue;1672+ // Hauler budget per settlement for this slow tick.1673+ const budget = new Map<number, number>();1674+ let anyHauler = false;1675+ for (const s of own) {1676+ const b = s.workers[PI.hauler]! * C.HAULER_THROUGHPUT;1677+ budget.set(s.id, b);1678+ if (s.workers[PI.hauler]! > 0) anyHauler = true;1679+ }1680+ // Standing routes, ascending array (insertion) order.1681+ for (let i = tribe.routes.length - 1; i >= 0; i--) {1682+ const r = tribe.routes[i]!;1683+ const from = this.findSettlement(r.fromId);1684+ const to = this.findSettlement(r.toId);1685+ if (1686+ from === undefined ||1687+ to === undefined ||1688+ from.tribe !== tribe.id ||1689+ to.tribe !== tribe.id1690+ ) {1691+ tribe.routes.splice(i, 1);1692+ continue;1693+ }1694+ }1695+ for (const r of tribe.routes) {1696+ const from = this.findSettlement(r.fromId)!;1697+ const to = this.findSettlement(r.toId)!;1698+ const b = budget.get(from.id)!;1699+ if (b <= 0) continue;1700+ const surplus = from.stores[r.res]! - C.ROUTE_KEEP_LEVEL;1701+ if (surplus <= 0) continue;1702+ let move = Math.trunc((surplus * r.permille) / 1000);1703+ move = Math.min(move, b, this.storeCapOf(to) - to.stores[r.res]!);1704+ if (move <= 0) continue;1705+ from.stores[r.res]! -= move;1706+ to.stores[r.res]! += move;1707+ budget.set(from.id, b - move);1708+ }1709+ // Default food equalization (needs >=1 hauler and >=2 settlements).1710+ if (!anyHauler || own.length < 2) continue;1711+ let totalPop = 0;1712+ const points = own.map((s) => {1713+ totalPop += s.pop;1714+ let p = 0;1715+ for (const [res, sat] of C.SATIATION) p += s.stores[res]! * sat;1716+ return p;1717+ });1718+ if (totalPop === 0) continue;1719+ let totalPoints = 0;1720+ for (const p of points) totalPoints += p;1721+ const excess = own.map(1722+ (s, i) => points[i]! - Math.trunc((totalPoints * s.pop) / totalPop),1723+ );1724+ let di = 0;1725+ let ri = 0;1726+ while (di < own.length && ri < own.length) {1727+ if (excess[di]! <= 0) {1728+ di++;1729+ continue;1730+ }1731+ if (excess[ri]! >= 0) {1732+ ri++;1733+ continue;1734+ }1735+ const donor = own[di]!;1736+ const recip = own[ri]!;1737+ let b = budget.get(donor.id)!;1738+ if (b <= 0) {1739+ di++;1740+ continue;1741+ }1742+ for (const [res, sat] of C.SATIATION) {1743+ if (excess[di]! <= 0 || excess[ri]! >= 0 || b <= 0) break;1744+ const maxUnits = Math.min(1745+ donor.stores[res]!,1746+ b,1747+ Math.trunc(excess[di]! / sat),1748+ Math.ceil(-excess[ri]! / sat),1749+ this.storeCapOf(recip) - recip.stores[res]!,1750+ );1751+ if (maxUnits <= 0) continue;1752+ donor.stores[res]! -= maxUnits;1753+ recip.stores[res]! += maxUnits;1754+ excess[di]! -= maxUnits * sat;1755+ excess[ri]! += maxUnits * sat;1756+ b -= maxUnits;1757+ }1758+ budget.set(donor.id, b);1759+ if (excess[di]! <= 0 || b <= 0) di++;1760+ else ri++;1761+ }1762+ }1763+ }1764+1765+ // -- phase 8: consumption -------------------------------------------------1766+1767+ private phaseConsumption(): void {1768+ for (const s of this.settlements) {1769+ if (s.pop <= 0) continue;1770+ const tribe = this.tribes[s.tribe]!;1771+ // Food (satiation points).1772+ const dTotal = s.pop * tribe.decrees[0]! + s.acc[ACC.FOOD]!;1773+ let need = Math.trunc(dTotal / C.FOOD_DEMAND_DIV);1774+ s.acc[ACC.FOOD] = dTotal - need * C.FOOD_DEMAND_DIV;1775+ for (let k = 0; k < C.SATIATION.length && need > 0; k++) {1776+ const [res, sat] = C.SATIATION[k]!;1777+ const units = Math.min(s.stores[res]!, Math.ceil(need / sat));1778+ if (units <= 0) continue;1779+ s.stores[res]! -= units;1780+ this.cons(s.tribe, res, units);1781+ need -= units * sat;1782+ s.kindsEaten |= 1 << k;9731783 }974- if (bTarget !== null) {975- // Soldiers deal SOLDIER_SIEGE_MULT x damage to buildings.976- bTarget.hp -= dmg * SOLDIER_SIEGE_MULT;977- if (bTarget.hp <= 0) {978- this.events.push({979- t: 'building_destroyed',980- tick: this.tick,981- tribe: bTarget.tribe,982- building: bTarget.kind,983- x: bTarget.x,984- y: bTarget.y,985- by: u.tribe,986- });1784+ if (need > 0) {1785+ // Starvation.1786+ const cap = Math.max(1, Math.trunc(s.pop / C.STARVATION_DEATH_CAP_DIV));1787+ const deaths = Math.min(s.pop, Math.min(need, cap));1788+ s.pop -= deaths;1789+ s.morale = Math.max(0, s.morale - C.STARVATION_MORALE_HIT);1790+ s.flags |= FLAG_STARVED;1791+ tribe.starvI += deaths;1792+ tribe.deathsI += deaths;1793+ this.events.push({1794+ t: 'starvation',1795+ tick: this.tick,1796+ tribe: s.tribe,1797+ settlementId: s.id,1798+ deaths,1799+ });1800+ }1801+ // Ale ration.1802+ const aleDecree = tribe.decrees[3]!;1803+ if (aleDecree > 0) {1804+ const aTotal = s.pop * aleDecree + s.acc[ACC.ALE]!;1805+ const wantAle = Math.trunc(aTotal / C.ALE_DEMAND_DIV);1806+ s.acc[ACC.ALE] = aTotal - wantAle * C.ALE_DEMAND_DIV;1807+ if (wantAle > 0) {1808+ const served = Math.min(wantAle, s.stores[RI.ale]!);1809+ s.stores[RI.ale]! -= served;1810+ this.cons(s.tribe, RI.ale, served);1811+ if (served < wantAle) s.flags |= FLAG_ALE_SHORT;1812+ }1813+ }1814+ // Clothes upkeep in cold biomes.1815+ if (s.census.cold) {1816+ const cTotal = s.pop + s.acc[ACC.CLOTHES]!;1817+ const wantC = Math.trunc(cTotal / C.CLOTHES_DEMAND_DIV);1818+ s.acc[ACC.CLOTHES] = cTotal - wantC * C.CLOTHES_DEMAND_DIV;1819+ if (wantC > 0) {1820+ const got = Math.min(wantC, s.stores[RI.clothes]!);1821+ s.stores[RI.clothes]! -= got;1822+ this.cons(s.tribe, RI.clothes, got);1823+ if (got < wantC) s.flags |= FLAG_CLOTHES_SHORT;9871824 }9881825 }9891826 }1827+ }9901828 991- // Towers, ascending building id: shoot nearest enemy unit in range992- // (nearest, then lowest id). Building kills also count as kills.993- for (const b of this.buildings) {994- if (b.kind !== 'tower' || b.progress < 100 || b.hp <= 0) continue;995- let target: Unit | null = null;996- let bestD = TOWER_RANGE + 1;997- const x0 = Math.max(0, b.x - TOWER_RANGE);998- const x1 = Math.min(this.size - 1, b.x + TOWER_RANGE);999- const y0 = Math.max(0, b.y - TOWER_RANGE);1000- const y1 = Math.min(this.size - 1, b.y + TOWER_RANGE);1001- for (let yy = y0; yy <= y1; yy++) {1002- for (let xx = x0; xx <= x1; xx++) {1003- const list = this.unitsAt(xx, yy);1004- if (list === undefined) continue;1005- for (const e of list) {1006- if (e.tribe === b.tribe || e.hp <= 0) continue;1007- const d = cheb(b.x, b.y, e.x, e.y);1008- if (d < bestD || (d === bestD && (target === null || e.id < target.id))) {1009- target = e;1829+ // -- phase 9: population & morale -----------------------------------------1830+1831+ private phasePopulation(): void {1832+ for (const s of this.settlements) {1833+ const tribe = this.tribes[s.tribe]!;1834+ // Morale drifts toward its target.1835+ let kinds = 0;1836+ let mask = s.kindsEaten;1837+ while (mask !== 0) {1838+ kinds += mask & 1;1839+ mask >>= 1;1840+ }1841+ let target = C.MORALE_BASE_TARGET;1842+ if (kinds >= 2) target += C.MORALE_VARIETY_BONUS;1843+ if (tribe.decrees[3]! > 0 && (s.flags & FLAG_ALE_SHORT) === 0) target += C.MORALE_ALE_BONUS;1844+ if ((s.flags & FLAG_CLOTHES_SHORT) !== 0) target -= C.MORALE_CLOTHES_PENALTY;1845+ if ((s.flags & FLAG_STARVED) !== 0) target -= C.MORALE_STARVED_PENALTY;1846+ if (this.effectActive(FX.blessing, s.tribe, s.id)) target += C.MORALE_BLESSING_BONUS;1847+ target = Math.max(0, Math.min(1000, target));1848+ s.morale += Math.trunc((target - s.morale) / 4);1849+ s.morale = Math.max(0, Math.min(1000, s.morale));1850+ s.kindsEaten = 0;1851+ s.flags = 0;1852+ if (s.pop <= 0) continue;1853+ // Births.1854+ const housing = this.housingOf(s);1855+ let fHousing = 0;1856+ if (housing > 0 && s.pop < housing) {1857+ const softCap = Math.trunc((housing * C.HOUSING_SOFT_PERMILLE) / 1000);1858+ fHousing =1859+ s.pop <= softCap1860+ ? 10001861+ : Math.trunc(((housing - s.pop) * 1000) / Math.max(1, housing - softCap));1862+ }1863+ let storePoints = 0;1864+ for (const [res, sat] of C.SATIATION) storePoints += s.stores[res]! * sat;1865+ const needPer50 = Math.max(1866+ 1,1867+ Math.trunc((s.pop * tribe.decrees[0]! * C.SURPLUS_TICKS) / C.FOOD_DEMAND_DIV),1868+ );1869+ const fSurplus = Math.min(1000, Math.trunc((storePoints * 1000) / needPer50));1870+ // Combined factor in ‰ (each stage floors; keeps accumulators 32-bit).1871+ let f = Math.trunc((fHousing * s.morale) / 1000);1872+ f = Math.trunc((f * fSurplus) / 1000);1873+ const bTotal = s.pop * C.BIRTH_RATE * f + s.acc[ACC.BIRTH]!;1874+ const births = Math.trunc(bTotal / 1_000_000);1875+ s.acc[ACC.BIRTH] = bTotal - births * 1_000_000;1876+ if (births > 0) {1877+ s.pop += births;1878+ tribe.birthsI += births;1879+ }1880+ // Natural deaths.1881+ const dTotal = s.pop * C.NATURAL_DEATH_RATE + s.acc[ACC.DEATH]!;1882+ const deaths = Math.min(s.pop, Math.trunc(dTotal / 1000));1883+ s.acc[ACC.DEATH] = dTotal - Math.trunc(dTotal / 1000) * 1000;1884+ if (deaths > 0) {1885+ s.pop -= deaths;1886+ tribe.deathsI += deaths;1887+ }1888+ // Emigration under low morale.1889+ if (s.morale < C.EMIGRATION_MORALE) {1890+ const eTotal = s.pop * C.EMIGRATION_RATE + s.acc[ACC.EMIG]!;1891+ const gone = Math.min(s.pop, Math.trunc(eTotal / 1000));1892+ s.acc[ACC.EMIG] = eTotal - Math.trunc(eTotal / 1000) * 1000;1893+ if (gone > 0) {1894+ s.pop -= gone;1895+ tribe.deathsI += gone;1896+ }1897+ }1898+ this.updateTier(s);1899+ }1900+ }1901+1902+ // -- phase 10: military ---------------------------------------------------1903+1904+ private warbandPower(w: Warband, defTerrain: number, wallLevel: number): number {1905+ if (w.size <= 0) return 0;1906+ let per = C.POWER_BASE;1907+ if (w.weapons >= w.size) per += C.POWER_ARMED;1908+ per += Math.trunc((w.armor * 10) / w.size); // armorRatio‰ / 1001909+ per += Math.trunc(w.vet / 100);1910+ if (defTerrain >= 0) per += C.TERRAIN_DEF_MOD[defTerrain] ?? 0;1911+ if (wallLevel > 0) per += wallLevel * C.WALL_DEF_PER_SOLDIER;1912+ return w.size * Math.max(1, per);1913+ }1914+1915+ private variance(): number {1916+ return C.VARIANCE_BASE + this.rng.int(C.VARIANCE_SPAN);1917+ }1918+1919+ private casualties(1920+ winnerP: number,1921+ loserP: number,1922+ ): { winnerPm: number; loserPm: number } {1923+ const r = Math.trunc((winnerP * 1000) / Math.max(1, loserP));1924+ const loserPm = Math.min(C.LOSER_LOSS_CAP, C.LOSER_LOSS_BASE + Math.trunc(r / 5));1925+ const winnerPm = Math.min(1926+ C.WINNER_LOSS_CAP,1927+ Math.max(C.WINNER_LOSS_MIN, Math.trunc(350000 / Math.max(1, r))),1928+ );1929+ return { winnerPm, loserPm };1930+ }1931+1932+ private applyWarbandLoss(w: Warband, pm: number, minOne: boolean): number {1933+ let loss = Math.trunc((w.size * pm) / 1000);1934+ if (minOne && loss === 0 && w.size > 0) loss = 1;1935+ loss = Math.min(loss, w.size);1936+ w.size -= loss;1937+ w.weapons = Math.min(w.weapons, w.size);1938+ w.armor = Math.min(w.armor, w.size);1939+ w.horses = Math.min(w.horses, w.size);1940+ w.supply = Math.min(w.supply, w.size * C.SUPPLY_CAP_PER_SOLDIER);1941+ return loss;1942+ }1943+1944+ private routHome(w: Warband): void {1945+ const home = this.findSettlement(w.home);1946+ const dest =1947+ home !== undefined && home.tribe === w.tribe1948+ ? home1949+ : this.nearestOwnSettlement(w.tribe, w.x, w.y);1950+ w.order = {1951+ type: WO.march,1952+ targetId: -1,1953+ tx: dest !== null && dest !== undefined ? dest.x : w.x,1954+ ty: dest !== null && dest !== undefined ? dest.y : w.y,1955+ };1956+ w.morale = Math.max(0, w.morale - C.ROUT_MORALE_HIT);1957+ }1958+1959+ private resolveFieldBattle(a: Warband, b: Warband): void {1960+ const riteA = this.consumeWarRite(a);1961+ const riteB = this.consumeWarRite(b);1962+ let pa = this.warbandPower(a, -1, 0);1963+ let pb = this.warbandPower(b, this.terrainAt(b.x, b.y), 0);1964+ pa = Math.trunc((pa * (1000 + riteA)) / 1000);1965+ pb = Math.trunc((pb * (1000 + riteB)) / 1000);1966+ pa = Math.trunc((pa * this.variance()) / 1000);1967+ pb = Math.trunc((pb * this.variance()) / 1000);1968+ const aWins = pa > pb;1969+ const win = aWins ? a : b;1970+ const lose = aWins ? b : a;1971+ const winP = aWins ? pa : pb;1972+ const loseP = aWins ? pb : pa;1973+ const { winnerPm, loserPm } = this.casualties(winP, loseP);1974+ const winLoss = this.applyWarbandLoss(win, winnerPm, false);1975+ const loseLoss = this.applyWarbandLoss(lose, loserPm, true);1976+ win.vet = Math.min(1000, win.vet + C.VET_WIN);1977+ lose.vet = Math.min(1000, lose.vet + C.VET_LOSS);1978+ this.tribes[win.tribe]!.battlesWon++;1979+ this.tribes[lose.tribe]!.battlesLost++;1980+ this.tribes[win.tribe]!.casDealt += loseLoss;1981+ this.tribes[win.tribe]!.casTaken += winLoss;1982+ this.tribes[lose.tribe]!.casDealt += winLoss;1983+ this.tribes[lose.tribe]!.casTaken += loseLoss;1984+ if (lose.size > 0) this.routHome(lose);1985+ a.engaged = true;1986+ b.engaged = true;1987+ this.events.push({1988+ t: 'battle',1989+ tick: this.tick,1990+ x: b.x,1991+ y: b.y,1992+ attacker: a.tribe,1993+ defender: b.tribe,1994+ attackerLosses: aWins ? winLoss : loseLoss,1995+ defenderLosses: aWins ? loseLoss : winLoss,1996+ winner: win.tribe,1997+ });1998+ }1999+2000+ private resolveCampBattle(w: Warband, camp: Camp): void {2001+ const rite = this.consumeWarRite(w);2002+ let pw = this.warbandPower(w, -1, 0);2003+ pw = Math.trunc((pw * (1000 + rite)) / 1000);2004+ let pc = camp.strength * C.CAMP_POWER_MULT;2005+ pw = Math.trunc((pw * this.variance()) / 1000);2006+ pc = Math.trunc((pc * this.variance()) / 1000);2007+ w.engaged = true;2008+ const tribe = this.tribes[w.tribe]!;2009+ if (pw > pc) {2010+ const { winnerPm } = this.casualties(pw, pc);2011+ const loss = this.applyWarbandLoss(w, winnerPm, false);2012+ tribe.casTaken += loss;2013+ tribe.casDealt += camp.strength;2014+ tribe.battlesWon++;2015+ tribe.campsCleared++;2016+ w.vet = Math.min(1000, w.vet + C.VET_WIN);2017+ const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2018+ if (home !== null && home !== undefined && home.tribe === w.tribe) {2019+ this.addStore(home, RI.coin, camp.strength * C.CAMP_CLEAR_COIN_MULT);2020+ }2021+ this.events.push({2022+ t: 'battle',2023+ tick: this.tick,2024+ x: camp.x,2025+ y: camp.y,2026+ attacker: w.tribe,2027+ defender: null,2028+ attackerLosses: loss,2029+ defenderLosses: camp.strength,2030+ winner: w.tribe,2031+ });2032+ this.events.push({2033+ t: 'threat_cleared',2034+ tick: this.tick,2035+ tribe: w.tribe,2036+ kind: THREAT_KINDS[camp.kind]!,2037+ x: camp.x,2038+ y: camp.y,2039+ });2040+ const i = this.camps.indexOf(camp);2041+ if (i !== -1) this.camps.splice(i, 1);2042+ } else {2043+ const { loserPm } = this.casualties(pc, pw);2044+ const loss = this.applyWarbandLoss(w, loserPm, true);2045+ tribe.casTaken += loss;2046+ tribe.battlesLost++;2047+ const campLoss = Math.max(1, Math.trunc(camp.strength / 4));2048+ camp.strength -= campLoss;2049+ tribe.casDealt += campLoss;2050+ w.vet = Math.min(1000, w.vet + C.VET_LOSS);2051+ this.events.push({2052+ t: 'battle',2053+ tick: this.tick,2054+ x: camp.x,2055+ y: camp.y,2056+ attacker: w.tribe,2057+ defender: null,2058+ attackerLosses: loss,2059+ defenderLosses: campLoss,2060+ winner: null,2061+ });2062+ this.routHome(w);2063+ }2064+ }2065+2066+ private garrisonsAt(s: Settlement): Warband[] {2067+ return this.warbands.filter(2068+ (w) =>2069+ w.tribe === s.tribe &&2070+ w.order.type === WO.garrison &&2071+ w.order.targetId === s.id &&2072+ w.x === s.x &&2073+ w.y === s.y &&2074+ w.size > 0,2075+ );2076+ }2077+2078+ private settlementDefense(s: Settlement, garrisons: Warband[]): number {2079+ const wall = s.buildings[BI.walls]!;2080+ let def =2081+ Math.trunc(s.pop / C.MILITIA_POP_DIV) +2082+ wall * C.WALL_DEFENSE +2083+ s.buildings[BI.tower]! * C.TOWER_DEFENSE;2084+ for (const g of garrisons) def += this.warbandPower(g, this.terrainAt(s.x, s.y), wall);2085+ return def;2086+ }2087+2088+ private resolveRaid(w: Warband, s: Settlement): void {2089+ w.engaged = true;2090+ const tribe = this.tribes[w.tribe]!;2091+ const victim = this.tribes[s.tribe]!;2092+ const rite = this.consumeWarRite(w);2093+ const garrisons = this.garrisonsAt(s);2094+ let pa = Math.trunc((this.warbandPower(w, -1, 0) * (1000 + rite)) / 1000);2095+ let pd = this.settlementDefense(s, garrisons);2096+ pa = Math.trunc((pa * this.variance()) / 1000);2097+ pd = Math.trunc((pd * this.variance()) / 1000);2098+ let attackerLosses: number;2099+ let defenderLosses = 0;2100+ if (pa > pd) {2101+ const { winnerPm, loserPm } = this.casualties(pa, pd);2102+ attackerLosses = this.applyWarbandLoss(w, winnerPm, false);2103+ for (const g of garrisons) defenderLosses += this.applyWarbandLoss(g, loserPm, true);2104+ const popLoss = Math.min(s.pop, Math.min(Math.trunc(s.pop / 50), Math.trunc(w.size / 2)));2105+ s.pop -= popLoss;2106+ defenderLosses += popLoss;2107+ victim.deathsI += popLoss;2108+ s.morale = Math.max(0, s.morale - 50);2109+ // Loot.2110+ let carry = w.size * C.RAID_CARRY_PER_SOLDIER;2111+ const loot: Partial<Record<Resource, number>> = {};2112+ const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2113+ for (const res of LOOT_ORDER) {2114+ if (carry <= 0) break;2115+ const take = Math.min(carry, s.stores[res]!);2116+ if (take <= 0) continue;2117+ s.stores[res]! -= take;2118+ carry -= take;2119+ loot[RESOURCES[res]!] = take;2120+ if (home !== null && home !== undefined && home.tribe === w.tribe) {2121+ this.addStore(home, res, take);2122+ }2123+ }2124+ // Building damage on an overwhelming win.2125+ if (pa >= pd * C.RAID_DESTROY_RATIO) {2126+ for (const b of DESTROY_ORDER) {2127+ if (s.buildings[b]! > 0) {2128+ s.buildings[b]!--;2129+ this.events.push({2130+ t: 'building_destroyed',2131+ tick: this.tick,2132+ tribe: s.tribe,2133+ building: BUILDINGS[b]!,2134+ settlementId: s.id,2135+ by: w.tribe,2136+ });2137+ break;2138+ }2139+ }2140+ }2141+ tribe.battlesWon++;2142+ victim.battlesLost++;2143+ w.vet = Math.min(1000, w.vet + C.VET_WIN);2144+ this.events.push({2145+ t: 'raid',2146+ tick: this.tick,2147+ tribe: s.tribe,2148+ by: w.tribe,2149+ settlementId: s.id,2150+ loot,2151+ });2152+ } else {2153+ const { winnerPm, loserPm } = this.casualties(pd, pa);2154+ attackerLosses = this.applyWarbandLoss(w, loserPm, true);2155+ for (const g of garrisons) defenderLosses += this.applyWarbandLoss(g, winnerPm, false);2156+ tribe.battlesLost++;2157+ victim.battlesWon++;2158+ for (const g of garrisons) g.vet = Math.min(1000, g.vet + C.VET_WIN);2159+ }2160+ tribe.casTaken += attackerLosses;2161+ tribe.casDealt += defenderLosses;2162+ victim.casDealt += attackerLosses;2163+ victim.casTaken += defenderLosses;2164+ this.events.push({2165+ t: 'battle',2166+ tick: this.tick,2167+ x: s.x,2168+ y: s.y,2169+ attacker: w.tribe,2170+ defender: s.tribe,2171+ attackerLosses,2172+ defenderLosses,2173+ winner: pa > pd ? w.tribe : s.tribe,2174+ });2175+ if (w.size > 0) this.routHome(w);2176+ }2177+2178+ private resolveCaravanRaid(w: Warband, c: Caravan): void {2179+ // An escorting warband within 1 tile defends the caravan first.2180+ let escort: Warband | null = null;2181+ for (const e of this.warbands) {2182+ if (2183+ e.tribe === c.tribe &&2184+ e.order.type === WO.escort &&2185+ e.order.targetId === c.id &&2186+ cheb(e.x, e.y, c.x, c.y) <= 1 &&2187+ e.size > 0 &&2188+ !e.engaged2189+ ) {2190+ escort = e;2191+ break;2192+ }2193+ }2194+ if (escort !== null) {2195+ this.resolveFieldBattle(w, escort);2196+ if (w.size <= 0 || escort.size > 0) return; // escort held (or raider died)2197+ if (cheb(w.x, w.y, c.x, c.y) > 1) return;2198+ } else {2199+ w.engaged = true;2200+ }2201+ const owner = this.tribes[c.tribe]!;2202+ owner.caravansLost++;2203+ const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2204+ if (home !== null && home !== undefined && home.tribe === w.tribe) {2205+ this.addStore(home, c.res, c.amount);2206+ }2207+ this.events.push({2208+ t: 'caravan_raided',2209+ tick: this.tick,2210+ tribe: c.tribe,2211+ by: w.tribe,2212+ x: c.x,2213+ y: c.y,2214+ resource: RESOURCES[c.res]!,2215+ amount: c.amount,2216+ });2217+ const i = this.caravans.indexOf(c);2218+ if (i !== -1) this.caravans.splice(i, 1);2219+ if (w.size > 0) this.routHome(w);2220+ }2221+2222+ private resolveSiege(w: Warband, s: Settlement): void {2223+ const garrisons = this.garrisonsAt(s);2224+ if (garrisons.length > 0) {2225+ // One battle per slow tick against the lowest-id garrison (with walls).2226+ const g = garrisons[0]!;2227+ const riteA = this.consumeWarRite(w);2228+ const riteD = this.consumeWarRite(g);2229+ let pa = Math.trunc((this.warbandPower(w, -1, 0) * (1000 + riteA)) / 1000);2230+ let pd = Math.trunc(2231+ (this.warbandPower(g, this.terrainAt(s.x, s.y), s.buildings[BI.walls]!) *2232+ (1000 + riteD)) /2233+ 1000,2234+ );2235+ pa = Math.trunc((pa * this.variance()) / 1000);2236+ pd = Math.trunc((pd * this.variance()) / 1000);2237+ const aWins = pa > pd;2238+ const win = aWins ? w : g;2239+ const lose = aWins ? g : w;2240+ const winP = aWins ? pa : pd;2241+ const loseP = aWins ? pd : pa;2242+ const { winnerPm, loserPm } = this.casualties(winP, loseP);2243+ const winLoss = this.applyWarbandLoss(win, winnerPm, false);2244+ const loseLoss = this.applyWarbandLoss(lose, loserPm, true);2245+ win.vet = Math.min(1000, win.vet + C.VET_WIN);2246+ lose.vet = Math.min(1000, lose.vet + C.VET_LOSS);2247+ this.tribes[win.tribe]!.battlesWon++;2248+ this.tribes[lose.tribe]!.battlesLost++;2249+ this.tribes[win.tribe]!.casDealt += loseLoss;2250+ this.tribes[win.tribe]!.casTaken += winLoss;2251+ this.tribes[lose.tribe]!.casDealt += winLoss;2252+ this.tribes[lose.tribe]!.casTaken += loseLoss;2253+ if (lose === w && w.size > 0) this.routHome(w);2254+ w.engaged = true;2255+ g.engaged = true;2256+ this.events.push({2257+ t: 'battle',2258+ tick: this.tick,2259+ x: s.x,2260+ y: s.y,2261+ attacker: w.tribe,2262+ defender: s.tribe,2263+ attackerLosses: aWins ? winLoss : loseLoss,2264+ defenderLosses: aWins ? loseLoss : winLoss,2265+ winner: win.tribe,2266+ });2267+ return;2268+ }2269+ const wall = s.buildings[BI.walls]!;2270+ if (wall > 0) {2271+ let dmg = this.warbandPower(w, -1, 0);2272+ if (wall >= 2 && !w.siege) dmg = Math.trunc(dmg / C.NO_RAM_DIV);2273+ s.wallDamage += dmg;2274+ if (s.wallDamage >= wall * C.WALL_HP) {2275+ s.buildings[BI.walls] = wall - 1;2276+ s.wallDamage = 0;2277+ this.events.push({2278+ t: 'building_destroyed',2279+ tick: this.tick,2280+ tribe: s.tribe,2281+ building: 'walls',2282+ settlementId: s.id,2283+ by: w.tribe,2284+ });2285+ }2286+ return;2287+ }2288+ // No walls, no garrison: storm vs militia.2289+ const rite = this.consumeWarRite(w);
@@ -1194,79 +2931,205 @@ class World implements EngineWorld {
@@ -1276,52 +3139,194 @@ class World implements EngineWorld {
@@ -1346,56 +3351,130 @@ class World implements EngineWorld {
diff truncated
M packages/engine/test/engine.test.ts +664 -372
@@ -1,95 +1,30 @@
11 import { describe, expect, it } from 'vitest';2-import type { EngineWorld, Order, SimEvent, TribeView } from '@tribes/protocol';3-import { LIMITS } from '@tribes/protocol';4-import { createWorld } from '../src/index.js';2+import type { EngineWorld, MetricsFrame, Order, SimEvent, TribeView } from '@tribes/protocol';3+import { LIMITS, PROFESSIONS, RESOURCES } from '@tribes/protocol';4+import { apportion, createWorld } from '../src/index.js';55 66 type Bot = (view: TribeView) => Order[];77 88 const passiveBot: Bot = () => [];99 10-/** Simple deterministic economy: gather, house, barracks, train villagers. */11-const economyBot: Bot = (v) => {12- const orders: Order[] = [];13- const villagers = v.units.filter((u) => u.kind === 'villager');14- const sites = v.buildings.filter((b) => b.progress < 100);15- const builders = villagers.filter((u) => u.job === 'build');16- if (sites.length > 0 && builders.length === 0 && villagers.length > 0) {17- orders.push({ kind: 'assign', unitId: villagers[0]!.id, job: 'build' });18- }19- if (sites.length === 0) {20- for (const b of builders) orders.push({ kind: 'assign', unitId: b.id, job: 'gather_food' });21- }22- const idle = villagers.filter((u) => u.job === 'idle');23- idle.forEach((u, i) => {24- orders.push({25- kind: 'assign',26- unitId: u.id,27- job: i % 2 === 0 ? 'gather_food' : 'gather_wood',28- });29- });30- const tc = v.buildings.find((b) => b.kind === 'town_center');31- if (tc !== undefined) {32- if (v.tick === 10) orders.push({ kind: 'build', building: 'house', x: tc.x + 2, y: tc.y + 2 });33- if (v.tick === 80) {34- // Granary, not barracks: keeps 'train soldier' invalid for the whole run35- // (the order-validation test relies on that).36- orders.push({ kind: 'build', building: 'granary', x: tc.x - 2, y: tc.y + 2 });37- }38- if (v.tick > 0 && v.tick % 40 === 0 && v.pop < v.popCap && v.resources.food >= 80) {39- orders.push({ kind: 'train', unit: 'villager' });40- }41- }42- return orders;43-};44-45-/** Builds a barracks, trains soldiers, sends an attack army at the mirrored spawn. */46-function makeRushBot(): Bot {47- let armyFormed = false;48- return (v) => {49- const orders: Order[] = [];50- const tc = v.buildings.find((b) => b.kind === 'town_center');51- const villagers = v.units.filter((u) => u.kind === 'villager');52- if (v.tick === 0 && tc !== undefined) {53- orders.push({ kind: 'build', building: 'barracks', x: tc.x + 2, y: tc.y });54- for (const u of villagers) orders.push({ kind: 'assign', unitId: u.id, job: 'build' });55- }56- const barracks = v.buildings.find((b) => b.kind === 'barracks');57- if (barracks !== undefined && barracks.progress >= 100) {58- villagers.forEach((u, i) => {59- if (u.job === 'build' || u.job === 'idle') {60- orders.push({61- kind: 'assign',62- unitId: u.id,63- job: i === 0 ? 'gather_wood' : 'gather_food',64- });65- }66- });67- if (v.resources.food >= 60 && v.resources.wood >= 20 && v.pop < v.popCap) {68- orders.push({ kind: 'train', unit: 'soldier' });69- }70- }71- const freeSoldiers = v.units.filter((u) => u.kind === 'soldier' && u.armyId === null);72- if (!armyFormed && freeSoldiers.length >= 4) {73- orders.push({74- kind: 'form_army',75- unitIds: freeSoldiers.map((s) => s.id),76- stance: 'attack',77- });78- armyFormed = true;79- }80- const army = v.armies[0];81- if (army !== undefined && army.target === null && tc !== undefined) {82- // Enemy spawn is mirrored through the map center.83- orders.push({84- kind: 'move_army',85- armyId: army.id,86- x: v.mapSize - 1 - tc.x,87- y: v.mapSize - 1 - tc.y,88- });89- }90- return orders;91- };92-}10+const A = (profession: string, permille: number): Order =>11+ ({ kind: 'allocate', profession, permille }) as Order;12+13+/** Self-sufficient economy policy (~40% food labor). */14+const basePolicy = (): Order[] => [15+ A('farmer', 400),16+ A('woodcutter', 100),17+ A('miner', 60),18+ A('herder', 30),19+ A('smith', 20),20+ A('builder', 60),21+ A('hauler', 20),22+ A('scholar', 20),23+ A('priest', 10),24+];25+26+/** Policy-at-tick-0 economy bot. */27+const economyBot: Bot = (v) => (v.tick === 0 ? basePolicy() : []);9328 9429 function stepWithBots(world: EngineWorld, bots: ReadonlyMap<number, Bot>): void {9530 const orders = new Map<number, Order[]>();
@@ -101,29 +36,103 @@ function runTicks(world: EngineWorld, bots: ReadonlyMap<number, Bot>, ticks: num
10136 for (let i = 0; i < ticks && !world.isOver(); i++) stepWithBots(world, bots);10237 }10338 104-const economyBots = (n: number): Map<number, Bot> => {39+const botsFor = (n: number, bot: Bot): Map<number, Bot> => {10540 const m = new Map<number, Bot>();106- for (let i = 0; i < n; i++) m.set(i, economyBot);41+ for (let i = 0; i < n; i++) m.set(i, bot);10742 return m;10843 };10944 45+const metricsOf = (world: EngineWorld, tribe: number): MetricsFrame['tribes'][number] => {46+ const m = world.latestMetrics();47+ if (m === null) throw new Error('no metrics yet');48+ return m.tribes.find((t) => t.tribeId === tribe)!;49+};50+11051 // ---------------------------------------------------------------------------11152 // Golden replay11253 // ---------------------------------------------------------------------------11354 114-const GOLDEN_CONFIG = { seed: '123456789', tribeCount: 4, mapSize: 64, maxTicks: 10_000 };55+/** Scripted scenario touching allocations, build, muster, command, trade,56+ * research, decree, craft, rite and say. */57+function goldenBot(id: number): Bot {58+ return (v) => {59+ const o: Order[] = [];60+ const s = v.settlements[0];61+ if (v.tick === 0) {62+ o.push(...basePolicy());63+ o.push({ kind: 'craft', target: 'tools', permille: 800 });64+ o.push({ kind: 'decree', key: 'slaughter', value: 50 });65+ o.push({ kind: 'research', tech: 'milling' });66+ }67+ if (v.tick === 5 && s !== undefined) {68+ o.push({ kind: 'build', building: 'house', settlementId: s.id, count: 2 });69+ o.push({ kind: 'build', building: 'farm', settlementId: s.id });70+ }71+ if (v.tick === 30) {72+ o.push({73+ kind: 'trade_offer',74+ give: 'timber',75+ giveAmount: 100,76+ want: 'stone',77+ wantAmount: 50,78+ tribe: null,79+ repeat: false,80+ });81+ }82+ if (v.tick === 31 && id === 1) {83+ o.push({84+ kind: 'trade_offer',85+ give: 'stone',86+ giveAmount: 60,87+ want: 'timber',88+ wantAmount: 80,89+ tribe: null,90+ repeat: false,91+ });92+ }93+ if (v.tick === 50 && s !== undefined) {94+ o.push({ kind: 'muster', settlementId: s.id, size: 40, unitKind: 'infantry' });95+ }96+ if (v.tick === 60 && v.warbands[0] !== undefined && s !== undefined) {97+ o.push({98+ kind: 'command',99+ warbandId: v.warbands[0].id,100+ order: { type: 'patrol', target: { x: s.x + 4, y: s.y + 4 } },101+ });102+ }103+ if (v.tick === 500 && s !== undefined) {104+ o.push({ kind: 'build', building: 'mill', settlementId: s.id });105+ }106+ if (v.tick === 900 && s !== undefined) {107+ o.push({ kind: 'rite', rite: 'blessing', target: s.id });108+ }109+ if (v.tick > 0 && v.tick % 250 === 0) {110+ o.push({ kind: 'say', text: `t${v.tick} from ${id}` });111+ }112+ return o;113+ };114+}115+116+const goldenBots = (): Map<number, Bot> => {117+ const m = new Map<number, Bot>();118+ for (let i = 0; i < 4; i++) m.set(i, goldenBot(i));119+ return m;120+};121+122+const GOLDEN_CONFIG = { seed: '123456789', tribeCount: 4, mapSize: 128, maxTicks: 10_000 };115123 // Generated once from this exact scenario; any engine change that alters116-// simulation behavior must regenerate these.124+// simulation behavior must regenerate these (see scripts in repo history:125+// run this test with GOLDEN printed on failure).117126 const GOLDEN_HASHES: Record<number, string> = {118- 0: 'e64c1260885face7',119- 100: '906a164346190600',120- 1000: '790aaede80a6ad95',127+ 0: 'aa20b26cd833e3ef',128+ 100: 'a38a105355f64efc',129+ 1000: 'd19b89fa4d2ac6ad',121130 };122131 123132 describe('golden replay', () => {124133 it('matches the recorded state hashes at ticks 0, 100, 1000', () => {125134 const world = createWorld(GOLDEN_CONFIG);126- const bots = economyBots(4);135+ const bots = goldenBots();127136 const actual: Record<number, string> = { 0: world.stateHash() };128137 while (world.tick < 1000 && !world.isOver()) {129138 stepWithBots(world, bots);
@@ -134,26 +143,38 @@ describe('golden replay', () => {
134143 });135144 136145 // ---------------------------------------------------------------------------137-// Determinism146+// Determinism (hashes, events, metrics; accumulators are hashed state)138147 // ---------------------------------------------------------------------------139148 140149 describe('determinism', () => {141- it('two independent runs with the same seed and orders produce identical hash timelines', () => {150+ it('dual runs agree on hashes, event streams and metrics frames', () => {142151 const config = { seed: '42', tribeCount: 3, mapSize: 64, maxTicks: 10_000 };143- const timeline = (): string[] => {152+ const run = (): { hashes: string[]; events: string[]; metrics: string[] } => {144153 const world = createWorld(config);145- const bots = economyBots(3);154+ const bots = new Map<number, Bot>();155+ for (let i = 0; i < 3; i++) bots.set(i, goldenBot(i));146156 const hashes: string[] = [world.stateHash()];147- for (let t = 0; t < 500 && !world.isOver(); t++) {157+ const events: string[] = [];158+ const metrics: string[] = [];159+ for (let t = 0; t < 400 && !world.isOver(); t++) {148160 stepWithBots(world, bots);149- if (world.tick % 50 === 0) hashes.push(world.stateHash());161+ hashes.push(world.stateHash());162+ events.push(163+ JSON.stringify([world.viewFor(0).events, world.viewFor(1).events, world.viewFor(2).events]),164+ );165+ if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {166+ metrics.push(JSON.stringify(world.latestMetrics()));167+ }150168 }151- return hashes;169+ return { hashes, events, metrics };152170 };153- const a = timeline();154- const b = timeline();155- expect(a.length).toBeGreaterThan(5);156- expect(b).toEqual(a);171+ const a = run();172+ const b = run();173+ expect(b.hashes).toEqual(a.hashes);174+ expect(b.events).toEqual(a.events);175+ expect(b.metrics).toEqual(a.metrics);176+ expect(a.metrics.length).toBeGreaterThan(10);177+ expect(a.events.some((e) => e !== '[[],[],[]]')).toBe(true);157178 });158179 159180 it('different seeds diverge', () => {
@@ -161,188 +182,489 @@ describe('determinism', () => {
161182 const b = createWorld({ seed: '2', tribeCount: 2, mapSize: 64, maxTicks: 100 });162183 expect(a.stateHash()).not.toEqual(b.stateHash());163184 });185+186+ it('reading views and metrics never touches the state hash', () => {187+ const world = createWorld({ seed: '7', tribeCount: 2, mapSize: 64, maxTicks: 1000 });188+ runTicks(world, botsFor(2, economyBot), 25);189+ const before = world.stateHash();190+ world.viewFor(0);191+ world.viewFor(1);192+ world.latestMetrics();193+ world.result();194+ expect(world.stateHash()).toBe(before);195+ });164196 });165197 166198 // ---------------------------------------------------------------------------167-// Order validation199+// Apportionment (largest remainder, ties ascending index)168200 // ---------------------------------------------------------------------------169201 170-describe('order validation', () => {171- it('invalid orders are dropped without state divergence', () => {172- const config = { seed: '777', tribeCount: 2, mapSize: 64, maxTicks: 10_000 };173- // Tribe 0 owns units 1..3; tribe 1 owns units 4..6 (spawn order).174- const junk: Order[] = [175- { kind: 'assign', unitId: 4, job: 'gather_food' }, // enemy unit176- { kind: 'assign', unitId: 9999, job: 'gather_wood' }, // no such unit177- { kind: 'assign', unitId: 1, job: 'fly' as never }, // bad job178- { kind: 'build', building: 'tower', x: 30, y: 30 }, // unaffordable (0 stone)179- { kind: 'build', building: 'house', x: -1, y: 5 }, // out of bounds180- { kind: 'build', building: 'town_center', x: 30, y: 31 }, // unbuildable kind181- { kind: 'train', unit: 'soldier' }, // no barracks182- { kind: 'train', unit: 'dragon' as never }, // bad kind183- { kind: 'form_army', unitIds: [1, 2, 3], stance: 'attack' }, // villagers184- { kind: 'form_army', unitIds: [4, 5], stance: 'attack' }, // enemy units185- { kind: 'set_stance', armyId: 12, stance: 'defend' }, // no such army186- { kind: 'move_army', armyId: 12, x: 5, y: 5 },187- { kind: 'disband_army', armyId: 55 },188- { bogus: true } as never, // malformed189- ];190- const cleanHashes: string[] = [];191- {192- const world = createWorld(config);193- const bots = economyBots(2);194- for (let t = 0; t < 150; t++) {195- stepWithBots(world, bots);196- cleanHashes.push(world.stateHash());202+describe('apportion', () => {203+ it('sums exactly and floors quotas', () => {204+ expect(apportion(7, [500, 300, 200])).toEqual([4, 2, 1]);205+ expect(apportion(100, [1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);206+ expect(apportion(1200, [400, 100, 60, 30, 20, 60, 20, 20, 10]).reduce((a, b) => a + b)).toBe(207+ 1200,208+ );209+ });210+ it('breaks remainder ties by ascending index', () => {211+ expect(apportion(10, [1, 1, 1])).toEqual([4, 3, 3]);212+ expect(apportion(2, [1, 1, 1, 1])).toEqual([1, 1, 0, 0]);213+ });214+ it('handles zero weights and zero totals', () => {215+ expect(apportion(5, [0, 0])).toEqual([0, 0]);216+ expect(apportion(0, [3, 4])).toEqual([0, 0]);217+ });218+ it('normalizes weight sums above 1000', () => {219+ const out = apportion(1000, [1500, 1500]);220+ expect(out).toEqual([500, 500]);221+ });222+});223+224+// ---------------------------------------------------------------------------225+// Economy integration226+// ---------------------------------------------------------------------------227+228+describe('economy', () => {229+ it('allocated farmers grow food; workers respect capacity', () => {230+ const world = createWorld({ seed: '11', tribeCount: 2, mapSize: 64, maxTicks: 5000 });231+ const bots = new Map<number, Bot>([232+ [0, economyBot],233+ [1, passiveBot],234+ ]);235+ runTicks(world, bots, 100);236+ const m = metricsOf(world, 0);237+ expect(m.production.grain).toBeGreaterThan(0);238+ expect(m.production.timber).toBeGreaterThan(0);239+ expect(m.starvationDeaths).toBe(0);240+ const v = world.viewFor(0);241+ const s = v.settlements[0]!;242+ expect(s.workers.farmer).toBeGreaterThan(0);243+ expect(s.workers.farmer!).toBeLessThanOrEqual(s.capacity.farmer!);244+ // Passive tribe extracts nothing.245+ expect(metricsOf(world, 1).production.grain).toBe(0);246+ });247+248+ it('tool shortage drops per-worker yield to ~600‰; smiths restore coverage', () => {249+ const world = createWorld({ seed: '12', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });250+ let phase2 = false;251+ const bot: Bot = (v) => {252+ const o: Order[] = [];253+ if (v.tick === 0) {254+ // 150‰ farmers (~180 of 1200) < 250 starting tools: full coverage.255+ o.push(A('farmer', 150), { kind: 'decree', key: 'rations', value: 500 } as Order);256+ o.push({ kind: 'craft', target: 'tools', permille: 1000 });257+ }258+ if (phase2 && v.tick % 50 === 0) o.push(A('smith', 30));259+ return o;260+ };261+ const bots = new Map<number, Bot>([262+ [0, bot],263+ [1, passiveBot],264+ ]);265+ runTicks(world, bots, 50);266+ const early = metricsOf(world, 0);267+ expect(early.toolCoverage).toBe(1000);268+ const earlyPerWorker = early.production.grain / early.popByProfession.farmer;269+ // Deplete the tools (wear, no smiths).270+ runTicks(world, bots, 800);271+ const late = metricsOf(world, 0);272+ expect(late.toolCoverage).toBeLessThan(50);273+ const latePerWorker = late.production.grain / late.popByProfession.farmer;274+ const ratio = latePerWorker / earlyPerWorker;275+ expect(ratio).toBeGreaterThan(0.55);276+ expect(ratio).toBeLessThan(0.66);277+ // Phase 2: smiths turn the stored metal into tools; coverage recovers278+ // while the fresh tools last.279+ phase2 = true;280+ let maxCoverage = 0;281+ let toolsMade = 0;282+ for (let t = 0; t < 300 && !world.isOver(); t++) {283+ stepWithBots(world, bots);284+ if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {285+ const m = metricsOf(world, 0);286+ maxCoverage = Math.max(maxCoverage, m.toolCoverage);287+ toolsMade += m.production.tools;197288 }198289 }199- const dirtyHashes: string[] = [];200- {201- const world = createWorld(config);202- const bots = economyBots(2);203- for (let t = 0; t < 150; t++) {204- const orders = new Map<number, Order[]>();205- for (const [id, bot] of bots) {206- const base = bot(world.viewFor(id));207- orders.set(id, id === 0 ? [...base, ...junk] : base);208- }209- world.step(orders);210- dirtyHashes.push(world.stateHash());290+ expect(toolsMade).toBeGreaterThan(0);291+ expect(maxCoverage).toBeGreaterThan(late.toolCoverage + 50);292+ });293+294+ it('starvation: an unworked tribe runs out of food, loses pop, gets events', () => {295+ const world = createWorld({ seed: '13', tribeCount: 2, mapSize: 64, maxTicks: 5000 });296+ const bots = botsFor(2, passiveBot);297+ let starved: SimEvent | undefined;298+ let totalStarved = 0;299+ for (let t = 0; t < 600 && !world.isOver(); t++) {300+ stepWithBots(world, bots);301+ starved ??= world.viewFor(0).events.find((e) => e.t === 'starvation' && e.tribe === 0);302+ if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {303+ totalStarved += metricsOf(world, 0).starvationDeaths;304+ }305+ }306+ expect(starved).toMatchObject({ t: 'starvation', tribe: 0 });307+ expect(totalStarved).toBeGreaterThan(0);308+ expect(metricsOf(world, 0).pop).toBeLessThan(LIMITS.START_POP);309+ });310+311+ it('population grows under a fed, housed economy', () => {312+ const world = createWorld({ seed: '14', tribeCount: 2, mapSize: 64, maxTicks: 5000 });313+ const bots = new Map<number, Bot>([314+ [0, economyBot],315+ [1, passiveBot],316+ ]);317+ let sawBirths = false;318+ for (let t = 0; t < 600 && !world.isOver(); t++) {319+ stepWithBots(world, bots);320+ if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0 && metricsOf(world, 0).births > 0) {321+ sawBirths = true;211322 }212323 }213- expect(dirtyHashes).toEqual(cleanHashes);324+ expect(sawBirths).toBe(true);325+ expect(metricsOf(world, 0).pop).toBeGreaterThan(LIMITS.START_POP);214326 });215327 });216328 217329 // ---------------------------------------------------------------------------218-// Combat & elimination330+// Military219331 // ---------------------------------------------------------------------------220332 221-describe('combat and elimination', () => {222- it('a scripted rush eliminates a passive tribe; result reports winner and diedAt', () => {223- const config = { seed: '2024', tribeCount: 2, mapSize: 64, maxTicks: 5000 };224- const world = createWorld(config);333+describe('military', () => {334+ it('muster requires weapons (and horses+tech for cavalry)', () => {335+ const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 1000 });336+ const sid = world.viewFor(0).settlements[0]!.id;337+ const orders: Order[] = [338+ { kind: 'muster', settlementId: sid, size: 150, unitKind: 'infantry' }, // > 100 weapons339+ { kind: 'muster', settlementId: sid, size: 5, unitKind: 'infantry' }, // < MUSTER_MIN340+ { kind: 'muster', settlementId: sid, size: 50, unitKind: 'infantry' }, // ok341+ { kind: 'muster', settlementId: sid, size: 60, unitKind: 'infantry' }, // 50 weapons left342+ { kind: 'muster', settlementId: sid, size: 10, unitKind: 'cavalry' }, // no horsemanship343+ ];344+ world.step(new Map([[0, orders]]));345+ const v = world.viewFor(0);346+ expect(v.warbands.length).toBe(1);347+ expect(v.warbands[0]!).toMatchObject({ size: 50, weapons: 50, kind: 'infantry' });348+ expect(v.settlements[0]!.stores.weapons).toBe(50);349+ expect(v.settlements[0]!.pop).toBe(LIMITS.START_POP - 50);350+ });351+352+ it('battles resolve deterministically; superior force wins', () => {353+ const config = { seed: '22', tribeCount: 2, mapSize: 64, maxTicks: 5000 };354+ const attacker: Bot = (v) => {355+ const o: Order[] = [];356+ const s = v.settlements[0];357+ if (v.tick === 0 && s !== undefined) {358+ o.push({ kind: 'stance', tribeId: 1, posture: 'hostile' });359+ o.push({ kind: 'muster', settlementId: s.id, size: 80, unitKind: 'infantry' });360+ }361+ if (v.tick === 1 && v.warbands[0] !== undefined) {362+ o.push({363+ kind: 'command',364+ warbandId: v.warbands[0].id,365+ order: { type: 'march', target: { x: v.mapSize - 1 - v.settlements[0]!.x, y: v.mapSize - 1 - v.settlements[0]!.y } },366+ });367+ }368+ return o;369+ };370+ const defender: Bot = (v) => {371+ const o: Order[] = [];372+ if (v.tick === 0 && v.settlements[0] !== undefined) {373+ o.push({ kind: 'muster', settlementId: v.settlements[0].id, size: 40, unitKind: 'infantry' });374+ }375+ return o;376+ };377+ const run = (): { events: string[]; firstBattle: SimEvent | undefined } => {378+ const world = createWorld(config);379+ const bots = new Map<number, Bot>([380+ [0, attacker],381+ [1, defender],382+ ]);383+ const events: string[] = [];384+ let firstBattle: SimEvent | undefined;385+ for (let t = 0; t < 300 && !world.isOver(); t++) {386+ stepWithBots(world, bots);387+ const evs = world.viewFor(1).events;388+ events.push(JSON.stringify(evs));389+ firstBattle ??= evs.find((e) => e.t === 'battle');390+ }391+ return { events, firstBattle };392+ };393+ const a = run();394+ const b = run();395+ expect(a.firstBattle).toBeDefined();396+ expect(a.firstBattle).toMatchObject({ t: 'battle', attacker: 0, defender: 1, winner: 0 });397+ const fb = a.firstBattle as Extract<SimEvent, { t: 'battle' }>;398+ expect(fb.defenderLosses).toBeGreaterThan(0);399+ expect(b.events).toEqual(a.events);400+ });401+402+ it('besiege captures an undefended settlement and eliminates the tribe', () => {403+ const world = createWorld({ seed: '23', tribeCount: 2, mapSize: 64, maxTicks: 5000 });404+ const enemySid = 2; // settlements are created in tribe order: tribe1 owns id 2405+ const attacker: Bot = (v) => {406+ const o: Order[] = [];407+ if (v.tick === 0 && v.settlements[0] !== undefined) {408+ o.push({ kind: 'muster', settlementId: v.settlements[0].id, size: 80, unitKind: 'infantry' });409+ }410+ if (v.tick === 1 && v.warbands[0] !== undefined) {411+ o.push({412+ kind: 'command',413+ warbandId: v.warbands[0].id,414+ order: { type: 'besiege', target: enemySid },415+ });416+ }417+ return o;418+ };225419 const bots = new Map<number, Bot>([226- [0, makeRushBot()],420+ [0, attacker],227421 [1, passiveBot],228422 ]);229- runTicks(world, bots, 5000);423+ let captured: SimEvent | undefined;424+ let eliminated: SimEvent | undefined;425+ for (let t = 0; t < 400 && !world.isOver(); t++) {426+ stepWithBots(world, bots);427+ captured ??= world.viewFor(0).events.find((e) => e.t === 'settlement_captured');428+ // The elimination event is delivered to the eliminated tribe itself.429+ eliminated ??= world.viewFor(1).events.find((e) => e.t === 'eliminated');430+ }431+ expect(captured).toMatchObject({432+ t: 'settlement_captured',433+ tribe: 1,434+ by: 0,435+ settlementId: enemySid,436+ });437+ expect(eliminated).toMatchObject({ t: 'eliminated', tribe: 1 });230438 expect(world.isOver()).toBe(true);231439 const result = world.result();232440 expect(result.reason).toBe('last_standing');233441 expect(result.winner).toBe(0);234- const loser = result.tribes.find((t) => t.tribeId === 1)!;235- expect(loser.alive).toBe(false);236- expect(loser.diedAt).toBeTypeOf('number');237- expect(loser.diedAt).toBeGreaterThan(0);238- expect(loser.pop).toBe(0);239- expect(loser.buildings).toBe(0);240- const victor = result.tribes.find((t) => t.tribeId === 0)!;241- expect(victor.alive).toBe(true);242- expect(victor.kills).toBeGreaterThan(0);243- expect(victor.score).toBeGreaterThan(loser.score);244- expect(result.endedAt).toBe(world.tick);442+ expect(result.tribes.find((t) => t.tribeId === 0)!.settlements).toBe(2);443+ expect(result.tribes.find((t) => t.tribeId === 1)!.diedAt).toBeGreaterThan(0);444+ // The captured settlement now appears in the captor's view.445+ expect(world.viewFor(0).settlements.map((s) => s.id).sort()).toEqual([1, 2]);245446 });447+});246448 247- it('forfeit removes the tribe and can end the sim', () => {248- const world = createWorld({ seed: '5', tribeCount: 2, mapSize: 64, maxTicks: 100 });249- world.forfeit(1);250- expect(world.isOver()).toBe(true);251- expect(world.viewFor(1).events).toContainEqual({ t: 'forfeited', tick: 0, tribe: 1 });252- const result = world.result();253- expect(result.reason).toBe('last_standing');254- expect(result.winner).toBe(0);255- const dead = result.tribes.find((t) => t.tribeId === 1)!;256- expect(dead.alive).toBe(false);257- expect(dead.diedAt).toBe(0);258- expect(dead.pop).toBe(0);259- expect(dead.buildings).toBe(0);449+// ---------------------------------------------------------------------------450+// Threats451+// ---------------------------------------------------------------------------452+453+describe('threats', () => {454+ it('camps spawn and raid settlements (raid.by === -1 convention)', () => {455+ const world = createWorld({ seed: '32', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });456+ const bots = botsFor(2, economyBot);457+ let raid: SimEvent | undefined;458+ let threatSeen = false;459+ for (let t = 0; t < 4000 && !world.isOver(); t++) {460+ stepWithBots(world, bots);461+ for (const id of [0, 1]) {462+ const v = world.viewFor(id);463+ if (v.threats.length > 0) threatSeen = true;464+ raid ??= v.events.find((e) => e.t === 'raid' && e.by === -1 && e.tribe === id);465+ }466+ if (raid !== undefined && threatSeen) break;467+ }468+ // Camps spawn 10..25 tiles out; settlement vision is radius+2 = 14, so469+ // nearby camps show up in ThreatView. Raids reach the victim as raid470+ // events with the documented by = -1 threat convention.471+ expect(raid).toBeDefined();472+ expect(raid).toMatchObject({ t: 'raid', by: -1 });473+ expect(threatSeen).toBe(true);260474 });261475 });262476 263477 // ---------------------------------------------------------------------------264-// Fog of war478+// Trade265479 // ---------------------------------------------------------------------------266480 267-describe('fog of war', () => {268- it('shows ? beyond vision at tick 0 and hides distant enemies', () => {269- const world = createWorld({ seed: '99', tribeCount: 2, mapSize: 64, maxTicks: 1000 });270- const v = world.viewFor(0);271- expect(v.terrain.length).toBe(64 * 64);272- expect(v.terrain).toContain('?');273- const tc = v.buildings.find((b) => b.kind === 'town_center')!;274- // Own base explored...275- expect(v.terrain[tc.y * 64 + tc.x]).not.toBe('?');276- // ...but the far corner is not, and the distant enemy is invisible.277- expect(v.terrain[0]).toBe('?');278- expect(v.enemies).toEqual([]);279- expect(v.aliveTribes).toEqual([0, 1]);481+describe('trade', () => {482+ it('matches complementary offers at the midpoint and delivers by caravan', () => {483+ const world = createWorld({ seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 5000 });484+ const t0: Order[] = [485+ { kind: 'trade_offer', give: 'timber', giveAmount: 200, want: 'stone', wantAmount: 100, tribe: null, repeat: false },486+ ];487+ const t1: Order[] = [488+ { kind: 'trade_offer', give: 'stone', giveAmount: 100, want: 'timber', wantAmount: 150, tribe: null, repeat: false },489+ ];490+ world.step(491+ new Map<number, Order[]>([492+ [0, t0],493+ [1, t1],494+ ]),495+ );496+ // Offers visible to both sides (both have markets, neutral stances).497+ expect(world.viewFor(0).offers.length).toBe(2);498+ expect(world.viewFor(1).offers.length).toBe(2);499+ const empty = new Map<number, Order[]>();500+ let sawCaravan = false;501+ for (let t = 0; t < 150 && !world.isOver(); t++) {502+ world.step(empty);503+ if (world.viewFor(0).caravans.length > 0) sawCaravan = true;504+ }505+ expect(sawCaravan).toBe(true);506+ // q = min(100, 100) = 100 stone; pay = 100*(200*100+100*150)/(2*100*100) = 175 timber.507+ const m0 = metricsOf(world, 0);508+ const m1 = metricsOf(world, 1);509+ expect(m0.stores.stone).toBe(250 + 100);510+ expect(m0.stores.timber).toBe(400 - 175);511+ expect(m1.stores.timber).toBe(400 + 175);512+ expect(m1.stores.stone).toBe(250 - 100);513+ expect(m0.tradeVolume).toBeGreaterThan(0);514+ // Matched non-repeat offers are gone.515+ expect(world.viewFor(0).offers.length).toBe(0);280516 });281517 282- it('reveals enemies only when they come into range', () => {283- const config = { seed: '2024', tribeCount: 2, mapSize: 64, maxTicks: 5000 };284- const world = createWorld(config);285- const bots = new Map<number, Bot>([286- [0, makeRushBot()],287- [1, passiveBot],288- ]);289- // The passive tribe sees nothing at first.290- expect(world.viewFor(1).enemies).toEqual([]);291- let sightingTick = -1;292- for (let t = 0; t < 5000 && !world.isOver(); t++) {293- stepWithBots(world, bots);294- if (sightingTick === -1) {295- const v = world.viewFor(1);296- if (v.enemies.length > 0) {297- sightingTick = world.tick;298- const sighting = v.enemies[0]!;299- expect(sighting.tribeId).toBe(0);300- expect(sighting.units.length + sighting.buildings.length).toBeGreaterThan(0);301- // Everything sighted is within vision range (6) of some own entity.302- const own = [303- ...v.units.map((u) => ({ x: u.x, y: u.y })),304- ...v.buildings.map((b) => ({ x: b.x, y: b.y })),305- ];306- for (const e of sighting.units) {307- const near = own.some((o) => Math.max(Math.abs(o.x - e.x), Math.abs(o.y - e.y)) <= 6);308- expect(near).toBe(true);309- }310- break;518+ it('peddler: sell-to-coin offers clear instantly at a poor rate', () => {519+ const world = createWorld({ seed: '42', tribeCount: 2, mapSize: 64, maxTicks: 5000 });520+ const orders: Order[] = [521+ { kind: 'trade_offer', give: 'timber', giveAmount: 100, want: 'coin', wantAmount: 1, tribe: null, repeat: false },522+ ];523+ world.step(new Map([[0, orders]]));524+ const empty = new Map<number, Order[]>();525+ for (let t = 0; t < 12; t++) world.step(empty);526+ // 100 timber * value 1 * 250‰ = 25 coin.527+ expect(metricsOf(world, 0).stores.coin).toBe(100 + 25);528+ expect(metricsOf(world, 0).stores.timber).toBe(400 - 100);529+ });530+});531+532+// ---------------------------------------------------------------------------533+// Order validation534+// ---------------------------------------------------------------------------535+536+describe('order validation', () => {537+ it('invalid orders are dropped without state divergence', () => {538+ const config = { seed: '77', tribeCount: 2, mapSize: 64, maxTicks: 10_000 };539+ const junk: Order[] = [540+ { kind: 'allocate', profession: 'wizard' as never, permille: 100 },541+ { kind: 'allocate', profession: 'farmer', permille: 1500 },542+ { kind: 'allocate', profession: 'farmer', permille: -5 },543+ { kind: 'allocate', profession: 'farmer', permille: 100, settlementId: 2 }, // enemy settlement544+ { kind: 'craft', target: 'bogus' as never, permille: 100 },545+ { kind: 'decree', key: 'nope' as never, value: 100 },546+ { kind: 'decree', key: 'rations', value: 9999 },547+ { kind: 'build', building: 'hall', settlementId: 1 },548+ { kind: 'build', building: 'castle' as never, settlementId: 1 },549+ { kind: 'build', building: 'farm', settlementId: 2 },550+ { kind: 'build', building: 'mill', settlementId: 1 }, // milling not researched551+ { kind: 'build', building: 'tower', settlementId: 1 }, // no walls552+ { kind: 'demolish', building: 'hall', settlementId: 1 },553+ { kind: 'found', x: -3, y: 5, settlers: 100, supplies: {} },554+ { kind: 'found', x: 30, y: 30, settlers: 5, supplies: {} }, // too few555+ { kind: 'route', fromId: 1, toId: 2, resource: 'grain', permilleOfSurplus: 100 }, // enemy dest556+ { kind: 'muster', settlementId: 2, size: 20, unitKind: 'infantry' },557+ { kind: 'muster', settlementId: 1, size: 20, unitKind: 'dragon' as never },558+ { kind: 'muster', settlementId: 1, size: 20, unitKind: 'cavalry' }, // no horsemanship559+ { kind: 'command', warbandId: 99, order: { type: 'march', target: { x: 1, y: 1 } } },560+ { kind: 'trade_offer', give: 'grain', giveAmount: 0, want: 'coin', wantAmount: 1, tribe: null, repeat: false },561+ { kind: 'trade_offer', give: 'grain', giveAmount: 10, want: 'grain', wantAmount: 10, tribe: null, repeat: false },562+ { kind: 'trade_cancel', offerId: 12345 },563+ { kind: 'stance', tribeId: 0, posture: 'hostile' }, // self564+ { kind: 'stance', tribeId: 7, posture: 'ally' }, // no such tribe565+ { kind: 'research', tech: 'alchemy' as never },566+ { kind: 'research', tech: 'plate_armor' }, // missing prereqs567+ { kind: 'rite', rite: 'blessing', target: 1 }, // no faith yet568+ { kind: 'rite', rite: 'war', target: null },569+ { bogus: true } as never,570+ ];571+ const runHashes = (withJunk: boolean): string[] => {572+ const world = createWorld(config);573+ const bots = botsFor(2, economyBot);574+ const hashes: string[] = [];575+ for (let t = 0; t < 120; t++) {576+ const orders = new Map<number, Order[]>();577+ for (const [id, bot] of bots) {578+ const base = bot(world.viewFor(id));579+ orders.set(id, withJunk && id === 0 ? [...base, ...junk] : base);311580 }581+ world.step(orders);582+ hashes.push(world.stateHash());312583 }313- }314- expect(sightingTick).toBeGreaterThan(0);584+ return hashes;585+ };586+ expect(runHashes(true)).toEqual(runHashes(false));315587 });316588 });317589 318590 // ---------------------------------------------------------------------------319-// Views are plain JSON-safe data591+// Views, fog, events320592 // ---------------------------------------------------------------------------321593 322594 describe('viewFor', () => {323595 it('survives a JSON round-trip and structuredClone unchanged', () => {324- const world = createWorld({ seed: '31337', tribeCount: 2, mapSize: 64, maxTicks: 1000 });325- runTicks(world, economyBots(2), 120);596+ const world = createWorld({ seed: '55', tribeCount: 2, mapSize: 64, maxTicks: 1000 });597+ runTicks(world, botsFor(2, (v) => goldenBot(v.tribeId)(v)), 120);326598 const v = world.viewFor(0);327599 expect(JSON.parse(JSON.stringify(v))).toEqual(v);328600 expect(structuredClone(v)).toEqual(v);329- expect(v.units.length).toBeGreaterThan(0);330- expect(v.pop).toBe(v.units.length);331- expect(v.popCap).toBeGreaterThanOrEqual(10);601+ expect(v.settlements.length).toBeGreaterThan(0);602+ expect(v.terrain.length).toBe(64 * 64);603+ // All record fields fully populated.604+ for (const r of RESOURCES) expect(v.settlements[0]!.stores[r]).toBeTypeOf('number');605+ for (const p of PROFESSIONS) expect(v.allocations[p]).toBeTypeOf('number');332606 });333-});334607 335-// ---------------------------------------------------------------------------336-// Event feed337-// ---------------------------------------------------------------------------608+ it('fog: unexplored terrain is ?, enemies hidden until seen', () => {609+ const world = createWorld({ seed: '56', tribeCount: 2, mapSize: 128, maxTicks: 1000 });610+ const v = world.viewFor(0);611+ expect(v.terrain).toContain('?');612+ const s = v.settlements[0]!;613+ expect(v.terrain[s.y * 128 + s.x]).not.toBe('?');614+ expect(v.enemySettlements).toEqual([]);615+ expect(v.enemyWarbands).toEqual([]);616+ expect(v.aliveTribes).toEqual([0, 1]);617+ });618+619+ it('spawn fairness: quotas hold within radius 8 of every founding site', () => {620+ const world = createWorld({ seed: '57', tribeCount: 8, mapSize: 128, maxTicks: 100 });621+ for (let id = 0; id < 8; id++) {622+ const v = world.viewFor(id);623+ const s = v.settlements[0]!;624+ let plains = 0;625+ let forest = 0;626+ let hills = 0;627+ let ore = 0;628+ let waterAdj = 0;629+ const at = (x: number, y: number): string =>630+ x >= 0 && y >= 0 && x < 128 && y < 128 ? v.terrain[y * 128 + x]! : 'w';631+ for (let dy = -8; dy <= 8; dy++) {632+ for (let dx = -8; dx <= 8; dx++) {633+ const x = s.x + dx;634+ const y = s.y + dy;635+ const t = at(x, y);636+ if (t === '.') plains++;637+ else if (t === 'f') forest++;638+ if ('hmOGv'.includes(t)) hills++;639+ if (t === 'O' || t === 'G') ore++;640+ if (t !== 'w' && t !== 'r' && t !== '?') {641+ let adjWater = false;642+ for (let ay = -1; ay <= 1 && !adjWater; ay++) {643+ for (let ax = -1; ax <= 1; ax++) {644+ const n = at(x + ax, y + ay);645+ if (n === 'w' || n === 'r') {646+ adjWater = true;647+ break;648+ }649+ }650+ }651+ if (adjWater) waterAdj++;652+ }653+ }654+ }655+ expect(plains, `tribe ${id} plains`).toBeGreaterThanOrEqual(20);656+ expect(forest, `tribe ${id} forest`).toBeGreaterThanOrEqual(10);657+ expect(hills, `tribe ${id} hills`).toBeGreaterThanOrEqual(6);658+ expect(ore, `tribe ${id} ore`).toBeGreaterThanOrEqual(1);659+ expect(waterAdj, `tribe ${id} waterAdj`).toBeGreaterThanOrEqual(3);660+ }661+ });338662 339-describe('events', () => {340- it('say orders broadcast to every tribe on the next view, capped per tick and length', () => {341- const world = createWorld({ seed: '7', tribeCount: 3, mapSize: 64, maxTicks: 100 });663+ it('say broadcasts to everyone; events capped per view', () => {664+ const world = createWorld({ seed: '58', tribeCount: 3, mapSize: 64, maxTicks: 100 });342665 const says: Order[] = [343666 { kind: 'say', text: 'hello' },344- { kind: 'say', text: 'second — dropped by SAYS_PER_TICK' },345- { kind: 'say', text: 'third — dropped' },667+ { kind: 'say', text: 'dropped by SAYS_PER_TICK' },346668 ];347669 const long: Order[] = [{ kind: 'say', text: 'x'.repeat(300) }];348670 world.step(
@@ -357,29 +679,18 @@ describe('events', () => {
357679 { t: 'say', tick: 0, tribe: 0, text: 'hello' },358680 { t: 'say', tick: 0, tribe: 1, text: 'x'.repeat(LIMITS.SAY_MAX_CHARS) },359681 ]);682+ expect(world.viewFor(t).events.length).toBeLessThanOrEqual(LIMITS.EVENTS_PER_VIEW);360683 }361- // Buffer is per-step (previous says gone) and invalid says are dropped.362- const junkSays: Order[] = [363- { kind: 'say', text: '' },364- { kind: 'say', text: 42 as never },365- ];366- world.step(new Map<number, Order[]>([[0, junkSays]]));367- expect(world.viewFor(2).events.filter((e) => e.t === 'say')).toEqual([]);368684 });369685 370686 it('say orders never affect stateHash', () => {371- const config = { seed: '31337', tribeCount: 2, mapSize: 64, maxTicks: 1000 };687+ const config = { seed: '59', tribeCount: 2, mapSize: 64, maxTicks: 1000 };372688 const run = (withSay: boolean): string[] => {373689 const world = createWorld(config);374- const bots = economyBots(2);375690 const hashes: string[] = [];376- for (let t = 0; t < 100; t++) {691+ for (let t = 0; t < 60; t++) {377692 const orders = new Map<number, Order[]>();378- for (const [id, bot] of bots) {379- const base = bot(world.viewFor(id));380- if (withSay) base.push({ kind: 'say', text: `tick ${t} from ${id}` });381- orders.set(id, base);382- }693+ orders.set(0, withSay ? [{ kind: 'say', text: `tick ${t}` }] : []);383694 world.step(orders);384695 hashes.push(world.stateHash());385696 }
@@ -387,149 +698,130 @@ describe('events', () => {
387698 };388699 expect(run(true)).toEqual(run(false));389700 });701+});390702 391- it('combat deaths, razings, and elimination emit events delivered to both sides', () => {392- const world = createWorld({ seed: '2024', tribeCount: 2, mapSize: 64, maxTicks: 5000 });393- const bots = new Map<number, Bot>([394- [0, makeRushBot()],395- [1, passiveBot],396- ]);397- let lost0: SimEvent | undefined; // killer's view398- let lost1: SimEvent | undefined; // victim's view399- let destroyed0: SimEvent | undefined;400- let destroyed1: SimEvent | undefined;401- let elim1: SimEvent | undefined;402- for (let t = 0; t < 5000 && !world.isOver(); t++) {403- stepWithBots(world, bots);404- const v0 = world.viewFor(0);405- const v1 = world.viewFor(1);406- const isLost = (e: SimEvent): boolean =>407- e.t === 'unit_lost' && e.tribe === 1 && e.by === 0;408- const isRazed = (e: SimEvent): boolean =>409- e.t === 'building_destroyed' && e.tribe === 1 && e.by === 0;410- lost0 ??= v0.events.find(isLost);411- lost1 ??= v1.events.find(isLost);412- destroyed0 ??= v0.events.find(isRazed);413- destroyed1 ??= v1.events.find(isRazed);414- elim1 ??= v1.events.find((e) => e.t === 'eliminated' && e.tribe === 1);415- }416- expect(lost0).toMatchObject({ t: 'unit_lost', tribe: 1, by: 0 });417- expect(lost0).toEqual(lost1);418- expect(destroyed0).toMatchObject({ t: 'building_destroyed', tribe: 1, by: 0 });419- expect(destroyed0).toEqual(destroyed1);420- expect(elim1).toMatchObject({ t: 'eliminated', tribe: 1 });421- });703+// ---------------------------------------------------------------------------704+// Metrics705+// ---------------------------------------------------------------------------422706 423- it('does not deliver out-of-vision events to uninvolved tribes', () => {424- const world = createWorld({ seed: '99', tribeCount: 2, mapSize: 64, maxTicks: 1000 });707+describe('metrics', () => {708+ it('frames are complete and interval flows reset each interval', () => {709+ const world = createWorld({ seed: '61', tribeCount: 2, mapSize: 64, maxTicks: 5000 });425710 const bots = new Map<number, Bot>([426711 [0, economyBot],427712 [1, passiveBot],428713 ]);429- let complete: SimEvent | undefined;430- for (let t = 0; t < 200 && complete === undefined; t++) {431- stepWithBots(world, bots);432- complete = world.viewFor(0).events.find((e) => e.t === 'building_complete');433- if (complete !== undefined) {434- // Spawns are far apart: tribe 1 neither owns the site nor sees it.435- const leaked = world.viewFor(1).events.filter((e) => e.t === 'building_complete');436- expect(leaked).toEqual([]);437- }714+ expect(world.latestMetrics()).toBeNull();715+ runTicks(world, bots, 200);716+ const frameA = JSON.parse(JSON.stringify(world.latestMetrics())) as MetricsFrame;717+ runTicks(world, bots, 10);718+ const frameB = world.latestMetrics()!;719+ expect(frameB.tick).toBe(frameA.tick + 10);720+ const a = frameA.tribes[0]!;721+ const b = frameB.tribes[0]!;722+ // Interval flows, not cumulative: steady economy → similar magnitudes.723+ expect(a.production.grain).toBeGreaterThan(0);724+ expect(b.production.grain).toBeGreaterThan(0);725+ expect(b.production.grain).toBeLessThan(a.production.grain * 2);726+ expect(b.consumption.grain).toBeGreaterThan(0);727+ // Every field populated.728+ for (const r of RESOURCES) {729+ expect(a.stores[r]).toBeGreaterThanOrEqual(0);730+ expect(a.production[r]).toBeGreaterThanOrEqual(0);731+ expect(a.consumption[r]).toBeGreaterThanOrEqual(0);438732 }439- expect(complete).toMatchObject({ t: 'building_complete', tribe: 0 });440- });441-442- it('sighted fires once per observer/enemy pair per SIGHT_COOLDOWN window', () => {443- const world = createWorld({ seed: '2024', tribeCount: 2, mapSize: 64, maxTicks: 5000 });444- const bots = new Map<number, Bot>([445- [0, makeRushBot()],446- [1, passiveBot],447- ]);448- const observerTicks: number[] = []; // observer 1 sighting enemy 0449- let deliveredToEnemy = false; // the same event reaches tribe 0 (e.enemy === 0)450- for (let t = 0; t < 2000 && !world.isOver(); t++) {451- stepWithBots(world, bots);452- for (const e of world.viewFor(1).events) {453- if (e.t === 'sighted' && e.tribe === 1 && e.enemy === 0) observerTicks.push(e.tick);454- }455- if (456- world.viewFor(0).events.some((e) => e.t === 'sighted' && e.tribe === 1 && e.enemy === 0)457- ) {458- deliveredToEnemy = true;459- }460- }461- expect(observerTicks.length).toBeGreaterThan(0);462- // Enemy remains in vision on consecutive ticks; a re-emit before the463- // cooldown elapses would produce a gap < 200 here.464- for (let i = 1; i < observerTicks.length; i++) {465- expect(observerTicks[i]! - observerTicks[i - 1]!).toBeGreaterThanOrEqual(200);466- }467- expect(deliveredToEnemy).toBe(true);733+ for (const p of PROFESSIONS) expect(a.popByProfession[p]).toBeGreaterThanOrEqual(0);734+ expect(a.pop).toBeGreaterThan(0);735+ expect(a.moraleAvg).toBeGreaterThan(0);736+ expect(a.territoryTiles).toBeGreaterThan(0);737+ expect(a.settlementsByTier).toEqual([0, 0, 0, 1]);738+ expect(a.score).toBe(739+ a.scoreBreakdown.pop +740+ a.scoreBreakdown.settlements +741+ a.scoreBreakdown.techs +742+ a.scoreBreakdown.military +743+ a.scoreBreakdown.coin +744+ a.scoreBreakdown.buildings,745+ );746+ expect(a.toolCoverage).toBeGreaterThanOrEqual(0);747+ expect(a.toolCoverage).toBeLessThanOrEqual(1000);468748 });749+});469750 470- it('event streams are identical across runs and reading them never touches stateHash', () => {471- const config = { seed: '4242', tribeCount: 2, mapSize: 64, maxTicks: 5000 };472- const run = (): { hashes: string[]; streams: string[] } => {473- const world = createWorld(config);474- const bots = new Map<number, Bot>([475- [0, makeRushBot()],476- [1, passiveBot],477- ]);478- const hashes: string[] = [];479- const streams: string[] = [];480- for (let t = 0; t < 600 && !world.isOver(); t++) {481- stepWithBots(world, bots);482- const before = world.stateHash();483- const v0 = world.viewFor(0);484- const v1 = world.viewFor(1);485- expect(world.stateHash()).toBe(before);486- hashes.push(before);487- streams.push(JSON.stringify([v0.events, v1.events]));488- }489- return { hashes, streams };490- };491- const a = run();492- const b = run();493- expect(b.hashes).toEqual(a.hashes);494- expect(b.streams).toEqual(a.streams);495- // The scenario actually produced events (streams are not all empty).496- expect(a.streams.some((s) => s !== '[[],[]]')).toBe(true);497- });751+// ---------------------------------------------------------------------------752+// Forfeit753+// ---------------------------------------------------------------------------498754 499- it('events survive a JSON round-trip (QuickJS boundary) and respect EVENTS_PER_VIEW', () => {500- const world = createWorld({ seed: '2024', tribeCount: 2, mapSize: 64, maxTicks: 5000 });501- const bots = new Map<number, Bot>([502- [0, makeRushBot()],503- [1, passiveBot],504- ]);505- for (let t = 0; t < 800 && !world.isOver(); t++) {506- stepWithBots(world, bots);507- for (const id of [0, 1]) {508- const evs = world.viewFor(id).events;509- expect(evs.length).toBeLessThanOrEqual(LIMITS.EVENTS_PER_VIEW);510- if (evs.length > 0) expect(JSON.parse(JSON.stringify(evs))).toEqual(evs);511- }512- }755+describe('forfeit', () => {756+ it('removes the tribe and can end the sim', () => {757+ const world = createWorld({ seed: '71', tribeCount: 2, mapSize: 64, maxTicks: 100 });758+ world.forfeit(1);759+ expect(world.isOver()).toBe(true);760+ expect(world.viewFor(1).events).toContainEqual({ t: 'forfeited', tick: 0, tribe: 1 });761+ const result = world.result();762+ expect(result.reason).toBe('last_standing');763+ expect(result.winner).toBe(0);764+ const dead = result.tribes.find((t) => t.tribeId === 1)!;765+ expect(dead.alive).toBe(false);766+ expect(dead.settlements).toBe(0);767+ expect(world.latestMetrics()).not.toBeNull();513768 });514769 });515770 516771 // ---------------------------------------------------------------------------517-// Performance smoke772+// Performance518773 // ---------------------------------------------------------------------------519774 520775 describe('performance', () => {521776 it(522- 'runs 10,000 ticks with 8 tribes in a reasonable time',777+ '8 tribes x 10k ticks with active policies stays under budget',523778 () => {524- const world = createWorld({ seed: '8888', tribeCount: 8, mapSize: 64, maxTicks: 10_000 });525- const bots = economyBots(8);779+ const world = createWorld({ seed: '8888', tribeCount: 8, mapSize: 128, maxTicks: 10_000 });780+ const activeBot = (id: number): Bot => (v) => {781+ if (v.tick % 10 !== 0) return [];782+ const o: Order[] = [];783+ if (v.tick === 0) o.push(...basePolicy());784+ const s = v.settlements[0];785+ if (s !== undefined) {786+ // Periodic policy churn + entity orders.787+ o.push(A('farmer', 380 + ((v.tick / 10 + id) % 4) * 10));788+ if (v.tick % 500 === 0 && v.tick > 0) {789+ o.push({ kind: 'build', building: 'house', settlementId: s.id });790+ }791+ if (v.tick === 100) {792+ o.push({ kind: 'muster', settlementId: s.id, size: 30, unitKind: 'infantry' });793+ }794+ if (v.tick === 120 && v.warbands[0] !== undefined) {795+ o.push({796+ kind: 'command',797+ warbandId: v.warbands[0].id,798+ order: { type: 'patrol', target: { x: s.x + 3, y: s.y } },799+ });800+ }801+ if (v.tick % 1000 === 0 && v.tick > 0) {802+ o.push({803+ kind: 'trade_offer',804+ give: 'timber',805+ giveAmount: 50,806+ want: 'stone',807+ wantAmount: 25,808+ tribe: null,809+ repeat: false,810+ });811+ }812+ }813+ return o;814+ };815+ const bots = new Map<number, Bot>();816+ for (let i = 0; i < 8; i++) bots.set(i, activeBot(i));526817 const start = performance.now();527818 runTicks(world, bots, 10_000);528819 const elapsed = performance.now() - start;820+ // eslint-disable-next-line no-console821+ console.log(`perf: ${world.tick} ticks in ${elapsed.toFixed(0)}ms`);529822 expect(world.tick).toBeGreaterThan(1000);530- // Loose bound: the target is "a few seconds" for a full run.531- expect(elapsed).toBeLessThan(15_000);823+ expect(elapsed).toBeLessThan(20_000); // target < 10s532824 },533- 30_000,825+ 60_000,534826 );535827 });
M packages/engine/tsconfig.tsbuildinfo +1 -1

Not shown: this page already shows 6000 lines of diff.

M packages/protocol/src/game.ts +333 -109

Not shown: this page already shows 6000 lines of diff.

M packages/protocol/tsconfig.tsbuildinfo +1 -1

Not shown: this page already shows 6000 lines of diff.

A packages/script-host/bots/bulwark.js +142 -0

Not shown: this page already shows 6000 lines of diff.

D packages/script-host/bots/gatherer.js +0 -133

Not shown: this page already shows 6000 lines of diff.

A packages/script-host/bots/homesteader.js +119 -0

Not shown: this page already shows 6000 lines of diff.

A packages/script-host/bots/magnate.js +168 -0

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/bots/primitives.js +72 -109

Not shown: this page already shows 6000 lines of diff.

D packages/script-host/bots/rusher.js +0 -156

Not shown: this page already shows 6000 lines of diff.

D packages/script-host/bots/turtle.js +0 -170

Not shown: this page already shows 6000 lines of diff.

A packages/script-host/bots/warlord.js +170 -0

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/scripts/gen-bots.mjs +2 -2

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/src/bots.ts +610 -505

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/src/index.ts +216 -51

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/src/prelude.ts +109 -27

Not shown: this page already shows 6000 lines of diff.

A packages/script-host/test/fixtures.ts +103 -0

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/test/host.test.ts +191 -76

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/test/primitives.test.ts +45 -44

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/test/regex-gas.test.ts +7 -13

Not shown: this page already shows 6000 lines of diff.

M packages/script-host/tsconfig.tsbuildinfo +1 -1

Not shown: this page already shows 6000 lines of diff.

M scripts/balance.mjs +37 -25

Not shown: this page already shows 6000 lines of diff.

D scripts/import-assets.ts +0 -160

Not shown: this page already shows 6000 lines of diff.

A scripts/import-urizen.mjs +86 -0

Not shown: this page already shows 6000 lines of diff.

A web/public/urizen/LICENSE.txt +12 -0

Not shown: this page already shows 6000 lines of diff.

A web/public/urizen/urizen_onebit_tileset__v2d0.png +0 -0

Binary file.

M web/src/dev-mock.ts +712 -257

Not shown: this page already shows 6000 lines of diff.

M web/src/main.ts +1 -1

Not shown: this page already shows 6000 lines of diff.

A web/src/pages/dashboard.ts +450 -0

Not shown: this page already shows 6000 lines of diff.

M web/src/pages/home.ts +1 -1

Not shown: this page already shows 6000 lines of diff.

M web/src/pages/viewer.ts +189 -199

Not shown: this page already shows 6000 lines of diff.

D web/src/render/manifest.ts +0 -41

Not shown: this page already shows 6000 lines of diff.

A web/src/render/map-view.ts +290 -0

Not shown: this page already shows 6000 lines of diff.

D web/src/render/renderer.ts +0 -237

Not shown: this page already shows 6000 lines of diff.

A web/src/render/urizen-manifest.ts +102 -0

Not shown: this page already shows 6000 lines of diff.

A web/src/render/urizen.ts +137 -0

Not shown: this page already shows 6000 lines of diff.

M web/src/sim-worker.ts +145 -87

Not shown: this page already shows 6000 lines of diff.

M web/src/sim/frames.ts +70 -40

Not shown: this page already shows 6000 lines of diff.

M web/src/sim/runner.ts +16 -3

Not shown: this page already shows 6000 lines of diff.

M web/src/style.css +119 -0

Not shown: this page already shows 6000 lines of diff.

A web/src/viz/charts.ts +748 -0

Not shown: this page already shows 6000 lines of diff.

A web/src/viz/palette.ts +66 -0

Not shown: this page already shows 6000 lines of diff.

M worker/src/examples.ts +20 -13

Not shown: this page already shows 6000 lines of diff.

M worker/src/mcp/docs.ts +414 -165

Not shown: this page already shows 6000 lines of diff.

M worker/src/mcp/handler.test.ts +1 -1

Not shown: this page already shows 6000 lines of diff.