diff --git a/src/games/blockilization/client/input.ts b/src/games/blockilization/client/input.ts index 1e1630f..f89916b 100644 --- a/src/games/blockilization/client/input.ts +++ b/src/games/blockilization/client/input.ts @@ -3,7 +3,7 @@ import { canFortify, isMilitary, UNITS } from '../rules/units'; import type { CityWire, PreviewAsk, UnitWire } from '../protocol'; import { cam, centreHere, pick, zoom } from './camera'; import { flagsIn, hitAt, type Hit } from './overlay'; -import { ask, attackable, cityAt, enemyAt, me, myCity, myUnit, store, unitsAt } from './store'; +import { ask, attackable, cityAt, enemyAt, isNuke, me, myCity, myUnit, store, unitsAt } from './store'; import { closePanels, endTurnAction, explores, front, openCity, platformMenuOpen, selectNextIdle, send, setTipPosition, toggleLens, togglePanel } from './ui'; import { openPedia, tileArticle } from './pedia'; import { openOverview } from './overview'; @@ -90,6 +90,9 @@ function talkAt(city: CityWire): boolean { return true; } +/** Whether a unit is an aircraft of yours that could rebase to a tile now (as its owner's view lists: its cities and ships with room, in reach). */ +const rebasesTo = (u: UnitWire | undefined, t: number) => !!u && t !== u.tile && !!u.bases?.includes(t); + /** A click on the map (or a tap, `touch`): on a unit's flag or a city's banner (`hit`), else on the tile under it. */ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, shift = false) { const v = store.view; @@ -130,7 +133,8 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s store.changed++; return; } - if (!right && city?.owner === me()) return openCity(city.id); + // (Your city's banner opens it: unless an aircraft of yours is selected that could rebase there, which goes.) + if (!right && city?.owner === me() && !rebasesTo(myUnit(store.selectedUnit), t)) return openCity(city.id); // (Another's banner: its city-state or its leader, unless a unit of yours is about to be sent there.) if (!right && city && !myUnit(store.selectedUnit) && talkAt(city)) return; } @@ -138,7 +142,8 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s const sel = myUnit(store.selectedUnit); const mineHere = unitsAt(t).filter((u) => u.owner === me()); const city = cityAt(t); - if (!right) { + // (An aircraft selected, a click on where it could rebase: it goes there, not selecting what's there.) + if (!right && !rebasesTo(sel, t)) { // Selecting: your own units here (each click the next), else your city. if (sel && sel.tile === t && mineHere.length > 1) { const i = mineHere.findIndex((u) => u.id === sel.id); @@ -194,8 +199,13 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s store.changed++; return; } - if (attackable(sel, t)) send({ t: 'attack', unit: sel.id, target: t }); - else if (UNITS[sel.type]?.domain === 'air') send({ t: 'rebase', unit: sel.id, to: t }); + if (attackable(sel, t)) { + // (A nuclear strike can't be taken back, and the world won't forget it: asked first.) + if (isNuke(sel)) { + const unit = sel.id; + ask(`Launch the ${UNITS[sel.type].name} here? Everything in the blast is struck, its land left in fallout, and every civ that knows you will long remember it. It can only fall where everyone it would hit is someone you're at war with.`, 'Launch it', () => send({ t: 'attack', unit, target: t })); + } else send({ t: 'attack', unit: sel.id, target: t }); + } else if (UNITS[sel.type]?.domain === 'air') send({ t: 'rebase', unit: sel.id, to: t }); else { // A melee unit sent at an enemy it can reach this turn charges: next to it, then the attack. const from = chargeFrom(sel, t); diff --git a/src/games/blockilization/client/overlay.ts b/src/games/blockilization/client/overlay.ts index 2051e66..cf6bd59 100644 --- a/src/games/blockilization/client/overlay.ts +++ b/src/games/blockilization/client/overlay.ts @@ -576,8 +576,22 @@ export function drawOverlay(now: number) { const committed = routeOf(sel); if (committed) drawPath(sel.tile, committed, 'committed', now); const goal = committed?.tiles[committed.tiles.length - 1]; + // An aircraft doesn't go by a way: where it could rebase, outlined (as a rebase's aim has it, below). + const air = UNITS[sel.type]?.domain === 'air'; + if (air && !store.unitAim) { + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(106,184,255,0.6)'; + for (const t of sel.bases ?? []) if (hexPath(t, tileY(t), 0.9)) ctx.stroke(); + } if (hover >= 0 && hover !== sel.tile && attackable(sel, hover)) drawTarget(hover); - else if (hover >= 0 && hover !== sel.tile && hover !== goal) { + else if (air) { + // (Nowhere it could rebase: no way there.) + if (hover >= 0 && hover !== sel.tile && known[hover] && !sel.bases?.includes(hover)) { + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(255,107,94,0.85)'; + if (hexPath(hover, tileY(hover), 0.9)) ctx.stroke(); + } + } else if (hover >= 0 && hover !== sel.tile && hover !== goal) { const preview = previewPath(sel, hover); if (preview) drawPath(sel.tile, preview, 'preview', now); else if (known[hover]) { @@ -613,12 +627,14 @@ export function drawOverlay(now: number) { if (store.hover >= 0 && store.hover !== aiming.tile && enemyAt(store.hover)) drawTarget(store.hover); } // A unit choosing where to go by air: where it can (a paratrooper, empty land it knows within its - // drop's reach; an airlift, your other cities with an airport), outlined. + // drop's reach; an airlift, your other cities with an airport; an aircraft, its bases in reach), outlined. const flier = store.unitAim ? view.units.find((u) => u.id === store.unitAim!.unit) : undefined; if (flier && store.unitAim) { const places = store.unitAim.order === 'paradrop' ? within(g, flier.tile, PARADROP_RANGE).filter((t) => t !== flier.tile && known[t] && !TERRAINS[store.terrain[t]?.terrain]?.water && store.terrain[t]?.elevation !== 'mountain' && !view.units.some((u) => u.tile === t) && !cityAt(t)) - : view.cities.filter((c) => c.owner === me() && c.tile !== flier.tile && c.buildings?.includes('airport')).map((c) => c.tile); + : store.unitAim.order === 'rebase' + ? (flier.bases ?? []) + : view.cities.filter((c) => c.owner === me() && c.tile !== flier.tile && c.buildings?.includes('airport')).map((c) => c.tile); ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(106,184,255,0.75)'; ctx.fillStyle = 'rgba(106,184,255,0.14)'; diff --git a/src/games/blockilization/client/overview.ts b/src/games/blockilization/client/overview.ts index 41c1e18..346d35c 100644 --- a/src/games/blockilization/client/overview.ts +++ b/src/games/blockilization/client/overview.ts @@ -180,18 +180,21 @@ const STATUS: Record = { fortified: ['Fortified', '#7bd16a'], sleeping: ['Asleep', '#8fa1b3'], alert: ['On alert', '#7bd16a'], + patrol: ['On patrol', '#7bd16a'], skipped: ['Waiting this turn', '#8fa1b3'], done: ['Done this turn', '#5f7286'], }; function statusOf(u: UnitWire): string { - if ((u.promotionsDue ?? 0) > 0) return 'promote'; + // (Due, and one open to it to take: as the unit's own card has it.) + if ((u.promotionsDue ?? 0) > 0 && u.promotionOptions?.length) return 'promote'; if (u.work) return 'working'; if (u.auto) return u.auto === 'explore' ? 'exploring' : 'automated'; if (u.path?.length) return 'moving'; if (u.stance === 'fortify') return 'fortified'; if (u.stance === 'sleep') return 'sleeping'; - if (u.stance === 'alert') return 'alert'; + // (A fighter on alert is on patrol: intercepting.) + if (u.stance === 'alert') return UNITS[u.type]?.class === 'fighter' ? 'patrol' : 'alert'; if (u.skip) return 'skipped'; return (u.moves ?? 0) > 0 ? 'idle' : 'done'; } diff --git a/src/games/blockilization/client/store.ts b/src/games/blockilization/client/store.ts index 4a72086..7d2ac57 100644 --- a/src/games/blockilization/client/store.ts +++ b/src/games/blockilization/client/store.ts @@ -6,6 +6,7 @@ import type { Lens } from './lenses'; import type { Reaction } from './leaderscene'; import { prefs } from './prefs'; import type { CombatPreview } from '../rules/preview'; +import { PROMOTIONS } from '../rules/promotions'; import type { Yields } from '../rules/terrain'; import { isGreatPerson, isMilitary, UNITS } from '../rules/units'; @@ -101,8 +102,8 @@ export interface Store { tap: { unit: number; tile: number } | null; /** A city of yours taking aim (its ranged attack): the next click on the map is its target. */ cityTarget: number | null; - /** A unit of yours choosing where to go by air (a paratrooper's drop, an airlift): the next click on the map is where. */ - unitAim: { unit: number; order: 'paradrop' | 'airlift' } | null; + /** A unit of yours choosing where to go by air (a paratrooper's drop, an airlift, an aircraft's rebase): the next click on the map is where. */ + unitAim: { unit: number; order: 'paradrop' | 'airlift' | 'rebase' } | null; /** A move just ordered by a click: the unit, where to, and once the server has taken it, when (this screen's time): its destination's marker pulses, then the next unit can be selected. */ ordered: { unit: number; to: number; at: number; taken: boolean; advanced?: boolean } | null; /** A question waiting for a yes (the sandboxed frame has no `confirm()`). */ @@ -315,10 +316,27 @@ export function enemyAt(t: number): boolean { return unitsAt(t).some((u) => war(u.owner)) || (!!city && war(city.owner)); } -/** How far a unit reaches with an attack: its range if it fires (the Range promotion one more), else next door. */ +/** How far a unit reaches with an attack: its range if it fires or flies (more with promotions: Range, Air Range), else next door. */ export function reachOf(u: UnitWire): number { const type = UNITS[u.type]; - return type?.ranged ? (type.range ?? 1) + (u.promotions?.includes('range') ? 1 : 0) : 1; + if (!type || (!type.ranged && type.domain !== 'air')) return 1; + return (type.range ?? 1) + (u.promotions ?? []).reduce((s, id) => s + (PROMOTIONS[id]?.range ?? 0), 0); +} + +/** Whether a unit is a nuclear weapon (it strikes a tile, not a unit: `nukeable`). */ +export const isNuke = (u: UnitWire) => !!UNITS[u.type]?.abilities?.includes('nuke'); + +/** + * Whether a nuclear weapon could be aimed at a tile, as far as this screen knows: a known tile in its + * range with an enemy there, or in an enemy's land. (The rules refuse it if the blast would reach + * anyone you're not at war with: the strike's confirmation says so.) + */ +export function nukeable(u: UnitWire, t: number): boolean { + const v = store.view; + const g = store.grid; + if (!v || !g || t === u.tile || !store.known[t] || distance(g, u.tile, t) > reachOf(u)) return false; + const owner = store.known[t]!.owner; + return enemyAt(t) || (owner >= 0 && owner !== me() && !!v.players[owner]?.war); } /** @@ -335,6 +353,8 @@ export function outOfReach(u: UnitWire, t: number): string { export function attackable(u: UnitWire, t: number): boolean { const type = UNITS[u.type]; const g = store.grid; + // (A nuclear weapon has no strength of its own: it strikes where it's aimed.) + if (type && isNuke(u)) return nukeable(u, t); if (!type || !isMilitary(type) || type.abilities?.includes('no_attack') || !g) return false; if (!store.known[t]?.visible || !enemyAt(t)) return false; // A ranged shot from the ground (as the rules' `ranged`): not from the water, and only at an enemy diff --git a/src/games/blockilization/client/ui.ts b/src/games/blockilization/client/ui.ts index e35bf2a..c000220 100644 --- a/src/games/blockilization/client/ui.ts +++ b/src/games/blockilization/client/ui.ts @@ -1364,14 +1364,18 @@ function renderUnit(): El | null { // (Why the advisor marks no work for it, when it marks none.) const quiet = workQuiet(); if (u.pillage) act('Pillage', { t: 'pillage', unit: u.id }, 'Pillage what stands here: gold, and it heals; the land yields nothing until a worker repairs it', 'axe'); - // By air: a paratrooper's drop, an airlift between airports; the next click on the map says where. - const aim = (order: 'paradrop' | 'airlift') => () => { + // By air: a paratrooper's drop, an airlift between airports, an aircraft's rebase; the next click on the map says where. + const aim = (order: 'paradrop' | 'airlift' | 'rebase') => () => { store.unitAim = store.unitAim?.unit === u.id && store.unitAim.order === order ? null : { unit: u.id, order }; store.changed++; }; const city = cityAt(u.tile); if (type.abilities?.includes('paradrop')) act('Paradrop', aim('paradrop'), `Drop it on empty land you know, within ${PARADROP_RANGE} tiles (from your own land, before it moves)`, 'plane', store.unitAim?.unit === u.id ? '.gold' : ''); if (type.domain === 'land' && city?.owner === me() && city.buildings?.includes('airport')) act('Airlift', aim('airlift'), 'Fly it to another of your cities with an airport (before it moves)', 'jet', store.unitAim?.unit === u.id ? '.gold' : ''); + if (type.domain === 'air') { + const why = (u.moves ?? 0) <= 0 ? 'It has flown this turn already' : !u.bases?.length ? `None of your cities or ships with room for it within ${reachOf(u) * 2} tiles` : undefined; + act('Rebase', aim('rebase'), why ?? `Fly it to one of your cities or ships with room for it, within ${reachOf(u) * 2} tiles (they're outlined in blue)`, 'plane', store.unitAim?.unit === u.id ? '.gold' : '', !!why); + } // A soldier in (or next to) a friendly city-state's land can be given to it, for influence. const cs = [u.tile, ...neighbours(store.grid!, u.tile)].map((t) => store.view!.players[store.known[t]?.owner ?? -1]).find((p) => p?.cityState && !p.war); // (Not with another unit on its tile, as the rules' `giftUnit` has it.) @@ -1412,6 +1416,8 @@ function renderUnit(): El | null { ? 'Shift-click more of your units to add them, or shift-drag a box round them (shift-click one in the group to take it out).' : store.unitAim?.unit === u.id ? `${click(true)} where it should ${store.unitAim.order === 'paradrop' ? 'land' : 'fly to'}${touchScreen() ? '.' : ' (Esc to change your mind).'}` + : type.abilities?.includes('nuke') + ? `${click(true)} an enemy, or their land, within ${reachOf(u)} tiles to strike there (you'll be asked first), or one of your cities${u.type === 'atomic_bomb' ? ' or Carriers' : ', Missile Cruisers or Nuclear Submarines'} outlined in blue to rebase there. Everyone in the blast must be someone you're at war with.${u.type === 'atomic_bomb' ? ' Fighters and anti-aircraft guns near the target may shoot it down.' : ''}` : u.auto === 'work' ? 'Automated: it improves your land by itself each turn. Any order takes it back.' : u.auto === 'explore' @@ -1425,7 +1431,7 @@ function renderUnit(): El | null { : type.abilities?.includes('combat_bonus_aura') ? `${ABILITY_TEXT.combat_bonus_aura} Keep it with your army (it can share a tile with a soldier), out of the enemy's reach.` : type.domain === 'air' - ? `${click(true)} an enemy within ${reachOf(u)} tiles to strike it (they're ringed in red), or one of your cities or carriers within ${reachOf(u) * 2} to rebase there. Fighters and anti-aircraft guns near a target may intercept.` + ? `${click(true)} an enemy within ${reachOf(u)} tiles to strike it (they're ringed in red), or one of your cities or ships outlined in blue to rebase there. Fighters and anti-aircraft guns near a target may intercept.${type.class === 'missile' ? ' The missile is spent in its strike.' : ''}` : type.ranged ? `Enemies in reach are ringed in red: ${touchScreen() ? 'tap one to see how a shot would go, tap it again to fire' : 'point at one to see how a shot would go, click to fire'}.` : type.strength @@ -2620,7 +2626,11 @@ function renderTip() { if (store.unitAim) bits.push(h('div.ln.way', {}, icon('plane', 13), `${click(true)} to ${store.unitAim.order === 'paradrop' ? 'drop' : 'fly'} it here`)); else { bits.push(...work); - if (sel && t !== sel.tile && !attackable(sel, t)) { + // (An aircraft rebases, rather than going by a way: where it could, or why not.) + if (sel && t !== sel.tile && !attackable(sel, t) && UNITS[sel.type]?.domain === 'air') { + const base = sel.bases?.includes(t); + bits.push(h(`div.ln.way${base ? '' : '.no'}`, {}, icon(base ? 'plane' : 'close', 13), base ? `${click(true)} to rebase it here` : `It rebases only to your cities and ships with room for it, within ${reachOf(sel) * 2} tiles`)); + } else if (sel && t !== sel.tile && !attackable(sel, t)) { const route = previewPath(sel, t); bits.push(h(`div.ln.way${route ? '' : '.no'}`, {}, icon(route ? 'moves' : 'close', 13), route ? wayThere(route, sel) : whyNot(sel, t))); const walls = route && wallsOnWay(route, sel); diff --git a/src/games/blockilization/protocol.ts b/src/games/blockilization/protocol.ts index 609d723..af6e20e 100644 --- a/src/games/blockilization/protocol.ts +++ b/src/games/blockilization/protocol.ts @@ -242,6 +242,8 @@ export interface UnitWire { destinations?: { city: number; name: string; gold: number; science: number; food: number; prod: number; home: boolean }[]; /** Own units: whether it can pillage where it stands now. */ pillage?: boolean; + /** Own aircraft: the tiles it could rebase to now (its cities and ships with room for it, in reach). */ + bases?: number[]; /** Own military units: their strength defending where they stand, and what changes it. */ defence?: { strength: number; mods: Modifier[] }; /** Own units: what they can be upgraded to now, and for how much gold. */ diff --git a/src/games/blockilization/rules/air.ts b/src/games/blockilization/rules/air.ts index bbb28ac..fe910bb 100644 --- a/src/games/blockilization/rules/air.ts +++ b/src/games/blockilization/rules/air.ts @@ -2,27 +2,48 @@ import { cityStrength, damage, die, gain, rangeOf, strength, type CombatResult } import { atWar, cityAt, note, removeUnit, seesUnit } from './core'; import { distance } from './hex'; import { occupants } from './movement'; +import { fromPromotions } from './promotions'; import type { City, Game, Unit } from './state'; import { isMilitary, UNITS } from './units'; /** * Aircraft, Civ V's way (the basics). They live in their civ's cities (six to a city) and aboard its - * carriers (three to a carrier, carried where it sails), rebase between them (up to twice their - * range), and strike what's within range instead of moving (no line of sight needed). Before a strike - * lands the defence may intercept it: the strongest of an enemy fighter on patrol with the target in - * its range, or an enemy anti-aircraft unit within two tiles of it, fires on the striker first (each - * once a turn). A striker that comes through takes something from the target's reply (more from a - * city's guns). A city taken or a carrier sunk loses the aircraft there. + * ships that carry them (`CARGO`: a Carrier's fighters and bombers, a Missile Cruiser's or a Nuclear + * Submarine's missiles, carried where it sails), rebase between them (up to twice their range), and + * strike what's within range instead of moving (no line of sight needed). Before a strike lands the + * defence may intercept it: the strongest of an enemy fighter on patrol with the target in its range, + * or an enemy anti-aircraft unit within two tiles of it, fires on the striker first (each once a turn). + * A striker that comes through takes something from the target's reply (more from a city's guns); a + * Guided Missile is spent in its strike. A city taken or a ship sunk loses the aircraft there. */ export const CITY_AIRCRAFT = 6; -export const CARRIER_AIRCRAFT = 3; + +/** + * The ships that carry aircraft, and how many (Civ V's): a Carrier, fighters and bombers (not the + * Stealth Bomber) and the Atomic Bomb a bomber drops; a Missile Cruiser or a Nuclear Submarine, + * Guided and Nuclear Missiles. + */ +export const CARGO: Record = { + carrier: { room: 3, missiles: false }, + missile_cruiser: { room: 3, missiles: true }, + nuclear_submarine: { room: 2, missiles: true }, +}; export const isAircraft = (u: Unit) => UNITS[u.type]?.domain === 'air'; -const isCarrier = (u: Unit) => UNITS[u.type]?.class === 'carrier'; +/** A missile (not the Atomic Bomb: a bomber's load, as `combat.ts` has it). */ +const isMissile = (type: string) => UNITS[type]?.class === 'missile' && type !== 'atomic_bomb'; -/** Where an aircraft of `owner` could be based on a tile (not counting `self`): its city there, a carrier of its there with room, or nowhere (null). */ +/** Whether a ship of this type carries aircraft of that type (with `aircraft` unknown, whether it carries any). */ +export function carries(ship: string, aircraft?: string): boolean { + const cargo = CARGO[ship]; + if (!cargo) return false; + if (aircraft === undefined) return true; + return cargo.missiles ? isMissile(aircraft) : !isMissile(aircraft) && aircraft !== 'stealth_bomber'; +} + +/** Where an aircraft of `owner` could be based on a tile (not counting `self`): its city there, a ship of its there with room for it, or nowhere (null). */ export function airBase(game: Game, owner: number, t: number, self?: Unit): { carrier?: Unit } | null { const city = cityAt(game, t); if (city?.owner === owner && game.units.filter((a) => a !== self && a.tile === t && a.owner === owner && isAircraft(a) && a.carrier === undefined).length < CITY_AIRCRAFT) return {}; @@ -30,22 +51,48 @@ export function airBase(game: Game, owner: number, t: number, self?: Unit): { ca return carrier ? { carrier } : null; } -/** A carrier of `owner`'s on a tile with room for one more aircraft (not counting `self`). */ +/** A ship of `owner`'s on a tile that carries `self`'s kind, with room for one more (not counting `self`). */ const carrierWithRoom = (game: Game, owner: number, t: number, self?: Unit) => - game.units.find((c) => c.tile === t && c.owner === owner && isCarrier(c) && game.units.filter((a) => a !== self && a.carrier === c.id).length < CARRIER_AIRCRAFT); + game.units.find((c) => c.tile === t && c.owner === owner && carries(c.type, self?.type) && game.units.filter((a) => a !== self && a.carrier === c.id).length < CARGO[c.type].room); /** How far an aircraft rebases: twice its strike range. */ export const rebaseRange = (u: Unit) => rangeOf(u) * 2; -/** An aircraft flies to another of its civ's cities or carriers (why it can't, if it can't). */ +/** + * Where an aircraft could be based on a tile it rebases to (null: nowhere). Where it is already, only + * the other kind of base there: from a city to a ship in its port, or from the ship to the city. + */ +function rebaseSpot(game: Game, u: Unit, to: number): { carrier?: Unit } | null { + if (to !== u.tile) return airBase(game, u.owner, to, u); + if (u.carrier === undefined) { + const ship = carrierWithRoom(game, u.owner, to, u); + return ship ? { carrier: ship } : null; + } + const city = cityAt(game, to); + return city?.owner === u.owner && game.units.filter((a) => a !== u && a.tile === to && a.owner === u.owner && isAircraft(a) && a.carrier === undefined).length < CITY_AIRCRAFT ? {} : null; +} + +/** The tiles an aircraft could rebase to now (its own cities and ships with room for it, in reach), for the screens to show. */ +export function rebaseSpots(game: Game, u: Unit): number[] { + if (!isAircraft(u) || u.moves <= 0) return []; + const reach = rebaseRange(u); + const tiles = new Set(); + for (const c of game.cities) if (c.owner === u.owner) tiles.add(c.tile); + for (const s of game.units) if (s.owner === u.owner && carries(s.type, u.type)) tiles.add(s.tile); + return [...tiles].filter((t) => distance(game.grid, u.tile, t) <= reach && !!rebaseSpot(game, u, t)); +} + +/** An aircraft flies to another of its civ's cities or ships (why it can't, if it can't). */ export function rebase(game: Game, u: Unit, to: number): string | null { if (u.moves <= 0) return 'It has no moves left this turn'; if (distance(game.grid, u.tile, to) > rebaseRange(u)) return `Too far: aircraft rebase up to ${rebaseRange(u)} tiles`; - // (In a port, aboard the carrier there; else to the city or a carrier.) - const aboard = to === u.tile && u.carrier === undefined ? carrierWithRoom(game, u.owner, to, u) : undefined; - if (to === u.tile && !aboard) return 'It is based there already'; - const base = aboard ? { carrier: aboard } : airBase(game, u.owner, to, u); - if (!base) return 'Only to one of your cities (six aircraft) or carriers (three) with room'; + const base = rebaseSpot(game, u, to); + if (!base) { + if (to === u.tile) return 'It is based there already'; + return isMissile(u.type) + ? 'Only to one of your cities (six aircraft), Missile Cruisers (three missiles) or Nuclear Submarines (two) with room' + : `Only to one of your cities (six aircraft)${u.type === 'stealth_bomber' ? '' : ' or Carriers (three)'} with room`; + } u.tile = to; u.carrier = base.carrier?.id; u.moves = 0; @@ -96,7 +143,8 @@ export function airStrike(game: Game, u: Unit, target: number): CombatResult | n guard.by.intercepted = game.turn; // (A bomber is a bigger, slower mark than a fighter.) const evade = strength(game, u, 'ranged', guard.by, u.tile) * (UNITS[u.type].class === 'fighter' ? 1 : 0.6); - taken = damage(game, guard.power, evade); + // (Evasion takes some of it off.) + taken = Math.round(damage(game, guard.power, evade) * (1 - Math.min(100, fromPromotions(u, 'evasion')) / 100)); u.hp -= taken; gain(game, guard.by, 2, u.owner); note(game, guard.by.owner, { text: `Your ${UNITS[guard.by.type].name} intercepted ${game.players[u.owner].name}'s ${UNITS[u.type].name}.`, at: guard.by.tile, kind: 'good' }); @@ -126,6 +174,11 @@ export function airStrike(game: Game, u: Unit, target: number): CombatResult | n killed = v.hp <= 0; if (killed) die(game, v, u); } + // A Guided Missile is spent in its strike (it takes no reply, and learns nothing from it). + if (UNITS[u.type].class === 'missile') { + removeUnit(game, u); + return { attacker: u.id, dealt, taken, killed, died: true, at: target }; + } u.hp -= reply; taken += reply; gain(game, u, 3, aim.city?.owner ?? aim.unit!.owner); @@ -134,12 +187,12 @@ export function airStrike(game: Game, u: Unit, target: number): CombatResult | n return { attacker: u.id, dealt, taken, killed, died, at: target }; } -/** A carrier's aircraft go where it goes. */ +/** A ship's aircraft go where it goes. */ export function carry(game: Game, carrier: Unit) { for (const a of game.units) if (a.carrier === carrier.id) a.tile = carrier.tile; } -/** Aircraft left with no base (their city taken, their carrier sunk) are lost. */ +/** Aircraft left with no base (their city taken or burnt, their ship sunk) are lost. */ export function strandedAircraft(game: Game) { for (const a of game.units.filter(isAircraft)) { const based = a.carrier !== undefined ? game.units.some((c) => c.id === a.carrier && c.owner === a.owner) : cityAt(game, a.tile)?.owner === a.owner; diff --git a/src/games/blockilization/rules/combat.ts b/src/games/blockilization/rules/combat.ts index d7df154..436bb87 100644 --- a/src/games/blockilization/rules/combat.ts +++ b/src/games/blockilization/rules/combat.ts @@ -212,8 +212,12 @@ function promotionCounts(when: PromotionWhen, game: Game, role: 'attack' | 'defe return fired; case 'vs_mounted': return !!vsUnit && UNITS[vsUnit.type].class === 'mounted'; + case 'vs_land': + return !!vsUnit && UNITS[vsUnit.type].domain === 'land'; case 'vs_ships': return !!vsUnit && UNITS[vsUnit.type].domain === 'sea'; + case 'vs_aircraft': + return !!vsUnit && UNITS[vsUnit.type].domain === 'air'; case 'vs_cities': return !!vsCity && role !== 'defend'; case 'always': @@ -452,7 +456,7 @@ export function cityFire(game: Game, c: City, target: number): CombatResult | nu /** A unit is destroyed (by someone, or nobody; `how` says by what when no unit did it: a city's walls, a citadel). */ export function die(game: Game, u: Unit, by: Unit | null, how?: string) { removeUnit(game, u); - // A carrier sunk loses the aircraft aboard (however it was sunk: a unit, a city's fire, a strike, a citadel). + // A ship sunk loses the aircraft aboard (however it was sunk: a unit, a city's fire, a strike, a citadel). for (const a of game.units.filter((x) => x.carrier === u.id && x.owner === u.owner)) { removeUnit(game, a); note(game, a.owner, { text: `Your ${UNITS[a.type].name} was lost with its base.`, at: a.tile, kind: 'bad' }); @@ -635,6 +639,8 @@ export function nuke(game: Game, u: Unit, target: number): CombatResult | null { let killed = false; for (const t of area) { for (const o of game.units.filter((x) => x.tile === t)) { + // (Aircraft already lost with the ship that carried them aren't struck again.) + if (!game.units.includes(o)) continue; const harm = isMilitary(UNITS[o.type]) ? 60 + Math.floor(random(game) * 41) : 100; dealt += harm; o.hp -= harm; diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index 89d5ade..9136b57 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -1834,6 +1834,8 @@ export function endTurn(game: Game) { congressTurn(game, eraOf); barbarianTurn(game); raze(game); + // (A city burnt to the ground loses the aircraft there, as one taken does.) + strandedAircraft(game); // The world's own turn: its disasters and its climate (what they did kept for the screens to show). const events = [...disasterTurn(game), ...climateTurn(game)]; if (events.length) game.events = events; diff --git a/src/games/blockilization/rules/liberation.ts b/src/games/blockilization/rules/liberation.ts index 2b1d0a1..3e93140 100644 --- a/src/games/blockilization/rules/liberation.ts +++ b/src/games/blockilization/rules/liberation.ts @@ -1,10 +1,11 @@ -import { atWar, citiesOf, knowsOf, nameTo, noteAll, spawnUnit, updateAllVisibility } from './core'; +import { airBase, isAircraft, rebaseRange } from './air'; +import { atWar, citiesOf, knowsOf, nameTo, note, noteAll, spawnUnit, updateAllVisibility } from './core'; import { assignCitizens } from './economy'; import { remember, relationsOf } from './diplomacy'; import { within, distance } from './hex'; import { canEnter } from './movement'; import type { City, Game, Player } from './state'; -import { UNIT_LIST } from './units'; +import { UNIT_LIST, UNITS } from './units'; /** * Liberating cities, Civ V's (Brave New World): a city taken from a civ that hadn't founded it (it @@ -45,8 +46,19 @@ export function liberate(game: Game, c: City): string | null { c.locked = []; for (const tile of game.tiles) if (tile.city === c.id) tile.owner = to.index; for (const w of c.wonders ?? []) game.wonders[w] = to.index; - // (Its liberator's soldiers step out: it's another's city now, and no one stands in another's city in peace.) + // (Its liberator's soldiers step out: it's another's city now, and no one stands in another's city in + // peace. Its aircraft fly to the nearest of its bases in reach, or are lost.) for (const u of game.units.filter((x) => x.tile === c.tile && x.owner !== to.index)) { + if (isAircraft(u)) { + const base = within(game.grid, c.tile, rebaseRange(u)) + .filter((t) => t !== c.tile && !!airBase(game, u.owner, t, u)) + .sort((a, b) => distance(game.grid, c.tile, a) - distance(game.grid, c.tile, b))[0]; + if (base === undefined) { + game.units.splice(game.units.indexOf(u), 1); + note(game, u.owner, { text: `Your ${UNITS[u.type].name} had nowhere to fly to from ${c.name}, and was lost.`, at: c.tile, kind: 'bad' }); + } else Object.assign(u, { tile: base, carrier: airBase(game, u.owner, base, u)!.carrier?.id, stance: undefined }); + continue; + } const out = within(game.grid, c.tile, 3) .filter((t) => t !== c.tile && canEnter(game, u, t)) .sort((a, b) => distance(game.grid, c.tile, a) - distance(game.grid, c.tile, b))[0]; diff --git a/src/games/blockilization/rules/mapgen.ts b/src/games/blockilization/rules/mapgen.ts index aa32fca..a4419c7 100644 --- a/src/games/blockilization/rules/mapgen.ts +++ b/src/games/blockilization/rules/mapgen.ts @@ -285,6 +285,7 @@ export function generate(g: Grid, seed: number, type: MapType, players: number, } placeNaturalWonders(g, tiles, rng, starts, players); strategicBalance(g, tiles, rng, starts.slice(0, players)); + modernStrategics(g, tiles, rng, starts, starts.slice(players, players + cityStates).filter((t) => t >= 0)); return { tiles, starts }; } @@ -345,6 +346,48 @@ function strategicBalance(g: Grid, tiles: Tile[], rng: Rng, starts: number[]) { } } +/** + * Civ V's modern strategics, the last of a new world's resources: Aluminum about as common across the + * land as Iron or Oil (commoner than Oil, in Civ V), Uranium about half as common, each scattered + * where it belongs and near no start in particular, so many to the tiles that suit it (as Civ V + * counts them, so a map is never without); and three city-states in four a small deposit of Coal, + * Oil or Aluminum beside them (Civ V's `AddModernMinorStrategicsToCityStates`). (Left to the roll in + * `placeResources` alone they were rare: a small map often had no Aluminum at all, and so no Jet + * Fighters, Stealth Bombers, Modern Armor, Hydro Plants or Spaceship Factories.) + */ +function modernStrategics(g: Grid, tiles: Tile[], rng: Rng, starts: number[], cityStates: number[]) { + const fits = (t: number, id: string) => { + const tile = tiles[t]; + if (tile.resource || tile.ruin || tile.wonder || tile.antiquity || starts.includes(t) || tile.elevation === 'mountain' || tile.feature === 'ice' || tile.feature === 'oasis') return false; + return RESOURCES[id].on.some((on) => on === tile.terrain || on === tile.feature || on === tile.elevation); + }; + const place = (t: number, id: string, small = false) => { + const [lo, hi] = RESOURCES[id].amount!; + tiles[t].resource = id; + tiles[t].amount = small ? lo : rng.int(lo, hi); + }; + for (const [id, share] of Object.entries(MODERN_SHARE)) { + const spots = tiles.map((_, t) => t).filter((t) => fits(t, id)); + // (Spread out: none beside another of its kind.) + for (let left = Math.ceil(spots.length * share); left > 0 && spots.length; ) { + const t = spots.splice(Math.floor(rng.next() * spots.length), 1)[0]; + if (neighbours(g, t).some((n) => tiles[n].resource === id)) continue; + place(t, id); + left--; + } + } + for (const cs of cityStates) { + const roll = rng.int(0, 3); + if (!roll) continue; + const id = (['coal', 'oil', 'aluminum'] as const)[roll - 1]; + const spots = within(g, cs, 3).filter((t) => t !== cs && fits(t, id)); + if (spots.length) place(rng.pick(spots), id, true); + } +} + +/** The share of the tiles free for it that get a deposit, in `modernStrategics`. */ +const MODERN_SHARE = { aluminum: 0.03, uranium: 0.02 }; + /** The land tiles joined to a tile by land (mountains included). */ function landmass(g: Grid, tiles: Tile[], from: number): Set { const out = new Set([from]); diff --git a/src/games/blockilization/rules/promotions.ts b/src/games/blockilization/rules/promotions.ts index 8f97d35..30f006f 100644 --- a/src/games/blockilization/rules/promotions.ts +++ b/src/games/blockilization/rules/promotions.ts @@ -6,7 +6,8 @@ import { UNITS, type UnitClass } from './units'; * more) it can take one, each class of unit from its own list, some needing another first. Civ V's, * the common ones: Shock and Drill (open ground, rough ground), Cover, Accuracy and Barrage for archers * and siege, March and Medic, Blitz, Logistics and Range, Formation against horses, Siege against - * cities, Sentry for sight, and a couple for ships. What each does is data (`combat`, `range`...), + * cities, Sentry for sight, a couple for ships, and aircraft's own (Interception, Air Siege, Bombardment, + * Air Targeting, Evasion, Air Repair, Air Range). What each does is data (`combat`, `range`...), * which the combat, movement and healing rules read; a few are only given (Morale, the Heroic Epic's; * Altitude Training, the Cloudcrown's). */ @@ -21,9 +22,11 @@ export type PromotionWhen = | 'ranged_rough' /** Defending against a ranged attack (a unit's or a city's). */ | 'vs_ranged' - /** Fighting mounted units, or ships; attacking a city. */ + /** Fighting mounted units, land units, ships or aircraft; attacking a city. */ | 'vs_mounted' + | 'vs_land' | 'vs_ships' + | 'vs_aircraft' | 'vs_cities' | 'always'; @@ -36,11 +39,12 @@ export interface Promotion { needs?: string[]; /** Combat strength percent, and when it counts. */ combat?: { percent: number; when: PromotionWhen }; - /** More range, sight and moves; attacks a turn. */ + /** More range, sight and moves; attacks a turn; less damage from interception, in percent (an aircraft's Evasion). */ range?: number; sight?: number; moves?: number; attacks?: number; + evasion?: number; /** Heals even after moving or fighting (March). */ healsOnTheMove?: boolean; /** Units next to it heal this much more (Medic). */ @@ -50,6 +54,7 @@ export interface Promotion { const MELEE: UnitClass[] = ['melee', 'gun', 'mounted', 'armor', 'recon']; const RANGED: UnitClass[] = ['ranged', 'siege', 'naval_ranged']; const NAVAL: UnitClass[] = ['naval_melee', 'naval_ranged', 'submarine', 'carrier']; +const AIR: UnitClass[] = ['fighter', 'bomber']; export const PROMOTION_LIST: Promotion[] = [ { id: 'shock_1', name: 'Shock I', text: '+15% combat strength in open terrain.', classes: MELEE, combat: { percent: 15, when: 'open' } }, @@ -79,6 +84,21 @@ export const PROMOTION_LIST: Promotion[] = [ { id: 'mobility', name: 'Mobility', text: '+1 move.', classes: ['mounted', 'armor', 'recon', ...NAVAL], needs: ['shock_2'], moves: 1 }, { id: 'boarding_party', name: 'Boarding Party', text: '+15% against ships.', classes: NAVAL, combat: { percent: 15, when: 'vs_ships' } }, { id: 'coastal_raider', name: 'Coastal Raider', text: '+20% against cities.', classes: NAVAL, combat: { percent: 20, when: 'vs_cities' } }, + // Aircraft (Civ V's, the common ones): fighters first to Interception, bombers to Air Siege. + { id: 'interception_1', name: 'Interception I', text: '+33% strength against aircraft (intercepting them, or intercepted by them).', classes: ['fighter'], combat: { percent: 33, when: 'vs_aircraft' } }, + { id: 'interception_2', name: 'Interception II', text: '+33% more against aircraft.', classes: ['fighter'], needs: ['interception_1'], combat: { percent: 33, when: 'vs_aircraft' } }, + { id: 'interception_3', name: 'Interception III', text: '+33% more against aircraft.', classes: ['fighter'], needs: ['interception_2'], combat: { percent: 33, when: 'vs_aircraft' } }, + { id: 'air_siege_1', name: 'Air Siege I', text: '+33% strength striking cities.', classes: AIR, combat: { percent: 33, when: 'vs_cities' } }, + { id: 'air_siege_2', name: 'Air Siege II', text: '+33% more striking cities.', classes: AIR, needs: ['air_siege_1'], combat: { percent: 33, when: 'vs_cities' } }, + { id: 'air_siege_3', name: 'Air Siege III', text: '+33% more striking cities.', classes: AIR, needs: ['air_siege_2'], combat: { percent: 33, when: 'vs_cities' } }, + { id: 'bombardment_1', name: 'Bombardment I', text: '+33% strength striking land units.', classes: AIR, combat: { percent: 33, when: 'vs_land' } }, + { id: 'bombardment_2', name: 'Bombardment II', text: '+33% more striking land units.', classes: AIR, needs: ['bombardment_1'], combat: { percent: 33, when: 'vs_land' } }, + { id: 'bombardment_3', name: 'Bombardment III', text: '+33% more striking land units.', classes: AIR, needs: ['bombardment_2'], combat: { percent: 33, when: 'vs_land' } }, + { id: 'air_targeting_1', name: 'Air Targeting I', text: '+33% strength striking ships.', classes: AIR, combat: { percent: 33, when: 'vs_ships' } }, + { id: 'air_targeting_2', name: 'Air Targeting II', text: '+33% more striking ships.', classes: AIR, needs: ['air_targeting_1'], combat: { percent: 33, when: 'vs_ships' } }, + { id: 'evasion', name: 'Evasion', text: 'Takes half the damage when intercepted.', classes: ['bomber'], needs: ['air_siege_1'], evasion: 50 }, + { id: 'air_repair', name: 'Air Repair', text: 'Heals even after flying.', classes: AIR, needs: ['air_siege_1'], healsOnTheMove: true }, + { id: 'air_range', name: 'Air Range', text: '+2 range (and so +4 to rebase).', classes: AIR, range: 2 }, { id: 'morale', name: 'Morale', text: '+15% combat strength (from the Heroic Epic).', classes: [], combat: { percent: 15, when: 'always' } }, { id: 'altitude_training', name: 'Altitude Training', text: '+15% combat strength in rough terrain; heals even after moving or fighting (from the Cloudcrown).', classes: [], combat: { percent: 15, when: 'rough' }, healsOnTheMove: true }, ]; @@ -102,5 +122,5 @@ export function promotionsFor(u: Unit): Promotion[] { return PROMOTION_LIST.filter((p) => p.classes.includes(cls) && !u.promotions.includes(p.id) && (p.needs ?? []).every((n) => u.promotions.includes(n))); } -/** The sum of a number its promotions add (range, sight, moves, attacks). */ -export const fromPromotions = (u: Unit, key: 'range' | 'sight' | 'moves' | 'attacks') => u.promotions.reduce((s, id) => s + (PROMOTIONS[id]?.[key] ?? 0), 0); +/** The sum of a number its promotions add (range, sight, moves, attacks, evasion). */ +export const fromPromotions = (u: Unit, key: 'range' | 'sight' | 'moves' | 'attacks' | 'evasion') => u.promotions.reduce((s, id) => s + (PROMOTIONS[id]?.[key] ?? 0), 0); diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index 9b6dad4..a59947c 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -4,7 +4,7 @@ import { cityHeal } from '../rules/combat'; import { routeBlockaded } from '../rules/trade'; import { adviseResearch, adviseWork } from '../advisor'; import { BUILDINGS } from '../rules/buildings'; -import { canPillage, cityFireStrength, cityHooks, cityMaxHp, cityStrength, damageFor, gain, leadership, strength } from '../rules/combat'; +import { canPillage, cityFireStrength, cityHooks, cityMaxHp, cityStrength, damageFor, gain, leadership, rangeOf, strength } from '../rules/combat'; import { CITY_STATES, CIV_LIST } from '../rules/civs'; import { PERSONALITIES } from '../rules/ai/personality'; import { UNITS } from '../rules/units'; @@ -31,7 +31,7 @@ import { 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'; -import { promotionsDue } from '../rules/promotions'; +import { promotionsDue, promotionsFor } from '../rules/promotions'; import { BELIEF_LIST } from '../rules/religion'; import { campCleared, QUEST_REWARD, questsFor, questsOf, questsTurn, type Quest, type QuestKind } from '../rules/quests'; import { allyOf, cannotBully, cannotCoup, cityStateBonus, cityStateTurn, coupChance, influenceFall, TRIBUTE_COST, tributeGold, tributeTurns } from '../rules/citystates'; @@ -73,7 +73,7 @@ import { bombInterceptChance } from '../rules/combat'; import { bestUnit, ERA_STARTS, SCENARIOS, startEraOf, startWords, techsBefore, turnLimitFor, withScenario } from '../rules/starts'; import { TECH_LIST } from '../rules/techs'; import { TURN_CHOICES } from '../rules/state'; -import { eraOf, foundCity } from '../rules/game'; +import { autoPromote, eraOf, foundCity } from '../rules/game'; import { botAnswer, EMOTES, isEmote, isPing, moodOf, toneOf } from '../rules/chat'; import { isMilitary } from '../rules/units'; import { civEffects, has } from '../rules/core'; @@ -3035,6 +3035,30 @@ test('map generation: Horses and Iron within four tiles of every civ’s start, ok(coal >= starts * 0.9 && oil >= starts * 0.9, `Coal (${coal}) and Oil (${oil}) within reach of nearly all ${starts} starts`); }); +test('map generation: Aluminum and Uranium across every map, as in Civ V (about as much Aluminum as Iron, Uranium about half), and most city-states with a little Coal, Oil or Aluminum beside them', () => { + const total = { iron: 0, aluminum: 0, uranium: 0 }; + let cityStates = 0; + let supplied = 0; + for (const size of ['duel', 'small', 'standard'] as MapSize[]) + for (const type of ['continents', 'pangaea', 'archipelago', 'fractal'] as const) + for (const seed of [1, 2, 3, 4]) { + const m = MAP_SIZES[size]; + const g = { width: m.width, height: m.height, wrap: true }; + const map = generate(g, seed, type, m.players, m.cityStates); + const count = (id: string) => map.tiles.filter((t) => t.resource === id).length; + // (Left to chance before, a small map often had no Aluminum at all.) + ok(count('aluminum') > 0 && count('uranium') > 0, `${size} ${type} ${seed}: Aluminum (${count('aluminum')}) and Uranium (${count('uranium')}) somewhere`); + for (const id of ['iron', 'aluminum', 'uranium'] as const) total[id] += count(id); + for (const cs of map.starts.slice(m.players, m.players + m.cityStates)) { + cityStates++; + if (within(g, cs, 3).some((t) => ['coal', 'oil', 'aluminum'].includes(map.tiles[t].resource ?? ''))) supplied++; + } + } + ok(total.aluminum >= total.iron * 0.75 && total.aluminum <= total.iron * 1.5, `about as much Aluminum (${total.aluminum}) as Iron (${total.iron})`); + ok(total.uranium >= total.aluminum * 0.35 && total.uranium <= total.aluminum * 0.8, `Uranium (${total.uranium}) about half as common as Aluminum`); + ok(supplied >= cityStates * 0.7, `most city-states (${supplied} of ${cityStates}) with Coal, Oil or Aluminum beside them`); +}); + test('map generation: an archipelago is many islands, the civs mostly each on their own, each with room for three or four cities', () => { for (const wrap of [false, true]) for (const size of ['small', 'standard', 'large'] as MapSize[]) { @@ -4295,6 +4319,165 @@ test('a carrier sunk (by a city’s fire, or a strike) loses the aircraft aboard ok(!game.units.includes(fighter), 'its fighter with it'); }); +test('a Guided Missile is spent in its strike', () => { + const { game, at } = blank(); + city(game, 0, at(5, 5)); + city(game, 1, at(25, 5)); + war(game, 0, 1); + const missile = unit(game, 'guided_missile', 0, at(5, 5)); + const foe = unit(game, 'machine_gun', 1, at(8, 5)); + unit(game, 'scout', 0, at(7, 5)); + updateAllVisibility(game); + const r = apply(game, 0, { t: 'attack', unit: missile.id, target: foe.tile }); + ok(r.ok && foe.hp < 100, 'it strikes'); + ok(!game.units.includes(missile), 'and is gone'); + ok(!!r.combat?.[0].died, 'the strike says so'); +}); + +test('aircraft aboard ships: a Carrier takes three fighters or bombers (not the Stealth Bomber), a Missile Cruiser three missiles, a Nuclear Submarine two', () => { + const { game, at } = blank(); + const home = city(game, 0, at(5, 5)); + city(game, 1, at(25, 5)); + for (const c of [6, 7, 8]) for (const r of [4, 5, 6]) if (at(c, r) !== home.tile) game.tiles[at(c, r)].terrain = 'coast'; + const carrier = unit(game, 'carrier', 0, at(7, 5)); + const cruiser = unit(game, 'missile_cruiser', 0, at(7, 6)); + const sub = unit(game, 'nuclear_submarine', 0, at(8, 5)); + const rebase = (type: string, to: number) => { + const a = unit(game, type, 0, home.tile); + const r = apply(game, 0, { t: 'rebase', unit: a.id, to }); + return { a, r }; + }; + for (let i = 0; i < 3; i++) { + const { a, r } = rebase(i ? 'bomber' : 'fighter', carrier.tile); + ok(r.ok && a.carrier === carrier.id, `aircraft ${i + 1} aboard the Carrier`); + } + const full = rebase('fighter', carrier.tile).r; + ok(!full.ok, `a fourth has no room (${full.error})`); + const stealth = rebase('stealth_bomber', carrier.tile).r; + ok(!stealth.ok && /cities/.test(stealth.error ?? ''), `no Stealth Bomber on a Carrier (${stealth.error})`); + ok(!rebase('guided_missile', carrier.tile).r.ok, 'nor a missile'); + ok(!rebase('fighter', cruiser.tile).r.ok, 'no fighter on a Missile Cruiser'); + for (let i = 0; i < 3; i++) ok(rebase('guided_missile', cruiser.tile).r.ok, `missile ${i + 1} aboard the Missile Cruiser`); + ok(!rebase('guided_missile', cruiser.tile).r.ok, 'a fourth has no room'); + for (let i = 0; i < 2; i++) ok(rebase(i ? 'nuclear_missile' : 'guided_missile', sub.tile).r.ok, `missile ${i + 1} aboard the Nuclear Submarine`); + ok(!rebase('guided_missile', sub.tile).r.ok, 'a third has no room'); + // Where the ship sails, its missiles go. + const aboard = game.units.filter((a) => a.carrier === sub.id); + ok(apply(game, 0, { t: 'move', unit: sub.id, to: at(8, 4) }).ok, 'the submarine sails'); + ok(aboard.every((a) => a.tile === sub.tile), 'its missiles with it'); +}); + +test('an aircraft rebases between a city and a ship in its port; its view lists where it could rebase to', () => { + const { game, at } = blank(); + const home = city(game, 0, at(5, 5)); + const other = city(game, 0, at(5, 12)); + city(game, 1, at(25, 5)); + game.tiles[at(6, 5)].terrain = 'coast'; + const fighter = unit(game, 'fighter', 0, home.tile); + const stay = apply(game, 0, { t: 'rebase', unit: fighter.id, to: home.tile }); + ok(!stay.ok && /already/.test(stay.error ?? ''), `nowhere else to be in its own city (${stay.error})`); + const carrier = unit(game, 'carrier', 0, home.tile); + const bases = () => viewFor(game, game.players[0], null).units.find((u) => u.id === fighter.id)?.bases ?? []; + ok(bases().includes(home.tile) && bases().includes(other.tile), `the Carrier in port and the other city are open to it (${bases()})`); + ok(apply(game, 0, { t: 'rebase', unit: fighter.id, to: home.tile }).ok && fighter.carrier === carrier.id, 'aboard the Carrier in port'); + eq(bases().length, 0, 'nothing more this turn'); + fighter.moves = 1; + ok(bases().includes(home.tile), 'next turn, back to the city'); + ok(apply(game, 0, { t: 'rebase', unit: fighter.id, to: home.tile }).ok && fighter.carrier === undefined, 'back in the city'); + fighter.moves = 1; + const far = city(game, 0, at(24, 14)); + ok(distance(game.grid, fighter.tile, far.tile) > 16 && !bases().includes(far.tile), 'not a city beyond twice its range'); + const mine = viewFor(game, game.players[1], null).units.find((u) => u.id === fighter.id); + eq(mine?.bases, undefined, 'another’s screen isn’t told'); +}); + +test('air promotions: open to fighters and bombers; Interception strengthens a fighter against aircraft, Evasion halves what an interception does, Air Range reaches further', () => { + const { game, at } = blank(); + const fighter = unit(game, 'fighter', 0, at(5, 5)); + const bomber = unit(game, 'bomber', 0, at(5, 5)); + const sword = unit(game, 'swordsman', 0, at(6, 6)); + const open = (u: typeof fighter) => promotionsFor(u).map((p) => p.id); + ok(open(fighter).includes('interception_1') && open(fighter).includes('air_siege_1'), `a fighter's (${open(fighter)})`); + ok(open(bomber).includes('air_siege_1') && !open(bomber).includes('interception_1') && !open(bomber).includes('evasion'), `a bomber's (${open(bomber)})`); + ok(!open(sword).some((id) => id.startsWith('air_') || id.startsWith('interception')), 'none for a swordsman'); + bomber.promotions.push('air_siege_1'); + ok(open(bomber).includes('evasion'), 'Evasion after Air Siege I'); + // Taken as any other, by experience. + fighter.xp = 10; + ok(apply(game, 0, { t: 'promote', unit: fighter.id, promotion: 'interception_1' }).ok, 'a fighter takes Interception I'); + const plain = unit(game, 'fighter', 0, at(5, 5)); + const s = (u: typeof fighter) => strength(game, u, 'ranged', bomber, u.tile); + ok(Math.abs(s(fighter) / s(plain) - 1.33) < 0.02, `a third stronger against a bomber (${s(fighter)} to ${s(plain)})`); + const range = rangeOf(plain); + plain.promotions.push('air_range'); + eq(rangeOf(plain), range + 2, 'Air Range: two further'); + // A bot's aircraft promote as a fighter or a bomber would. + const auto = unit(game, 'fighter', 0, at(5, 5), { xp: 10 }); + autoPromote(game, auto); + eq(auto.promotions[0], 'interception_1', 'a bot’s fighter to Interception'); + const autoB = unit(game, 'bomber', 0, at(5, 5), { xp: 10 }); + autoPromote(game, autoB); + eq(autoB.promotions[0], 'air_siege_1', 'a bot’s bomber to Air Siege'); + // Evasion: the same strike, intercepted, with and without it. + const intercepted = (promotions: string[]) => { + const w = blank(); + for (const p of w.game.players) p.bot = false; + city(w.game, 0, w.at(5, 5)); + const theirs = city(w.game, 1, w.at(14, 5)); + war(w.game, 0, 1); + unit(w.game, 'fighter', 1, theirs.tile, { stance: 'alert' }); + const b = unit(w.game, 'bomber', 0, w.at(5, 5), { promotions }); + unit(w.game, 'scout', 0, w.at(12, 5)); + w.game.rng = 4242; + ok(apply(w.game, 0, { t: 'attack', unit: b.id, target: theirs.tile }).ok, 'the strike'); + const n = w.game.players[0].notes.find((x) => /was intercepted/.test(x.text)); + return Number(/\((\d+) damage\)/.exec(n?.text ?? '')?.[1] ?? NaN); + }; + const without = intercepted(['air_siege_1']); + const evading = intercepted(['air_siege_1', 'evasion']); + ok(without > 0 && Math.abs(evading - without / 2) <= 1, `half the damage (${evading} to ${without})`); +}); + +test('a nuclear strike on a Carrier loses its aircraft once, with it', () => { + const { game, at } = blank(); + for (const p of game.players) p.bot = false; + techsTo(game, 0, 15); + const home = city(game, 0, at(3, 5), { buildings: ['manhattan_project'] }); + const theirs = city(game, 1, at(12, 5)); + war(game, 0, 1); + game.tiles[at(13, 5)].terrain = 'coast'; + const carrier = unit(game, 'carrier', 1, at(13, 5), { hp: 20 }); + // (Wounded, so the blast would finish it off again.) + const fighter = unit(game, 'fighter', 1, carrier.tile, { carrier: carrier.id, hp: 20 }); + const bystander = unit(game, 'worker', 1, at(20, 12)); + const missile = unit(game, 'nuclear_missile', 0, home.tile); + updateAllVisibility(game); + ok(apply(game, 0, { t: 'attack', unit: missile.id, target: theirs.tile }).ok, 'the strike'); + ok(!game.units.includes(carrier) && !game.units.includes(fighter), 'the Carrier and its fighter are lost'); + ok(game.units.includes(bystander), 'and nothing else (a unit far off) with them'); + eq(game.players[1].notes.filter((n) => /Fighter/.test(n.text)).length, 1, 'the fighter is lost once'); +}); + +test('a city burnt to the ground loses the aircraft there; one liberated sends its liberator’s aircraft to another base', () => { + const { game, at } = blank(['egypt', 'babylon', 'china'], { size: 'small' }); + city(game, 0, at(3, 3)); + const near = city(game, 0, at(3, 9)); + city(game, 1, at(28, 3)); + city(game, 2, at(16, 18)); + const burning = city(game, 0, at(10, 3), { pop: 1 }); + Object.assign(burning, { founder: 1, takenFrom: 1, razing: 1 }); + const lost = unit(game, 'fighter', 0, burning.tile); + endTurn(game); + ok(!game.cities.includes(burning), 'the city is gone'); + ok(!game.units.includes(lost), 'its fighter with it'); + // Liberated: it flies to the nearest of its civ's bases in reach. + const town = city(game, 0, at(8, 9), { pop: 4 }); + Object.assign(town, { founder: 2, takenFrom: 1, puppet: true }); + const flies = unit(game, 'bomber', 0, town.tile); + ok(apply(game, 0, { t: 'cityChoice', city: town.id, choice: 'liberate' }).ok, 'liberated'); + ok(game.units.includes(flies) && flies.tile === near.tile, `the bomber flew to the nearest city (${flies.tile})`); +}); + test('attacking breaks fortifying: a fortified unit that wins and moves in isn’t fortified there', () => { const { game, at } = blank(); city(game, 0, at(3, 3)); diff --git a/src/games/blockilization/views.ts b/src/games/blockilization/views.ts index 298a582..6619df6 100644 --- a/src/games/blockilization/views.ts +++ b/src/games/blockilization/views.ts @@ -1,5 +1,6 @@ import { adviseBuild } from './advisor'; import { packKnown, packStatic, packYields, type CityInfo, type CityWire, type ClanWire, type DemographicId, type MapWire, type Relation, type SelfWire, type StatsInfo, type UnitWire, type View } from './protocol'; +import { rebaseSpots } from './rules/air'; import { BUILDINGS } from './rules/buildings'; import { canPillage, cityMaxHp, cityStrength, strength, type Modifier } from './rules/combat'; import { atWar, citiesOf, civEffects, has, knows, seesResource, seesUnit, techCost } from './rules/core'; @@ -374,6 +375,7 @@ export function viewFor(game: Game, p: Player | null, timer: number | null, pres const home = UNITS[u.type]?.abilities?.includes('trade_route') ? routeHome(game, p!, u) : undefined; if (home) w.destinations = tradeDestinations(game, p!, home, UNITS[u.type].domain === 'sea'); if (canPillage(game, u)) w.pillage = true; + if (UNITS[u.type]?.domain === 'air') w.bases = rebaseSpots(game, u); if (isMilitary(UNITS[u.type])) { const mods: Modifier[] = []; w.defence = { strength: Math.round(strength(game, u, 'defend', null, u.tile, mods) * 10) / 10, mods };