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 },
|
"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 },
|
"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 },
|
"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": {
|
"citySheets": {
|
||||||
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
|
"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 },
|
"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": "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": "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 },
|
"spaceship": { "structuralNeeded": 8, "componentsNeeded": 4, "modulesNeeded": 3, "travelTurns": 15 },
|
||||||
|
|
@ -250,11 +252,11 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"difficulties": [
|
"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": "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 }
|
{ "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": [
|
"civTraits": [
|
||||||
|
|
@ -285,6 +287,69 @@
|
||||||
|
|
||||||
"playerColors": ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7", "#06b6d4", "#f97316", "#ec4899"],
|
"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": [
|
"cityNames": [
|
||||||
"Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick",
|
"Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick",
|
||||||
"Northolt", "Southmere", "Goldcrest", "Ironforge", "Millbrook", "Ashford", "Briarwood", "Cedar Falls",
|
"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']
|
'helicopter', 'missle', 'submarine', 'sword', 'tank']
|
||||||
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
|
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
|
||||||
(scene) => sheetsFrom(scene, 'civilization-artwork',
|
(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
|
// Cups/tracks index always loads (individual track-NNN.json files are
|
||||||
// fetched lazily at race start). Racer/theme/item sheets and backdrops are
|
// fetched lazily at race start). Racer/theme/item sheets and backdrops are
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,22 @@ import {
|
||||||
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
|
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
|
||||||
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
|
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
|
||||||
} from './CivilizationDiplomacy.js';
|
} from './CivilizationDiplomacy.js';
|
||||||
|
import {
|
||||||
|
runBarbarianTurn, barbarianThreatFor, barbarianCities, currentLeader,
|
||||||
|
} from './CivilizationBarbarians.js';
|
||||||
|
|
||||||
const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops
|
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) {
|
export function runAITurn(rules, state, civIdx) {
|
||||||
const civ = state.civs[civIdx];
|
const civ = state.civs[civIdx];
|
||||||
if (!civ.alive || state.over) return;
|
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);
|
const strategy = computeStrategy(rules, state, civIdx);
|
||||||
doDiplomacy(rules, state, civIdx, strategy);
|
doDiplomacy(rules, state, civIdx, strategy);
|
||||||
doResearch(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 myCities = civCities(state, civIdx);
|
||||||
const myPower = civPower(rules, 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);
|
&& civ.relations[c.id] === 'war').map((c) => c.id);
|
||||||
|
|
||||||
const techTotal = rules.techList.filter((t) => !t.repeatable).length;
|
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 (wantsSpace) phase = 'space';
|
||||||
else if (canExpand || myCities.length === 0) phase = 'expand';
|
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);
|
checkPledges(rules, state, civIdx);
|
||||||
for (const other of state.civs) {
|
for (const other of state.civs) {
|
||||||
if (other.id === civIdx || !other.alive) continue;
|
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;
|
if (civ.relations[other.id] === 'nocontact') continue;
|
||||||
// A leader worn down by refused requests tears up its treaties first; if
|
// A leader worn down by refused requests tears up its treaties first; if
|
||||||
// the grudge keeps climbing, the hostility checks below finish the job.
|
// 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) {
|
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');
|
&& 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);
|
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) {
|
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) {
|
function govRank(id) {
|
||||||
return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0;
|
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'
|
.filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land'
|
||||||
&& !rules.units[u.type].flags.includes('noncombat'));
|
&& !rules.units[u.type].flags.includes('noncombat'));
|
||||||
|
|
||||||
// Emergency: garrison first, buy it when the enemy is at the gate.
|
// Emergency: garrison first, buy it when the enemy is at the gate. Raiders
|
||||||
const wantGarrison = strategy.phase === 'war' ? 2 : 1;
|
// 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) {
|
if (defenders.length < wantGarrison) {
|
||||||
const best = bestDefender(rules, state, civ, city);
|
const best = bestDefender(rules, state, civ, city);
|
||||||
if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id);
|
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]];
|
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) {
|
for (const [dx, dy] of dirs) {
|
||||||
const x = unit.x + dx;
|
const x = unit.x + dx;
|
||||||
const y = unit.y + dy;
|
const y = unit.y + dy;
|
||||||
|
|
@ -692,12 +739,28 @@ function stepMilitary(rules, state, civIdx, unit, strategy) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// March on the weakest reachable enemy city.
|
// Hunt a sighted Barbarian Leader. Without this the AI only ever stumbles
|
||||||
if (strategy.atWarWith.length && def.attack > 0) {
|
// 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 target = null;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
for (const city of state.cities) {
|
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 defenders = unitsAt(state, city.x, city.y).length;
|
||||||
const d = cheb(unit.x, unit.y, city.x, city.y);
|
const d = cheb(unit.x, unit.y, city.x, city.y);
|
||||||
const score = -defenders * 4 - d;
|
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];
|
const other = state.civs[toIdx];
|
||||||
if (civ.relations[toIdx] !== 'peace' && civ.relations[toIdx] !== 'alliance') return null;
|
if (civ.relations[toIdx] !== 'peace' && civ.relations[toIdx] !== 'alliance') return null;
|
||||||
if (attitude < (tuning(rules).goodStandingAttitude ?? 25)) 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'
|
&& civ.relations[c.id] === 'war'
|
||||||
&& other.relations[c.id] !== 'nocontact' && other.relations[c.id] !== 'war');
|
&& other.relations[c.id] !== 'nocontact' && other.relations[c.id] !== 'war');
|
||||||
if (!targets.length) return null;
|
if (!targets.length) return null;
|
||||||
|
|
@ -160,7 +162,7 @@ function makeBreakTreaty(rules, state, fromIdx, toIdx, attitude) {
|
||||||
const civ = state.civs[fromIdx];
|
const civ = state.civs[fromIdx];
|
||||||
const other = state.civs[toIdx];
|
const other = state.civs[toIdx];
|
||||||
if (attitude < Math.round((tuning(rules).goodStandingAttitude ?? 25) / 2)) return null;
|
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)
|
&& (civ.relations[c.id] === 'war' || (civ.attitude[c.id] ?? 0) <= -25)
|
||||||
&& (other.relations[c.id] === 'peace' || other.relations[c.id] === 'alliance'));
|
&& (other.relations[c.id] === 'peace' || other.relations[c.id] === 'alliance'));
|
||||||
if (!targets.length) return null;
|
if (!targets.length) return null;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||||
import * as Logic from './CivilizationLogic.js';
|
import * as Logic from './CivilizationLogic.js';
|
||||||
import { runAITurn, respondToProposal } from './CivilizationAI.js';
|
import { runAITurn, respondToProposal } from './CivilizationAI.js';
|
||||||
import { requestValid, resolveRequest } from './CivilizationDiplomacy.js';
|
import { requestValid, resolveRequest } from './CivilizationDiplomacy.js';
|
||||||
|
import { currentLeader, leaderTurnsLeft } from './CivilizationBarbarians.js';
|
||||||
import { RENOUNCE, GIFT_GOLD, GIFT_TECH, pickLine } from './CivilizationChat.js';
|
import { RENOUNCE, GIFT_GOLD, GIFT_TECH, pickLine } from './CivilizationChat.js';
|
||||||
import { CivilizationMapView } from './CivilizationMapView.js';
|
import { CivilizationMapView } from './CivilizationMapView.js';
|
||||||
import { openCityScreen } from './CivilizationCityScreen.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)}`
|
? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}`
|
||||||
: '— pick research —';
|
: '— pick research —';
|
||||||
this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`);
|
this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`);
|
||||||
|
this.refreshWarlordBanner();
|
||||||
this.refreshUnitPanel();
|
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() {
|
refreshUnitPanel() {
|
||||||
const unit = this.selectedUnit();
|
const unit = this.selectedUnit();
|
||||||
if (!unit) {
|
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) {
|
} else if (e.type === 'buildingSold' && e.civ === human) {
|
||||||
const city = Logic.cityById(this.state, e.cityId);
|
const city = Logic.cityById(this.state, e.cityId);
|
||||||
const building = this.rules.buildings[e.building];
|
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) {
|
toastHut(hut) {
|
||||||
const msgs = {
|
const msgs = {
|
||||||
gold: `You found ${hut.gold} gold in the hut!`,
|
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!`,
|
unit: `A band of ${hut.unit} joins you!`,
|
||||||
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
||||||
ambushLost: 'Hostile tribe! Your unit was lost!',
|
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.');
|
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_FIELD = 0.1;
|
||||||
export const HEAL_CITY = 1 / 3;
|
export const HEAL_CITY = 1 / 3;
|
||||||
export const PATH_EXPANSION_CAP = 400;
|
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.
|
// Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles.
|
||||||
export const CITY_RADIUS = [];
|
export const CITY_RADIUS = [];
|
||||||
|
|
@ -154,9 +154,76 @@ export function createGame(rules, opts) {
|
||||||
}
|
}
|
||||||
exploreAround(state, i, sx, sy, 2);
|
exploreAround(state, i, sx, sy, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createBarbarianCiv(rules, state, difficulty);
|
||||||
return state;
|
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) {
|
function shuffledIndexes(state, len) {
|
||||||
const arr = Array.from({ length: len }, (_, i) => i);
|
const arr = Array.from({ length: len }, (_, i) => i);
|
||||||
for (let i = arr.length - 1; i > 0; i -= 1) {
|
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 knowsTech(civ, techId) { return !!civ.known[techId]; }
|
||||||
export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; }
|
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) {
|
export function availableTechs(rules, civ) {
|
||||||
return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id])
|
return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id])
|
||||||
&& t.prereqs.every((p) => civ.known[p]));
|
&& t.prereqs.every((p) => civ.known[p]));
|
||||||
|
|
@ -213,6 +377,9 @@ export function availableTechs(rules, civ) {
|
||||||
|
|
||||||
export function availableUnits(rules, state, civ, city) {
|
export function availableUnits(rules, state, civ, city) {
|
||||||
return rules.unitList.filter((u) => {
|
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.prereq && !knowsTech(civ, u.prereq)) return false;
|
||||||
if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].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;
|
if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false;
|
||||||
|
|
@ -729,7 +896,7 @@ export function sizeCap(rules, city) {
|
||||||
return 8;
|
return 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyCity(rules, state, city) {
|
export function destroyCity(rules, state, city) {
|
||||||
state.cities = state.cities.filter((c) => c.id !== city.id);
|
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 u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null;
|
||||||
for (const other of state.cities) {
|
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 targetCity = cityAt(state, tx, ty);
|
||||||
const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ);
|
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?
|
// Attack?
|
||||||
if (targets.length || (targetCity && targetCity.civ !== unit.civ)) {
|
if (targets.length || (targetCity && targetCity.civ !== unit.civ)) {
|
||||||
const enemyCiv = targets.length ? targets[0].civ : targetCity.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);
|
exploreAround(state, unit.civ, tx, ty, 2);
|
||||||
makeContacts(rules, state, unit.civ, tx, ty);
|
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);
|
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;
|
state.world.huts[hutIdx] = 0;
|
||||||
return { result: 'moved', hut: resolveHut(rules, state, unit) };
|
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 });
|
state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type, x, y });
|
||||||
return { outcome: 'unit', unit: type };
|
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.
|
// Ambush: fight a phantom era-scaled hostile on the spot.
|
||||||
const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion');
|
const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion');
|
||||||
const phantom = rules.units[phantomType];
|
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 });
|
state.events.push({ type: 'nukeBlocked', cityId: city.id });
|
||||||
return { result: 'nukeBlocked' };
|
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));
|
if (city) city.size = Math.max(1, Math.ceil(city.size / 2));
|
||||||
removeUnit(state, attacker);
|
removeUnit(state, attacker);
|
||||||
state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null });
|
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.
|
// plays for it.
|
||||||
let advanced = false;
|
let advanced = false;
|
||||||
if (duel.attackerWon) {
|
if (duel.attackerWon) {
|
||||||
|
noteBarbarianCasualty(rules, state, defender, attackerCiv);
|
||||||
removeUnit(state, defender);
|
removeUnit(state, defender);
|
||||||
if (!protectedStack) {
|
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);
|
attacker.hp = Math.max(1, duel.attackerHp);
|
||||||
if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true;
|
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 {
|
} else {
|
||||||
defender.hp = Math.max(1, duel.defenderHp);
|
defender.hp = Math.max(1, duel.defenderHp);
|
||||||
if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true;
|
if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true;
|
||||||
|
noteBarbarianCasualty(rules, state, attacker, defenderCiv);
|
||||||
removeUnit(state, attacker);
|
removeUnit(state, attacker);
|
||||||
}
|
}
|
||||||
spendMove(attacker, 3);
|
spendMove(attacker, 3);
|
||||||
|
|
@ -1193,6 +1403,42 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
||||||
return { result: 'combat', won: duel.attackerWon };
|
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) {
|
export function captureCity(rules, state, unit, city) {
|
||||||
const oldCiv = state.civs[city.civ];
|
const oldCiv = state.civs[city.civ];
|
||||||
const newCivIdx = unit.civ;
|
const newCivIdx = unit.civ;
|
||||||
|
|
@ -1232,6 +1478,12 @@ export function captureCity(rules, state, unit, city) {
|
||||||
|
|
||||||
export function eliminateCiv(rules, state, civIdx) {
|
export function eliminateCiv(rules, state, civIdx) {
|
||||||
const civ = state.civs[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;
|
if (!civ.alive) return;
|
||||||
civ.alive = false;
|
civ.alive = false;
|
||||||
for (const u of civUnits(state, civIdx)) removeUnit(state, u);
|
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.
|
// Full proximity sweep: any of my units/cities within 3 of theirs = contact.
|
||||||
export function contactSweep(rules, state, civIdx) {
|
export function contactSweep(rules, state, civIdx) {
|
||||||
const civ = state.civs[civIdx];
|
const civ = state.civs[civIdx];
|
||||||
|
if (civ.barbarian) return; // raiders are met at swordpoint, not introduced
|
||||||
const minePoints = [
|
const minePoints = [
|
||||||
...civUnits(state, civIdx).map((u) => [u.x, u.y]),
|
...civUnits(state, civIdx).map((u) => [u.x, u.y]),
|
||||||
...civCities(state, civIdx).map((c) => [c.x, c.y]),
|
...civCities(state, civIdx).map((c) => [c.x, c.y]),
|
||||||
];
|
];
|
||||||
for (const other of state.civs) {
|
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;
|
if (civ.relations[other.id] !== 'nocontact') continue;
|
||||||
const theirs = [
|
const theirs = [
|
||||||
...civUnits(state, other.id).map((u) => [u.x, u.y]),
|
...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) {
|
export function makeContacts(rules, state, civIdx, x, y) {
|
||||||
|
if (state.civs[civIdx].barbarian) return;
|
||||||
for (const other of state.civs) {
|
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;
|
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)
|
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);
|
|| 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];
|
const gov = rules.governments[civA.government];
|
||||||
civA.reputation -= 25 * (gov.warPenalty ?? 1);
|
civA.reputation -= 25 * (gov.warPenalty ?? 1);
|
||||||
for (const other of state.civs) {
|
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 });
|
state.events.push({ type: 'war', a, b, sneak });
|
||||||
|
|
@ -1659,8 +1913,14 @@ export function civPower(rules, state, civIdx) {
|
||||||
export function updateAttitudes(rules, state, civIdx) {
|
export function updateAttitudes(rules, state, civIdx) {
|
||||||
const civ = state.civs[civIdx];
|
const civ = state.civs[civIdx];
|
||||||
const myPower = civPower(rules, state, civIdx);
|
const myPower = civPower(rules, state, civIdx);
|
||||||
|
if (civ.barbarian) return; // no opinions, no diplomacy
|
||||||
for (const other of state.civs) {
|
for (const other of state.civs) {
|
||||||
if (other.id === civIdx || !other.alive) continue;
|
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;
|
if (civ.relations[other.id] === 'nocontact') continue;
|
||||||
let baseline = 0;
|
let baseline = 0;
|
||||||
const theirPower = civPower(rules, state, other.id);
|
const theirPower = civPower(rules, state, other.id);
|
||||||
|
|
@ -1668,6 +1928,7 @@ export function updateAttitudes(rules, state, civIdx) {
|
||||||
// Shared enemies build friendship.
|
// Shared enemies build friendship.
|
||||||
for (const third of state.civs) {
|
for (const third of state.civs) {
|
||||||
if (third.id === civIdx || third.id === other.id || !third.alive) continue;
|
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;
|
if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20;
|
||||||
}
|
}
|
||||||
// Border friction: their combat units close to my cities.
|
// 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),
|
...state.events.slice(-200).filter((e) => !e.keep),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
progressRevolution(rules, state, civ);
|
// Barbarians skip every empire system: no government, no attitudes, no city
|
||||||
updateAttitudes(rules, state, civIdx);
|
// 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.
|
// Cities produce/grow/research.
|
||||||
for (const city of civCities(state, civIdx)) processCity(rules, state, city);
|
for (const city of civCities(state, civIdx)) processCity(rules, state, city);
|
||||||
progressResearch(rules, state, civ);
|
progressResearch(rules, state, civ);
|
||||||
if (!civ.researching && !civ.human) {
|
if (!civ.researching && !civ.human) {
|
||||||
// AIs always keep researching something (module CivilizationAI refines this).
|
// AIs always keep researching something (module CivilizationAI refines this).
|
||||||
const options = availableTechs(rules, civ);
|
const options = availableTechs(rules, civ);
|
||||||
if (options.length) civ.researching = options[randInt(state, options.length)].id;
|
if (options.length) civ.researching = options[randInt(state, options.length)].id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Units: reset movement, heal stationary ones, progress work orders.
|
// Units: reset movement, heal stationary ones, progress work orders.
|
||||||
|
|
@ -1794,13 +2061,17 @@ function checkSpaceshipArrivals(rules, state) {
|
||||||
export function checkVictory(rules, state) {
|
export function checkVictory(rules, state) {
|
||||||
if (state.over) return state.over;
|
if (state.over) return state.over;
|
||||||
for (const civ of state.civs) {
|
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
|
if (civ.alive && civCities(state, civ.id).length === 0
|
||||||
&& !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler'))
|
&& !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler'))
|
||||||
&& state.turn > 0) {
|
&& state.turn > 0) {
|
||||||
eliminateCiv(rules, state, civ.id);
|
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) {
|
if (living.length === 1) {
|
||||||
state.over = { type: 'conquest', winner: living[0].id };
|
state.over = { type: 'conquest', winner: living[0].id };
|
||||||
state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true });
|
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
|
// 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
|
// 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.
|
// 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) {
|
if (spriteMode) {
|
||||||
const ring = scene.add.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1);
|
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]);
|
container.add([ring, img]);
|
||||||
} else {
|
} else {
|
||||||
const g = scene.add.graphics();
|
const g = scene.add.graphics();
|
||||||
|
|
@ -658,6 +668,30 @@ export class CivilizationMapView {
|
||||||
// Badges ride at shoulder height: low against the flat procedural
|
// Badges ride at shoulder height: low against the flat procedural
|
||||||
// roundel, higher up against the taller sprite frame.
|
// roundel, higher up against the taller sprite frame.
|
||||||
const badgeY = spriteMode ? -60 : -14;
|
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) {
|
if (unit.vet) {
|
||||||
container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
|
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('; ')}`);
|
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
|
// --- derived: what each tech unlocks (for UI hovers and the "every tech
|
||||||
// matters" verify check)
|
// matters" verify check)
|
||||||
const gates = {};
|
const gates = {};
|
||||||
|
|
@ -167,6 +250,8 @@ export function compileRules(json) {
|
||||||
worldSizeList: json.worldSizes ?? [],
|
worldSizeList: json.worldSizes ?? [],
|
||||||
diplomacy,
|
diplomacy,
|
||||||
requestKinds,
|
requestKinds,
|
||||||
|
barbarians,
|
||||||
|
barbarianColor: json.barbarianColor ?? '#7f1d1d',
|
||||||
techRank: rank,
|
techRank: rank,
|
||||||
techGates: gates,
|
techGates: gates,
|
||||||
specialsByTerrain,
|
specialsByTerrain,
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,8 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
|
||||||
const left = GAME_WIDTH / 2 - width / 2 + 40;
|
const left = GAME_WIDTH / 2 - width / 2 + 40;
|
||||||
const top = GAME_HEIGHT / 2 - height / 2 + 80;
|
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');
|
&& civ.relations[c.id] !== 'nocontact');
|
||||||
|
|
||||||
if (!contacts.length) {
|
if (!contacts.length) {
|
||||||
|
|
@ -716,7 +717,7 @@ export function openSpaceshipScreen(scene, rules, state, onClose) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rival intel.
|
// 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');
|
&& state.civs[human].relations[c.id] !== 'nocontact');
|
||||||
const intel = rivals.map((c) => {
|
const intel = rivals.map((c) => {
|
||||||
const s = c.spaceship;
|
const s = c.spaceship;
|
||||||
|
|
@ -763,6 +764,7 @@ export function showVictoryOverlay(scene, rules, state, opponentsData, onPlayAga
|
||||||
|
|
||||||
// Final scores.
|
// Final scores.
|
||||||
const scores = state.civs
|
const scores = state.civs
|
||||||
|
.filter((c) => !c.barbarian)
|
||||||
.map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive }))
|
.map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive }))
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((s, i) => `${i + 1}. ${s.name}${s.alive ? '' : ' †'} — ${s.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.',
|
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.',
|
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.',
|
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.',
|
noncombat: 'Cannot attack or be used to initiate combat.',
|
||||||
ignorewalls: 'Attack ignores the defensive bonus from City Walls.',
|
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.',
|
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 |
|
| 4 | `civilization-resources.png` | 320×256 | Medium |
|
||||||
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
||||||
| 6 | `civilization-icons.png` | 480×96 | Low |
|
| 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`,
|
After painting a sheet, set its `path` in `data/civilization-artwork.json`,
|
||||||
e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and
|
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 Logic from '../src/games/civilization/CivilizationLogic.js';
|
||||||
import * as AI from '../src/games/civilization/CivilizationAI.js';
|
import * as AI from '../src/games/civilization/CivilizationAI.js';
|
||||||
import * as Diplo from '../src/games/civilization/CivilizationDiplomacy.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';
|
import * as Chat from '../src/games/civilization/CivilizationChat.js';
|
||||||
// Pure data module (no Phaser) even though its consumers are UI — the city
|
// Pure data module (no Phaser) even though its consumers are UI — the city
|
||||||
// screen's tooltips are checkable headlessly, and worth checking.
|
// 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();
|
const roots = RULES.techList.filter((t) => t.prereqs.length === 0).map((t) => t.id).sort();
|
||||||
check('8 root techs', roots.length === 8, roots.join(','));
|
check('8 root techs', roots.length === 8, roots.join(','));
|
||||||
|
|
||||||
// Units.
|
// Units. Barbarian-only units (the Leader) are exempt from the buildable-unit
|
||||||
check('unit count 51', RULES.unitList.length === 51, `${RULES.unitList.length}`);
|
// 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();
|
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
|
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);
|
&& 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}`);
|
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);
|
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'));
|
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');
|
const ssUnits = RULES.unitList.filter((u) => u.domain === 'project');
|
||||||
check('3 spaceship parts', ssUnits.length === 3);
|
check('3 spaceship parts', ssUnits.length === 3);
|
||||||
check('spaceship config', RULES.spaceship.structuralNeeded === 8
|
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 leaders = ['steve', 'gerome', 'jerry'].map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) }));
|
||||||
const play = (policy) => {
|
const play = (policy) => {
|
||||||
const st = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'prince', leaders, humanIndex: 0 });
|
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; };
|
const setRel = (a, b, r) => { st.civs[a].relations[b] = r; st.civs[b].relations[a] = r; };
|
||||||
setRel(0, 1, 'alliance');
|
setRel(0, 1, 'alliance');
|
||||||
setRel(0, 2, 'peace');
|
setRel(0, 2, 'peace');
|
||||||
|
|
@ -2170,6 +2209,395 @@ if (RULES) {
|
||||||
check('createGame deterministic', Logic.hashState(stA) === Logic.hashState(stB));
|
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)');
|
section('5+9. AI self-play soak (+ research pacing)');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue