Flew aircraft and missiles straight and let strikes target fogged cities

This commit is contained in:
2026-09-24 20:25:08 +02:00
parent 36ac6698a8
commit a219cf650f
21 changed files with 829 additions and 127 deletions
+16 -2
View File
@@ -132,8 +132,8 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`strikeReadyHour` cooldown. The strike is charged up front per aircraft
(`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and
the ferry is bounded by fuel, not the combat `missionRange`; they never join
melee. A strike may target a garrison inside a hostile city — the plane enters
that tile as a goal only to bomb and turns for home, and captures skip
melee. A strike may target a garrison inside a hostile city — the plane reaches
that tile only to bomb and turns for home, and captures skip
aircraft, so it never occupies or takes the city. A strike is only accepted
when the whole round trip fits the remaining
fuel (out to the target, then home to a friendly airport), and the browser
@@ -142,6 +142,20 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
snapshot and
`map_view/motion.js` draws the red dashed line and target ring on
`#layer-targets` while either endpoint is selected.
- **Aircraft and launched missiles fly straight lines, not hexes**
(`GameState._isFlier`). Their `path` is a list of world-pixel points (the
two-point `flightLine` from `shared/hex.js`, using `nearestCopyPoint` for the
wrapped seam), and `_advanceFlier`/`_completeFlightLeg` in `movement.js` fly
them in `advanceMovement`; `_segmentHours` is the single source for flight
time and fuel, so a plane can never run dry on a route its own check accepted.
`findPath` returns a straight line for a flier and never runs A*. The browser
mirrors all of this in `map_view/motion.js` (`_planMove`, `_flightHours`,
`_motionTile`, `motion.flight`): a mismatch makes the icon fight the server.
A missile (`requestMissileStrike`) now flies to its target and detonates on
arrival in `_detonateMissile`, so air defense can shoot it down first.
Air defense is a **corridor**, not a tile-entry volley: `_airDefenseVolley`
fires once per in-game hour from every hostile battery within
`AIR_DEFENSE.rangeTiles` of the flier, charged per firing battery.
- Terrain generation is deterministic from `(seed, mapConfig)`, so
`GameState._buildTerrain` and the settlement/road step cache the world shape
(`shared/game_state/world.js`) rather than regenerating it for every game with
+1 -1
View File
@@ -322,7 +322,7 @@ simplification worth revisiting. Anything unticked is still open.
(any unit caught in the blast is destroyed too)
- [x] Stacks must behave as single units when it comes to pathfinding
- [x] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- [ ] Airplanes move in straight lines as they are not bound by tiles.
- [x] Airplanes move in straight lines as they are not bound by tiles.
* [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them
### Intelligence
+8
View File
@@ -387,6 +387,14 @@ input:focus, select:focus { border-color: var(--accent); }
/* Picking a target takes over the cursor. */
#map-viewport.targeting { cursor: crosshair; }
#map-viewport.targeting.dragging { cursor: grabbing; }
/* While picking a target every click belongs to the map. The unit and city
icons must not swallow it: a click on an enemy unit icon would otherwise run
the normal selection path (clear the selection) instead of choosing the
target, so the Attack action appeared to do nothing. */
#map-viewport.targeting #layer-entities,
#map-viewport.targeting #layer-labels {
pointer-events: none;
}
/* The delivery graph, shown only in the economic map modes: a white line per
delivery leg, an animated merchandise icon riding it and a dot on every
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "36ac669",
"date": "2026-09-24",
"subject": "Let stacks split by shift-click and cross allied or at-war land",
"body": ""
},
{
"hash": "cc5c57c",
"date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Drew the cylindrical map as a repeating world",
"body": ""
},
{
"hash": "4600b16",
"date": "2026-09-23",
"subject": "Stockpiled a month of every material and cached the trade walks",
"body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended."
}
];
+19 -2
View File
@@ -11,6 +11,7 @@ import { MapView } from "./map_view.js";
import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { flightDistanceTiles } from "../../shared/hex.js";
import { bootLog } from "./loading.js";
import { formatCapabilityReport } from "./capabilities.js";
import { panelMethods } from "./game_screen/panels.js";
@@ -456,6 +457,19 @@ export class GameScreen {
}
}
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// An attack on a neutral nation's unit or city declares war exactly as
// marching into its land does, so the browser asks first. The territory
// scan in `_groupMovePlan` only names owners a ground route crosses; a
// strike on an aircraft's straight line, or on a defenceless city, must be
// named here.
if (type === "attack") {
const defender = this._attackTargetCiv(coords);
if (defender >= 0 && !targets.includes(defender) &&
!this._isAtWar(this.localCiv, defender) &&
!this._isAllied(this.localCiv, defender)) {
targets.push(defender);
}
}
// A strike is refused before it is ever sent when the target lies beyond
// the unit's reach, so the browser and the server never disagree. An air
// strike must fit the whole round trip, not just the flight out.
@@ -478,9 +492,12 @@ export class GameScreen {
if (!route) return false;
if (!proto.air) return false;
// A ferry flight is bounded by fuel, not the combat mission radius:
// endurance hours at the aircraft's speed.
// endurance hours at the aircraft's speed. The flight is a straight line,
// so distance is measured along it rather than by counting path tiles.
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
return route.length - 1 > ferryTiles;
const from = { x: unit.coords[0], y: unit.coords[1] };
return flightDistanceTiles(from, coords, this.map.topology, this.map._maxStepLength) >
ferryTiles;
});
if (outOfRange) {
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
+47 -13
View File
@@ -47,6 +47,7 @@ import { defenseFactors, unitDefense, productionFactors, productionMultiplier, p
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { flightDistanceTiles } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
@@ -312,6 +313,9 @@ export const panelMethods = {
if (!route || route.length < 2) continue;
routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue;
// A flight path is world pixels, not tiles, and an aircraft crosses
// foreign land without declaring war; only ground marches name targets.
if (this._isAirUnit(unit)) continue;
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
@@ -616,8 +620,9 @@ export const panelMethods = {
},
// A left-click in target-picking mode. A ranged attack fires when the tile
// holds a hostile military unit; a spy operation files its report or opens its
// bombardment when the tile holds the city it wants.
// holds a hostile military unit, or -- for an aircraft -- a hostile city; a
// spy operation files its report or opens its bombardment when the tile holds
// the city it wants.
_chooseTarget(coords) {
const targeting = this._targeting;
if (!targeting) return;
@@ -644,7 +649,7 @@ export const panelMethods = {
}
return;
}
if (!this._isEnemyMilitaryAt(coords)) return;
if (!this._isEnemyMilitaryAt(coords) && !this._airStrikeOrder(units, coords)) return;
this._orderMove(units, coords, "attack");
},
@@ -671,8 +676,8 @@ export const panelMethods = {
return !!(proto && proto.air);
},
// Whether `coords` holds a hostile military unit, the only thing a strike may
// target.
// Whether `coords` holds a hostile military unit, the target of a ranged
// strike.
_isEnemyMilitaryAt(coords) {
return this.map.unitIdsAt(coords).some((id) => {
const data = this._unit(id);
@@ -680,6 +685,30 @@ export const panelMethods = {
});
},
// A city that is not ours, at `coords`. A city known to the player but hidden
// under the fog is still a legitimate target, so this reads the snapshot
// rather than the visible unit layer.
_isEnemyCityAt(coords) {
return this._cityCivAt(coords) >= 0;
},
// The hostiles a strike on `coords` would fall on: the enemy military unit
// there, else the non-friendly city. -1 when there is nothing to attack.
_attackTargetCiv(coords) {
const hostile = this.map.unitIdsAt(coords)
.map((id) => this._unit(id))
.find((data) => data && data.civ !== this.localCiv && this._isMilitaryUnit(data));
if (hostile) return hostile.civ;
return this._cityCivAt(coords);
},
_cityCivAt(coords) {
const city = ((this.snapshot && this.snapshot.cities) || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
return city && city.civ !== this.localCiv ? city.civ : -1;
},
// A ground/naval battery attack: resolved where the battery stands, with a
// standing hourly target rather than a march.
_rangedAttackOrder(units, coords) {
@@ -689,12 +718,13 @@ export const panelMethods = {
return this._isEnemyMilitaryAt(coords);
},
// An aircraft attack: the plane flies to the hostile unit and bombs it on
// arrival, then returns home to re-arm.
// An aircraft attack: the plane flies to the hostile unit or city and bombs
// it on arrival, then returns home to re-arm. A city is a valid target even
// when it is under the fog and even when its garrison is not visible.
_airStrikeOrder(units, coords) {
if (units.length === 0) return false;
if (!units.every((unit) => this._isAirUnit(unit))) return false;
return this._isEnemyMilitaryAt(coords);
return this._isEnemyMilitaryAt(coords) || this._isEnemyCityAt(coords);
},
// Why an air strike cannot be issued right now, or null when it can. Blocks a
@@ -765,14 +795,18 @@ export const panelMethods = {
const toTarget = this.map.topology.tileDistance(from, coords);
const airport = this._airReturnAirport(unit, coords);
if (!airport) return "This aircraft has no friendly airport to return to.";
const toHome = this.map.topology.tileDistance(coords, {
x: airport.coords[0],
y: airport.coords[1],
});
const home = { x: airport.coords[0], y: airport.coords[1] };
const speed = proto.speed || 1;
const fuel = unit.airHours === undefined || unit.airHours === null
? proto.enduranceHours
: unit.airHours;
// Fuel is spent in straight-line hours: out to the target, then on to the
// nearest friendly airport. Mirrors GameState._canStrikeAndReturn, so the
// browser refuses exactly what the server would.
const maxStep = this.map._maxStepLength || 1;
const hours =
flightDistanceTiles(from, coords, this.map.topology, maxStep) / speed +
flightDistanceTiles(coords, home, this.map.topology, maxStep) / speed;
// The furthest it can strike: half the fuel buys the flight out, the other
// half the flight home, capped by the mission radius.
const effectiveRange = Math.floor(
@@ -780,7 +814,7 @@ export const panelMethods = {
);
const beyondMission =
toTarget > (proto.missionRange || Infinity);
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9;
const beyondFuel = hours > fuel + 1e-9;
if (beyondMission || beyondFuel) {
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
}
+10 -3
View File
@@ -643,10 +643,11 @@ export const entityMethods = {
_currentTile(motion) {
if (motion.path.length >= 2) {
// A flight stores world pixels, so its tile comes from where it is on the
// line; a ground path stores tiles and is grouped with the one it heads
// to, so a stack splits the moment one of its members marches away.
if (motion.flight) return this._motionTile(motion);
const index = pathProgress(motion).index;
// A unit that has been ordered off a tile no longer belongs to that
// tile's stack: it is grouped with the tile it is heading to, so the
// stack splits the moment one of its members marches away.
return motion.path[Math.min(index + 1, motion.path.length - 1)];
}
return motion.coords;
@@ -687,11 +688,17 @@ export const entityMethods = {
// selected stack can be ordered onto this tile (move, stack or attack).
// Shift detaches just this unit to the moving group instead.
if (event.button !== 0) return;
// While picking a ranged target the click belongs to the map, even when
// it lands on an icon; swallowing it here would clear the attacker's
// selection instead of choosing the target.
if (this._targeting) return;
event.stopPropagation();
if (event.shiftKey) this.onUnitToggled(data.id);
else this.onUnitSelected(data.id);
});
view.addEventListener("dblclick", (event) => {
// A double-click while picking a target is just two target clicks.
if (this._targeting) return;
// A unit standing on one of our cities yields to it: double-clicking the
// unit opens the city, exactly as double-clicking the city icon would.
const current = this._unitData.get(data.id) || data;
+94 -34
View File
@@ -1,7 +1,13 @@
// Dead-reckoning unit motion between snapshots, path drawing and the local
// move prediction that mirrors GameState.find_path.
import { mapToLocal, key, parseKey } from "../../../shared/hex.js";
import {
mapToLocal,
localToMap,
flightLine,
key,
parseKey,
} from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js";
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
@@ -51,9 +57,14 @@ export const motionMethods = {
const id = data.id;
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
let segmentHours = data.segmentHours || [];
// A flight path is already a straight pixel line with no tile occupants to
// stop it: the truncation walk works in tiles and must not touch it.
const flight = this._isFlierData(data);
if (!flight) {
const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy);
path = clipped.path;
segmentHours = clipped.segmentHours;
}
const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
@@ -64,6 +75,7 @@ export const motionMethods = {
segmentHours: [],
pathHours: 0,
coords: { x: data.coords[0], y: data.coords[1] },
flight,
predicting: false,
predictAge: 0,
});
@@ -81,6 +93,7 @@ export const motionMethods = {
segmentHours,
pathHours,
coords: { x: data.coords[0], y: data.coords[1] },
flight,
predicting: false,
predictAge: 0,
});
@@ -185,10 +198,14 @@ export const motionMethods = {
}
const from = path[index];
const to = path[index + 1] || from;
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
const step = segmentHours[index] || 1;
const t = Math.max(0, Math.min(1, remaining / step));
// A flight path is already world pixels: interpolate the segment directly.
if (motion.flight) {
return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
}
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
return { x: a.x + delta.x * t, y: a.y + delta.y * t };
},
@@ -204,14 +221,21 @@ export const motionMethods = {
if (!this._selectedUnitIds.has(id)) continue;
if (motion.path.length < 2) continue;
const index = pathProgress(motion).index;
const base = mapToLocal(motion.path[index].x, motion.path[index].y);
const base = motion.flight
? motion.path[index]
: mapToLocal(motion.path[index].x, motion.path[index].y);
const current = this._positionAlongPath(motion);
let previous = base;
for (let i = index + 1; i < motion.path.length; i++) {
const from = motion.path[i - 1];
const to = motion.path[i];
const delta = this.topology.pixelDelta(from, to);
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
// A flight path stores pixel points already; a ground path stores tiles
// and is unwrapped a segment at a time.
const next = motion.flight
? to
: (() => {
const delta = this.topology.pixelDelta(motion.path[i - 1], to);
return { x: previous.x + delta.x, y: previous.y + delta.y };
})();
const start = i === index + 1 ? current : previous;
fragment.appendChild(makeSegment(start, next, "#ffffff", 4));
previous = next;
@@ -236,12 +260,13 @@ export const motionMethods = {
return mapToLocal(motion.coords.x, motion.coords.y);
},
// Draws the red dashed barrage arc from every ranged attacker with a standing
// target to the target tile, and a red ring around the target. The shell lobs
// in a parabola rather than along the ground, and the dashes march and a
// glowing shell travels the curve so the direction is unmistakable. They stay
// visible so an ongoing barrage cannot be forgotten, and sit on a layer above
// the terrain but below the unit and city icons.
// Draws the red dashed line from every ranged attacker with a standing target
// to the target tile, and a red ring around the target. A gun's shell lobs in
// a parabola rather than along the ground; an aircraft's strike is no ballistic
// launch, so its line stays straight. The dashes march and a glowing shell
// travels the line so the direction is unmistakable. They stay visible so an
// ongoing barrage cannot be forgotten, and sit on a layer above the terrain but
// below the unit and city icons.
//
// The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when
// its contents change; the per-strike target lookup goes through one index of
@@ -296,10 +321,12 @@ export const motionMethods = {
const a = fromPoint;
const b = this._nearestCopy(toPoint, fromPoint);
const distance = Math.hypot(b.x - a.x, b.y - a.y);
// A quadratic Bezier lobs the shell: the control point is lifted above the
// midpoint, its height compensated for the layer's Y foreshortening so the
// arc keeps its shape on screen.
const arc = Math.max(18, distance * 0.4) / CAMERA_TILT;
// A gun lobs its shell: a quadratic Bezier whose control point is lifted
// above the midpoint, its height compensated for the layer's Y
// foreshortening so the arc keeps its shape on screen. An aircraft flies
// straight to the target, so its line is a plain segment.
const straight = !!(this.protoUnits[data.proto] || {}).air;
const arc = straight ? 0 : Math.max(18, distance * 0.4) / CAMERA_TILT;
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2 - arc;
let view = views.get(id);
@@ -316,15 +343,20 @@ export const motionMethods = {
view = { path, shell, ring };
views.set(id, view);
}
view.path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`);
view.path.setAttribute(
"d",
straight
? `M ${a.x} ${a.y} L ${b.x} ${b.y}`
: `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`
);
view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line");
// Marching dashes flow from the battery towards the target.
view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`;
// A glowing shell rides the curve to show exactly where it is going.
// A glowing shell rides the line to show exactly where it is going.
const t = (now / 1300) % 1;
const mt = 1 - t;
view.shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
view.shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
view.shell.setAttribute("cx", `${straight ? a.x + (b.x - a.x) * t : mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
view.shell.setAttribute("cy", `${straight ? a.y + (b.y - a.y) * t : mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
view.ring.className = selected ? "target-ring target-selected" : "target-ring";
view.ring.style.left = `${b.x}px`;
view.ring.style.top = `${b.y}px`;
@@ -528,9 +560,7 @@ export const motionMethods = {
const view = this._unitViews.get(id);
if (view && view.style.display === "none") continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y);
if (!index.has(k)) index.set(k, id);
}
@@ -567,9 +597,15 @@ export const motionMethods = {
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
const segmentHours = [];
if (proto.air || proto.missile) {
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._flightHours(proto, route[i - 1], route[i], speed));
}
} else {
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed));
}
}
this._applyPrediction(unitData, { route, segmentHours, start });
return route;
},
@@ -581,6 +617,7 @@ export const motionMethods = {
segmentHours: plan.segmentHours,
pathHours: 0,
coords: plan.start,
flight: this._isFlierData(unitData),
predicting: true,
predictAge: 0,
offset: existing ? existing.offset : { x: 0, y: 0 },
@@ -600,19 +637,39 @@ export const motionMethods = {
? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}`
: null;
if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
const path = await this._search(unitData, proto, start, goal, speed);
// A flier's route is a straight pixel line to the goal, exactly as the
// server builds it; there is no territory, terrain or zone to search.
const path = (proto.air || proto.missile)
? flightLine(mapToLocal(start.x, start.y), goal, this.topology)
: await this._search(unitData, proto, start, goal, speed);
let plan = null;
if (path && path.length >= 2) {
const segmentHours = [];
if (proto.air || proto.missile) {
segmentHours.push(this._flightHours(proto, path[0], path[1], speed));
} else {
for (let i = 1; i < path.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
}
}
plan = { route: path, segmentHours, start };
}
if (cacheKey) cache.set(cacheKey, plan);
return plan;
},
// Whether a snapshot unit flies a straight pixel path (aircraft or missile).
_isFlierData(data) {
const proto = this.protoUnits[data.proto];
return !!(proto && (proto.air || proto.missile));
},
// Hours to fly a straight pixel segment, mirroring GameState._segmentFlightHours.
_flightHours(proto, a, b, speed = null) {
const effective = Math.max(speed || proto.speed, 0.0001);
return (Math.hypot(b.x - a.x, b.y - a.y) / (this._maxStepLength || 1)) / effective;
},
// The A* this mirrors recomputes the cheapest movement cost on every node,
// and the tile scan behind it dominated the search. Cache it here (the
// TerrainStats memoises it too) and index the units once instead of scanning
@@ -712,9 +769,7 @@ export const motionMethods = {
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
if (tile.x === coords.x && tile.y === coords.y) result.push(data);
}
return result;
@@ -728,9 +783,7 @@ export const motionMethods = {
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y);
const list = index.get(k);
if (list) list.push(data);
@@ -756,16 +809,23 @@ export const motionMethods = {
return denied;
},
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
// The tile a motion is over right now. A flight stores world pixels, so its
// tile is derived from where the line currently is.
_motionTile(motion) {
if (motion.path.length >= 2) {
if (motion.flight) return localToMap(this._positionAlongPath(motion), this.topology);
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
}
return motion.coords;
},
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
return this._motionTile(motion);
},
// The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is
// allowed; every other foreign territory is a wall.
+6 -3
View File
@@ -63,10 +63,13 @@ export const AIR_STRIKE = {
cooldownHours: 48,
};
// A city's air defense fires one volley at a hostile aircraft the moment it
// enters the city's tile. Damage scales with the building's level.
// A city's air defense watches a corridor of `rangeTiles` around it: any
// hostile aircraft or missile inside that corridor takes continuous fire, dealt
// once per in-game hour, scaled by the building's level. Aircraft no longer
// have to enter the city's own tile to be engaged.
export const AIR_DEFENSE = {
damagePerLevel: 150,
damagePerLevelPerHour: 150,
rangeTiles: 2,
};
// Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five
+111 -20
View File
@@ -2,7 +2,7 @@
// where a port may be raised, which cities may train aircraft or warships, and
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
import { key, mapToLocal, flightLine, flightDistanceTiles } from "../hex.js";
import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { RESOURCE_RULES } from "../data/resources.js";
@@ -43,6 +43,17 @@ export const airMethods = {
return this._isAirProto(this.unitProto(unit));
},
// A unit that flies straight over the hex grid instead of walking it: an
// aircraft, or a missile once launched. Their path is a list of world-pixel
// points, not tiles, and the browser predicts the same line.
_isFlierProto(proto) {
return !!(proto && (proto.air || proto.missile));
},
_isFlier(unit) {
return this._isFlierProto(this.unitProto(unit));
},
// A unit that attacks from range instead of charging into melee: every
// aircraft and anything with a reach beyond a single tile. A melee unit has a
// range of one and stays on the ground.
@@ -83,16 +94,59 @@ export const airMethods = {
_pathHours(unit, path) {
let total = 0;
for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]);
for (const hours of this._segmentHours(unit, path)) total += hours;
return total;
},
// The per-segment hours of a route. A ground route is a list of tiles and
// each step costs its tile's travel time; a flight is a list of world-pixel
// points and each leg costs its straight-line distance. This is the one place
// the two movement models differ, so time and fuel can never drift apart.
_segmentHours(unit, path) {
const hours = [];
if (!path || path.length < 2) return hours;
if (this._isFlier(unit)) {
for (let i = 1; i < path.length; i++) {
hours.push(this._segmentFlightHours(unit, path[i - 1], path[i]));
}
return hours;
}
for (let i = 1; i < path.length; i++) hours.push(this._tileTravelHours(unit, path[i]));
return hours;
},
// Hours to fly the straight pixel segment `a` to `b`.
_segmentFlightHours(unit, a, b) {
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
return (Math.hypot(b.x - a.x, b.y - a.y) / this._maxStepLength) / speed;
},
// Hours to fly straight from tile `from` to tile `to`, the measure the fuel
// and strike-reach checks use.
_flightHours(unit, from, to) {
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
return flightDistanceTiles(from, to, this.topology, this._maxStepLength) / speed;
},
// Where a flier is right now as a world pixel, whether parked or mid-leg.
_flierPixel(unit) {
if (this._hasRoute(unit)) {
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
return mapToLocal(unit.coords.x, unit.coords.y);
},
// Routes an aircraft back to a friendly airport, unless the trip would run
// past its remaining endurance, in which case it stays put and will crash.
_orderAirReturn(unit) {
const airport = this._nearestFriendlyAirport(unit);
if (!airport) return false;
const path = this.findPath(unit, unit.coords, airport.coords);
const from = this._flierPixel(unit);
const path = flightLine(from, airport.coords, this.topology);
if (path.length < 2) return false;
const proto = this.unitProto(unit);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
@@ -161,6 +215,14 @@ export const airMethods = {
this._destroyUnit(unit);
}
if (crashed.length > 0) this._visibilityDirty = true;
// The anti-air corridor: every hostile battery within reach fires on each
// flier aloft. A parked aircraft is safe on its own runway, but anything
// crossing defended airspace takes fire for as long as it is inside.
for (const unit of this.units.slice()) {
if (!this._isFlier(unit) || !this._hasRoute(unit)) continue;
if (this._isAirUnit(unit) && this._friendlyAirportAt(unit.coords, unit.civ)) continue;
this._airDefenseVolley(unit);
}
},
// ----------------------------------------------------------- strike --
@@ -264,18 +326,27 @@ export const airMethods = {
return razed;
},
// Fires the city's air defense at a hostile aircraft that has just entered its
// tile: one volley per pass, scaled by the building's level. Returns true when
// the plane is shot down.
// Every hostile anti-air battery within `rangeTiles` of the flier fires one
// in-game hour of continuous fire: damage scales with the building's level,
// and each battery that fires spends its ammunition. These are not tile-entry
// volleys any more -- a plane is under fire whenever it is inside the
// corridor, so skirting the map's tiles no longer offers cover. Returns true
// when the aircraft is shot down.
_airDefenseVolley(unit) {
const city = this.cityAt(unit.coords);
if (!city || city.civ === unit.civ) return false;
let damage = 0;
for (const city of this.cities) {
if (city.civ === unit.civ) continue;
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE);
if (level <= 0) return false;
if (level <= 0) continue;
if (this.topology.tileDistance(unit.coords, city.coords) > AIR_DEFENSE.rangeTiles) {
continue;
}
// Air defense is cheap to keep ready and costly to fire: no ammunition,
// no volley.
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false;
const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level));
// no fire.
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) continue;
damage += Math.max(1, Math.round(AIR_DEFENSE.damagePerLevelPerHour * level));
}
if (damage <= 0) return false;
unit.hp -= damage;
if (unit.hp > 0) return false;
unit.hp = 0;
@@ -285,6 +356,25 @@ export const airMethods = {
return true;
},
// Resolves one missile's warhead where it lands and consumes the rocket. The
// missile flies a straight line to its target (requestMissileStrike); this is
// the moment of detonation.
_detonateMissile(unit) {
const goal = unit.missileTarget;
const nuclear = !!unit.nuclearPayload;
unit.missileTarget = null;
unit.nuclearPayload = false;
if (goal) {
if (nuclear) this._applyNuclearBlast(goal, unit.civ);
else this._resolveConventionalMissile(unit, goal);
}
// A nuclear blast at the missile's own tile already destroyed it above.
if (this.findUnit(unit.id)) this._destroyUnit(unit);
this._invalidateUnitCaches();
this._visibilityDirty = true;
this._emitChanged();
},
// Applies one ranged blow to a target unit, with the special fates of exposed
// support units, and passes a tenth of the damage on to any structures on the
// tile.
@@ -316,10 +406,11 @@ export const airMethods = {
: (proto.range || 1);
},
// Whether an aircraft has the fuel for the whole sortie: fly to `goal`, bomb,
// then run home to a friendly airport. A strike that could not make it back
// is refused up front, so a sortie never strands a plane. Air routes ignore
// the ground, so tile distance is the number of flight steps.
// Whether an aircraft has the fuel for the whole sortie: fly straight to
// `goal`, bomb, then run home to a friendly airport. A strike that could not
// make it back is refused up front, so a sortie never strands a plane. Both
// legs are measured along the straight line, so the fuel here and the hours
// the mover actually spends always agree.
_canStrikeAndReturn(unit, goal, fuel = null) {
const proto = this.unitProto(unit);
if (!proto) return false;
@@ -328,10 +419,10 @@ export const airMethods = {
const remaining = fuel === null
? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours)
: fuel;
const tiles =
this.topology.tileDistance(unit.coords, goal) +
this.topology.tileDistance(goal, airport.coords);
return tiles / this._unitSpeed(unit) <= remaining + 1e-9;
const hours =
this._flightHours(unit, unit.coords, goal) +
this._flightHours(unit, goal, airport.coords);
return hours <= remaining + 1e-9;
},
// Whether a unit may strike `goal` right now: inside its reach and, for an
+103 -19
View File
@@ -1,7 +1,7 @@
// Movement: translating orders into paths, advancing units along them each
// tick, and the travel-cost model the browser mirrors for prediction.
import { key } from "../hex.js";
import { key, mapToLocal, localToMap, flightLine } from "../hex.js";
import { TRANSPORT_BY_ID } from "../data/improvements.js";
import {
MIN_STEP_COST,
@@ -188,6 +188,12 @@ export const movementMethods = {
for (const unit of this.units.slice()) {
if (!this.findUnit(unit.id)) continue;
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue;
// Aircraft and launched missiles fly straight lines in pixel space; the
// tile walker below cannot advance them.
if (this._isFlier(unit)) {
this._advanceFlier(unit, hours);
continue;
}
if (engaged.has(unit)) continue;
let hoursLeft = hours;
while (hoursLeft > 0) {
@@ -225,24 +231,7 @@ export const movementMethods = {
this._moveUnitTo(unit, next);
this._visibilityDirty = true;
this._onUnitArrived(unit);
if (this._isAirUnit(unit)) {
// A hostile city's air defense gets one volley as the plane enters
// its tile; a plane shot down here never drops its bombs.
if (this._airDefenseVolley(unit)) break;
// Aircraft never join a melee. Reaching the target tile of a bomb
// run resolves the strike; the next hourly tick sends the plane
// home to re-arm.
if (unit.strikeTarget &&
unit.coords.x === unit.strikeTarget.x &&
unit.coords.y === unit.strikeTarget.y) {
this._resolveAirStrike(unit);
this._stopUnit(unit);
// Turn for home at once: idling on the target until the next
// hourly tick would burn fuel the round trip never budgeted for.
this._orderAirReturn(unit);
break;
}
} else if (this._hasHostileOn(unit.coords, unit.civ)) {
if (this._hasHostileOn(unit.coords, unit.civ)) {
// The fight is joined: hold here and lock every participant. The
// unit that charged in is the attacker; the occupants defend.
this._stopUnit(unit);
@@ -267,6 +256,90 @@ export const movementMethods = {
}
},
// Advances a flier along its straight pixel path: aircraft and launched
// missiles. Unlike the tile walker there is no terrain, no occupancy and no
// obstacle to route around, so the leg simply runs its hours and the plane's
// tile is derived from where the pixel line is.
_advanceFlier(unit, hours) {
unit._flightHalted = false;
let hoursLeft = hours;
while (hoursLeft > 1e-9) {
if (!this._hasRoute(unit)) {
if (!unit.waypoints || unit.waypoints.length === 0) break;
if (!this._followNextWaypoint(unit)) break;
continue;
}
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
if (!(total > 0)) {
// A zero-length leg (already at the destination) completes at once.
this._completeFlightLeg(unit);
if (!this.findUnit(unit.id) || unit._flightHalted) break;
continue;
}
const needed = total - unit.progressHours;
if (hoursLeft + 1e-9 < needed) {
unit.progressHours += hoursLeft;
hoursLeft = 0;
this._syncFlierCoords(unit);
break;
}
hoursLeft -= needed;
unit.progressHours = total;
this._completeFlightLeg(unit);
if (!this.findUnit(unit.id) || unit._flightHalted) break;
}
if (this.findUnit(unit.id) && !this._hasRoute(unit) &&
(!unit.waypoints || unit.waypoints.length === 0)) {
this._stopUnit(unit);
}
},
// Finishes one flight leg: lands the flier on its destination tile, resolves
// a bomb run or missile warhead, and lets a scheduled move pick up its next
// destination. `_flightHalted` tells the mover to stop consuming hours, for
// example after a plane turns for home.
_completeFlightLeg(unit) {
unit.pathIndex += 1;
unit.progressHours = 0;
unit.coords = localToMap(unit.path[unit.pathIndex], this.topology);
this._visibilityDirty = true;
// A missile's flight is its whole life: the warhead goes off where it lands,
// and it must never capture the tile it flies over (a missile is not an
// aircraft, so the capture rules would otherwise treat it as a ground unit).
if (this._isMissileUnit(unit)) {
this._detonateMissile(unit);
return;
}
this._onUnitArrived(unit);
if (!this.findUnit(unit.id)) return;
// A bomb run that has reached its target bombs once, then turns for home.
if (unit.strikeTarget &&
unit.coords.x === unit.strikeTarget.x && unit.coords.y === unit.strikeTarget.y) {
this._resolveAirStrike(unit);
this._stopUnit(unit);
this._orderAirReturn(unit);
unit._flightHalted = true;
}
},
// Keeps a flying unit's tile in step with its pixel position, so visibility,
// combat and the snapshot follow the plane between snapshot ticks.
_syncFlierCoords(unit) {
if (!this._hasRoute(unit)) return;
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0;
const pixel = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
const tile = localToMap(pixel, this.topology);
if (tile.x !== unit.coords.x || tile.y !== unit.coords.y) {
unit.coords = tile;
this._visibilityDirty = true;
}
},
// A hostile military unit standing on the next tile is an opponent to fight,
// not an obstacle to route around.
_isBlockingEnemy(unit, occupant) {
@@ -499,6 +572,17 @@ export const movementMethods = {
? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}`
: null;
if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
// A flier ignores the ground: a straight line is its whole route, so the A*
// search -- and with it terrain, zone-of-control and occupancy -- never
// runs. The route is a pair of world-pixel points, the same line the
// browser draws and predicts.
if (this._isFlier(unit)) {
const path = start.x === goal.x && start.y === goal.y
? [mapToLocal(start.x, start.y)]
: flightLine(mapToLocal(start.x, start.y), goal, this.topology);
if (cacheKey) pathCache.set(cacheKey, path);
return path;
}
const occupancy = this._buildOccupancy(unit);
const targetCiv = this._targetCivFor(unit, goal, occupancy);
const zoc = this._zoneOfControl(unit.civ);
+6 -6
View File
@@ -297,14 +297,14 @@ export const serializationMethods = {
return this.units.map((unit) => {
const rawPath = unit.path || [];
const pathIndex = unit.pathIndex;
const path = rawPath.map((coords) => [coords.x, coords.y]);
const segmentHours = [];
for (let i = 1; i < rawPath.length; i++) {
segmentHours.push(this._tileTravelHours(unit, rawPath[i]));
}
// A ground path is a list of tiles; a flight is a list of world-pixel
// points. Both ship as [x, y] pairs and get their per-segment hours from
// the one shared calculator, so the browser need not know which it is.
const path = rawPath.map((point) => [point.x, point.y]);
const segmentHours = this._segmentHours(unit, rawPath);
let stepHours = 0;
if (pathIndex + 1 < rawPath.length) {
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]);
stepHours = this._segmentHours(unit, [rawPath[pathIndex], rawPath[pathIndex + 1]])[0] || 0;
}
return {
id: unit.id,
+12 -4
View File
@@ -35,7 +35,9 @@ export const wmdMethods = {
// Fires one or more missiles at `goal`. A conventional warhead strikes the
// unit or city there; a nuclear one flattens the whole tile. `nuclear` is
// honoured only for a nation that has researched the bomb, and an ICBM is
// always nuclear. Every missile is consumed, so the stack is gone afterwards.
// always nuclear. Every missile is consumed, but only when its straight-line
// flight reaches the target: `_detonateMissile` runs the warhead on arrival,
// so a missile can be shot down by air defense on the way.
requestMissileStrike(unitIds, goal, { nuclear = false } = {}) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit));
if (units.length === 0) return false;
@@ -55,9 +57,15 @@ export const wmdMethods = {
if (units.some((unit) => !this._missileInRange(unit, goal))) return false;
this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) {
if (useNuclear) this._applyNuclearBlast(goal, civ);
else this._resolveConventionalMissile(unit, goal);
this._destroyUnit(unit);
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
unit.missileTarget = { x: goal.x, y: goal.y };
unit.nuclearPayload = useNuclear;
}
this._invalidateUnitCaches();
this._visibilityDirty = true;
+50
View File
@@ -55,6 +55,56 @@ export function mapToLocal(x, y) {
return { x: COL_STEP * x, y: HEX_H * (y + 0.5 * parity(x)) };
}
// The tile whose centre is nearest a world pixel point: the inverse of
// `mapToLocal`. Three candidate columns are tested (the point can sit just past
// a column boundary); the winning cell is folded back into the map when the
// world wraps.
export function localToMap(point, topology = null) {
const column = Math.round(point.x / COL_STEP);
let best = null;
let bestDistance = Infinity;
for (let x = column - 1; x <= column + 1; x++) {
const originY = HEX_H * 0.5 * parity(x);
const y = Math.round((point.y - originY) / HEX_H);
const center = mapToLocal(x, y);
const distance = (center.x - point.x) ** 2 + (center.y - point.y) ** 2;
if (distance < bestDistance) {
bestDistance = distance;
best = { x, y };
}
}
return topology ? topology.wrapCoords(best.x, best.y) : best;
}
// The copy of a world pixel point nearest `reference`, a whole number of map
// periods away. A flat map returns the point unchanged. Mirrors the browser's
// `_nearestCopy`, so a flight across the seam is drawn where the server flies.
export function nearestCopyPoint(point, reference, topology) {
const period = topology && topology.periodPixels ? topology.periodPixels() : 0;
if (!period) return { x: point.x, y: point.y };
const k = Math.round((reference.x - point.x) / period);
return { x: point.x + k * period, y: point.y };
}
// A straight flight: the world-pixel points from `fromPoint` to the nearest
// wrapped copy of the tile `toCoords`. `fromPoint` is already a pixel, so a
// plane mid-flight can be redirected without snapping back to a tile centre.
export function flightLine(fromPoint, toCoords, topology) {
const to = mapToLocal(toCoords.x, toCoords.y);
return [
{ x: fromPoint.x, y: fromPoint.y },
nearestCopyPoint(to, fromPoint, topology),
];
}
// Straight-line distance between two tile centres in tile units, taking the
// shortest wrapped route and scaling by the longest hex step. This is the
// aircraft mile: fuel and mission reach are both measured along it.
export function flightDistanceTiles(from, to, topology, maxStepLength) {
const delta = topology.pixelDelta(from, to);
return Math.hypot(delta.x, delta.y) / (maxStepLength || 1);
}
export class MapTopology {
constructor(size, cylindrical) {
this.width = size.x;
+126
View File
@@ -0,0 +1,126 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { mapToLocal, flightLine, flightDistanceTiles, key } from "../shared/hex.js";
import { AIR_DEFENSE } from "../shared/data/combat.js";
const BIG_BUDGET = 1.0e15;
function buildingIndex(state, id) {
return state.protoBuildings.findIndex((building) => building.id === id);
}
function protoOf(state, id) {
return state.protoUnits.find((unit) => unit.id === id);
}
function giveBuilding(state, city, id, level = 1) {
city.buildings[buildingIndex(state, id)] = level;
state._gdpPerCapitaCache.clear();
}
// Any tile exactly `distance` hex steps from `center`.
function tileAtDistance(state, center, distance) {
const seen = new Set([key(center.x, center.y)]);
let frontier = [{ x: center.x, y: center.y }];
for (let step = 0; step < distance; step++) {
const next = [];
for (const cell of frontier) {
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
const k = key(neighbour.x, neighbour.y);
if (seen.has(k) || !state.tiles[k]) continue;
seen.add(k);
next.push(neighbour);
}
}
frontier = next;
}
return frontier[0] || null;
}
export class AirMovementTest extends TestCase {
test_a_flight_is_a_straight_two_point_line() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((city) => city.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
const path = state.findPath(unit, home.coords, away.coords);
this.assertEqual(path.length, 2, "a flight is two world-pixel points");
const expected = flightLine(
mapToLocal(home.coords.x, home.coords.y),
away.coords,
state.topology
);
this.assertApprox(path[0].x, expected[0].x, 1e-9, "the flight starts at the tile centre");
this.assertApprox(path[1].x, expected[1].x, 1e-9);
this.assertApprox(path[1].y, expected[1].y, 1e-9);
// The one segment's hours are the straight-line distance over speed, so
// fuel and flight time can never disagree.
const hours = state._pathHours(unit, path);
const expectedHours =
flightDistanceTiles(home.coords, away.coords, state.topology, state._maxStepLength) /
jet.speed;
this.assertApprox(hours, expectedHours, 1e-9, "time follows the straight line");
const segmentHours = state._segmentHours(unit, path);
this.assertEqual(segmentHours.length, 1, "one leg, one segment");
this.assertApprox(segmentHours[0], expectedHours, 1e-9);
}
test_a_ferry_flight_only_needs_a_runway_at_the_destination() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((city) => city.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"a ferry to a friendly airport is accepted"
);
this.assertEqual(unit.path.length, 2, "the ferry is a straight line");
// The plane reaches the far airport and lands, refuelling there.
for (let hour = 0; hour < 40 && unit.coords.x !== away.coords.x; hour++) {
state.advanceHour();
}
this.assertEqual(unit.coords.x, away.coords.x, "it crossed in a straight line");
this.assertEqual(unit.coords.y, away.coords.y);
this.assertFalse(unit.airborne, "and landed");
}
test_air_defense_fires_across_its_corridor() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 1);
giveBuilding(state, city, "aa_building", 1);
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(state.landCells[0], 0, jet);
// Inside the corridor but not standing on the city tile: the old tile-entry
// volley would have missed it.
const near = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles);
this.assertNotNull(near, "there is a tile inside the corridor");
unit.coords = { x: near.x, y: near.y };
this.assertFalse(
unit.coords.x === city.coords.x && unit.coords.y === city.coords.y,
"the plane is not over the city"
);
const before = unit.hp;
state._airDefenseVolley(unit);
this.assertLess(unit.hp, before, "a plane inside the corridor takes fire");
// One step further out is beyond the battery's reach.
const far = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles + 1);
this.assertNotNull(far, "there is a tile outside the corridor");
unit.hp = unit.maxHp;
unit.coords = { x: far.x, y: far.y };
const safe = unit.hp;
state._airDefenseVolley(unit);
this.assertEqual(unit.hp, safe, "a plane outside the corridor is untouched");
}
}
+43
View File
@@ -556,6 +556,49 @@ export class GameScreenTest extends TestCase {
}
}
async test_air_strike_on_a_fogged_neutral_city_asks_to_declare_war() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((c) => c.civ === 0);
grantBuilding(state, home, "airport");
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto, home.id);
const city = state.cities.find((c) => c.civ === 1);
// Defenceless and under the fog: no garrison is visible, but a known city
// tile is still a legitimate air-strike target.
for (const unit of state.unitsAt(city.coords).slice()) state._destroyUnit(unit);
screen.onState(state.snapshot(0));
screen._selectUnits([jet.id]);
this.assertTrue(
screen._isEnemyCityAt({ x: city.coords.x, y: city.coords.y }),
"the fogged city is a target"
);
screen._toggleTargeting();
this.assertNotNull(screen._targeting, "targeting is armed");
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
for (let i = 0; i < 100 && env.$("#confirm-title").text() !== "Declare war?"; i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
this.assertEqual(
env.$("#confirm-title").text(),
"Declare war?",
"attacking a neutral city asks first, exactly like marching in"
);
env.$("#confirm-ok").trigger("click");
const order = network.orders.find((entry) => entry.type === "attack");
this.assertNotNull(order, "confirming sends the air strike");
this.assertEqual(order.coords[0], city.coords.x);
this.assertEqual(order.coords[1], city.coords.y);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_air_strike_beyond_the_round_trip_is_refused() {
const env = await setupDom();
try {
+7 -1
View File
@@ -221,7 +221,13 @@ export class GameServerTest extends TestCase {
server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }),
"the server accepts the missile order"
);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched");
// The rocket flies a straight line to the target and is consumed when the
// warhead goes off, not the moment it is launched.
for (let hour = 0; hour < 200 && state.findUnit(missile.id); hour++) {
server.advanceMovement(1.0);
server.tickHour();
}
this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "and the target is destroyed");
}
+69 -1
View File
@@ -1,5 +1,16 @@
import { TestCase } from "./framework/test_case.js";
import { MapTopology, mapToLocal, key, parseKey, COL_STEP, HEX_H } from "../shared/hex.js";
import {
MapTopology,
mapToLocal,
localToMap,
nearestCopyPoint,
flightLine,
flightDistanceTiles,
key,
parseKey,
COL_STEP,
HEX_H,
} from "../shared/hex.js";
export class HexTest extends TestCase {
flat() {
@@ -68,6 +79,63 @@ export class HexTest extends TestCase {
);
}
test_local_to_map_inverts_map_to_local() {
const flat = this.flat();
for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -7, y: 2 }]) {
this.assertEqual(localToMap(mapToLocal(coords.x, coords.y), flat), coords);
}
// A pixel a few units off a centre still resolves to that tile.
const centre = mapToLocal(2, 3);
this.assertEqual(localToMap({ x: centre.x + 4, y: centre.y - 3 }, flat), { x: 2, y: 3 });
// On a cylinder the resolved tile folds back into range.
this.assertEqual(localToMap(mapToLocal(5, 0), this.cylinder()), { x: -5, y: 0 });
}
test_nearest_copy_point_folds_by_whole_periods() {
const point = mapToLocal(0, 0);
const period = this.cylinder().periodPixels();
const reference = { x: point.x - period * 1.2, y: 0 };
const copy = nearestCopyPoint(point, reference, this.cylinder());
this.assertApprox(copy.x, point.x - period, 1e-9);
this.assertEqual(copy.y, point.y);
// A flat map returns the point untouched.
this.assertEqual(nearestCopyPoint(point, reference, this.flat()), point);
}
test_flight_line_takes_the_shortest_wrapped_copy() {
const topology = this.cylinder();
const from = mapToLocal(-5, 0);
// -5 and 4 are neighbours across the seam, one map period apart.
const line = flightLine(from, { x: 4, y: 0 }, topology);
this.assertEqual(line.length, 2);
this.assertEqual(line[0], from);
// Adjacent columns are offset by half a hex, so a neighbour hop is a
// diagonal of hypot(COL_STEP, HEX_H/2), not a bare COL_STEP.
const hop = Math.hypot(COL_STEP, HEX_H / 2);
this.assertApprox(
Math.hypot(line[1].x - line[0].x, line[1].y - line[0].y),
hop,
1e-6,
"the flight leaves one edge and re-enters the other"
);
}
test_flight_distance_measures_tiles_along_the_line() {
const maxStep = HEX_H;
const hop = Math.hypot(COL_STEP, HEX_H / 2);
this.assertApprox(
flightDistanceTiles({ x: 0, y: 0 }, { x: 1, y: 0 }, this.flat(), maxStep),
hop / maxStep,
1e-9
);
// Across the seam on a cylinder it is the short hop, not the long way.
this.assertApprox(
flightDistanceTiles({ x: -5, y: 0 }, { x: 4, y: 0 }, this.cylinder(), maxStep),
hop / maxStep,
1e-9
);
}
test_key_and_parse_key_round_trip_and_odd_column_offset() {
for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -12, y: 7 }]) {
this.assertEqual(parseKey(key(coords.x, coords.y)), coords);
+24
View File
@@ -387,6 +387,30 @@ export class MapViewInputTest extends MapViewFixture {
}
}
async test_a_unit_icon_does_not_steal_a_target_click() {
const state = smallState();
const attacker = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, attacker);
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
let selected = -1;
let reachedMap = false;
map.onUnitSelected = (id) => { selected = id; };
map.$viewport.on("mousedown", () => { reachedMap = true; });
map.beginTargeting({ unitIds: [attacker.id], origin: attacker.coords, range: 3 });
const view = map._unitViews.get(defender.id);
this.assertNotNull(view, "the defender has a view");
// Mousedown on the enemy icon while picking a target must fall through to
// the map, not select the enemy (which would clear the attacker).
view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true }));
this.assertEqual(selected, -1, "the target icon does not steal the click");
this.assertTrue(reachedMap, "the click reaches the map so the target is chosen");
} finally {
teardownDom(env);
}
}
async test_ctrl_right_click_schedules_a_destination() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
+49
View File
@@ -203,6 +203,33 @@ export class MapViewRenderTest extends MapViewFixture {
}
}
async test_an_aircraft_is_predicted_along_the_server_flight_line() {
const state = smallState();
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
const { env, map, snapshot } = await this.build(state);
try {
const data = snapshot.units.find((u) => u.id === unit.id);
const route = await map.planMove(data, away.coords);
this.assertNotNull(route, "a flight was planned");
this.assertEqual(route.length, 2, "the flight is a straight two-point line");
// The browser's flight time matches the server's for the same leg.
const clientHours = map._flightHours(jet, route[0], route[1], null);
const serverHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords));
this.assertApprox(clientHours, serverHours, 1e-9, "browser and server agree on flight time");
await map.predictMove(data, away.coords, null, route);
const motion = map._unitMotion.get(unit.id);
this.assertTrue(motion.flight, "the motion is flagged as a flight");
this.assertEqual(motion.path.length, 2);
} finally {
teardownDom(env);
}
}
async test_plan_move_reuses_a_shared_route() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
@@ -250,6 +277,28 @@ export class MapViewRenderTest extends MapViewFixture {
}
}
async test_an_air_strike_line_is_straight_not_an_arc() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const jet = state._spawnUnit(spot, 0, state.protoUnits.find((p) => p.id === "jet_fighter"));
const goal = state.topology
.neighbours(spot.x, spot.y)
.find((n) => state._isLand(n) && !state.unitAt(n));
jet.strikeTarget = { x: goal.x, y: goal.y };
const { env, map } = await this.build(state);
try {
map.setSelectedUnit(jet.id);
map.animate(0);
const d = env.$("#layer-targets .target-line").get(0).getAttribute("d");
this.assertTrue(d.includes(" L "), "the air strike is a straight segment");
this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
} finally {
teardownDom(env);
}
}
async test_draws_a_bombardment_line_and_ring_always_visible() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
+13 -3
View File
@@ -59,6 +59,12 @@ function freeLand(state) {
return null;
}
// A missile is no longer consumed the instant it is fired: it flies a straight
// line to its target and only then detonates. Fly the clock until it is gone.
function flyUntilGone(state, id, maxHours = 200) {
for (let hour = 0; hour < maxHours && state.findUnit(id); hour++) state.advanceHour();
}
export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState();
@@ -89,7 +95,9 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([missile.id], targetCoords),
"the strike is accepted"
);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed");
this.assertNotNull(state.findUnit(missile.id), "the rocket is in flight");
flyUntilGone(state, missile.id);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast");
}
@@ -112,7 +120,8 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([icbm.id], far),
"an ICBM reaches any tile"
);
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed");
flyUntilGone(state, icbm.id);
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "the target is destroyed");
}
@@ -136,7 +145,8 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
"with the bomb the strike goes ahead"
);
this.assertNull(state.findUnit(missile.id), "the missile was fired");
flyUntilGone(state, missile.id);
this.assertNull(state.findUnit(missile.id), "the missile flew and detonated");
this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out");
}