Let stacks split by shift-click and cross allied or at-war land
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user