diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index a0b9577..cb2d07b 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -226,6 +226,7 @@ export const CONCEPTS: Article[] = [ sub: 'War, peace and city-states', sections: [ { text: 'You meet a civilization when you first see its units or land. From the Diplomacy panel you can declare war, make peace (a peace treaty holds for a number of turns), and open your borders to its units.' }, + { title: 'Borders', text: 'In peace, no unit of yours, soldier or civilian, may enter another civilization’s land unless it has opened its borders to you: open borders come by a deal at the trade table (with Writing). Missionaries, inquisitors and Great Prophets go in regardless, and a city-state’s land is open to everyone. When open borders end, your units still in their land are moved out.' }, { title: 'City-states', text: `City-states are small single-city nations, marked “City-state” over their banners on the map, with your influence on a bar beneath (a “!” while one asks something of you). Click a city-state’s city to speak to it. Gifts of gold and services (clearing barbarian camps near them) win influence with them, which drifts back each turn. At ${FRIEND} influence they are your friends; the friend with the most, at ${ALLY} or more, is their ally. Friends and allies get what their [[cat:citystate|kind]] gives; an ally also gets their luxuries and their vote in the United Nations.` }, { title: 'Pledges to protect', text: `From a city-state’s card you may pledge to protect it (met, at peace, your influence not below 0). Your influence with it then rests ${PLEDGE_REST} higher, and anyone else who bullies it for tribute or declares war on it offends you, and the leaders who have pledged remember it. Bully or attack it yourself and the pledge is broken (${PLEDGE_BROKEN} influence lost); let your influence fall below 0 and it lapses. Either way you can’t pledge to it again for ${PLEDGE_TURNS} turns (at standard speed).` }, { title: 'Liberating cities', text: `A city you take from a civilization that hadn’t founded it (it had been taken from its founder before) can be liberated instead of annexed, kept as a puppet or razed: it goes back to its founder, who never forgets it. A city-state given back its city gives you ${LIBERATE_INFLUENCE} influence with it, enough to be its ally. A civilization or city-state that had been destroyed comes back to life with the city, at peace with you, its quarrels with the world forgotten. Liberating cities also weighs against your conquests in other leaders’ eyes. An original capital given back counts again as its founder’s for [[victory:domination|Domination]].` }, diff --git a/src/games/blockilization/client/path.ts b/src/games/blockilization/client/path.ts index 6beda74..87b63c5 100644 --- a/src/games/blockilization/client/path.ts +++ b/src/games/blockilization/client/path.ts @@ -3,7 +3,7 @@ import type { UnitWire, View } from '../protocol'; import { dangerCost, dangerZones, type Danger } from '../rules/danger'; import { ALL, findWay, neighbours, type Walker } from '../rules/hex'; import { FEATURES, HILLS, RAIL_MOVE, ROAD_MOVE, TERRAINS } from '../rules/terrain'; -import { isGreatPerson, isMilitary, stackKind, UNITS } from '../rules/units'; +import { heedsBorders, isGreatPerson, isMilitary, stackKind, UNITS } from '../rules/units'; import { cityAt, enemyAt, me, outOfReach, store } from './store'; /** @@ -67,7 +67,7 @@ function mover(u: UnitWire, view: View) { if (city && city.owner !== me()) return false; const owner = store.known[t]?.owner ?? -1; const them = owner >= 0 && owner !== me() ? view.players[owner] : undefined; - return !(military && them && !them.war && !them.cityState && !them.opensBorders); + return !(heedsBorders(u.type) && them && !them.war && !them.cityState && !them.opensBorders); }; /** Next to an enemy unit or city (a zone of control: stepping from one into another ends the move). */ const zoc = (t: number) => @@ -249,11 +249,11 @@ const ownThere = (u: UnitWire, t: number) => store.view?.units.find((x) => x.til /** Another civ's unit on a tile that the player isn't at war with: no unit of theirs can stop there (nor attack it). */ const neutralThere = (t: number) => store.view?.units.find((x) => x.tile === t && x.owner !== me() && !store.view?.players[x.owner]?.war); -/** Whether a tile is in land closed to a soldier: another civ's, at peace, without open borders to it. */ +/** Whether a tile is in land closed to a unit: another civ's, at peace, without open borders to it (missionaries, inquisitors and prophets aside). */ function closedTo(u: UnitWire, t: number): boolean { const owner = store.known[t]?.owner ?? -1; const them = owner >= 0 && owner !== me() ? store.view?.players[owner] : undefined; - return soldier(u) && !!them && !them.war && !them.cityState && !them.opensBorders; + return heedsBorders(u.type) && !!them && !them.war && !them.cityState && !them.opensBorders; } /** Why a unit can't go to a tile, in a few words: what's in the way there, or that there's no way. */ diff --git a/src/games/blockilization/rules/ai/civilians.ts b/src/games/blockilization/rules/ai/civilians.ts index 145c500..099b512 100644 --- a/src/games/blockilization/rules/ai/civilians.ts +++ b/src/games/blockilization/rules/ai/civilians.ts @@ -101,12 +101,13 @@ function slotUnderway(ctx: Ctx, work: 'art' | 'writing' | 'music'): boolean { /** * A Great Musician's concert tour, in the land of the civ the bot's culture holds least sway over (of - * those it's met and at peace with): there, and it plays. False if there's none to go to. + * those it's met, at peace with and whose borders are open to it): there, and it plays. False if + * there's none it can go to. */ function concertTour(ctx: Ctx, u: Unit): boolean { const { game, p } = ctx; const sway = (o: Player) => (p.tourism[o.index] ?? 0) / Math.max(1, o.lifetimeCulture); - const host = game.players.filter((o) => o !== p && o.alive && !o.cityState && o.civ !== 'barbarian' && hasMet(game, p, o) && !p.diplomacy.war[o.index]).sort((a, b) => sway(a) - sway(b))[0]; + const host = game.players.filter((o) => o !== p && o.alive && !o.cityState && o.civ !== 'barbarian' && hasMet(game, p, o) && !p.diplomacy.war[o.index] && o.diplomacy.openBorders[p.index] > 0).sort((a, b) => sway(a) - sway(b))[0]; if (!host) return false; if (game.tiles[u.tile].owner === host.index) return act(ctx, { t: 'greatPerson', unit: u.id, action: 'concert' }); // (The nearest of its land it knows, not a city's own tile.) @@ -114,7 +115,8 @@ function concertTour(ctx: Ctx, u: Unit): boolean { for (let t = 0; t < game.tiles.length; t++) if (game.tiles[t].owner === host.index && p.explored[t] && !cityOn(ctx, t) && !civilianAt(ctx, t) && !TERRAINS[game.tiles[t].terrain].water && (best < 0 || dist(ctx, u.tile, t) < dist(ctx, u.tile, best))) best = t; if (best < 0) return false; if (goTo(ctx, u, best, 'great')) act(ctx, { t: 'greatPerson', unit: u.id, action: 'concert' }); - return true; + // (There, or on its way: else, with no way there, it does something else.) + return u.tile === best || !!ctx.mem.orders[u.id]; } /** A Great Admiral: repairs the fleet once enough of it is hurt about it; else to the most hurt of its ships, or a port. */ diff --git a/src/games/blockilization/rules/ai/context.ts b/src/games/blockilization/rules/ai/context.ts index 65ece00..a18abb6 100644 --- a/src/games/blockilization/rules/ai/context.ts +++ b/src/games/blockilization/rules/ai/context.ts @@ -5,13 +5,13 @@ import { dangerCost, dangerZones, type Danger } from '../danger'; import { happiness, type CityYields } from '../economy'; import { apply, eraOf, type Order } from '../game'; import { distance, neighbours, path, within } from '../hex'; -import { ALL, canCrossOcean, canEmbark, occupants, stepCost } from '../movement'; +import { ALL, canCrossOcean, canEmbark, closedLand, occupants, stepCost } from '../movement'; import { questsFor, type QuestKind } from '../quests'; import type { City, Game, Player, Unit } from '../state'; import { teammates } from '../teams'; import { TERRAINS } from '../terrain'; import { unVote } from '../trade'; -import { isGreatPerson, isMilitary, UNITS } from '../units'; +import { heedsBorders, isGreatPerson, isMilitary, UNITS } from '../units'; import { memoryOf, type BotMemory } from './memory'; import { aimLeaning, personalityOf, type Personality } from './personality'; import { nearVictory } from './opinion'; @@ -215,12 +215,7 @@ export function passable(ctx: Ctx, u: Unit, t: number, passing = false): boolean if (c && c.owner !== u.owner && !military) return false; // (A caravan, cargo ship or great person may stop in its own city beside another civilian, as the rules have it.) if (!passing && (military ? m && m !== u : c && c !== u && !((type.abilities?.includes('trade_route') || isGreatPerson(u.type)) && city?.owner === u.owner))) return false; - const owner = game.tiles[t].owner; - if (military && owner >= 0 && owner !== u.owner && !atWar(game, u.owner, owner)) { - const them = game.players[owner]; - if (!them.cityState && !them.diplomacy.openBorders[u.owner]) return false; - } - return true; + return !(heedsBorders(u.type) && closedLand(game, u.owner, t)); } /** diff --git a/src/games/blockilization/rules/ai/settle.ts b/src/games/blockilization/rules/ai/settle.ts index 34d208a..4442505 100644 --- a/src/games/blockilization/rules/ai/settle.ts +++ b/src/games/blockilization/rules/ai/settle.ts @@ -2,18 +2,18 @@ import { canFound } from '../game'; import { freshWater, tileYields } from '../economy'; import { neighbours, within } from '../hex'; import type { Unit } from '../state'; -import { canEmbark } from '../movement'; +import { canEmbark, closedLand } from '../movement'; import { teamOf } from '../teams'; import { RESOURCES, TERRAINS } from '../terrain'; -import { act, cityOn, civilianAt, coastal, danger, dist, goTo, militaryAt, sameLand, seaRoute, type Ctx } from './context'; +import { act, cityOn, civilianAt, coastal, danger, dist, goTo, landRoute, militaryAt, route, sameLand, seaRoute, type Ctx } from './context'; /** * Where to found cities: each land tile's worth as a city site (its land's food and production, fresh * water, the coast, a hill to stand on, luxuries, strategic resources and natural wonders); then, for - * each bot, the sites still open to it (not too near another city, not in another's land, not where - * enemies roam; on its own land or, with Optics, a coast over the water), nearer home better, new - * luxuries better still. Settlers go to the best and found there, and wait in a city while barbarians - * are about. + * each bot, the sites still open to it (not too near another city, not in another's land nor beyond + * land closed to it, not where enemies roam; on its own land or, with Optics, a coast over the + * water), nearer home better, new luxuries better still. Settlers go to the best and found there, + * and wait in a city while barbarians are about. */ /** What a site's tile of two food or more counts beyond its yields, for each food past the first (`FOOD_BONUS`: tests and scripts may set it). */ @@ -68,6 +68,8 @@ export function bestSite(ctx: Ctx, from: number, settler?: Unit): { tile: number for (const tile of game.tiles) if (tile.owner === ctx.me && tile.resource && RESOURCES[tile.resource].kind === 'luxury') haveLux.add(tile.resource); let best: { tile: number; score: number } | null = null; const reach = ctx.cities.length ? 12 : 4; + // (Whether any land is closed to it: only then is a site on its own land maybe out of its settlers' reach.) + const fenced = game.tiles.some((_, x) => closedLand(game, ctx.me, x)); for (const t of within(game.grid, from, reach)) { if (!p.explored[t] || !canFound(game, t) || !Number.isFinite(siteValue(ctx, t))) continue; const tile = game.tiles[t]; @@ -96,6 +98,9 @@ export function bestSite(ctx: Ctx, from: number, settler?: Unit): { tile: number if (best && s <= best.score) continue; // (A way there by sea, looked for only when the site would do.) if (overseas && !seaRoute(game, p, from, t)) continue; + // (Nor one it couldn't get to over its land, with another civ's land closed to it in the way: the + // settler's own way there, else a way that keeps out of such land.) + if (!overseas && fenced && !(settler ? route(ctx, settler, t) : landRoute(game, from, t, p))) continue; best = { tile: t, score: s }; } return best; diff --git a/src/games/blockilization/rules/ai/workers.ts b/src/games/blockilization/rules/ai/workers.ts index 5a2b02f..cf5d68e 100644 --- a/src/games/blockilization/rules/ai/workers.ts +++ b/src/games/blockilization/rules/ai/workers.ts @@ -167,35 +167,44 @@ export function workers(ctx: Ctx, list: Unit[]) { if (u.work) continue; const mine = mem.orders[u.id]; if (mine) busy.delete(mine.to); - let best: Job | null = null; - let bestScore = -Infinity; - for (const job of jobs) { - if (busy.has(job.tile) || (civilianAt(ctx, job.tile) && civilianAt(ctx, job.tile) !== u)) continue; - const far = dist(ctx, u.tile, job.tile); - if (far > (job.young ? 16 : 12)) continue; - const score = job.value - far * (job.young ? 0.35 : 0.7); - if (score > bestScore) (bestScore = score), (best = job); - } - if (!best && roads.length) { - const t = roads.filter((x) => !busy.has(x) && d[x] === 0 && canBuild(game, { ...u, tile: x }, 'road')).sort((a, b) => dist(ctx, a, u.tile) - dist(ctx, b, u.tile))[0]; - if (t !== undefined) best = { tile: t, what: 'road', value: 1 }; - } - if (!best) { - delete mem.orders[u.id]; - // (Nothing to do: out of a city, where it would keep another civilian from it, to the land beside it, out of reach.) - if (cityOn(ctx, u.tile) && d[u.tile] === 0) { - const aside = neighbours(game.grid, u.tile).find((t) => game.tiles[t].owner === ctx.me && !d[t] && !cityOn(ctx, t) && !TERRAINS[game.tiles[t].terrain].water && passable(ctx, u, t)); - if (aside !== undefined) act(ctx, { t: 'move', unit: u.id, to: aside }); + // (A job it finds no way to, with another civ's land closed to it in the way, is passed over for the next best: a few at most.) + const noWay = new Set(); + for (let tries = 0; tries < 3; tries++) { + let best: Job | null = null; + let bestScore = -Infinity; + for (const job of jobs) { + if (busy.has(job.tile) || noWay.has(job.tile) || (civilianAt(ctx, job.tile) && civilianAt(ctx, job.tile) !== u)) continue; + const far = dist(ctx, u.tile, job.tile); + if (far > (job.young ? 16 : 12)) continue; + const score = job.value - far * (job.young ? 0.35 : 0.7); + if (score > bestScore) (bestScore = score), (best = job); } - continue; - } - busy.add(best.tile); - if (best.tile === u.tile) { - delete mem.orders[u.id]; - act(ctx, { t: 'build', unit: u.id, what: best.what }); - continue; + if (!best && roads.length) { + const t = roads.filter((x) => !busy.has(x) && !noWay.has(x) && d[x] === 0 && canBuild(game, { ...u, tile: x }, 'road')).sort((a, b) => dist(ctx, a, u.tile) - dist(ctx, b, u.tile))[0]; + if (t !== undefined) best = { tile: t, what: 'road', value: 1 }; + } + if (!best) { + delete mem.orders[u.id]; + // (Nothing to do: out of a city, where it would keep another civilian from it, to the land beside it, out of reach.) + if (cityOn(ctx, u.tile) && d[u.tile] === 0) { + const aside = neighbours(game.grid, u.tile).find((t) => game.tiles[t].owner === ctx.me && !d[t] && !cityOn(ctx, t) && !TERRAINS[game.tiles[t].terrain].water && passable(ctx, u, t)); + if (aside !== undefined) act(ctx, { t: 'move', unit: u.id, to: aside }); + } + break; + } + busy.add(best.tile); + if (best.tile === u.tile) { + delete mem.orders[u.id]; + act(ctx, { t: 'build', unit: u.id, what: best.what }); + break; + } + const there = goTo(ctx, u, best.tile, 'work', d); + if (there && u.moves > 0) act(ctx, { t: 'build', unit: u.id, what: best.what }); + // (There, or on its way: else its orders are gone, as there's no way there.) + if (there || mem.orders[u.id]) break; + busy.delete(best.tile); + noWay.add(best.tile); } - if (goTo(ctx, u, best.tile, 'work', d) && u.moves > 0) act(ctx, { t: 'build', unit: u.id, what: best.what }); } mem.crew = Object.fromEntries(list.map((u) => [u.id, u.tile])); } diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index 6fafb22..eb792b8 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -16,7 +16,7 @@ import { fromPromotions, promotionsDue, promotionsFor, PROMOTIONS } from './prom import { liberate, liberation } from './liberation'; import { pledge, pledgeOffended } from './pledges'; import { distance, neighbours, within } from './hex'; -import { advance, campHooks, canEnter, captureHooks, moveTo, occupants, planPath, ruinHooks, sightStops, swap, swapWith } from './movement'; +import { advance, campHooks, canEnter, captureHooks, closedLand, moveTo, occupants, planPath, ruinHooks, sightStops, swap, swapWith } from './movement'; import { wondersBeside } from './nature'; import { adoptable, ALL_POLICIES, IDEOLOGIES, policyMet, TREES, treeDone } from './policies'; import { BELIEF_LIST, BELIEFS, pantheonCost, takenBeliefs } from './religion'; @@ -33,7 +33,7 @@ import { startSequence, waitsFor, warBegun } from './turns'; import { climateTurn } from './climate'; import { disasterTurn } from './disasters'; import { FALLOUT, FEATURES, IMPROVEMENTS, RESOURCES, SEA_WORK, tapsWith, TERRAINS, type ImprovementId } from './terrain'; -import { canFortify, isGreatPerson, isMilitary, PARADROP_RANGE, UNIT_LIST, UNITS } from './units'; +import { canFortify, heedsBorders, isGreatPerson, isMilitary, PARADROP_RANGE, UNIT_LIST, UNITS } from './units'; /** * The rules: every order a player can give (each checked: a player only commands their own, and only @@ -1117,8 +1117,7 @@ function applyOrder(game: Game, by: number, o: Order): Outcome { // (A civilian sent at an enemy would only stop next to it.) const foe = cityAt(game, o.to) ?? occupants(game, o.to).military ?? occupants(game, o.to).civilian; if ((!isMilitary(UNITS[u.type]) || isGreatPerson(u.type)) && foe && atWar(game, by, foe.owner)) return fail(`${aUnit(u.type)} can't go where an enemy is`); - const owner = game.players[game.tiles[o.to].owner]; - if (owner && owner !== p && !owner.cityState && !atWar(game, by, owner.index) && !owner.diplomacy.openBorders[by] && isMilitary(UNITS[u.type]) && !isGreatPerson(u.type)) return fail(`${owner.name}'s borders are closed to you (open borders come by a deal)`); + if (heedsBorders(u.type) && closedLand(game, by, o.to)) return fail(`${game.players[game.tiles[o.to].owner].name}'s borders are closed to you (open borders come by a deal)`); // (A settler sent to a city site: it's told if the site's lost on the way.) u.site = UNITS[u.type].abilities?.includes('found_city') && canFound(game, o.to) ? o.to : undefined; if (moveTo(game, u, o.to)) return OK; @@ -1850,17 +1849,15 @@ export function endTurn(game: Game) { } /** - * Soldiers standing in land that's closed to them (borders grown over them, open borders ended) are - * moved out, Civ V's way, to the nearest tile they may stand on (dry land first): else, unable to - * step anywhere in that land, they'd be stuck there for good. + * Units standing in land that's closed to them (borders grown over them, open borders ended), soldiers + * and civilians alike, are moved out, Civ V's way, to the nearest tile they may stand on (dry land + * first): else, unable to step anywhere in that land, they'd be stuck there for good. (Missionaries, + * inquisitors and prophets may stay: closed borders don't keep them out.) */ function expel(game: Game) { for (const u of game.units) { const type = UNITS[u.type]; - if (!type || !isMilitary(type) || isGreatPerson(u.type) || isAircraft(u)) continue; - const owner = game.tiles[u.tile].owner; - const them = game.players[owner]; - if (!them || owner === u.owner || them.cityState || atWar(game, u.owner, owner) || them.diplomacy.openBorders[u.owner]) continue; + if (!type || !heedsBorders(u.type) || isAircraft(u) || !closedLand(game, u.owner, u.tile)) continue; const near = within(game.grid, u.tile, 8) .filter((t) => t !== u.tile && canEnter(game, u, t)) .sort((a, b) => distance(game.grid, u.tile, a) - distance(game.grid, u.tile, b) || Number(!!TERRAINS[game.tiles[a].terrain].water) - Number(!!TERRAINS[game.tiles[b].terrain].water))[0]; @@ -1869,6 +1866,8 @@ function expel(game: Game) { u.path = undefined; u.stance = undefined; u.fortified = 0; + // (A worker's job there is left behind with the tile.) + u.work = undefined; u.embarked = type.domain === 'land' && TERRAINS[game.tiles[near].terrain].water ? true : undefined; // (A carrier's aircraft go with it.) carry(game, u); diff --git a/src/games/blockilization/rules/movement.ts b/src/games/blockilization/rules/movement.ts index c3a9e38..4779a07 100644 --- a/src/games/blockilization/rules/movement.ts +++ b/src/games/blockilization/rules/movement.ts @@ -5,7 +5,7 @@ import { dangerCost, dangerZones, type Danger } from './danger'; import { ALL, distance, findWay, neighbours } from './hex'; import type { Game, Unit } from './state'; import { FEATURES, HILLS, RAIL_MOVE, ROAD_MOVE, TERRAINS } from './terrain'; -import { isGreatPerson, isMilitary, stackKind, UNITS } from './units'; +import { heedsBorders, isGreatPerson, isMilitary, stackKind, UNITS } from './units'; /** * Moving: what each step costs (terrain, features, roads, rivers; the Great Wall's border and the @@ -149,14 +149,20 @@ export function canEnter(game: Game, u: Unit, t: number, passing = false): boole const waits = (!!UNITS[u.type].abilities?.includes('trade_route') || isGreatPerson(u.type)) && city?.owner === u.owner; const held = holding(game, u.type, t, here); if (!passing && held && held !== u && (military || !waits)) return false; - // Closed borders: no entering another civ's land in peace without open borders (Civ V's). + // Closed borders: no entering another civ's land in peace without open borders (Civ V's), soldier or civilian. + return !(heedsBorders(u.type) && closedLand(game, u.owner, t)); +} + +/** + * Whether a tile is in land closed to a player's units: another civ's that it's at peace with and + * that hasn't opened its borders to it (their grant lets it in: open borders it has given them + * doesn't open their land to it). A city-state's land is open to all. + */ +export function closedLand(game: Game, by: number, t: number): boolean { const owner = game.tiles[t].owner; - if (owner >= 0 && owner !== u.owner && !atWar(game, u.owner, owner)) { - const them = game.players[owner]; - // (Their grant lets us in: open borders we've given them doesn't open their land to us.) - if (!them.cityState && !them.diplomacy.openBorders[u.owner] && military) return false; - } - return true; + if (owner < 0 || owner === by || atWar(game, by, owner)) return false; + const them = game.players[owner]; + return !!them && !them.cityState && !them.diplomacy.openBorders[by]; } /** diff --git a/src/games/blockilization/rules/units.ts b/src/games/blockilization/rules/units.ts index 4cf0321..762fd69 100644 --- a/src/games/blockilization/rules/units.ts +++ b/src/games/blockilization/rules/units.ts @@ -240,6 +240,13 @@ export const canFortify = (type: UnitType) => isMilitary(type) && type.domain == /** Whether a unit type is a great person (not the Great War's infantry and bombers, which only share the word). */ export const isGreatPerson = (type: string) => UNITS[type]?.class === 'great'; +/** + * Whether a unit type is kept out of land closed to it (another civ's, at peace, without open + * borders): every unit, soldier or civilian, but those that carry a faith (missionaries, + * inquisitors and Great Prophets go into rival land without leave, Civ V's). + */ +export const heedsBorders = (type: string) => !UNITS[type]?.abilities?.some((a) => a === 'spread_religion' || a === 'remove_heresy'); + /** * The place a unit takes on a tile, one unit to each (Civ V's one soldier and one civilian to a * tile): a civilian's (great people too), a soldier's, or, at sea, a land soldier's in its boat diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index e6f6dea..f45530f 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -27,7 +27,7 @@ import { congressProposal, congressVote } from '../rules/ai/congress'; import { moveUnits } from '../rules/ai/military'; import { exploreSea } from '../rules/ai/navy'; import { pickProduction } from '../rules/ai/production'; -import { siteValue } from '../rules/ai/settle'; +import { bestSite, siteValue } from '../rules/ai/settle'; import { canEnter, occupants, planPath, stepCost } from '../rules/movement'; import { adoptable, ALL_POLICIES } from '../rules/policies'; import { previewAttack, previewCityFire } from '../rules/preview'; @@ -1111,20 +1111,110 @@ test('meeting goes both ways, and lasts', () => { ok(hasMet(game, a, b) && hasMet(game, b, a), 'out of sight, still met'); }); -test('a soldier in land closed to it is moved out as the turn ends (it could never step anywhere there)', () => { +test('units in land closed to them are moved out as the turn ends, soldiers and civilians alike (they could never step anywhere there)', () => { const { game, at } = blank(); + city(game, 0, at(2, 2)); const theirs = city(game, 1, at(10, 8)); - const inside = neighbours(game.grid, theirs.tile)[0]; + const [inside, beside, third, fourth] = neighbours(game.grid, theirs.tile); const w = unit(game, 'warrior', 0, inside); - const scout = unit(game, 'worker', 0, neighbours(game.grid, theirs.tile)[1]); + const worker = unit(game, 'worker', 0, beside); + const musician = unit(game, 'great_musician', 0, third); + const missionary = unit(game, 'missionary', 0, fourth); endTurn(game); ok(game.tiles[w.tile].owner !== 1, 'the warrior is out of their land'); eq(distance(game.grid, w.tile, inside), 1, 'to the nearest tile outside (next door: their land is one ring)'); - eq(game.tiles[scout.tile].owner, 1, 'civilians may stay'); + ok(game.tiles[worker.tile].owner !== 1 && distance(game.grid, worker.tile, beside) === 1, 'the worker too'); + ok(game.tiles[musician.tile].owner !== 1, 'and a great person'); + eq(missionary.tile, fourth, 'a missionary may stay (closed borders don’t keep the faith out)'); w.tile = inside; + worker.tile = beside; game.players[1].diplomacy.openBorders[0] = 10; endTurn(game); - eq(w.tile, inside, 'with open borders it stays'); + eq(w.tile, inside, 'with open borders the warrior stays'); + eq(worker.tile, beside, 'and the worker'); + // Open borders ended: out they go. + game.players[1].diplomacy.openBorders[0] = 0; + endTurn(game); + ok(game.tiles[w.tile].owner !== 1 && game.tiles[worker.tile].owner !== 1, 'once their borders close again, both are moved out'); +}); + +test('closed borders keep civilians out as well as soldiers: no order into another civ’s land in peace, ways go round it; open borders, a war or a city-state’s land let them in; the faith goes in regardless', () => { + const { game, at } = blank(['egypt', 'babylon'], { cityStates: 1 }); + const [egypt, babylon] = game.players; + egypt.bot = false; + const theirs = city(game, 1, at(10, 8)); + const inside = neighbours(game.grid, theirs.tile)[0]; + const worker = unit(game, 'worker', 0, at(10, 4)); + const settler = unit(game, 'settler', 0, at(10, 3)); + const caravan = unit(game, 'caravan', 0, at(9, 3)); + const musician = unit(game, 'great_musician', 0, at(11, 3)); + for (const u of [worker, settler, caravan, musician]) ok(!canEnter(game, u, inside), `a ${u.type} can't step into their land`); + const refused = apply(game, 0, { t: 'move', unit: worker.id, to: inside }); + ok(!refused.ok && !!refused.error?.includes('borders are closed'), `the order is refused, and says why (${refused.error})`); + eq(worker.tile, at(10, 4), 'the worker stays put'); + // Across their land: round it. + const way = planPath(game, settler, at(10, 13))!; + ok(!!way?.length, 'a settler finds a way past them'); + ok(way.every((t) => game.tiles[t].owner !== 1), 'round their land, not through it'); + // Faith goes in without leave. + ok(canEnter(game, unit(game, 'missionary', 0, at(8, 4)), inside), 'a missionary may go in'); + ok(canEnter(game, unit(game, 'great_prophet', 0, at(12, 4)), inside), 'and a Great Prophet'); + // Open borders we've given them don't open their land to us; theirs given to us do. + egypt.diplomacy.openBorders[1] = 10; + ok(!canEnter(game, worker, inside), 'our borders open to them: theirs still closed to us'); + babylon.diplomacy.openBorders[0] = 10; + for (const u of [worker, settler, caravan, musician]) ok(canEnter(game, u, inside), `with their borders open, a ${u.type} may go in`); + babylon.diplomacy.openBorders[0] = 0; + war(game, 0, 1); + ok(canEnter(game, worker, inside), 'at war, their land is no longer closed'); + // A city-state's land is open to all. + const cs = game.players.find((p) => p.cityState)!; + const town = city(game, cs.index, at(18, 12)); + ok(canEnter(game, unit(game, 'worker', 0, at(18, 9)), neighbours(game.grid, town.tile)[0]), 'a city-state’s land is open to everyone'); +}); + +test('a bot’s settler doesn’t head for a site its land is cut off from by land closed to it; open borders, and it does', () => { + const sites = (wall: boolean, open = false) => { + const { game, at } = blank(); + const [egypt, babylon] = game.players; + // Poor land west, good land east. + for (let t = 0; t < game.tiles.length; t++) if (colOf(game.grid, t) < 12) game.tiles[t].terrain = 'snow'; + city(game, 0, at(4, 8)); + const settler = unit(game, 'settler', 0, at(8, 8)); + if (wall) for (let t = 0; t < game.tiles.length; t++) if (colOf(game.grid, t) === 12 || colOf(game.grid, t) === 13) game.tiles[t].owner = 1; + if (open) babylon.diplomacy.openBorders[0] = 10; + for (let t = 0; t < game.tiles.length; t++) egypt.explored[t] = 1; + const site = bestSite(makeCtx(game, egypt), settler.tile, settler); + return site && colOf(game.grid, site.tile); + }; + ok((sites(false) ?? 0) > 13, 'with nothing in the way, the good land east'); + ok((sites(true) ?? 0) < 12, 'beyond another’s closed land, not'); + ok((sites(true, true) ?? 0) > 13, 'with their borders open to it, east again'); +}); + +test('a bot’s worker passes over a job it can’t get to past land closed to it for one it can', () => { + const run = (wall: boolean) => { + const { game, at } = blank(); + const egypt = game.players[0]; + techsTo(game, 0, 4); + city(game, 0, at(4, 8)); + city(game, 0, at(17, 8)); + const worker = unit(game, 'worker', 0, at(9, 8)); + // A luxury by the eastern city, the western one's land bare. + const lux = neighbours(game.grid, at(17, 8))[3]; + game.tiles[lux].resource = 'wine'; + game.tiles[lux].terrain = 'plains'; + if (wall) for (let t = 0; t < game.tiles.length; t++) if (colOf(game.grid, t) === 12 || colOf(game.grid, t) === 13) game.tiles[t].owner = 1; + for (let t = 0; t < game.tiles.length; t++) egypt.explored[t] = 1; + const ctx = makeCtx(game, egypt); + workers(ctx, [worker]); + return { to: ctx.mem.orders[worker.id]?.to, lux, moved: worker.tile !== at(9, 8), col: (t: number) => colOf(game.grid, t) }; + }; + const free = run(false); + eq(free.to, free.lux, 'nothing in the way: off to the luxury'); + const walled = run(true); + ok(walled.to !== undefined && walled.col(walled.to) < 12, 'walled off from it: a job on its own side'); + ok(walled.moved, 'and it sets off this turn'); }); test('news names only civs the reader has met, and speaks to who it’s about', () => {