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:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 & 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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) || {},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 --
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user