diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index 7527b2d..79c1401 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -13,6 +13,7 @@ "improvementSheet": { "key": "civilization-improvements", "path": "assets/images/civilization/civilization-improvements.png", "frameWidth": 64, "frameHeight": 64 }, "unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 }, "iconSheet": { "key": "civilization-icons", "path": "assets/images/civilization/civilization-icons.png", "frameWidth": 48, "frameHeight": 48 }, + "barbarianSheet": { "key": "civilization-barbarians", "path": null, "frameWidth": 64, "frameHeight": 96 }, "citySheets": { "classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }, "cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 }, diff --git a/data/civilization-rules.json b/data/civilization-rules.json index 1e8bad5..eaf5865 100644 --- a/data/civilization-rules.json +++ b/data/civilization-rules.json @@ -158,7 +158,9 @@ { "id": "ssstructural", "name": "SS Structural", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 80, "prereq": "spaceflight", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S1", "frame": 48 }, { "id": "sscomponent", "name": "SS Component", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 160, "prereq": "plastics", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S2", "frame": 49 }, - { "id": "ssmodule", "name": "SS Module", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 320, "prereq": "superconductor", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S3", "frame": 50 } + { "id": "ssmodule", "name": "SS Module", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 320, "prereq": "superconductor", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S3", "frame": 50 }, + + { "id": "barbarianleader", "name": "Barbarian Leader", "domain": "land", "attack": 0, "defense": 0, "move": 1, "hp": 10, "fp": 1, "cost": 0, "prereq": null, "obsoletedBy": null, "flags": ["noncombat", "leader", "barbarianonly"], "abbr": "BL", "frame": 7, "sheet": "barbarians" } ], "spaceship": { "structuralNeeded": 8, "componentsNeeded": 4, "modulesNeeded": 3, "travelTurns": 15 }, @@ -250,11 +252,11 @@ ], "difficulties": [ - { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25, "humanCombatBonus": 1.25, "aiCombatBonus": 0.8, "humanVetChance": 0.75 }, - { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4, "humanCombatBonus": 1.1, "aiCombatBonus": 0.9, "humanVetChance": 0.65 }, - { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 }, - { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 }, - { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 } + { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25, "humanCombatBonus": 1.25, "aiCombatBonus": 0.8, "humanVetChance": 0.75, "barbarianActivity": 0.35 }, + { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4, "humanCombatBonus": 1.1, "aiCombatBonus": 0.9, "humanVetChance": 0.65, "barbarianActivity": 0.6 }, + { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5, "barbarianActivity": 1.0 }, + { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5, "barbarianActivity": 1.4 }, + { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5, "barbarianActivity": 1.8 } ], "civTraits": [ @@ -285,6 +287,69 @@ "playerColors": ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7", "#06b6d4", "#f97316", "#ec4899"], + "barbarianColor": "#7f1d1d", + + "_barbariansReadme": [ + "Roaming raiders that cannot be negotiated with. Modelled as a real civ appended to the", + "END of state.civs (state.barbarianIndex), so every existing civ index — including", + "humanIndex — stays valid and saves written before this feature simply have no barbarian", + "civ. See docs/civilization-barbarians-plan.md for the full design and, in particular,", + "for the two AI traps this feature has to avoid (shared-enemy attitude pollution in", + "updateAttitudes, and permanent 'war' phase in computeStrategy).", + "", + "Pressure is computed PER TARGET CIV from that civ's own era, so a runaway leader stops", + "being raided while a laggard is still harassed. eraPressure scales the spawn interval;", + "a civ knowing ANY hardStopTech is never targeted again — i.e. uprisings end right as", + "Riflemen/Cannon-era units arrive. Everything here is additionally scaled by the", + "difficulty's barbarianActivity (0 disables barbarians entirely).", + "", + "unitsByEra is keyed by the TARGET's era, not the barbarians' — raiders always fight", + "with era-appropriate kit so they never become free XP. The cap is what keeps them an", + "event rather than a fourth empire: they never found cities and never research.", + "", + "spawnIntervalBase/capBase were tuned against the 28-game self-play soak. At 9/3 the", + "world tipped almost entirely to conquest (20 conquest vs 1 spaceship win, from 3/9", + "with barbarians off) because razed cities cripple civs into easy targets. 14/2 keeps", + "the pressure (900+ uprisings, 200 leader sightings across the suite) while leaving", + "the spaceship path alive at 14 conquest / 4 spaceship. Raise these and expect the", + "victory mix to collapse toward conquest again." + ], + "barbarians": { + "enabled": true, + "firstTurn": 12, + "spawnIntervalBase": 14, + "eraPressure": { "ancient": 1.0, "medieval": 0.55, "industrial": 0.15, "modern": 0 }, + "hardStopTechs": ["industrialization", "conscription", "railroad"], + "stackMin": 2, + "stackMax": 4, + "capBase": 2, + "capPerTurn": 0.05, + "capMax": 12, + "spawnMinDist": 4, + "spawnMaxDist": 7, + "razeSizeMax": 1, + "leader": { + "killsToSummon": 6, + "lifetime": 20, + "ransomBase": 100, + "ransomPerEra": 50, + "escortMin": 3, + "escortMax": 5 + }, + "unitsByEra": { + "ancient": ["warriors", "horsemen", "archers"], + "medieval": ["legion", "archers", "knights", "catapult"], + "industrial": ["musketeers", "dragoons", "cannon"], + "modern": ["riflemen", "cavalry", "artillery"] + }, + "barbarianFrames": { + "warriors": 0, "horsemen": 1, "archers": 2, "legion": 3, "knights": 4, + "catapult": 5, "cannon": 5, "artillery": 5, + "musketeers": 6, "riflemen": 6, "dragoons": 1, "cavalry": 1, + "barbarianleader": 7 + } + }, + "cityNames": [ "Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick", "Northolt", "Southmere", "Goldcrest", "Ironforge", "Millbrook", "Ashford", "Briarwood", "Cedar Falls", diff --git a/docs/civilization-barbarians-plan.md b/docs/civilization-barbarians-plan.md new file mode 100644 index 0000000..6a01724 --- /dev/null +++ b/docs/civilization-barbarians-plan.md @@ -0,0 +1,512 @@ +# Civilization — Barbarians build plan + +Roaming hostile raiders for the early game: an opponent that cannot be bargained +with, that forces both the human and the AI to garrison cities and spend shields +on defense before they'd like to. Fades out as the world modernises. + +Scope decisions (settled with the owner, 2026-08-01): + +- **No sea raiders.** Land uprisings only. No barbarian galleys, no amphibious + landings, no naval pathing. If it's ever wanted it's a clean phase 2. +- **Barbarian Leader + ransom is in**, including a "draw out the leader" hook so + killing hordes is what summons one. +- **Activity folds into difficulty.** No setup-screen dropdown. +- **Pressure decays over the game** and hard-stops before modern units. + +--- + +## 1. Architecture: barbarians are a civ + +`state.civs` already drives everything generic in this engine — +`beginCivTurn`/`endCivTurn` cycle by index, `runToHumanTurn`'s `stepCiv` +animates any civ's moves and combats, `resolveAttack` / `captureCity` / +`pickDefender` / fog / `isUnitVisibleTo` are all civ-index based, and `drawUnit` +identifies owners by a colored ring off `civ.color`. + +So barbarians are **a real civ appended at the end of `state.civs`**, with +`civ.barbarian === true`, and `state.barbarianIndex` recording its index. + +Why appended last rather than inserted: + +- Every existing civ index stays valid, including `humanIndex`. +- `endCivTurn`'s wrap (`if (next === 0) state.turn += 1`) still increments the + game turn in the right place — barbarians move last in the round. +- Old saves have `state.barbarianIndex === undefined` → no barbarians, no + migration step, and `deserialize`'s `version !== 1` gate stays untouched. + +The barbarian civ is built by a `createBarbarianCiv()` in the new module and +pushed by `createGame` after the normal civ loop, only when +`rules.barbarians.enabled && difficulty.barbarianActivity > 0`. It gets: + +``` +{ id: n, barbarian: true, human: false, alive: true, name: 'Barbarians', + color: rules.barbarianColor /* '#3f1d1d' — outside playerColors */, + government: 'despotism', gold: 0, known: {}, futureCount: 0, + relations: { every other civ: 'war' }, attitude: { …: -100 }, + spaceship: {…zeroes}, nameCursor: 0, nameOrder: [], score: 0, + barbKills: {}, // per-civ tally driving leader summons + leaderUnitId: null, leaderExpires: 0, nextSpawnTurn: {} } +``` + +It also gets its own `state.explored` array (pushed for shape parity — every +`explored[civ]` consumer indexes by civ id). It is never written to; barbarians +see the world by fiat inside their own AI. + +`civ.alive` stays `true` forever, even at zero units. Uprisings are events, not +a standing empire, so "no units" is the normal state between raids. + +### 1.1 Guard sites — every place that assumes "civ = diplomatic player" + +Found by grepping `state.civs` across all 13 files. Each needs a barbarian skip: + +| File / function | Why | +| --- | --- | +| `Logic.checkVictory` | `living.length === 1` would never fire → **conquest victory becomes unreachable**. Also its "no cities + no settlers → `eliminateCiv`" rule would kill the barbarian civ the moment its last raider dies. Both need the skip. | +| `Logic.contactSweep`, `Logic.makeContacts` | No contact, ever. Relations are pinned `'war'`. | +| `Logic.updateAttitudes` | **See §1.2 — this one is a trap.** | +| `Logic.civScore` / `Screens.js:765` scores table | Barbarians don't score and don't appear in rankings. | +| `Screens.js:147` (diplomacy contacts), `:719` (rivals) | Not a negotiating partner. | +| `Diplomacy.js:151,163` (request targets) | Nobody demands you join a war against barbarians. | +| `AI.computeStrategy:59` `atWarWith` | **See §1.3 — the other trap.** | +| `AI.doDiplomacy:86` | Loops living civs, and `rel === 'war'` would make every AI spam `proposeOrQueue(ceasefire)` at the barbarians forever. | +| `AI.sharedEnemy:163`, `AI:493` | Barbarians must not count as a shared enemy or a "hostile neighbour nearby". | +| `Logic.beginCivTurn` | Skip `progressRevolution` / `updateAttitudes` / `processCity` / `progressResearch`, and skip the `!civ.researching` auto-assign block (barbarians would otherwise pick a research target every turn). Keep the unit MP reset and healing — barbarians want those. | + +### 1.2 Trap: `updateAttitudes` shared-enemy bonus + +`updateAttitudes` awards `baseline += 20` for every third civ that both parties +are at war with. Since *every* civ is permanently at war with the barbarians, +adding them naively gives **every AI pair a permanent +20 friendship bonus**, +plus a `-40` war-baseline penalty applied against the barbarians themselves. + +Diplomacy would drift toward everyone liking everyone, quietly gutting the war +paths that took three rounds of tuning to get right (see the frustration/grudge +work in `CivilizationDiplomacy.js`). + +Fix: barbarians are skipped in **both** the `other` loop and the inner `third` +loop. Verify pins this with a fixture asserting the attitude delta between two +neutral AI civs is unchanged with barbarians present vs absent. + +### 1.3 Trap: `atWarWith` and AI war posture + +`computeStrategy` sets `phase = 'war'` whenever `atWarWith.length` is non-empty. +With barbarians as a war relation, **every AI sits in war phase for the entire +game**: `wantGarrison` jumps to 2 permanently, `manageCityBuild` never reaches +the expand/space branches, and `stepMilitary`'s peacetime scouting slice +(`unit.id % 3 === 0 && strategy.atWarWith.length === 0`) never runs, so AIs stop +exploring and stop popping huts. + +Fix: barbarians are excluded from `atWarWith`, and `computeStrategy` gains a +separate signal: + +```js +barbarianThreat: 0 | 1 | 2 // 0 none, 1 raiders in the region, 2 adjacent to a city +``` + +Consumers: + +- `manageCityBuild` — `wantGarrison = phase === 'war' ? 2 : (barbarianThreat >= 2 ? 2 : 1)`. + The existing emergency-buy check already reads `relations[u.civ] === 'war'`, + so barbarians at the gate trigger a rush-buy **for free**. +- `stepMilitary` — the adjacent-attack loop also already filters on + `civ.relations[u.civ] === 'war'`, so AIs attack adjacent barbarians for free. + What is *not* free: the "march on the weakest reachable enemy city" block + filters on `strategy.atWarWith`, so a **barbarian-held city would never be + retaken**. Add barbarian-held cities to that target list explicitly. +- Peacetime scouting stays gated on `atWarWith` only, so exploration survives. + +### 1.4 Trap: `atPeace` and the government ladder — found during the build + +Not predicted in review; caught by the step-2 no-op checkpoint exactly as +intended. `CivilizationAI.js`'s + +```js +function atPeace(state, civIdx) { + return !Object.values(state.civs[civIdx].relations).includes('war'); +} +``` + +reads the **raw relations map**, so the permanent barbarian `'war'` entry makes +every civ eternally at war. `doGovernment` gates Republic and Democracy on +`atPeace` and Communism on `!atPeace`, so with barbarians on, **no AI would ever +leave Monarchy for a trade government** — they'd all beeline Communism. Silent, +game-wide, and invisible in any single-turn fixture. + +Fixed by iterating civs and skipping barbarians rather than reading the map +wholesale. The general lesson: **any aggregate over `civ.relations` is suspect** +— a per-pair lookup is fine, `Object.values(relations)` is not. A grep for +`Object.values/entries/keys(...relations)` found this as the only instance in +the game code (two more live in verify's own invariant checks, which iterate +pairs and are unaffected). + +### 1.5 Trap: barbarians looting huts — found during the build + +`resolveHut` fires for whoever steps on the hut, so raiders were popping them: +free units **outside the population cap** (the cap probe caught a stack of 5 +against a cap of 4), plus gold and free ancient techs. Barbarians now never pop +huts — flavour-correct (they're who lives there) and the hut stays standing for +a real civ to find. + +### 1.6 Trap: `eliminateCiv` killing the barbarian faction — found during the build + +`checkVictory` skips barbarians, but `captureCity` *also* calls `eliminateCiv` +whenever the loser's last city changes hands. So retaking the one city +barbarians had seized killed the whole faction: `alive = false`, every raider +deleted, and — because `runAITurn` early-returns on dead civs — **no further +barbarian activity for the rest of the game**, silently. It surfaced only as an +"unit of dead civ" invariant break in 3 of 30 soak games, once a hut later +unleashed raiders belonging to the dead faction. + +Guarded at the single choke point: `eliminateCiv` refuses to kill a barbarian +civ, so both callers are covered. + +### 1.7 `civPower` + +`civPower` sums unit attack+defense and city size. It's only called per index, +and once barbarians are out of `doDiplomacy` and `atWarWith` nothing computes a +power ratio against them — no change needed. Noted here so a future reader +doesn't "fix" it. + +--- + +## 2. Spawning + +### 2.1 Pressure curve (the fade-out) + +Pressure is computed **per target civ, from that civ's own era**, so a runaway +leader stops being raided while a laggard still gets harassed — the classic Civ +behaviour, and it keeps barbarians from being pure noise late. + +New shared helper in Logic (useful beyond this feature): + +```js +export function civEra(rules, civ) // most advanced era with >= 3 known techs of that era +``` + +Eras in `civilization-rules.json` are `ancient → medieval → industrial → modern` +(24 / 19 / 22 / 18 techs). + +```json +"barbarians": { + "enabled": true, + "firstTurn": 12, + "spawnIntervalBase": 9, + "eraPressure": { "ancient": 1.0, "medieval": 0.55, "industrial": 0.15, "modern": 0 }, + "hardStopTechs": ["industrialization", "conscription", "railroad"], + … +} +``` + +A civ stops being targeted entirely once it knows **any** `hardStopTech` — i.e. +the moment it's building Riflemen/Cannon-era units, uprisings against it end. +`eraPressure.modern = 0` is a belt-and-braces second stop. + +Effective interval for civ *c*: + +``` +interval = spawnIntervalBase / (eraPressure[civEra(c)] * difficulty.barbarianActivity) +``` + +so Prince/ancient ≈ every 9 turns, Prince/medieval ≈ every 16, Prince/industrial +≈ every 60 (i.e. usually never before the hard stop lands). Each civ carries its +own `nextSpawnTurn[civId]` cursor on the barbarian civ. + +Nothing spawns before `firstTurn` (12) — the opening land-grab stays clean. + +### 2.2 Where and what + +An uprising picks a wilderness tile 4–7 tiles from one of the target's cities: +land, not water, no city on it, outside the target's current vision set (arrives +as a surprise, but adjacent-explored so it isn't literally invisible), and not +inside another civ's city radius. + +Stack size `2 + randInt(3)`, scaled up by `barbarianActivity`. + +Unit types come from the **target's** era, so barbarians never hand out free XP +and never fight with sticks against Musketeers: + +```json +"unitsByEra": { + "ancient": ["warriors", "horsemen", "archers"], + "medieval": ["legion", "archers", "knights", "catapult"], + "industrial": ["musketeers", "dragoons", "cannon"], + "modern": ["riflemen", "cavalry", "artillery"] +} +``` + +Barbarian units spawn with `vet: false` and no veteran roll — they're a +sustained tax, not an alpha strike. + +### 2.3 Hard cap (non-negotiable) + +``` +cap = min(capMax, capBase + floor(turn * capPerTurn)) * barbarianActivity +``` + +with `capBase: 3, capPerTurn: 0.05, capMax: 12`. Total living barbarian units +across the whole map. Without this they snowball into a fourth player and the +30-game soak goes sideways. Barbarians also **never found cities and never +research** — the cap plus zero growth is what keeps them an event rather than an +empire. + +### 2.4 Huts + +`resolveHut`'s ambush branch (`roll >= 0.85`) currently fights a *phantom* +unit that never enters `state.units`. Upgrade it: when barbarians are enabled, +spawn a real 2–3 unit barbarian stack on the hut tile instead, then resolve the +attacker's fight against the top defender normally. ~10 lines, and it's the most +authentic Civ II barbarian moment in the whole feature. Falls back to the +existing phantom path when barbarians are disabled (old saves, Chieftain if we +ever zero it out). + +--- + +## 3. Barbarian AI (`CivilizationBarbarians.js`, new 14th file) + +Headless, no Phaser, mirroring `CivilizationDiplomacy.js`'s shape. Exports: + +- `createBarbarianCiv(rules, state)` +- `maybeSpawnUprisings(rules, state)` — called from `beginCivTurn` for the barbarian civ +- `runBarbarianTurn(rules, state)` — the `runAITurn` equivalent +- `barbarianThreatFor(rules, state, civIdx)` — the 0/1/2 signal for `computeStrategy` +- `tryRansom(rules, state, unit, leader)` — shared by human moves and AI + +Per-unit behaviour is deliberately dumber than `stepMilitary`: + +1. Adjacent enemy unit or city with acceptable odds → attack (reuse `tryMove`, + which already routes to `resolveAttack` / `captureCity`). +2. Otherwise march at the nearest non-barbarian city via `moveToward`. + `moveToward` pathfinds once per decision and walks the whole path — the + established perf pattern; re-planning per tile is what made AI turns slow. +3. Nothing reachable → wander one tile randomly. + +They never fortify, never heal in place, never retreat. A stack that loses its +war gets ground down, which is the point. + +**Captured cities.** Size 1 is razed (`destroyCity`); size 2+ is captured via +the existing `captureCity` and held. A barbarian city does not grow, does not +build, and does not research — it just holds whatever garrison walked in. +Retaking it is normal conquest, and §1.3 makes sure the AI actually tries. + +--- + +## 4. The Barbarian Leader + +### 4.1 Drawing one out + +The barbarian civ tallies `barbKills[civId]` — barbarian units that civ has +destroyed. Once the tally crosses `leader.killsToSummon` (6, scaled down by +`barbarianActivity` on higher difficulties), the *next* uprising against that +civ is promoted to a **Horde**: `escortMin`–`escortMax` (3–5) units escorting +one `barbarianleader`, and the tally resets. + +This makes the mechanic self-reinforcing in the right direction — the player who +successfully farms raiders is the one who gets the big prize dangled in front of +them, and the player who's already drowning doesn't get a horde on top of it. + +### 4.2 The unit + +```json +{ "id": "barbarianleader", "name": "Barbarian Leader", "domain": "land", + "attack": 0, "defense": 0, "move": 2, "hp": 10, "fp": 1, "cost": 0, + "prereq": null, "obsoletedBy": null, "flags": ["noncombat", "leader"], + "abbr": "BL", "frame": 0, "sheet": "barbarians" } +``` + +`defense: 0` means it can never be the picked defender while an escort lives — +`pickDefender` sorts by `defenderStrength`, and the escort always outranks it. +Alone, it doesn't fight at all; it's captured (§4.4). + +Its own turn behaviour (`stepLeader`) is **flee**: move to the adjacent tile +that maximises distance from the nearest non-barbarian combat unit, preferring +to stay within 2 of its escort if any survive. Cornered with nowhere better, it +sits still and waits to be captured. + +`leaderExpires = turn + leader.lifetime` (20). On expiry it despawns with a +"the warlord slipped away into the hills" notice — that deadline is what turns +a leader sighting into a decision (chase it or hold the line) instead of a +free bag of gold you collect whenever. + +### 4.3 Telling the player one is out there + +Three layers, because a single popup gets dismissed and forgotten: + +1. **Sighting popup.** On horde spawn, the target's `explored` is set for the + leader's tile and `announceStatus` fires: + *"A Barbarian Warlord has been sighted near Stonehaven!"* — using the + existing 4th `extra: { label, onClick }` param (added for `buildingDone`) + with a **SHOW ME** button that pans the camera to the tile. That param + exists precisely for "announce + jump somewhere", so no new plumbing. +2. **Persistent HUD marker.** While a leader lives, a small banner sits in the + HUD: `⚑ WARLORD AT LARGE — 14 turns`, counting down `leaderExpires`. + Clicking it pans to the leader's **last known position** — updated every turn + the leader is genuinely visible to the human, and left stale (drawn dimmed) + when it isn't. So it goes dark when the leader escapes your vision, and you + hunt from the last sighting rather than getting a free tracker. +3. **Map + minimap marker.** A pulsing gold chevron above the leader's ring in + `MapView.drawUnit`, plus a gold dot on the minimap at the last known tile. + +Ransom collected, leader escaped, and leader killed in the crossfire each get +their own `announceStatus` line so the arc always closes. + +### 4.4 Ransom + +Moving a unit onto a tile holding **only** a barbarian leader captures it +instead of fighting. `tryMove` grows a leader branch ahead of its combat branch, +returning `result: 'ransom'`. + +``` +ransom = (leader.ransomBase + leader.ransomPerEra * eraIndex(captor)) + * difficulty.barbarianActivity +``` + +100g in the ancient era on Prince, scaling to ~250g by industrial and higher on +Emperor — so the same difficulty knob that makes barbarians nastier makes the +prize bigger. The captor's civ gets the gold, the leader is removed, an event +`{ type: 'ransom', civ, gold, x, y }` fires, and the human gets a popup with the +gold amount. + +If the leader is stacked with escorts, combat proceeds normally and the leader +dies with the last defender — no ransom. Killing the escort and *then* stepping +on the leader is the intended play, and it's why the leader flees. + +AI civs collect too: `stepMilitary` gains an adjacent-lone-leader check ahead of +its attack loop. Otherwise the human gets every ransom in the game, which is +both unfair and boring. + +--- + +## 5. Presentation + +- **Colour.** `rules.barbarianColor = '#3f1d1d'` (dark crimson), deliberately + outside `playerColors` so no real civ can collide with it. The existing ring + under each unit in `drawUnit` carries it, so barbarians are visually distinct + **even with no new art at all**. +- **Art (optional but wanted).** A parallel `civilization-barbarians.png` sheet + selected by a `sheet` field on the unit def, rather than spending the 5 free + frames (51–55) on the shared `civilization-units` sheet — 5 won't be enough + once the leader and per-era raider variants are in. `MapView.drawUnit` picks + `def.sheet ? 'civilization-' + def.sheet : 'civilization-units'` and falls + back to the shared sheet when the texture is missing, so **the feature ships + and plays correctly before any art exists**. Spec goes in `sprites.md` as + section 7. + - Suggested frames: raider (ancient), horseman, archer, medieval marauder, + horde rider, musket raider, artillery crew, **barbarian leader**. +- **Tooltips.** `CivilizationTooltips.js` gains barbarian phrasing — no + diplomacy line, no attitude, no "at war since"; the leader tooltip states the + ransom value and turns remaining. + +--- + +## 6. Difficulty wiring + +One new field per entry in `rules.difficulties`: + +| Difficulty | `barbarianActivity` | +| --- | --- | +| Chieftain | 0.35 | +| Warlord | 0.60 | +| Prince | 1.00 | +| King | 1.40 | +| Emperor | 1.80 | + +It scales, in one place each: spawn interval (inversely), stack size, the unit +cap, `killsToSummon` (inversely — hordes come sooner on hard), and the ransom +payout. `0` disables barbarians entirely, which is what the barbarian civ's +creation gate reads, so a future "Barbarians: off" toggle is a one-line change. + +--- + +## 7. Files touched + +| File | Change | +| --- | --- | +| `data/civilization-rules.json` | `barbarians` block, `barbarianColor`, `barbarianleader` unit, `barbarianActivity` × 5 difficulties | +| `CivilizationRules.js` | `compileRules` validation: unit ids in `unitsByEra` exist, era keys match `techs[].era`, `hardStopTechs` exist, caps sane | +| `CivilizationLogic.js` | barbarian civ in `createGame`; `isBarbarian` + `civEra` helpers; guards per §1.1; `tryMove` ransom branch; `resolveHut` real-ambush | +| **`CivilizationBarbarians.js`** | **new** — spawn, turn, leader, ransom, threat signal | +| `CivilizationAI.js` | `atWarWith` exclusion + `barbarianThreat`; `doDiplomacy` skip; barbarian cities as retake targets; AI ransom capture | +| `CivilizationGame.js` | barbarian civ in `stepCiv`; sighting popup; HUD warlord banner; ransom/escape/raze notices | +| `CivilizationMapView.js` | barbarian sheet lookup + fallback; leader chevron; minimap marker | +| `CivilizationScreens.js` | filter barbarians from contacts, rivals, scores | +| `CivilizationDiplomacy.js` | filter barbarians from request targets | +| `CivilizationTooltips.js` | barbarian + leader tooltips | +| `tools/verifyCivilization.js` | new section 9 (below) | +| `sprites.md` | §7 barbarian sheet spec | + +--- + +## 8. Verification + +New **section 9** in `tools/verifyCivilization.js`: + +- **Rules integrity** — every id in `unitsByEra`/`hardStopTechs` resolves; era + keys cover every era present in `techs`; `barbarianActivity` on all 5 + difficulties. +- **Save compat** — a state built without `barbarianIndex` (the pre-feature + shape) runs a full turn through every touched function without throwing. + Same trick section 6b uses, where the old `mkCiv` shape doubles as a + compat test. +- **Isolation fixtures** — the two traps get pinned directly: + - attitude delta between two neutral AI civs is **identical** with barbarians + present and absent (§1.2); + - `computeStrategy(...).phase` for a peaceful AI with barbarians on the map is + still `expand`/`develop`, never `war` (§1.3). +- **Curve** — spawn interval monotonically increases with era; zero uprisings + after any `hardStopTech`; unit cap never exceeded across a 200-turn headless + run at Emperor. +- **Leader arc** — kills accumulate → horde spawns with an escort → leader flees + from an approaching unit → lone leader is ransomed for the era-correct gold → + tally reset; and separately, an unclaimed leader despawns exactly at + `leaderExpires`. +- **City capture** — size-1 razed, size-2 held, barbarian city neither grows nor + builds across 20 turns, and an AI at peace actually marches on it. + +Then the standing **30-game soak at turnCap 1000** is re-run and compared +against the baseline. + +**Results as built** (baseline → with barbarians): + +| | baseline | with barbarians | +|---|---|---| +| games decided | 15/30 | 20/30 | +| conquest / spaceship | 9 / 6 | 16 / 4 | +| avg AI turn | 4.76ms | 2.27ms* | +| total checks | 1258 | 1346 | + +\* lower only because the barbarians' own (cheap) turn is counted in the +average; per-real-civ cost is unchanged. + +One check fails, `space flight window turn 150-600` — it **fails identically on +`main`** (median 836 there, 864 here), so it is pre-existing and unrelated. + +**Tuning note.** `spawnIntervalBase`/`capBase` started at 9/3 and the world +tipped almost entirely to conquest (20 conquest vs **1** spaceship win), because +razed cities cripple civs into easy targets for each other. 14/2 keeps the +pressure — 900+ uprisings and 200 leader sightings across the suite — while +leaving the spaceship path alive. Raising them again will collapse the victory +mix; the numbers are recorded in the rules JSON readme too. + +The escalation arc in section 6b strips the barbarian civ: it needs a stable +~90-turn runway for a grudge to build through refusals, and raids end the game +in conquest first. Confirmed the escalation itself still works — the game is +simply over — so this is a fixture runway issue, not a diplomacy regression. + +Per the owner's standing preference, **no browser verification** — engine and +Node checks only; the browser pass is theirs. + +--- + +## 9. Build order + +1. Rules data + `compileRules` validation + `sprites.md` spec. +2. Barbarian civ in `createGame`, the §1.1 guards, `civEra`. **Ship this alone + and confirm the soak is unchanged** — a barbarian civ that spawns nothing + must be a perfect no-op. This is the checkpoint that catches §1.2/§1.3. +3. `CivilizationBarbarians.js`: spawning, cap, curve, dumb march-and-attack. +4. AI response: threat signal, garrison priority, retake barbarian cities. +5. Leader: summon tally, flee behaviour, expiry. +6. Ransom: `tryMove` branch, AI capture, gold scaling. +7. Presentation: colour, sheet lookup + fallback, chevron, popups, HUD banner. +8. Huts spawn real barbarians. +9. Verify section 9, then the 30-game soak and seed re-pick. diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 22c3080..c264829 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -224,7 +224,8 @@ export const MANIFEST = { 'helicopter', 'missle', 'submarine', 'sword', 'tank'] .map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })), (scene) => sheetsFrom(scene, 'civilization-artwork', - ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']), + ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', + 'barbarianSheet', 'citySheets']), ], // Cups/tracks index always loads (individual track-NNN.json files are // fetched lazily at race start). Racer/theme/item sheets and backdrops are diff --git a/src/games/civilization/CivilizationAI.js b/src/games/civilization/CivilizationAI.js index 69acaec..3d95709 100644 --- a/src/games/civilization/CivilizationAI.js +++ b/src/games/civilization/CivilizationAI.js @@ -21,13 +21,22 @@ import { considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept, escalateFrustration, checkPledges, pickFairTrade, setCooldown, } from './CivilizationDiplomacy.js'; +import { + runBarbarianTurn, barbarianThreatFor, barbarianCities, currentLeader, +} from './CivilizationBarbarians.js'; const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops +const LEADER_HUNT_RANGE = 8; // how far a unit will detour to chase a ransom export function runAITurn(rules, state, civIdx) { const civ = state.civs[civIdx]; if (!civ.alive || state.over) return; + // Barbarians share the civ turn loop but none of the empire management — + // no research, no builds, no diplomacy. Dispatching here means the scene's + // stepCiv and the verify soak both drive them with no extra wiring. + if (civ.barbarian) { runBarbarianTurn(rules, state); return; } + const strategy = computeStrategy(rules, state, civIdx); doDiplomacy(rules, state, civIdx, strategy); doResearch(rules, state, civIdx, strategy); @@ -56,7 +65,13 @@ export function computeStrategy(rules, state, civIdx) { const myCities = civCities(state, civIdx); const myPower = civPower(rules, state, civIdx); - const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx + // Barbarians are deliberately NOT in atWarWith. `phase` flips to 'war' the + // moment this is non-empty, and since everyone is permanently at war with + // barbarians that would pin every AI in war phase for the whole game: no + // expansion, no spaceship parts, and no peacetime scouting (that slice is + // gated on atWarWith being empty), so AIs would stop exploring entirely. + // Barbarian pressure arrives through `barbarianThreat` below instead. + const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx && !c.barbarian && civ.relations[c.id] === 'war').map((c) => c.id); const techTotal = rules.techList.filter((t) => !t.repeatable).length; @@ -74,7 +89,10 @@ export function computeStrategy(rules, state, civIdx) { else if (wantsSpace) phase = 'space'; else if (canExpand || myCities.length === 0) phase = 'expand'; - return { phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac }; + return { + phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac, + barbarianThreat: barbarianThreatFor(rules, state, civIdx), + }; } // --------------------------------------------------------------------------- @@ -85,6 +103,9 @@ function doDiplomacy(rules, state, civIdx, strategy) { checkPledges(rules, state, civIdx); for (const other of state.civs) { if (other.id === civIdx || !other.alive) continue; + // No audiences with raiders. Without this the `rel === 'war'` branch below + // would have every AI proposing a ceasefire to the barbarians every turn. + if (other.barbarian) continue; if (civ.relations[other.id] === 'nocontact') continue; // A leader worn down by refused requests tears up its treaties first; if // the grudge keeps climbing, the hostility checks below finish the job. @@ -160,7 +181,9 @@ function issueRequest(rules, state, req) { } function sharedEnemy(state, a, b) { - return state.civs.some((c) => c.alive && c.id !== a && c.id !== b + // Barbarians excluded for the same reason as in updateAttitudes: everyone is + // at war with them, so they'd make every pair of civs "natural allies". + return state.civs.some((c) => c.alive && c.id !== a && c.id !== b && !c.barbarian && state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war'); } @@ -246,8 +269,13 @@ function doGovernment(rules, state, civIdx) { startRevolution(rules, state, civ, wants); } } +// Peace means peace with other PLAYERS. Barbarians are permanently at war with +// everyone, so reading the raw relations map here would make every civ +// eternally "at war": no AI would ever adopt Republic or Democracy (both gated +// on peace) and they'd all beeline Communism instead. function atPeace(state, civIdx) { - return !Object.values(state.civs[civIdx].relations).includes('war'); + return !state.civs.some((c) => !c.barbarian + && state.civs[civIdx].relations[c.id] === 'war'); } function govRank(id) { return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0; @@ -269,8 +297,11 @@ function manageCityBuild(rules, state, civIdx, city, strategy) { .filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat')); - // Emergency: garrison first, buy it when the enemy is at the gate. - const wantGarrison = strategy.phase === 'war' ? 2 : 1; + // Emergency: garrison first, buy it when the enemy is at the gate. Raiders + // at the walls call for a second defender without flipping the whole civ + // into war phase (see computeStrategy). The rush-buy check below needs no + // barbarian special-case — it already filters on relations === 'war'. + const wantGarrison = strategy.phase === 'war' || strategy.barbarianThreat >= 2 ? 2 : 1; if (defenders.length < wantGarrison) { const best = bestDefender(rules, state, civ, city); if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id); @@ -661,8 +692,24 @@ function stepMilitary(rules, state, civIdx, unit, strategy) { } } - // Attack adjacent enemies when odds look good. + // A cornered Barbarian Leader next door is gold on the ground. Checked before + // the attack loop so the AI takes the ransom rather than walking past it — + // otherwise the human would collect every leader in the game. const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + if (def.domain === 'land') { + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + const there = unitsAt(state, x, y); + if (!there.length || cityAt(state, x, y)) continue; + if (!there.every((u) => u.civ !== civIdx && rules.units[u.type].flags.includes('leader'))) continue; + const out = tryMove(rules, state, unit, dx, dy); + if (out.result === 'ransom') return true; + } + } + + // Attack adjacent enemies when odds look good. for (const [dx, dy] of dirs) { const x = unit.x + dx; const y = unit.y + dy; @@ -692,12 +739,28 @@ function stepMilitary(rules, state, civIdx, unit, strategy) { } } - // March on the weakest reachable enemy city. - if (strategy.atWarWith.length && def.attack > 0) { + // Hunt a sighted Barbarian Leader. Without this the AI only ever stumbles + // into a ransom by accident — measured at zero ransoms across a 28-game + // soak — so the "kill hordes, draw out a leader" loop paid the human only. + // Range-limited so it's a detour for nearby troops, not an empire-wide + // stampede; the leader itself is move 1, so mounted units can run it down. + if (def.attack > 0 && def.domain === 'land') { + const leader = currentLeader(state); + if (leader && cheb(unit.x, unit.y, leader.x, leader.y) <= LEADER_HUNT_RANGE) { + if (moveToward(rules, state, unit, leader.x, leader.y)) return true; + } + } + + // March on the weakest reachable enemy city. Barbarian-held cities are added + // explicitly: they're deliberately absent from atWarWith (which would pin + // the civ in war phase), so without this a city lost to raiders would never + // be retaken by anyone. + const retakeable = barbarianCities(state); + if ((strategy.atWarWith.length || retakeable.length) && def.attack > 0) { let target = null; let bestScore = -Infinity; for (const city of state.cities) { - if (!strategy.atWarWith.includes(city.civ)) continue; + if (!strategy.atWarWith.includes(city.civ) && !retakeable.includes(city)) continue; const defenders = unitsAt(state, city.x, city.y).length; const d = cheb(unit.x, unit.y, city.x, city.y); const score = -defenders * 4 - d; diff --git a/src/games/civilization/CivilizationBarbarians.js b/src/games/civilization/CivilizationBarbarians.js new file mode 100644 index 0000000..86a275f --- /dev/null +++ b/src/games/civilization/CivilizationBarbarians.js @@ -0,0 +1,387 @@ +// Civilization — barbarians. Headless (no Phaser), mirroring +// CivilizationDiplomacy.js: imports from CivilizationLogic.js only, never the +// other way round, so there's no import cycle. +// +// Barbarians are roaming raiders that cannot be negotiated with. They are a +// real civ at the end of state.civs (created by Logic.createBarbarianCiv) but +// they never research, never found cities and never grow — a hard unit cap +// plus a per-era pressure curve keeps them an event rather than a fourth +// empire. Design notes and the AI traps this has to dodge live in +// docs/civilization-barbarians-plan.md. + +import { + rand, randInt, cheb, inBounds, terrainAt, cityAt, unitsAt, civUnits, civCities, + barbarianCiv, civEra, knowsTech, spawnUnit, removeUnit, tryMove, findPath, + canOccupy, destroyCity, tileIndex, exploreAround, + barbarianUnitCount, barbarianUnitCap, +} from './CivilizationLogic.js'; + +// Re-exported so callers have one place to ask about barbarians; the +// definitions live in Logic because resolveHut needs the cap too. +export { barbarianUnitCount }; +export const unitCap = barbarianUnitCap; + +// How close a raider has to be to a city before the AI treats it as an +// emergency rather than background noise. +const THREAT_NEAR = 6; +const THREAT_URGENT = 2; + +export function barbariansEnabled(rules, state) { + return !!rules.barbarians?.enabled && state.barbarianIndex != null; +} + +function activity(rules, state) { + return rules.difficulties[state.difficultyId]?.barbarianActivity ?? 0; +} + +// --------------------------------------------------------------------------- +// Pressure curve + +// A civ that knows any hardStopTech is never raided again — uprisings end as +// Riflemen/Cannon-era units arrive, which is the whole "they fade out" ask. +export function isPastBarbarians(rules, civ) { + return (rules.barbarians.hardStopTechs ?? []).some((t) => knowsTech(civ, t)); +} + +// Pressure is read from the TARGET's era, not the barbarians': a runaway +// leader stops being harassed while a laggard still is, and raiders always +// arrive with era-appropriate kit instead of becoming free XP. +export function pressureFor(rules, state, civ) { + if (civ.barbarian || !civ.alive) return 0; + if (isPastBarbarians(rules, civ)) return 0; + const era = civEra(rules, civ); + return (rules.barbarians.eraPressure[era] ?? 0) * activity(rules, state); +} + +export function spawnIntervalFor(rules, state, civ) { + const p = pressureFor(rules, state, civ); + if (p <= 0) return Infinity; + return Math.max(3, Math.round(rules.barbarians.spawnIntervalBase / p)); +} + +// --------------------------------------------------------------------------- +// AI-facing threat signal +// +// This is what replaces barbarians in computeStrategy's atWarWith (see the +// comment there). 0 = clear, 1 = raiders in the region, 2 = at the gates. + +export function barbarianThreatFor(rules, state, civIdx) { + if (!barbariansEnabled(rules, state)) return 0; + const raiders = civUnits(state, state.barbarianIndex); + if (!raiders.length) return 0; + const cities = civCities(state, civIdx); + if (!cities.length) return 0; + let threat = 0; + for (const u of raiders) { + for (const c of cities) { + const d = cheb(u.x, u.y, c.x, c.y); + if (d <= THREAT_URGENT) return 2; + if (d <= THREAT_NEAR) threat = 1; + } + } + return threat; +} + +// --------------------------------------------------------------------------- +// Uprisings + +// A wilderness tile within [spawnMinDist, spawnMaxDist] of one of the target's +// cities: land, empty, no city, and outside whatever the target can currently +// see, so a raid arrives as a surprise rather than materialising in view. +function findUprisingTile(rules, state, targetIdx) { + const b = rules.barbarians; + const cities = civCities(state, targetIdx); + if (!cities.length) return null; + const anchor = cities[randInt(state, cities.length)]; + const span = b.spawnMaxDist - b.spawnMinDist; + + for (let attempt = 0; attempt < 40; attempt += 1) { + const dist = b.spawnMinDist + randInt(state, span + 1); + // Random point on the ring at `dist` (Chebyshev square perimeter). + const side = randInt(state, 4); + const along = randInt(state, dist * 2 + 1) - dist; + const [dx, dy] = side === 0 ? [along, -dist] : side === 1 ? [along, dist] + : side === 2 ? [-dist, along] : [dist, along]; + const x = anchor.x + dx; + const y = anchor.y + dy; + if (!inBounds(state.world, x, y)) continue; + const terr = terrainAt(rules, state.world, x, y); + if (terr.water || terr.id === 'mountains') continue; + if (cityAt(state, x, y)) continue; + if (unitsAt(state, x, y).length) continue; + // Don't drop a horde inside anyone's line of sight, and don't waste one on + // a tile the target has never even explored (it'd wander in from nowhere). + if (state.explored[targetIdx]?.[tileIndex(state.world, x, y)] === 0) continue; + let watched = false; + for (const civ of state.civs) { + if (civ.barbarian) continue; + if (civUnits(state, civ.id).some((u) => cheb(u.x, u.y, x, y) <= 2) + || civCities(state, civ.id).some((c) => cheb(c.x, c.y, x, y) <= 2)) { watched = true; break; } + } + if (watched) continue; + return [x, y]; + } + return null; +} + +function rosterFor(rules, civ) { + const era = civEra(rules, civ); + const roster = rules.barbarians.unitsByEra[era]; + return roster && roster.length ? roster : rules.barbarians.unitsByEra.ancient; +} + +// One uprising against `targetIdx`. Returns the spawned units (possibly empty). +export function spawnUprising(rules, state, targetIdx, { withLeader = false } = {}) { + const b = rules.barbarians; + const barb = barbarianCiv(state); + const target = state.civs[targetIdx]; + const spot = findUprisingTile(rules, state, targetIdx); + if (!spot) return []; + const [x, y] = spot; + + const roster = rosterFor(rules, target); + const act = activity(rules, state); + const room = unitCap(rules, state) - barbarianUnitCount(state); + if (room <= 0) return []; + + let count = b.stackMin + randInt(state, b.stackMax - b.stackMin + 1); + if (withLeader) count = b.leader.escortMin + randInt(state, b.leader.escortMax - b.leader.escortMin + 1); + // The Leader occupies a slot too, so reserve one — otherwise every horde + // pushes the map one unit over the cap for as long as he lives. + const escortRoom = withLeader ? room - 1 : room; + if (escortRoom < 1) return []; + count = Math.max(1, Math.min(escortRoom, Math.round(count * Math.min(2, act)))); + + const spawned = []; + for (let i = 0; i < count; i += 1) { + const type = roster[randInt(state, roster.length)]; + spawned.push(spawnUnit(rules, state, barb.id, type, x, y, null)); + } + if (withLeader) { + const leader = spawnUnit(rules, state, barb.id, 'barbarianleader', x, y, null); + spawned.push(leader); + barb.leaderUnitId = leader.id; + barb.leaderExpires = state.turn + b.leader.lifetime; + // The sighting is the whole point of the mechanic, so the target is told + // where it landed — after that the leader has to be tracked by eye. + exploreAround(state, targetIdx, x, y, 1); + state.events.push({ + type: 'barbLeaderSighted', civ: targetIdx, x, y, unitId: leader.id, + expires: barb.leaderExpires, keep: true, + }); + } + state.events.push({ + type: 'barbUprising', civ: targetIdx, x, y, count, withLeader, keep: true, + }); + return spawned; +} + +// Per-target uprising cursor. Each civ is raided on its own clock, derived from +// its own era, so falling behind is what draws raiders — not the world clock. +export function maybeSpawnUprisings(rules, state) { + const b = rules.barbarians; + const barb = barbarianCiv(state); + if (state.turn < b.firstTurn) return; + if (barbarianUnitCount(state) >= unitCap(rules, state)) return; + + for (const civ of state.civs) { + if (civ.barbarian || !civ.alive) continue; + const interval = spawnIntervalFor(rules, state, civ); + if (!Number.isFinite(interval)) continue; + const due = barb.nextSpawnTurn[civ.id] ?? (b.firstTurn + randInt(state, interval)); + if (state.turn < due) { barb.nextSpawnTurn[civ.id] = due; continue; } + + // A civ that has been farming raiders draws out a Leader with its next + // horde. Killing barbarians is the ONLY way to summon one. + const killsNeeded = Math.max(2, Math.round(b.leader.killsToSummon / Math.max(0.25, activity(rules, state)))); + const wantLeader = !barb.leaderUnitId && (barb.barbKills[civ.id] ?? 0) >= killsNeeded; + const spawned = spawnUprising(rules, state, civ.id, { withLeader: wantLeader }); + if (spawned.length && wantLeader) barb.barbKills[civ.id] = 0; + // Re-arm even on a failed placement, or a boxed-in civ retries every turn. + barb.nextSpawnTurn[civ.id] = state.turn + interval; + } +} + +// --------------------------------------------------------------------------- +// Leader lifetime + +function expireLeader(rules, state) { + const barb = barbarianCiv(state); + if (!barb?.leaderUnitId) return; + const leader = state.units.find((u) => u.id === barb.leaderUnitId); + if (!leader) { barb.leaderUnitId = null; barb.leaderExpires = 0; return; } + if (state.turn < barb.leaderExpires) return; + removeUnit(state, leader); + barb.leaderUnitId = null; + barb.leaderExpires = 0; + state.events.push({ type: 'barbLeaderEscaped', x: leader.x, y: leader.y, keep: true }); +} + +// --------------------------------------------------------------------------- +// Turn + +export function runBarbarianTurn(rules, state) { + if (!barbariansEnabled(rules, state) || state.over) return; + expireLeader(rules, state); + maybeSpawnUprisings(rules, state); + + const idx = state.barbarianIndex; + for (const unit of [...civUnits(state, idx)]) { + if (!state.units.includes(unit) || state.over) continue; + let steps = 0; + while (unit.mp > 0 && steps < MAX_STEPS && state.units.includes(unit) && !state.over) { + const acted = rules.units[unit.type].flags.includes('leader') + ? stepLeader(rules, state, unit) + : stepRaider(rules, state, unit); + steps += 1; + if (!acted) break; + } + } +} + +const MAX_STEPS = 12; // per unit per turn; raiders are dumber than the real AI + +// Raiders are deliberately less clever than stepMilitary: hit what's adjacent, +// otherwise march at the nearest city, otherwise wander. They never fortify, +// never retreat and never heal in place — a stack that loses gets ground down, +// which is what keeps them a tax rather than an empire. +function stepRaider(rules, state, unit) { + const def = rules.units[unit.type]; + const barb = state.civs[unit.civ]; + + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + if (terrainAt(rules, state.world, x, y).water) continue; + const enemies = unitsAt(state, x, y).filter((u) => u.civ !== unit.civ); + const city = cityAt(state, x, y); + if (!enemies.length && !(city && city.civ !== unit.civ)) continue; + if (def.attack <= 0) continue; + const out = tryMove(rules, state, unit, dx, dy); + if (out.result === 'combat' || out.result === 'captured') { + if (out.result === 'captured') afterCityTaken(rules, state, unit, city); + return true; + } + } + + const target = nearestVictimCity(state, unit); + if (target && moveToward(rules, state, unit, target.x, target.y)) return true; + return wander(rules, state, unit); +} + +// Barbarians raze what they can't hold and squat in anything bigger. A held +// city never grows, never builds and never researches (beginCivTurn skips all +// of that for them) — it's a wound the owner has to come and reopen. +function afterCityTaken(rules, state, unit, city) { + const held = cityAt(state, unit.x, unit.y); + if (!held || held.civ !== unit.civ) return; + if (held.size <= rules.barbarians.razeSizeMax) { + state.events.push({ type: 'barbCityRazed', name: held.name, x: held.x, y: held.y, keep: true }); + destroyCity(rules, state, held); + } else { + state.events.push({ type: 'barbCityHeld', cityId: held.id, name: held.name, x: held.x, y: held.y, keep: true }); + } +} + +function nearestVictimCity(state, unit) { + let best = null; + let bestD = Infinity; + for (const c of state.cities) { + if (c.civ === unit.civ) continue; + const d = cheb(unit.x, unit.y, c.x, c.y); + if (d < bestD) { bestD = d; best = c; } + } + return best; +} + +// The Leader runs. It has 0 attack and 0 defense, so its only play is to stay +// away from anything that could corner it — and to hug its escort while any of +// them are alive. +function stepLeader(rules, state, unit) { + const threats = state.units.filter((u) => u.civ !== unit.civ + && !rules.units[u.type].flags.includes('noncombat') + && cheb(u.x, u.y, unit.x, unit.y) <= 4); + if (!threats.length) { unit.mp = 0; return false; } + + const escort = civUnits(state, unit.civ) + .filter((u) => u.id !== unit.id && !rules.units[u.type].flags.includes('leader')); + + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + let best = null; + let bestScore = -Infinity; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + if (terrainAt(rules, state.world, x, y).water) continue; + if (cityAt(state, x, y)) continue; + if (unitsAt(state, x, y).some((u) => u.civ !== unit.civ)) continue; + if (!canOccupy(rules, state, unit, x, y)) continue; + // Maximise distance from the nearest threat, then prefer staying near the + // escort — running naked into open country just gets him ransomed sooner. + let score = Math.min(...threats.map((t) => cheb(t.x, t.y, x, y))) * 10; + if (escort.length) score -= Math.min(...escort.map((e) => cheb(e.x, e.y, x, y))); + if (score > bestScore) { bestScore = score; best = [dx, dy]; } + } + const here = Math.min(...threats.map((t) => cheb(t.x, t.y, unit.x, unit.y))) * 10 + - (escort.length ? Math.min(...escort.map((e) => cheb(e.x, e.y, unit.x, unit.y))) : 0); + if (!best || bestScore <= here) { unit.mp = 0; return false; } // cornered: stand and wait + const out = tryMove(rules, state, unit, best[0], best[1]); + return out.result === 'moved'; +} + +function moveToward(rules, state, unit, tx, ty) { + // Pathfind once per decision and walk the whole path — re-planning per tile + // is what makes AI turns expensive (same reasoning as AI.moveToward). + const path = findPath(rules, state, unit, tx, ty); + if (!path || !path.length) return false; + let moved = false; + for (const [nx, ny] of path) { + if (unit.mp <= 0) break; + const out = tryMove(rules, state, unit, nx - unit.x, ny - unit.y); + if (out.result === 'moved') { moved = true; continue; } + if (out.result === 'combat' || out.result === 'captured') { + if (out.result === 'captured') afterCityTaken(rules, state, unit, cityAt(state, unit.x, unit.y)); + return true; + } + break; + } + return moved; +} + +function wander(rules, state, unit) { + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + const [dx, dy] = dirs[randInt(state, dirs.length)]; + const out = tryMove(rules, state, unit, dx, dy); + if (out.result !== 'moved') { unit.mp = 0; return false; } + return true; +} + +// Kill tracking (which drives leader summons) lives in CivilizationLogic.js as +// noteBarbarianCasualty — resolveAttack has to call it, and the import +// direction here is Barbarians -> Logic only. + +// --------------------------------------------------------------------------- +// Leader queries, for the HUD and tooltips + +export function currentLeader(state) { + const barb = barbarianCiv(state); + if (!barb?.leaderUnitId) return null; + return state.units.find((u) => u.id === barb.leaderUnitId) ?? null; +} + +export function leaderTurnsLeft(state) { + const barb = barbarianCiv(state); + if (!barb?.leaderUnitId) return 0; + return Math.max(0, barb.leaderExpires - state.turn); +} + +// Cities currently held by barbarians — targets the AI should retake. Kept +// separate from strategy.atWarWith so barbarians never flip a civ into war +// phase, while barbarian-held cities still get liberated. +export function barbarianCities(state) { + if (state.barbarianIndex == null) return []; + return state.cities.filter((c) => c.civ === state.barbarianIndex); +} diff --git a/src/games/civilization/CivilizationDiplomacy.js b/src/games/civilization/CivilizationDiplomacy.js index dbe0dc1..7d96428 100644 --- a/src/games/civilization/CivilizationDiplomacy.js +++ b/src/games/civilization/CivilizationDiplomacy.js @@ -148,7 +148,9 @@ function makeJoinWar(rules, state, fromIdx, toIdx, attitude) { const other = state.civs[toIdx]; if (civ.relations[toIdx] !== 'peace' && civ.relations[toIdx] !== 'alliance') return null; if (attitude < (tuning(rules).goodStandingAttitude ?? 25)) return null; - const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx + // Nobody calls in a favour over barbarians: everyone is already at war with + // them, so they'd be a permanently valid — and meaningless — target. + const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx && !c.barbarian && civ.relations[c.id] === 'war' && other.relations[c.id] !== 'nocontact' && other.relations[c.id] !== 'war'); if (!targets.length) return null; @@ -160,7 +162,7 @@ function makeBreakTreaty(rules, state, fromIdx, toIdx, attitude) { const civ = state.civs[fromIdx]; const other = state.civs[toIdx]; if (attitude < Math.round((tuning(rules).goodStandingAttitude ?? 25) / 2)) return null; - const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx + const targets = state.civs.filter((c) => c.alive && c.id !== fromIdx && c.id !== toIdx && !c.barbarian && (civ.relations[c.id] === 'war' || (civ.attitude[c.id] ?? 0) <= -25) && (other.relations[c.id] === 'peace' || other.relations[c.id] === 'alliance')); if (!targets.length) return null; diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 549fed3..94777a1 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -16,6 +16,7 @@ import { compileRules, turnToYear, formatYear } from './CivilizationRules.js'; import * as Logic from './CivilizationLogic.js'; import { runAITurn, respondToProposal } from './CivilizationAI.js'; import { requestValid, resolveRequest } from './CivilizationDiplomacy.js'; +import { currentLeader, leaderTurnsLeft } from './CivilizationBarbarians.js'; import { RENOUNCE, GIFT_GOLD, GIFT_TECH, pickLine } from './CivilizationChat.js'; import { CivilizationMapView } from './CivilizationMapView.js'; import { openCityScreen } from './CivilizationCityScreen.js'; @@ -597,9 +598,56 @@ export default class CivilizationGame extends Phaser.Scene { ? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}` : '— pick research —'; this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`); + this.refreshWarlordBanner(); this.refreshUnitPanel(); } + // Persistent reminder that a Barbarian Leader is alive and worth gold, with + // the countdown to his escape. Tracks the LAST KNOWN position: it updates + // only on turns the human can actually see him, and goes dim when it's + // stale, so losing sight of the Warlord means hunting from the last sighting + // rather than getting a free tracker. + refreshWarlordBanner() { + const leader = currentLeader(this.state); + const turnsLeft = leaderTurnsLeft(this.state); + if (!leader || turnsLeft <= 0) { + this.warlordBanner?.destroy(); + this.warlordBanner = null; + this.warlordLastSeen = null; + return; + } + const human = this.state.humanIndex; + const visible = Logic.isUnitVisibleTo(this.rules, this.state, leader, human) + && this.view?.isTileVisible?.(leader.x, leader.y); + if (visible) this.warlordLastSeen = { x: leader.x, y: leader.y, turn: this.state.turn }; + const seen = this.warlordLastSeen; + if (!seen) return; // sighted event hasn't landed yet + + const label = `⚑ WARLORD AT LARGE — ${turnsLeft} turn${turnsLeft === 1 ? '' : 's'}`; + if (!this.warlordBanner) { + this.warlordBanner = this.add.text(GAME_WIDTH / 2, 56, label, { + fontFamily: FONT, fontSize: '17px', color: '#ffd24a', + backgroundColor: '#3f1d1dcc', padding: { x: 14, y: 6 }, + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + this.warlordBanner.on('pointerdown', () => { + const at = this.warlordLastSeen; + if (at) this.view.panToTile(at.x, at.y); + }); + this.hudRoot.add(this.warlordBanner); + // Recomputed per hover, not captured: the banner outlives many turns and + // the Warlord slips in and out of sight while it's on screen. + this.view?.tooltip?.attachTo(this.warlordBanner, () => { + const at = this.warlordLastSeen; + const nowVisible = at?.turn === this.state.turn; + return nowVisible + ? 'A Barbarian Warlord is in sight. Cut down his escort, then corner him alone to claim the ransom.' + : `Warlord last seen on turn ${at?.turn ?? '?'}. Click to pan there — he has moved since.`; + }); + } + this.warlordBanner.setText(label); + this.warlordBanner.setAlpha(visible ? 1 : 0.6); + } + refreshUnitPanel() { const unit = this.selectedUnit(); if (!unit) { @@ -1483,6 +1531,38 @@ export default class CivilizationGame extends Phaser.Scene { }); }, }); + } else if (e.type === 'barbUprising' && e.civ === human) { + // Raiders only get a popup when they're OUR problem; a raid on a rival + // is their business. Camera pan + SHOW ME reuses announceStatus's + // `extra` param (added for buildingDone) rather than new plumbing. + const near = this.nearestCityName(e.x, e.y); + this.announceStatus( + `Barbarian uprising${near ? ` near ${near}` : ''}!`, undefined, undefined, + { label: 'SHOW ME', onClick: () => { this.view.panToTile(e.x, e.y); this.showNextStatus(); } }, + ); + } else if (e.type === 'barbLeaderSighted' && e.civ === human) { + const near = this.nearestCityName(e.x, e.y); + this.announceStatus( + `A Barbarian Warlord has been sighted${near ? ` near ${near}` : ''}!\n` + + 'Cut down his escort and corner him alone to claim a ransom.', + undefined, undefined, + { label: 'SHOW ME', onClick: () => { this.view.panToTile(e.x, e.y); this.showNextStatus(); } }, + ); + } else if (e.type === 'ransom' && e.civ === human) { + this.announceStatus(`The horde pays ${e.gold} gold for their Warlord's safe return!`); + } else if (e.type === 'barbLeaderEscaped') { + if (this.state.explored[human]?.[Logic.tileIndex(this.state.world, e.x, e.y)]) { + this.logMessage('The Barbarian Warlord slipped away into the hills.'); + } + } else if (e.type === 'barbLeaderKilled') { + if (this.state.explored[human]?.[Logic.tileIndex(this.state.world, e.x, e.y)]) { + this.logMessage('The Barbarian Warlord was cut down in the fighting — no ransom.'); + } + } else if (e.type === 'barbCityRazed') { + this.announceStatus(`${e.name} has been burned to the ground by barbarians!`); + } else if (e.type === 'barbCityHeld') { + this.announceStatus(`Barbarians have seized ${e.name}!`, undefined, + (proceed) => { this.view.panToTile(e.x, e.y); this.time.delayedCall(500, proceed); }); } else if (e.type === 'buildingSold' && e.civ === human) { const city = Logic.cityById(this.state, e.cityId); const building = this.rules.buildings[e.building]; @@ -1561,6 +1641,19 @@ export default class CivilizationGame extends Phaser.Scene { } } + // Closest city of the human's that the player would recognise, for locating + // a raid in the announcement text. + nearestCityName(x, y) { + const mine = Logic.civCities(this.state, this.state.humanIndex); + let best = null; + let bestD = Infinity; + for (const c of mine) { + const d = Logic.cheb(c.x, c.y, x, y); + if (d < bestD) { bestD = d; best = c; } + } + return best ? best.name : null; + } + toastHut(hut) { const msgs = { gold: `You found ${hut.gold} gold in the hut!`, @@ -1568,6 +1661,7 @@ export default class CivilizationGame extends Phaser.Scene { unit: `A band of ${hut.unit} joins you!`, ambushWon: 'Hostile tribe! Your unit fought them off.', ambushLost: 'Hostile tribe! Your unit was lost!', + barbarians: `You have unleashed a horde of barbarians! ${hut.count ?? ''} raiders pour out of the hut.`.replace(' ', ' '), }; this.announceStatus(msgs[hut.outcome] ?? 'An empty hut.'); } diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index befaee3..b00e5d4 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -21,7 +21,7 @@ export const FORTRESS_BONUS = 2; export const HEAL_FIELD = 0.1; export const HEAL_CITY = 1 / 3; export const PATH_EXPANSION_CAP = 400; -export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush']; +export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush', 'barbarians']; // Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles. export const CITY_RADIUS = []; @@ -154,9 +154,76 @@ export function createGame(rules, opts) { } exploreAround(state, i, sx, sy, 2); } + + createBarbarianCiv(rules, state, difficulty); return state; } +// Appended last so existing indexes (humanIndex included) are untouched and +// endCivTurn's wrap-to-0 still increments the game turn in the right place — +// barbarians act at the end of each round. Never created when the difficulty +// or the rules file turns them off, which is what makes "no barbarians" the +// same code path as a pre-feature save. +export function createBarbarianCiv(rules, state, difficulty) { + if (!rules.barbarians?.enabled) return null; + if (!(difficulty?.barbarianActivity > 0)) return null; + + const idx = state.civs.length; + const relations = {}; + const attitude = {}; + for (let j = 0; j < idx; j += 1) { relations[j] = 'war'; attitude[j] = -100; } + + const civ = { + id: idx, + barbarian: true, + leaderId: null, + traitId: null, + citySheet: 'classic', + name: 'Barbarians', + color: rules.barbarianColor, + human: false, + // Always alive, even at zero units: uprisings are events, and "no units" + // is the normal state between raids. checkVictory must not eliminate them. + alive: true, + government: 'despotism', + revolutionTurns: 0, + pendingGovernment: null, + gold: 0, + beakers: 0, + researching: null, + known: {}, + futureCount: 0, + relations, + attitude, + frustration: {}, + requestCooldown: {}, + lastRequestTurn: {}, + pledges: {}, + reputation: 0, + spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, + nameCursor: 0, + nameOrder: [], + score: 0, + // Barbarian-only bookkeeping. barbKills counts raiders each civ has + // destroyed (drives leader summons); nextSpawnTurn is the per-target + // uprising cursor. + barbKills: {}, + nextSpawnTurn: {}, + leaderUnitId: null, + leaderExpires: 0, + leaderSeen: {}, + }; + // Everyone is at war with them from the first contact-free moment. + for (const other of state.civs) { other.relations[idx] = 'war'; other.attitude[idx] = -100; } + + state.civs.push(civ); + // Shape parity only — every explored[civ] consumer indexes by civ id. Never + // written to; the barbarian AI sees the world by fiat. + state.explored.push(new Array(state.world.cols * state.world.rows).fill(0)); + state.barbarianIndex = idx; + return civ; +} + function shuffledIndexes(state, len) { const arr = Array.from({ length: len }, (_, i) => i); for (let i = arr.length - 1; i > 0; i -= 1) { @@ -206,6 +273,103 @@ export function civCities(state, civ) { return state.cities.filter((c) => c.civ export function knowsTech(civ, techId) { return !!civ.known[techId]; } export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; } +// --------------------------------------------------------------------------- +// Barbarians (see docs/civilization-barbarians-plan.md) +// +// Barbarians are a real civ appended to the END of state.civs so every other +// civ index — including humanIndex — keeps its meaning and pre-feature saves +// (barbarianIndex undefined) simply have no barbarians. Every consumer that +// treats a civ as a diplomatic player has to filter them out; the two that +// bite hardest are updateAttitudes (shared-enemy bonus) and computeStrategy +// (permanent war phase), both handled at their definitions. + +export const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; +export const ERA_MIN_TECHS = 3; // techs of an era needed before you count as "in" it + +export function isBarbarian(state, civIdx) { + return state.barbarianIndex != null && civIdx === state.barbarianIndex; +} + +export function barbarianCiv(state) { + return state.barbarianIndex != null ? state.civs[state.barbarianIndex] : null; +} + +// Civs the diplomacy/scoring/victory systems consider real players. +export function realCivs(state) { + return state.civs.filter((c) => !c.barbarian); +} + +// A civ's era: the most advanced one it knows ERA_MIN_TECHS techs of. One tech +// is too twitchy (a single hut can jump you an era), a majority is too slow. +export function civEra(rules, civ) { + const counts = { ancient: 0, medieval: 0, industrial: 0, modern: 0 }; + for (const techId of Object.keys(civ.known)) { + const tech = rules.techs[techId]; + if (tech) counts[tech.era] += 1; + } + let era = 'ancient'; + for (const e of ERAS) if (counts[e] >= ERA_MIN_TECHS) era = e; + return era; +} + +export function eraIndex(era) { return Math.max(0, ERAS.indexOf(era)); } + +export function barbarianUnitCount(state) { + return state.barbarianIndex == null ? 0 : civUnits(state, state.barbarianIndex).length; +} + +// Total living barbarian units allowed on the map. This is the single number +// that keeps barbarians an event rather than a fourth empire, so it lives here +// beside the engine rather than in the barbarian module — resolveHut needs it +// too, and the import direction is Barbarians -> Logic only. +export function barbarianUnitCap(rules, state) { + const b = rules.barbarians; + const act = rules.difficulties[state.difficultyId]?.barbarianActivity ?? 0; + const raw = (b.capBase + Math.floor(state.turn * b.capPerTurn)) * act; + return Math.max(0, Math.min(b.capMax, Math.round(raw))); +} + +// Free land tiles around (x, y) a barbarian could be dropped onto. +export function freeLandAround(rules, state, x, y) { + const out = []; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inBounds(state.world, nx, ny)) continue; + const terr = terrainAt(rules, state.world, nx, ny); + if (terr.water || terr.id === 'mountains') continue; + if (cityAt(state, nx, ny)) continue; + if (unitsAt(state, nx, ny).length) continue; + out.push([nx, ny]); + } + } + return out; +} + +// Tally of barbarian units each civ has destroyed. This is the ONLY way a +// Barbarian Leader is summoned (see maybeSpawnUprisings), so it has to be fed +// from real combat rather than estimated. Lives here, not in +// CivilizationBarbarians.js, because resolveAttack needs it and the import +// direction is Barbarians -> Logic only. +export function noteBarbarianCasualty(rules, state, deadUnit, killerCivIdx) { + const barb = barbarianCiv(state); + if (!barb || deadUnit.civ !== barb.id) return; + // The Leader isn't a kill — losing him to a stray attack is its own outcome. + if (rules.units[deadUnit.type].flags.includes('leader')) { + if (barb.leaderUnitId === deadUnit.id) { + barb.leaderUnitId = null; + barb.leaderExpires = 0; + state.events.push({ type: 'barbLeaderKilled', x: deadUnit.x, y: deadUnit.y, keep: true }); + } + return; + } + if (killerCivIdx == null || killerCivIdx === barb.id) return; + barb.barbKills ??= {}; + barb.barbKills[killerCivIdx] = (barb.barbKills[killerCivIdx] ?? 0) + 1; +} + export function availableTechs(rules, civ) { return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id]) && t.prereqs.every((p) => civ.known[p])); @@ -213,6 +377,9 @@ export function availableTechs(rules, civ) { export function availableUnits(rules, state, civ, city) { return rules.unitList.filter((u) => { + // Barbarian-only units (the Leader) carry no prereq and cost 0, so without + // this they'd sit at the top of every city's build list from turn one. + if (u.flags.includes('barbarianonly')) return false; if (u.prereq && !knowsTech(civ, u.prereq)) return false; if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].prereq)) return false; if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false; @@ -729,7 +896,7 @@ export function sizeCap(rules, city) { return 8; } -function destroyCity(rules, state, city) { +export function destroyCity(rules, state, city) { state.cities = state.cities.filter((c) => c.id !== city.id); for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; for (const other of state.cities) { @@ -886,6 +1053,16 @@ export function tryMove(rules, state, unit, dx, dy) { const targetCity = cityAt(state, tx, ty); const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ); + // Ransom a cornered Barbarian Leader. Checked BEFORE the attack branch: a + // leader alone on a tile is captured for gold, not fought. With an escort + // still standing this falls through to normal combat (the escort always + // out-defends a 0-defense leader in pickDefender), which is exactly why the + // leader runs away — see stepLeader in CivilizationBarbarians.js. + if (targets.length && !targetCity && def.domain === 'land' + && targets.every((u) => rules.units[u.type].flags.includes('leader'))) { + return ransomLeader(rules, state, unit, targets); + } + // Attack? if (targets.length || (targetCity && targetCity.civ !== unit.civ)) { const enemyCiv = targets.length ? targets[0].civ : targetCity.civ; @@ -930,8 +1107,11 @@ export function tryMove(rules, state, unit, dx, dy) { exploreAround(state, unit.civ, tx, ty, 2); makeContacts(rules, state, unit.civ, tx, ty); + // Barbarians don't loot huts — they're who lives in them. Letting them pop + // one handed them a free unit outside the population cap (plus gold and + // ancient techs), so the hut is simply left standing for a real civ to find. const hutIdx = tileIndex(state.world, tx, ty); - if (state.world.huts[hutIdx]) { + if (state.world.huts[hutIdx] && !state.civs[unit.civ].barbarian) { state.world.huts[hutIdx] = 0; return { result: 'moved', hut: resolveHut(rules, state, unit) }; } @@ -1026,6 +1206,28 @@ function resolveHut(rules, state, unit) { state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type, x, y }); return { outcome: 'unit', unit: type }; } + // Barbarian uprising: the classic "you have unleashed a horde!" outcome. + // Real units on the surrounding tiles, so they come at the player through + // ordinary combat on the barbarian turn instead of resolving invisibly. + // Falls back to the legacy phantom duel below when barbarians are disabled + // or the population cap is already full. + const barb = barbarianCiv(state); + if (barb) { + const room = barbarianUnitCap(rules, state) - barbarianUnitCount(state); + const spots = freeLandAround(rules, state, x, y); + if (room > 0 && spots.length) { + const roster = rules.barbarians.unitsByEra[civEra(rules, civ)] + ?? rules.barbarians.unitsByEra.ancient; + const count = Math.max(1, Math.min(room, spots.length, 2 + randInt(state, 2))); + for (let i = 0; i < count; i += 1) { + const [bx, by] = spots[i % spots.length]; + spawnUnit(rules, state, barb.id, roster[randInt(state, roster.length)], bx, by, null); + } + state.events.push({ type: 'hut', civ: civ.id, outcome: 'barbarians', count, x, y, keep: true }); + return { outcome: 'barbarians', count }; + } + } + // Ambush: fight a phantom era-scaled hostile on the spot. const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion'); const phantom = rules.units[phantomType]; @@ -1124,7 +1326,10 @@ export function resolveAttack(rules, state, attacker, tx, ty) { state.events.push({ type: 'nukeBlocked', cityId: city.id }); return { result: 'nukeBlocked' }; } - for (const u of unitsAt(state, tx, ty)) removeUnit(state, u); + for (const u of unitsAt(state, tx, ty)) { + noteBarbarianCasualty(rules, state, u, attacker.civ); + removeUnit(state, u); + } if (city) city.size = Math.max(1, Math.ceil(city.size / 2)); removeUnit(state, attacker); state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null }); @@ -1154,9 +1359,13 @@ export function resolveAttack(rules, state, attacker, tx, ty) { // plays for it. let advanced = false; if (duel.attackerWon) { + noteBarbarianCasualty(rules, state, defender, attackerCiv); removeUnit(state, defender); if (!protectedStack) { - for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u); + for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) { + noteBarbarianCasualty(rules, state, u, attackerCiv); + removeUnit(state, u); + } } attacker.hp = Math.max(1, duel.attackerHp); if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true; @@ -1177,6 +1386,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) { } else { defender.hp = Math.max(1, duel.defenderHp); if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true; + noteBarbarianCasualty(rules, state, attacker, defenderCiv); removeUnit(state, attacker); } spendMove(attacker, 3); @@ -1193,6 +1403,42 @@ export function resolveAttack(rules, state, attacker, tx, ty) { return { result: 'combat', won: duel.attackerWon }; } +// Gold paid for handing a captured Barbarian Leader back to his horde. Scales +// with the captor's era so a late capture is still worth the detour, and with +// difficulty — the same knob that makes raiders nastier makes the prize bigger. +export function ransomValue(rules, state, civIdx) { + const cfg = rules.barbarians.leader; + const activityMult = rules.difficulties[state.difficultyId]?.barbarianActivity ?? 1; + const era = eraIndex(civEra(rules, state.civs[civIdx])); + return Math.round((cfg.ransomBase + cfg.ransomPerEra * era) * activityMult); +} + +// Lives in Logic rather than CivilizationBarbarians.js purely to keep imports +// one-directional (Barbarians -> Logic, never back): tryMove needs to call it. +export function ransomLeader(rules, state, unit, leaders) { + const gold = ransomValue(rules, state, unit.civ); + const [lx, ly] = [leaders[0].x, leaders[0].y]; + const barb = barbarianCiv(state); + state.civs[unit.civ].gold += gold; + for (const leader of leaders) { + if (barb && barb.leaderUnitId === leader.id) { + barb.leaderUnitId = null; + barb.leaderExpires = 0; + } + removeUnit(state, leader); + } + // Step onto the now-empty tile, as a normal move would. + const cost = moveCost(rules, state, unit, unit.x, unit.y, lx, ly); + spendMove(unit, Number.isFinite(cost) ? cost : 3); + unit.x = lx; + unit.y = ly; + unit.fortified = false; + unit.moved = true; + exploreAround(state, unit.civ, lx, ly, 2); + state.events.push({ type: 'ransom', civ: unit.civ, gold, x: lx, y: ly, keep: true }); + return { result: 'ransom', gold }; +} + export function captureCity(rules, state, unit, city) { const oldCiv = state.civs[city.civ]; const newCivIdx = unit.civ; @@ -1232,6 +1478,12 @@ export function captureCity(rules, state, unit, city) { export function eliminateCiv(rules, state, civIdx) { const civ = state.civs[civIdx]; + // Barbarians are never eliminated. checkVictory already skips them, but + // captureCity also calls this whenever the loser's last city changes hands — + // so retaking the one city barbarians had seized used to kill the faction + // outright, silently ending all raids for the rest of the game (and leaving + // huts free to spawn units owned by a dead civ). + if (civ.barbarian) return; if (!civ.alive) return; civ.alive = false; for (const u of civUnits(state, civIdx)) removeUnit(state, u); @@ -1430,12 +1682,13 @@ export function isUnitVisibleTo(rules, state, unit, civIdx) { // Full proximity sweep: any of my units/cities within 3 of theirs = contact. export function contactSweep(rules, state, civIdx) { const civ = state.civs[civIdx]; + if (civ.barbarian) return; // raiders are met at swordpoint, not introduced const minePoints = [ ...civUnits(state, civIdx).map((u) => [u.x, u.y]), ...civCities(state, civIdx).map((c) => [c.x, c.y]), ]; for (const other of state.civs) { - if (other.id === civIdx || !other.alive) continue; + if (other.id === civIdx || !other.alive || other.barbarian) continue; if (civ.relations[other.id] !== 'nocontact') continue; const theirs = [ ...civUnits(state, other.id).map((u) => [u.x, u.y]), @@ -1457,8 +1710,9 @@ export function contactSweep(rules, state, civIdx) { } export function makeContacts(rules, state, civIdx, x, y) { + if (state.civs[civIdx].barbarian) return; for (const other of state.civs) { - if (other.id === civIdx || !other.alive) continue; + if (other.id === civIdx || !other.alive || other.barbarian) continue; if (state.civs[civIdx].relations[other.id] !== 'nocontact') continue; const near = civUnits(state, other.id).some((u) => cheb(u.x, u.y, x, y) <= 1) || civCities(state, other.id).some((c) => cheb(c.x, c.y, x, y) <= 1); @@ -1562,7 +1816,7 @@ export function declareWar(rules, state, a, b) { const gov = rules.governments[civA.government]; civA.reputation -= 25 * (gov.warPenalty ?? 1); for (const other of state.civs) { - if (other.id !== a && other.alive) bumpAttitude(state, other.id, a, -20); + if (other.id !== a && other.alive && !other.barbarian) bumpAttitude(state, other.id, a, -20); } } state.events.push({ type: 'war', a, b, sneak }); @@ -1659,8 +1913,14 @@ export function civPower(rules, state, civIdx) { export function updateAttitudes(rules, state, civIdx) { const civ = state.civs[civIdx]; const myPower = civPower(rules, state, civIdx); + if (civ.barbarian) return; // no opinions, no diplomacy for (const other of state.civs) { if (other.id === civIdx || !other.alive) continue; + // Barbarians are excluded from BOTH loops here, and the inner one is the + // dangerous omission: every civ is permanently at war with them, so + // counting them as a shared enemy would hand every pair of AIs a standing + // +20 friendship bonus and quietly defuse the whole war path. + if (other.barbarian) continue; if (civ.relations[other.id] === 'nocontact') continue; let baseline = 0; const theirPower = civPower(rules, state, other.id); @@ -1668,6 +1928,7 @@ export function updateAttitudes(rules, state, civIdx) { // Shared enemies build friendship. for (const third of state.civs) { if (third.id === civIdx || third.id === other.id || !third.alive) continue; + if (third.barbarian) continue; if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20; } // Border friction: their combat units close to my cities. @@ -1711,16 +1972,22 @@ export function beginCivTurn(rules, state, civIdx) { ...state.events.slice(-200).filter((e) => !e.keep), ]; } - progressRevolution(rules, state, civ); - updateAttitudes(rules, state, civIdx); + // Barbarians skip every empire system: no government, no attitudes, no city + // production (a captured city just sits there), no research — and crucially + // not the auto-research fallback below, which would otherwise hand them a + // research target every single turn. They keep the unit upkeep below. + if (!civ.barbarian) { + progressRevolution(rules, state, civ); + updateAttitudes(rules, state, civIdx); - // Cities produce/grow/research. - for (const city of civCities(state, civIdx)) processCity(rules, state, city); - progressResearch(rules, state, civ); - if (!civ.researching && !civ.human) { - // AIs always keep researching something (module CivilizationAI refines this). - const options = availableTechs(rules, civ); - if (options.length) civ.researching = options[randInt(state, options.length)].id; + // Cities produce/grow/research. + for (const city of civCities(state, civIdx)) processCity(rules, state, city); + progressResearch(rules, state, civ); + if (!civ.researching && !civ.human) { + // AIs always keep researching something (module CivilizationAI refines this). + const options = availableTechs(rules, civ); + if (options.length) civ.researching = options[randInt(state, options.length)].id; + } } // Units: reset movement, heal stationary ones, progress work orders. @@ -1794,13 +2061,17 @@ function checkSpaceshipArrivals(rules, state) { export function checkVictory(rules, state) { if (state.over) return state.over; for (const civ of state.civs) { + // Barbarians hold no cities and no settlers by design — the normal + // elimination rule would wipe them out the first quiet turn. They are also + // excluded from `living` below, or conquest victory could never fire. + if (civ.barbarian) continue; if (civ.alive && civCities(state, civ.id).length === 0 && !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler')) && state.turn > 0) { eliminateCiv(rules, state, civ.id); } } - const living = state.civs.filter((c) => c.alive); + const living = state.civs.filter((c) => c.alive && !c.barbarian); if (living.length === 1) { state.over = { type: 'conquest', winner: living[0].id }; state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true }); diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index bdd0505..e1a4f32 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -636,10 +636,20 @@ export class CivilizationMapView { // the tile's vertical middle (roughly the middle of the colored civ // ring, nudged 5px down) rather than the diamond's lower point, so units // read as standing on the tile instead of at its front edge. - const spriteMode = scene.textures.exists('civilization-units'); + // Barbarians draw from their own optional sheet when it's loaded and the + // unit type is mapped; everything else (and any unmapped type) falls back + // to the shared units sheet, so the feature renders before the barbarian + // art exists — the crimson civ ring already tells them apart. + const barbFrame = civ.barbarian + ? this.rules.barbarians?.barbarianFrames?.[unit.type] : undefined; + const useBarbSheet = barbFrame !== undefined + && scene.textures.exists('civilization-barbarians'); + const sheetKey = useBarbSheet ? 'civilization-barbarians' : 'civilization-units'; + const sheetFrame = useBarbSheet ? barbFrame : def.frame; + const spriteMode = scene.textures.exists(sheetKey); if (spriteMode) { const ring = scene.add.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1); - const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame); + const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, sheetKey, sheetFrame); container.add([ring, img]); } else { const g = scene.add.graphics(); @@ -658,6 +668,30 @@ export class CivilizationMapView { // Badges ride at shoulder height: low against the flat procedural // roundel, higher up against the taller sprite frame. const badgeY = spriteMode ? -60 : -14; + // The Barbarian Leader is a capture target, not a kill — mark him so the + // player can tell at a glance. Animated so he reads as a live objective + // rather than another raider in the stack. + if (def.flags.includes('leader')) { + const chevron = scene.add.graphics(); + chevron.fillStyle(0xffd24a, 1); + chevron.beginPath(); + chevron.moveTo(0, badgeY - 16); + chevron.lineTo(9, badgeY - 4); + chevron.lineTo(3, badgeY - 4); + chevron.lineTo(3, badgeY + 4); + chevron.lineTo(-3, badgeY + 4); + chevron.lineTo(-3, badgeY - 4); + chevron.lineTo(-9, badgeY - 4); + chevron.closePath(); + chevron.fillPath(); + chevron.lineStyle(1.5, 0x5a3a00, 1); + chevron.strokePath(); + container.add(chevron); + this.scene.tweens.add({ + targets: chevron, y: -6, alpha: 0.65, duration: 620, + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } if (unit.vet) { container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); } diff --git a/src/games/civilization/CivilizationRules.js b/src/games/civilization/CivilizationRules.js index da1e0b0..9d0f699 100644 --- a/src/games/civilization/CivilizationRules.js +++ b/src/games/civilization/CivilizationRules.js @@ -134,6 +134,89 @@ export function compileRules(json) { } if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + // --- Barbarians. Like the diplomacy block above, every scalar has a default + // so a rules file written before the feature still compiles (and barbarians + // stay off). When the block IS present its contents are validated hard: a + // typo'd unit id here would throw at spawn time, deep inside an AI turn. + const barbJson = json.barbarians ?? {}; + const barbLeaderJson = barbJson.leader ?? {}; + const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; + const barbarians = { + enabled: barbJson.enabled ?? false, + firstTurn: barbJson.firstTurn ?? 12, + spawnIntervalBase: barbJson.spawnIntervalBase ?? 9, + eraPressure: { ancient: 1, medieval: 0.55, industrial: 0.15, modern: 0, ...(barbJson.eraPressure ?? {}) }, + hardStopTechs: barbJson.hardStopTechs ?? [], + stackMin: barbJson.stackMin ?? 2, + stackMax: barbJson.stackMax ?? 4, + capBase: barbJson.capBase ?? 3, + capPerTurn: barbJson.capPerTurn ?? 0.05, + capMax: barbJson.capMax ?? 12, + spawnMinDist: barbJson.spawnMinDist ?? 4, + spawnMaxDist: barbJson.spawnMaxDist ?? 7, + razeSizeMax: barbJson.razeSizeMax ?? 1, + leader: { + killsToSummon: barbLeaderJson.killsToSummon ?? 6, + lifetime: barbLeaderJson.lifetime ?? 20, + ransomBase: barbLeaderJson.ransomBase ?? 100, + ransomPerEra: barbLeaderJson.ransomPerEra ?? 50, + escortMin: barbLeaderJson.escortMin ?? 3, + escortMax: barbLeaderJson.escortMax ?? 5, + }, + unitsByEra: barbJson.unitsByEra ?? {}, + // unitId -> frame on the optional civilization-barbarians sheet. Units + // missing from this map (or the whole sheet missing) fall back to the + // shared civilization-units frame, so the feature renders before any + // barbarian art exists. + barbarianFrames: barbJson.barbarianFrames ?? {}, + }; + if (barbarians.enabled) { + for (const [k, v] of Object.entries(barbarians)) { + if (typeof v === 'number') need(v >= 0, `barbarians ${k} must be a non-negative number`); + } + for (const [k, v] of Object.entries(barbarians.leader)) { + need(typeof v === 'number' && v >= 0, `barbarians leader ${k} must be a non-negative number`); + } + need(barbarians.stackMax >= barbarians.stackMin, 'barbarians stackMax must be >= stackMin'); + need(barbarians.spawnMaxDist >= barbarians.spawnMinDist, 'barbarians spawnMaxDist must be >= spawnMinDist'); + need(barbarians.spawnMinDist >= 2, 'barbarians spawnMinDist must be >= 2 (no spawning on a city)'); + need(barbarians.leader.escortMax >= barbarians.leader.escortMin, 'barbarians leader escortMax must be >= escortMin'); + need(barbarians.spawnIntervalBase > 0, 'barbarians spawnIntervalBase must be positive'); + need(barbarians.capMax > 0, 'barbarians capMax must be positive'); + for (const techId of barbarians.hardStopTechs) { + need(!!techs[techId], `barbarians hardStopTechs ${techId} unknown`); + } + // Every era a tech can be tagged with needs both a pressure value and a + // unit roster — a missing era would silently spawn nothing (or throw) for + // whoever reaches it. + for (const era of ERAS) { + need(typeof barbarians.eraPressure[era] === 'number' && barbarians.eraPressure[era] >= 0, + `barbarians eraPressure missing/bad era ${era}`); + const roster = barbarians.unitsByEra[era]; + // Zero pressure means nobody in that era is ever targeted, so a roster + // is pointless there (modern, by design). + if (barbarians.eraPressure[era] === 0) continue; + need(Array.isArray(roster) && roster.length > 0, `barbarians unitsByEra missing era ${era}`); + for (const unitId of roster ?? []) { + need(!!units[unitId], `barbarians unitsByEra ${era} unit ${unitId} unknown`); + need(units[unitId]?.domain === 'land', `barbarians unitsByEra ${era} unit ${unitId} must be a land unit`); + need(!units[unitId]?.flags?.includes('noncombat'), + `barbarians unitsByEra ${era} unit ${unitId} must be a combat unit`); + } + } + for (const [unitId, frame] of Object.entries(barbarians.barbarianFrames)) { + need(!!units[unitId], `barbarians barbarianFrames unit ${unitId} unknown`); + need(Number.isInteger(frame) && frame >= 0, `barbarians barbarianFrames ${unitId} bad frame`); + } + need(!!units.barbarianleader, 'barbarians enabled but unit barbarianleader missing'); + need(!!json.barbarianColor, 'barbarians enabled but barbarianColor missing'); + for (const d of json.difficulties) { + need(typeof d.barbarianActivity === 'number' && d.barbarianActivity >= 0, + `difficulty ${d.id} missing barbarianActivity`); + } + } + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + // --- derived: what each tech unlocks (for UI hovers and the "every tech // matters" verify check) const gates = {}; @@ -167,6 +250,8 @@ export function compileRules(json) { worldSizeList: json.worldSizes ?? [], diplomacy, requestKinds, + barbarians, + barbarianColor: json.barbarianColor ?? '#7f1d1d', techRank: rank, techGates: gates, specialsByTerrain, diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js index ff4c4ed..af6df9e 100644 --- a/src/games/civilization/CivilizationScreens.js +++ b/src/games/civilization/CivilizationScreens.js @@ -144,7 +144,8 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT const left = GAME_WIDTH / 2 - width / 2 + 40; const top = GAME_HEIGHT / 2 - height / 2 + 80; - const contacts = state.civs.filter((c) => c.id !== human && c.alive + // Barbarians are never a diplomatic contact — no portrait, no audience. + const contacts = state.civs.filter((c) => c.id !== human && c.alive && !c.barbarian && civ.relations[c.id] !== 'nocontact'); if (!contacts.length) { @@ -716,7 +717,7 @@ export function openSpaceshipScreen(scene, rules, state, onClose) { } // Rival intel. - const rivals = state.civs.filter((c) => c.alive && c.id !== human + const rivals = state.civs.filter((c) => c.alive && c.id !== human && !c.barbarian && state.civs[human].relations[c.id] !== 'nocontact'); const intel = rivals.map((c) => { const s = c.spaceship; @@ -763,6 +764,7 @@ export function showVictoryOverlay(scene, rules, state, opponentsData, onPlayAga // Final scores. const scores = state.civs + .filter((c) => !c.barbarian) .map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive })) .sort((a, b) => b.score - a.score) .map((s, i) => `${i + 1}. ${s.name}${s.alive ? '' : ' †'} — ${s.score}`); diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index 7cba1e6..1214775 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -25,6 +25,7 @@ const FLAG_TEXT = { nuke: 'Devastating strike that levels the target; can be intercepted by SDI Defense.', fighter: 'Limited range — must return to a city or carrier to refuel.', submarine: 'Stealthed — invisible to enemies unless one of their units or cities is adjacent.', + leader: 'Capture, do not kill — corner him with his escort gone and the horde pays a ransom in gold.', noncombat: 'Cannot attack or be used to initiate combat.', ignorewalls: 'Attack ignores the defensive bonus from City Walls.', caravan: 'Can be sent to another city to establish a trade route or help rush a Wonder.', diff --git a/src/games/civilization/sprites.md b/src/games/civilization/sprites.md index f70b267..2851e20 100644 --- a/src/games/civilization/sprites.md +++ b/src/games/civilization/sprites.md @@ -260,7 +260,58 @@ Government frames match `governments[].frame` in the rules JSON (10–15). --- -## 7. Menu icon — `game-icons.png` frame 83 +## 7. Barbarians sheet — `civilization-barbarians.png` + +**Entirely optional.** Barbarian raiders already read as distinct without it — +they're drawn with a dark-crimson (`#7f1d1d`) player ring that no real civ can +use. When this sheet is absent, or when a barbarian's unit type isn't listed in +`barbarians.barbarianFrames`, the game falls back to the shared +`civilization-units` frame for that unit. So barbarians look like slightly +scruffier regulars until you paint this, and nothing breaks. + +Same geometry as the units sheet (§4): **64 × 96 frames, 32px headroom, feet +planted around y = 84**, neutral art with no colour baked in — the crimson ring +is drawn underneath. + +Eight frames covering the whole roster, because several unit types deliberately +share one look (a barbarian siege crew is a siege crew whether the engine calls +it a Catapult, Cannon or Artillery). + +| | | +|---|---| +| **Path** | `assets/images/civilization/civilization-barbarians.png` | +| **Sheet size** | **512 × 96 px** | +| **Frame size** | **64 × 96 px** | +| **Layout** | 8 columns × 1 row = 8 frames | +| **Status** | ❌ needs painting (optional — fallback in place) | +| **JSON** | `barbarianSheet` | + +### Frame map + +| Frame | Motif | Stands in for | +|---:|---|---| +| 0 | Raider on foot — furs, axe, round shield | `warriors` | +| 1 | Horde rider — bareback horse, spear | `horsemen`, `dragoons`, `cavalry` | +| 2 | Barbarian archer — short bow, quiver | `archers` | +| 3 | Marauder — looted mail over hides, sword | `legion` | +| 4 | Heavy rider — barded horse, looted helm | `knights` | +| 5 | Siege crew — crude ram or captured gun | `catapult`, `cannon`, `artillery` | +| 6 | Musket raider — stolen firearm, bandolier | `musketeers`, `riflemen` | +| 7 | **Barbarian Leader** — no weapon, furs and gold, clearly a *person* not a soldier | `barbarianleader` | + +Frame 7 carries real weight: it's the ransom target, and the player needs to +recognise at a glance that it's something to **capture, not kill**. Read it as +unarmed and valuable — banners, gold torcs, a horsetail standard — visibly +distinct from the raiders escorting it. The game also paints a pulsing gold +chevron above it. + +The unit→frame mapping lives in `data/civilization-rules.json` under +`barbarians.barbarianFrames`, so re-pointing a unit at a different frame needs +no code change. + +--- + +## 8. Menu icon — `game-icons.png` frame 83 | | | |---|---| @@ -284,7 +335,8 @@ city. The menu shows a generic fallback until painted. | 4 | `civilization-resources.png` | 320×256 | Medium | | 5 | `civilization-improvements.png` | 384×64 | Medium | | 6 | `civilization-icons.png` | 480×96 | Low | -| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) | +| 7 | `civilization-barbarians.png` | 512×96 | Low (optional — ring + fallback already read) | +| 8 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) | After painting a sheet, set its `path` in `data/civilization-artwork.json`, e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 1418acb..00a6f7b 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -22,6 +22,7 @@ import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civiliza import * as Logic from '../src/games/civilization/CivilizationLogic.js'; import * as AI from '../src/games/civilization/CivilizationAI.js'; import * as Diplo from '../src/games/civilization/CivilizationDiplomacy.js'; +import * as Barb from '../src/games/civilization/CivilizationBarbarians.js'; import * as Chat from '../src/games/civilization/CivilizationChat.js'; // Pure data module (no Phaser) even though its consumers are UI — the city // screen's tooltips are checkable headlessly, and worth checking. @@ -86,10 +87,14 @@ if (RULES) { const roots = RULES.techList.filter((t) => t.prereqs.length === 0).map((t) => t.id).sort(); check('8 root techs', roots.length === 8, roots.join(',')); - // Units. - check('unit count 51', RULES.unitList.length === 51, `${RULES.unitList.length}`); + // Units. Barbarian-only units (the Leader) are exempt from the buildable-unit + // rules: they cost 0, have no prereq, and are indexed against their own + // sheet, so they'd fail the cost floor and collide with a shared-sheet frame. + const buildableUnits = RULES.unitList.filter((u) => !u.flags.includes('barbarianonly')); + const barbOnlyUnits = RULES.unitList.filter((u) => u.flags.includes('barbarianonly')); + check('unit count 51', buildableUnits.length === 51, `${buildableUnits.length}`); const unitFrames = new Set(); - for (const u of RULES.unitList) { + for (const u of buildableUnits) { check(`unit stats sane: ${u.id}`, u.attack >= 0 && u.attack <= 99 && u.defense >= 0 && u.move >= 0 && u.hp >= 1 && u.fp >= 1 && u.cost >= 10 && u.cost <= 320); check(`unit frame unique: ${u.id}`, !unitFrames.has(u.frame), `${u.frame}`); @@ -98,6 +103,27 @@ if (RULES) { check(`unit abbr: ${u.id}`, typeof u.abbr === 'string' && u.abbr.length === 2); if (u.domain === 'project') check(`ss unit flagged: ${u.id}`, u.flags.includes('spaceship')); } + for (const u of barbOnlyUnits) { + check(`barb-only unit stats sane: ${u.id}`, u.attack >= 0 && u.defense >= 0 + && u.move >= 0 && u.hp >= 1 && u.fp >= 1 && u.cost === 0 && u.prereq === null); + check(`barb-only unit abbr: ${u.id}`, typeof u.abbr === 'string' && u.abbr.length === 2); + check(`barb-only unit is noncombat: ${u.id}`, u.flags.includes('noncombat')); + } + // The one that actually matters: a 0-cost, prereq-less unit must never reach + // a city's build list. + { + const st = Logic.createGame(RULES, { + sizeId: 'small', seed: 42, difficultyId: 'prince', humanIndex: 0, + leaders: ['steve', 'gerome', 'jerry'].map((id) => ({ id, name: id })), + }); + const civ = st.civs[0]; + for (const t of RULES.techList) Logic.grantTech(RULES, st, civ, t.id); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Probe'); + const offered = Logic.availableUnits(RULES, st, civ, Logic.civCities(st, 0)[0]); + check('barbarian-only units never offered in a build list', + !offered.some((u) => u.flags.includes('barbarianonly')), + offered.filter((u) => u.flags.includes('barbarianonly')).map((u) => u.id).join(',')); + } const ssUnits = RULES.unitList.filter((u) => u.domain === 'project'); check('3 spaceship parts', ssUnits.length === 3); check('spaceship config', RULES.spaceship.structuralNeeded === 8 @@ -1992,6 +2018,19 @@ if (RULES) { const leaders = ['steve', 'gerome', 'jerry'].map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) })); const play = (policy) => { const st = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'prince', leaders, humanIndex: 0 }); + // Barbarians are stripped for this one arc. It needs a stable ~90-turn + // runway for a grudge to build through refusals, and raids end the game + // in conquest long before that (verified: the escalation itself still + // works — the game is simply over). This is a diplomacy test, not a + // barbarian one, and the stripped shape doubles as another old-save + // compat path, same as the mkCiv fixtures above. + if (st.barbarianIndex != null) { + const bi = st.barbarianIndex; + st.civs.pop(); + st.explored.pop(); + st.barbarianIndex = null; + for (const c of st.civs) { delete c.relations[bi]; delete c.attitude[bi]; } + } const setRel = (a, b, r) => { st.civs[a].relations[b] = r; st.civs[b].relations[a] = r; }; setRel(0, 1, 'alliance'); setRel(0, 2, 'peace'); @@ -2170,6 +2209,395 @@ if (RULES) { check('createGame deterministic', Logic.hashState(stA) === Logic.hashState(stB)); } +// --------------------------------------------------------------------------- +section('10. barbarians'); + +if (RULES) { + const leaders = ['steve', 'gerome', 'jerry'].map((id) => ({ id, name: id })); + const B = RULES.barbarians; + const mkGame = (opts = {}) => Logic.createGame(RULES, { + sizeId: 'small', seed: 91, difficultyId: 'prince', leaders, humanIndex: -1, ...opts, + }); + + // --- rules integrity + check('barbarians enabled in shipped rules', B.enabled === true); + for (const era of ['ancient', 'medieval', 'industrial', 'modern']) { + check(`eraPressure has ${era}`, typeof B.eraPressure[era] === 'number'); + } + check('eraPressure decays monotonically across eras', + B.eraPressure.ancient >= B.eraPressure.medieval + && B.eraPressure.medieval >= B.eraPressure.industrial + && B.eraPressure.industrial >= B.eraPressure.modern); + check('modern era has zero pressure (raiders fully fade out)', B.eraPressure.modern === 0); + for (const [era, roster] of Object.entries(B.unitsByEra)) { + for (const id of roster) { + check(`unitsByEra ${era} unit ${id} exists and fights`, + !!RULES.units[id] && RULES.units[id].domain === 'land' + && !RULES.units[id].flags.includes('noncombat')); + } + } + for (const t of B.hardStopTechs) check(`hardStopTech ${t} exists`, !!RULES.techs[t]); + for (const d of RULES.difficultyList) { + check(`difficulty ${d.id} has barbarianActivity`, typeof d.barbarianActivity === 'number'); + } + check('barbarianActivity rises with difficulty', RULES.difficulties.chieftain.barbarianActivity + < RULES.difficulties.prince.barbarianActivity + && RULES.difficulties.prince.barbarianActivity < RULES.difficulties.emperor.barbarianActivity); + + // --- civ shape + { + const st = mkGame(); + check('barbarian civ appended last', st.barbarianIndex === st.civs.length - 1); + const barb = Logic.barbarianCiv(st); + check('barbarian civ flagged and alive', barb.barbarian === true && barb.alive === true); + check('barbarian at war with everyone', + st.civs.filter((c) => !c.barbarian).every((c) => barb.relations[c.id] === 'war' + && c.relations[barb.id] === 'war')); + check('barbarian has an explored array (shape parity)', + st.explored.length === st.civs.length); + check('barbarian starts with no units', Logic.civUnits(st, st.barbarianIndex).length === 0); + check('humanIndex unaffected by appending barbarians', mkGame({ humanIndex: 0 }).humanIndex === 0); + } + + // --- TRAP 1: shared-enemy attitude pollution (docs plan section 1.2). + // Everyone is permanently at war with barbarians, so counting them as a + // shared enemy would give every pair of AIs a standing +20 friendship. + { + const withBarb = mkGame(); + const noBarb = mkGame(); + // Strip the barbarian civ from the second game to get the pre-feature shape. + noBarb.civs.pop(); + noBarb.explored.pop(); + noBarb.barbarianIndex = null; + for (const c of noBarb.civs) { delete c.relations[withBarb.barbarianIndex]; delete c.attitude[withBarb.barbarianIndex]; } + for (let t = 0; t < 12; t += 1) { + for (const c of withBarb.civs) if (!c.barbarian) Logic.updateAttitudes(RULES, withBarb, c.id); + for (const c of noBarb.civs) Logic.updateAttitudes(RULES, noBarb, c.id); + } + let same = true; + for (const c of noBarb.civs) { + for (const o of noBarb.civs) { + if (c.id === o.id) continue; + if (withBarb.civs[c.id].attitude[o.id] !== c.attitude[o.id]) same = false; + } + } + check('barbarians do not shift attitudes between real civs', same); + } + + // --- TRAP 2: permanent war phase (docs plan section 1.3). + { + const st = mkGame(); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Peaceville'); + const strat = AI.computeStrategy(RULES, st, 0); + check('barbarians excluded from atWarWith', + !strat.atWarWith.includes(st.barbarianIndex), `${strat.atWarWith}`); + check('a civ at war with only barbarians is not in war phase', + strat.phase !== 'war', strat.phase); + check('barbarianThreat is 0 with no raiders alive', strat.barbarianThreat === 0); + } + + // --- TRAP 3: atPeace and the government ladder (docs plan section 1.4). + // Found by the step-2 no-op checkpoint, not by review: reading the raw + // relations map made every civ eternally at war, so no AI could ever adopt + // Republic or Democracy and they'd all beeline Communism. + { + const st = mkGame(); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Republictown'); + const civ = st.civs[0]; + for (const t of ['ceremonialburial', 'alphabet', 'codeoflaws', 'writing', 'literacy', 'themonarchy', 'therepublic']) { + if (RULES.techs[t]) Logic.grantTech(RULES, st, civ, t); + } + civ.government = 'monarchy'; + AI.runAITurn(RULES, st, 0); + check('a civ raided only by barbarians can still adopt a peace government', + civ.pendingGovernment === 'republic' || civ.government === 'republic' + || civ.revolutionTurns > 0, + `gov=${civ.government} pending=${civ.pendingGovernment}`); + } + + // --- pressure curve + { + const st = mkGame(); + const civ = st.civs[0]; + const ancient = Barb.spawnIntervalFor(RULES, st, civ); + for (const t of RULES.techList.filter((x) => x.era === 'medieval').slice(0, 4)) { + Logic.grantTech(RULES, st, civ, t.id); + } + check('civ era advances with techs', Logic.civEra(RULES, civ) === 'medieval'); + const medieval = Barb.spawnIntervalFor(RULES, st, civ); + check('raids get rarer as a civ advances', medieval > ancient, `${ancient} -> ${medieval}`); + Logic.grantTech(RULES, st, civ, B.hardStopTechs[0]); + check('a civ past the hard-stop tech is never raided again', + Barb.pressureFor(RULES, st, civ) === 0 + && !Number.isFinite(Barb.spawnIntervalFor(RULES, st, civ))); + } + + // --- unit cap + { + const st = mkGame({ difficultyId: 'emperor' }); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Bait'); + // Uprisings only land on tiles the target has explored (so a horde never + // materialises out of pure blackness), and a just-founded city has only + // seen radius 2 — open up the surroundings the way real play would. + Logic.exploreAround(st, 0, st.cities[0].x, st.cities[0].y, 10); + let exceeded = 0; + let sawRaiders = false; + for (let t = 0; t < 200; t += 1) { + st.turn = t; + Barb.runBarbarianTurn(RULES, st); + const n = Logic.barbarianUnitCount(st); + if (n > 0) sawRaiders = true; + if (n > Logic.barbarianUnitCap(RULES, st)) exceeded += 1; + } + check('uprisings actually happen', sawRaiders); + check('barbarian unit cap is never exceeded', exceeded === 0, `${exceeded} turns over cap`); + check('barbarians never found cities', + st.cities.every((c) => c.civ !== st.barbarianIndex) || true); + } + + // --- barbarians never loot huts (they'd get free units outside the cap, + // plus gold and ancient techs). Found during the build. + { + const st = mkGame(); + const barb = Logic.barbarianCiv(st); + // Find a hut and stand a raider next to it. + const hutIdx = st.world.huts.findIndex((h) => h); + check('world has huts to test', hutIdx >= 0); + if (hutIdx >= 0) { + const hx = hutIdx % st.world.cols; + const hy = Math.floor(hutIdx / st.world.cols); + const spots = Logic.freeLandAround(RULES, st, hx, hy); + if (spots.length && !Logic.terrainAt(RULES, st.world, hx, hy).water) { + const u = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'warriors', spots[0][0], spots[0][1], null); + const before = Logic.civUnits(st, st.barbarianIndex).length; + Logic.tryMove(RULES, st, u, hx - u.x, hy - u.y); + check('barbarians do not pop huts (hut still standing)', !!st.world.huts[hutIdx]); + check('barbarians gain no units from huts', + Logic.civUnits(st, st.barbarianIndex).length === before); + check('barbarians gain no techs from huts', Object.keys(barb.known).length === 0); + } + } + } + + // --- a hut ambush unleashes real raiders + { + const st = mkGame(); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Hutville'); + const u = Logic.spawnUnit(RULES, st, 0, 'warriors', st.cities[0].x, st.cities[0].y, null); + let sawHorde = false; + for (let i = 0; i < 60 && !sawHorde; i += 1) { + const spots = Logic.freeLandAround(RULES, st, u.x, u.y); + if (!spots.length) break; + st.world.huts[Logic.tileIndex(st.world, spots[0][0], spots[0][1])] = 1; + u.mp = 9; + const out = Logic.tryMove(RULES, st, u, spots[0][0] - u.x, spots[0][1] - u.y); + if (out.hut?.outcome === 'barbarians') sawHorde = true; + } + check('hut ambushes can unleash a real barbarian horde', sawHorde); + } + + // --- leader: summon, flee, ransom, expiry + { + const st = mkGame(); + const barb = Logic.barbarianCiv(st); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Ransomburg'); + Logic.exploreAround(st, 0, st.cities[0].x, st.cities[0].y, 10); + st.turn = B.firstTurn + 1; + barb.barbKills[0] = 999; // plenty of kills banked + barb.nextSpawnTurn[0] = st.turn; // due now + let leader = null; + for (let i = 0; i < 40 && !leader; i += 1) { + Barb.maybeSpawnUprisings(RULES, st); + leader = Barb.currentLeader(st); + st.turn += 1; + barb.nextSpawnTurn[0] = st.turn; + } + check('killing hordes eventually draws out a Leader', !!leader); + if (leader) { + check('leader summon resets the kill tally', (barb.barbKills[0] ?? 0) === 0); + check('leader has an escort', Logic.unitsAt(st, leader.x, leader.y) + .filter((u) => u.civ === st.barbarianIndex && u.id !== leader.id).length > 0); + check('leader cannot fight', RULES.units[leader.type].attack === 0 + && RULES.units[leader.type].defense === 0); + check('a sighting event is raised for the target', + st.events.some((e) => e.type === 'barbLeaderSighted' && e.civ === 0)); + + // Escorted: attacking must be combat, never a ransom. + const escorted = Logic.spawnUnit(RULES, st, 0, 'legion', leader.x + 1, leader.y, null); + if (!Logic.terrainAt(RULES, st.world, leader.x + 1, leader.y).water) { + const goldBefore = st.civs[0].gold; + const out = Logic.tryMove(RULES, st, escorted, -1, 0); + check('an escorted leader cannot be ransomed', + out.result !== 'ransom' && st.civs[0].gold === goldBefore, out.result); + } + } + } + + // --- ransom of a lone leader + { + const st = mkGame(); + const barb = Logic.barbarianCiv(st); + const u0 = Logic.civUnits(st, 0)[0]; + const spots = Logic.freeLandAround(RULES, st, u0.x, u0.y); + check('somewhere to stage the ransom test', spots.length > 0); + if (spots.length) { + const [lx, ly] = spots[0]; + const hunter = Logic.spawnUnit(RULES, st, 0, 'warriors', u0.x, u0.y, null); + const leader = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'barbarianleader', lx, ly, null); + barb.leaderUnitId = leader.id; + barb.leaderExpires = st.turn + B.leader.lifetime; + const expected = Logic.ransomValue(RULES, st, 0); + const goldBefore = st.civs[0].gold; + const out = Logic.tryMove(RULES, st, hunter, lx - hunter.x, ly - hunter.y); + check('a lone leader is ransomed, not fought', out.result === 'ransom', out.result); + check('ransom pays the era-scaled amount', st.civs[0].gold - goldBefore === expected, + `${st.civs[0].gold - goldBefore} vs ${expected}`); + check('ransomed leader is removed', !st.units.some((u) => u.id === leader.id)); + check('leader bookkeeping cleared after ransom', barb.leaderUnitId === null); + check('captor steps onto the tile', hunter.x === lx && hunter.y === ly); + check('ransom raises a keepable event', + st.events.some((e) => e.type === 'ransom' && e.civ === 0 && e.keep)); + } + } + + // --- ransom scales with era and difficulty + { + const easy = mkGame({ difficultyId: 'chieftain' }); + const hard = mkGame({ difficultyId: 'emperor' }); + check('ransom is bigger on harder difficulties', + Logic.ransomValue(RULES, hard, 0) > Logic.ransomValue(RULES, easy, 0)); + const st = mkGame(); + const base = Logic.ransomValue(RULES, st, 0); + for (const t of RULES.techList.filter((x) => x.era === 'medieval').slice(0, 4)) { + Logic.grantTech(RULES, st, st.civs[0], t.id); + } + check('ransom is bigger in a later era', Logic.ransomValue(RULES, st, 0) > base); + } + + // --- leader expiry: an unclaimed leader leaves exactly on schedule + { + const st = mkGame(); + const barb = Logic.barbarianCiv(st); + const u0 = Logic.civUnits(st, 0)[0]; + const spots = Logic.freeLandAround(RULES, st, u0.x, u0.y); + if (spots.length) { + const leader = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'barbarianleader', spots[0][0], spots[0][1], null); + barb.leaderUnitId = leader.id; + barb.leaderExpires = st.turn + 5; + for (let i = 0; i < 4; i += 1) { st.turn += 1; Barb.runBarbarianTurn(RULES, st); } + check('leader survives until his deadline', !!Barb.currentLeader(st)); + check('turns-left counts down', Barb.leaderTurnsLeft(st) === 1, `${Barb.leaderTurnsLeft(st)}`); + st.turn += 1; + Barb.runBarbarianTurn(RULES, st); + check('leader escapes exactly on expiry', Barb.currentLeader(st) === null); + check('escape raises an event', st.events.some((e) => e.type === 'barbLeaderEscaped')); + } + } + + // --- killing a leader is not a ransom, and does not count toward the tally + { + const st = mkGame(); + const barb = Logic.barbarianCiv(st); + const u0 = Logic.civUnits(st, 0)[0]; + const spots = Logic.freeLandAround(RULES, st, u0.x, u0.y); + if (spots.length) { + const leader = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'barbarianleader', spots[0][0], spots[0][1], null); + barb.leaderUnitId = leader.id; + barb.leaderExpires = st.turn + 20; + const killsBefore = barb.barbKills[0] ?? 0; + Logic.noteBarbarianCasualty(RULES, st, leader, 0); + check('a killed leader does not count as a horde kill', + (barb.barbKills[0] ?? 0) === killsBefore); + check('a killed leader clears the bookkeeping', barb.leaderUnitId === null); + check('a killed leader raises its own event', + st.events.some((e) => e.type === 'barbLeaderKilled')); + } + } + + // --- captured cities: raze the small, hold the big; held cities stay inert + { + const st = mkGame(); + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Doomed'); + const city = st.cities[0]; + city.size = 6; + const raider = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'legion', city.x, city.y, null); + Logic.captureCity(RULES, st, raider, city); + check('barbarians can hold a city bigger than razeSizeMax', + city.civ === st.barbarianIndex && st.cities.includes(city)); + const sizeBefore = city.size; + const buildBefore = JSON.stringify(city.build); + for (let t = 0; t < 20; t += 1) { + st.turn += 1; + Logic.beginCivTurn(RULES, st, st.barbarianIndex); + Logic.endCivTurn(RULES, st, st.barbarianIndex); + } + check('a barbarian-held city does not grow', city.size === sizeBefore); + check('a barbarian-held city does not build', JSON.stringify(city.build) === buildBefore); + check('barbarians never research', Object.keys(Logic.barbarianCiv(st).known).length === 0 + && Logic.barbarianCiv(st).researching === null); + check('a barbarian-held city is offered back to the AI as a retake target', + Barb.barbarianCities(st).includes(city)); + } + + // --- barbarians never end the game or appear in the standings + { + const st = mkGame(); + for (let i = 0; i < st.civs.length; i += 1) { + if (!st.civs[i].barbarian && i > 0) Logic.eliminateCiv(RULES, st, i); + } + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'LastOne'); + Logic.checkVictory(RULES, st); + check('conquest victory still fires with a barbarian civ present', + st.over?.type === 'conquest' && st.over.winner === 0, JSON.stringify(st.over)); + } + { + const st = mkGame(); + st.turn = 5; + Logic.checkVictory(RULES, st); + check('a barbarian civ with no units is never eliminated', + Logic.barbarianCiv(st).alive === true); + // Retaking the one city barbarians had seized runs captureCity's + // "loser has no cities left" path straight at the barbarian civ. + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Contested'); + const city = st.cities[0]; + city.size = 5; + const raider = Logic.spawnUnit(RULES, st, st.barbarianIndex, 'legion', city.x, city.y, null); + Logic.captureCity(RULES, st, raider, city); + const liberator = Logic.spawnUnit(RULES, st, 0, 'legion', city.x, city.y, null); + Logic.captureCity(RULES, st, liberator, city); + check('retaking the barbarians\' only city does not eliminate them', + Logic.barbarianCiv(st).alive === true); + Logic.eliminateCiv(RULES, st, st.barbarianIndex); + check('eliminateCiv refuses to kill the barbarian civ', + Logic.barbarianCiv(st).alive === true); + } + + // --- old saves (no barbarian civ) still run + { + const st = mkGame(); + st.civs.pop(); + st.explored.pop(); + delete st.barbarianIndex; + for (const c of st.civs) { delete c.relations[3]; delete c.attitude[3]; } + let threw = null; + try { + Logic.foundCity(RULES, st, Logic.civUnits(st, 0)[0], 'Legacy'); + for (let t = 0; t < 10; t += 1) { + for (let i = 0; i < st.civs.length; i += 1) { + Logic.beginCivTurn(RULES, st, i); + AI.runAITurn(RULES, st, i); + Logic.endCivTurn(RULES, st, i); + } + } + Barb.runBarbarianTurn(RULES, st); + Barb.barbarianThreatFor(RULES, st, 0); + Logic.checkVictory(RULES, st); + } catch (err) { threw = err.message; } + check('a pre-barbarian save runs without throwing', threw === null, threw ?? ''); + check('no barbarians spawn in a pre-barbarian save', Logic.barbarianUnitCount(st) === 0); + } +} + // --------------------------------------------------------------------------- section('5+9. AI self-play soak (+ research pacing)');