From 15f682341bf8df03608862da6111e6f809536fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:13:15 +0000 Subject: [PATCH] Blockilization: workers barbarians capture walk back to their camp, Civ V style A worker (or settler) the barbarians caught became theirs and was then run as a raider: it headed for the nearest of its old owner's units and "attacked" them, dying at once, so it seemed to vanish. Now a captive is led back to its captors' camp (the nearest, if theirs is gone) and waits there, beside it if another captive is already in it. Any civ can take it back by stepping a soldier onto it on the way, or by clearing the camp. Captives don't count toward the raiders a camp keeps out, so a camp holding one still sends raiders. A capture clears the worker's automation, stance and settler site, and the note says where it's being taken; the civ that frees one is told. On the map, a unit's owner is kept current, so a captured worker walking out of sight no longer looks like it died. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NTPcknrRzzKZVG96rQjwXG --- src/games/blockilization/client/concepts.ts | 1 + src/games/blockilization/client/models3d.ts | 2 ++ src/games/blockilization/rules/barbarians.ts | 33 +++++++++++++---- src/games/blockilization/rules/combat.ts | 18 ++++++++-- src/games/blockilization/tests/cases.ts | 37 ++++++++++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) 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;