Files
OpenFrontIO/tests/client/render/frame/TrailManager.test.ts
T
2794ab1270 feat: nuke-trail cosmetic effect + tabbed effects picker (#4466)
## What

Adds a **`nukeTrail`** cosmetic effectType alongside
`transportShipTrail`, so nukes leave a trail colored by their own
gradient/transition effect — independent of the boat-trail effect (a
player can run both). Also reorganizes the effects picker and store into
per-effectType **tabs**.

## Rendering

Boat and nuke trails are stamped into **one** trail texture keyed only
by owner, so independent coloring needs a per-tile unit-class signal:

- **Trail texture** `R8UI` → `R16UI`: texel = `ownerID(bits 0-11) |
nukeBit(bit 12)`. `TrailManager` stamps the bit (and preserves it when
repainting on unit death); the `Uint8Array`→`Uint16Array` ripple +
`UNSIGNED_SHORT` uploads flow through `GpuResources`, `TrailPass`,
`Upload`, `MapRenderer`, `Renderer`, `FrameData`.
- **Effect texture** widened to two stacked blocks
(`TRAIL_EFFECT_BLOCKS`): rows 0–7 = transportShipTrail, rows 8–15 =
nukeTrail. `writeEffectEntry(…, rowBase)`; `syncPlayerEffects` resolves
both effectTypes.
- **Shader** masks the owner, derives `rowBase` from the nuke bit,
offsets every row, and reuses the gradient/transition decode.
- Bonus: the 12-bit owner mask lifts the old `R8UI` >255-player
truncation.

## Schema / server / UI

- Shared attributes schema renamed `TransportShipTrail…` →
**`TrailEffectAttributesSchema`** (it's no longer ship-specific);
`NukeTrailEffectSchema` added to `EffectSchema` +
`CosmeticsSchema.effects`. `EFFECT_TYPES = [transportShipTrail,
nukeTrail]`.
- Server `Privilege`, selection, and the picker grid all iterate
`EFFECT_TYPES`, so they handle the new type with **no per-type code**.
- **Tabs:** the selection modal uses one tab per effectType
(`BaseModal`'s native tabs); the **store's** EFFECTS panel gets an
internal sub-tab bar (its top-level PACKS/EFFECTS tabs can't nest). Tabs
are always present, so a type you own entirely still appears as an empty
tab (previously the boat-trail section vanished from the store when you
owned everything).

## Review

A 3-angle adversarial review (bit-packing, type-ripple, GLSL/data-flow)
**refuted** the correctness concerns — the R16UI format, masking, and
block layout agree across `TrailManager` / shader / builder. The minor
survivors (a preview that only resolved boat trails, stale comments)
were fixed.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` — all clean.
- Schema/`Privilege` tests updated for `nukeTrail` (96 tests pass).
- The GL trail + tab UI are visual — not yet verified in a running game.
- The catalog (`cosmetics.json`, closed-source API) must ship the
`effects.nukeTrail` block for the effect to appear in production.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:13:41 -07:00

114 lines
3.3 KiB
TypeScript

/**
* TrailManager stamps a unit's path into the per-tile "last owner" texture.
*
* Smoothed nukes are interpolated lastPos→pos per render frame (UnitPass), so
* their trail must stamp only up to `lastPos` — otherwise the tail would lead
* the smoothly-moving missile sprite. Every other unit stamps up to `pos`.
*/
import { describe, expect, it } from "vitest";
import {
NUKE_TRAIL_BIT,
TrailManager,
} from "../../../../src/client/render/frame/TrailManager";
import type { UnitState } from "../../../../src/client/render/types";
import {
UT_ATOM_BOMB,
UT_TRADE_SHIP,
} from "../../../../src/client/render/types";
const MAP_W = 50;
const MAP_H = 50;
function unit(overrides: Partial<UnitState> = {}): UnitState {
return {
id: 1,
unitType: UT_ATOM_BOMB,
ownerID: 7,
lastOwnerID: null,
pos: 0,
lastPos: 0,
isActive: true,
reachedTarget: false,
retreating: false,
targetable: true,
markedForDeletion: false,
health: null,
underConstruction: false,
targetUnitId: null,
targetTile: null,
troops: 0,
missileTimerQueue: [],
level: 1,
hasTrainStation: false,
trainType: null,
loaded: null,
constructionStartTick: null,
...overrides,
};
}
function units(...us: UnitState[]): Map<number, UnitState> {
return new Map(us.map((u) => [u.id, u]));
}
const ref = (x: number, y: number) => y * MAP_W + x;
describe("TrailManager", () => {
it("stamps a smoothed nuke's trail only up to lastPos, not pos", () => {
const tm = new TrailManager(MAP_W, MAP_H);
const trail = tm.getTrailState();
// A nuke's texel carries the owner smallID plus the nuke bit (bit 12).
const nukeTexel = 7 | NUKE_TRAIL_BIT;
// First sighting: lastPos === pos at spawn.
tm.update(units(unit({ pos: ref(2, 2), lastPos: ref(2, 2) })), [1]);
expect(trail[ref(2, 2)]).toBe(nukeTexel);
// Move: lastPos trails pos by a tile. The trail head must reach lastPos
// (3,2) but NOT the current pos (4,2) — the smoothed sprite occupies the
// lastPos→pos span this frame.
tm.update(units(unit({ pos: ref(4, 2), lastPos: ref(3, 2) })), [1]);
expect(trail[ref(3, 2)]).toBe(nukeTexel);
expect(trail[ref(4, 2)]).toBe(0);
});
it("stamps a non-smoothed unit's trail up to pos", () => {
const tm = new TrailManager(MAP_W, MAP_H);
const trail = tm.getTrailState();
tm.update(
units(
unit({ unitType: UT_TRADE_SHIP, pos: ref(2, 2), lastPos: ref(2, 2) }),
),
[1],
);
tm.update(
units(
unit({ unitType: UT_TRADE_SHIP, pos: ref(4, 2), lastPos: ref(3, 2) }),
),
[1],
);
// Trade ships are not interpolated, so the trail reaches the current pos.
expect(trail[ref(4, 2)]).toBe(7);
});
it("clears a unit's trail when it disappears", () => {
const tm = new TrailManager(MAP_W, MAP_H);
const trail = tm.getTrailState();
const nukeTexel = 7 | NUKE_TRAIL_BIT;
tm.update(units(unit({ pos: ref(5, 5), lastPos: ref(5, 5) })), [1]);
tm.update(units(unit({ pos: ref(7, 5), lastPos: ref(6, 5) })), [1]);
expect(trail[ref(5, 5)]).toBe(nukeTexel);
expect(trail[ref(6, 5)]).toBe(nukeTexel);
// Unit gone from the map → its tiles are cleared.
tm.update(new Map(), []);
expect(trail[ref(5, 5)]).toBe(0);
expect(trail[ref(6, 5)]).toBe(0);
});
});