Added aircraft sorties, artillery bombardment and local testing mode

- aircraft attack orders fly a sortie to the target, bomb once, return to a
  friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing
  is bounded by fuel rather than the 10-tile combat radius
- artillery and warships open a sustained hourly bombardment on a standing
  target, drawn as a red dashed line and ring while either endpoint is selected
- a game launched from localhost can enable a testing mode whose train and build
  rows offer free, instant orders; the server only honours them for a loopback
  admin in a testing game
- an open city panel now unlocks aircraft as soon as an airport finishes
This commit is contained in:
2026-09-18 21:55:30 +02:00
parent e8cef400bc
commit 1a9d48cdc9
28 changed files with 1048 additions and 133 deletions
+42 -4
View File
@@ -9,8 +9,10 @@ everything else (transport included) is hand-written.
- Install dev dependencies once: `npm install` (only `c8` and `jsdom`, used by
the test suite — nothing the game runs needs them).
- Run the test suite: `node tests/run_tests.js` (alias `npm test`; prints a
summary and exits non-zero on failure).
- Run the test suite: `node tests/run_tests.js` (alias `npm test`; discovers
every `tests/*_test.js` and runs each in its own worker process, prints a
summary and exits non-zero on failure). `--jobs N` caps the pool and
`--file <name>` runs a single suite in-process; the worker marker is internal.
- Coverage report: `npm run coverage` (c8, writes `coverage/`).
- Start the game server and client locally:
`node server/server.js --port 27015 --bind 127.0.0.1`. Then open
@@ -24,6 +26,19 @@ everything else (transport included) is hand-written.
There is no linter, formatter, or CI. Do not invent commands beyond these.
### Local testing mode
- Opening the client from localhost (`http://127.0.0.1:27015/`) adds a
**Testing mode** checkbox to the New game screen. When the admin starts such a
game, every city's train and build rows gain a second "Free & instant" button.
- This is gated on the server, never trusted from the client: `server/server.js`
only sets `setup.testing` when the admin's WebSocket peer is loopback
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), strips a `free`
flag from any non-loopback order, and `GameServer` honours it only while
`this.testing` is set. `GameState.requestTrain/requestBuild(..., free)` then
spawn or raise immediately without cost or queue (placement rules still apply).
- The `testing` flag is shipped in the snapshot so joiners see the same buttons.
## Gotchas
- Keep the shared modules framework-free: they are imported unchanged by both
@@ -40,6 +55,10 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
fragmented and large frames (`server/websocket.js`). Do not lower the buffer
limits without checking the snapshot path.
- Indent `.js` files with 2 spaces. `.editorconfig` only enforces UTF-8.
- Keep aircraft out of the melee: `_battleTiles`/`_combatTarget` skip them and
their strikes are routed explicitly. Mirror the air rules (airport-only
rebasing, strike radius, ferry fuel) on both client and server, or prediction
and the authoritative state will disagree.
## Architecture
@@ -58,6 +77,25 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
pre-generated into `GameState.roads`) flatten a tile's movement cost on both
sides through `_tileTravelHours` / `_stepHours`; the road set is shipped in
the snapshot and drawn in the chunked `layer-roads`.
- Air and artillery are both ranged strikers (`GameState._isStrikeUnit`). A
ground or naval battery ordered to attack opens a **sustained hourly
bombardment**: it stays put and `_tickStrikes` removes a small share of its
attack damage each hour (`BOMBARD.hourlyDamageFraction`), clearing the target
when it dies or slips out of reach. An aircraft ordered to attack flies a
**sortie** (`requestAirStrike`): it bombs once on arrival in `advanceMovement`
and then returns to a friendly airport, where landing arms a two-day
`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 standing `strikeTarget` is shipped in the snapshot and
`map_view/motion.js` draws the red dashed line and target ring on
`#layer-targets` while either endpoint is selected.
- 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
a known seed. The snapshot ships `mapConfig` and `MapView.ensureTerrain`
rebuilds the exact same terrain from it (not the global `MAP_CONFIG`), which
also lets the DOM tests use a small map fixture.
- `shared/` holds the reusable algorithms so they stay testable without a
server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`,
`terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`,
@@ -67,8 +105,8 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`shared/data.js` is a barrel re-exporting the whole catalogue, so keep
importing from `shared/data.js` and edit the file under `shared/data/`.
`shared/game_state.js` is likewise a thin class composed from the mixins in
`shared/game_state/` (world, territory, economy, movement, combat, status,
diplomacy, orders, visibility, serialization).
`shared/game_state/` (world, territory, economy, movement, combat, air,
status, diplomacy, orders, visibility, serialization).
- `client/js/` splits the UI: `app.js` (screens and flow), `net.js` (transport
wrapper), `game_screen.js` + `game_screen/` (HUD and panels), `map_view.js` +
`map_view/` (DOM map), `modals.js` + `modals/` (city
+30
View File
@@ -216,6 +216,30 @@ input:focus, select:focus { border-color: var(--accent); }
pointer-events: none;
}
/* A ranged unit's bombardment: a red dashed line from the battery to the
target and a red ring around the target. Drawn on the layer below the entity
icons so the units themselves stay on top. */
.target-line {
position: absolute;
height: 0;
border-top: 3px dashed #ff2b2b;
transform-origin: 0 50%;
pointer-events: none;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
}
.target-ring {
position: absolute;
width: 46px;
height: 46px;
margin-left: -23px;
margin-top: -23px;
box-sizing: border-box;
border: 3px solid #ff2b2b;
border-radius: 50%;
pointer-events: none;
box-shadow: 0 0 6px rgba(255, 43, 43, 0.85);
}
.tile-selection {
position: absolute;
width: var(--hex-w);
@@ -770,6 +794,12 @@ input:focus, select:focus { border-color: var(--accent); }
gap: 10px;
text-align: left;
}
/* Local test game: the free, instant twin of an order button. */
.free-button {
border: 1px dashed rgba(130, 225, 170, 0.75) !important;
color: #b8f5cd;
}
.free-button:disabled { opacity: 0.4; }
#modal-city .training-status { flex: 0 0 auto; }
#modal-city .training-queue { flex: 1 1 auto; min-height: 0; }
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
+8
View File
@@ -31,6 +31,13 @@
<span>Nations in the world</span>
<input id="new-count" type="number" min="1" max="9" value="9" />
</label>
<label class="field hidden" id="new-testing-row">
<span>Testing mode</span>
<span class="inline">
<input id="new-testing" type="checkbox" />
Free &amp; instant construction and training
</span>
</label>
<label class="field">
<span>Player name</span>
<input id="new-name" type="text" autocomplete="off" value="adrien" />
@@ -99,6 +106,7 @@
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div>
+11 -1
View File
@@ -12,8 +12,17 @@ const config = {
playerCivId: CIVILISATIONS[0].id,
civilisations: [],
seed: 0,
// Set when the creator ticks testing mode on a locally served page.
testing: false,
};
// Testing tools are only offered to a page served from the local machine; the
// server likewise refuses to enable them for a remote connection.
const isLocalhost = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(
window.location.hostname
);
$("#new-testing-row").toggleClass("hidden", !isLocalhost);
let gameScreen = null;
let joining = false;
let mode = "join";
@@ -90,6 +99,7 @@ $("#new-start").on("click", () => {
config.playerCivId = player.id;
config.civilisations = list;
config.seed = 0;
config.testing = isLocalhost && $("#new-testing").is(":checked");
mode = "create";
setNewEnabled(false);
status("new-status", "Starting the game...");
@@ -101,7 +111,7 @@ $("#new-start").on("click", () => {
function setNewEnabled(enabled) {
$("#new-start, #new-back").prop("disabled", !enabled);
$("#new-count, #new-name, #new-password").prop("disabled", !enabled);
$("#new-count, #new-name, #new-password, #new-testing").prop("disabled", !enabled);
$("#new-civs").css("pointer-events", enabled ? "" : "none");
}
+35 -8
View File
@@ -52,6 +52,7 @@ export class GameScreen {
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
}
@@ -100,6 +101,7 @@ export class GameScreen {
seed: this.config.seed || 0,
player_civ: playerIndex,
civilisations: ids,
testing: !!this.config.testing,
});
}
@@ -203,20 +205,45 @@ export class GameScreen {
// Issues a move for the group, prompting first when the route would enter
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// A ranged attack is refused before it is ever sent when the target lies
// beyond the unit's reach, so the browser and the server never disagree.
const airStrike = type === "attack" && this._airStrikeOrder(units, coords);
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
// Aircraft may only be based at one of our airports; any other move (an
// empty tile or a city without a runway) is refused up front.
if ((type === "move" || type === "schedule") &&
units.length > 0 && units.every((unit) => this._isAirUnit(unit)) &&
!this._isFriendlyAirport(coords)) {
this.confirmModal.show(
"No runway",
"Aircraft may only fly to one of your cities with an airport, or attack an enemy unit.",
null
);
return;
}
if (airStrike) {
const blocked = this._airStrikeBlocked(units);
if (blocked) {
this.confirmModal.show(blocked.title, blocked.message, null);
return;
}
}
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// 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.
const outOfRange = units.some((unit) => {
const proto = this.protoUnits[unit.proto];
if (!proto) return false;
if (airStrike || striking) {
const from = { x: unit.coords[0], y: unit.coords[1] };
const reach = airStrike ? (proto.missionRange || Infinity) : (proto.range || 1);
return this.map.topology.tileDistance(from, coords) > reach;
}
const route = routes.get(unit.id);
if (!route) return false;
const distance = route.length - 1;
// A ranged attack is limited by the weapon range; a ferry flight by the
// aircraft's mission radius.
if (striking) return distance > (proto.range || 1);
return proto.air && distance > (proto.missionRange || Infinity);
if (!proto.air) return false;
// A ferry flight is bounded by fuel, not the combat mission radius:
// endurance hours at the aircraft's speed.
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
return route.length - 1 > ferryTiles;
});
if (outOfRange) {
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
+103 -20
View File
@@ -17,6 +17,7 @@ import {
effectValue,
pillageSteps,
ROADS,
AIR_STRIKE,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
@@ -36,6 +37,12 @@ export const panelMethods = {
this.sendOrder({ type: "build", city: cityId, building: index });
this.cityModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
// Testing mode adds a free, instant version of each order. The server
// ignores `free` unless the game is a local test game.
this.cityModal.onFreeTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex, free: true });
this.cityModal.onFreeBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index, free: true });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
@@ -232,24 +239,77 @@ export const panelMethods = {
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
},
// Whether an attack on `coords` is resolved instantly from range rather than
// by marching. Mirrors the server: a group of ranged units bombs a hostile
// military unit, while aircraft always attack from the air and never march
// onto a hostile tile.
_rangedAttackOrder(units, coords) {
if (units.length === 0) return false;
const allAir = units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.air);
});
if (allAir) return true;
if (!units.every((unit) => this._isRangedUnit(unit))) return false;
_isAirUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.air);
},
// Whether `coords` holds a hostile military unit, the only thing a strike may
// target.
_isEnemyMilitaryAt(coords) {
return this.map.unitIdsAt(coords).some((id) => {
const data = this._unit(id);
return data && data.civ !== this.localCiv && this._isMilitaryUnit(data);
});
},
// A ground/naval battery attack: resolved where the battery stands, with a
// standing hourly target rather than a march.
_rangedAttackOrder(units, coords) {
if (units.length === 0) return false;
if (units.some((unit) => this._isAirUnit(unit))) return false;
if (!units.every((unit) => this._isRangedUnit(unit))) return false;
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.
_airStrikeOrder(units, coords) {
if (units.length === 0) return false;
if (!units.every((unit) => this._isAirUnit(unit))) return false;
return this._isEnemyMilitaryAt(coords);
},
// Why an air strike cannot be issued right now, or null when it can. Blocks a
// plane that is already flying a mission, is still re-arming, or whose
// nation cannot afford the (deliberately steep) price.
_airStrikeBlocked(units) {
const snapshot = this.snapshot || {};
const totalHours = snapshot.totalHours || 0;
const budget = (snapshot.viewerStats || {}).budget || 0;
for (const unit of units) {
if (unit.strikeTarget) {
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
}
if ((unit.strikeReadyHour || 0) > totalHours) {
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
return {
title: "Aircraft re-arming",
message: `This aircraft cannot strike again for ${hours} more hour(s).`,
};
}
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > budget) {
return {
title: "Insufficient budget",
message: `An air strike costs €${groupDigits(cost)}, which the treasury cannot cover.`,
};
}
return null;
},
// Whether `coords` is one of our own cities with an airport, the only tiles
// an aircraft may be ordered to when it is not attacking.
_isFriendlyAirport(coords) {
const city = ((this.snapshot && this.snapshot.cities) || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
if (!city || city.civ !== this.localCiv) return false;
const index = BUILDINGS.findIndex((b) => b.id === "airport");
return index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
},
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
@@ -326,7 +386,7 @@ export const panelMethods = {
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
$("#unit-name").text(this._unitLabel(unit, proto));
$("#unit-attack, #unit-defense").removeClass("hidden");
this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
@@ -338,6 +398,21 @@ export const panelMethods = {
this._refreshBottomVisibility();
},
// The unit's name with its current air activity, so the panel explains why a
// plane cannot strike again yet.
_unitLabel(unit, proto) {
const name = proto ? proto.name : "Unit";
if (!proto || !proto.air) return name;
const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
if (unit.strikeTarget) return `${name} — on a strike mission`;
if ((unit.strikeReadyHour || 0) > totalHours) {
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
return `${name} — re-arming (${hours} h)`;
}
if (unit.airborne) return `${name} — airborne`;
return name;
},
// Attack and defense of the unit where it stands, with the hovering tooltips
// listing each factor that shaped the number.
_showUnitStats(unit, proto) {
@@ -550,26 +625,32 @@ export const panelMethods = {
this._updateCityModal();
},
_cityView(city) {
const stats = this.snapshot.viewerStats || {};
// The units a city may train right now. Aircraft and warships only appear
// once the city has the installation that trains them, so the order can never
// be sent and rejected. Recomputed each snapshot so a building finished while
// the modal is open unlocks its unit without reopening the panel.
_trainableUnits(city) {
const levels = this._cityBuildings.get(city.id) || {};
const hasBuilding = (id) => {
const index = BUILDINGS.findIndex((b) => b.id === id);
return index >= 0 && (levels[index] || 0) > 0;
};
// Aircraft and warships only appear once the city has the installation
// that trains them, so the order can never be sent and rejected.
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
return TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
},
_cityView(city) {
const stats = this.snapshot.viewerStats || {};
return {
budget: stats.budget || 0,
gdp: stats.gdp || 0,
coastal: !!city.coastal,
trainable,
testing: !!(this.snapshot && this.snapshot.testing),
trainable: this._trainableUnits(city),
buildings: BUILDINGS,
levels,
levels: this._cityBuildings.get(city.id) || {},
queue: this._trainingQueue(city.id),
};
},
@@ -588,6 +669,8 @@ export const panelMethods = {
const queue = this._trainingQueue(this.selectedCityId);
this.cityModal.renderQueue(queue);
this._updateCityProgress(queue);
const city = this._city(this.selectedCityId);
if (city) this.cityModal.refreshTrainList(this._trainableUnits(city));
this.cityModal.refreshBuildings(
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {},
+5
View File
@@ -27,6 +27,11 @@ export class MapView {
this.$borders = layers.borders;
this.$fog = layers.fog;
this.$paths = layers.paths;
// Bombardment target lines and rings, drawn over the terrain and paths but
// under the unit and city icons.
this.$targets = layers.targets && layers.targets.length
? layers.targets
: $("#layer-targets");
this.$entities = layers.entities;
// The simplified map used when zoomed out. It is created on demand so
// callers (and tests) that predate it keep working unchanged.
+55
View File
@@ -85,6 +85,7 @@ export const motionMethods = {
this._syncChunks();
this._applyEntityWrap();
this._drawPaths();
this._drawStrikeTargets();
},
// Repositions every unit and city at the wrapped copy nearest the camera.
@@ -171,6 +172,60 @@ export const motionMethods = {
this.$paths.empty()[0].appendChild(fragment);
},
// Where a unit is drawn right now, whether it is parked or mid-route.
_unitRenderPosition(id) {
const motion = this._unitMotion.get(id);
if (!motion) {
const data = this._unitData.get(id);
if (!data) return null;
return mapToLocal(data.coords[0], data.coords[1]);
}
if (motion.path.length >= 2) return this._positionAlongPath(motion);
return mapToLocal(motion.coords.x, motion.coords.y);
},
// Draws the red dashed line from every ranged attacker with a standing target
// to the target tile, and a red ring around the target. Both appear only
// while the attacker (or the unit taking the fire) is selected, and sit on a
// layer above the terrain but below the unit and city icons.
_drawStrikeTargets() {
if (!this.$targets || !this.$targets.length) return;
const fragment = document.createDocumentFragment();
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const targetIds = this.unitIdsAt(target);
const highlighted =
this._selectedUnitIds.has(id) ||
targetIds.some((targetId) => this._selectedUnitIds.has(targetId));
if (!highlighted) continue;
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
const targetId = targetIds[0];
const toPoint = targetId
? this._unitRenderPosition(targetId)
: mapToLocal(target.x, target.y);
if (!toPoint) continue;
const a = this._wrappedPosition(fromPoint);
const b = this._wrappedPosition(toPoint);
const dx = b.x - a.x;
const dy = b.y - a.y;
const line = document.createElement("div");
line.className = "target-line";
line.style.left = `${a.x}px`;
line.style.top = `${a.y}px`;
line.style.width = `${Math.hypot(dx, dy)}px`;
line.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`;
fragment.appendChild(line);
const ring = document.createElement("div");
ring.className = "target-ring";
ring.style.left = `${b.x}px`;
ring.style.top = `${b.y}px`;
fragment.appendChild(ring);
}
this.$targets.empty()[0].appendChild(fragment);
},
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
+40 -1
View File
@@ -32,8 +32,13 @@ export class CityModal {
this.cityId = 0;
this.gdp = 0;
this.coastal = false;
// Local test game: each order also offers a free, instant button.
this.testing = false;
this._levels = {};
this.protoNames = new Map();
// Signature of the trainable set currently drawn, so a snapshot that did
// not change it leaves the list (and its scroll) alone.
this._trainableSignature = null;
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
this.buildingRows = [];
// Building types whose upkeep breakdown is expanded on the budget tab.
@@ -44,6 +49,8 @@ export class CityModal {
this.onCancel = () => {};
this.onBuild = () => {};
this.onDemolish = () => {};
this.onFreeTrain = () => {};
this.onFreeBuild = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find(".tab").on("click", (event) => {
@@ -65,6 +72,7 @@ export class CityModal {
this.cityId = city.id;
this.gdp = view.gdp || 0;
this.coastal = !!view.coastal;
this.testing = !!view.testing;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
this.renderQueue(view.queue || []);
@@ -84,6 +92,7 @@ export class CityModal {
_renderTrainList(gdp, trainableUnits) {
this.protoNames = new Map();
this.$trainList.empty();
const signature = [];
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
@@ -98,10 +107,28 @@ export class CityModal {
$("<span></span>").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`)
);
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
signature.push(serverIndex);
this.protoNames.set(serverIndex, proto.name);
button.on("click", () => this.onTrain(this.cityId, serverIndex));
this.$trainList.append(button);
if (this.testing) {
const free = $("<button class='aero train-button free-button'></button>").text(
`Free & instant — ${proto.name}`
);
free.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
this.$trainList.append(free);
}
}
this._trainableSignature = signature.join(",");
}
// Rebuilds the trainable list only when the set changes, so a building that
// finishes while the panel is open (an airport, say) unlocks its unit without
// reopening the city and without discarding the list's scroll position.
refreshTrainList(trainableUnits) {
const signature = trainableUnits.map((proto) => proto.serverIndex).join(",");
if (signature === this._trainableSignature) return;
this._renderTrainList(this.gdp, trainableUnits);
}
// Draws the shared production queue: one row per run of contiguous identical
@@ -185,10 +212,16 @@ export class CityModal {
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
// In a local test game, a second button raises the next level for free.
const $free = this.testing ? $("<button class='aero free-button'></button>") : null;
if ($free) {
$free.on("click", () => this.onFreeBuild(this.cityId, index));
$actions.append($free);
}
if ($icon) $card.append($icon);
$card.append($info, $actions);
this.$buildingsList.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade, $free };
}
// Why a building cannot be raised here, or null when it can. Ports need the
@@ -228,6 +261,12 @@ export class CityModal {
row.$upgrade
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
.prop("disabled", !!requirement || budget < cost);
if (row.$free) {
// Free does not ignore placement rules (a port still needs the sea).
row.$free
.text(`Free & instant — ${row.proto.name}`)
.prop("disabled", !!requirement);
}
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
+20 -7
View File
@@ -15,6 +15,8 @@ export class GameServer extends EventEmitter {
this.network = network;
this.state = null;
this.hostCiv = 0;
// True for a local test game, where free, instant orders are allowed.
this.testing = false;
this.peerCiv = new Map();
this.login = new LoginManager();
this.adminName = "";
@@ -63,8 +65,10 @@ export class GameServer extends EventEmitter {
configureGame(setup) {
if (this._configured) return;
this.hostCiv = Number(setup.player_civ || 0);
this.testing = !!setup.testing;
this.state = new GameState();
this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0));
this.state.testing = this.testing;
this.state.onChanged(() => {
this._stateDirty = true;
});
@@ -129,12 +133,19 @@ export class GameServer extends EventEmitter {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
const coords = coordsOf(order.coords);
// A group that hits from range (artillery, warships, aircraft) bombs a
// hostile unit without closing in; anything else -- or an attack on a tile
// with no unit on it, an undefended city say -- is a charge.
const allRanged = units.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
// Aircraft fly to a hostile unit and bomb it on arrival, then return to a
// friendly airport. A ground/naval battery (artillery, warships) instead
// opens a sustained hourly bombardment from where it stands. Anything else
// -- or an attack on a tile with no unit on it, an undefended city say --
// is a charge.
if (units.every((id) => this.state._isAirUnit(this.state.findUnit(id)))) {
return this.state.requestAirStrike(units, coords);
}
const ground = units.filter((id) => !this.state._isAirUnit(this.state.findUnit(id)));
const allRanged =
ground.length > 0 && ground.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
if (allRanged && this.state._strikeTargetAt(coords, civ)) {
return this.state.requestStrike(units, coords);
return this.state.requestBombard(ground, coords);
}
return this.state.requestAttack(units, coords);
}
@@ -174,7 +185,8 @@ export class GameServer extends EventEmitter {
_handleTrain(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.requestTrain(city.id, Number(order.proto));
const free = !!order.free && this.testing;
return this.state.requestTrain(city.id, Number(order.proto), free);
}
_handleCancelTrain(civ, order) {
@@ -186,7 +198,8 @@ export class GameServer extends EventEmitter {
_handleBuild(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.requestBuild(city.id, Number(order.building));
const free = !!order.free && this.testing;
return this.state.requestBuild(city.id, Number(order.building), free);
}
_handleDemolish(civ, order) {
+22 -4
View File
@@ -69,6 +69,14 @@ async function serveStatic(request, response) {
}
}
// Whether a peer address is the local machine. Handles the IPv4-mapped IPv6
// form Node reports for a v4 socket ("::ffff:127.0.0.1") as well as plain v4/v6.
export function isLoopback(address) {
if (!address) return false;
const value = String(address).replace(/^::ffff:/, "");
return value === "::1" || value === "localhost" || value.startsWith("127.");
}
// Builds the whole server without listening. `listen()` resolves once the port
// is bound and yields the bound address.
export function startServer({ port = 27015, bind = "127.0.0.1", log = console.log } = {}) {
@@ -125,19 +133,29 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
gameServer.onPeerJoined(connection.id, result.name);
break;
}
case "configure_game":
case "configure_game": {
if (connection.name !== gameServer.adminName) return;
gameServer.configureGame(message.setup || {});
// Testing mode is only offered to a game launched from the local
// machine; a remote host cannot turn it on.
const setup = message.setup || {};
const testing = !!setup.testing && isLoopback(connection.remoteAddress);
gameServer.configureGame({ ...setup, testing });
for (const peer of connections.values()) {
peer.send(JSON.stringify({ t: "game_configured" }));
}
break;
}
case "claim_civ":
gameServer.claimCiv(connection.id, message.civ);
break;
case "order":
gameServer.handleOrder(connection.id, message.order || {});
case "order": {
const order = message.order || {};
// Even inside a local test game, the free shortcut is only honoured for
// a connection that is itself local.
if (order.free && !isLoopback(connection.remoteAddress)) order.free = false;
gameServer.handleOrder(connection.id, order);
break;
}
default:
break;
}
+3
View File
@@ -55,6 +55,9 @@ export class WebSocketServer extends EventEmitter {
const id = this._nextId++;
const connection = new Connection(id, socket, head);
// The peer's address, so the server can tell a local connection (which may
// use the testing tools) from a remote one.
connection.remoteAddress = socket.remoteAddress || "";
this._connections.set(id, connection);
this.emit("connection", connection);
connection.on("close", () => this._connections.delete(id));
+17
View File
@@ -25,6 +25,23 @@ export const PILLAGE = {
healPerPeople: 1000,
};
// Sustained bombardment: a ground or naval battery ordered onto a target fires
// one volley every in-game hour. Each volley deals this fraction of the damage
// a single strike would (attack minus the defender's effective defense), so a
// unit under fire is worn down over many hours instead of erased in one salvo.
export const BOMBARD = {
hourlyDamageFraction: 0.1,
};
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days).
// Each attacking aircraft costs `cost` from its nation's budget, charged up
// front so bombing cannot be spammed for free.
export const AIR_STRIKE = {
cost: 10_000_000,
cooldownHours: 48,
};
// The result of pillaging a tile with `count` units in sequence, starting from
// its current population. Each step removes a tenth of what is left and heals
// for the people it removed; the GDP-per-capita multiplier compounds. Shared so
+5 -4
View File
@@ -67,10 +67,12 @@ export const PROTO_UNITS = [
traversableTerrains: ["Land", "Sea", "Ice"],
// Aircraft only operate near a friendly airport: `enduranceHours` is how
// long they may stay aloft and `missionRange` the furthest they may be
// ordered from home. Both are per-type so a future plane can differ.
// ordered from home. Both are per-type so a future plane can differ. The
// fighter's 10-tile reach needs 4 hours aloft for the round trip, leaving
// an hour of reserve.
air: true,
enduranceHours: 2,
missionRange: 5,
enduranceHours: 5,
missionRange: 10,
requiresBuilding: "airport",
icon: "icon_jet_fighter.svg",
},
@@ -92,7 +94,6 @@ export const PROTO_UNITS = [
enduranceHours: 2,
missionRange: 5,
strike: true,
strikeHours: 1,
requiresBuilding: "airport",
icon: "icon_bomber.svg",
},
+4
View File
@@ -75,6 +75,9 @@ export class GameState {
this.totalHours = 0;
this._configured = false;
// Set by the server for a local test game: construction and training may be
// issued for free and instantly. Never trusted from a remote client.
this.testing = false;
this._generator = null;
this._pathfinder = new HexPathfinder();
this._minMovementCost = 1.0;
@@ -224,6 +227,7 @@ export class GameState {
this.totalHours += 1;
this._tickTraining();
this._tickAirUnits();
this._tickStrikes();
this._tickGarrisonHealing();
if (this._visibilityDirty) {
this._refreshVisibility();
+118 -34
View File
@@ -3,6 +3,7 @@
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
import { AIR_STRIKE, BOMBARD } from "../data/combat.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
@@ -110,6 +111,12 @@ export const airMethods = {
const proto = this.unitProto(unit);
if (!this._isAirProto(proto)) continue;
if (this._friendlyAirportAt(unit.coords, unit.civ)) {
// A plane that has just flown a strike mission re-arms on the ground:
// its two-day cooldown begins once it is back on the runway.
if (unit.airborne && unit.missionFlown) {
unit.strikeReadyHour = this.totalHours + (AIR_STRIKE.cooldownHours || 0);
unit.missionFlown = false;
}
unit.airHours = proto.enduranceHours;
unit.airborne = false;
unit.autoReturn = false;
@@ -156,65 +163,142 @@ export const airMethods = {
return enemies[0];
},
// Whether every ranged unit in the group may bomb `goal`: all strike-capable,
// within weapon range and, for aircraft, inside the mission radius with fuel.
canStrike(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
if (units.length === 0) return false;
const civ = units[0].civ;
if (!this._strikeTargetAt(goal, civ)) return false;
return units.every((unit) => this._strikeReady(unit, goal));
// How far a unit can reach: an aircraft's mission radius, a ground battery's
// weapon range.
_strikeRange(proto) {
if (!proto) return 1;
return this._isAirProto(proto)
? (proto.missionRange || proto.range || 1)
: (proto.range || 1);
},
// Whether a unit may strike `goal` right now: inside its reach and, for an
// aircraft, parked/armed with fuel in the tanks and not still rearming or
// already flying a mission.
_strikeReady(unit, goal) {
const proto = this.unitProto(unit);
if (!proto) return false;
const distance = this.topology.tileDistance(unit.coords, goal);
if (distance > (proto.range || 1)) return false;
// Aircraft must also stay inside their mission radius and keep fuel in the
// tanks; a ground battery has no such endurance.
if (distance > this._strikeRange(proto)) return false;
if (!this._isAirProto(proto)) return true;
if (distance > (proto.missionRange || distance)) return false;
if (unit.strikeTarget) return false;
if (this.totalHours < (unit.strikeReadyHour || 0)) return false;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
return fuel > 0;
},
// A ranged attack: every unit bombs/attacks the weakest hostile military unit
// on `goal` from a distance, without moving and without joining the melee.
// An aircraft sortie burns `strikeHours` of endurance, so planes must still
// return to an airport; a ground battery has no endurance to spend. Declares
// war on the target if the two were at peace.
requestStrike(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
// A ground or naval battery opens a sustained bombardment of `goal`: it never
// moves and fires one small volley every in-game hour (`_tickStrikes`) until
// the target dies, moves out of reach, or the battery is ordered elsewhere.
// Declares war on the target if the two were at peace.
requestBombard(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter(
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit)
);
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
if (!this._strikeTargetAt(goal, civ)) return false;
this._declareWar(civ, this._strikeTargetAt(goal, civ).civ);
const destroyed = [];
this._declareWar(civ, target.civ);
for (const unit of units) {
this._stopUnit(unit);
unit.strikeTarget = { x: goal.x, y: goal.y };
}
this._emitChanged();
return true;
},
// An aircraft sortie: the plane flies to `goal` and bombs the hostile unit
// there on arrival, then heads home. The attack is charged up front, per
// aircraft, and the cooldown starts when the plane lands again.
requestAirStrike(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isAirUnit(unit));
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
const paths = [];
for (const unit of units) {
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
const proto = this.unitProto(unit);
const target = this._strikeTargetAt(goal, civ);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
paths.push(path);
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
this._declareWar(civ, target.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
unit.autoReturn = false;
unit.strikeTarget = { x: goal.x, y: goal.y };
});
this._emitChanged();
return true;
},
// Resolves a plane's bomb run the moment it reaches its target tile: one
// strike on the weakest hostile military unit there. The plane then turns for
// home and its cooldown is armed when it lands.
_resolveAirStrike(unit) {
const goal = unit.strikeTarget;
unit.strikeTarget = null;
unit.missionFlown = true;
if (goal) {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
target.hp -= damage;
if (target.hp <= 0) {
target.hp = 0;
destroyed.push(target);
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
}
}
if (this._isAirProto(proto)) {
unit.airHours = Math.max(0, (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) - (proto.strikeHours || 1));
}
unit.autoReturn = false;
}
if (destroyed.length > 0) {
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
this._visibilityDirty = true;
}
this._emitChanged();
return true;
},
// Runs once per in-game hour: every ground/naval battery with a standing
// target fires a small volley, wearing the enemy down instead of destroying
// it outright. A target that has died or slipped out of reach clears the
// battery's orders.
_tickStrikes() {
const damage = new Map();
for (const unit of this.units) {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
if (!target || !this._strikeReady(unit, goal)) {
unit.strikeTarget = null;
continue;
}
const full = this._unitAttack(unit) - this._effectiveDefense(target, true);
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [target, amount] of damage) {
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
destroyed.push(target);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// ---------------------------------------------------------- training --
+8 -3
View File
@@ -62,9 +62,13 @@ export const combatMethods = {
// or null. With every combatant on one tile this is simply the weakest
// hostile military unit there, so a stack focuses its fire.
_combatTarget(unit) {
if (!this._isMilitary(unit)) return null;
if (!this._isMilitary(unit) || this._isAirUnit(unit)) return null;
const enemies = this.unitsAt(unit.coords).filter(
(other) => other.id !== unit.id && other.civ !== unit.civ && this._isMilitary(other)
(other) =>
other.id !== unit.id &&
other.civ !== unit.civ &&
this._isMilitary(other) &&
!this._isAirUnit(other)
);
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
@@ -82,7 +86,8 @@ export const combatMethods = {
_battleTiles() {
const byTile = new Map();
for (const unit of this.units) {
if (!this._isMilitary(unit)) continue;
// Aircraft fly over a battlefield without taking part in the melee.
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
const k = key(unit.coords.x, unit.coords.y);
let entry = byTile.get(k);
if (!entry) {
+25 -4
View File
@@ -132,9 +132,17 @@ export const movementMethods = {
if (path.length < 2) {
return false;
}
// An aircraft may only be ordered inside its mission radius.
if (air && path.length - 1 > (proto.missionRange || Infinity)) {
return false;
// Aircraft may only be based at a friendly airport: they cannot be sent to
// an empty tile or to an allied city without a runway. Rebasing is a ferry
// flight, whose only bound is the fuel in the tanks (the mission radius
// limits combat sorties, not ferrying). A strike is issued through
// requestAirStrike, which routes the plane to the enemy instead.
if (air) {
const city = this.cityAt(goal);
const airport = !!(city && city.civ === unit.civ && this.hasCityBuilding(city, "airport"));
if (!airport) return false;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
}
// Marching a military unit into, or attacking, a country we are at peace
// with declares war on it. The browser confirms the move first, but the
@@ -154,6 +162,8 @@ export const movementMethods = {
unit.progressHours = 0;
unit.moveSpeed = speed;
unit.autoReturn = false;
// A move cancels any standing bombardment; the battery is relocating.
unit.strikeTarget = null;
return true;
},
@@ -203,7 +213,18 @@ export const movementMethods = {
unit.coords = next;
this._visibilityDirty = true;
this._onUnitArrived(unit);
if (this._hasHostileOn(unit.coords, unit.civ)) {
if (this._isAirUnit(unit)) {
// 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);
break;
}
} else 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);
+19 -2
View File
@@ -13,13 +13,20 @@ export const orderMethods = {
// buildings and holds at most TRAINING_QUEUE_LIMIT entries; the first entry
// is produced while the rest wait. The unit's price is paid up front from the
// budget, then it takes production capacity to finish.
requestTrain(cityId, protoIndex) {
requestTrain(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city) return false;
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
const proto = this.protoUnits[protoIndex];
// Aircraft and warships need the right installation in the city first.
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
// The local testing shortcut: no cost, no production time, no queue.
if (free) {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
// A free unit (the worker) needs neither money nor production time.
@@ -62,13 +69,23 @@ export const orderMethods = {
// unit, the level is paid for up front from the budget and only rises once
// its production time has elapsed. Repeated orders stack levels: the second
// queued level is priced off the level the first will leave behind.
requestBuild(cityId, protoIndex) {
requestBuild(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
// Placement rules: a port needs the sea, a shipyard needs a port.
if (proto.coastal && !this.isCoastalCity(city)) return false;
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
// The local testing shortcut: raise the next level at once, for free.
if (free) {
const level =
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
const level =
+9
View File
@@ -16,6 +16,8 @@ export const serializationMethods = {
// rebuilds the same terrain from it, so a game on a non-default map size
// or shape is rendered correctly instead of falling back to the default.
mapConfig: this.mapConfig,
// A local test game where the client offers free, instant orders.
testing: !!this.testing,
totalHours: this.totalHours,
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
@@ -125,7 +127,14 @@ export const serializationMethods = {
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
// Aircraft carry their endurance so the client can show the fuel left.
airHours: unit.airHours,
airborne: !!unit.airborne,
homeCityId: unit.homeCityId,
// A standing bombardment target (ground battery) or bomb-run target
// (aircraft), as [x, y], and the hour an aircraft may strike again.
strikeTarget: unit.strikeTarget
? [unit.strikeTarget.x, unit.strikeTarget.y]
: null,
strikeReadyHour: unit.strikeReadyHour || 0,
path,
pathIndex,
progressHours: unit.progressHours,
+5
View File
@@ -207,6 +207,11 @@ export const worldMethods = {
waypoints: [],
invader: false,
statuses: [],
// A standing bombardment target (ground batteries) or a pending bomb run
// (aircraft), plus the hour an aircraft may strike again.
strikeTarget: null,
strikeReadyHour: 0,
missionFlown: false,
};
if (proto.air) {
unit.homeCityId = homeCityId;
+135 -40
View File
@@ -1,6 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { parseKey } from "../shared/hex.js";
import { AIR_STRIKE } from "../shared/data.js";
const BIG_BUDGET = 1.0e15;
@@ -30,10 +31,6 @@ function giveBuilding(state, city, id, level = 1) {
state._gdpPerCapitaCache.clear();
}
function firstLandNeighbour(state, coords) {
return state.topology.neighbours(coords.x, coords.y).find((n) => state._isLand(n)) || null;
}
function freeAdjacentLand(state) {
for (const cell of state.landCells) {
if (state.cityAt(cell) || state.unitAt(cell)) continue;
@@ -45,6 +42,23 @@ function freeAdjacentLand(state) {
return null;
}
function freeLandNear(state, center, maxDistance) {
let best = null;
let bestDistance = Infinity;
for (const k in state.tiles) {
const coords = parseKey(k);
if (state.tiles[k].terrainClass !== "Land") continue;
if (state.cityAt(coords) || state.unitAt(coords)) continue;
const distance = state.topology.tileDistance(center, coords);
if (distance <= 0 || distance > maxDistance) continue;
if (distance < bestDistance) {
bestDistance = distance;
best = coords;
}
}
return best;
}
function farTile(state, from, minDistance) {
for (const k in state.tiles) {
const coords = parseKey(k);
@@ -125,26 +139,57 @@ export class AirTest extends TestCase {
);
}
test_aircraft_fly_home_and_refuel_after_a_sortie() {
test_aircraft_strike_sortie_bombs_returns_and_rearms() {
const state = smallState();
const city = coastalCity(state, 0);
giveBuilding(state, city, "airport");
state.budgets.set(0, BIG_BUDGET);
const jet = protoUnit(state, "jet_fighter");
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
const goal = firstLandNeighbour(state, city.coords);
this.assertNotNull(goal, "there is somewhere to fly");
this.assertTrue(state.requestGroupMove([unit.id], goal), "the sortie is accepted");
const target = freeLandNear(state, city.coords, jet.missionRange);
this.assertNotNull(target, "there is a target tile within range");
const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry"));
this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the strike is accepted");
this.assertEqual(unit.coords.x, city.coords.x, "it has not taken off yet");
this.assertApprox(
state.getBudget(0),
BIG_BUDGET - AIR_STRIKE.cost,
1e-6,
"the strike was paid for up front"
);
state.advanceHour();
this.assertEqual(unit.coords.x, goal.x, "it reached the target");
this.assertTrue(unit.airborne, "it is airborne away from the airport");
this.assertLess(unit.airHours, jet.enduranceHours, "the sortie burned fuel");
this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target");
this.assertNull(unit.strikeTarget, "the bomb run is finished");
state.advanceHour();
this.assertEqual(unit.coords.x, city.coords.x, "it returned to its airport");
this.assertEqual(unit.coords.y, city.coords.y);
this.assertFalse(unit.airborne, "it is parked again");
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled");
this.assertGreater(unit.strikeReadyHour, state.totalHours, "its cooldown begins on landing");
}
test_aircraft_cannot_strike_again_while_rearming() {
const state = smallState();
const city = coastalCity(state, 0);
giveBuilding(state, city, "airport");
state.budgets.set(0, BIG_BUDGET);
const jet = protoUnit(state, "jet_fighter");
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
const target = freeLandNear(state, city.coords, jet.missionRange);
const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry"));
this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the first strike flies");
state.advanceHour();
state.advanceHour();
const second = freeLandNear(state, city.coords, jet.missionRange);
this.assertNotNull(second, "there is another target in range");
const newDefender = state._spawnUnit(second, 1, protoUnit(state, "modern_infantry"));
this.assertFalse(
state.requestAirStrike([unit.id], newDefender.coords),
"a re-arming aircraft is grounded"
);
}
test_aircraft_crash_when_no_airport_can_reach_them() {
@@ -153,8 +198,8 @@ export class AirTest extends TestCase {
const spot = freeAdjacentLand(state);
const unit = state._spawnUnit(spot[0], 0, jet, null);
this.assertNull(state._nearestFriendlyAirport(unit), "no airport exists yet");
state.advanceHour();
this.assertNotNull(state.findUnit(unit.id), "still flying after one hour");
for (let hour = 1; hour < jet.enduranceHours; hour++) state.advanceHour();
this.assertNotNull(state.findUnit(unit.id), "still flying before the tanks run dry");
state.advanceHour();
this.assertNull(state.findUnit(unit.id), "out of fuel, it crashed");
}
@@ -178,29 +223,76 @@ export class AirTest extends TestCase {
this.assertGreater(checked, 0);
}
test_aircraft_reach_the_edge_of_their_radius_and_return() {
test_jet_fighter_can_rebase_to_a_city_ten_tiles_away() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
this.assertEqual(
state.topology.tileDistance(home.coords, away.coords),
10,
"the fixture's two cities sit ten tiles apart"
);
const jet = protoUnit(state, "jet_fighter");
this.assertGreaterOrEqual(jet.missionRange, 10, "the jet reaches at least ten tiles");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"the ten-tile rebase is accepted"
);
state.advanceHour();
state.advanceHour();
this.assertEqual(unit.coords.x, away.coords.x, "it reached the far city");
this.assertEqual(unit.coords.y, away.coords.y);
this.assertFalse(unit.airborne, "it landed and refuelled");
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled");
}
test_aircraft_can_ferry_farther_than_their_strike_radius() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const bomber = protoUnit(state, "bomber");
this.assertGreater(
bomber.enduranceHours * bomber.speed,
bomber.missionRange,
"fuel lets the bomber ferry beyond its strike radius"
);
const unit = state._spawnUnit(home.coords, 0, bomber, home.id);
this.assertFalse(
state._strikeReady(unit, away.coords),
"the ten-tile city is beyond the bomber's strike radius"
);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"but it can still fly there to rebase"
);
}
test_air_strike_is_limited_to_the_mission_radius() {
const state = smallState();
const city = coastalCity(state, 0);
giveBuilding(state, city, "airport");
state.budgets.set(0, BIG_BUDGET);
const jet = protoUnit(state, "jet_fighter");
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
let goal = null;
for (const k in state.tiles) {
const coords = parseKey(k);
if (state.topology.tileDistance(city.coords, coords) !== jet.missionRange) continue;
if (state.cityAt(coords) || state.unitAt(coords)) continue;
goal = coords;
break;
}
this.assertNotNull(goal, "a tile sits on the radius edge");
this.assertTrue(state.requestGroupMove([unit.id], goal), "the edge is reachable");
state.advanceHour();
this.assertEqual(unit.coords.x, goal.x, "it reached the edge");
this.assertEqual(unit.coords.y, goal.y);
state.advanceHour();
this.assertEqual(unit.coords.x, city.coords.x, "the round trip closed");
this.assertEqual(unit.coords.y, city.coords.y);
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it landed and refuelled");
const outside = farTile(state, city.coords, jet.missionRange);
this.assertNotNull(outside, "the map has tiles beyond the radius");
const far = state._spawnUnit(outside, 1, protoUnit(state, "modern_infantry"));
this.assertFalse(
state.requestAirStrike([unit.id], far.coords),
"a target beyond the radius is refused"
);
const inside = freeLandNear(state, city.coords, jet.missionRange);
this.assertNotNull(inside, "there is a target inside the radius");
const near = state._spawnUnit(inside, 1, protoUnit(state, "modern_infantry"));
this.assertTrue(
state.requestAirStrike([unit.id], near.coords),
"a target inside the radius is struck"
);
}
test_aircraft_cannot_be_ordered_beyond_their_range() {
@@ -215,21 +307,22 @@ export class AirTest extends TestCase {
this.assertEmpty(unit.path, "no route was stored");
}
test_bombers_strike_from_afar_without_joining_the_battle() {
test_bombers_fly_to_the_target_and_never_join_the_battle() {
const state = smallState();
const bomber = protoUnit(state, "bomber");
const infantry = protoUnit(state, "modern_infantry");
const pair = freeAdjacentLand(state);
const attacker = state._spawnUnit(pair[0], 0, bomber, null);
const defender = state._spawnUnit(pair[1], 1, infantry);
this.assertTrue(state.requestStrike([attacker.id], defender.coords), "the strike is accepted");
this.assertTrue(state.requestAirStrike([attacker.id], defender.coords), "the strike is accepted");
state.advanceHour();
this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target");
this.assertEqual(attacker.coords.x, pair[0].x, "the bomber never moved");
this.assertEqual(attacker.coords.y, pair[0].y);
this.assertEqual(attacker.coords.x, pair[1].x, "the bomber flew onto the target");
this.assertEqual(attacker.coords.y, pair[1].y);
this.assertEmpty(state._battleTiles(), "no melee battle was started");
}
test_ground_ranged_units_strike_from_afar_like_aircraft() {
test_ground_ranged_units_bombard_every_hour() {
const state = smallState();
const artillery = protoUnit(state, "artillery");
const infantry = protoUnit(state, "modern_infantry");
@@ -237,14 +330,16 @@ export class AirTest extends TestCase {
const attacker = state._spawnUnit(pair[0], 0, artillery);
const defender = state._spawnUnit(pair[1], 1, infantry);
this.assertTrue(state._isStrikeUnit(attacker), "artillery counts as a ranged attacker");
this.assertTrue(state.requestStrike([attacker.id], defender.coords), "the battery opens fire");
this.assertLess(defender.hp, defender.maxHp, "the shell damaged the target");
this.assertTrue(state.requestBombard([attacker.id], defender.coords), "the battery opens fire");
this.assertEqual(defender.hp, defender.maxHp, "the first volley waits for the hour");
this.assertEqual(attacker.coords.x, pair[0].x, "the artillery never moved");
this.assertEqual(attacker.coords.y, pair[0].y);
state.advanceHour();
this.assertLess(defender.hp, defender.maxHp, "the hourly shell damaged the target");
this.assertEmpty(state._battleTiles(), "no melee battle was started");
}
test_ranged_attack_beyond_reach_is_refused() {
test_ground_bombard_beyond_reach_is_refused() {
const state = smallState();
const artillery = protoUnit(state, "artillery");
const infantry = protoUnit(state, "modern_infantry");
@@ -253,7 +348,7 @@ export class AirTest extends TestCase {
const goal = farTile(state, from, artillery.range);
this.assertNotNull(goal, "the map has a tile beyond the battery's range");
const defender = state._spawnUnit(goal, 1, infantry);
this.assertFalse(state.requestStrike([attacker.id], goal), "a target out of reach is refused");
this.assertFalse(state.requestBombard([attacker.id], goal), "a target out of reach is refused");
this.assertEqual(defender.hp, defender.maxHp, "the target is untouched");
}
+24
View File
@@ -143,6 +143,30 @@ export class AppTest extends TestCase {
}
}
async test_testing_mode_is_offered_and_sent_from_localhost() {
const env = await setupDom();
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?testing=${Date.now()}`);
env.$("#menu-create").click();
this.assertFalse(env.$("#new-testing-row").hasClass("hidden"), "the toggle is offered locally");
env.$("#new-name").val("Alice");
env.$("#new-password").val("pw");
env.$("#new-testing").prop("checked", true);
env.$("#new-start").click();
const socket = FakeWebSocket.last;
socket.simulateOpen();
await flush();
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "Alice" });
await flush();
const setup = socket.lastSent();
this.assertEqual(setup.t, "configure_game");
this.assertTrue(setup.setup.testing, "the setup requests testing mode");
} finally {
teardownDom(env);
}
}
async test_missing_credentials_show_a_message() {
const env = await setupDom();
try {
+1
View File
@@ -59,6 +59,7 @@ export function mapLayers($) {
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
};
+165
View File
@@ -232,6 +232,171 @@ export class GameScreenTest extends TestCase {
}
}
async test_air_strike_order_is_sent_and_the_flight_is_predicted() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto);
const goal = adjacentLand(state, home);
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const unit = screen._unit(jet.id);
this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible");
await screen._sendMove([unit], goal, null, "attack");
this.assertEqual(network.orders[0], {
type: "attack",
unit: unit.id,
coords: [goal.x, goal.y],
});
this.assertTrue(
screen.map._unitMotion.get(unit.id).predicting,
"the aircraft flies toward the target"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_rearming_aircraft_is_refused_with_a_message() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto);
jet.strikeReadyHour = 100;
const goal = adjacentLand(state, home);
state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
this.assertEqual(network.orders.length, 0, "no order is sent while re-arming");
this.assertEqual(env.$("#confirm-title").text(), "Aircraft re-arming");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_unit_panel_shows_an_aircraft_is_rearming() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto);
jet.strikeReadyHour = state.totalHours + 10;
screen.onState(state.snapshot(0));
screen._selectUnits([jet.id]);
this.assertTrue(
env.$("#unit-name").text().includes("re-arming"),
"the panel shows the re-arming cooldown"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_unaffordable_air_strike_is_refused_with_a_message() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 0);
const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto);
const goal = adjacentLand(state, home);
state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
this.assertEqual(network.orders.length, 0, "no order is sent without the budget");
this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_finishing_an_airport_unlocks_aircraft_in_an_open_panel() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
const before = env.$("#city-train-list button").length;
this.assertEqual(before, 2, "only infantry and artillery are trainable at first");
// The airport is completed server-side and a fresh snapshot arrives while
// the panel is still open.
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
capital.buildings[airport] = 1;
screen.onState(state.snapshot(0));
this.assertGreater(
env.$("#city-train-list button").length,
before,
"the aircraft appears in the still-open panel"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_testing_mode_city_panel_offers_free_instant_orders() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.testing = true;
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-train-list .free-button"), 2, "a free button per trainable unit");
env.$("#city-train-list .free-button").first().click();
this.assertEqual(network.orders[0], {
type: "train",
city: capital.id,
proto: 0,
free: true,
});
env.$("#modal-city .tab[data-tab='buildings']").click();
this.assertGreater(
env.$("#city-buildings-list .free-button").length,
0,
"a free button per building"
);
env.$("#city-buildings-list .free-button").first().click();
this.assertTrue(network.orders[1].free, "the building order is free");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_training_modal_lists_the_queue_and_cancels() {
const env = await setupDom();
try {
+2
View File
@@ -209,6 +209,8 @@ export class GameServerOrdersTest extends TestCase {
"the ranged attack is accepted"
);
this.assertEqual(attacker.coords, pair.a, "the artillery held its ground");
this.assertEqual(defender.hp, defender.maxHp, "the first volley waits for the hour");
state.tickHour();
this.assertLess(defender.hp, defender.maxHp, "the enemy stack was bombed");
}
+30 -1
View File
@@ -383,8 +383,37 @@ export class MapViewTest extends TestCase {
}
}
async test_draws_a_bombardment_line_and_ring_when_the_battery_is_selected() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const artillery = state._spawnUnit(
spot,
0,
state.protoUnits.find((p) => p.id === "artillery")
);
const goal = state.topology
.neighbours(spot.x, spot.y)
.find((n) => state._isLand(n) && !state.unitAt(n));
state._spawnUnit(goal, 1, state.protoUnits[0]);
this.assertTrue(state.requestBombard([artillery.id], goal), "the battery opens fire");
const { env, map } = await this.build(state);
try {
map.setSelectedUnit(artillery.id);
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 1, "the line is drawn");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
map.clearSelection();
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 0, "the line clears with selection");
this.assertSize(env.$("#layer-targets .target-ring"), 0, "the ring clears with selection");
} finally {
teardownDom(env);
}
}
async test_click_handling_selects_and_orders() {
const state = defaultState();
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
+107
View File
@@ -0,0 +1,107 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { GameServer } from "../server/game_server.js";
import { isLoopback } from "../server/server.js";
const BIG_BUDGET = 1.0e15;
function makeServer(testing) {
const network = { getPeerIds: () => [], send: () => {} };
const server = new GameServer(network);
server.configureGame({
seed: 12345,
player_civ: 0,
civilisations: ["france", "britain"],
testing,
});
return server;
}
export class TestingModeTest extends TestCase {
test_loopback_addresses_are_recognised() {
this.assertTrue(isLoopback("127.0.0.1"), "v4 loopback");
this.assertTrue(isLoopback("127.0.1.5"), "any 127/8 address");
this.assertTrue(isLoopback("::1"), "v6 loopback");
this.assertTrue(isLoopback("::ffff:127.0.0.1"), "mapped v4 loopback");
this.assertFalse(isLoopback("10.0.0.1"), "a private address is remote");
this.assertFalse(isLoopback("::ffff:8.8.8.8"), "a mapped public address is remote");
this.assertFalse(isLoopback(""), "an empty address is remote");
this.assertFalse(isLoopback(undefined), "a missing address is remote");
}
test_free_training_spawns_instantly_and_costs_nothing() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = state.cities.find((c) => c.civ === 0);
const artillery = state.protoUnits.findIndex((u) => u.id === "artillery");
const before = state.units.filter((u) => u.civ === 0).length;
this.assertTrue(state.requestTrain(city.id, artillery, true), "the free order is accepted");
this.assertEqual(
state.units.filter((u) => u.civ === 0).length,
before + 1,
"the unit appears at once"
);
this.assertEqual(state.getBudget(0), BIG_BUDGET, "nothing is charged");
this.assertFalse(state.training.has(city.id), "nothing is queued");
}
test_free_build_raises_the_level_instantly_and_costs_nothing() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = state.cities.find((c) => c.civ === 0);
const market = state.protoBuildings.findIndex((b) => b.id === "market");
this.assertTrue(state.requestBuild(city.id, market, true), "the free build is accepted");
this.assertEqual(state.getCityBuildingLevel(city, market), 1, "the level rose at once");
this.assertEqual(state.getBudget(0), BIG_BUDGET, "nothing is charged");
this.assertFalse(state.training.has(city.id), "nothing is queued");
}
test_free_build_still_respects_placement_rules() {
const state = smallState();
const inland = state.cities.find((c) => c.civ === 0 && !state.isCoastalCity(c));
this.assertNotNull(inland, "the fixture has an inland city");
const shipyard = state.protoBuildings.findIndex((b) => b.id === "shipyard");
this.assertFalse(state.requestBuild(inland.id, shipyard, true), "an inland shipyard is refused");
}
test_a_normal_order_still_charges_and_queues() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = state.cities.find((c) => c.civ === 0);
const market = state.protoBuildings.findIndex((b) => b.id === "market");
this.assertTrue(state.requestBuild(city.id, market), "the paid order is accepted");
this.assertEqual(state.getCityBuildingLevel(city, market), 0, "it does not rise at once");
this.assertLess(state.getBudget(0), BIG_BUDGET, "it was charged");
this.assertTrue(state.training.has(city.id), "it went into the queue");
}
test_server_honours_free_orders_in_a_testing_game() {
const server = makeServer(true);
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
"the order is accepted"
);
this.assertEqual(server.state.getCityBuildingLevel(city, market), 1, "the building went up");
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
this.assertTrue(server.state.testing, "the snapshot flag is set");
}
test_server_ignores_the_free_flag_in_a_normal_game() {
const server = makeServer(false);
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
"the order is still processed"
);
this.assertEqual(server.state.getCityBuildingLevel(city, market), 0, "it only went into the queue");
this.assertLess(server.state.getBudget(0), budgetBefore, "the normal cost was charged");
this.assertFalse(server.state.testing, "the testing flag stays off");
}
}