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

24 KiB
Raw Blame History

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:

barbarianThreat: 0 | 1 | 2   // 0 none, 1 raiders in the region, 2 adjacent to a city

Consumers:

  • manageCityBuildwantGarrison = 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

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):

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

"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:

"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: escortMinescortMax (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

{ "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.