Let stacks split by shift-click and cross allied or at-war land

This commit is contained in:
2026-09-24 19:41:08 +02:00
parent cc5c57c5f4
commit 36ac6698a8
14 changed files with 379 additions and 27 deletions
+4 -4
View File
@@ -98,8 +98,8 @@ simplification worth revisiting. Anything unticked is still open.
enemy walking on it), coastal cannon (auto-fires on warships).
- [x] Embark / disembark orders wired on server and client, with a cargo readout
in the unit panel.
- [ ] Easy and ergonomic way to split unit stacks.
- [ ] Give stacks with range units a dedicated button to perform ranged attacks,
- [x] Easy and ergonomic way to split unit stacks.
- [x] Give stacks with range units a dedicated button to perform ranged attacks,
right-clicking will now ONLY move (obviously triggers a battle when moving on the same tile)
## 6. Content and tuning
@@ -320,8 +320,8 @@ simplification worth revisiting. Anything unticked is still open.
- [x] All buildings and improvements destroyed, except for bunkers and fortifications
- [x] -1000 popularity across the board
(any unit caught in the blast is destroyed too)
- [ ] Stacks must behave as single units when it comes to pathfinding
- [ ] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- [x] Stacks must behave as single units when it comes to pathfinding
- [x] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- [ ] Airplanes move in straight lines as they are not bound by tiles.
* [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them
+26
View File
@@ -218,3 +218,29 @@ Open questions:
`INTELLIGENCE.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`POLITICS_PERFORMANCE.md`, `TRAINING_UI.md`, `ICONS.md` (repo root).
## 11. Decisions taken (implemented)
- **Split (§4): shift-click detach.** A plain left-click still takes the whole
stack; shift + left-click on a map unit (or a panel card, which already toggled)
adds or removes just that unit from the moving group. Right-click moves the
ticked subset at its own slowest-member pace; unticked cards stay in the panel
so a mis-click is recoverable. No persistent sub-stack entity was introduced.
`client/js/map_view/input.js` (`_onLeftClick`) forwards the modifier and
`client/js/game_screen.js` (`map.onUnitToggled`) edits `selectedUnitIds`
in place.
- **Territory (§5): neutral third parties are the only wall.**
`GameState._canTraverseTerritory(civ, owner, targetCiv)` allows a nation's own
land, the land of the nation a route is aimed at, an ally's, and a nation
already at war; `_canUnitEnter` uses it and `_orderMove` no longer declares war
on an ally or an at-war nation it crosses. "Allied" means an **alliance
treaty** specifically (`areAllied`). The browser mirrors this in
`map_view/motion.js` `_canTraverseTerritory`, with the alliance/war pairs read
from the snapshot in `map_view/entities.js` `_applyRelations`.
- **One route per stack (§5.3): deliberately not changed.** Mixed stacks (land
and naval, say) have different traversable terrain, so a single shared path
would be wrong; the existing per-type route cache already collapses identical
searches, and every member is pinned to the slowest speed. "Behave as a single
unit" is therefore the shared traversal policy plus formation speed, not a
literal one-path-fits-all.
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "cc5c57c",
"date": "2026-09-24",
"subject": "Gave the world spies, treaties and nuclear weapons",
"body": ""
},
{
"hash": "54efd66",
"date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Stockpiled a month of every material and cached the trade walks",
"body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended."
},
{
"hash": "3418e80",
"date": "2026-09-23",
"subject": "Turned unit upkeep into a resource bill and gave research the erlenmeyer icon",
"body": ""
}
];
+11
View File
@@ -352,6 +352,17 @@ export class GameScreen {
// Clicking a unit also reveals the tile (or city) it stands on.
this._inspectUnitTile(unit);
};
// Shift-clicking a unit detaches (or re-attaches) just that unit to the
// moving group, leaving the rest of the stack behind: the ergonomic split.
this.map.onUnitToggled = (id) => {
const unit = this._unit(id);
if (!unit || unit.civ !== this.localCiv) return;
const next = this.selectedUnitIds.slice();
const at = next.indexOf(unit.id);
if (at >= 0) next.splice(at, 1);
else next.push(unit.id);
this._selectUnits(next);
};
this.map.onCitySelected = (id) => this._selectCity(id);
// A double click goes one step further and opens the full city panel.
this.map.onCityOpened = (id) => {
+18
View File
@@ -20,6 +20,7 @@ import {
NEWS_ENRICHMENT,
NEWS_TREATY,
TREATY_PROPOSED,
TREATY_ALLIANCE,
treatyById,
ECONOMY,
EFFECT_PRODUCTION,
@@ -315,6 +316,7 @@ export const panelMethods = {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (this._isAllied(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
}
@@ -802,6 +804,18 @@ export const panelMethods = {
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
},
// Whether two nations have a standing alliance. Read from the snapshot's
// treaties, which the client mirrors on every state. An ally's land may be
// crossed without a war declaration.
_isAllied(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return ((this.snapshot && this.snapshot.treaties) || []).some(
(treaty) =>
treaty.type === TREATY_ALLIANCE &&
((treaty.a === a && treaty.b === b) || (treaty.a === b && treaty.b === a))
);
},
_civName(index) {
const civ = this.civilisations[index];
return civ ? civ.name : `Nation ${index + 1}`;
@@ -1001,6 +1015,10 @@ export const panelMethods = {
this._updateCargo(units);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
// The deck was built while the panel was still hidden, so its cards had
// no measurable width and the overlap was skipped. Lay them out again now
// that they are actually on screen.
this._layoutStackDecks();
return;
}
if (units.length === 0) {
+5
View File
@@ -137,6 +137,10 @@ export class MapView {
this._warfareViews = new Map();
this.civilisations = [];
this.localCiv = -1;
// Diplomacy mirrored into pair sets for movement prediction (see
// map_view/entities.js `_applyRelations`).
this._wars = new Set();
this._allies = new Set();
this.protoUnits = [];
// Battlefield tiles, keyed "x,y" and mapped to { defender, attacker }.
this._battles = new Map();
@@ -244,6 +248,7 @@ export class MapView {
this._wrapperPool = [];
this.onUnitSelected = () => {};
this.onUnitToggled = () => {};
this.onCitySelected = () => {};
this.onCityOpened = () => {};
this.onTileRequested = () => {};
+23 -2
View File
@@ -2,7 +2,7 @@
// plus the stack and battle layout of unit markers.
import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
import { statusById, RELATION_WAR, TREATY_ALLIANCE } from "../../../shared/data.js";
import { tileImprovementById } from "../../../shared/data/improvements.js";
import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
@@ -31,6 +31,7 @@ export const entityMethods = {
this.setConstructionSites(state.constructionSites || []);
bootLog.step("Mirrored the transport network");
this.localCiv = state.viewer;
this._applyRelations(state.diplomacy, state.treaties);
this._applyTerritory(state.territory, state.versions && state.versions.territory);
bootLog.step("Mirrored the territory and borders");
this._applyRegions(state.regions || [], state.versions && state.versions.regions);
@@ -50,6 +51,24 @@ export const entityMethods = {
this.animate(0);
},
// Mirrors the snapshot's diplomacy matrix and alliance treaties into compact
// pair sets, so movement prediction can tell a neutral third party's land (a
// wall) from an ally's or a nation already at war (both walkable). The sets
// are tiny -- one key per pair of civilisations -- so they are rebuilt from
// each snapshot rather than versioned.
_applyRelations(diplomacy, treaties) {
this._wars = new Set();
this._allies = new Set();
for (const entry of diplomacy || []) {
if (entry[2] !== RELATION_WAR) continue;
this._wars.add(entry[0] < entry[1] ? `${entry[0]}:${entry[1]}` : `${entry[1]}:${entry[0]}`);
}
for (const treaty of treaties || []) {
if (treaty.type !== TREATY_ALLIANCE) continue;
this._allies.add(treaty.a < treaty.b ? `${treaty.a}:${treaty.b}` : `${treaty.b}:${treaty.a}`);
}
},
// Mirrors the battlefields in the snapshot: which tile is being fought over,
// and which side defends it. Also keeps one battle marker element per tile.
_applyBattles(entries) {
@@ -666,9 +685,11 @@ export const entityMethods = {
// A left click on the icon picks the unit and stops there so the map does
// not re-handle it. Right-clicks fall through to the viewport so the
// selected stack can be ordered onto this tile (move, stack or attack).
// Shift detaches just this unit to the moving group instead.
if (event.button !== 0) return;
event.stopPropagation();
this.onUnitSelected(data.id);
if (event.shiftKey) this.onUnitToggled(data.id);
else this.onUnitSelected(data.id);
});
view.addEventListener("dblclick", (event) => {
// A unit standing on one of our cities yields to it: double-clicking the
+9 -2
View File
@@ -116,7 +116,7 @@ export const inputMethods = {
if (dragged) return;
const coords = this.tileAtScreen(event.clientX, event.clientY);
if (!coords) return;
if (pressButton === 0) this._onLeftClick(coords);
if (pressButton === 0) this._onLeftClick(coords, event);
else if (pressButton === 2) this._onRightClick(coords, event);
});
@@ -137,7 +137,7 @@ export const inputMethods = {
// ------------------------------------------------------------ clicks --
_onLeftClick(coords) {
_onLeftClick(coords, event) {
// While picking a ranged target a left-click chooses it; the controller
// decides whether the tile is a valid target.
if (this._targeting) {
@@ -145,6 +145,13 @@ export const inputMethods = {
return;
}
const unitId = this.unitAt(coords);
// Shift detaches (or re-attaches) a single unit to the moving group instead
// of grabbing the whole stack, so a stack can be split without opening the
// panel and unticking cards. A plain left-click still takes the whole stack.
if (unitId && event && event.shiftKey) {
this.onUnitToggled(unitId);
return;
}
if (unitId && !this._selectedUnitIds.has(unitId)) {
this.onUnitSelected(unitId);
return;
+33 -5
View File
@@ -778,10 +778,37 @@ export const motionMethods = {
return this.territory.has(k) ? this.territory.get(k) : -1;
},
_pairKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`;
},
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(this._pairKey(a, b));
},
_isAllied(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._allies.has(this._pairKey(a, b));
},
// Mirrors GameState._canTraverseTerritory: a nation's own land, the land of
// the nation a route is aimed at, an ally's and a nation already at war are
// all open; only a neutral third party's land is a wall. Kept in step with
// the server or prediction will draw a route the server refuses.
_canTraverseTerritory(civ, owner, targetCiv = -1) {
if (owner < 0 || owner === civ || owner === targetCiv) return true;
if (this._isAtWar(civ, owner)) return true;
if (this._isAllied(civ, owner)) return true;
return false;
},
// Mirrors GameState._canUnitEnter: friendly units share a tile, while a
// hostile unit or city is only a valid destination (`isGoal`). Foreign
// territory is a wall too, except for the civilisation being targeted, so a
// route never cuts through a third party and provokes a war.
// hostile unit or city is only a valid destination (`isGoal`). A foreign land
// is a wall only for a neutral third party; the civilisation being targeted,
// an ally and a nation already at war may be crossed (see
// `_canTraverseTerritory`), so a route never cuts through a bystander and
// provokes a war.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1, zoc = null, from = null) {
const k = key(coords.x, coords.y);
const air = !!(proto && proto.air);
@@ -797,8 +824,9 @@ export const motionMethods = {
}
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
// A spy slips through foreign territory at will; every other unit treats a
// third party's land as a wall (the targeted civilisation is exempt).
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv && !proto.spy) {
// neutral third party's land as a wall (the targeted civilisation, an ally
// and anyone already at war are all exempt).
if (!air && !isGoal && !proto.spy && !this._canTraverseTerritory(civ, owner, targetCiv)) {
return false;
}
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
+26 -7
View File
@@ -158,7 +158,11 @@ export const movementMethods = {
if (!air) {
for (const coords of path) {
const owner = this.civAt(coords);
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
// Crossing an ally's or an at-war nation's land is legal and declares
// nothing; only a neutral third party's land turns the march into war.
if (!this._canTraverseTerritory(unit.civ, owner, -1)) {
this._declareWar(unit.civ, owner);
}
}
}
}
@@ -378,10 +382,11 @@ export const movementMethods = {
// Enemy cities are only open to military units, since walking one in
// captures it.
//
// Foreign territory is a wall as well, so a route never cuts through a third
// party and provokes a war for purely technical reasons. The one exception is
// the civilisation being targeted (`targetCiv`, the owner of the goal), whose
// land the route may cross to reach it.
// A foreign land is a wall only when its owner is a neutral third party: a
// route may cross the land of the nation it is aimed at (`targetCiv`), of an
// ally, and of a nation already at war (the war was declared regardless), as
// `_canTraverseTerritory` spells out. So a march never drags a bystander into
// a war for purely technical reasons.
//
// A hostile unit or city is only a valid destination (`isGoal`), never a tile
// to pass through: pathfinding routes around enemies instead of through them.
@@ -411,8 +416,9 @@ export const movementMethods = {
}
const owner = this.civAt(coords);
// A spy slips through foreign territory at will; every other unit treats a
// third party's land as a wall (the targeted civilisation is exempt).
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv && !proto.spy) {
// neutral third party's land as a wall (the targeted civilisation, an ally
// and anyone already at war are all exempt).
if (!air && !isGoal && !proto.spy && !this._canTraverseTerritory(unit.civ, owner, targetCiv)) {
return false;
}
const occupants = occupancy
@@ -437,6 +443,19 @@ export const movementMethods = {
return true;
},
// Whether `civ` may cross land owned by `owner` on a through-route. A nation's
// own land is always open, and so is the land of the nation a route is aimed
// at (`targetCiv`), of an ally, and of a nation already at war -- the war was
// declared regardless. Only a neutral third party is a wall, so a march never
// drags a bystander into a war for purely technical reasons. Mirrored by the
// browser in map_view/motion.js `_canTraverseTerritory`.
_canTraverseTerritory(civ, owner, targetCiv = -1) {
if (owner < 0 || owner === civ || owner === targetCiv) return true;
if (this.isAtWar(civ, owner)) return true;
if (this.areAllied(civ, owner)) return true;
return false;
},
// Every unit bucketed by the tile it stands on, so a search can look up
// occupancy in constant time instead of scanning the whole army for every
// tile it considers. The mover is left out: it never blocks itself. The
+78
View File
@@ -1054,6 +1054,84 @@ export class GameScreenTest extends TestCase {
}
}
async test_stack_deck_is_laid_out_after_the_panel_is_shown() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.units.find((u) => u.civ === 0);
state._spawnUnit(home.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const snap = screen.snapshot.units.find((u) => u.civ === 0);
// Records whether the panel was already on screen when the deck was
// measured, so a hidden panel (zero-width cards, no overlap) is caught.
let visibleAtLayout = null;
const layout = screen._layoutStackDecks.bind(screen);
screen._layoutStackDecks = () => {
visibleAtLayout = !env.$("#unit-info").hasClass("hidden");
layout();
};
screen.map.onUnitSelected(snap.id);
this.assertGreater(env.$("#unit-stack-list .stack-unit-tile").length, 1, "the deck rendered");
this.assertTrue(visibleAtLayout, "the deck is measured only once the panel is shown");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_shift_detach_splits_the_stack_into_a_moving_group() {
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 a = home;
const b = state._spawnUnit(home.coords, 0, state.protoUnits[0]);
const c = state._spawnUnit(home.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const sorted = (list) => list.slice().sort((x, y) => x - y);
const all = sorted([a.id, b.id, c.id]);
screen.map.onUnitSelected(a.id);
this.assertEqual(sorted(screen.selectedUnitIds), all, "a click takes the whole stack");
screen.map.onUnitToggled(b.id);
this.assertEqual(
sorted(screen.selectedUnitIds),
sorted([a.id, c.id]),
"shift detaches one unit from the moving group"
);
this.assertTrue(
screen._stackMembers.includes(b.id),
"the detached card stays in the panel, so a mis-click is recoverable"
);
screen.map.onUnitToggled(b.id);
this.assertEqual(screen.selectedUnitIds.length, 3, "toggling it again re-attaches it");
screen.map.onUnitToggled(b.id);
const goal = state.topology
.neighbours(home.coords.x, home.coords.y)
.find((n) => state._isLand(n) && state.civAt(n) <= 0 && !state.unitAt(n));
this.assertNotNull(goal, "the fixture has an empty own-land neighbour");
await screen.map.onMoveOrdered(screen.selectedUnitIds.slice(), goal);
const order = network.orders.at(-1);
this.assertEqual(order.type, "move");
this.assertEqual(
sorted(order.units),
sorted([a.id, c.id]),
"only the ticked subset marches"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_the_stat_tooltip_sits_outside_the_hud() {
const env = await setupDom();
try {
+41 -1
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { TRAINING_QUEUE_LIMIT } from "../shared/game_state.js";
import { ECONOMY } from "../shared/data.js";
import { ECONOMY, TREATY_ALLIANCE } from "../shared/data.js";
import {
smallState,
adjacentLand,
@@ -80,6 +80,46 @@ export class GameStateTest extends TestCase {
);
}
test_allied_and_at_war_territory_may_be_crossed() {
const state = smallState(["france", "britain", "poland"]);
const mover = state.units.find((u) => u.civ === 0);
const tile = state.landCells.find(
(c) =>
!state.unitAt(c) &&
!state.cityAt(c) &&
(c.x !== mover.coords.x || c.y !== mover.coords.y)
);
state.territory.set(`${tile.x},${tile.y}`, 2);
// Poland is a neutral third party, so its land is a wall on a through-route.
this.assertFalse(
state._canUnitEnter(mover, tile, false, -1),
"a neutral third party's land blocks transit"
);
// A war with Poland opens that same land, with no extra war declared.
state.requestDeclareWar(0, 2);
this.assertTrue(
state._canUnitEnter(mover, tile, false, -1),
"a nation at war may be crossed"
);
const allied = smallState(["france", "britain", "poland"]);
const allyMover = allied.units.find((u) => u.civ === 0);
const allyTile = allied.landCells.find(
(c) =>
!allied.unitAt(c) &&
!allied.cityAt(c) &&
(c.x !== allyMover.coords.x || c.y !== allyMover.coords.y)
);
allied.territory.set(`${allyTile.x},${allyTile.y}`, 2);
allied.requestProposeTreaty(0, 2, TREATY_ALLIANCE);
allied.requestRespondTreaty(2, allied.treatyProposals[0].id, true);
this.assertTrue(allied.areAllied(0, 2), "France and Poland are allied");
this.assertTrue(
allied._canUnitEnter(allyMover, allyTile, false, -1),
"an ally's land may be crossed"
);
}
test_territory_only_claims_land() {
const state = smallState();
this.assertNotEmpty(state.territory);
+85
View File
@@ -3,6 +3,7 @@ import { MapViewFixture } from "./framework/map_view_fixture.js";
import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js";
import { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js";
import { smallState, adjacentLand, seaTile } from "./framework/helpers.js";
import { TREATY_ALLIANCE } from "../shared/data.js";
export class MapViewInputTest extends MapViewFixture {
async test_selecting_a_unit_marks_its_view() {
@@ -185,6 +186,90 @@ export class MapViewInputTest extends MapViewFixture {
}
}
async test_shift_click_toggles_one_unit_instead_of_the_whole_stack() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
let selected = -1;
let toggled = -1;
map.onUnitSelected = (id) => { selected = id; };
map.onUnitToggled = (id) => { toggled = id; };
const coords = { x: unit.coords[0], y: unit.coords[1] };
map._onLeftClick(coords, { shiftKey: true });
this.assertEqual(toggled, unit.id, "shift-click asks to toggle that one unit");
this.assertEqual(selected, -1, "and does not grab the whole stack");
map._onLeftClick(coords, {});
this.assertEqual(selected, unit.id, "a plain click still takes the stack");
} finally {
teardownDom(env);
}
}
async test_prediction_crosses_allied_and_at_war_land_but_not_a_neutral() {
const state = smallState(["france", "britain", "poland"]);
const { env, map } = await this.build(state);
try {
const proto = map.protoUnits[0];
const occupied = (c) =>
Array.from(map._unitData.values()).some((u) => u.coords[0] === c.x && u.coords[1] === c.y);
const tile = Object.keys(map.tiles)
.map((k) => parseKey(k))
.find(
(c) =>
map.tiles[`${c.x},${c.y}`].terrainClass === "Land" &&
!map._cityCiv.has(`${c.x},${c.y}`) &&
!occupied(c)
);
this.assertNotNull(tile, "the fixture has an empty land tile");
map.territory.set(`${tile.x},${tile.y}`, 2);
this.assertFalse(map._canEnter(0, proto, tile), "a neutral third party's land is a wall");
state.requestDeclareWar(0, 2);
map.applySnapshot(state.snapshot(0));
this.assertTrue(map._isAtWar(0, 2), "the war is mirrored for prediction");
this.assertTrue(map._canEnter(0, proto, tile), "a nation at war may be crossed");
const allied = smallState(["france", "britain", "poland"]);
const { env: env2, map: map2 } = await this.build(allied);
try {
allied.requestProposeTreaty(0, 2, TREATY_ALLIANCE);
allied.requestRespondTreaty(2, allied.treatyProposals[0].id, true);
map2.applySnapshot(allied.snapshot(0));
map2.territory.set(`${tile.x},${tile.y}`, 2);
this.assertTrue(map2._isAllied(0, 2), "the alliance is mirrored for prediction");
this.assertTrue(map2._canEnter(0, proto, tile), "an ally's land may be crossed");
} finally {
teardownDom(env2);
}
} finally {
teardownDom(env);
}
}
async test_shift_mousedown_on_a_unit_icon_detaches_that_unit() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
let toggled = -1;
let selected = -1;
map.onUnitToggled = (id) => { toggled = id; };
map.onUnitSelected = (id) => { selected = id; };
const view = map._unitViews.get(unit.id);
this.assertNotNull(view, "the unit has a view");
view.dispatchEvent(
new env.window.MouseEvent("mousedown", { button: 0, shiftKey: true, bubbles: true })
);
this.assertEqual(toggled, unit.id, "shift-mousedown detaches that one unit");
this.assertEqual(selected, -1, "it does not grab the whole stack");
view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true }));
this.assertEqual(selected, unit.id, "a plain mousedown still takes the stack");
} finally {
teardownDom(env);
}
}
async test_stacked_units_show_one_icon_with_a_count_badge() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
+14
View File
@@ -91,6 +91,20 @@ export class TreatyTest extends TestCase {
this.assertTrue(state.isAtWar(0, 1));
}
test_marching_across_an_ally_does_not_declare_war() {
const state = smallState(["france", "britain", "poland"]);
this.assertTrue(sign(state, 0, 2, TREATY_ALLIANCE));
const unit = state.units.find((u) => u.civ === 0);
const goal = state.topology
.neighbours(unit.coords.x, unit.coords.y)
.find((n) => state._isLand(n) && !state.cityAt(n) && !state.unitAt(n));
this.assertNotNull(goal, "the fixture has an empty land neighbour");
state.territory.set(`${goal.x},${goal.y}`, 2);
this.assertTrue(state.requestMove(unit.id, goal), "the march into allied land is accepted");
this.assertFalse(state.isAtWar(0, 2), "crossing an ally declares no war");
this.assertTrue(state.areAllied(0, 2), "and leaves the alliance intact");
}
test_renewed_war_breaks_a_peace_treaty() {
const state = smallState();
this.assertTrue(state.requestDeclareWar(0, 1));