Files
OpenFrontIO/tests/nukes/NukeMotionPlan.test.ts
T
Evan f4db4a33c8 Send nukes as motion plans and render them smoothly per frame (#4255)
## Summary

Follow-up to #4244's payload work: nukes were the last per-tick movers
flooding the worker → main update stream.

- **Core**: nuke trajectories are fully determined at launch
(precomputed parabola), so `NukeExecution` now records a `GridPathPlan`
when the nuke is built — same mechanism trade ships use — and the client
derives the position each tick. Per-tick `UnitUpdate`s for nukes in
flight are suppressed; only targetable flips and deletion
(interception/detonation) still emit. This covers atom bombs, hydrogen
bombs, and MIRV warheads (dozens of per-tick movers per MIRV
separation).
- The plan path replays a separate pathfinder rather than reusing the
stored trajectory array: the curve's cached points don't advance exactly
one index per tick, and the plan must match the movement pathfinder's
exact per-tick tile sequence.
  - `startTick` accounts for MIRV warheads' staggered `waitTicks`.
- **Render**: `UnitPass.drawMissiles` now lerps each nuke's instance
position `lastPos→pos` by wall-clock progress through the current tick,
so nukes glide along their arc at render framerate instead of jumping
once per 100ms tick. Both endpoints are real simulated positions — the
rendered nuke trails the sim by at most one tick and settles exactly on
it when ticks stop. Plan-driven units sync `lastPos` on path-stall ticks
so the lerp never replays a segment. Shells keep their existing
two-instance trail; SAM missiles are unchanged.

## Test plan

- New `tests/nukes/NukeMotionPlan.test.ts`: tick-exact alignment between
the recorded plan and core nuke position over the whole flight
(mirroring `GameView.advanceMotionPlannedUnits` math), `waitTicks`
offset, and that no per-tick unit updates are emitted in flight except
targetable flips and deletion.
- Full suite passes (1452 + 65), tsc/eslint/prettier clean.
- Verified in-game (headless Chromium, real WebGL): atom bomb arcs from
silo to target with the client position driven by the plan, missile
sprite renders intact while the smoothing rewrites the instance buffer
every frame, detonation FX land at the target.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:59:03 -07:00

149 lines
4.6 KiB
TypeScript

import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
import { NukeExecution } from "../../src/core/execution/NukeExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
Unit,
UnitType,
} from "../../src/core/game/Game";
import { TileRef } from "../../src/core/game/GameMap";
import { GameUpdateType, UnitUpdate } from "../../src/core/game/GameUpdates";
import {
GridPathPlan,
unpackMotionPlans,
} from "../../src/core/game/MotionPlans";
import { setup } from "../util/Setup";
describe("Nuke motion plan", () => {
let game: Game;
let player: Player;
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
beforeEach(async () => {
game = await setup("plains", { infiniteGold: true, instantBuild: true }, [
info,
]);
player = game.player(info.id);
player.conquer(game.ref(1, 1));
game.addExecution(
new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)),
);
game.executeNextTick();
game.executeNextTick();
expect(player.units(UnitType.MissileSilo)).toHaveLength(1);
game.drainPackedMotionPlans();
});
function buildNuke(): Unit {
for (
let i = 0;
i < 10 && player.units(UnitType.AtomBomb).length === 0;
i++
) {
game.executeNextTick();
}
const nukes = player.units(UnitType.AtomBomb);
expect(nukes).toHaveLength(1);
return nukes[0];
}
function drainGridPlan(unitId: number): GridPathPlan {
const packed = game.drainPackedMotionPlans();
expect(packed).not.toBeNull();
const plan = unpackMotionPlans(packed!).find(
(r): r is GridPathPlan => r.kind === "grid" && r.unitId === unitId,
);
expect(plan).toBeDefined();
return plan!;
}
// game.ticks() after executeNextTick() matches the tick the client receives
// for that update batch (GameRunner reads ticks() post-increment), so this
// mirrors GameView.advanceMotionPlannedUnits exactly.
function expectedTile(plan: GridPathPlan, tick: number): TileRef {
const dt = tick - plan.startTick;
const stepIndex =
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
const idx = Math.max(0, Math.min(plan.path.length - 1, stepIndex));
return plan.path[idx] as TileRef;
}
test("records a grid plan matching the nuke's per-tick positions", () => {
game.addExecution(
new ConstructionExecution(player, UnitType.AtomBomb, game.ref(80, 80)),
);
const nuke = buildNuke();
const plan = drainGridPlan(nuke.id());
expect(plan.ticksPerStep).toBe(1);
expect(plan.startTick).toBe(game.ticks());
expect(plan.path[0]).toBe(nuke.tile());
expect(plan.path.length).toBeGreaterThan(2);
let moveTicks = 0;
for (let i = 0; i < 10_000 && nuke.isActive(); i++) {
const lastTile = nuke.tile();
game.executeNextTick();
if (!nuke.isActive()) break;
expect(nuke.tile()).toBe(expectedTile(plan, game.ticks()));
if (nuke.tile() !== lastTile) moveTicks++;
}
expect(nuke.isActive()).toBe(false);
expect(moveTicks).toBeGreaterThan(2);
});
test("plan start accounts for waitTicks", () => {
const waitTicks = 5;
game.addExecution(
new NukeExecution(
UnitType.AtomBomb,
player,
game.ref(80, 80),
null,
-1,
waitTicks,
),
);
const nuke = buildNuke();
const spawn = nuke.tile();
const plan = drainGridPlan(nuke.id());
expect(plan.startTick).toBe(game.ticks() + waitTicks);
for (let i = 0; i < 10_000 && nuke.isActive(); i++) {
game.executeNextTick();
if (!nuke.isActive()) break;
if (i < waitTicks) {
expect(nuke.tile()).toBe(spawn);
}
expect(nuke.tile()).toBe(expectedTile(plan, game.ticks()));
}
expect(nuke.isActive()).toBe(false);
});
test("does not emit per-tick unit updates while in flight", () => {
game.addExecution(
new ConstructionExecution(player, UnitType.AtomBomb, game.ref(80, 80)),
);
const nuke = buildNuke();
let lastTargetable = nuke.isTargetable();
for (let i = 0; i < 10_000 && nuke.isActive(); i++) {
const updates = game.executeNextTick();
const nukeUpdates = (updates[GameUpdateType.Unit] as UnitUpdate[]).filter(
(u) => u.id === nuke.id(),
);
for (const u of nukeUpdates) {
// Moves are plan-driven; only deletion and targetable flips may emit.
expect(u.isActive === false || u.targetable !== lastTargetable).toBe(
true,
);
lastTargetable = u.targetable;
}
}
expect(nuke.isActive()).toBe(false);
});
});