diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index 2c122f4..29d698a 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -240,6 +240,7 @@ export const CONCEPTS: Article[] = [ sub: 'Danger and reward in the wilds', sections: [ { text: 'Barbarians come out of camps in the unclaimed lands and raid whoever is near. Clearing a camp brings gold, and city-states close by are grateful for it.' }, + { title: 'Captured workers', text: 'A worker (or a settler, which becomes a worker) that barbarians catch is theirs, and is led back to their camp to wait there. Step a soldier onto it on the way, or clear the camp, to take it back.' }, { title: 'Clans', text: 'Every camp is a clan’s, and a civilization that has found a camp can deal with its clan in the Diplomacy screen (or by clicking the camp): bribe it, and its raiders leave you and your team alone for a while; hire one of its warriors, now and then; or pay it to raid a civilization you have met, which will hear of it, and may find out who paid. Left alone, a clan grows, faster when paid and as its raiders pillage, and slower as they die. Grown, it settles down where its camp is as a new city-state, if the camp is in no one’s land and no city is close by.' }, { text: 'Ancient ruins are scattered over the world. The first land unit to step on one gets its reward: gold, a technology, a citizen, a map of the land round it, or a unit.' }, ], diff --git a/src/games/blockilization/client/models3d.ts b/src/games/blockilization/client/models3d.ts index 78049eb..b331e48 100644 --- a/src/games/blockilization/client/models3d.ts +++ b/src/games/blockilization/client/models3d.ts @@ -266,6 +266,8 @@ export function drawModels(client: Client, dt: number) { units.set(u.id, s); } s.seen = frame; + // (Whose it is now: a worker captured is the captor's, and isn't seen to die when it walks off out of sight.) + s.owner = u.owner; // Moved: it marches there, tile by tile. if (s.tile !== t) { march(s, u, s.tile, t, tx, tz, client.time); diff --git a/src/games/blockilization/rules/barbarians.ts b/src/games/blockilization/rules/barbarians.ts index 2cc709e..f1faf75 100644 --- a/src/games/blockilization/rules/barbarians.ts +++ b/src/games/blockilization/rules/barbarians.ts @@ -7,13 +7,14 @@ import { campCleared } from './quests'; import { clanAt, clanById, clanPillaged, clansTurn, endClan, newClan, raidTarget, spares } from './clans'; import type { Game, Unit } from './state'; import { TERRAINS } from './terrain'; -import { UNITS } from './units'; +import { isMilitary, UNITS } from './units'; /** * Barbarians, Civ V's way: camps appear in land nobody can see, each sends out a raider every so * often (stronger as the world's techs advance), raiders go for whatever's near (units, workers, * cities) and pillage what they stand on, and whoever clears a camp takes 25 gold (Germany, often a - * raider who joins them too). They never take a city. + * raider who joins them too). They never take a city. A worker (or settler) they catch is theirs, a + * worker led back to the camp, where it waits for anyone to take it back (Civ V's). * * Each camp is a clan's (`clans.ts`): its raiders leave alone the civs that have bribed it, and go * after the one it's been paid to raid, more of them, and farther. @@ -21,6 +22,9 @@ import { UNITS } from './units'; export const barbarianIndex = (game: Game) => game.players.findIndex((p) => p.civ === 'barbarian'); +/** The barbarians' fighting units (not the workers they've taken). */ +const raiders = (game: Game, b: number) => unitsOf(game, b).filter((u) => isMilitary(UNITS[u.type])); + /** The unit a camp sends out: by the most advanced civ's era. */ function raider(game: Game): string { const most = Math.max(...game.players.filter((p) => p.civ !== 'barbarian').map((p) => p.techs.length)); @@ -56,16 +60,33 @@ export function barbarianTurn(game: Game) { const paid = raidTarget(game, clan) !== null; if (random(game) > (paid ? 0.2 : 0.06)) return; const near = within(game.grid, t, 1).find((x) => canEnter(game, { id: -1, type: 'warrior', owner: b, tile: x, moves: 0, hp: 100, xp: 0, promotions: [] }, x)); - const around = game.units.filter((x) => x.owner === b && distance(game.grid, x.tile, t) <= 4).length; - if (near !== undefined && around < (paid ? 4 : 2) && (paid || unitsOf(game, b).length < maxCamps * 2)) spawnUnit(game, raider(game), b, near, { clan: clan?.id }); + const around = raiders(game, b).filter((x) => distance(game.grid, x.tile, t) <= 4).length; + if (near !== undefined && around < (paid ? 4 : 2) && (paid || raiders(game, b).length < maxCamps * 2)) spawnUnit(game, raider(game), b, near, { clan: clan?.id }); }); - // Raiders: attack what's next to them, else head for something within reach, else wander. + // Raiders: attack what's next to them, else head for something within reach, else wander. The + // workers they've taken: back to the camp. for (const u of unitsOf(game, b)) { if (!game.units.includes(u)) continue; - raid(game, u, b); + if (isMilitary(UNITS[u.type])) raid(game, u, b); + else homeward(game, u); } } +/** + * A worker the barbarians have taken, led back to its captors' camp (or, theirs gone, the nearest), + * to wait there: beside it, if another waits in it already. With no camp left, it waits where it is. + */ +function homeward(game: Game, u: Unit) { + const clan = clanById(game, u.clan); + const camps = game.tiles.flatMap((tile, t) => (tile.camp ? [t] : [])).sort((a, c) => distance(game.grid, a, u.tile) - distance(game.grid, c, u.tile)); + const camp = clan && game.tiles[clan.tile]?.camp ? clan.tile : camps[0]; + u.path = undefined; + if (camp === undefined || u.tile === camp) return; + const to = [camp, ...neighbours(game.grid, camp).sort((a, c) => distance(game.grid, a, u.tile) - distance(game.grid, c, u.tile))].find((t) => t === u.tile || canEnter(game, u, t)); + if (to === undefined || to === u.tile) return; + u.path = planPath(game, u, to) ?? undefined; +} + function raid(game: Game, u: Unit, b: number) { const type = UNITS[u.type]; // (Its clan's: whom it spares, a bribe's or a raid paid for, and whom it's been paid to raid.) diff --git a/src/games/blockilization/rules/combat.ts b/src/games/blockilization/rules/combat.ts index faf0c6e..316d3f5 100644 --- a/src/games/blockilization/rules/combat.ts +++ b/src/games/blockilization/rules/combat.ts @@ -450,22 +450,34 @@ export function die(game: Game, u: Unit, by: Unit | null, how?: string) { cityHooks.killed(game, u, by); } -/** A civilian taken by a military unit: settlers become workers (Civ V's), workers change hands. */ /** Whether an enemy's civilian of this type is taken when caught (workers, settlers), not killed (great people, missionaries, caravans). */ export const takenAlive = (type: string) => !isGreatPerson(type) && !['missionary', 'inquisitor', 'caravan', 'cargo_ship'].includes(type); +/** + * A civilian taken by a military unit: settlers become workers (Civ V's), workers change hands. One + * the barbarians take is led back to their camp (`barbarians.ts`), for anyone to take back on the + * way there or by clearing the camp. + */ export function capture(game: Game, by: Unit, civ: Unit) { + const name = UNITS[civ.type].name; if (!takenAlive(civ.type)) { removeUnit(game, civ); - note(game, civ.owner, { text: `Your ${UNITS[civ.type].name} was killed by ${game.players[by.owner].name}'s ${UNITS[by.type].name}.`, at: civ.tile, kind: 'bad' }); + note(game, civ.owner, { text: `Your ${name} was killed by ${game.players[by.owner].name}'s ${UNITS[by.type].name}.`, at: civ.tile, kind: 'bad' }); return; } - note(game, civ.owner, { text: `Your ${UNITS[civ.type].name} was captured.`, at: civ.tile, kind: 'bad' }); + const barbarians = game.players[by.owner].civ === 'barbarian'; + note(game, civ.owner, { text: barbarians ? `Your ${name} was captured by barbarians, who are taking it back to their camp: catch it on the way, or clear the camp, to free it.` : `Your ${name} was captured.`, at: civ.tile, kind: 'bad' }); + if (game.players[civ.owner].civ === 'barbarian') note(game, by.owner, { text: `You have freed a ${name} from the barbarians.`, at: civ.tile, kind: 'good' }); civ.owner = by.owner; civ.type = civ.type === 'settler' ? 'worker' : civ.type; + // (Taken by a clan's raider, it's that clan's, and goes to its camp; taken from them, no one's.) + civ.clan = by.clan; civ.moves = 0; civ.path = undefined; civ.work = undefined; + civ.auto = undefined; + civ.stance = undefined; + civ.site = undefined; if (TERRAINS[game.tiles[civ.tile].terrain].water) removeUnit(game, civ); } diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index 93ca872..593d01f 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -6354,6 +6354,43 @@ test('clans: one left alone grows into a city-state where its camp was, its raid ok(clansOf(game).some((x) => x.tile === camp3), 'an old camp given a clan'); }); +test('barbarians: a worker they take is led back to its captors’ camp (not into a fight), and taken back on the way or by clearing the camp', () => { + const { game, at } = blank(['egypt', 'babylon'], { barbarians: true }); + const [egypt] = game.players; + egypt.bot = false; + const b = game.players.findIndex((x) => x.civ === 'barbarian'); + const camp = at(18, 10); + game.tiles[camp].camp = true; + const clan = newClan(game, camp); + city(game, 0, at(6, 10)); + // (A soldier of Egypt's by its land: the worker, taken, once went for it as a raider would, and died.) + unit(game, 'warrior', 0, at(9, 12), { stance: 'fortify' }); + const worker = unit(game, 'worker', 0, at(11, 10), { auto: 'work' }); + unit(game, 'warrior', b, at(12, 10), { clan: clan.id }); + barbarianTurn(game); + ok(game.units.includes(worker) && worker.owner === b, 'the worker is the barbarians’'); + ok(worker.clan === clan.id && !worker.auto, 'its captors’ clan’s, and no longer at work for Egypt'); + ok(egypt.notes.some((n) => n.text.startsWith('Your Worker was captured by barbarians, who are taking it back to their camp')), 'Egypt is told where it’s taken'); + for (let i = 0; i < 10 && game.units.includes(worker) && worker.tile !== camp; i++) endTurn(game); + ok(game.units.includes(worker) && worker.owner === b, 'it’s still there, the barbarians’'); + eq(worker.tile, camp, 'it has walked back to the camp'); + endTurn(game); + eq(worker.tile, camp, 'and waits there'); + // Caught on the way: Egypt's again. + const captive = unit(game, 'worker', b, at(10, 4), { clan: clan.id }); + const rescuer = unit(game, 'warrior', 0, at(9, 4)); + egypt.notes = []; + ok(apply(game, 0, { t: 'move', unit: rescuer.id, to: captive.tile }).ok, 'stepped on'); + ok(captive.owner === 0 && captive.clan === undefined, 'taken back, no clan’s'); + ok(egypt.notes.some((n) => n.text === 'You have freed a Worker from the barbarians.'), 'Egypt is told'); + // The camp cleared: the worker in it Egypt's. + const defender = unit(game, 'warrior', b, camp, { clan: clan.id, hp: 1 }); + const attacker = unit(game, 'swordsman', 0, neighbours(game.grid, camp)[0]); + ok(apply(game, 0, { t: 'attack', unit: attacker.id, target: camp }).ok, 'the camp attacked'); + ok(!game.units.includes(defender) && attacker.tile === camp && !game.tiles[camp].camp, 'its defender killed, the camp cleared'); + eq(worker.owner, 0, 'the worker in it taken back'); +}); + test('clans: a bot bribes a clan by its cities, pays one near its enemy to raid it, and hires a warrior at war', () => { const { game, at } = blank(['egypt', 'babylon'], { barbarians: true }); const [egypt] = game.players;