mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 16:16:50 +00:00
29a1e8dfda
Resolves #3666 ## Description: Adds RTS-style box selection for warships. Hold Shift and drag (desktop) or long-press and drag (touch/mobile) to draw a selection rectangle — all player-owned warships inside get selected at once. A subsequent click/tap on water sends them all to that location. - `SelectionBoxLayer` — pixel-dashed rectangle in world-space, player territory color; shared between desktop and touch - `UILayer` — same pulsing selection outline on each box-selected warship; clears correctly when switching between single/multi selection - `UnitLayer` — finds warships in screen rect, filters inactive ships before sending; touch support included - `InputHandler` — Shift+drag and touch long-press+drag both emit selection box events; cursor becomes crosshair on Shift; discards active ghost structure on Shift press; configurable via `shiftKey` keybind - `Transport` — single atomic `move_multiple_warships` intent (no split on socket drop) - `Schemas` + `ExecutionManager` + `MoveMultipleWarshipsExecution` — server fans out atomic intent into individual `MoveWarshipExecution` per ship - `DynamicUILayer` — `MoveIndicatorUI` chevron animation on target tile for both single and multi move - `UnitDisplay` — warship tooltip Shift hint via `translateText` - `HelpModal` — new hotkey row: Shift + drag → select multiple warships ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## UI update ### Mouse + Keyboard https://github.com/user-attachments/assets/3f35ab5e-1f3c-4c5d-bc4f-aabccf64dc60 ### Touch https://github.com/user-attachments/assets/0d6aec3f-44fa-4fee-b5c6-b267b9b14d79 ## ## Please put your Discord username so you can be contacted if a bug or regression is found: fghjk_60845
288 lines
7.7 KiB
TypeScript
288 lines
7.7 KiB
TypeScript
import { MoveWarshipExecution } from "../src/core/execution/MoveWarshipExecution";
|
|
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
|
|
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "../src/core/game/Game";
|
|
import { TileRef } from "../src/core/game/GameMap";
|
|
import { setup } from "./util/Setup";
|
|
import { executeTicks } from "./util/utils";
|
|
|
|
const coastX = 7;
|
|
let game: Game;
|
|
let player1: Player;
|
|
let player2: Player;
|
|
|
|
describe("Warship", () => {
|
|
beforeEach(async () => {
|
|
game = await setup(
|
|
"half_land_half_ocean",
|
|
{
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
},
|
|
[
|
|
new PlayerInfo("boat dude", PlayerType.Human, null, "player_1_id"),
|
|
new PlayerInfo("boat dude", PlayerType.Human, null, "player_2_id"),
|
|
],
|
|
);
|
|
|
|
while (game.inSpawnPhase()) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
player1 = game.player("player_1_id");
|
|
player2 = game.player("player_2_id");
|
|
});
|
|
|
|
test("Warship heals only if player has port", async () => {
|
|
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
|
|
if (typeof maxHealth !== "number") {
|
|
expect(typeof maxHealth).toBe("number");
|
|
throw new Error("unreachable");
|
|
}
|
|
|
|
const port = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
|
|
expect(warship.health()).toBe(maxHealth);
|
|
warship.modifyHealth(-10);
|
|
expect(warship.health()).toBe(maxHealth - 10);
|
|
game.executeNextTick();
|
|
expect(warship.health()).toBe(maxHealth - 9);
|
|
|
|
port.delete();
|
|
|
|
game.executeNextTick();
|
|
expect(warship.health()).toBe(maxHealth - 9);
|
|
});
|
|
|
|
test("Warship captures trade if player has port", async () => {
|
|
const portTile = game.ref(coastX, 10);
|
|
player1.buildUnit(UnitType.Port, portTile, {});
|
|
game.addExecution(
|
|
new WarshipExecution(
|
|
player1.buildUnit(UnitType.Warship, portTile, {
|
|
patrolTile: portTile,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 7),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
// Let plenty of time for A* to execute
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
expect(tradeShip.owner()).toBe(player1);
|
|
});
|
|
|
|
test("Warship do not capture trade if player has no port", async () => {
|
|
game.addExecution(
|
|
new WarshipExecution(
|
|
player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 11), {
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
targetUnit: player1.buildUnit(UnitType.Port, game.ref(coastX, 11), {}),
|
|
},
|
|
);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
// Let plenty of time for warship to potentially capture trade ship
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship does not target trade ships that are safe from pirates", async () => {
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
tradeShip.setSafeFromPirates();
|
|
|
|
executeTicks(game, 10);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship moves to new patrol tile", async () => {
|
|
game.config().warshipTargettingRange = () => 1;
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.addExecution(
|
|
new MoveWarshipExecution(
|
|
player1,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
),
|
|
);
|
|
|
|
executeTicks(game, 10);
|
|
|
|
expect(warship.patrolTile()).toBe(game.ref(coastX + 5, 15));
|
|
});
|
|
|
|
test("Warship does not not target trade ships outside of patrol range", async () => {
|
|
game.config().warshipTargettingRange = () => 3;
|
|
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 15),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
executeTicks(game, 10);
|
|
|
|
// Trade ship should not be captured
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship does not target trade ships in different water components", async () => {
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warshipTile = game.ref(coastX + 1, 2);
|
|
const tradeShipTile = game.ref(coastX + 1, 12);
|
|
|
|
const warship = player1.buildUnit(UnitType.Warship, warshipTile, {
|
|
patrolTile: warshipTile,
|
|
});
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(UnitType.TradeShip, tradeShipTile, {
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
});
|
|
|
|
// Mock different water components
|
|
game.getWaterComponent = (tile: TileRef) => {
|
|
if (tile === warshipTile) return 1;
|
|
return 2;
|
|
};
|
|
|
|
game.hasWaterComponent = (tile: TileRef, component: number) => {
|
|
return game.getWaterComponent(tile) === component;
|
|
};
|
|
|
|
executeTicks(game, 10);
|
|
|
|
// Trade ship should not be captured because it's in a different component
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("MoveWarshipExecution fails if player is not the owner", async () => {
|
|
const originalPatrolTile = game.ref(coastX + 1, 10);
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 5),
|
|
{
|
|
patrolTile: originalPatrolTile,
|
|
},
|
|
);
|
|
new MoveWarshipExecution(
|
|
player2,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
).init(game, 0);
|
|
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
|
});
|
|
|
|
test("MoveWarshipExecution fails if warship is not active", async () => {
|
|
const originalPatrolTile = game.ref(coastX + 1, 10);
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 5),
|
|
{
|
|
patrolTile: originalPatrolTile,
|
|
},
|
|
);
|
|
warship.delete();
|
|
new MoveWarshipExecution(
|
|
player1,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
).init(game, 0);
|
|
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
|
});
|
|
|
|
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
|
|
const exec = new MoveWarshipExecution(
|
|
player1,
|
|
[123],
|
|
game.ref(coastX + 5, 15),
|
|
);
|
|
|
|
// Verify that no error is thrown.
|
|
exec.init(game, 0);
|
|
|
|
expect(exec.isActive()).toBe(false);
|
|
});
|
|
});
|