513 lines
24 KiB
Markdown
513 lines
24 KiB
Markdown
# Civilization — Barbarians build plan
|
||
|
||
Roaming hostile raiders for the early game: an opponent that cannot be bargained
|
||
with, that forces both the human and the AI to garrison cities and spend shields
|
||
on defense before they'd like to. Fades out as the world modernises.
|
||
|
||
Scope decisions (settled with the owner, 2026-08-01):
|
||
|
||
- **No sea raiders.** Land uprisings only. No barbarian galleys, no amphibious
|
||
landings, no naval pathing. If it's ever wanted it's a clean phase 2.
|
||
- **Barbarian Leader + ransom is in**, including a "draw out the leader" hook so
|
||
killing hordes is what summons one.
|
||
- **Activity folds into difficulty.** No setup-screen dropdown.
|
||
- **Pressure decays over the game** and hard-stops before modern units.
|
||
|
||
---
|
||
|
||
## 1. Architecture: barbarians are a civ
|
||
|
||
`state.civs` already drives everything generic in this engine —
|
||
`beginCivTurn`/`endCivTurn` cycle by index, `runToHumanTurn`'s `stepCiv`
|
||
animates any civ's moves and combats, `resolveAttack` / `captureCity` /
|
||
`pickDefender` / fog / `isUnitVisibleTo` are all civ-index based, and `drawUnit`
|
||
identifies owners by a colored ring off `civ.color`.
|
||
|
||
So barbarians are **a real civ appended at the end of `state.civs`**, with
|
||
`civ.barbarian === true`, and `state.barbarianIndex` recording its index.
|
||
|
||
Why appended last rather than inserted:
|
||
|
||
- Every existing civ index stays valid, including `humanIndex`.
|
||
- `endCivTurn`'s wrap (`if (next === 0) state.turn += 1`) still increments the
|
||
game turn in the right place — barbarians move last in the round.
|
||
- Old saves have `state.barbarianIndex === undefined` → no barbarians, no
|
||
migration step, and `deserialize`'s `version !== 1` gate stays untouched.
|
||
|
||
The barbarian civ is built by a `createBarbarianCiv()` in the new module and
|
||
pushed by `createGame` after the normal civ loop, only when
|
||
`rules.barbarians.enabled && difficulty.barbarianActivity > 0`. It gets:
|
||
|
||
```
|
||
{ id: n, barbarian: true, human: false, alive: true, name: 'Barbarians',
|
||
color: rules.barbarianColor /* '#3f1d1d' — outside playerColors */,
|
||
government: 'despotism', gold: 0, known: {}, futureCount: 0,
|
||
relations: { every other civ: 'war' }, attitude: { …: -100 },
|
||
spaceship: {…zeroes}, nameCursor: 0, nameOrder: [], score: 0,
|
||
barbKills: {}, // per-civ tally driving leader summons
|
||
leaderUnitId: null, leaderExpires: 0, nextSpawnTurn: {} }
|
||
```
|
||
|
||
It also gets its own `state.explored` array (pushed for shape parity — every
|
||
`explored[civ]` consumer indexes by civ id). It is never written to; barbarians
|
||
see the world by fiat inside their own AI.
|
||
|
||
`civ.alive` stays `true` forever, even at zero units. Uprisings are events, not
|
||
a standing empire, so "no units" is the normal state between raids.
|
||
|
||
### 1.1 Guard sites — every place that assumes "civ = diplomatic player"
|
||
|
||
Found by grepping `state.civs` across all 13 files. Each needs a barbarian skip:
|
||
|
||
| File / function | Why |
|
||
| --- | --- |
|
||
| `Logic.checkVictory` | `living.length === 1` would never fire → **conquest victory becomes unreachable**. Also its "no cities + no settlers → `eliminateCiv`" rule would kill the barbarian civ the moment its last raider dies. Both need the skip. |
|
||
| `Logic.contactSweep`, `Logic.makeContacts` | No contact, ever. Relations are pinned `'war'`. |
|
||
| `Logic.updateAttitudes` | **See §1.2 — this one is a trap.** |
|
||
| `Logic.civScore` / `Screens.js:765` scores table | Barbarians don't score and don't appear in rankings. |
|
||
| `Screens.js:147` (diplomacy contacts), `:719` (rivals) | Not a negotiating partner. |
|
||
| `Diplomacy.js:151,163` (request targets) | Nobody demands you join a war against barbarians. |
|
||
| `AI.computeStrategy:59` `atWarWith` | **See §1.3 — the other trap.** |
|
||
| `AI.doDiplomacy:86` | Loops living civs, and `rel === 'war'` would make every AI spam `proposeOrQueue(ceasefire)` at the barbarians forever. |
|
||
| `AI.sharedEnemy:163`, `AI:493` | Barbarians must not count as a shared enemy or a "hostile neighbour nearby". |
|
||
| `Logic.beginCivTurn` | Skip `progressRevolution` / `updateAttitudes` / `processCity` / `progressResearch`, and skip the `!civ.researching` auto-assign block (barbarians would otherwise pick a research target every turn). Keep the unit MP reset and healing — barbarians want those. |
|
||
|
||
### 1.2 Trap: `updateAttitudes` shared-enemy bonus
|
||
|
||
`updateAttitudes` awards `baseline += 20` for every third civ that both parties
|
||
are at war with. Since *every* civ is permanently at war with the barbarians,
|
||
adding them naively gives **every AI pair a permanent +20 friendship bonus**,
|
||
plus a `-40` war-baseline penalty applied against the barbarians themselves.
|
||
|
||
Diplomacy would drift toward everyone liking everyone, quietly gutting the war
|
||
paths that took three rounds of tuning to get right (see the frustration/grudge
|
||
work in `CivilizationDiplomacy.js`).
|
||
|
||
Fix: barbarians are skipped in **both** the `other` loop and the inner `third`
|
||
loop. Verify pins this with a fixture asserting the attitude delta between two
|
||
neutral AI civs is unchanged with barbarians present vs absent.
|
||
|
||
### 1.3 Trap: `atWarWith` and AI war posture
|
||
|
||
`computeStrategy` sets `phase = 'war'` whenever `atWarWith.length` is non-empty.
|
||
With barbarians as a war relation, **every AI sits in war phase for the entire
|
||
game**: `wantGarrison` jumps to 2 permanently, `manageCityBuild` never reaches
|
||
the expand/space branches, and `stepMilitary`'s peacetime scouting slice
|
||
(`unit.id % 3 === 0 && strategy.atWarWith.length === 0`) never runs, so AIs stop
|
||
exploring and stop popping huts.
|
||
|
||
Fix: barbarians are excluded from `atWarWith`, and `computeStrategy` gains a
|
||
separate signal:
|
||
|
||
```js
|
||
barbarianThreat: 0 | 1 | 2 // 0 none, 1 raiders in the region, 2 adjacent to a city
|
||
```
|
||
|
||
Consumers:
|
||
|
||
- `manageCityBuild` — `wantGarrison = phase === 'war' ? 2 : (barbarianThreat >= 2 ? 2 : 1)`.
|
||
The existing emergency-buy check already reads `relations[u.civ] === 'war'`,
|
||
so barbarians at the gate trigger a rush-buy **for free**.
|
||
- `stepMilitary` — the adjacent-attack loop also already filters on
|
||
`civ.relations[u.civ] === 'war'`, so AIs attack adjacent barbarians for free.
|
||
What is *not* free: the "march on the weakest reachable enemy city" block
|
||
filters on `strategy.atWarWith`, so a **barbarian-held city would never be
|
||
retaken**. Add barbarian-held cities to that target list explicitly.
|
||
- Peacetime scouting stays gated on `atWarWith` only, so exploration survives.
|
||
|
||
### 1.4 Trap: `atPeace` and the government ladder — found during the build
|
||
|
||
Not predicted in review; caught by the step-2 no-op checkpoint exactly as
|
||
intended. `CivilizationAI.js`'s
|
||
|
||
```js
|
||
function atPeace(state, civIdx) {
|
||
return !Object.values(state.civs[civIdx].relations).includes('war');
|
||
}
|
||
```
|
||
|
||
reads the **raw relations map**, so the permanent barbarian `'war'` entry makes
|
||
every civ eternally at war. `doGovernment` gates Republic and Democracy on
|
||
`atPeace` and Communism on `!atPeace`, so with barbarians on, **no AI would ever
|
||
leave Monarchy for a trade government** — they'd all beeline Communism. Silent,
|
||
game-wide, and invisible in any single-turn fixture.
|
||
|
||
Fixed by iterating civs and skipping barbarians rather than reading the map
|
||
wholesale. The general lesson: **any aggregate over `civ.relations` is suspect**
|
||
— a per-pair lookup is fine, `Object.values(relations)` is not. A grep for
|
||
`Object.values/entries/keys(...relations)` found this as the only instance in
|
||
the game code (two more live in verify's own invariant checks, which iterate
|
||
pairs and are unaffected).
|
||
|
||
### 1.5 Trap: barbarians looting huts — found during the build
|
||
|
||
`resolveHut` fires for whoever steps on the hut, so raiders were popping them:
|
||
free units **outside the population cap** (the cap probe caught a stack of 5
|
||
against a cap of 4), plus gold and free ancient techs. Barbarians now never pop
|
||
huts — flavour-correct (they're who lives there) and the hut stays standing for
|
||
a real civ to find.
|
||
|
||
### 1.6 Trap: `eliminateCiv` killing the barbarian faction — found during the build
|
||
|
||
`checkVictory` skips barbarians, but `captureCity` *also* calls `eliminateCiv`
|
||
whenever the loser's last city changes hands. So retaking the one city
|
||
barbarians had seized killed the whole faction: `alive = false`, every raider
|
||
deleted, and — because `runAITurn` early-returns on dead civs — **no further
|
||
barbarian activity for the rest of the game**, silently. It surfaced only as an
|
||
"unit of dead civ" invariant break in 3 of 30 soak games, once a hut later
|
||
unleashed raiders belonging to the dead faction.
|
||
|
||
Guarded at the single choke point: `eliminateCiv` refuses to kill a barbarian
|
||
civ, so both callers are covered.
|
||
|
||
### 1.7 `civPower`
|
||
|
||
`civPower` sums unit attack+defense and city size. It's only called per index,
|
||
and once barbarians are out of `doDiplomacy` and `atWarWith` nothing computes a
|
||
power ratio against them — no change needed. Noted here so a future reader
|
||
doesn't "fix" it.
|
||
|
||
---
|
||
|
||
## 2. Spawning
|
||
|
||
### 2.1 Pressure curve (the fade-out)
|
||
|
||
Pressure is computed **per target civ, from that civ's own era**, so a runaway
|
||
leader stops being raided while a laggard still gets harassed — the classic Civ
|
||
behaviour, and it keeps barbarians from being pure noise late.
|
||
|
||
New shared helper in Logic (useful beyond this feature):
|
||
|
||
```js
|
||
export function civEra(rules, civ) // most advanced era with >= 3 known techs of that era
|
||
```
|
||
|
||
Eras in `civilization-rules.json` are `ancient → medieval → industrial → modern`
|
||
(24 / 19 / 22 / 18 techs).
|
||
|
||
```json
|
||
"barbarians": {
|
||
"enabled": true,
|
||
"firstTurn": 12,
|
||
"spawnIntervalBase": 9,
|
||
"eraPressure": { "ancient": 1.0, "medieval": 0.55, "industrial": 0.15, "modern": 0 },
|
||
"hardStopTechs": ["industrialization", "conscription", "railroad"],
|
||
…
|
||
}
|
||
```
|
||
|
||
A civ stops being targeted entirely once it knows **any** `hardStopTech` — i.e.
|
||
the moment it's building Riflemen/Cannon-era units, uprisings against it end.
|
||
`eraPressure.modern = 0` is a belt-and-braces second stop.
|
||
|
||
Effective interval for civ *c*:
|
||
|
||
```
|
||
interval = spawnIntervalBase / (eraPressure[civEra(c)] * difficulty.barbarianActivity)
|
||
```
|
||
|
||
so Prince/ancient ≈ every 9 turns, Prince/medieval ≈ every 16, Prince/industrial
|
||
≈ every 60 (i.e. usually never before the hard stop lands). Each civ carries its
|
||
own `nextSpawnTurn[civId]` cursor on the barbarian civ.
|
||
|
||
Nothing spawns before `firstTurn` (12) — the opening land-grab stays clean.
|
||
|
||
### 2.2 Where and what
|
||
|
||
An uprising picks a wilderness tile 4–7 tiles from one of the target's cities:
|
||
land, not water, no city on it, outside the target's current vision set (arrives
|
||
as a surprise, but adjacent-explored so it isn't literally invisible), and not
|
||
inside another civ's city radius.
|
||
|
||
Stack size `2 + randInt(3)`, scaled up by `barbarianActivity`.
|
||
|
||
Unit types come from the **target's** era, so barbarians never hand out free XP
|
||
and never fight with sticks against Musketeers:
|
||
|
||
```json
|
||
"unitsByEra": {
|
||
"ancient": ["warriors", "horsemen", "archers"],
|
||
"medieval": ["legion", "archers", "knights", "catapult"],
|
||
"industrial": ["musketeers", "dragoons", "cannon"],
|
||
"modern": ["riflemen", "cavalry", "artillery"]
|
||
}
|
||
```
|
||
|
||
Barbarian units spawn with `vet: false` and no veteran roll — they're a
|
||
sustained tax, not an alpha strike.
|
||
|
||
### 2.3 Hard cap (non-negotiable)
|
||
|
||
```
|
||
cap = min(capMax, capBase + floor(turn * capPerTurn)) * barbarianActivity
|
||
```
|
||
|
||
with `capBase: 3, capPerTurn: 0.05, capMax: 12`. Total living barbarian units
|
||
across the whole map. Without this they snowball into a fourth player and the
|
||
30-game soak goes sideways. Barbarians also **never found cities and never
|
||
research** — the cap plus zero growth is what keeps them an event rather than an
|
||
empire.
|
||
|
||
### 2.4 Huts
|
||
|
||
`resolveHut`'s ambush branch (`roll >= 0.85`) currently fights a *phantom*
|
||
unit that never enters `state.units`. Upgrade it: when barbarians are enabled,
|
||
spawn a real 2–3 unit barbarian stack on the hut tile instead, then resolve the
|
||
attacker's fight against the top defender normally. ~10 lines, and it's the most
|
||
authentic Civ II barbarian moment in the whole feature. Falls back to the
|
||
existing phantom path when barbarians are disabled (old saves, Chieftain if we
|
||
ever zero it out).
|
||
|
||
---
|
||
|
||
## 3. Barbarian AI (`CivilizationBarbarians.js`, new 14th file)
|
||
|
||
Headless, no Phaser, mirroring `CivilizationDiplomacy.js`'s shape. Exports:
|
||
|
||
- `createBarbarianCiv(rules, state)`
|
||
- `maybeSpawnUprisings(rules, state)` — called from `beginCivTurn` for the barbarian civ
|
||
- `runBarbarianTurn(rules, state)` — the `runAITurn` equivalent
|
||
- `barbarianThreatFor(rules, state, civIdx)` — the 0/1/2 signal for `computeStrategy`
|
||
- `tryRansom(rules, state, unit, leader)` — shared by human moves and AI
|
||
|
||
Per-unit behaviour is deliberately dumber than `stepMilitary`:
|
||
|
||
1. Adjacent enemy unit or city with acceptable odds → attack (reuse `tryMove`,
|
||
which already routes to `resolveAttack` / `captureCity`).
|
||
2. Otherwise march at the nearest non-barbarian city via `moveToward`.
|
||
`moveToward` pathfinds once per decision and walks the whole path — the
|
||
established perf pattern; re-planning per tile is what made AI turns slow.
|
||
3. Nothing reachable → wander one tile randomly.
|
||
|
||
They never fortify, never heal in place, never retreat. A stack that loses its
|
||
war gets ground down, which is the point.
|
||
|
||
**Captured cities.** Size 1 is razed (`destroyCity`); size 2+ is captured via
|
||
the existing `captureCity` and held. A barbarian city does not grow, does not
|
||
build, and does not research — it just holds whatever garrison walked in.
|
||
Retaking it is normal conquest, and §1.3 makes sure the AI actually tries.
|
||
|
||
---
|
||
|
||
## 4. The Barbarian Leader
|
||
|
||
### 4.1 Drawing one out
|
||
|
||
The barbarian civ tallies `barbKills[civId]` — barbarian units that civ has
|
||
destroyed. Once the tally crosses `leader.killsToSummon` (6, scaled down by
|
||
`barbarianActivity` on higher difficulties), the *next* uprising against that
|
||
civ is promoted to a **Horde**: `escortMin`–`escortMax` (3–5) units escorting
|
||
one `barbarianleader`, and the tally resets.
|
||
|
||
This makes the mechanic self-reinforcing in the right direction — the player who
|
||
successfully farms raiders is the one who gets the big prize dangled in front of
|
||
them, and the player who's already drowning doesn't get a horde on top of it.
|
||
|
||
### 4.2 The unit
|
||
|
||
```json
|
||
{ "id": "barbarianleader", "name": "Barbarian Leader", "domain": "land",
|
||
"attack": 0, "defense": 0, "move": 2, "hp": 10, "fp": 1, "cost": 0,
|
||
"prereq": null, "obsoletedBy": null, "flags": ["noncombat", "leader"],
|
||
"abbr": "BL", "frame": 0, "sheet": "barbarians" }
|
||
```
|
||
|
||
`defense: 0` means it can never be the picked defender while an escort lives —
|
||
`pickDefender` sorts by `defenderStrength`, and the escort always outranks it.
|
||
Alone, it doesn't fight at all; it's captured (§4.4).
|
||
|
||
Its own turn behaviour (`stepLeader`) is **flee**: move to the adjacent tile
|
||
that maximises distance from the nearest non-barbarian combat unit, preferring
|
||
to stay within 2 of its escort if any survive. Cornered with nowhere better, it
|
||
sits still and waits to be captured.
|
||
|
||
`leaderExpires = turn + leader.lifetime` (20). On expiry it despawns with a
|
||
"the warlord slipped away into the hills" notice — that deadline is what turns
|
||
a leader sighting into a decision (chase it or hold the line) instead of a
|
||
free bag of gold you collect whenever.
|
||
|
||
### 4.3 Telling the player one is out there
|
||
|
||
Three layers, because a single popup gets dismissed and forgotten:
|
||
|
||
1. **Sighting popup.** On horde spawn, the target's `explored` is set for the
|
||
leader's tile and `announceStatus` fires:
|
||
*"A Barbarian Warlord has been sighted near Stonehaven!"* — using the
|
||
existing 4th `extra: { label, onClick }` param (added for `buildingDone`)
|
||
with a **SHOW ME** button that pans the camera to the tile. That param
|
||
exists precisely for "announce + jump somewhere", so no new plumbing.
|
||
2. **Persistent HUD marker.** While a leader lives, a small banner sits in the
|
||
HUD: `⚑ WARLORD AT LARGE — 14 turns`, counting down `leaderExpires`.
|
||
Clicking it pans to the leader's **last known position** — updated every turn
|
||
the leader is genuinely visible to the human, and left stale (drawn dimmed)
|
||
when it isn't. So it goes dark when the leader escapes your vision, and you
|
||
hunt from the last sighting rather than getting a free tracker.
|
||
3. **Map + minimap marker.** A pulsing gold chevron above the leader's ring in
|
||
`MapView.drawUnit`, plus a gold dot on the minimap at the last known tile.
|
||
|
||
Ransom collected, leader escaped, and leader killed in the crossfire each get
|
||
their own `announceStatus` line so the arc always closes.
|
||
|
||
### 4.4 Ransom
|
||
|
||
Moving a unit onto a tile holding **only** a barbarian leader captures it
|
||
instead of fighting. `tryMove` grows a leader branch ahead of its combat branch,
|
||
returning `result: 'ransom'`.
|
||
|
||
```
|
||
ransom = (leader.ransomBase + leader.ransomPerEra * eraIndex(captor))
|
||
* difficulty.barbarianActivity
|
||
```
|
||
|
||
100g in the ancient era on Prince, scaling to ~250g by industrial and higher on
|
||
Emperor — so the same difficulty knob that makes barbarians nastier makes the
|
||
prize bigger. The captor's civ gets the gold, the leader is removed, an event
|
||
`{ type: 'ransom', civ, gold, x, y }` fires, and the human gets a popup with the
|
||
gold amount.
|
||
|
||
If the leader is stacked with escorts, combat proceeds normally and the leader
|
||
dies with the last defender — no ransom. Killing the escort and *then* stepping
|
||
on the leader is the intended play, and it's why the leader flees.
|
||
|
||
AI civs collect too: `stepMilitary` gains an adjacent-lone-leader check ahead of
|
||
its attack loop. Otherwise the human gets every ransom in the game, which is
|
||
both unfair and boring.
|
||
|
||
---
|
||
|
||
## 5. Presentation
|
||
|
||
- **Colour.** `rules.barbarianColor = '#3f1d1d'` (dark crimson), deliberately
|
||
outside `playerColors` so no real civ can collide with it. The existing ring
|
||
under each unit in `drawUnit` carries it, so barbarians are visually distinct
|
||
**even with no new art at all**.
|
||
- **Art (optional but wanted).** A parallel `civilization-barbarians.png` sheet
|
||
selected by a `sheet` field on the unit def, rather than spending the 5 free
|
||
frames (51–55) on the shared `civilization-units` sheet — 5 won't be enough
|
||
once the leader and per-era raider variants are in. `MapView.drawUnit` picks
|
||
`def.sheet ? 'civilization-' + def.sheet : 'civilization-units'` and falls
|
||
back to the shared sheet when the texture is missing, so **the feature ships
|
||
and plays correctly before any art exists**. Spec goes in `sprites.md` as
|
||
section 7.
|
||
- Suggested frames: raider (ancient), horseman, archer, medieval marauder,
|
||
horde rider, musket raider, artillery crew, **barbarian leader**.
|
||
- **Tooltips.** `CivilizationTooltips.js` gains barbarian phrasing — no
|
||
diplomacy line, no attitude, no "at war since"; the leader tooltip states the
|
||
ransom value and turns remaining.
|
||
|
||
---
|
||
|
||
## 6. Difficulty wiring
|
||
|
||
One new field per entry in `rules.difficulties`:
|
||
|
||
| Difficulty | `barbarianActivity` |
|
||
| --- | --- |
|
||
| Chieftain | 0.35 |
|
||
| Warlord | 0.60 |
|
||
| Prince | 1.00 |
|
||
| King | 1.40 |
|
||
| Emperor | 1.80 |
|
||
|
||
It scales, in one place each: spawn interval (inversely), stack size, the unit
|
||
cap, `killsToSummon` (inversely — hordes come sooner on hard), and the ransom
|
||
payout. `0` disables barbarians entirely, which is what the barbarian civ's
|
||
creation gate reads, so a future "Barbarians: off" toggle is a one-line change.
|
||
|
||
---
|
||
|
||
## 7. Files touched
|
||
|
||
| File | Change |
|
||
| --- | --- |
|
||
| `data/civilization-rules.json` | `barbarians` block, `barbarianColor`, `barbarianleader` unit, `barbarianActivity` × 5 difficulties |
|
||
| `CivilizationRules.js` | `compileRules` validation: unit ids in `unitsByEra` exist, era keys match `techs[].era`, `hardStopTechs` exist, caps sane |
|
||
| `CivilizationLogic.js` | barbarian civ in `createGame`; `isBarbarian` + `civEra` helpers; guards per §1.1; `tryMove` ransom branch; `resolveHut` real-ambush |
|
||
| **`CivilizationBarbarians.js`** | **new** — spawn, turn, leader, ransom, threat signal |
|
||
| `CivilizationAI.js` | `atWarWith` exclusion + `barbarianThreat`; `doDiplomacy` skip; barbarian cities as retake targets; AI ransom capture |
|
||
| `CivilizationGame.js` | barbarian civ in `stepCiv`; sighting popup; HUD warlord banner; ransom/escape/raze notices |
|
||
| `CivilizationMapView.js` | barbarian sheet lookup + fallback; leader chevron; minimap marker |
|
||
| `CivilizationScreens.js` | filter barbarians from contacts, rivals, scores |
|
||
| `CivilizationDiplomacy.js` | filter barbarians from request targets |
|
||
| `CivilizationTooltips.js` | barbarian + leader tooltips |
|
||
| `tools/verifyCivilization.js` | new section 9 (below) |
|
||
| `sprites.md` | §7 barbarian sheet spec |
|
||
|
||
---
|
||
|
||
## 8. Verification
|
||
|
||
New **section 9** in `tools/verifyCivilization.js`:
|
||
|
||
- **Rules integrity** — every id in `unitsByEra`/`hardStopTechs` resolves; era
|
||
keys cover every era present in `techs`; `barbarianActivity` on all 5
|
||
difficulties.
|
||
- **Save compat** — a state built without `barbarianIndex` (the pre-feature
|
||
shape) runs a full turn through every touched function without throwing.
|
||
Same trick section 6b uses, where the old `mkCiv` shape doubles as a
|
||
compat test.
|
||
- **Isolation fixtures** — the two traps get pinned directly:
|
||
- attitude delta between two neutral AI civs is **identical** with barbarians
|
||
present and absent (§1.2);
|
||
- `computeStrategy(...).phase` for a peaceful AI with barbarians on the map is
|
||
still `expand`/`develop`, never `war` (§1.3).
|
||
- **Curve** — spawn interval monotonically increases with era; zero uprisings
|
||
after any `hardStopTech`; unit cap never exceeded across a 200-turn headless
|
||
run at Emperor.
|
||
- **Leader arc** — kills accumulate → horde spawns with an escort → leader flees
|
||
from an approaching unit → lone leader is ransomed for the era-correct gold →
|
||
tally reset; and separately, an unclaimed leader despawns exactly at
|
||
`leaderExpires`.
|
||
- **City capture** — size-1 razed, size-2 held, barbarian city neither grows nor
|
||
builds across 20 turns, and an AI at peace actually marches on it.
|
||
|
||
Then the standing **30-game soak at turnCap 1000** is re-run and compared
|
||
against the baseline.
|
||
|
||
**Results as built** (baseline → with barbarians):
|
||
|
||
| | baseline | with barbarians |
|
||
|---|---|---|
|
||
| games decided | 15/30 | 20/30 |
|
||
| conquest / spaceship | 9 / 6 | 16 / 4 |
|
||
| avg AI turn | 4.76ms | 2.27ms* |
|
||
| total checks | 1258 | 1346 |
|
||
|
||
\* lower only because the barbarians' own (cheap) turn is counted in the
|
||
average; per-real-civ cost is unchanged.
|
||
|
||
One check fails, `space flight window turn 150-600` — it **fails identically on
|
||
`main`** (median 836 there, 864 here), so it is pre-existing and unrelated.
|
||
|
||
**Tuning note.** `spawnIntervalBase`/`capBase` started at 9/3 and the world
|
||
tipped almost entirely to conquest (20 conquest vs **1** spaceship win), because
|
||
razed cities cripple civs into easy targets for each other. 14/2 keeps the
|
||
pressure — 900+ uprisings and 200 leader sightings across the suite — while
|
||
leaving the spaceship path alive. Raising them again will collapse the victory
|
||
mix; the numbers are recorded in the rules JSON readme too.
|
||
|
||
The escalation arc in section 6b strips the barbarian civ: it needs a stable
|
||
~90-turn runway for a grudge to build through refusals, and raids end the game
|
||
in conquest first. Confirmed the escalation itself still works — the game is
|
||
simply over — so this is a fixture runway issue, not a diplomacy regression.
|
||
|
||
Per the owner's standing preference, **no browser verification** — engine and
|
||
Node checks only; the browser pass is theirs.
|
||
|
||
---
|
||
|
||
## 9. Build order
|
||
|
||
1. Rules data + `compileRules` validation + `sprites.md` spec.
|
||
2. Barbarian civ in `createGame`, the §1.1 guards, `civEra`. **Ship this alone
|
||
and confirm the soak is unchanged** — a barbarian civ that spawns nothing
|
||
must be a perfect no-op. This is the checkpoint that catches §1.2/§1.3.
|
||
3. `CivilizationBarbarians.js`: spawning, cap, curve, dumb march-and-attack.
|
||
4. AI response: threat signal, garrison priority, retake barbarian cities.
|
||
5. Leader: summon tally, flee behaviour, expiry.
|
||
6. Ransom: `tryMove` branch, AI capture, gold scaling.
|
||
7. Presentation: colour, sheet lookup + fallback, chevron, popups, HUD banner.
|
||
8. Huts spawn real barbarians.
|
||
9. Verify section 9, then the 30-game soak and seed re-pick.
|