Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/games/blockilization/client/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const CONCEPTS: Article[] = [
{ title: 'Terrain and position', text: 'Defending on hills, in forest or jungle is stronger; marsh and open flood plains are weaker. Fortifying (F) adds to defence each turn up to +50%. Each of your other units next to the target adds 10% to an attack (flanking). A [[unit:great_general|Great General]] adds 15% to units within two tiles.' },
{ title: 'Zones of control', text: 'Stepping from one tile next to an enemy unit or city to another next to it ends a unit’s move. Crossing a river without a bridge ([[tech:construction|Construction]]) or embarking takes all its moves.' },
{ title: 'Experience', text: 'Fighting earns experience, and at each step a unit takes a [[cat:promotion|promotion]]. Barracks and the like start new units with experience.' },
{ title: 'Line of sight', text: 'A ranged unit fires at what it can see. Next door it always can; further off, forest, jungle, hills and mountains block its view of what lies behind them (not the tile itself), though a line running past the edge of one can look round it from two tiles away. From a hill a unit sees over forest and other hills, but not over wooded hills or mountains. With Indirect Fire (Artillery, Rocket Artillery and the Battleship have it, and archers, siege units and ships can earn it) a unit fires over all of it, at anything your units see.' },
{ title: 'Cities', text: 'Siege units hit cities twice as hard; mounted and armoured units are weaker against them. A city fires once a turn at a unit within two tiles.' },
{ title: 'Corps and armies', text: 'From the Industrial Era, units of a kind side by side can join as one, far stronger: see [[concept:formations|Corps and armies]].' },
],
Expand Down Expand Up @@ -335,6 +336,7 @@ export const ABILITY_TEXT: Record<string, string> = {
bonus_vs_cities: '+100% strength against cities.',
penalty_vs_cities: '-33% strength against cities.',
must_setup: 'Must set up (a move) before it can fire.',
indirect_fire: 'Indirect Fire: fires over hills, forest, jungle and mountains in the way, at anything your units see.',
no_attack: 'Cannot attack.',
coast_only: 'Cannot enter the open ocean.',
see_submarines: 'Sees submarines.',
Expand Down
5 changes: 3 additions & 2 deletions src/games/blockilization/client/pedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ function unitPage(u: UnitType): Page {
if (abilities.length) body.push(h('ul.pd-list', {}, ...abilities.map((a) => h('li', {}, a))));
const replacedBy = UNIT_LIST.filter((x) => x.replaces === u.id).map((x) => `unit:${x.id}`);
const from = UNIT_LIST.filter((x) => x.upgrade === u.id).map((x) => `unit:${x.id}`);
const promos = u.class === 'civilian' || u.class === 'great' || u.class === 'trade' || u.class === 'religious' ? [] : PROMOTION_LIST.filter((p) => p.classes.includes(u.class) && !p.needs?.length).map((p) => `promotion:${p.id}`);
const promos = u.class === 'civilian' || u.class === 'great' || u.class === 'trade' || u.class === 'religious' ? [] : PROMOTION_LIST.filter((p) => p.classes.includes(u.class) && !p.needs?.length && !p.needsOne?.length).map((p) => `promotion:${p.id}`);
return {
sub: u.civ ? `${CIVS[u.civ]?.name} · unique unit` : u.unbuildable ? 'Not built in cities' : `${ERA_NAMES[eraOfTech(u.tech)]} unit`,
facts,
Expand Down Expand Up @@ -755,7 +755,8 @@ function promotionPage(id: string): Page {
body: [rich(p.text), rich('A unit takes a promotion each time it has the experience for one: at 10, 30, 60, 100 and so on.')],
related: [
['Needs', (p.needs ?? []).map((n) => `promotion:${n}`)],
['Leads to', PROMOTION_LIST.filter((x) => x.needs?.includes(id)).map((x) => `promotion:${x.id}`)],
['Needs one of', (p.needsOne ?? []).map((n) => `promotion:${n}`)],
['Leads to', PROMOTION_LIST.filter((x) => x.needs?.includes(id) || x.needsOne?.includes(id)).map((x) => `promotion:${x.id}`)],
],
};
}
Expand Down
31 changes: 16 additions & 15 deletions src/games/blockilization/client/store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { unpackKnown, unpackStatic, unpackYields, type CityInfo, type DealCheck, type KnownTile, type LobbyView, type MapWire, type ReshapeWire, type PreviewAsk, type ResearchAdvice, type StatsInfo, type UnitWire, type View } from '../protocol';
import { indirectFire } from '../rules/combat';
import type { DealItem } from '../rules/diplomacy';
import { distance, line, type Grid } from '../rules/hex';
import { clear, distance, ray, type Grid } from '../rules/hex';
import type { Note } from '../rules/state';
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 { seeFromLevel, seeThroughLevel, type Yields } from '../rules/terrain';
import { isGreatPerson, isMilitary, UNITS } from '../rules/units';

/**
Expand Down Expand Up @@ -339,17 +340,18 @@ export function nukeable(u: UnitWire, t: number): boolean {
return enemyAt(t) || (owner >= 0 && owner !== me() && !!v.players[owner]?.war);
}

/**
* Whether a unit could attack a tile from where it stands: a fighting unit, an enemy in sight there,
* in reach; and for a shot past the next tile, nothing in the way (hills, forest, jungle or mountains
* between, unless it fires from a hill), as the rules' `canFire` has it. Aircraft need no line of sight.
*/
/** Why a ranged unit can't fire at an enemy it's pointed at: too far, or something in the way of its shot. */
export function outOfReach(u: UnitWire, t: number): string {
const g = store.grid;
return g && distance(g, u.tile, t) <= reachOf(u) ? 'Hills, forest or jungle block its shot' : `Out of range (${reachOf(u)})`;
return g && distance(g, u.tile, t) <= reachOf(u) ? 'Hills, forest, jungle or mountains block its shot' : `Out of range (${reachOf(u)})`;
}

/**
* Whether a unit could attack a tile from where it stands: a fighting unit, an enemy in sight there,
* in reach; and for a shot past the next tile, a line of sight to it, as the rules' `canFire` has it
* (`inSight`: what stands higher than the shooter blocks, unless it has Indirect Fire). Aircraft need
* no line of sight.
*/
export function attackable(u: UnitWire, t: number): boolean {
const type = UNITS[u.type];
const g = store.grid;
Expand All @@ -368,13 +370,12 @@ export function attackable(u: UnitWire, t: number): boolean {
}
const d = distance(g, u.tile, t);
if (d > reachOf(u)) return false;
if (d <= 1 || type.domain === 'air' || store.terrain[u.tile]?.elevation === 'hills') return true;
return line(g, u.tile, t)
.slice(1, -1)
.every((x) => {
const s = store.terrain[x];
return !!s && s.elevation !== 'mountain' && s.elevation !== 'hills' && s.feature !== 'forest' && s.feature !== 'jungle';
});
if (d <= 1 || type.domain === 'air' || indirectFire(u)) return true;
const from = store.terrain[u.tile];
if (!from) return false;
const eye = seeFromLevel(from);
// (A tile not known blocks it, as the edge of the map does.)
return clear(ray(g, u.tile, t), (x) => !store.terrain[x] || seeThroughLevel(store.terrain[x]) > eye);
}

/** Ask before doing something drastic: a dialog of the game's own, `then` on yes. */
Expand Down
8 changes: 4 additions & 4 deletions src/games/blockilization/rules/ai/military.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { sizeOf } from '../armies';
import { canFire, canPillage, cityFireStrength, cityMaxHp, cityStrength, rangeOf, strength } from '../combat';
import { canFire, canPillage, cityFireStrength, cityMaxHp, cityStrength, indirectFire, rangeOf, strength } from '../combat';
import { canStand, citiesOf } from '../core';
import { weighing } from '../economy';
import { neighbours, within } from '../hex';
Expand Down Expand Up @@ -151,7 +151,7 @@ function bestStrike(ctx: Ctx, u: Unit, garrison: boolean): Strike | null {
if (needsSetup && u.moves < 2) return null;
const range = rangeOf(u);
// (A mounted archer next to its target would charge instead of shooting: only from range.)
for (const t of within(game.grid, u.tile, range)) if (t !== u.tile && canFire(game, u.tile, t, range) && !(type.class === 'mounted' && dist(ctx, u.tile, t) === 1)) consider(t, u.tile, needsSetup);
for (const t of within(game.grid, u.tile, range)) if (t !== u.tile && canFire(game, u.tile, t, range, indirectFire(u)) && !(type.class === 'mounted' && dist(ctx, u.tile, t) === 1)) consider(t, u.tile, needsSetup);
return best;
}
if (garrison) return null;
Expand Down Expand Up @@ -404,7 +404,7 @@ export function moveUnits(ctx: Ctx, garrisons: Set<number>) {
goTo(ctx, u, camp, 'camp');
continue;
}
const spot = freeSpot(ctx, u, camp, fires(u) ? 2 : 1, claimed, (t) => t !== camp && (!fires(u) || canFire(game, t, camp, rangeOf(u))));
const spot = freeSpot(ctx, u, camp, fires(u) ? 2 : 1, claimed, (t) => t !== camp && (!fires(u) || canFire(game, t, camp, rangeOf(u), indirectFire(u))));
if (spot === undefined) continue;
claimed.add(spot);
goTo(ctx, u, spot, 'camp');
Expand Down Expand Up @@ -571,7 +571,7 @@ function siege(ctx: Ctx, army: Unit[], claimed: Set<number>) {
if (!u.embarked && !sameLand(game, u.tile, target.tile)) continue;
if (fires(u)) {
const r = rangeOf(u);
const spot = freeSpot(ctx, u, target.tile, r, claimed, (t) => dist(ctx, t, target.tile) >= (type.class === 'mounted' ? 2 : Math.min(2, r)) && canFire(game, t, target.tile, r));
const spot = freeSpot(ctx, u, target.tile, r, claimed, (t) => dist(ctx, t, target.tile) >= (type.class === 'mounted' ? 2 : Math.min(2, r)) && canFire(game, t, target.tile, r, indirectFire(u)));
if (spot === undefined) {
if (far > r + 1) goTo(ctx, u, target.tile, 'siege');
continue;
Expand Down
6 changes: 3 additions & 3 deletions src/games/blockilization/rules/ai/navy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { canFire, rangeOf } from '../combat';
import { canFire, indirectFire, rangeOf } from '../combat';
import { canStand } from '../core';
import { trainable } from '../game';
import { neighbours, within } from '../hex';
Expand Down Expand Up @@ -154,7 +154,7 @@ export function moveNavy(ctx: Ctx, ships: Unit[], claimed: Set<number>) {
}
// With an invasion: off the target city (to batter it) or off the beach (to guard the landing).
if (spot === undefined && target && plan?.amphibious) {
spot = ranged ? seaSpot(ctx, u, target.tile, rangeOf(u), claimed, (t) => canFire(game, t, target.tile, rangeOf(u))) : plan.landing !== undefined ? seaSpot(ctx, u, plan.landing, 2, claimed) : undefined;
spot = ranged ? seaSpot(ctx, u, target.tile, rangeOf(u), claimed, (t) => canFire(game, t, target.tile, rangeOf(u), indirectFire(u))) : plan.landing !== undefined ? seaSpot(ctx, u, plan.landing, 2, claimed) : undefined;
}
// Enemies at sea near home: after them.
if (spot === undefined && threats.length) {
Expand All @@ -164,7 +164,7 @@ export function moveNavy(ctx: Ctx, ships: Unit[], claimed: Set<number>) {
// A war: bombard the nearest enemy city on the coast (ranged ships), the melee ones with them.
if (spot === undefined && shores.length) {
const city = [...shores].sort((a, b) => dist(ctx, a.tile, u.tile) - dist(ctx, b.tile, u.tile))[0];
if (dist(ctx, city.tile, u.tile) <= 14) spot = seaSpot(ctx, u, city.tile, ranged ? rangeOf(u) : 2, claimed, (t) => !ranged || canFire(game, t, city.tile, rangeOf(u)));
if (dist(ctx, city.tile, u.tile) <= 14) spot = seaSpot(ctx, u, city.tile, ranged ? rangeOf(u) : 2, claimed, (t) => !ranged || canFire(game, t, city.tile, rangeOf(u), indirectFire(u)));
}
// Otherwise: on station off the bot's own city with the fewest ships about.
if (spot === undefined) {
Expand Down
4 changes: 2 additions & 2 deletions src/games/blockilization/rules/barbarians.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { atWar, cityAt, civOf, has, note, random, spawnUnit, unitsOf } from './core';
import { service } from './citystates';
import { canFire, canPillage, melee, pillage, ranged } from './combat';
import { canFire, canPillage, indirectFire, melee, pillage, ranged } from './combat';
import { distance, neighbours, within } from './hex';
import { canEnter, occupants, planPath, campHooks } from './movement';
import { campCleared } from './quests';
Expand Down Expand Up @@ -100,7 +100,7 @@ function raid(game: Game, u: Unit, b: number) {
const city = cityAt(game, t);
const whose = city ? city.owner : enemy && atWar(game, b, enemy.owner) ? enemy.owner : -1;
if (whose < 0 || spared(whose)) continue;
if (type.ranged && !canFire(game, u.tile, t, type.range ?? 1)) continue;
if (type.ranged && !canFire(game, u.tile, t, type.range ?? 1, indirectFire(u))) continue;
if (city && !type.ranged && city.hp <= 30) continue; // they don't take cities
const r = type.ranged ? ranged(game, u, t) : melee(game, u, t);
if (r) return;
Expand Down
24 changes: 12 additions & 12 deletions src/games/blockilization/rules/combat.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { formationBonus, formationKind, sizeOf, unitName } from './armies';
import { BUILDINGS } from './buildings';
import { atWar, canStand, cityAt, cityById, citiesOf, note, random, removeUnit, seesUnit, speed, updateVisibility } from './core';
import { atWar, canStand, cityAt, cityById, citiesOf, inSight, note, random, removeUnit, seesUnit, speed, updateVisibility } from './core';
import { assignCitizens, blockaded, cityEffects, civEffects, garrisoned, has, happiness } from './economy';
import type { CombatWhen } from './effects';
import { cityReligion } from './faith';
import { distance, line, neighbours, within } from './hex';
import { distance, neighbours, ray, within } from './hex';
import { campHooks, civiliansAt, occupants } from './movement';
import { fromPromotions, PROMOTIONS, type PromotionWhen } from './promotions';
import type { City, Game, Unit } from './state';
Expand Down Expand Up @@ -290,19 +290,19 @@ export function gain(game: Game, u: Unit, xp: number, foe: number) {
if (!barbarian && !p.cityState && p.civ !== 'barbarian') cityHooks.greatPoints(game, u, xp);
}

/** Whether `from` can see `to` for a ranged attack (range, and nothing in the way unless it's next door). */
export function canFire(game: Game, from: number, to: number, range: number): boolean {
/**
* Whether `from` can fire on `to` (Civ V's): in range, and in sight of it past what's between (next
* door always: `inSight`). A unit with Indirect Fire fires over it all, at whatever its side sees.
*/
export function canFire(game: Game, from: number, to: number, range: number, indirect = false): boolean {
const d = distance(game.grid, from, to);
if (d < 1 || d > range) return false;
if (d === 1) return true;
return line(game.grid, from, to)
.slice(1, -1)
.every((t) => {
const tile = game.tiles[t];
return tile.elevation !== 'mountain' && !(tile.elevation === 'hills' || tile.feature === 'forest' || tile.feature === 'jungle') || game.tiles[from].elevation === 'hills';
});
return indirect || inSight(game, from, ray(game.grid, from, to));
}

/** Whether a unit fires over what's in the way: Indirect Fire (Artillery's, Rocket Artillery's and the Battleship's, or a promotion). */
export const indirectFire = (u: { type: string; promotions?: string[] }) => !!UNITS[u.type]?.abilities?.includes('indirect_fire') || !!u.promotions?.some((p) => PROMOTIONS[p]?.indirect);

/** A unit's range (more with the Range promotion). */
export const rangeOf = (u: Unit) => (UNITS[u.type]?.range ?? 1) + fromPromotions(u, 'range');

Expand Down Expand Up @@ -370,7 +370,7 @@ export function melee(game: Game, u: Unit, target: number): CombatResult | null
/** A ranged attack on a military unit or a city it can see: no reply, and it doesn't advance. Not from a boat. */
export function ranged(game: Game, u: Unit, target: number): CombatResult | null {
const type = UNITS[u.type];
if (!type.ranged || u.embarked || !canFire(game, u.tile, target, rangeOf(u))) return null;
if (!type.ranged || u.embarked || !canFire(game, u.tile, target, rangeOf(u), indirectFire(u))) return null;
if (type.abilities?.includes('must_setup') && !u.setUp) return null;
const p = game.players[u.owner];
if (!(p.visible[target] > 0)) return null;
Expand Down
34 changes: 16 additions & 18 deletions src/games/blockilization/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { BUILDINGS } from './buildings';
import { CITY_STATES, CIVS, unitFor } from './civs';
import { handicapEffects, handicapOf } from './difficulty';
import type { Effects } from './effects';
import { distance, line, neighbours, within, type Grid } from './hex';
import { clear, distance, neighbours, ray, within, type Grid, type Ray } from './hex';
import { generate, placeVolcanoes, Rng } from './mapgen';
import { FIRST_FINDER_GOLDEN, FOUND_HAPPINESS, NATURAL_WONDERS, wondersBeside } from './nature';
import { policyEffects } from './policies';
import { fromPromotions } from './promotions';
import { beliefEffects } from './religion';
import { earthSize, MAP_SIZES, SPEEDS, type City, type Game, type Note, type Player, type Settings, type Unit } from './state';
import { bestUnit, eraStartOf, scenarioOf, startEraOf, startTechs } from './starts';
import { RESOURCES, TERRAINS } from './terrain';
import { RESOURCES, seeFromLevel, seeThroughLevel, TERRAINS } from './terrain';
import { bindTeams, teammates, teamOf } from './teams';
import { baseTechCost, ERA_NAMES, ERAS, TECHS } from './techs';
import { UNITS } from './units';
Expand Down Expand Up @@ -309,36 +309,34 @@ export function removeUnit(game: Game, u: Unit) {
// Fog of war
// -------------------------------------------------------------------------------------------------

/** What blocks sight: mountains, and hills, forests and jungles (which are seen, but not past). */
const blocks = (game: Game, t: number) => {
const tile = game.tiles[t];
return tile.elevation === 'mountain' || tile.elevation === 'hills' || tile.feature === 'forest' || tile.feature === 'jungle';
};

/**
* The tiles within `radius` of a tile, each with those between them on the line of sight: the grid's
* alone (a map of that size always has the same), so worked out once for each tile and radius.
* The tiles within `radius` of a tile, each with the way a line of sight takes to it: the grid's alone
* (a map of that size always has the same), so worked out once for each tile and radius.
*/
const viewLines = new Map<string, Map<number, { t: number; between: number[] }[]>>();
function linesOfSight(g: Grid, from: number, radius: number): { t: number; between: number[] }[] {
const viewLines = new Map<string, Map<number, { t: number; way: Ray }[]>>();
function linesOfSight(g: Grid, from: number, radius: number): { t: number; way: Ray }[] {
const size = `${g.width}x${g.height}${g.wrap ? 'w' : ''}`;
const lines = viewLines.get(size) ?? new Map<number, { t: number; between: number[] }[]>();
const lines = viewLines.get(size) ?? new Map<number, { t: number; way: Ray }[]>();
viewLines.set(size, lines);
const key = from * 16 + radius;
let out = lines.get(key);
if (!out) {
out = within(g, from, radius).map((t) => ({ t, between: distance(g, from, t) <= 1 ? [] : line(g, from, t).slice(1, -1) }));
out = within(g, from, radius).map((t) => ({ t, way: ray(g, from, t) }));
lines.set(key, out);
}
return out;
}

/** The tiles a unit or city on `from` sees within `radius` (higher ground sees over what blocks). */
/** Whether `from` sees the end of a ray from it past what's between (`hex.ts`'s `clear`): only what stands higher than `from` blocks (`seeThroughLevel`). */
export function inSight(game: Game, from: number, way: Ray): boolean {
const eye = seeFromLevel(game.tiles[from]);
return clear(way, (x) => seeThroughLevel(game.tiles[x]) > eye);
}

/** The tiles a unit or city on `from` sees within `radius` (from higher ground, over more of what blocks). */
export function sight(game: Game, from: number, radius: number): number[] {
const high = game.tiles[from].elevation === 'hills';
const out: number[] = [];
// Seen unless something between blocks the view (from a hill, only mountains do).
for (const { t, between } of linesOfSight(game.grid, from, radius)) if (between.every((x) => (high ? game.tiles[x].elevation !== 'mountain' : !blocks(game, x)))) out.push(t);
for (const { t, way } of linesOfSight(game.grid, from, radius)) if (inSight(game, from, way)) out.push(t);
return out;
}

Expand Down
Loading
Loading