Better map generation

This commit is contained in:
2026-09-17 22:01:30 +02:00
parent b08074a2f3
commit 4e2eae3306
23 changed files with 1766 additions and 4363 deletions
+219 -6
View File
@@ -57,7 +57,7 @@ export class GameScreenTest extends TestCase {
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertEqual(network.orders[1], { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
this.assertTrue(screen.map._unitMotion.get(unit.id).predicting, "the move was predicted");
@@ -131,7 +131,7 @@ export class GameScreenTest extends TestCase {
this.assertFalse(env.$("#train-status").hasClass("hidden"));
this.assertTrue(env.$("#train-label").text().includes("Training"));
state.training.get(capital.id).elapsedHours = 3;
state.training.get(capital.id)[0].elapsedHours = 3;
screen.onState(state.snapshot(0));
this.assertTrue(env.$("#train-label").text().includes("left"));
@@ -144,6 +144,68 @@ export class GameScreenTest extends TestCase {
}
}
async test_attack_and_scheduled_move_orders_are_serialised() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const raw = state.units.find((u) => u.civ === 0);
const unit = screen._unit(raw.id);
const goal = adjacentLand(state, raw);
await screen._sendMove([unit], goal, null, "attack");
this.assertEqual(network.orders[0], {
type: "attack",
unit: unit.id,
coords: [goal.x, goal.y],
});
await screen._sendMove([unit], goal, null, "schedule");
this.assertEqual(network.orders[1], {
type: "schedule",
unit: unit.id,
coords: [goal.x, goal.y],
goals: [[goal.x, goal.y]],
append: true,
});
screen.leave();
} finally {
teardownDom(env);
}
}
async test_training_modal_lists_the_queue_and_cancels() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-train").click();
state.requestTrain(capital.id, 0);
state.requestTrain(capital.id, 0);
screen.onState(state.snapshot(0));
this.assertSize(env.$("#train-queue .queue-row"), 1, "contiguous identical units merge");
this.assertTrue(
env.$("#train-queue .queue-row").first().text().includes("×2"),
"the merged row shows how many units it holds"
);
this.assertTrue(
env.$("#train-queue .queue-row").first().text().includes("all ready in"),
"the merged row forecasts the whole run"
);
env.$("#train-queue .queue-row").first().find("button").click();
this.assertEqual(network.orders[0], { type: "cancel_train", city: capital.id, index: 1 });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_bottom_panel_information() {
const env = await setupDom();
try {
@@ -160,6 +222,11 @@ export class GameScreenTest extends TestCase {
screen.map.onUnitSelected(unit.id);
this.assertFalse(env.$("#unit-info").hasClass("hidden"));
this.assertTrue(env.$("#unit-name").text().length > 0);
this.assertEqual(
env.$("#unit-health-label").text(),
`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`,
"the panel shows hit points next to the bar"
);
screen._showUnit(null);
this.assertTrue(env.$("#unit-info").hasClass("hidden"));
screen._showCity(null);
@@ -314,7 +381,7 @@ export class GameScreenTest extends TestCase {
screen.map.predictMove = (u, coords) => {
predicted = { u, coords };
};
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
this.assertEmpty(network.orders, "nothing is sent before confirming");
this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
@@ -340,7 +407,7 @@ export class GameScreenTest extends TestCase {
const goal = { x: 902, y: 902 };
screen.map.territory.set("902,902", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
env.$("#confirm-cancel").click();
this.assertEmpty(network.orders, "cancelling keeps the peace");
screen.leave();
@@ -363,7 +430,7 @@ export class GameScreenTest extends TestCase {
screen.map.territory.set("901,901", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.predictMove = () => null;
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertTrue(env.$("#modal-confirm").hasClass("hidden"));
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] });
screen.leave();
@@ -381,7 +448,7 @@ export class GameScreenTest extends TestCase {
screen.onState(defaultState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
const row = env.$("#diplomacy-list .row-card").first();
const row = env.$("#diplomacy-list tbody tr").first();
this.assertTrue(row.find(".name").text().length > 0);
row.find("button").click();
this.assertEqual(network.orders[0].type, "declare_war");
@@ -448,4 +515,150 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
async test_stack_menu_selects_all_and_splits_one() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
this.assertFalse(env.$("#stack-menu").hasClass("hidden"));
this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all");
env.$("#stack-menu .stack-all").click();
this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]);
this.assertTrue(env.$("#unit-name").text().includes("2 units selected"));
this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick");
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click();
this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_clicking_a_stacked_unit_selects_the_whole_stack() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onUnitSelected(unit.id);
this.assertEqual(
screen.selectedUnitIds.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b),
"the whole stack is selected"
);
// The next order is given to every unit in the stack.
const goal = adjacentLand(state, unit);
await screen.map._onRightClick(goal, {});
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(
network.orders[0].units.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b)
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_group_move_uses_the_slowest_speed() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const capital = state.units.find((u) => u.civ === 0);
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
screen.onState(state.snapshot(0));
screen._selectUnits([infantry.id, artillery.id]);
const goal = adjacentLand(state, capital);
await screen.map.onMoveOrdered([infantry.id, artillery.id], goal);
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]);
const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0];
const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0];
this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_forecasts_and_sends_the_order() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const coords = findEnemyTile(state);
this.assertNotNull(coords, "found an enemy tile");
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
const first = state._spawnUnit(coords, 0, state.protoUnits[0]);
const second = state._spawnUnit(coords, 0, state.protoUnits[0]);
first.hp = 100;
second.hp = 90;
screen.onState(state.snapshot(0));
screen._selectUnits([first.id, second.id]);
const $button = env.$("#unit-pillage");
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
const tooltip = $button.attr("data-tooltip") || "";
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
$button.click();
this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_hides_in_friendly_land() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
screen.onState(state.snapshot(0));
screen._selectUnits([unit.id]);
this.assertTrue(env.$("#unit-pillage").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
}
function findEnemyTile(state) {
for (const [k, owner] of state.territory) {
if (owner !== 1) continue;
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
if (state.unitAt(coords) || state.cityAt(coords)) continue;
return coords;
}
return null;
}