feat: add barbarian raiders system with warlord ransoms

Introduce barbarians as a non-diplomatic civ appended to state.civs,
providing escalating pressure that scales with difficulty and target
civ era. Key mechanics:

- Uprisings spawn on explored tiles, capped by a growing population
  limit to keep raids an event rather than a fourth empire
- Era-based pressure curve: raids decay as a civ advances, stopping
  entirely at hard-stop techs (industrialization/conscription/railroad)
- Killing hordes summons a Barbarian Leader with an escort; cornering
  the lone leader triggers a gold ransom scaled by era and difficulty
- Captured cities are either razed (small) or held inert (large)
- Leader expires after a set lifetime if not ransomed

Diplomacy and AI carefully exclude barbarians to prevent three traps:
shared-enemy attitude pollution, permanent war phase pinning, and
government ladder lockout. Pre-feature saves remain compatible.

New assets: optional civilization-barbarians.png sheet (8 frames).
Updated sprites.md and unit tooltips. Comprehensive verification tests
added in verifyCivilization.js.
This commit is contained in:
Brian Fertig 2026-08-01 18:18:36 -06:00
parent d5f5e8981a
commit 390dadbc14
15 changed files with 2044 additions and 46 deletions

View File

@ -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 },

View File

@ -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",

View File

@ -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 47 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 23 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` (35) 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 (5155) 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.

View File

@ -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

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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.');
}

View File

@ -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,6 +1972,11 @@ export function beginCivTurn(rules, state, civIdx) {
...state.events.slice(-200).filter((e) => !e.keep),
];
}
// 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);
@ -1722,6 +1988,7 @@ export function beginCivTurn(rules, state, civIdx) {
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.
for (const unit of civUnits(state, civIdx)) {
@ -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 });

View File

@ -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));
}

View File

@ -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,

View File

@ -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}`);

View File

@ -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.',

View File

@ -260,7 +260,58 @@ Government frames match `governments[].frame` in the rules JSON (1015).
---
## 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

View File

@ -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)');