fertig-classic-games/docs/civilization-barbarians-pla...

513 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.