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:
parent
d5f5e8981a
commit
390dadbc14
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,512 @@
|
|||
# Civilization — Barbarians build plan
|
||||
|
||||
Roaming hostile raiders for the early game: an opponent that cannot be bargained
|
||||
with, that forces both the human and the AI to garrison cities and spend shields
|
||||
on defense before they'd like to. Fades out as the world modernises.
|
||||
|
||||
Scope decisions (settled with the owner, 2026-08-01):
|
||||
|
||||
- **No sea raiders.** Land uprisings only. No barbarian galleys, no amphibious
|
||||
landings, no naval pathing. If it's ever wanted it's a clean phase 2.
|
||||
- **Barbarian Leader + ransom is in**, including a "draw out the leader" hook so
|
||||
killing hordes is what summons one.
|
||||
- **Activity folds into difficulty.** No setup-screen dropdown.
|
||||
- **Pressure decays over the game** and hard-stops before modern units.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture: barbarians are a civ
|
||||
|
||||
`state.civs` already drives everything generic in this engine —
|
||||
`beginCivTurn`/`endCivTurn` cycle by index, `runToHumanTurn`'s `stepCiv`
|
||||
animates any civ's moves and combats, `resolveAttack` / `captureCity` /
|
||||
`pickDefender` / fog / `isUnitVisibleTo` are all civ-index based, and `drawUnit`
|
||||
identifies owners by a colored ring off `civ.color`.
|
||||
|
||||
So barbarians are **a real civ appended at the end of `state.civs`**, with
|
||||
`civ.barbarian === true`, and `state.barbarianIndex` recording its index.
|
||||
|
||||
Why appended last rather than inserted:
|
||||
|
||||
- Every existing civ index stays valid, including `humanIndex`.
|
||||
- `endCivTurn`'s wrap (`if (next === 0) state.turn += 1`) still increments the
|
||||
game turn in the right place — barbarians move last in the round.
|
||||
- Old saves have `state.barbarianIndex === undefined` → no barbarians, no
|
||||
migration step, and `deserialize`'s `version !== 1` gate stays untouched.
|
||||
|
||||
The barbarian civ is built by a `createBarbarianCiv()` in the new module and
|
||||
pushed by `createGame` after the normal civ loop, only when
|
||||
`rules.barbarians.enabled && difficulty.barbarianActivity > 0`. It gets:
|
||||
|
||||
```
|
||||
{ id: n, barbarian: true, human: false, alive: true, name: 'Barbarians',
|
||||
color: rules.barbarianColor /* '#3f1d1d' — outside playerColors */,
|
||||
government: 'despotism', gold: 0, known: {}, futureCount: 0,
|
||||
relations: { every other civ: 'war' }, attitude: { …: -100 },
|
||||
spaceship: {…zeroes}, nameCursor: 0, nameOrder: [], score: 0,
|
||||
barbKills: {}, // per-civ tally driving leader summons
|
||||
leaderUnitId: null, leaderExpires: 0, nextSpawnTurn: {} }
|
||||
```
|
||||
|
||||
It also gets its own `state.explored` array (pushed for shape parity — every
|
||||
`explored[civ]` consumer indexes by civ id). It is never written to; barbarians
|
||||
see the world by fiat inside their own AI.
|
||||
|
||||
`civ.alive` stays `true` forever, even at zero units. Uprisings are events, not
|
||||
a standing empire, so "no units" is the normal state between raids.
|
||||
|
||||
### 1.1 Guard sites — every place that assumes "civ = diplomatic player"
|
||||
|
||||
Found by grepping `state.civs` across all 13 files. Each needs a barbarian skip:
|
||||
|
||||
| File / function | Why |
|
||||
| --- | --- |
|
||||
| `Logic.checkVictory` | `living.length === 1` would never fire → **conquest victory becomes unreachable**. Also its "no cities + no settlers → `eliminateCiv`" rule would kill the barbarian civ the moment its last raider dies. Both need the skip. |
|
||||
| `Logic.contactSweep`, `Logic.makeContacts` | No contact, ever. Relations are pinned `'war'`. |
|
||||
| `Logic.updateAttitudes` | **See §1.2 — this one is a trap.** |
|
||||
| `Logic.civScore` / `Screens.js:765` scores table | Barbarians don't score and don't appear in rankings. |
|
||||
| `Screens.js:147` (diplomacy contacts), `:719` (rivals) | Not a negotiating partner. |
|
||||
| `Diplomacy.js:151,163` (request targets) | Nobody demands you join a war against barbarians. |
|
||||
| `AI.computeStrategy:59` `atWarWith` | **See §1.3 — the other trap.** |
|
||||
| `AI.doDiplomacy:86` | Loops living civs, and `rel === 'war'` would make every AI spam `proposeOrQueue(ceasefire)` at the barbarians forever. |
|
||||
| `AI.sharedEnemy:163`, `AI:493` | Barbarians must not count as a shared enemy or a "hostile neighbour nearby". |
|
||||
| `Logic.beginCivTurn` | Skip `progressRevolution` / `updateAttitudes` / `processCity` / `progressResearch`, and skip the `!civ.researching` auto-assign block (barbarians would otherwise pick a research target every turn). Keep the unit MP reset and healing — barbarians want those. |
|
||||
|
||||
### 1.2 Trap: `updateAttitudes` shared-enemy bonus
|
||||
|
||||
`updateAttitudes` awards `baseline += 20` for every third civ that both parties
|
||||
are at war with. Since *every* civ is permanently at war with the barbarians,
|
||||
adding them naively gives **every AI pair a permanent +20 friendship bonus**,
|
||||
plus a `-40` war-baseline penalty applied against the barbarians themselves.
|
||||
|
||||
Diplomacy would drift toward everyone liking everyone, quietly gutting the war
|
||||
paths that took three rounds of tuning to get right (see the frustration/grudge
|
||||
work in `CivilizationDiplomacy.js`).
|
||||
|
||||
Fix: barbarians are skipped in **both** the `other` loop and the inner `third`
|
||||
loop. Verify pins this with a fixture asserting the attitude delta between two
|
||||
neutral AI civs is unchanged with barbarians present vs absent.
|
||||
|
||||
### 1.3 Trap: `atWarWith` and AI war posture
|
||||
|
||||
`computeStrategy` sets `phase = 'war'` whenever `atWarWith.length` is non-empty.
|
||||
With barbarians as a war relation, **every AI sits in war phase for the entire
|
||||
game**: `wantGarrison` jumps to 2 permanently, `manageCityBuild` never reaches
|
||||
the expand/space branches, and `stepMilitary`'s peacetime scouting slice
|
||||
(`unit.id % 3 === 0 && strategy.atWarWith.length === 0`) never runs, so AIs stop
|
||||
exploring and stop popping huts.
|
||||
|
||||
Fix: barbarians are excluded from `atWarWith`, and `computeStrategy` gains a
|
||||
separate signal:
|
||||
|
||||
```js
|
||||
barbarianThreat: 0 | 1 | 2 // 0 none, 1 raiders in the region, 2 adjacent to a city
|
||||
```
|
||||
|
||||
Consumers:
|
||||
|
||||
- `manageCityBuild` — `wantGarrison = phase === 'war' ? 2 : (barbarianThreat >= 2 ? 2 : 1)`.
|
||||
The existing emergency-buy check already reads `relations[u.civ] === 'war'`,
|
||||
so barbarians at the gate trigger a rush-buy **for free**.
|
||||
- `stepMilitary` — the adjacent-attack loop also already filters on
|
||||
`civ.relations[u.civ] === 'war'`, so AIs attack adjacent barbarians for free.
|
||||
What is *not* free: the "march on the weakest reachable enemy city" block
|
||||
filters on `strategy.atWarWith`, so a **barbarian-held city would never be
|
||||
retaken**. Add barbarian-held cities to that target list explicitly.
|
||||
- Peacetime scouting stays gated on `atWarWith` only, so exploration survives.
|
||||
|
||||
### 1.4 Trap: `atPeace` and the government ladder — found during the build
|
||||
|
||||
Not predicted in review; caught by the step-2 no-op checkpoint exactly as
|
||||
intended. `CivilizationAI.js`'s
|
||||
|
||||
```js
|
||||
function atPeace(state, civIdx) {
|
||||
return !Object.values(state.civs[civIdx].relations).includes('war');
|
||||
}
|
||||
```
|
||||
|
||||
reads the **raw relations map**, so the permanent barbarian `'war'` entry makes
|
||||
every civ eternally at war. `doGovernment` gates Republic and Democracy on
|
||||
`atPeace` and Communism on `!atPeace`, so with barbarians on, **no AI would ever
|
||||
leave Monarchy for a trade government** — they'd all beeline Communism. Silent,
|
||||
game-wide, and invisible in any single-turn fixture.
|
||||
|
||||
Fixed by iterating civs and skipping barbarians rather than reading the map
|
||||
wholesale. The general lesson: **any aggregate over `civ.relations` is suspect**
|
||||
— a per-pair lookup is fine, `Object.values(relations)` is not. A grep for
|
||||
`Object.values/entries/keys(...relations)` found this as the only instance in
|
||||
the game code (two more live in verify's own invariant checks, which iterate
|
||||
pairs and are unaffected).
|
||||
|
||||
### 1.5 Trap: barbarians looting huts — found during the build
|
||||
|
||||
`resolveHut` fires for whoever steps on the hut, so raiders were popping them:
|
||||
free units **outside the population cap** (the cap probe caught a stack of 5
|
||||
against a cap of 4), plus gold and free ancient techs. Barbarians now never pop
|
||||
huts — flavour-correct (they're who lives there) and the hut stays standing for
|
||||
a real civ to find.
|
||||
|
||||
### 1.6 Trap: `eliminateCiv` killing the barbarian faction — found during the build
|
||||
|
||||
`checkVictory` skips barbarians, but `captureCity` *also* calls `eliminateCiv`
|
||||
whenever the loser's last city changes hands. So retaking the one city
|
||||
barbarians had seized killed the whole faction: `alive = false`, every raider
|
||||
deleted, and — because `runAITurn` early-returns on dead civs — **no further
|
||||
barbarian activity for the rest of the game**, silently. It surfaced only as an
|
||||
"unit of dead civ" invariant break in 3 of 30 soak games, once a hut later
|
||||
unleashed raiders belonging to the dead faction.
|
||||
|
||||
Guarded at the single choke point: `eliminateCiv` refuses to kill a barbarian
|
||||
civ, so both callers are covered.
|
||||
|
||||
### 1.7 `civPower`
|
||||
|
||||
`civPower` sums unit attack+defense and city size. It's only called per index,
|
||||
and once barbarians are out of `doDiplomacy` and `atWarWith` nothing computes a
|
||||
power ratio against them — no change needed. Noted here so a future reader
|
||||
doesn't "fix" it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Spawning
|
||||
|
||||
### 2.1 Pressure curve (the fade-out)
|
||||
|
||||
Pressure is computed **per target civ, from that civ's own era**, so a runaway
|
||||
leader stops being raided while a laggard still gets harassed — the classic Civ
|
||||
behaviour, and it keeps barbarians from being pure noise late.
|
||||
|
||||
New shared helper in Logic (useful beyond this feature):
|
||||
|
||||
```js
|
||||
export function civEra(rules, civ) // most advanced era with >= 3 known techs of that era
|
||||
```
|
||||
|
||||
Eras in `civilization-rules.json` are `ancient → medieval → industrial → modern`
|
||||
(24 / 19 / 22 / 18 techs).
|
||||
|
||||
```json
|
||||
"barbarians": {
|
||||
"enabled": true,
|
||||
"firstTurn": 12,
|
||||
"spawnIntervalBase": 9,
|
||||
"eraPressure": { "ancient": 1.0, "medieval": 0.55, "industrial": 0.15, "modern": 0 },
|
||||
"hardStopTechs": ["industrialization", "conscription", "railroad"],
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
A civ stops being targeted entirely once it knows **any** `hardStopTech` — i.e.
|
||||
the moment it's building Riflemen/Cannon-era units, uprisings against it end.
|
||||
`eraPressure.modern = 0` is a belt-and-braces second stop.
|
||||
|
||||
Effective interval for civ *c*:
|
||||
|
||||
```
|
||||
interval = spawnIntervalBase / (eraPressure[civEra(c)] * difficulty.barbarianActivity)
|
||||
```
|
||||
|
||||
so Prince/ancient ≈ every 9 turns, Prince/medieval ≈ every 16, Prince/industrial
|
||||
≈ every 60 (i.e. usually never before the hard stop lands). Each civ carries its
|
||||
own `nextSpawnTurn[civId]` cursor on the barbarian civ.
|
||||
|
||||
Nothing spawns before `firstTurn` (12) — the opening land-grab stays clean.
|
||||
|
||||
### 2.2 Where and what
|
||||
|
||||
An uprising picks a wilderness tile 4–7 tiles from one of the target's cities:
|
||||
land, not water, no city on it, outside the target's current vision set (arrives
|
||||
as a surprise, but adjacent-explored so it isn't literally invisible), and not
|
||||
inside another civ's city radius.
|
||||
|
||||
Stack size `2 + randInt(3)`, scaled up by `barbarianActivity`.
|
||||
|
||||
Unit types come from the **target's** era, so barbarians never hand out free XP
|
||||
and never fight with sticks against Musketeers:
|
||||
|
||||
```json
|
||||
"unitsByEra": {
|
||||
"ancient": ["warriors", "horsemen", "archers"],
|
||||
"medieval": ["legion", "archers", "knights", "catapult"],
|
||||
"industrial": ["musketeers", "dragoons", "cannon"],
|
||||
"modern": ["riflemen", "cavalry", "artillery"]
|
||||
}
|
||||
```
|
||||
|
||||
Barbarian units spawn with `vet: false` and no veteran roll — they're a
|
||||
sustained tax, not an alpha strike.
|
||||
|
||||
### 2.3 Hard cap (non-negotiable)
|
||||
|
||||
```
|
||||
cap = min(capMax, capBase + floor(turn * capPerTurn)) * barbarianActivity
|
||||
```
|
||||
|
||||
with `capBase: 3, capPerTurn: 0.05, capMax: 12`. Total living barbarian units
|
||||
across the whole map. Without this they snowball into a fourth player and the
|
||||
30-game soak goes sideways. Barbarians also **never found cities and never
|
||||
research** — the cap plus zero growth is what keeps them an event rather than an
|
||||
empire.
|
||||
|
||||
### 2.4 Huts
|
||||
|
||||
`resolveHut`'s ambush branch (`roll >= 0.85`) currently fights a *phantom*
|
||||
unit that never enters `state.units`. Upgrade it: when barbarians are enabled,
|
||||
spawn a real 2–3 unit barbarian stack on the hut tile instead, then resolve the
|
||||
attacker's fight against the top defender normally. ~10 lines, and it's the most
|
||||
authentic Civ II barbarian moment in the whole feature. Falls back to the
|
||||
existing phantom path when barbarians are disabled (old saves, Chieftain if we
|
||||
ever zero it out).
|
||||
|
||||
---
|
||||
|
||||
## 3. Barbarian AI (`CivilizationBarbarians.js`, new 14th file)
|
||||
|
||||
Headless, no Phaser, mirroring `CivilizationDiplomacy.js`'s shape. Exports:
|
||||
|
||||
- `createBarbarianCiv(rules, state)`
|
||||
- `maybeSpawnUprisings(rules, state)` — called from `beginCivTurn` for the barbarian civ
|
||||
- `runBarbarianTurn(rules, state)` — the `runAITurn` equivalent
|
||||
- `barbarianThreatFor(rules, state, civIdx)` — the 0/1/2 signal for `computeStrategy`
|
||||
- `tryRansom(rules, state, unit, leader)` — shared by human moves and AI
|
||||
|
||||
Per-unit behaviour is deliberately dumber than `stepMilitary`:
|
||||
|
||||
1. Adjacent enemy unit or city with acceptable odds → attack (reuse `tryMove`,
|
||||
which already routes to `resolveAttack` / `captureCity`).
|
||||
2. Otherwise march at the nearest non-barbarian city via `moveToward`.
|
||||
`moveToward` pathfinds once per decision and walks the whole path — the
|
||||
established perf pattern; re-planning per tile is what made AI turns slow.
|
||||
3. Nothing reachable → wander one tile randomly.
|
||||
|
||||
They never fortify, never heal in place, never retreat. A stack that loses its
|
||||
war gets ground down, which is the point.
|
||||
|
||||
**Captured cities.** Size 1 is razed (`destroyCity`); size 2+ is captured via
|
||||
the existing `captureCity` and held. A barbarian city does not grow, does not
|
||||
build, and does not research — it just holds whatever garrison walked in.
|
||||
Retaking it is normal conquest, and §1.3 makes sure the AI actually tries.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Barbarian Leader
|
||||
|
||||
### 4.1 Drawing one out
|
||||
|
||||
The barbarian civ tallies `barbKills[civId]` — barbarian units that civ has
|
||||
destroyed. Once the tally crosses `leader.killsToSummon` (6, scaled down by
|
||||
`barbarianActivity` on higher difficulties), the *next* uprising against that
|
||||
civ is promoted to a **Horde**: `escortMin`–`escortMax` (3–5) units escorting
|
||||
one `barbarianleader`, and the tally resets.
|
||||
|
||||
This makes the mechanic self-reinforcing in the right direction — the player who
|
||||
successfully farms raiders is the one who gets the big prize dangled in front of
|
||||
them, and the player who's already drowning doesn't get a horde on top of it.
|
||||
|
||||
### 4.2 The unit
|
||||
|
||||
```json
|
||||
{ "id": "barbarianleader", "name": "Barbarian Leader", "domain": "land",
|
||||
"attack": 0, "defense": 0, "move": 2, "hp": 10, "fp": 1, "cost": 0,
|
||||
"prereq": null, "obsoletedBy": null, "flags": ["noncombat", "leader"],
|
||||
"abbr": "BL", "frame": 0, "sheet": "barbarians" }
|
||||
```
|
||||
|
||||
`defense: 0` means it can never be the picked defender while an escort lives —
|
||||
`pickDefender` sorts by `defenderStrength`, and the escort always outranks it.
|
||||
Alone, it doesn't fight at all; it's captured (§4.4).
|
||||
|
||||
Its own turn behaviour (`stepLeader`) is **flee**: move to the adjacent tile
|
||||
that maximises distance from the nearest non-barbarian combat unit, preferring
|
||||
to stay within 2 of its escort if any survive. Cornered with nowhere better, it
|
||||
sits still and waits to be captured.
|
||||
|
||||
`leaderExpires = turn + leader.lifetime` (20). On expiry it despawns with a
|
||||
"the warlord slipped away into the hills" notice — that deadline is what turns
|
||||
a leader sighting into a decision (chase it or hold the line) instead of a
|
||||
free bag of gold you collect whenever.
|
||||
|
||||
### 4.3 Telling the player one is out there
|
||||
|
||||
Three layers, because a single popup gets dismissed and forgotten:
|
||||
|
||||
1. **Sighting popup.** On horde spawn, the target's `explored` is set for the
|
||||
leader's tile and `announceStatus` fires:
|
||||
*"A Barbarian Warlord has been sighted near Stonehaven!"* — using the
|
||||
existing 4th `extra: { label, onClick }` param (added for `buildingDone`)
|
||||
with a **SHOW ME** button that pans the camera to the tile. That param
|
||||
exists precisely for "announce + jump somewhere", so no new plumbing.
|
||||
2. **Persistent HUD marker.** While a leader lives, a small banner sits in the
|
||||
HUD: `⚑ WARLORD AT LARGE — 14 turns`, counting down `leaderExpires`.
|
||||
Clicking it pans to the leader's **last known position** — updated every turn
|
||||
the leader is genuinely visible to the human, and left stale (drawn dimmed)
|
||||
when it isn't. So it goes dark when the leader escapes your vision, and you
|
||||
hunt from the last sighting rather than getting a free tracker.
|
||||
3. **Map + minimap marker.** A pulsing gold chevron above the leader's ring in
|
||||
`MapView.drawUnit`, plus a gold dot on the minimap at the last known tile.
|
||||
|
||||
Ransom collected, leader escaped, and leader killed in the crossfire each get
|
||||
their own `announceStatus` line so the arc always closes.
|
||||
|
||||
### 4.4 Ransom
|
||||
|
||||
Moving a unit onto a tile holding **only** a barbarian leader captures it
|
||||
instead of fighting. `tryMove` grows a leader branch ahead of its combat branch,
|
||||
returning `result: 'ransom'`.
|
||||
|
||||
```
|
||||
ransom = (leader.ransomBase + leader.ransomPerEra * eraIndex(captor))
|
||||
* difficulty.barbarianActivity
|
||||
```
|
||||
|
||||
100g in the ancient era on Prince, scaling to ~250g by industrial and higher on
|
||||
Emperor — so the same difficulty knob that makes barbarians nastier makes the
|
||||
prize bigger. The captor's civ gets the gold, the leader is removed, an event
|
||||
`{ type: 'ransom', civ, gold, x, y }` fires, and the human gets a popup with the
|
||||
gold amount.
|
||||
|
||||
If the leader is stacked with escorts, combat proceeds normally and the leader
|
||||
dies with the last defender — no ransom. Killing the escort and *then* stepping
|
||||
on the leader is the intended play, and it's why the leader flees.
|
||||
|
||||
AI civs collect too: `stepMilitary` gains an adjacent-lone-leader check ahead of
|
||||
its attack loop. Otherwise the human gets every ransom in the game, which is
|
||||
both unfair and boring.
|
||||
|
||||
---
|
||||
|
||||
## 5. Presentation
|
||||
|
||||
- **Colour.** `rules.barbarianColor = '#3f1d1d'` (dark crimson), deliberately
|
||||
outside `playerColors` so no real civ can collide with it. The existing ring
|
||||
under each unit in `drawUnit` carries it, so barbarians are visually distinct
|
||||
**even with no new art at all**.
|
||||
- **Art (optional but wanted).** A parallel `civilization-barbarians.png` sheet
|
||||
selected by a `sheet` field on the unit def, rather than spending the 5 free
|
||||
frames (51–55) on the shared `civilization-units` sheet — 5 won't be enough
|
||||
once the leader and per-era raider variants are in. `MapView.drawUnit` picks
|
||||
`def.sheet ? 'civilization-' + def.sheet : 'civilization-units'` and falls
|
||||
back to the shared sheet when the texture is missing, so **the feature ships
|
||||
and plays correctly before any art exists**. Spec goes in `sprites.md` as
|
||||
section 7.
|
||||
- Suggested frames: raider (ancient), horseman, archer, medieval marauder,
|
||||
horde rider, musket raider, artillery crew, **barbarian leader**.
|
||||
- **Tooltips.** `CivilizationTooltips.js` gains barbarian phrasing — no
|
||||
diplomacy line, no attitude, no "at war since"; the leader tooltip states the
|
||||
ransom value and turns remaining.
|
||||
|
||||
---
|
||||
|
||||
## 6. Difficulty wiring
|
||||
|
||||
One new field per entry in `rules.difficulties`:
|
||||
|
||||
| Difficulty | `barbarianActivity` |
|
||||
| --- | --- |
|
||||
| Chieftain | 0.35 |
|
||||
| Warlord | 0.60 |
|
||||
| Prince | 1.00 |
|
||||
| King | 1.40 |
|
||||
| Emperor | 1.80 |
|
||||
|
||||
It scales, in one place each: spawn interval (inversely), stack size, the unit
|
||||
cap, `killsToSummon` (inversely — hordes come sooner on hard), and the ransom
|
||||
payout. `0` disables barbarians entirely, which is what the barbarian civ's
|
||||
creation gate reads, so a future "Barbarians: off" toggle is a one-line change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `data/civilization-rules.json` | `barbarians` block, `barbarianColor`, `barbarianleader` unit, `barbarianActivity` × 5 difficulties |
|
||||
| `CivilizationRules.js` | `compileRules` validation: unit ids in `unitsByEra` exist, era keys match `techs[].era`, `hardStopTechs` exist, caps sane |
|
||||
| `CivilizationLogic.js` | barbarian civ in `createGame`; `isBarbarian` + `civEra` helpers; guards per §1.1; `tryMove` ransom branch; `resolveHut` real-ambush |
|
||||
| **`CivilizationBarbarians.js`** | **new** — spawn, turn, leader, ransom, threat signal |
|
||||
| `CivilizationAI.js` | `atWarWith` exclusion + `barbarianThreat`; `doDiplomacy` skip; barbarian cities as retake targets; AI ransom capture |
|
||||
| `CivilizationGame.js` | barbarian civ in `stepCiv`; sighting popup; HUD warlord banner; ransom/escape/raze notices |
|
||||
| `CivilizationMapView.js` | barbarian sheet lookup + fallback; leader chevron; minimap marker |
|
||||
| `CivilizationScreens.js` | filter barbarians from contacts, rivals, scores |
|
||||
| `CivilizationDiplomacy.js` | filter barbarians from request targets |
|
||||
| `CivilizationTooltips.js` | barbarian + leader tooltips |
|
||||
| `tools/verifyCivilization.js` | new section 9 (below) |
|
||||
| `sprites.md` | §7 barbarian sheet spec |
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification
|
||||
|
||||
New **section 9** in `tools/verifyCivilization.js`:
|
||||
|
||||
- **Rules integrity** — every id in `unitsByEra`/`hardStopTechs` resolves; era
|
||||
keys cover every era present in `techs`; `barbarianActivity` on all 5
|
||||
difficulties.
|
||||
- **Save compat** — a state built without `barbarianIndex` (the pre-feature
|
||||
shape) runs a full turn through every touched function without throwing.
|
||||
Same trick section 6b uses, where the old `mkCiv` shape doubles as a
|
||||
compat test.
|
||||
- **Isolation fixtures** — the two traps get pinned directly:
|
||||
- attitude delta between two neutral AI civs is **identical** with barbarians
|
||||
present and absent (§1.2);
|
||||
- `computeStrategy(...).phase` for a peaceful AI with barbarians on the map is
|
||||
still `expand`/`develop`, never `war` (§1.3).
|
||||
- **Curve** — spawn interval monotonically increases with era; zero uprisings
|
||||
after any `hardStopTech`; unit cap never exceeded across a 200-turn headless
|
||||
run at Emperor.
|
||||
- **Leader arc** — kills accumulate → horde spawns with an escort → leader flees
|
||||
from an approaching unit → lone leader is ransomed for the era-correct gold →
|
||||
tally reset; and separately, an unclaimed leader despawns exactly at
|
||||
`leaderExpires`.
|
||||
- **City capture** — size-1 razed, size-2 held, barbarian city neither grows nor
|
||||
builds across 20 turns, and an AI at peace actually marches on it.
|
||||
|
||||
Then the standing **30-game soak at turnCap 1000** is re-run and compared
|
||||
against the baseline.
|
||||
|
||||
**Results as built** (baseline → with barbarians):
|
||||
|
||||
| | baseline | with barbarians |
|
||||
|---|---|---|
|
||||
| games decided | 15/30 | 20/30 |
|
||||
| conquest / spaceship | 9 / 6 | 16 / 4 |
|
||||
| avg AI turn | 4.76ms | 2.27ms* |
|
||||
| total checks | 1258 | 1346 |
|
||||
|
||||
\* lower only because the barbarians' own (cheap) turn is counted in the
|
||||
average; per-real-civ cost is unchanged.
|
||||
|
||||
One check fails, `space flight window turn 150-600` — it **fails identically on
|
||||
`main`** (median 836 there, 864 here), so it is pre-existing and unrelated.
|
||||
|
||||
**Tuning note.** `spawnIntervalBase`/`capBase` started at 9/3 and the world
|
||||
tipped almost entirely to conquest (20 conquest vs **1** spaceship win), because
|
||||
razed cities cripple civs into easy targets for each other. 14/2 keeps the
|
||||
pressure — 900+ uprisings and 200 leader sightings across the suite — while
|
||||
leaving the spaceship path alive. Raising them again will collapse the victory
|
||||
mix; the numbers are recorded in the rules JSON readme too.
|
||||
|
||||
The escalation arc in section 6b strips the barbarian civ: it needs a stable
|
||||
~90-turn runway for a grudge to build through refusals, and raids end the game
|
||||
in conquest first. Confirmed the escalation itself still works — the game is
|
||||
simply over — so this is a fixture runway issue, not a diplomacy regression.
|
||||
|
||||
Per the owner's standing preference, **no browser verification** — engine and
|
||||
Node checks only; the browser pass is theirs.
|
||||
|
||||
---
|
||||
|
||||
## 9. Build order
|
||||
|
||||
1. Rules data + `compileRules` validation + `sprites.md` spec.
|
||||
2. Barbarian civ in `createGame`, the §1.1 guards, `civEra`. **Ship this alone
|
||||
and confirm the soak is unchanged** — a barbarian civ that spawns nothing
|
||||
must be a perfect no-op. This is the checkpoint that catches §1.2/§1.3.
|
||||
3. `CivilizationBarbarians.js`: spawning, cap, curve, dumb march-and-attack.
|
||||
4. AI response: threat signal, garrison priority, retake barbarian cities.
|
||||
5. Leader: summon tally, flee behaviour, expiry.
|
||||
6. Ransom: `tryMove` branch, AI capture, gold scaling.
|
||||
7. Presentation: colour, sheet lookup + fallback, chevron, popups, HUD banner.
|
||||
8. Huts spawn real barbarians.
|
||||
9. Verify section 9, then the 30-game soak and seed re-pick.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const FORTRESS_BONUS = 2;
|
|||
export const HEAL_FIELD = 0.1;
|
||||
export const HEAL_CITY = 1 / 3;
|
||||
export const PATH_EXPANSION_CAP = 400;
|
||||
export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush'];
|
||||
export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush', 'barbarians'];
|
||||
|
||||
// Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles.
|
||||
export const CITY_RADIUS = [];
|
||||
|
|
@ -154,9 +154,76 @@ export function createGame(rules, opts) {
|
|||
}
|
||||
exploreAround(state, i, sx, sy, 2);
|
||||
}
|
||||
|
||||
createBarbarianCiv(rules, state, difficulty);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Appended last so existing indexes (humanIndex included) are untouched and
|
||||
// endCivTurn's wrap-to-0 still increments the game turn in the right place —
|
||||
// barbarians act at the end of each round. Never created when the difficulty
|
||||
// or the rules file turns them off, which is what makes "no barbarians" the
|
||||
// same code path as a pre-feature save.
|
||||
export function createBarbarianCiv(rules, state, difficulty) {
|
||||
if (!rules.barbarians?.enabled) return null;
|
||||
if (!(difficulty?.barbarianActivity > 0)) return null;
|
||||
|
||||
const idx = state.civs.length;
|
||||
const relations = {};
|
||||
const attitude = {};
|
||||
for (let j = 0; j < idx; j += 1) { relations[j] = 'war'; attitude[j] = -100; }
|
||||
|
||||
const civ = {
|
||||
id: idx,
|
||||
barbarian: true,
|
||||
leaderId: null,
|
||||
traitId: null,
|
||||
citySheet: 'classic',
|
||||
name: 'Barbarians',
|
||||
color: rules.barbarianColor,
|
||||
human: false,
|
||||
// Always alive, even at zero units: uprisings are events, and "no units"
|
||||
// is the normal state between raids. checkVictory must not eliminate them.
|
||||
alive: true,
|
||||
government: 'despotism',
|
||||
revolutionTurns: 0,
|
||||
pendingGovernment: null,
|
||||
gold: 0,
|
||||
beakers: 0,
|
||||
researching: null,
|
||||
known: {},
|
||||
futureCount: 0,
|
||||
relations,
|
||||
attitude,
|
||||
frustration: {},
|
||||
requestCooldown: {},
|
||||
lastRequestTurn: {},
|
||||
pledges: {},
|
||||
reputation: 0,
|
||||
spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 },
|
||||
nameCursor: 0,
|
||||
nameOrder: [],
|
||||
score: 0,
|
||||
// Barbarian-only bookkeeping. barbKills counts raiders each civ has
|
||||
// destroyed (drives leader summons); nextSpawnTurn is the per-target
|
||||
// uprising cursor.
|
||||
barbKills: {},
|
||||
nextSpawnTurn: {},
|
||||
leaderUnitId: null,
|
||||
leaderExpires: 0,
|
||||
leaderSeen: {},
|
||||
};
|
||||
// Everyone is at war with them from the first contact-free moment.
|
||||
for (const other of state.civs) { other.relations[idx] = 'war'; other.attitude[idx] = -100; }
|
||||
|
||||
state.civs.push(civ);
|
||||
// Shape parity only — every explored[civ] consumer indexes by civ id. Never
|
||||
// written to; the barbarian AI sees the world by fiat.
|
||||
state.explored.push(new Array(state.world.cols * state.world.rows).fill(0));
|
||||
state.barbarianIndex = idx;
|
||||
return civ;
|
||||
}
|
||||
|
||||
function shuffledIndexes(state, len) {
|
||||
const arr = Array.from({ length: len }, (_, i) => i);
|
||||
for (let i = arr.length - 1; i > 0; i -= 1) {
|
||||
|
|
@ -206,6 +273,103 @@ export function civCities(state, civ) { return state.cities.filter((c) => c.civ
|
|||
export function knowsTech(civ, techId) { return !!civ.known[techId]; }
|
||||
export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Barbarians (see docs/civilization-barbarians-plan.md)
|
||||
//
|
||||
// Barbarians are a real civ appended to the END of state.civs so every other
|
||||
// civ index — including humanIndex — keeps its meaning and pre-feature saves
|
||||
// (barbarianIndex undefined) simply have no barbarians. Every consumer that
|
||||
// treats a civ as a diplomatic player has to filter them out; the two that
|
||||
// bite hardest are updateAttitudes (shared-enemy bonus) and computeStrategy
|
||||
// (permanent war phase), both handled at their definitions.
|
||||
|
||||
export const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
|
||||
export const ERA_MIN_TECHS = 3; // techs of an era needed before you count as "in" it
|
||||
|
||||
export function isBarbarian(state, civIdx) {
|
||||
return state.barbarianIndex != null && civIdx === state.barbarianIndex;
|
||||
}
|
||||
|
||||
export function barbarianCiv(state) {
|
||||
return state.barbarianIndex != null ? state.civs[state.barbarianIndex] : null;
|
||||
}
|
||||
|
||||
// Civs the diplomacy/scoring/victory systems consider real players.
|
||||
export function realCivs(state) {
|
||||
return state.civs.filter((c) => !c.barbarian);
|
||||
}
|
||||
|
||||
// A civ's era: the most advanced one it knows ERA_MIN_TECHS techs of. One tech
|
||||
// is too twitchy (a single hut can jump you an era), a majority is too slow.
|
||||
export function civEra(rules, civ) {
|
||||
const counts = { ancient: 0, medieval: 0, industrial: 0, modern: 0 };
|
||||
for (const techId of Object.keys(civ.known)) {
|
||||
const tech = rules.techs[techId];
|
||||
if (tech) counts[tech.era] += 1;
|
||||
}
|
||||
let era = 'ancient';
|
||||
for (const e of ERAS) if (counts[e] >= ERA_MIN_TECHS) era = e;
|
||||
return era;
|
||||
}
|
||||
|
||||
export function eraIndex(era) { return Math.max(0, ERAS.indexOf(era)); }
|
||||
|
||||
export function barbarianUnitCount(state) {
|
||||
return state.barbarianIndex == null ? 0 : civUnits(state, state.barbarianIndex).length;
|
||||
}
|
||||
|
||||
// Total living barbarian units allowed on the map. This is the single number
|
||||
// that keeps barbarians an event rather than a fourth empire, so it lives here
|
||||
// beside the engine rather than in the barbarian module — resolveHut needs it
|
||||
// too, and the import direction is Barbarians -> Logic only.
|
||||
export function barbarianUnitCap(rules, state) {
|
||||
const b = rules.barbarians;
|
||||
const act = rules.difficulties[state.difficultyId]?.barbarianActivity ?? 0;
|
||||
const raw = (b.capBase + Math.floor(state.turn * b.capPerTurn)) * act;
|
||||
return Math.max(0, Math.min(b.capMax, Math.round(raw)));
|
||||
}
|
||||
|
||||
// Free land tiles around (x, y) a barbarian could be dropped onto.
|
||||
export function freeLandAround(rules, state, x, y) {
|
||||
const out = [];
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inBounds(state.world, nx, ny)) continue;
|
||||
const terr = terrainAt(rules, state.world, nx, ny);
|
||||
if (terr.water || terr.id === 'mountains') continue;
|
||||
if (cityAt(state, nx, ny)) continue;
|
||||
if (unitsAt(state, nx, ny).length) continue;
|
||||
out.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Tally of barbarian units each civ has destroyed. This is the ONLY way a
|
||||
// Barbarian Leader is summoned (see maybeSpawnUprisings), so it has to be fed
|
||||
// from real combat rather than estimated. Lives here, not in
|
||||
// CivilizationBarbarians.js, because resolveAttack needs it and the import
|
||||
// direction is Barbarians -> Logic only.
|
||||
export function noteBarbarianCasualty(rules, state, deadUnit, killerCivIdx) {
|
||||
const barb = barbarianCiv(state);
|
||||
if (!barb || deadUnit.civ !== barb.id) return;
|
||||
// The Leader isn't a kill — losing him to a stray attack is its own outcome.
|
||||
if (rules.units[deadUnit.type].flags.includes('leader')) {
|
||||
if (barb.leaderUnitId === deadUnit.id) {
|
||||
barb.leaderUnitId = null;
|
||||
barb.leaderExpires = 0;
|
||||
state.events.push({ type: 'barbLeaderKilled', x: deadUnit.x, y: deadUnit.y, keep: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (killerCivIdx == null || killerCivIdx === barb.id) return;
|
||||
barb.barbKills ??= {};
|
||||
barb.barbKills[killerCivIdx] = (barb.barbKills[killerCivIdx] ?? 0) + 1;
|
||||
}
|
||||
|
||||
export function availableTechs(rules, civ) {
|
||||
return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id])
|
||||
&& t.prereqs.every((p) => civ.known[p]));
|
||||
|
|
@ -213,6 +377,9 @@ export function availableTechs(rules, civ) {
|
|||
|
||||
export function availableUnits(rules, state, civ, city) {
|
||||
return rules.unitList.filter((u) => {
|
||||
// Barbarian-only units (the Leader) carry no prereq and cost 0, so without
|
||||
// this they'd sit at the top of every city's build list from turn one.
|
||||
if (u.flags.includes('barbarianonly')) return false;
|
||||
if (u.prereq && !knowsTech(civ, u.prereq)) return false;
|
||||
if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].prereq)) return false;
|
||||
if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false;
|
||||
|
|
@ -729,7 +896,7 @@ export function sizeCap(rules, city) {
|
|||
return 8;
|
||||
}
|
||||
|
||||
function destroyCity(rules, state, city) {
|
||||
export function destroyCity(rules, state, city) {
|
||||
state.cities = state.cities.filter((c) => c.id !== city.id);
|
||||
for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null;
|
||||
for (const other of state.cities) {
|
||||
|
|
@ -886,6 +1053,16 @@ export function tryMove(rules, state, unit, dx, dy) {
|
|||
const targetCity = cityAt(state, tx, ty);
|
||||
const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ);
|
||||
|
||||
// Ransom a cornered Barbarian Leader. Checked BEFORE the attack branch: a
|
||||
// leader alone on a tile is captured for gold, not fought. With an escort
|
||||
// still standing this falls through to normal combat (the escort always
|
||||
// out-defends a 0-defense leader in pickDefender), which is exactly why the
|
||||
// leader runs away — see stepLeader in CivilizationBarbarians.js.
|
||||
if (targets.length && !targetCity && def.domain === 'land'
|
||||
&& targets.every((u) => rules.units[u.type].flags.includes('leader'))) {
|
||||
return ransomLeader(rules, state, unit, targets);
|
||||
}
|
||||
|
||||
// Attack?
|
||||
if (targets.length || (targetCity && targetCity.civ !== unit.civ)) {
|
||||
const enemyCiv = targets.length ? targets[0].civ : targetCity.civ;
|
||||
|
|
@ -930,8 +1107,11 @@ export function tryMove(rules, state, unit, dx, dy) {
|
|||
exploreAround(state, unit.civ, tx, ty, 2);
|
||||
makeContacts(rules, state, unit.civ, tx, ty);
|
||||
|
||||
// Barbarians don't loot huts — they're who lives in them. Letting them pop
|
||||
// one handed them a free unit outside the population cap (plus gold and
|
||||
// ancient techs), so the hut is simply left standing for a real civ to find.
|
||||
const hutIdx = tileIndex(state.world, tx, ty);
|
||||
if (state.world.huts[hutIdx]) {
|
||||
if (state.world.huts[hutIdx] && !state.civs[unit.civ].barbarian) {
|
||||
state.world.huts[hutIdx] = 0;
|
||||
return { result: 'moved', hut: resolveHut(rules, state, unit) };
|
||||
}
|
||||
|
|
@ -1026,6 +1206,28 @@ function resolveHut(rules, state, unit) {
|
|||
state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type, x, y });
|
||||
return { outcome: 'unit', unit: type };
|
||||
}
|
||||
// Barbarian uprising: the classic "you have unleashed a horde!" outcome.
|
||||
// Real units on the surrounding tiles, so they come at the player through
|
||||
// ordinary combat on the barbarian turn instead of resolving invisibly.
|
||||
// Falls back to the legacy phantom duel below when barbarians are disabled
|
||||
// or the population cap is already full.
|
||||
const barb = barbarianCiv(state);
|
||||
if (barb) {
|
||||
const room = barbarianUnitCap(rules, state) - barbarianUnitCount(state);
|
||||
const spots = freeLandAround(rules, state, x, y);
|
||||
if (room > 0 && spots.length) {
|
||||
const roster = rules.barbarians.unitsByEra[civEra(rules, civ)]
|
||||
?? rules.barbarians.unitsByEra.ancient;
|
||||
const count = Math.max(1, Math.min(room, spots.length, 2 + randInt(state, 2)));
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const [bx, by] = spots[i % spots.length];
|
||||
spawnUnit(rules, state, barb.id, roster[randInt(state, roster.length)], bx, by, null);
|
||||
}
|
||||
state.events.push({ type: 'hut', civ: civ.id, outcome: 'barbarians', count, x, y, keep: true });
|
||||
return { outcome: 'barbarians', count };
|
||||
}
|
||||
}
|
||||
|
||||
// Ambush: fight a phantom era-scaled hostile on the spot.
|
||||
const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion');
|
||||
const phantom = rules.units[phantomType];
|
||||
|
|
@ -1124,7 +1326,10 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
state.events.push({ type: 'nukeBlocked', cityId: city.id });
|
||||
return { result: 'nukeBlocked' };
|
||||
}
|
||||
for (const u of unitsAt(state, tx, ty)) removeUnit(state, u);
|
||||
for (const u of unitsAt(state, tx, ty)) {
|
||||
noteBarbarianCasualty(rules, state, u, attacker.civ);
|
||||
removeUnit(state, u);
|
||||
}
|
||||
if (city) city.size = Math.max(1, Math.ceil(city.size / 2));
|
||||
removeUnit(state, attacker);
|
||||
state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null });
|
||||
|
|
@ -1154,9 +1359,13 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
// plays for it.
|
||||
let advanced = false;
|
||||
if (duel.attackerWon) {
|
||||
noteBarbarianCasualty(rules, state, defender, attackerCiv);
|
||||
removeUnit(state, defender);
|
||||
if (!protectedStack) {
|
||||
for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u);
|
||||
for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) {
|
||||
noteBarbarianCasualty(rules, state, u, attackerCiv);
|
||||
removeUnit(state, u);
|
||||
}
|
||||
}
|
||||
attacker.hp = Math.max(1, duel.attackerHp);
|
||||
if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true;
|
||||
|
|
@ -1177,6 +1386,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
} else {
|
||||
defender.hp = Math.max(1, duel.defenderHp);
|
||||
if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true;
|
||||
noteBarbarianCasualty(rules, state, attacker, defenderCiv);
|
||||
removeUnit(state, attacker);
|
||||
}
|
||||
spendMove(attacker, 3);
|
||||
|
|
@ -1193,6 +1403,42 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
return { result: 'combat', won: duel.attackerWon };
|
||||
}
|
||||
|
||||
// Gold paid for handing a captured Barbarian Leader back to his horde. Scales
|
||||
// with the captor's era so a late capture is still worth the detour, and with
|
||||
// difficulty — the same knob that makes raiders nastier makes the prize bigger.
|
||||
export function ransomValue(rules, state, civIdx) {
|
||||
const cfg = rules.barbarians.leader;
|
||||
const activityMult = rules.difficulties[state.difficultyId]?.barbarianActivity ?? 1;
|
||||
const era = eraIndex(civEra(rules, state.civs[civIdx]));
|
||||
return Math.round((cfg.ransomBase + cfg.ransomPerEra * era) * activityMult);
|
||||
}
|
||||
|
||||
// Lives in Logic rather than CivilizationBarbarians.js purely to keep imports
|
||||
// one-directional (Barbarians -> Logic, never back): tryMove needs to call it.
|
||||
export function ransomLeader(rules, state, unit, leaders) {
|
||||
const gold = ransomValue(rules, state, unit.civ);
|
||||
const [lx, ly] = [leaders[0].x, leaders[0].y];
|
||||
const barb = barbarianCiv(state);
|
||||
state.civs[unit.civ].gold += gold;
|
||||
for (const leader of leaders) {
|
||||
if (barb && barb.leaderUnitId === leader.id) {
|
||||
barb.leaderUnitId = null;
|
||||
barb.leaderExpires = 0;
|
||||
}
|
||||
removeUnit(state, leader);
|
||||
}
|
||||
// Step onto the now-empty tile, as a normal move would.
|
||||
const cost = moveCost(rules, state, unit, unit.x, unit.y, lx, ly);
|
||||
spendMove(unit, Number.isFinite(cost) ? cost : 3);
|
||||
unit.x = lx;
|
||||
unit.y = ly;
|
||||
unit.fortified = false;
|
||||
unit.moved = true;
|
||||
exploreAround(state, unit.civ, lx, ly, 2);
|
||||
state.events.push({ type: 'ransom', civ: unit.civ, gold, x: lx, y: ly, keep: true });
|
||||
return { result: 'ransom', gold };
|
||||
}
|
||||
|
||||
export function captureCity(rules, state, unit, city) {
|
||||
const oldCiv = state.civs[city.civ];
|
||||
const newCivIdx = unit.civ;
|
||||
|
|
@ -1232,6 +1478,12 @@ export function captureCity(rules, state, unit, city) {
|
|||
|
||||
export function eliminateCiv(rules, state, civIdx) {
|
||||
const civ = state.civs[civIdx];
|
||||
// Barbarians are never eliminated. checkVictory already skips them, but
|
||||
// captureCity also calls this whenever the loser's last city changes hands —
|
||||
// so retaking the one city barbarians had seized used to kill the faction
|
||||
// outright, silently ending all raids for the rest of the game (and leaving
|
||||
// huts free to spawn units owned by a dead civ).
|
||||
if (civ.barbarian) return;
|
||||
if (!civ.alive) return;
|
||||
civ.alive = false;
|
||||
for (const u of civUnits(state, civIdx)) removeUnit(state, u);
|
||||
|
|
@ -1430,12 +1682,13 @@ export function isUnitVisibleTo(rules, state, unit, civIdx) {
|
|||
// Full proximity sweep: any of my units/cities within 3 of theirs = contact.
|
||||
export function contactSweep(rules, state, civIdx) {
|
||||
const civ = state.civs[civIdx];
|
||||
if (civ.barbarian) return; // raiders are met at swordpoint, not introduced
|
||||
const minePoints = [
|
||||
...civUnits(state, civIdx).map((u) => [u.x, u.y]),
|
||||
...civCities(state, civIdx).map((c) => [c.x, c.y]),
|
||||
];
|
||||
for (const other of state.civs) {
|
||||
if (other.id === civIdx || !other.alive) continue;
|
||||
if (other.id === civIdx || !other.alive || other.barbarian) continue;
|
||||
if (civ.relations[other.id] !== 'nocontact') continue;
|
||||
const theirs = [
|
||||
...civUnits(state, other.id).map((u) => [u.x, u.y]),
|
||||
|
|
@ -1457,8 +1710,9 @@ export function contactSweep(rules, state, civIdx) {
|
|||
}
|
||||
|
||||
export function makeContacts(rules, state, civIdx, x, y) {
|
||||
if (state.civs[civIdx].barbarian) return;
|
||||
for (const other of state.civs) {
|
||||
if (other.id === civIdx || !other.alive) continue;
|
||||
if (other.id === civIdx || !other.alive || other.barbarian) continue;
|
||||
if (state.civs[civIdx].relations[other.id] !== 'nocontact') continue;
|
||||
const near = civUnits(state, other.id).some((u) => cheb(u.x, u.y, x, y) <= 1)
|
||||
|| civCities(state, other.id).some((c) => cheb(c.x, c.y, x, y) <= 1);
|
||||
|
|
@ -1562,7 +1816,7 @@ export function declareWar(rules, state, a, b) {
|
|||
const gov = rules.governments[civA.government];
|
||||
civA.reputation -= 25 * (gov.warPenalty ?? 1);
|
||||
for (const other of state.civs) {
|
||||
if (other.id !== a && other.alive) bumpAttitude(state, other.id, a, -20);
|
||||
if (other.id !== a && other.alive && !other.barbarian) bumpAttitude(state, other.id, a, -20);
|
||||
}
|
||||
}
|
||||
state.events.push({ type: 'war', a, b, sneak });
|
||||
|
|
@ -1659,8 +1913,14 @@ export function civPower(rules, state, civIdx) {
|
|||
export function updateAttitudes(rules, state, civIdx) {
|
||||
const civ = state.civs[civIdx];
|
||||
const myPower = civPower(rules, state, civIdx);
|
||||
if (civ.barbarian) return; // no opinions, no diplomacy
|
||||
for (const other of state.civs) {
|
||||
if (other.id === civIdx || !other.alive) continue;
|
||||
// Barbarians are excluded from BOTH loops here, and the inner one is the
|
||||
// dangerous omission: every civ is permanently at war with them, so
|
||||
// counting them as a shared enemy would hand every pair of AIs a standing
|
||||
// +20 friendship bonus and quietly defuse the whole war path.
|
||||
if (other.barbarian) continue;
|
||||
if (civ.relations[other.id] === 'nocontact') continue;
|
||||
let baseline = 0;
|
||||
const theirPower = civPower(rules, state, other.id);
|
||||
|
|
@ -1668,6 +1928,7 @@ export function updateAttitudes(rules, state, civIdx) {
|
|||
// Shared enemies build friendship.
|
||||
for (const third of state.civs) {
|
||||
if (third.id === civIdx || third.id === other.id || !third.alive) continue;
|
||||
if (third.barbarian) continue;
|
||||
if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20;
|
||||
}
|
||||
// Border friction: their combat units close to my cities.
|
||||
|
|
@ -1711,16 +1972,22 @@ export function beginCivTurn(rules, state, civIdx) {
|
|||
...state.events.slice(-200).filter((e) => !e.keep),
|
||||
];
|
||||
}
|
||||
progressRevolution(rules, state, civ);
|
||||
updateAttitudes(rules, state, civIdx);
|
||||
// Barbarians skip every empire system: no government, no attitudes, no city
|
||||
// production (a captured city just sits there), no research — and crucially
|
||||
// not the auto-research fallback below, which would otherwise hand them a
|
||||
// research target every single turn. They keep the unit upkeep below.
|
||||
if (!civ.barbarian) {
|
||||
progressRevolution(rules, state, civ);
|
||||
updateAttitudes(rules, state, civIdx);
|
||||
|
||||
// Cities produce/grow/research.
|
||||
for (const city of civCities(state, civIdx)) processCity(rules, state, city);
|
||||
progressResearch(rules, state, civ);
|
||||
if (!civ.researching && !civ.human) {
|
||||
// AIs always keep researching something (module CivilizationAI refines this).
|
||||
const options = availableTechs(rules, civ);
|
||||
if (options.length) civ.researching = options[randInt(state, options.length)].id;
|
||||
// Cities produce/grow/research.
|
||||
for (const city of civCities(state, civIdx)) processCity(rules, state, city);
|
||||
progressResearch(rules, state, civ);
|
||||
if (!civ.researching && !civ.human) {
|
||||
// AIs always keep researching something (module CivilizationAI refines this).
|
||||
const options = availableTechs(rules, civ);
|
||||
if (options.length) civ.researching = options[randInt(state, options.length)].id;
|
||||
}
|
||||
}
|
||||
|
||||
// Units: reset movement, heal stationary ones, progress work orders.
|
||||
|
|
@ -1794,13 +2061,17 @@ function checkSpaceshipArrivals(rules, state) {
|
|||
export function checkVictory(rules, state) {
|
||||
if (state.over) return state.over;
|
||||
for (const civ of state.civs) {
|
||||
// Barbarians hold no cities and no settlers by design — the normal
|
||||
// elimination rule would wipe them out the first quiet turn. They are also
|
||||
// excluded from `living` below, or conquest victory could never fire.
|
||||
if (civ.barbarian) continue;
|
||||
if (civ.alive && civCities(state, civ.id).length === 0
|
||||
&& !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler'))
|
||||
&& state.turn > 0) {
|
||||
eliminateCiv(rules, state, civ.id);
|
||||
}
|
||||
}
|
||||
const living = state.civs.filter((c) => c.alive);
|
||||
const living = state.civs.filter((c) => c.alive && !c.barbarian);
|
||||
if (living.length === 1) {
|
||||
state.over = { type: 'conquest', winner: living[0].id };
|
||||
state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true });
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -260,7 +260,58 @@ Government frames match `governments[].frame` in the rules JSON (10–15).
|
|||
|
||||
---
|
||||
|
||||
## 7. Menu icon — `game-icons.png` frame 83
|
||||
## 7. Barbarians sheet — `civilization-barbarians.png`
|
||||
|
||||
**Entirely optional.** Barbarian raiders already read as distinct without it —
|
||||
they're drawn with a dark-crimson (`#7f1d1d`) player ring that no real civ can
|
||||
use. When this sheet is absent, or when a barbarian's unit type isn't listed in
|
||||
`barbarians.barbarianFrames`, the game falls back to the shared
|
||||
`civilization-units` frame for that unit. So barbarians look like slightly
|
||||
scruffier regulars until you paint this, and nothing breaks.
|
||||
|
||||
Same geometry as the units sheet (§4): **64 × 96 frames, 32px headroom, feet
|
||||
planted around y = 84**, neutral art with no colour baked in — the crimson ring
|
||||
is drawn underneath.
|
||||
|
||||
Eight frames covering the whole roster, because several unit types deliberately
|
||||
share one look (a barbarian siege crew is a siege crew whether the engine calls
|
||||
it a Catapult, Cannon or Artillery).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `assets/images/civilization/civilization-barbarians.png` |
|
||||
| **Sheet size** | **512 × 96 px** |
|
||||
| **Frame size** | **64 × 96 px** |
|
||||
| **Layout** | 8 columns × 1 row = 8 frames |
|
||||
| **Status** | ❌ needs painting (optional — fallback in place) |
|
||||
| **JSON** | `barbarianSheet` |
|
||||
|
||||
### Frame map
|
||||
|
||||
| Frame | Motif | Stands in for |
|
||||
|---:|---|---|
|
||||
| 0 | Raider on foot — furs, axe, round shield | `warriors` |
|
||||
| 1 | Horde rider — bareback horse, spear | `horsemen`, `dragoons`, `cavalry` |
|
||||
| 2 | Barbarian archer — short bow, quiver | `archers` |
|
||||
| 3 | Marauder — looted mail over hides, sword | `legion` |
|
||||
| 4 | Heavy rider — barded horse, looted helm | `knights` |
|
||||
| 5 | Siege crew — crude ram or captured gun | `catapult`, `cannon`, `artillery` |
|
||||
| 6 | Musket raider — stolen firearm, bandolier | `musketeers`, `riflemen` |
|
||||
| 7 | **Barbarian Leader** — no weapon, furs and gold, clearly a *person* not a soldier | `barbarianleader` |
|
||||
|
||||
Frame 7 carries real weight: it's the ransom target, and the player needs to
|
||||
recognise at a glance that it's something to **capture, not kill**. Read it as
|
||||
unarmed and valuable — banners, gold torcs, a horsetail standard — visibly
|
||||
distinct from the raiders escorting it. The game also paints a pulsing gold
|
||||
chevron above it.
|
||||
|
||||
The unit→frame mapping lives in `data/civilization-rules.json` under
|
||||
`barbarians.barbarianFrames`, so re-pointing a unit at a different frame needs
|
||||
no code change.
|
||||
|
||||
---
|
||||
|
||||
## 8. Menu icon — `game-icons.png` frame 83
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
|
@ -284,7 +335,8 @@ city. The menu shows a generic fallback until painted.
|
|||
| 4 | `civilization-resources.png` | 320×256 | Medium |
|
||||
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
||||
| 6 | `civilization-icons.png` | 480×96 | Low |
|
||||
| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) |
|
||||
| 7 | `civilization-barbarians.png` | 512×96 | Low (optional — ring + fallback already read) |
|
||||
| 8 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) |
|
||||
|
||||
After painting a sheet, set its `path` in `data/civilization-artwork.json`,
|
||||
e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue