Skip to content

Commit ade29b6

Browse files
committed
Add per-move battery drain animation. Fixed a bug related to the battery depleting. Altered the bot-path overlay to look more natural. Fixed a bug in the Dijkstra pathfinding. Fixed a bug with Manhattan movement related to turn-direction selection. Fixed planning-phase bug where objects were allowed to overlap.
1 parent 32ae9f6 commit ade29b6

40 files changed

Lines changed: 3422 additions & 60 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"preview": "vite preview",
1010
"test": "vitest",
1111
"test:run": "vitest run",
12-
"test:coverage": "vitest run --coverage"
12+
"test:coverage": "vitest run --coverage",
13+
"test:seeds": "RUN_EXHAUSTIVE_SEEDS=1 vitest run src/__tests__/virtual-grid-invariants.test.ts"
1314
},
1415
"devDependencies": {
1516
"@testing-library/jest-dom": "^7.0.0",

public/assets/0_battery.png

189 Bytes
Loading

public/assets/100_battery.png

222 Bytes
Loading

public/assets/25_battery.png

207 Bytes
Loading

public/assets/50_battery.png

212 Bytes
Loading

public/assets/75_battery.png

218 Bytes
Loading

public/assets/thinking_emoji.png

1.29 KB
Loading

src/__tests__/GameApp.dom.test.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { describe, it, expect, vi, afterEach } from "vitest";
1111
import { render, cleanup, fireEvent, act } from "@testing-library/react";
1212
import { GameApp } from "../ui/react/GameApp";
1313
import { VirtualGrid } from "../grid/virtual-grid";
14-
import { USER_A } from "./helpers/fixtures";
14+
import { makeBot, makeCoin, USER_A } from "./helpers/fixtures";
15+
import {
16+
ANGLE_DIRS,
17+
COIN_COLLECT_TYPES,
18+
MOVEMENT_VALUES,
19+
MIN_COIN_ID,
20+
} from "../types";
1521

1622
function fakeLiveUpdates() {
1723
return {
@@ -23,8 +29,7 @@ function fakeLiveUpdates() {
2329
};
2430
}
2531

26-
function renderApp() {
27-
const grid = new VirtualGrid(16, 16);
32+
function renderApp(grid = new VirtualGrid(16, 16)) {
2833
render(
2934
<GameApp
3035
grid={grid} myUserId={USER_A} liveUpdates={fakeLiveUpdates() as never}
@@ -33,6 +38,17 @@ function renderApp() {
3338
);
3439
}
3540

41+
// A grid where the local bot has a predicted path (bot + target coin).
42+
function makePathGrid(): VirtualGrid {
43+
const grid = new VirtualGrid(16, 16);
44+
grid.add_bot(makeBot({
45+
userId: USER_A, real_bottom_left: [2, 3], angle: ANGLE_DIRS.RIGHT,
46+
movement_type: MOVEMENT_VALUES.MANHATTAN.value, targets: [COIN_COLLECT_TYPES.COIN],
47+
}));
48+
grid.add_coin(makeCoin({ id: MIN_COIN_ID, real_bottom_left: [8, 3] }));
49+
return grid;
50+
}
51+
3652
afterEach(() => {
3753
cleanup();
3854
document.body.innerHTML = "";
@@ -70,3 +86,30 @@ describe("GameApp layout", () => {
7086
expect(container().style.height).toBe("320px");
7187
});
7288
});
89+
90+
describe("bot path toggle", () => {
91+
it("renders a 'Show bot path' checkbox next to the gridlines one, on by default", () => {
92+
renderApp();
93+
const box = document.querySelector<HTMLInputElement>("#check_bot_paths")!;
94+
expect(box).not.toBeNull();
95+
expect(box.checked).toBe(true);
96+
// Same control row as the gridlines checkbox.
97+
expect(box.parentElement).toBe(
98+
document.querySelector("#check_gridlines")!.parentElement,
99+
);
100+
});
101+
102+
it("hides and re-shows the path overlay via the checkbox", () => {
103+
document.body.setAttribute("is-moving", "true");
104+
renderApp(makePathGrid());
105+
106+
expect(document.querySelector("#botPath")).not.toBeNull();
107+
108+
const box = document.querySelector<HTMLInputElement>("#check_bot_paths")!;
109+
fireEvent.click(box);
110+
expect(document.querySelector("#botPath")).toBeNull();
111+
112+
fireEvent.click(box);
113+
expect(document.querySelector("#botPath")).not.toBeNull();
114+
});
115+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// @vitest-environment jsdom
2+
//
3+
// The <BotBatteryPopup> cost layer: a floating movement-cost indicator that pops
4+
// over the local player's bot each movement tick to make the battery cost felt
5+
// in-place. Driven by the botBatteryCost bus event; local bot only (battery is
6+
// local/cosmetic and never synced). It draws the cost number (-2 / -1) alongside a
7+
// transparent battery-fill icon chosen from the bot's remaining charge (0..100).
8+
9+
import { describe, it, expect, afterEach, vi } from "vitest";
10+
import { render, cleanup, act } from "@testing-library/react";
11+
import { GameBoard } from "../ui/react/GameBoard";
12+
import {
13+
POPUP_DURATION_MS,
14+
batteryImageLevel,
15+
batteryImageFile,
16+
} from "../ui/react/BotBatteryPopup";
17+
import { gameEvents } from "../events";
18+
import { VirtualGrid } from "../grid/virtual-grid";
19+
import { makeBot, USER_A, USER_B } from "./helpers/fixtures";
20+
import { ANGLE_DIRS } from "../types";
21+
22+
const CELL = 40;
23+
24+
function makeBoardGrid(battery_pct = 100): VirtualGrid {
25+
const grid = new VirtualGrid(16, 16);
26+
grid.add_bot(makeBot({
27+
userId: USER_A, real_bottom_left: [2, 3], angle: ANGLE_DIRS.RIGHT,
28+
width: 1, height: 1, battery_pct,
29+
}));
30+
return grid;
31+
}
32+
33+
function renderBoard(grid: VirtualGrid): HTMLElement {
34+
render(
35+
<GameBoard
36+
grid={grid} myUserId={USER_A}
37+
cellSize={CELL} rows={grid.rows} cols={grid.cols} theme="None"
38+
/>,
39+
);
40+
return document.querySelector<HTMLElement>("#gridContainer")!;
41+
}
42+
43+
function emitCost(userId: string, amount: number): void {
44+
act(() => gameEvents.emit("botBatteryCost", { userId, amount }));
45+
}
46+
47+
function popups(container: HTMLElement): HTMLElement[] {
48+
return Array.from(
49+
container.querySelectorAll<HTMLElement>(".bot-battery-popup"),
50+
);
51+
}
52+
53+
function popupImg(container: HTMLElement): HTMLImageElement {
54+
return container.querySelector<HTMLImageElement>(".bot-battery-popup img")!;
55+
}
56+
57+
function popupAmount(container: HTMLElement): string {
58+
return container.querySelector<HTMLElement>(".bot-battery-popup-amount")!
59+
.textContent!;
60+
}
61+
62+
afterEach(() => {
63+
cleanup();
64+
document.body.innerHTML = "";
65+
document.body.removeAttribute("chosen-bot");
66+
});
67+
68+
// ── Pure helpers ──────────────────────────────────────────────────────────────
69+
70+
describe("batteryImageLevel (pure)", () => {
71+
it("rounds remaining charge to the nearest 25 and clamps 0–100", () => {
72+
expect(batteryImageLevel(100)).toBe(100);
73+
expect(batteryImageLevel(90)).toBe(100);
74+
expect(batteryImageLevel(87)).toBe(75);
75+
expect(batteryImageLevel(63)).toBe(75);
76+
expect(batteryImageLevel(62)).toBe(50);
77+
expect(batteryImageLevel(50)).toBe(50);
78+
expect(batteryImageLevel(12)).toBe(0);
79+
expect(batteryImageLevel(0)).toBe(0);
80+
});
81+
});
82+
83+
describe("batteryImageFile (pure)", () => {
84+
it("names the battery-icon sprite by rounded charge level", () => {
85+
expect(batteryImageFile(100)).toBe("100_battery.png");
86+
expect(batteryImageFile(50)).toBe("50_battery.png");
87+
expect(batteryImageFile(10)).toBe("0_battery.png");
88+
});
89+
});
90+
91+
// ── Component ─────────────────────────────────────────────────────────────────
92+
93+
describe("BotBatteryPopup", () => {
94+
it("draws the '-2' full-step label + battery icon over the local bot", () => {
95+
const container = renderBoard(makeBoardGrid(100));
96+
emitCost(USER_A, 2);
97+
98+
const shown = popups(container);
99+
expect(shown).toHaveLength(1);
100+
expect(shown[0].getAttribute("data-battery-popup")).toBe(USER_A);
101+
expect(popupAmount(container)).toBe("-2");
102+
expect(popupImg(container).getAttribute("src")).toContain("100_battery.png");
103+
});
104+
105+
it("draws the '-1' half-cost turn label", () => {
106+
const container = renderBoard(makeBoardGrid(100));
107+
emitCost(USER_A, 1);
108+
109+
expect(popups(container)).toHaveLength(1);
110+
expect(popupAmount(container)).toBe("-1");
111+
});
112+
113+
it("picks the battery icon from the bot's remaining charge", () => {
114+
const medium = renderBoard(makeBoardGrid(50));
115+
emitCost(USER_A, 2);
116+
expect(popupImg(medium).getAttribute("src")).toContain("50_battery.png");
117+
cleanup();
118+
document.body.innerHTML = "";
119+
120+
const low = renderBoard(makeBoardGrid(10));
121+
emitCost(USER_A, 2);
122+
expect(popupImg(low).getAttribute("src")).toContain("0_battery.png");
123+
});
124+
125+
it("ignores battery costs for any other bot (local bot only)", () => {
126+
const grid = makeBoardGrid();
127+
grid.add_bot(makeBot({ userId: USER_B, real_bottom_left: [8, 8], width: 1, height: 1 }));
128+
const container = renderBoard(grid);
129+
130+
emitCost(USER_B, 2);
131+
expect(popups(container)).toHaveLength(0);
132+
});
133+
134+
it("never intercepts pointer events (board drag/edit unaffected)", () => {
135+
const container = renderBoard(makeBoardGrid());
136+
emitCost(USER_A, 2);
137+
expect(popups(container)[0].style.pointerEvents).toBe("none");
138+
});
139+
140+
it("removes each popup once its float animation has run its course", () => {
141+
vi.useFakeTimers();
142+
try {
143+
const container = renderBoard(makeBoardGrid());
144+
emitCost(USER_A, 2);
145+
expect(popups(container)).toHaveLength(1);
146+
147+
act(() => vi.advanceTimersByTime(POPUP_DURATION_MS));
148+
expect(popups(container)).toHaveLength(0);
149+
} finally {
150+
vi.useRealTimers();
151+
}
152+
});
153+
});

src/__tests__/bot-movement.dom.test.ts

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ describe("single local loop", () => {
183183
expect(reward.claim_coin).toHaveBeenCalledWith(21);
184184
});
185185

186-
it("stops the loop when the battery is flat", () => {
186+
it("freezes the bot but keeps the play UI when the battery is flat", () => {
187+
// A dead battery must NOT bounce the player back to editing while the opponent
188+
// is still playing: the loop halts (bot frozen) but the `is-moving` play state
189+
// stays set, so the board/palette/edit-icons/start-stop button remain locked
190+
// until the phase actually ends.
187191
const grid = new VirtualGrid(10, 10);
188192
grid.add_bot(makeBot({
189193
userId: USER, real_bottom_left: [3, 3], angle: ANGLE_DIRS.RIGHT,
@@ -194,8 +198,95 @@ describe("single local loop", () => {
194198

195199
startMoving();
196200
expect(isMoving()).toBe(true);
197-
vi.advanceTimersByTime(500); // first tick sees battery<=0 and stops
198-
expect(isMoving()).toBe(false);
199-
expect(grid.bots[USER].real_bottom_left).toEqual([3, 3]); // never moved
201+
vi.advanceTimersByTime(500); // first tick sees battery<=0 and freezes
202+
203+
expect(grid.bots[USER].real_bottom_left).toEqual([3, 3]); // never moved
204+
expect(document.body.hasAttribute("is-moving")).toBe(true); // UI stays frozen
205+
expect(isMoving()).toBe(true); // still in play mode
206+
});
207+
208+
it("charges a full battery step for a straight move and announces the cost", () => {
209+
const grid = new VirtualGrid(10, 10);
210+
grid.add_bot(makeBot({
211+
userId: USER, real_bottom_left: [3, 3], angle: ANGLE_DIRS.RIGHT,
212+
movement_type: MOVEMENT_VALUES.MANHATTAN.value, targets: [COIN_COLLECT_TYPES.COIN],
213+
battery_pct: 100, width: 1, height: 1,
214+
}));
215+
grid.add_coin(makeCoin({ id: 21, real_bottom_left: [8, 3] })); // straight ahead → steps
216+
initBotMovement(grid, USER, noReward());
217+
const cap = captureEvent("botBatteryCost");
218+
219+
startLocalLoop();
220+
vi.advanceTimersByTime(500);
221+
222+
expect(grid.bots[USER].real_bottom_left).toEqual([4, 3]); // stepped
223+
expect(grid.bots[USER].battery_pct).toBe(98); // full step cost (−2)
224+
expect(cap.calls[cap.calls.length - 1]).toEqual({ userId: USER, amount: 2 });
225+
cap.stop();
226+
});
227+
228+
it("charges only half a battery step for a 90° turn and announces the reduced cost", () => {
229+
const grid = new VirtualGrid(10, 10);
230+
grid.add_bot(makeBot({
231+
userId: USER, real_bottom_left: [3, 3], angle: ANGLE_DIRS.UP,
232+
movement_type: MOVEMENT_VALUES.MANHATTAN.value, targets: [COIN_COLLECT_TYPES.COIN],
233+
battery_pct: 100, width: 1, height: 1,
234+
}));
235+
grid.add_coin(makeCoin({ id: 21, real_bottom_left: [8, 3] })); // to the right → turns to face it first
236+
initBotMovement(grid, USER, noReward());
237+
const cap = captureEvent("botBatteryCost");
238+
239+
startLocalLoop();
240+
vi.advanceTimersByTime(500);
241+
242+
expect(grid.bots[USER].real_bottom_left).toEqual([3, 3]); // rotated in place, no translation
243+
expect(grid.bots[USER].battery_pct).toBe(99); // half step cost (−1)
244+
expect(cap.calls[cap.calls.length - 1]).toEqual({ userId: USER, amount: 1 });
245+
cap.stop();
246+
});
247+
248+
it("announces a flat battery once and halts the loop (no re-emit while frozen)", () => {
249+
const grid = new VirtualGrid(10, 10);
250+
grid.add_bot(makeBot({
251+
userId: USER, real_bottom_left: [3, 3], angle: ANGLE_DIRS.RIGHT,
252+
movement_type: MOVEMENT_VALUES.MANHATTAN.value, battery_pct: 0,
253+
}));
254+
initBotMovement(grid, USER, noReward());
255+
const cap = captureEvent("localBatteryDepleted");
256+
257+
startMoving();
258+
vi.advanceTimersByTime(2000); // several ticks worth of time
259+
260+
// The loop is truly stopped: the death is announced exactly once, not once per
261+
// tick — and the play state is still frozen (not transitioned to editing).
262+
expect(cap.calls.length).toBe(1);
263+
expect(document.body.hasAttribute("is-moving")).toBe(true);
264+
cap.stop();
265+
});
266+
});
267+
268+
describe("same-coin race — a coin claimed out from under the local bot", () => {
269+
it("does not claim a coin removed before arrival and retargets to the next", () => {
270+
// The opponent collects the nearer coin (its removal syncs in) while the local
271+
// bot is still approaching. The bot must NOT phantom-claim the vanished coin;
272+
// because targets are re-derived each tick it retargets and claims the next.
273+
const grid = new VirtualGrid(10, 10);
274+
grid.add_bot(makeBot({
275+
userId: USER, real_bottom_left: [3, 3], angle: ANGLE_DIRS.RIGHT,
276+
movement_type: MOVEMENT_VALUES.MANHATTAN.value, targets: [COIN_COLLECT_TYPES.COIN],
277+
width: 1, height: 1,
278+
}));
279+
grid.add_coin(makeCoin({ id: 21, real_bottom_left: [5, 3] })); // nearer, ahead
280+
grid.add_coin(makeCoin({ id: 22, real_bottom_left: [8, 3] })); // farther, ahead
281+
const reward = noReward();
282+
initBotMovement(grid, USER, reward);
283+
284+
startLocalLoop();
285+
vi.advanceTimersByTime(500); // tick 1: bot steps to [4,3]
286+
grid.remove_coin(21); // opponent claims the near coin first
287+
vi.advanceTimersByTime(5000); // bot continues past [5,3] to [8,3]
288+
289+
expect(reward.claim_coin).toHaveBeenCalledWith(22);
290+
expect(reward.claim_coin).not.toHaveBeenCalledWith(21);
200291
});
201292
});

0 commit comments

Comments
 (0)