Files
OpenFrontIO/tests/UserSettings.test.ts
T
Evan 6ff202afb5 feat: nuke-explosion cosmetic effects (per-bomb-type shockwave customization) (#4485)
## Description:

Adds a new `nukeExplosion` cosmetic effect type: when a bomb detonates,
every client renders the shockwave in the firing player's equipped
effect for that bomb type.

**Cosmetics / selection**
- New `nukeExplosion` effect schema (`CosmeticSchemas.ts`) with per-bomb
selection slots — a slot is the effectType for trails and the `nukeType`
for explosions (`atom` / `hydro` / `mirvWarhead`), so players can equip
a distinct explosion per bomb type.
- Slot resolution + validation is one shared helper
(`findEffectForSlot`) used by client selection, server privilege checks
(`Privilege.ts`), and the renderer; a compile-time guard keeps the
nukeType and effectType slot namespaces disjoint.
- Effects picker gains an Atom / Hydrogen / MIRV sub-tab bar when
browsing nuke explosions; selections persist per slot in UserSettings
and are validated/dropped like other cosmetics.

**Rendering**
- `WebGLFrameBuilder` resolves each dead nuke's owner cosmetic onto the
dead-unit event; `FxShockwavePass` renders an EMP-style procedural ring
(jagged crackling front, rotating lightning arcs, inner energy fill)
from per-instance attributes. SAM interceptions and players with no
cosmetic keep the classic white ring.
- Catalog attributes have literal units:
- `size` — final ring width (diameter) in world tiles at fade-out,
absolute — independent of the bomb's blast radius
- `speed` — tiles/s the width grows; duration = size / speed, clamped to
0.1–15 s
- `thickness` (required) — ring band thickness in tiles, constant while
the ring expands
- `colors` — palette of up to 4 colors, cycled at `transitionSpeed`
steps/s (0 = static, negative = reverse; same semantics as trail
transitions)
- The shockwave quad is sized radius + thickness so the absolute-width
band isn't clipped into a box while the ring is young.

## Please complete the following:

- [ ] 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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:21:01 -07:00

61 lines
2.3 KiB
TypeScript

import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings";
describe("UserSettings effect selection", () => {
beforeEach(() => {
localStorage.clear();
// UserSettings keeps a static in-memory cache; reset it too so each test
// reads fresh from the (cleared) localStorage.
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("sets and reads a per-effectType selection", () => {
const s = new UserSettings();
s.setSelectedEffectName("transportShipTrail", "spectrum");
expect(s.getSelectedEffectName("transportShipTrail")).toBe("spectrum");
});
it("returns null when nothing is selected", () => {
expect(
new UserSettings().getSelectedEffectName("transportShipTrail"),
).toBeNull();
});
it("clearing the last selection removes the storage key", () => {
const s = new UserSettings();
s.setSelectedEffectName("transportShipTrail", "spectrum");
s.setSelectedEffectName("transportShipTrail", undefined);
expect(s.getSelectedEffectName("transportShipTrail")).toBeNull();
expect(localStorage.getItem(EFFECTS_KEY)).toBeNull();
});
it("clearing one effectType leaves other types intact", () => {
const s = new UserSettings();
// Seed two types directly (only one real effectType exists today).
localStorage.setItem(
EFFECTS_KEY,
JSON.stringify({ transportShipTrail: "spectrum", future: "x" }),
);
s.setSelectedEffectName("transportShipTrail", undefined);
expect(s.getSelectedEffects()).toEqual({ future: "x" });
});
it("returns an empty map for a corrupt blob", () => {
localStorage.setItem(EFFECTS_KEY, "not json");
expect(new UserSettings().getSelectedEffects()).toEqual({});
});
it("keeps per-nukeType nuke-explosion slots independent", () => {
const s = new UserSettings();
s.setSelectedEffectName("atom", "atom_boom");
s.setSelectedEffectName("hydro", "hydro_boom");
expect(s.getSelectedEffectName("atom")).toBe("atom_boom");
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
// Clearing one bomb's slot leaves the others intact.
s.setSelectedEffectName("atom", undefined);
expect(s.getSelectedEffectName("atom")).toBeNull();
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
});
});