Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
+3
-3
@@ -57,12 +57,12 @@ export default function App() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshManastormConfig();
|
||||
}, [refreshManastormConfig, sessionOwnerId]);
|
||||
void refreshManastormConfig(session);
|
||||
}, [refreshManastormConfig, session]);
|
||||
|
||||
let screen = null;
|
||||
if (phase === "login") screen = <LoginScreen />;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MapPanel } from "./ui/MapPanel";
|
||||
import { PauseMenu } from "./ui/PauseMenu";
|
||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||
import { GmReviewCard } from "./ui/GmReviewCard";
|
||||
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||
|
||||
function InputBridge() {
|
||||
@@ -94,6 +95,7 @@ export default function GameRuntime() {
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
<GmReviewCard />
|
||||
<GmWorldEditor />
|
||||
</main>
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates v1/v2 databases and stores combat presentation settings by owner", async () => {
|
||||
it("migrates legacy databases and stores combat presentation settings by owner", async () => {
|
||||
for (const version of [1, 2]) {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
@@ -152,11 +152,33 @@ describe("local profile repository", () => {
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
legacyV3Storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 3,
|
||||
accounts: [],
|
||||
rosters: { "offline-roster": [] },
|
||||
presentationSettingsByOwner: {
|
||||
"offline-roster": {
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", legacyV3Storage)).toMatchObject({
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "low",
|
||||
});
|
||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
|
||||
@@ -209,14 +209,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
? parsed.presentationSettingsByOwner as Record<string, unknown>
|
||||
: {};
|
||||
let presentationSettingsMigrated = false;
|
||||
const presentationSettingsByOwner = Object.fromEntries(
|
||||
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [
|
||||
ownerId,
|
||||
normalizeCombatPresentationSettings(settings),
|
||||
]),
|
||||
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => {
|
||||
const normalized = normalizeCombatPresentationSettings(settings);
|
||||
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||
? (settings as { vfxMode?: unknown }).vfxMode
|
||||
: undefined;
|
||||
if (storedMode !== normalized.vfxMode) presentationSettingsMigrated = true;
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3) writeDatabase(database, storage);
|
||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import { enterRandomGmReview } from "./gmManastormRuntime";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const character = {
|
||||
id: "gm-character",
|
||||
ownerId: "gm-account",
|
||||
name: "Verifier",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 20,
|
||||
experience: 0,
|
||||
talentRanks: {},
|
||||
actionBindings: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
location: "GM Tools",
|
||||
createdAt: 1,
|
||||
lastPlayedAt: 1,
|
||||
};
|
||||
|
||||
function prepareGm(config: ManastormAdminConfig): void {
|
||||
useShellStore.setState({
|
||||
phase: "gm-admin",
|
||||
session: {
|
||||
kind: "account",
|
||||
ownerId: "gm-account",
|
||||
displayName: "Game Master",
|
||||
accessToken: "gm-token",
|
||||
roles: ["gm"],
|
||||
},
|
||||
characters: [character],
|
||||
selectedCharacterId: character.id,
|
||||
activeCharacter: null,
|
||||
notice: "",
|
||||
});
|
||||
useManastormAdminStore.setState({ config, draft: config, dirty: false, saving: false, error: null });
|
||||
}
|
||||
|
||||
describe("GM random review runtime", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
useManastormStore.getState().resetRun();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
});
|
||||
|
||||
it("launches the exact remaining dungeon in GM test mode", () => {
|
||||
const available = DUNGEON_CATALOG.filter((dungeon) => dungeon.available);
|
||||
const selected = available[0];
|
||||
const config: ManastormAdminConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
dungeons: Object.fromEntries(available.map((dungeon) => [
|
||||
dungeon.id,
|
||||
{ status: dungeon.id === selected.id ? "needs-tested" : "ready" },
|
||||
])),
|
||||
};
|
||||
prepareGm(config);
|
||||
|
||||
expect(enterRandomGmReview("dungeon")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({ phase: "game", activeDungeonId: selected.id });
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "dungeon", dungeonId: selected.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a runnable Not ready Manastorm in an isolated GM review", () => {
|
||||
const catalog = resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
const selected = catalog.find((entry) => entry.runnable && entry.source.includes("installed-manastorm"));
|
||||
expect(selected).toBeTruthy();
|
||||
const config: ManastormAdminConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: Object.fromEntries(catalog.map((entry) => [
|
||||
entry.key,
|
||||
{ status: entry.key === selected!.key ? "not-ready" : "ready" },
|
||||
])),
|
||||
};
|
||||
prepareGm(config);
|
||||
|
||||
expect(enterRandomGmReview("manastorm")).toBe(true);
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
gameMode: "manastorm",
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "manastorm", bossKey: selected!.key },
|
||||
});
|
||||
expect(useManastormStore.getState().currentEncounter?.bossName).toBe(selected!.name);
|
||||
});
|
||||
|
||||
it("rejects non-GM sessions and clears review context when leaving", () => {
|
||||
prepareGm(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
useShellStore.setState({
|
||||
session: { kind: "offline", ownerId: "gm-account", displayName: "Offline", roles: ["gm"] },
|
||||
});
|
||||
expect(enterRandomGmReview("dungeon")).toBe(false);
|
||||
expect(useGameStore.getState().activeGmReviewTarget).toBeNull();
|
||||
|
||||
prepareGm(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
useGameStore.setState({
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "dungeon", dungeonId: DUNGEON_CATALOG[0].id },
|
||||
});
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
expect(useGameStore.getState()).toMatchObject({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
});
|
||||
});
|
||||
@@ -5,34 +5,87 @@ import {
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import {
|
||||
drawRandomGmReviewTarget,
|
||||
type GmReviewKind,
|
||||
type GmReviewTarget,
|
||||
} from "../game/gmContentReview";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
import { activateManastormStage } from "../game/manastormStageLoader";
|
||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { createEmptyManastormProgress, manastormModeProgress } from "../game/manastormProgress";
|
||||
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import type { CharacterProfile } from "./types";
|
||||
|
||||
export function enterGmManastormTest(bossKey: string): boolean {
|
||||
function activeGmCharacter(): CharacterProfile | null {
|
||||
const shell = useShellStore.getState();
|
||||
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const activeCharacter = touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((character) => character.id === shell.selectedCharacterId)
|
||||
?? null;
|
||||
if (!activeCharacter) {
|
||||
useShellStore.setState({ notice: "Select a character before testing a boss." });
|
||||
useShellStore.setState({ notice: "Select a character before testing content." });
|
||||
return null;
|
||||
}
|
||||
return activeCharacter;
|
||||
}
|
||||
|
||||
function enterGmDungeonTest(target: Extract<GmReviewTarget, { readonly kind: "dungeon" }>): boolean {
|
||||
const activeCharacter = activeGmCharacter();
|
||||
if (!activeCharacter) return false;
|
||||
useManastormStore.getState().resetRun();
|
||||
releaseActiveManastormStage();
|
||||
if (!activateDungeonSession(target.dungeonId, undefined, {
|
||||
gmTestMode: true,
|
||||
gmReviewTarget: target,
|
||||
})) {
|
||||
useShellStore.setState({ notice: "That dungeon did not pass its offline runtime gate." });
|
||||
return false;
|
||||
}
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter,
|
||||
activeDungeonId: target.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||
notice: "GM dungeon review",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enterRandomGmReview(kind: GmReviewKind): boolean {
|
||||
const admin = useManastormAdminStore.getState();
|
||||
if (admin.dirty || admin.saving) {
|
||||
useShellStore.setState({ notice: "Save or discard GM changes before testing another target." });
|
||||
return false;
|
||||
}
|
||||
const previous = useGameStore.getState().activeGmReviewTarget;
|
||||
const target = drawRandomGmReviewTarget(kind, admin.config, previous);
|
||||
if (!target) {
|
||||
const label = kind === "dungeon" ? "dungeons" : kind === "boss" ? "bosses" : "Manastorm encounters";
|
||||
useShellStore.setState({ notice: `All runnable ${label} have been marked Ready.` });
|
||||
return false;
|
||||
}
|
||||
if (target.kind === "dungeon") return enterGmDungeonTest(target);
|
||||
return enterGmManastormTest(target.bossKey, target);
|
||||
}
|
||||
|
||||
export function enterGmManastormTest(bossKey: string, reviewTarget: GmReviewTarget | null = null): boolean {
|
||||
const activeCharacter = activeGmCharacter();
|
||||
if (!activeCharacter) return false;
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
const entry = resolvedBossAdminCatalog(adminConfig).find((candidate) => candidate.key === bossKey);
|
||||
if (!entry || entry.status === "not-ready" || !entry.runnable || !entry.stage) {
|
||||
if (!entry || !entry.runnable || !entry.stage) {
|
||||
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||
return false;
|
||||
}
|
||||
@@ -84,7 +137,7 @@ export function enterGmManastormTest(bossKey: string): boolean {
|
||||
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||
if (!activateManastormStage(encounter, undefined, false, true, reviewTarget).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||
return false;
|
||||
|
||||
@@ -166,6 +166,7 @@ function abandonRun(): void {
|
||||
const state = useManastormStore.getState();
|
||||
const completed = state.completed;
|
||||
state.resetRun();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useShellStore.setState({
|
||||
phase: "manastorms",
|
||||
activeCharacter: null,
|
||||
@@ -179,6 +180,7 @@ function failRun(): void {
|
||||
const state = useManastormStore.getState();
|
||||
if (state.status !== "failed" && !state.failRun()) return;
|
||||
useGameStore.getState().setPaused(true);
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useShellStore.setState({
|
||||
phase: "manastorms",
|
||||
activeCharacter: null,
|
||||
@@ -190,6 +192,7 @@ function leaveActiveGame(): void {
|
||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
||||
useManastormStore.getState().resetRun();
|
||||
releaseActiveManastormStage();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
}
|
||||
|
||||
function saveSelectedLoadout(loadout: readonly string[]): boolean {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -164,6 +165,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
signOut: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
const session = get().session;
|
||||
void logoutOnlineSession(session);
|
||||
clearSession();
|
||||
@@ -352,6 +354,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
@@ -381,6 +384,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
failActiveManastorm: () => manastormShellRuntime()?.failRun(),
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
const session = get().session;
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
combatPresentationSessionId,
|
||||
emitCombatPresentation,
|
||||
normalizeCombatPresentationSettings,
|
||||
originalCombatPhaseAssets,
|
||||
presentationDeliveryForAbility,
|
||||
presentationSchoolForAbility,
|
||||
resetCombatPresentationSession,
|
||||
@@ -22,11 +23,20 @@ describe("combat presentation", () => {
|
||||
audioEnabled: { enemy: false },
|
||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||
})).toEqual({
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false, party: true, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
audioVolume: { player: 1, party: 0, enemy: 0.35 },
|
||||
});
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "hybrid",
|
||||
}).vfxMode).toBe("generated");
|
||||
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
});
|
||||
|
||||
@@ -84,4 +94,37 @@ describe("combat presentation", () => {
|
||||
expect(style.authentic).toBe(false);
|
||||
expect(style.sourceModelNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes only packaged original phase graphs", () => {
|
||||
const entry = {
|
||||
key: "wow335:2061",
|
||||
source: "wow335" as const,
|
||||
spellId: 2061,
|
||||
phases: {
|
||||
impact: [
|
||||
{ model: "Spells/FlashHeal_Base.mdx" },
|
||||
{
|
||||
model: "Spells/FlashHeal_Base.mdx",
|
||||
effectKind: "wow-m2-particles" as const,
|
||||
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/flash-heal.json",
|
||||
},
|
||||
{
|
||||
effectKind: "wow-m2-particles" as const,
|
||||
effectGraphUrl: "https://example.invalid/not-a-packaged-effect.json",
|
||||
},
|
||||
{
|
||||
model: "Spells/HolyDivineShield_State_Base.mdx",
|
||||
effectKind: "wow-m2-static-mesh" as const,
|
||||
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/shield.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(originalCombatPhaseAssets(entry, "impact")).toEqual([
|
||||
entry.phases.impact[1],
|
||||
entry.phases.impact[3],
|
||||
]);
|
||||
expect(originalCombatPhaseAssets(entry, "release")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export type CombatActorGroup = "player" | "party" | "enemy";
|
||||
export type CombatVfxMode = "generated" | "original";
|
||||
export type CombatVfxQuality = "off" | "low" | "high";
|
||||
export type CombatPresentationPhase =
|
||||
| "cast-start"
|
||||
@@ -23,6 +24,7 @@ export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "f
|
||||
export type CombatPresentationPosition = readonly [number, number, number];
|
||||
|
||||
export interface CombatPresentationSettings {
|
||||
readonly vfxMode: CombatVfxMode;
|
||||
readonly vfxQuality: CombatVfxQuality;
|
||||
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
readonly audioEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
||||
@@ -32,6 +34,7 @@ export interface CombatPresentationSettings {
|
||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
|
||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "high",
|
||||
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
@@ -77,10 +80,14 @@ export function normalizeCombatPresentationSettings(
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
||||
: {};
|
||||
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||
? record.vfxMode
|
||||
: fallback.vfxMode;
|
||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||
? record.vfxQuality
|
||||
: fallback.vfxQuality;
|
||||
return Object.freeze({
|
||||
vfxMode,
|
||||
vfxQuality,
|
||||
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
||||
audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled),
|
||||
@@ -155,6 +162,10 @@ export interface CombatEffectPhaseAsset {
|
||||
readonly model?: string;
|
||||
readonly attachment?: string;
|
||||
readonly scale?: number;
|
||||
/** Browser-ready graph generated from the original client asset. */
|
||||
readonly effectGraphUrl?: string;
|
||||
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "runewaker-particles";
|
||||
readonly originalSourceSha256?: string;
|
||||
}
|
||||
|
||||
export interface CombatEffectSoundAsset {
|
||||
@@ -215,6 +226,30 @@ export function combatEffectManifestEntry(
|
||||
return manifestBySpellId.get(spellId) ?? null;
|
||||
}
|
||||
|
||||
export function originalCombatPhaseAssets(
|
||||
entry: CombatEffectManifestEntry | null | undefined,
|
||||
phase: CombatPresentationPhase,
|
||||
): readonly CombatEffectPhaseAsset[] {
|
||||
return (entry?.phases?.[phase] ?? []).filter((asset) => (
|
||||
typeof asset.effectGraphUrl === "string"
|
||||
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
|
||||
&& (
|
||||
asset.effectKind === "wow-m2-particles"
|
||||
|| asset.effectKind === "wow-m2-static-mesh"
|
||||
|| asset.effectKind === "runewaker-particles"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
export function combatOriginalPhaseAssets(
|
||||
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId">,
|
||||
): readonly CombatEffectPhaseAsset[] {
|
||||
return originalCombatPhaseAssets(
|
||||
combatEffectManifestEntry(event.sourceSpellId, event.source),
|
||||
event.phase,
|
||||
);
|
||||
}
|
||||
|
||||
function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean {
|
||||
return ability.effects.some((effect) => effect.kind === kind);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { useGameStore, type GameMode } from "./store";
|
||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export function activateDungeonSession(
|
||||
gameMode: options.gameMode,
|
||||
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||
gmTestMode: options.gmTestMode,
|
||||
gmReviewTarget: options.gmReviewTarget,
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -91,6 +94,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
gmReviewTarget: game.activeGmReviewTarget,
|
||||
preserveCharacter: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DUNGEON_CATALOG } from "./dungeonCatalog";
|
||||
import {
|
||||
configWithGmReviewNote,
|
||||
configWithGmReviewStatus,
|
||||
describeGmReviewTarget,
|
||||
drawRandomGmReviewTarget,
|
||||
gmReviewQueue,
|
||||
type GmReviewTarget,
|
||||
} from "./gmContentReview";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
resolvedBossAdminCatalog,
|
||||
type GmReadinessStatus,
|
||||
type ManastormAdminConfig,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
function configWithBossStatuses(statuses: Readonly<Record<string, GmReadinessStatus>>): ManastormAdminConfig {
|
||||
return {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: Object.fromEntries(Object.entries(statuses).map(([key, status]) => [key, { status }])),
|
||||
};
|
||||
}
|
||||
|
||||
describe("random GM content review", () => {
|
||||
it("draws Needs tested dungeons before Not ready and never draws Ready", () => {
|
||||
const available = DUNGEON_CATALOG.filter((dungeon) => dungeon.available);
|
||||
expect(available.length).toBeGreaterThan(2);
|
||||
const config: ManastormAdminConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
dungeons: Object.fromEntries(available.map((dungeon, index) => [
|
||||
dungeon.id,
|
||||
{ status: index === 0 ? "needs-tested" : index === 1 ? "not-ready" : "ready" },
|
||||
])),
|
||||
};
|
||||
|
||||
expect(drawRandomGmReviewTarget("dungeon", config, null, () => 0.99)).toEqual({
|
||||
kind: "dungeon",
|
||||
dungeonId: available[0].id,
|
||||
});
|
||||
|
||||
const withoutNeedsTested = configWithGmReviewStatus(
|
||||
config,
|
||||
{ kind: "dungeon", dungeonId: available[0].id },
|
||||
"ready",
|
||||
)!;
|
||||
expect(drawRandomGmReviewTarget("dungeon", withoutNeedsTested, null, () => 0)).toEqual({
|
||||
kind: "dungeon",
|
||||
dungeonId: available[1].id,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses separate dungeon-boss and imported-Manastorm source queues", () => {
|
||||
const catalog = resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
const dungeonBosses = catalog.filter((entry) => entry.runnable && entry.source.includes("dungeon"));
|
||||
const manastorms = catalog.filter((entry) => entry.runnable && entry.source.includes("installed-manastorm"));
|
||||
expect(dungeonBosses.length).toBeGreaterThan(0);
|
||||
expect(manastorms.length).toBeGreaterThan(0);
|
||||
|
||||
const bossQueue = gmReviewQueue("boss", DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
const manastormQueue = gmReviewQueue("manastorm", DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
expect(bossQueue.every((target) => target.kind === "boss"
|
||||
&& dungeonBosses.some((entry) => entry.key === target.bossKey))).toBe(true);
|
||||
expect(manastormQueue.every((target) => target.kind === "manastorm"
|
||||
&& manastorms.some((entry) => entry.key === target.bossKey))).toBe(true);
|
||||
|
||||
const overlap = catalog.find((entry) => entry.runnable
|
||||
&& entry.source.includes("dungeon")
|
||||
&& entry.source.includes("installed-manastorm"));
|
||||
expect(overlap).toBeTruthy();
|
||||
expect(bossQueue).toContainEqual({ kind: "boss", bossKey: overlap!.key });
|
||||
expect(manastormQueue).toContainEqual({ kind: "manastorm", bossKey: overlap!.key });
|
||||
});
|
||||
|
||||
it("avoids an immediate repeat when another prioritized target exists", () => {
|
||||
const targets = gmReviewQueue("manastorm", DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
expect(targets.length).toBeGreaterThan(1);
|
||||
const previous = targets[0] as GmReviewTarget;
|
||||
const next = drawRandomGmReviewTarget("manastorm", DEFAULT_MANASTORM_ADMIN_CONFIG, previous, () => 0);
|
||||
expect(next).not.toEqual(previous);
|
||||
});
|
||||
|
||||
it("returns no target when every runnable record is Ready", () => {
|
||||
const catalog = resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
const config = configWithBossStatuses(Object.fromEntries(catalog.map((entry) => [entry.key, "ready"])));
|
||||
expect(drawRandomGmReviewTarget("boss", config, null, () => 0)).toBeNull();
|
||||
expect(drawRandomGmReviewTarget("manastorm", config, null, () => 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the shared boss record and resolves exact review details", () => {
|
||||
const target = gmReviewQueue("manastorm", DEFAULT_MANASTORM_ADMIN_CONFIG)[0];
|
||||
expect(target?.kind).toBe("manastorm");
|
||||
const updated = configWithGmReviewStatus(DEFAULT_MANASTORM_ADMIN_CONFIG, target!, "not-ready");
|
||||
expect(updated).toBeTruthy();
|
||||
const descriptor = describeGmReviewTarget(target!, updated!);
|
||||
expect(descriptor).toMatchObject({ status: "not-ready", runnable: true });
|
||||
if (target?.kind !== "dungeon") expect(updated!.bosses[target!.bossKey]?.status).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("keeps one shared review note across overlapping Boss and Manastorm targets", () => {
|
||||
const overlap = resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG).find((entry) => (
|
||||
entry.runnable
|
||||
&& entry.source.includes("dungeon")
|
||||
&& entry.source.includes("installed-manastorm")
|
||||
));
|
||||
expect(overlap).toBeTruthy();
|
||||
const bossTarget = { kind: "boss", bossKey: overlap!.key } as const;
|
||||
const manastormTarget = { kind: "manastorm", bossKey: overlap!.key } as const;
|
||||
const noted = configWithGmReviewNote(
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bossTarget,
|
||||
"Portal clips the west wall.",
|
||||
)!;
|
||||
const changedStatus = configWithGmReviewStatus(noted, manastormTarget, "not-ready")!;
|
||||
|
||||
expect(describeGmReviewTarget(manastormTarget, changedStatus)).toMatchObject({
|
||||
status: "not-ready",
|
||||
note: "Portal clips the west wall.",
|
||||
});
|
||||
expect(changedStatus.bosses[overlap!.key]).toMatchObject({
|
||||
status: "not-ready",
|
||||
note: "Portal clips the west wall.",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds and clears advisory dungeon notes without changing availability", () => {
|
||||
const dungeon = DUNGEON_CATALOG.find((entry) => entry.available)!;
|
||||
const target = { kind: "dungeon", dungeonId: dungeon.id } as const;
|
||||
const noted = configWithGmReviewNote(DEFAULT_MANASTORM_ADMIN_CONFIG, target, "Verify the final door.")!;
|
||||
const cleared = configWithGmReviewNote(noted, target, "")!;
|
||||
|
||||
expect(describeGmReviewTarget(target, noted)).toMatchObject({ note: "Verify the final door.", runnable: true });
|
||||
expect(cleared.dungeons[dungeon.id]?.note).toBeUndefined();
|
||||
expect(dungeon.available).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { DUNGEON_CATALOG, dungeonById } from "./dungeonCatalog";
|
||||
import {
|
||||
bossAdminRecordForStatus,
|
||||
GM_REVIEW_NOTE_MAX_LENGTH,
|
||||
resolvedBossAdminCatalog,
|
||||
type GmReadinessStatus,
|
||||
type ManastormAdminConfig,
|
||||
type ResolvedBossAdminEntry,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
export type GmReviewKind = "dungeon" | "boss" | "manastorm";
|
||||
|
||||
export type GmReviewTarget =
|
||||
| { readonly kind: "dungeon"; readonly dungeonId: string }
|
||||
| { readonly kind: "boss" | "manastorm"; readonly bossKey: string };
|
||||
|
||||
export interface GmReviewDescriptor {
|
||||
readonly target: GmReviewTarget;
|
||||
readonly name: string;
|
||||
readonly dungeonTitle: string;
|
||||
readonly mapId: number;
|
||||
readonly status: GmReadinessStatus;
|
||||
readonly note: string;
|
||||
readonly reviewedAt: number | null;
|
||||
readonly reviewedBy: string | null;
|
||||
readonly runnable: boolean;
|
||||
}
|
||||
|
||||
function sameTarget(left: GmReviewTarget, right: GmReviewTarget): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === "dungeon" && right.kind === "dungeon") return left.dungeonId === right.dungeonId;
|
||||
return left.kind !== "dungeon" && right.kind !== "dungeon" && left.bossKey === right.bossKey;
|
||||
}
|
||||
|
||||
function bossTargets(
|
||||
kind: "boss" | "manastorm",
|
||||
config: ManastormAdminConfig,
|
||||
): readonly GmReviewTarget[] {
|
||||
const source = kind === "boss" ? "dungeon" : "installed-manastorm";
|
||||
return resolvedBossAdminCatalog(config)
|
||||
.filter((entry) => entry.runnable && entry.source.includes(source))
|
||||
.map((entry) => ({ kind, bossKey: entry.key }));
|
||||
}
|
||||
|
||||
function targetStatus(target: GmReviewTarget, config: ManastormAdminConfig): GmReadinessStatus {
|
||||
if (target.kind === "dungeon") return config.dungeons[target.dungeonId]?.status ?? "needs-tested";
|
||||
return config.bosses[target.bossKey]?.status ?? "needs-tested";
|
||||
}
|
||||
|
||||
export function gmReviewQueue(
|
||||
kind: GmReviewKind,
|
||||
config: ManastormAdminConfig,
|
||||
previous: GmReviewTarget | null = null,
|
||||
): readonly GmReviewTarget[] {
|
||||
const targets: readonly GmReviewTarget[] = kind === "dungeon"
|
||||
? DUNGEON_CATALOG.filter((dungeon) => dungeon.available).map((dungeon) => ({
|
||||
kind: "dungeon",
|
||||
dungeonId: dungeon.id,
|
||||
}))
|
||||
: bossTargets(kind, config);
|
||||
const needsTested = targets.filter((target) => targetStatus(target, config) === "needs-tested");
|
||||
const prioritized = needsTested.length
|
||||
? needsTested
|
||||
: targets.filter((target) => targetStatus(target, config) === "not-ready");
|
||||
if (!previous || prioritized.length < 2) return prioritized;
|
||||
return prioritized.filter((target) => !sameTarget(target, previous));
|
||||
}
|
||||
|
||||
export function drawRandomGmReviewTarget(
|
||||
kind: GmReviewKind,
|
||||
config: ManastormAdminConfig,
|
||||
previous: GmReviewTarget | null = null,
|
||||
random: () => number = Math.random,
|
||||
): GmReviewTarget | null {
|
||||
const queue = gmReviewQueue(kind, config, previous);
|
||||
if (!queue.length) return null;
|
||||
const draw = random();
|
||||
const normalized = Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0;
|
||||
return queue[Math.floor(normalized * queue.length)] ?? queue[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvedBoss(target: GmReviewTarget, config: ManastormAdminConfig): ResolvedBossAdminEntry | null {
|
||||
if (target.kind === "dungeon") return null;
|
||||
return resolvedBossAdminCatalog(config).find((entry) => entry.key === target.bossKey) ?? null;
|
||||
}
|
||||
|
||||
export function describeGmReviewTarget(
|
||||
target: GmReviewTarget,
|
||||
config: ManastormAdminConfig,
|
||||
): GmReviewDescriptor | null {
|
||||
if (target.kind === "dungeon") {
|
||||
const dungeon = dungeonById(target.dungeonId);
|
||||
if (!dungeon) return null;
|
||||
const record = config.dungeons[target.dungeonId];
|
||||
return {
|
||||
target,
|
||||
name: dungeon.title,
|
||||
dungeonTitle: dungeon.location,
|
||||
mapId: dungeon.mapId,
|
||||
status: record?.status ?? "needs-tested",
|
||||
note: record?.note ?? "",
|
||||
reviewedAt: record?.reviewedAt ?? null,
|
||||
reviewedBy: record?.reviewedBy ?? null,
|
||||
runnable: dungeon.available,
|
||||
};
|
||||
}
|
||||
const entry = resolvedBoss(target, config);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
target,
|
||||
name: entry.name,
|
||||
dungeonTitle: entry.dungeonTitle,
|
||||
mapId: entry.mapId,
|
||||
status: entry.status,
|
||||
note: entry.reviewNote,
|
||||
reviewedAt: entry.reviewedAt,
|
||||
reviewedBy: entry.reviewedBy,
|
||||
runnable: entry.runnable,
|
||||
};
|
||||
}
|
||||
|
||||
export function configWithGmReviewStatus(
|
||||
config: ManastormAdminConfig,
|
||||
target: GmReviewTarget,
|
||||
status: GmReadinessStatus,
|
||||
): ManastormAdminConfig | null {
|
||||
if (target.kind === "dungeon") {
|
||||
if (!dungeonById(target.dungeonId)) return null;
|
||||
return {
|
||||
...config,
|
||||
dungeons: {
|
||||
...config.dungeons,
|
||||
[target.dungeonId]: { ...config.dungeons[target.dungeonId], status },
|
||||
},
|
||||
};
|
||||
}
|
||||
const entry = resolvedBoss(target, config);
|
||||
if (!entry || (status === "ready" && !entry.runnable)) return null;
|
||||
return {
|
||||
...config,
|
||||
bosses: {
|
||||
...config.bosses,
|
||||
[target.bossKey]: bossAdminRecordForStatus(entry, config, status),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function configWithGmReviewNote(
|
||||
config: ManastormAdminConfig,
|
||||
target: GmReviewTarget,
|
||||
note: string,
|
||||
): ManastormAdminConfig | null {
|
||||
const normalized = note.replace(/\r\n?/g, "\n").slice(0, GM_REVIEW_NOTE_MAX_LENGTH);
|
||||
const noteFields = normalized.trim() ? { note: normalized } : {};
|
||||
if (target.kind === "dungeon") {
|
||||
if (!dungeonById(target.dungeonId)) return null;
|
||||
const record = { ...(config.dungeons[target.dungeonId] ?? { status: "needs-tested" as const }) };
|
||||
delete record.note;
|
||||
return {
|
||||
...config,
|
||||
dungeons: {
|
||||
...config.dungeons,
|
||||
[target.dungeonId]: { ...record, ...noteFields },
|
||||
},
|
||||
};
|
||||
}
|
||||
const entry = resolvedBoss(target, config);
|
||||
if (!entry) return null;
|
||||
const current = bossAdminRecordForStatus(entry, config, entry.status);
|
||||
const record = { ...current };
|
||||
delete record.note;
|
||||
return {
|
||||
...config,
|
||||
bosses: {
|
||||
...config.bosses,
|
||||
[target.bossKey]: { ...record, ...noteFields },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
derivePartySpawnSlots,
|
||||
effectiveManastormCatalog,
|
||||
isGameMasterSession,
|
||||
normalizeManastormAdminConfig,
|
||||
publicManastormAdminConfig,
|
||||
resolveBossAdminEntry,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
@@ -21,6 +23,60 @@ function configWithBoss(key: string, record: ManastormAdminConfig["bosses"][stri
|
||||
}
|
||||
|
||||
describe("Manastorm GM catalog", () => {
|
||||
it("migrates schema-v1 records to schema v2 without losing authored content", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.baseStage);
|
||||
expect(candidate).toBeTruthy();
|
||||
const boss = bossAdminRecordForStatus(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG, "ready");
|
||||
const legacy = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
schemaVersion: 1,
|
||||
dungeonSpawns: { "wailing-caverns": { footPosition: [1, 2, 3], yaw: 0.5 } },
|
||||
bosses: { [candidate!.key]: boss },
|
||||
};
|
||||
delete (legacy as { dungeons?: unknown }).dungeons;
|
||||
|
||||
const migrated = normalizeManastormAdminConfig(legacy);
|
||||
|
||||
expect(migrated.schemaVersion).toBe(2);
|
||||
expect(migrated.dungeons).toEqual({});
|
||||
expect(migrated.dungeonSpawns["wailing-caverns"]).toEqual({ footPosition: [1, 2, 3], yaw: 0.5 });
|
||||
expect(migrated.bosses[candidate!.key]).toEqual(boss);
|
||||
});
|
||||
|
||||
it("normalizes dungeon readiness independently from player availability", () => {
|
||||
const normalized = normalizeManastormAdminConfig({
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
dungeons: {
|
||||
"wailing-caverns": { status: "not-ready" },
|
||||
deadmines: { status: "unknown" },
|
||||
},
|
||||
});
|
||||
expect(normalized.dungeons["wailing-caverns"]?.status).toBe("not-ready");
|
||||
expect(normalized.dungeons.deadmines?.status).toBe("needs-tested");
|
||||
});
|
||||
|
||||
it("preserves normalized review notes and server-owned review metadata", () => {
|
||||
const normalized = normalizeManastormAdminConfig({
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
dungeons: {
|
||||
deadmines: {
|
||||
status: "not-ready",
|
||||
note: "Door trigger needs another pass.\r\nRetest with a full party.",
|
||||
reviewedAt: 1_700_000_000_000,
|
||||
reviewedBy: " Phenom ",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.dungeons.deadmines).toEqual({
|
||||
status: "not-ready",
|
||||
note: "Door trigger needs another pass.\nRetest with a full party.",
|
||||
reviewedAt: 1_700_000_000_000,
|
||||
reviewedBy: "Phenom",
|
||||
});
|
||||
expect(publicManastormAdminConfig(normalized).dungeons.deadmines).toEqual({ status: "not-ready" });
|
||||
});
|
||||
|
||||
it("deduplicates map/name records and defaults every boss to Needs tested", () => {
|
||||
const identities = UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => `${entry.mapId}:${entry.name.toLowerCase()}`);
|
||||
expect(new Set(identities).size).toBe(identities.length);
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
} from "./manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog";
|
||||
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||
export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1";
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 2;
|
||||
export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v2";
|
||||
export const LEGACY_MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1";
|
||||
export const GM_REVIEW_NOTE_MAX_LENGTH = 1_000;
|
||||
|
||||
export type ManastormBossStatus = "ready" | "needs-tested" | "not-ready";
|
||||
export type GmReadinessStatus = "ready" | "needs-tested" | "not-ready";
|
||||
export type ManastormBossStatus = GmReadinessStatus;
|
||||
|
||||
export interface ManastormGlobalOptions {
|
||||
readonly enabled: boolean;
|
||||
@@ -37,15 +40,24 @@ export interface ManastormAdminStageConfig {
|
||||
readonly requiredKillCount: number;
|
||||
}
|
||||
|
||||
export interface ManastormBossAdminRecord {
|
||||
readonly status: ManastormBossStatus;
|
||||
export interface GmReviewRecord {
|
||||
readonly status: GmReadinessStatus;
|
||||
readonly note?: string;
|
||||
readonly reviewedAt?: number;
|
||||
readonly reviewedBy?: string;
|
||||
}
|
||||
|
||||
export interface ManastormBossAdminRecord extends GmReviewRecord {
|
||||
readonly stage?: ManastormAdminStageConfig;
|
||||
}
|
||||
|
||||
export type DungeonAdminRecord = GmReviewRecord;
|
||||
|
||||
export interface ManastormAdminConfig {
|
||||
readonly schemaVersion: typeof MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION;
|
||||
readonly global: ManastormGlobalOptions;
|
||||
readonly dungeonSpawns: Readonly<Record<string, WorldSpawn>>;
|
||||
readonly dungeons: Readonly<Record<string, DungeonAdminRecord>>;
|
||||
readonly bosses: Readonly<Record<string, ManastormBossAdminRecord>>;
|
||||
}
|
||||
|
||||
@@ -71,6 +83,9 @@ export interface UnifiedBossCatalogEntry {
|
||||
|
||||
export interface ResolvedBossAdminEntry extends UnifiedBossCatalogEntry {
|
||||
readonly status: ManastormBossStatus;
|
||||
readonly reviewNote: string;
|
||||
readonly reviewedAt: number | null;
|
||||
readonly reviewedBy: string | null;
|
||||
readonly stage: ManastormEncounterDefinition | null;
|
||||
readonly runnable: boolean;
|
||||
readonly validationIssues: readonly string[];
|
||||
@@ -88,6 +103,7 @@ export const DEFAULT_MANASTORM_ADMIN_CONFIG: ManastormAdminConfig = Object.freez
|
||||
milestoneEncountersEnabled: true,
|
||||
}),
|
||||
dungeonSpawns: Object.freeze({}),
|
||||
dungeons: Object.freeze({}),
|
||||
bosses: Object.freeze({}),
|
||||
});
|
||||
|
||||
@@ -143,10 +159,37 @@ function normalizedStage(value: unknown): ManastormAdminStageConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedReviewFields(value: Partial<GmReviewRecord>): Omit<GmReviewRecord, "status"> {
|
||||
const rawNote = typeof value.note === "string"
|
||||
? value.note.replace(/\r\n?/g, "\n").slice(0, GM_REVIEW_NOTE_MAX_LENGTH)
|
||||
: "";
|
||||
const reviewedAt = typeof value.reviewedAt === "number"
|
||||
&& Number.isInteger(value.reviewedAt)
|
||||
&& value.reviewedAt > 0
|
||||
? value.reviewedAt
|
||||
: null;
|
||||
const reviewedBy = typeof value.reviewedBy === "string"
|
||||
? value.reviewedBy.trim().slice(0, 256)
|
||||
: "";
|
||||
return {
|
||||
...(rawNote.trim() ? { note: rawNote } : {}),
|
||||
...(reviewedAt ? { reviewedAt } : {}),
|
||||
...(reviewedBy ? { reviewedBy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeManastormAdminConfig(value: unknown): ManastormAdminConfig {
|
||||
if (!value || typeof value !== "object") return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const input = value as Partial<ManastormAdminConfig>;
|
||||
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const input = value as {
|
||||
readonly schemaVersion?: unknown;
|
||||
readonly global?: Partial<ManastormGlobalOptions>;
|
||||
readonly dungeonSpawns?: Readonly<Record<string, unknown>>;
|
||||
readonly dungeons?: Readonly<Record<string, unknown>>;
|
||||
readonly bosses?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
if (input.schemaVersion !== 1 && input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||
return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
}
|
||||
const globalInput = input.global as Partial<ManastormGlobalOptions> | undefined;
|
||||
const global: ManastormGlobalOptions = {
|
||||
enabled: globalInput?.enabled !== false,
|
||||
@@ -160,14 +203,42 @@ export function normalizeManastormAdminConfig(value: unknown): ManastormAdminCon
|
||||
const spawn = normalizedSpawn(value);
|
||||
return id && spawn ? [[id, spawn]] : [];
|
||||
}));
|
||||
const dungeons = Object.fromEntries(Object.entries(input.dungeons ?? {}).flatMap(([id, value]) => {
|
||||
if (!id || !value || typeof value !== "object") return [];
|
||||
const candidate = value as Partial<DungeonAdminRecord>;
|
||||
const status: GmReadinessStatus = candidate.status === "ready" || candidate.status === "not-ready"
|
||||
? candidate.status
|
||||
: "needs-tested";
|
||||
return [[id, { status, ...normalizedReviewFields(candidate) }]];
|
||||
}));
|
||||
const bosses = Object.fromEntries(Object.entries(input.bosses ?? {}).flatMap(([key, value]) => {
|
||||
if (!key || !value || typeof value !== "object") return [];
|
||||
const candidate = value as Partial<ManastormBossAdminRecord>;
|
||||
const status: ManastormBossStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested";
|
||||
const status: GmReadinessStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested";
|
||||
const stage = normalizedStage(candidate.stage);
|
||||
return [[key, { status, ...(stage ? { stage } : {}) }]];
|
||||
return [[key, { status, ...normalizedReviewFields(candidate), ...(stage ? { stage } : {}) }]];
|
||||
}));
|
||||
return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global, dungeonSpawns, bosses };
|
||||
return {
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global,
|
||||
dungeonSpawns,
|
||||
dungeons,
|
||||
bosses,
|
||||
};
|
||||
}
|
||||
|
||||
export function publicManastormAdminConfig(config: ManastormAdminConfig): ManastormAdminConfig {
|
||||
return {
|
||||
...config,
|
||||
dungeons: Object.fromEntries(Object.entries(config.dungeons).map(([key, record]) => [
|
||||
key,
|
||||
{ status: record.status },
|
||||
])),
|
||||
bosses: Object.fromEntries(Object.entries(config.bosses).map(([key, record]) => [
|
||||
key,
|
||||
{ status: record.status, ...(record.stage ? { stage: record.stage } : {}) },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function dungeonBossEntityId(dungeonId: string, objectiveId: string): string {
|
||||
@@ -351,7 +422,8 @@ export function resolveBossAdminEntry(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ResolvedBossAdminEntry {
|
||||
const status = config.bosses[entry.key]?.status ?? "needs-tested";
|
||||
const record = config.bosses[entry.key];
|
||||
const status = record?.status ?? "needs-tested";
|
||||
const stage = configuredStageForBoss(entry, config);
|
||||
const issues: string[] = [];
|
||||
if (!stage) issues.push("No runnable Manastorm stage has been configured.");
|
||||
@@ -370,6 +442,9 @@ export function resolveBossAdminEntry(
|
||||
return {
|
||||
...entry,
|
||||
status,
|
||||
reviewNote: record?.note ?? "",
|
||||
reviewedAt: record?.reviewedAt ?? null,
|
||||
reviewedBy: record?.reviewedBy ?? null,
|
||||
stage,
|
||||
runnable,
|
||||
validationIssues: Object.freeze(issues),
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { PlayerSession } from "../app/types";
|
||||
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
LEGACY_MANASTORM_ADMIN_CACHE_KEY,
|
||||
MANASTORM_ADMIN_CACHE_KEY,
|
||||
normalizeManastormAdminConfig,
|
||||
publicManastormAdminConfig,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
type PublishedManastormAdminConfig,
|
||||
@@ -14,13 +16,16 @@ interface CachedPayload extends PublishedManastormAdminConfig {}
|
||||
|
||||
function readCache(): CachedPayload {
|
||||
try {
|
||||
const raw = typeof window !== "undefined" ? window.localStorage.getItem(MANASTORM_ADMIN_CACHE_KEY) : null;
|
||||
const raw = typeof window !== "undefined"
|
||||
? window.localStorage.getItem(MANASTORM_ADMIN_CACHE_KEY)
|
||||
?? window.localStorage.getItem(LEGACY_MANASTORM_ADMIN_CACHE_KEY)
|
||||
: null;
|
||||
const parsed = raw ? JSON.parse(raw) as Partial<CachedPayload> : null;
|
||||
if (!parsed || !Number.isInteger(parsed.revision) || (parsed.revision ?? -1) < 0) throw new Error("missing cache");
|
||||
return {
|
||||
revision: parsed.revision!,
|
||||
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : null,
|
||||
config: normalizeManastormAdminConfig(parsed.config),
|
||||
config: publicManastormAdminConfig(normalizeManastormAdminConfig(parsed.config)),
|
||||
};
|
||||
} catch {
|
||||
return { revision: 0, updatedAt: null, config: DEFAULT_MANASTORM_ADMIN_CONFIG };
|
||||
@@ -29,7 +34,10 @@ function readCache(): CachedPayload {
|
||||
|
||||
function writeCache(payload: PublishedManastormAdminConfig): void {
|
||||
try {
|
||||
window.localStorage.setItem(MANASTORM_ADMIN_CACHE_KEY, JSON.stringify(payload));
|
||||
window.localStorage.setItem(MANASTORM_ADMIN_CACHE_KEY, JSON.stringify({
|
||||
...payload,
|
||||
config: publicManastormAdminConfig(payload.config),
|
||||
}));
|
||||
} catch {
|
||||
// Private browsing and storage quotas must not prevent play.
|
||||
}
|
||||
@@ -42,7 +50,7 @@ export interface ManastormAdminState extends PublishedManastormAdminConfig {
|
||||
readonly error: string | null;
|
||||
readonly editingBossKey: string | null;
|
||||
readonly dirty: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
refresh: (session?: PlayerSession | null) => Promise<void>;
|
||||
replaceDraft: (config: ManastormAdminConfig) => void;
|
||||
resetDraft: () => void;
|
||||
publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||
@@ -59,10 +67,15 @@ export const useManastormAdminStore = create<ManastormAdminState>((set, get) =>
|
||||
error: null,
|
||||
editingBossKey: null,
|
||||
dirty: false,
|
||||
refresh: async () => {
|
||||
refresh: async (session = null) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/manastorm-config");
|
||||
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>(
|
||||
gm ? "/api/gm/manastorm-config" : "/api/manastorm-config",
|
||||
{},
|
||||
gm ? session?.accessToken : undefined,
|
||||
);
|
||||
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||
writeCache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, loading: false });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveContentUrl } from "../content/contentManager";
|
||||
import { activateDungeonSession } from "./dungeonSession";
|
||||
import type { DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
import {
|
||||
manastormStageAssetPackageById,
|
||||
type ManastormStageAssetPackage,
|
||||
@@ -68,6 +69,7 @@ export function activateManastormStage(
|
||||
loader: ManastormStageLoader = defaultLoader,
|
||||
preserveCharacter = true,
|
||||
gmTestMode = false,
|
||||
gmReviewTarget: GmReviewTarget | null = null,
|
||||
): ManastormStageLoadResult {
|
||||
const nextPackage = stage.assetPackageId
|
||||
? manastormStageAssetPackageById(stage.assetPackageId)
|
||||
@@ -78,6 +80,7 @@ export function activateManastormStage(
|
||||
spawn: stage.playerSpawn,
|
||||
preserveCharacter,
|
||||
gmTestMode,
|
||||
gmReviewTarget,
|
||||
});
|
||||
if (!activated) {
|
||||
return {
|
||||
|
||||
+11
-1
@@ -3,6 +3,7 @@ import { resolveAreaNameForDungeon } from "./dungeonManifest";
|
||||
import { DEFAULT_DUNGEON_ID, requireDungeonDefinition, type DungeonId } from "./dungeonRegistry";
|
||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
import type { ActionBindingLayer } from "./actionBindings";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
@@ -21,6 +22,7 @@ export interface GameState {
|
||||
activeDifficultyId: string;
|
||||
gameMode: GameMode;
|
||||
gmTestMode: boolean;
|
||||
activeGmReviewTarget: GmReviewTarget | null;
|
||||
activeSpawn: WorldSpawn;
|
||||
sessionRevision: number;
|
||||
overlay: GameOverlay;
|
||||
@@ -43,7 +45,12 @@ export interface GameState {
|
||||
activateDungeon: (
|
||||
dungeonId: DungeonId,
|
||||
difficultyId?: string,
|
||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean },
|
||||
options?: {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
},
|
||||
) => void;
|
||||
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
@@ -70,6 +77,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDifficultyId: initialDungeon.defaultDifficultyId,
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||
sessionRevision: 0,
|
||||
overlay: null,
|
||||
@@ -103,6 +111,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDifficultyId,
|
||||
gameMode: options?.gameMode ?? "dungeon",
|
||||
gmTestMode: options?.gmTestMode ?? false,
|
||||
activeGmReviewTarget: options?.gmReviewTarget ?? null,
|
||||
activeSpawn,
|
||||
sessionRevision: state.sessionRevision + 1,
|
||||
overlay: null,
|
||||
@@ -222,6 +231,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
return {
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn,
|
||||
overlay: null,
|
||||
paused: false,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
import { OriginalCombatEffect } from "./OriginalCombatEffect";
|
||||
|
||||
export interface CombatEffectsProps {
|
||||
readonly active: boolean;
|
||||
@@ -257,12 +258,13 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
|
||||
mode: settings.vfxMode,
|
||||
quality: effectiveQuality,
|
||||
systems: effects.length,
|
||||
activeLights: 0,
|
||||
allocatedLights: 0,
|
||||
});
|
||||
}, [effectiveQuality, effects.length]);
|
||||
}, [effectiveQuality, effects.length, settings.vfxMode]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
|
||||
@@ -331,13 +333,23 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
return (
|
||||
<group name="combat-effects">
|
||||
{effects.map((effect) => (
|
||||
<CombatEffectPrimitive
|
||||
key={effect.key}
|
||||
event={effect.event}
|
||||
quality={effectiveQuality}
|
||||
active={active}
|
||||
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
|
||||
/>
|
||||
settings.vfxMode === "original" ? (
|
||||
<OriginalCombatEffect
|
||||
key={`original:${effect.key}`}
|
||||
event={effect.event}
|
||||
quality={effectiveQuality}
|
||||
active={active}
|
||||
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
|
||||
/>
|
||||
) : (
|
||||
<CombatEffectPrimitive
|
||||
key={`generated:${effect.key}`}
|
||||
event={effect.event}
|
||||
quality={effectiveQuality}
|
||||
active={active}
|
||||
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
DynamicDrawUsage,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
MultiplyBlending,
|
||||
NormalBlending,
|
||||
ShaderMaterial,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
TextureLoader,
|
||||
Vector2,
|
||||
} from "three";
|
||||
import {
|
||||
combatOriginalPhaseAssets,
|
||||
preloadCombatEffectManifest,
|
||||
type CombatEffectPhaseAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import {
|
||||
interpolateOriginalLifetime,
|
||||
interpolateOriginalScalar,
|
||||
originalAuraMatchesPresentationEvent,
|
||||
originalEmitterParticleBudget,
|
||||
parseOriginalCombatEffectGraph,
|
||||
type OriginalCombatEffectAnimatedTrackName,
|
||||
type OriginalCombatEffectGraph,
|
||||
type OriginalCombatEffectParticle,
|
||||
} from "./originalCombatEffectRuntime";
|
||||
import {
|
||||
parseOriginalCombatMeshGraph,
|
||||
type OriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
|
||||
|
||||
interface LoadedOriginalGraph {
|
||||
readonly asset: CombatEffectPhaseAsset;
|
||||
readonly graph: OriginalSourceEffectGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
}
|
||||
|
||||
interface OriginalCombatEffectProps {
|
||||
readonly event: CombatPresentationEvent;
|
||||
readonly quality: Exclude<CombatVfxQuality, "off">;
|
||||
readonly active: boolean;
|
||||
readonly onDone: () => void;
|
||||
}
|
||||
|
||||
const textureLoader = new TextureLoader();
|
||||
const graphRequestCache = new Map<string, Promise<OriginalSourceEffectGraph>>();
|
||||
const textureRequestCache = new Map<string, Promise<Texture>>();
|
||||
const ORIGINAL_EFFECT_SLOT_LIMIT = { low: 384, high: 1_024 } as const;
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 particleColor;
|
||||
attribute float particleAlpha;
|
||||
attribute float particleSize;
|
||||
attribute float particleFrame;
|
||||
varying vec3 vParticleColor;
|
||||
varying float vParticleAlpha;
|
||||
varying float vParticleFrame;
|
||||
uniform float viewportHeight;
|
||||
|
||||
void main() {
|
||||
vParticleColor = particleColor;
|
||||
vParticleAlpha = particleAlpha;
|
||||
vParticleFrame = particleFrame;
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
gl_PointSize = clamp(
|
||||
particleSize * viewportHeight / max(1.0, -viewPosition.z),
|
||||
1.0,
|
||||
256.0
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
varying vec3 vParticleColor;
|
||||
varying float vParticleAlpha;
|
||||
varying float vParticleFrame;
|
||||
uniform sampler2D sourceTexture;
|
||||
uniform vec2 atlasSize;
|
||||
uniform float alphaKey;
|
||||
|
||||
void main() {
|
||||
float frameCount = max(1.0, atlasSize.x * atlasSize.y);
|
||||
float frame = mod(max(0.0, floor(vParticleFrame)), frameCount);
|
||||
float column = mod(frame, atlasSize.x);
|
||||
float row = floor(frame / atlasSize.x);
|
||||
vec2 atlasUv = (
|
||||
vec2(column, atlasSize.y - row - 1.0) + gl_PointCoord
|
||||
) / atlasSize;
|
||||
vec4 source = texture2D(sourceTexture, atlasUv);
|
||||
float resolvedAlpha = source.a * vParticleAlpha;
|
||||
if ((alphaKey > 0.5 && source.a < 0.5) || resolvedAlpha <= 0.002) discard;
|
||||
gl_FragColor = vec4(source.rgb * vParticleColor, resolvedAlpha);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
function sourceRandom(seed: number): number {
|
||||
const value = Math.sin(seed * 12.9898 + 78.233) * 43_758.5453;
|
||||
return value - Math.floor(value);
|
||||
}
|
||||
|
||||
function maximumSampleValue(samples: readonly { readonly value: number }[]): number {
|
||||
return samples.reduce((maximum, sample) => Math.max(maximum, sample.value), 0);
|
||||
}
|
||||
|
||||
function graphDurationMs(graph: OriginalCombatEffectGraph): number {
|
||||
return Math.max(1, graph.durationMs, ...graph.animations.map((animation) => animation.durationMs));
|
||||
}
|
||||
|
||||
function graphTailMs(graph: OriginalCombatEffectGraph): number {
|
||||
return graph.particles.reduce((maximum, particle) => (
|
||||
Math.max(maximum, maximumSampleValue(particle.samples.lifespan) * 1_000)
|
||||
), 0);
|
||||
}
|
||||
|
||||
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
|
||||
const normalized = model?.toLowerCase() ?? "";
|
||||
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
|
||||
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
|
||||
if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08;
|
||||
if (event.phase === "cast-start" || /(?:_|\/)hand(?:\.|_|$)/.test(normalized)) return 1.15;
|
||||
return event.delivery === "area" ? 0.08 : 1.05;
|
||||
}
|
||||
|
||||
function liveActorPosition(actorId: string | undefined) {
|
||||
if (!actorId) return null;
|
||||
if (actorId === PLAYER_AURA_ENTITY_ID) return useCombatStore.getState().playerPosition;
|
||||
return getMobPosition(actorId) ?? getPartyRuntimePosition(actorId);
|
||||
}
|
||||
|
||||
function auraDurationMs(event: CombatPresentationEvent): number {
|
||||
const ability = abilityById(event.abilityId);
|
||||
if (!ability) return 6_000;
|
||||
return ability.effects.reduce((maximum, effect) => {
|
||||
if ("durationMs" in effect && typeof effect.durationMs === "number") {
|
||||
return Math.max(maximum, effect.durationMs);
|
||||
}
|
||||
if ("aura" in effect && effect.aura && typeof effect.aura.durationMs === "number") {
|
||||
return Math.max(maximum, effect.aura.durationMs);
|
||||
}
|
||||
return maximum;
|
||||
}, 6_000);
|
||||
}
|
||||
|
||||
function auraStillActive(event: CombatPresentationEvent): boolean {
|
||||
const state = useCombatStore.getState();
|
||||
const targetId = event.targetActorId ?? PLAYER_AURA_ENTITY_ID;
|
||||
const legacyPlayerShield = targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.abilityId.includes("power-word-shield")
|
||||
&& state.shield > 0;
|
||||
const eventWithTarget = event.targetActorId === targetId
|
||||
? event
|
||||
: { ...event, targetActorId: targetId };
|
||||
return legacyPlayerShield || state.auras.some((aura) => (
|
||||
originalAuraMatchesPresentationEvent(aura, eventWithTarget)
|
||||
)) || state.effects.some((effect) => (
|
||||
effect.targetId === targetId && effect.sourceAbilityId === event.abilityId
|
||||
));
|
||||
}
|
||||
|
||||
function effectScale(asset: CombatEffectPhaseAsset): number {
|
||||
return typeof asset.scale === "number" && Number.isFinite(asset.scale)
|
||||
? Math.max(0.05, Math.min(5, asset.scale))
|
||||
: 1;
|
||||
}
|
||||
|
||||
async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
const existing = graphRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
// Graph filenames are keyed to the immutable source M2, while the decoded
|
||||
// browser schema can improve between releases. Revalidate the small JSON so
|
||||
// an older cached decoder payload cannot fail the current strict parser.
|
||||
const request = fetch(url, { cache: "no-cache" })
|
||||
.then((response) => response.ok
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
|
||||
.then((value) => (
|
||||
value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
|
||||
? parseOriginalCombatMeshGraph(value)
|
||||
: parseOriginalCombatEffectGraph(value)
|
||||
));
|
||||
graphRequestCache.set(url, request);
|
||||
request.catch(() => graphRequestCache.delete(url));
|
||||
return request;
|
||||
}
|
||||
|
||||
async function loadTexture(url: string): Promise<Texture> {
|
||||
const existing = textureRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
const request = textureLoader.loadAsync(url).then((texture) => {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.name = `original-combat-effect:${url}`;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
});
|
||||
textureRequestCache.set(url, request);
|
||||
request.catch(() => textureRequestCache.delete(url));
|
||||
return request;
|
||||
}
|
||||
|
||||
async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<LoadedOriginalGraph> {
|
||||
if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL.");
|
||||
const graph = await loadGraph(asset.effectGraphUrl);
|
||||
const textureIndices = graph.kind === "wow-m2-static-mesh"
|
||||
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
|
||||
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
|
||||
graph.particles[particleIndex]?.textureIndex
|
||||
)).filter((textureIndex): textureIndex is number => textureIndex !== undefined))];
|
||||
const loadedTextures = await Promise.all(textureIndices.map(async (textureIndex) => {
|
||||
const url = graph.textures[textureIndex]?.url;
|
||||
if (!url) throw new Error(`Original effect texture ${textureIndex} has no packaged URL.`);
|
||||
return [textureIndex, await loadTexture(url)] as const;
|
||||
}));
|
||||
return { asset, graph, textures: new Map(loadedTextures) };
|
||||
}
|
||||
|
||||
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
|
||||
const additive = particle.blend === "no-alpha-add"
|
||||
|| particle.blend === "add"
|
||||
|| particle.blend === "blend-add";
|
||||
const multiply = particle.blend === "mod" || particle.blend === "mod-2x";
|
||||
return new ShaderMaterial({
|
||||
name: `wow-m2-particle-${particle.id}`,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
uniforms: {
|
||||
sourceTexture: { value: texture },
|
||||
atlasSize: { value: new Vector2(particle.atlas.columns, particle.atlas.rows) },
|
||||
viewportHeight: { value: 720 },
|
||||
alphaKey: { value: particle.blend === "alpha-key" ? 1 : 0 },
|
||||
},
|
||||
blending: additive ? AdditiveBlending : multiply ? MultiplyBlending : NormalBlending,
|
||||
transparent: particle.blend !== "opaque",
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
}
|
||||
|
||||
const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
graph,
|
||||
particle,
|
||||
texture,
|
||||
budget,
|
||||
active,
|
||||
eventId,
|
||||
emitterIndex,
|
||||
loopTimeline,
|
||||
}: {
|
||||
readonly graph: OriginalCombatEffectGraph;
|
||||
readonly particle: OriginalCombatEffectParticle;
|
||||
readonly texture: Texture;
|
||||
readonly budget: number;
|
||||
readonly active: boolean;
|
||||
readonly eventId: number;
|
||||
readonly emitterIndex: number;
|
||||
readonly loopTimeline: boolean;
|
||||
}) {
|
||||
const elapsedRef = useRef(0);
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry();
|
||||
const position = new BufferAttribute(new Float32Array(budget * 3), 3);
|
||||
const color = new BufferAttribute(new Float32Array(budget * 3), 3);
|
||||
const alpha = new BufferAttribute(new Float32Array(budget), 1);
|
||||
const size = new BufferAttribute(new Float32Array(budget), 1);
|
||||
const frame = new BufferAttribute(new Float32Array(budget), 1);
|
||||
position.setUsage(DynamicDrawUsage);
|
||||
color.setUsage(DynamicDrawUsage);
|
||||
alpha.setUsage(DynamicDrawUsage);
|
||||
size.setUsage(DynamicDrawUsage);
|
||||
frame.setUsage(DynamicDrawUsage);
|
||||
next.setAttribute("position", position);
|
||||
next.setAttribute("particleColor", color);
|
||||
next.setAttribute("particleAlpha", alpha);
|
||||
next.setAttribute("particleSize", size);
|
||||
next.setAttribute("particleFrame", frame);
|
||||
next.setDrawRange(0, budget);
|
||||
return next;
|
||||
}, [budget]);
|
||||
const material = useMemo(() => materialForParticle(particle, texture), [particle, texture]);
|
||||
const durationMs = graphDurationMs(graph);
|
||||
const peakRate = maximumSampleValue(particle.samples.emissionRate);
|
||||
const peakLifespan = maximumSampleValue(particle.samples.lifespan);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
}, [geometry, material]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!active || budget === 0) return;
|
||||
elapsedRef.current += Math.min(0.1, Math.max(0, delta));
|
||||
material.uniforms.viewportHeight.value = state.size.height * state.viewport.dpr;
|
||||
const elapsed = elapsedRef.current;
|
||||
const positions = geometry.getAttribute("position") as BufferAttribute;
|
||||
const colors = geometry.getAttribute("particleColor") as BufferAttribute;
|
||||
const alphas = geometry.getAttribute("particleAlpha") as BufferAttribute;
|
||||
const sizes = geometry.getAttribute("particleSize") as BufferAttribute;
|
||||
const frames = geometry.getAttribute("particleFrame") as BufferAttribute;
|
||||
const cycleLength = Math.max(0.02, peakLifespan);
|
||||
const emitterSeed = eventId * 1_009
|
||||
+ particle.id * 17
|
||||
+ particle.textureIndex * 131
|
||||
+ emitterIndex * 7_919;
|
||||
|
||||
for (let index = 0; index < budget; index += 1) {
|
||||
const slotOffset = peakRate > 0 ? index / peakRate : Number.POSITIVE_INFINITY;
|
||||
const completedCycles = Math.floor((elapsed - slotOffset) / cycleLength);
|
||||
const birthTime = slotOffset + Math.max(0, completedCycles) * cycleLength;
|
||||
const age = elapsed - birthTime;
|
||||
const cycleSeed = emitterSeed + index * 97 + Math.max(0, completedCycles) * 271;
|
||||
const rawTimelineMs = birthTime * 1_000;
|
||||
const timelineMs = loopTimeline ? rawTimelineMs % durationMs : rawTimelineMs;
|
||||
const scalarAt = (track: OriginalCombatEffectAnimatedTrackName) => (
|
||||
interpolateOriginalScalar(
|
||||
particle.samples[track],
|
||||
timelineMs,
|
||||
particle.interpolation[track],
|
||||
) ?? 0
|
||||
);
|
||||
const emissionRate = rawTimelineMs <= durationMs || loopTimeline
|
||||
? Math.max(0, scalarAt("emissionRate"))
|
||||
: 0;
|
||||
const lifespanBase = Math.max(0.02, scalarAt("lifespan"));
|
||||
const lifespan = lifespanBase * Math.max(
|
||||
0.05,
|
||||
1 + (sourceRandom(cycleSeed + 1) * 2 - 1) * particle.lifespanVariation,
|
||||
);
|
||||
const accepted = elapsed >= slotOffset
|
||||
&& age >= 0
|
||||
&& age < lifespan
|
||||
&& scalarAt("enabled") > 0.5
|
||||
&& sourceRandom(cycleSeed + 2) <= Math.min(1, emissionRate / Math.max(peakRate, 0.001));
|
||||
if (!accepted) {
|
||||
alphas.setX(index, 0);
|
||||
sizes.setX(index, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lifeProgress = Math.min(1, Math.max(0, age / lifespan));
|
||||
const speedBase = scalarAt("emissionSpeed");
|
||||
const speedVariation = scalarAt("speedVariation");
|
||||
const speed = speedBase * Math.max(0, 1 + (sourceRandom(cycleSeed + 3) * 2 - 1) * speedVariation);
|
||||
const horizontalRange = particle.samples.horizontalRange.length
|
||||
? scalarAt("horizontalRange")
|
||||
: Math.PI * 2;
|
||||
const verticalRange = scalarAt("verticalRange");
|
||||
const gravity = scalarAt("gravity");
|
||||
const areaLength = scalarAt("emissionAreaLength");
|
||||
const areaWidth = scalarAt("emissionAreaWidth");
|
||||
const zSource = scalarAt("zSource");
|
||||
const azimuth = (sourceRandom(cycleSeed + 4) - 0.5) * horizontalRange;
|
||||
const elevation = (sourceRandom(cycleSeed + 5) - 0.5) * verticalRange;
|
||||
const radial = Math.cos(elevation);
|
||||
const velocityX = Math.sin(azimuth) * radial * speed;
|
||||
const velocityY = Math.cos(azimuth) * radial * speed;
|
||||
const velocityZ = Math.sin(elevation) * speed;
|
||||
let localX = (sourceRandom(cycleSeed + 6) - 0.5) * areaWidth;
|
||||
let localY = (sourceRandom(cycleSeed + 7) - 0.5) * areaLength;
|
||||
let localZ = zSource;
|
||||
if (particle.emitter === "sphere") {
|
||||
const longitude = sourceRandom(cycleSeed + 6) * Math.PI * 2;
|
||||
const sphereZ = sourceRandom(cycleSeed + 7) * 2 - 1;
|
||||
const sphereRadius = Math.sqrt(Math.max(0, 1 - sphereZ * sphereZ));
|
||||
localX = Math.cos(longitude) * sphereRadius * areaWidth;
|
||||
localY = Math.sin(longitude) * sphereRadius * areaLength;
|
||||
localZ += sphereZ * Math.max(areaWidth, areaLength);
|
||||
}
|
||||
// WoW M2 is Z-up; the game scene is Y-up.
|
||||
positions.setXYZ(
|
||||
index,
|
||||
particle.position[0] + localX + velocityX * age,
|
||||
particle.position[2] + localZ + velocityZ * age - gravity * age * age * 0.5,
|
||||
-particle.position[1] - localY - velocityY * age,
|
||||
);
|
||||
const color = interpolateOriginalLifetime(particle.samples.color, lifeProgress) ?? [255, 255, 255];
|
||||
const alpha = interpolateOriginalLifetime(particle.samples.alpha, lifeProgress) ?? 0;
|
||||
const scale = interpolateOriginalLifetime(particle.samples.scale, lifeProgress) ?? [0, 0];
|
||||
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
|
||||
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
|
||||
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
|
||||
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)));
|
||||
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
|
||||
const frameSamples = particle.samples.headCell.length
|
||||
? particle.samples.headCell
|
||||
: particle.samples.tailCell;
|
||||
const sourceFrame = interpolateOriginalLifetime(frameSamples, lifeProgress) ?? 0;
|
||||
frames.setX(index, Math.max(0, Math.floor(sourceFrame)));
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
colors.needsUpdate = true;
|
||||
alphas.needsUpdate = true;
|
||||
sizes.needsUpdate = true;
|
||||
frames.needsUpdate = true;
|
||||
});
|
||||
|
||||
return budget > 0 ? (
|
||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||
) : null;
|
||||
});
|
||||
|
||||
function meshBlendMode(blendMode: number) {
|
||||
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
|
||||
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
|
||||
return NormalBlending;
|
||||
}
|
||||
|
||||
const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
graph,
|
||||
textures,
|
||||
active,
|
||||
}: {
|
||||
readonly graph: OriginalCombatMeshGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
readonly active: boolean;
|
||||
}) {
|
||||
const billboardRef = useRef<Group>(null);
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry();
|
||||
const sourcePositions = graph.geometry.positions;
|
||||
const sourceNormals = graph.geometry.normals;
|
||||
const xCenter = Array.from({ length: sourcePositions.length / 3 }, (_, index) => (
|
||||
sourcePositions[index * 3]
|
||||
)).reduce((total, value) => total + value, 0) / (sourcePositions.length / 3);
|
||||
const positions = new Float32Array(sourcePositions.length);
|
||||
const normals = new Float32Array(sourceNormals.length);
|
||||
for (let index = 0; index < sourcePositions.length; index += 3) {
|
||||
// Turn WoW's Z-up source plane into an upright, camera-facing local XY plane.
|
||||
positions[index] = -sourcePositions[index + 1];
|
||||
positions[index + 1] = sourcePositions[index + 2];
|
||||
positions[index + 2] = sourcePositions[index] - xCenter;
|
||||
normals[index] = -sourceNormals[index + 1];
|
||||
normals[index + 1] = sourceNormals[index + 2];
|
||||
normals[index + 2] = sourceNormals[index];
|
||||
}
|
||||
next.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
next.setAttribute("normal", new BufferAttribute(normals, 3));
|
||||
next.setAttribute("uv", new BufferAttribute(new Float32Array(graph.geometry.uvs), 2));
|
||||
next.setIndex([...graph.geometry.indices]);
|
||||
next.clearGroups();
|
||||
for (const [index, layer] of graph.layers.entries()) {
|
||||
next.addGroup(layer.indexStart, layer.indexCount, index);
|
||||
}
|
||||
next.computeBoundingSphere();
|
||||
return next;
|
||||
}, [graph]);
|
||||
const materials = useMemo(() => graph.layers.map((layer) => new MeshBasicMaterial({
|
||||
name: `wow-m2-static-layer-${layer.submeshIndex}`,
|
||||
map: textures.get(layer.textureIndex) ?? null,
|
||||
blending: meshBlendMode(layer.blendMode),
|
||||
transparent: layer.blendMode !== 0,
|
||||
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
|
||||
depthWrite: layer.blendMode === 0,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
})), [graph.layers, textures]);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
for (const material of materials) material.dispose();
|
||||
}, [geometry, materials]);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (active && billboardRef.current) billboardRef.current.lookAt(camera.position);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={billboardRef}>
|
||||
<mesh geometry={geometry} material={materials} renderOrder={20} frustumCulled={false} />
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
function LoadedOriginalCombatEffect({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
loaded,
|
||||
onDone,
|
||||
}: OriginalCombatEffectProps & { readonly loaded: readonly LoadedOriginalGraph[] }) {
|
||||
const elapsedRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const particleBudgets = useMemo(() => {
|
||||
const emitters = loaded.flatMap((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
return graph.kind === "wow-m2-static-mesh"
|
||||
? []
|
||||
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
|
||||
key: `${loadedIndex}:${particleIndex}`,
|
||||
requested: originalEmitterParticleBudget(graph.particles[particleIndex], quality),
|
||||
}));
|
||||
});
|
||||
const requestedTotal = emitters.reduce((total, emitter) => total + emitter.requested, 0);
|
||||
const limit = ORIGINAL_EFFECT_SLOT_LIMIT[quality];
|
||||
if (requestedTotal <= limit) return new Map(emitters.map((emitter) => [emitter.key, emitter.requested]));
|
||||
const scale = limit / requestedTotal;
|
||||
let remaining = limit;
|
||||
return new Map(emitters.map((emitter) => {
|
||||
const budget = Math.min(
|
||||
remaining,
|
||||
emitter.requested > 0 ? Math.max(1, Math.floor(emitter.requested * scale)) : 0,
|
||||
);
|
||||
remaining -= budget;
|
||||
return [emitter.key, budget];
|
||||
}));
|
||||
}, [loaded, quality]);
|
||||
const durationMs = useMemo(() => {
|
||||
const longestGraph = loaded.reduce((maximum, item) => (
|
||||
Math.max(
|
||||
maximum,
|
||||
item.graph.kind === "wow-m2-static-mesh"
|
||||
? 6_000
|
||||
: graphDurationMs(item.graph) + graphTailMs(item.graph),
|
||||
)
|
||||
), 0);
|
||||
if (event.phase === "cast-start") return Math.max(120, event.durationMs ?? longestGraph);
|
||||
return Math.max(120, Math.min(6_000, longestGraph));
|
||||
}, [event.durationMs, event.phase, loaded]);
|
||||
const projectile = event.phase === "release"
|
||||
&& event.delivery === "projectile"
|
||||
&& event.targetPosition !== undefined;
|
||||
const projectileDurationMs = projectile && event.targetPosition
|
||||
? Math.max(180, Math.min(550, Math.hypot(
|
||||
event.targetPosition[0] - event.origin[0],
|
||||
event.targetPosition[1] - event.origin[1],
|
||||
event.targetPosition[2] - event.origin[2],
|
||||
) / 28 * 1_000))
|
||||
: 0;
|
||||
const anchor = event.phase === "cast-start" || projectile
|
||||
? event.origin
|
||||
: event.targetPosition ?? event.origin;
|
||||
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current) return;
|
||||
elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000));
|
||||
if (groupRef.current) {
|
||||
if (projectile && event.targetPosition) {
|
||||
const progress = Math.min(1, elapsedRef.current / projectileDurationMs);
|
||||
groupRef.current.position.set(
|
||||
event.origin[0] + (event.targetPosition[0] - event.origin[0]) * progress,
|
||||
event.origin[1] + (event.targetPosition[1] - event.origin[1]) * progress,
|
||||
event.origin[2] + (event.targetPosition[2] - event.origin[2]) * progress,
|
||||
);
|
||||
} else {
|
||||
const actorId = event.phase === "cast-start" ? event.sourceActorId : event.targetActorId;
|
||||
const live = liveActorPosition(actorId);
|
||||
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
|
||||
}
|
||||
}
|
||||
const terminalDuration = projectile
|
||||
? projectileDurationMs
|
||||
: event.phase === "aura-start"
|
||||
? auraDurationMs(event)
|
||||
: durationMs;
|
||||
if (event.phase === "aura-start"
|
||||
&& elapsedRef.current > 350
|
||||
&& !auraStillActive(event)) {
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (elapsedRef.current < terminalDuration) return;
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={anchor}>
|
||||
{loaded.map((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
return (
|
||||
<group
|
||||
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
|
||||
position={[0, placementHeight(item.asset.model, event), 0]}
|
||||
scale={effectScale(item.asset)}
|
||||
>
|
||||
{graph.kind === "wow-m2-static-mesh" ? (
|
||||
<OriginalStaticMesh graph={graph} textures={item.textures} active={active} />
|
||||
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
|
||||
const particle = graph.particles[particleIndex];
|
||||
const texture = item.textures.get(particle?.textureIndex ?? -1);
|
||||
return particle && texture ? (
|
||||
<OriginalParticleEmitter
|
||||
key={`${particleIndex}:${particle.id}:${particle.textureIndex}`}
|
||||
graph={graph}
|
||||
particle={particle}
|
||||
texture={texture}
|
||||
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
|
||||
active={active}
|
||||
eventId={event.id}
|
||||
emitterIndex={loadedIndex * 4_096 + particleIndex}
|
||||
loopTimeline={loopTimeline}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
onDone,
|
||||
}: OriginalCombatEffectProps) {
|
||||
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
|
||||
const onDoneRef = useRef(onDone);
|
||||
const terminalRef = useRef(false);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
terminalRef.current = false;
|
||||
setLoaded(null);
|
||||
void preloadCombatEffectManifest().then(async () => {
|
||||
const assets = combatOriginalPhaseAssets(event);
|
||||
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
|
||||
if (cancelled) return;
|
||||
const ready = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (import.meta.env.DEV && assets.length > ready.length) {
|
||||
const failures = settled.flatMap((result) => (
|
||||
result.status === "rejected"
|
||||
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
|
||||
: []
|
||||
));
|
||||
console.warn(
|
||||
`Original spell effect failed to load for ${event.name} (${event.phase}).`,
|
||||
failures,
|
||||
);
|
||||
}
|
||||
if (ready.length) {
|
||||
setLoaded(ready);
|
||||
return;
|
||||
}
|
||||
if (!terminalRef.current) {
|
||||
terminalRef.current = true;
|
||||
onDoneRef.current();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event]);
|
||||
|
||||
return loaded ? (
|
||||
<LoadedOriginalCombatEffect
|
||||
event={event}
|
||||
quality={quality}
|
||||
active={active}
|
||||
loaded={loaded}
|
||||
onDone={onDone}
|
||||
/>
|
||||
) : null;
|
||||
}, (previous, next) => (
|
||||
previous.event === next.event
|
||||
&& previous.quality === next.quality
|
||||
&& previous.active === next.active
|
||||
));
|
||||
@@ -0,0 +1,287 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ORIGINAL_PARTICLE_LIMIT_PER_EMITTER,
|
||||
interpolateOriginalLifetime,
|
||||
interpolateOriginalScalar,
|
||||
isOriginalCombatEffectGraph,
|
||||
originalAuraMatchesPresentationEvent,
|
||||
originalEmitterParticleBudget,
|
||||
parseOriginalCombatEffectGraph,
|
||||
selectCombatEffectRender,
|
||||
} from "./originalCombatEffectRuntime";
|
||||
|
||||
const shieldPresentationEvent = {
|
||||
id: 1,
|
||||
sessionId: 1,
|
||||
occurredAt: 0,
|
||||
phase: "aura-start" as const,
|
||||
actorGroup: "player" as const,
|
||||
sourceActorId: "player",
|
||||
targetActorId: "party-tank",
|
||||
abilityId: "wow335-priest-power-word-shield",
|
||||
sourceSpellId: 17,
|
||||
name: "Power Word: Shield",
|
||||
source: "wow335" as const,
|
||||
school: "holy" as const,
|
||||
delivery: "aura" as const,
|
||||
origin: [0, 0, 0] as const,
|
||||
};
|
||||
|
||||
const zeroTimeSamples = [{ timeMs: 0, value: 0 }];
|
||||
const particleSamples = {
|
||||
emissionSpeed: zeroTimeSamples,
|
||||
speedVariation: zeroTimeSamples,
|
||||
verticalRange: zeroTimeSamples,
|
||||
horizontalRange: zeroTimeSamples,
|
||||
gravity: zeroTimeSamples,
|
||||
lifespan: [{ timeMs: 0, value: 1 }],
|
||||
emissionRate: [{ timeMs: 0, value: 0 }, { timeMs: 500, value: 150 }],
|
||||
emissionAreaLength: zeroTimeSamples,
|
||||
emissionAreaWidth: zeroTimeSamples,
|
||||
zSource: zeroTimeSamples,
|
||||
color: [
|
||||
{ timestamp: 0, normalizedTime: 0, value: [1, 0, 0] },
|
||||
{ timestamp: 32_767, normalizedTime: 1, value: [0, 0, 1] },
|
||||
],
|
||||
alpha: [
|
||||
{ timestamp: 0, normalizedTime: 0, value: 32_767 },
|
||||
{ timestamp: 32_767, normalizedTime: 1, value: 0 },
|
||||
],
|
||||
scale: [
|
||||
{ timestamp: 0, normalizedTime: 0, value: [1, 1] },
|
||||
{ timestamp: 32_767, normalizedTime: 1, value: [2, 2] },
|
||||
],
|
||||
headCell: [{ timestamp: 0, normalizedTime: 0, value: 3 }],
|
||||
tailCell: [],
|
||||
enabled: [{ timeMs: 0, value: 1 }],
|
||||
};
|
||||
|
||||
const trackMetadata = {
|
||||
emissionSpeed: 0,
|
||||
speedVariation: 0,
|
||||
verticalRange: 0,
|
||||
horizontalRange: 0,
|
||||
gravity: 0,
|
||||
lifespan: 0,
|
||||
emissionRate: 1,
|
||||
emissionAreaLength: 0,
|
||||
emissionAreaWidth: 0,
|
||||
zSource: 0,
|
||||
enabled: 0,
|
||||
};
|
||||
|
||||
function graphFixture(): Record<string, unknown> {
|
||||
const particle = {
|
||||
id: 7,
|
||||
flags: 0,
|
||||
position: [0, 1, 2],
|
||||
parentBone: 0,
|
||||
textureIndex: 0,
|
||||
geometryModel: "",
|
||||
recursionModel: "",
|
||||
blendMode: 4,
|
||||
emitterType: 1,
|
||||
particleColorIndex: 0,
|
||||
particleType: 0,
|
||||
headOrTail: 0,
|
||||
textureTileRotation: 2,
|
||||
textureRows: 4,
|
||||
textureColumns: 8,
|
||||
lifespanVariation: 0,
|
||||
emissionRateVariation: 0,
|
||||
scaleVariation: [0, 0],
|
||||
tailLength: 0,
|
||||
interpolation: trackMetadata,
|
||||
globalSequence: Object.fromEntries(Object.keys(trackMetadata).map((key) => [key, -1])),
|
||||
samples: particleSamples,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "wow-m2-particle-effect",
|
||||
source: "wow335",
|
||||
sourceModel: "Spells/HolySmiteImpact.mdx",
|
||||
normalizedSourceModel: "Spells/HolySmiteImpact.m2",
|
||||
sourceSha256: "a".repeat(64),
|
||||
durationMs: 1_000,
|
||||
animations: [{ index: 0, id: 0, variationIndex: 0, durationMs: 1_000, moveSpeed: 0, flags: 0 }],
|
||||
textures: [
|
||||
{
|
||||
type: 0,
|
||||
flags: 0,
|
||||
filename: "Spells\\HolySmite.blp",
|
||||
sourcePath: "Spells/HolySmite.BLP",
|
||||
sourceSha256: "b".repeat(64),
|
||||
url: "/assets/game/combat-effects/original/wow335/priest/textures/holy-smite.png",
|
||||
},
|
||||
{
|
||||
type: 0,
|
||||
flags: 0,
|
||||
filename: "Spells\\GLOWSTAR.blp",
|
||||
sourcePath: "Spells/GLOWSTAR.BLP",
|
||||
},
|
||||
],
|
||||
particles: [particle, { ...particle, id: 8, textureIndex: 1 }],
|
||||
runtime: {
|
||||
renderer: "wow-m2-particles-v1",
|
||||
readyParticleIndices: [0],
|
||||
unsupportedRibbonCount: 1,
|
||||
unsupportedGeometryVertexCount: 12,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneFixture(): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(graphFixture())) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("original combat-effect graph contract", () => {
|
||||
it("keeps an original aura visual attached to its active ranked WoW aura", () => {
|
||||
const activeShield = {
|
||||
targetId: "party-tank",
|
||||
definition: {
|
||||
id: "wow335:17:effect:0:aura:69",
|
||||
name: "Power Word: Shield (DBC 17, Aura 69)",
|
||||
},
|
||||
};
|
||||
expect(originalAuraMatchesPresentationEvent(activeShield, shieldPresentationEvent)).toBe(true);
|
||||
expect(originalAuraMatchesPresentationEvent(
|
||||
{ ...activeShield, targetId: "other-party-member" },
|
||||
shieldPresentationEvent,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the shipped Holy Smite graph without contract drift", () => {
|
||||
const graphUrl = new URL(
|
||||
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holysmite-low-chest-5e638a90a1be.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const graph = parseOriginalCombatEffectGraph(JSON.parse(readFileSync(graphUrl, "utf8")));
|
||||
|
||||
expect(graph.normalizedSourceModel).toMatch(/\.m2$/i);
|
||||
expect(graph.runtime.renderer).toBe("wow-m2-particles-v1");
|
||||
expect(graph.runtime.readyParticleIndices.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses the shipped Lesser Heal cast and impact graphs", () => {
|
||||
const graphNames = [
|
||||
"holy-precast-med-hand-3001a24bb669.json",
|
||||
"lesserheal-base-a2863103c7d8.json",
|
||||
];
|
||||
const graphs = graphNames.map((name) => parseOriginalCombatEffectGraph(JSON.parse(readFileSync(
|
||||
new URL(`../../public/assets/game/combat-effects/original/wow335/priest/graphs/${name}`, import.meta.url),
|
||||
"utf8",
|
||||
))));
|
||||
|
||||
expect(graphs.map((graph) => graph.runtime.readyParticleIndices.length)).toEqual([1, 5]);
|
||||
expect(graphs.flatMap((graph) => graph.textures).filter((texture) => texture.url).length)
|
||||
.toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("strictly validates the packaged decoder output and derives renderer conveniences", () => {
|
||||
const graph = parseOriginalCombatEffectGraph(graphFixture());
|
||||
const particle = graph.particles[0];
|
||||
|
||||
expect(graph).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
kind: "wow-m2-particle-effect",
|
||||
source: "wow335",
|
||||
normalizedSourceModel: "Spells/HolySmiteImpact.m2",
|
||||
runtime: {
|
||||
renderer: "wow-m2-particles-v1",
|
||||
readyParticleIndices: [0],
|
||||
},
|
||||
});
|
||||
expect(particle).toMatchObject({
|
||||
textureIndex: 0,
|
||||
blend: "add",
|
||||
emitter: "plane",
|
||||
atlas: { rows: 4, columns: 8, rotation: 2 },
|
||||
});
|
||||
expect(particle.samples.emissionRate).toEqual([
|
||||
{ timeMs: 0, value: 0 },
|
||||
{ timeMs: 500, value: 150 },
|
||||
]);
|
||||
expect(graph.textures[1]).toEqual({
|
||||
type: 0,
|
||||
flags: 0,
|
||||
filename: "Spells\\GLOWSTAR.blp",
|
||||
sourcePath: "Spells/GLOWSTAR.BLP",
|
||||
});
|
||||
expect(isOriginalCombatEffectGraph(graphFixture())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong schemas, unknown fields, invalid references, and malformed samples", () => {
|
||||
expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), source: "runewaker" }))
|
||||
.toThrow(/source/);
|
||||
expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), fallback: "generated" }))
|
||||
.toThrow(/not part of schema/);
|
||||
|
||||
const badReference = cloneFixture();
|
||||
(badReference.particles as Array<Record<string, unknown>>)[0].textureIndex = 2;
|
||||
expect(() => parseOriginalCombatEffectGraph(badReference)).toThrow(/matching texture/);
|
||||
|
||||
const badSample = cloneFixture();
|
||||
const samples = (badSample.particles as Array<Record<string, unknown>>)[0].samples as Record<string, unknown>;
|
||||
(samples.emissionRate as Array<Record<string, unknown>>)[1].value = "150";
|
||||
expect(() => parseOriginalCombatEffectGraph(badSample)).toThrow(/emissionRate\[1\]\.value/);
|
||||
expect(isOriginalCombatEffectGraph(badSample)).toBe(false);
|
||||
|
||||
const badRuntime = cloneFixture();
|
||||
(badRuntime.runtime as Record<string, unknown>).readyParticleIndices = [1];
|
||||
expect(() => parseOriginalCombatEffectGraph(badRuntime)).toThrow(/unpackaged texture/);
|
||||
});
|
||||
|
||||
it("linearly samples animated scalars and scalar or vector lifetime keys", () => {
|
||||
expect(interpolateOriginalScalar([
|
||||
{ timeMs: 100, value: 4 },
|
||||
{ timeMs: 300, value: 12 },
|
||||
], 200)).toBe(8);
|
||||
expect(interpolateOriginalScalar([{ timeMs: 100, value: 4 }], 1_000)).toBe(4);
|
||||
expect(interpolateOriginalScalar([], 100)).toBeNull();
|
||||
expect(interpolateOriginalScalar([
|
||||
{ timeMs: 0, value: 0 },
|
||||
{ timeMs: 500, value: 10 },
|
||||
], 250, 0)).toBe(0);
|
||||
|
||||
expect(interpolateOriginalLifetime([
|
||||
{ normalizedTime: 0, value: 1 },
|
||||
{ normalizedTime: 1, value: 3 },
|
||||
], 0.25)).toBe(1.5);
|
||||
expect(interpolateOriginalLifetime([
|
||||
{ normalizedTime: 0, value: [1, 2, 3] as const },
|
||||
{ normalizedTime: 1, value: [3, 4, 5] as const },
|
||||
], 0.5)).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("uses the peak animated emission rate for deterministic per-emitter budgets", () => {
|
||||
const particle = parseOriginalCombatEffectGraph(graphFixture()).particles[0];
|
||||
|
||||
expect(particle.samples.emissionRate[0].value).toBe(0);
|
||||
expect(originalEmitterParticleBudget(particle, "low"))
|
||||
.toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.low);
|
||||
expect(originalEmitterParticleBudget(particle, "high"))
|
||||
.toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.high);
|
||||
expect(originalEmitterParticleBudget(particle, "off")).toBe(0);
|
||||
expect(originalEmitterParticleBudget(particle, "high"))
|
||||
.toBe(originalEmitterParticleBudget(particle, "high"));
|
||||
|
||||
const inactive = {
|
||||
samples: { ...particle.samples, emissionRate: [{ timeMs: 0, value: 0 }] },
|
||||
};
|
||||
expect(originalEmitterParticleBudget(inactive, "high")).toBe(0);
|
||||
});
|
||||
|
||||
it("never substitutes generated rendering when Original has no graph asset", () => {
|
||||
expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" });
|
||||
expect(selectCombatEffectRender("original", [
|
||||
{},
|
||||
{ effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json" },
|
||||
])).toEqual({
|
||||
kind: "original",
|
||||
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json",
|
||||
});
|
||||
expect(selectCombatEffectRender("original", undefined)).toBeNull();
|
||||
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,684 @@
|
||||
import type {
|
||||
CombatPresentationEvent,
|
||||
CombatVfxMode,
|
||||
CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
|
||||
export interface OriginalCombatAuraIdentity {
|
||||
readonly targetId: string;
|
||||
readonly definition: {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function originalAuraMatchesPresentationEvent(
|
||||
aura: OriginalCombatAuraIdentity,
|
||||
event: CombatPresentationEvent,
|
||||
): boolean {
|
||||
if (aura.targetId !== event.targetActorId) return false;
|
||||
if (event.sourceSpellId !== undefined
|
||||
&& aura.definition.id.startsWith(`wow335:${event.sourceSpellId}:`)) return true;
|
||||
return aura.definition.id.includes(event.abilityId)
|
||||
|| aura.definition.name === event.name
|
||||
|| aura.definition.name.startsWith(`${event.name} (`);
|
||||
}
|
||||
|
||||
export type OriginalCombatEffectVector2 = readonly [number, number];
|
||||
export type OriginalCombatEffectVector3 = readonly [number, number, number];
|
||||
|
||||
export interface OriginalCombatEffectTimeSample<T> {
|
||||
readonly timeMs: number;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectLifetimeSample<T> {
|
||||
readonly normalizedTime: number;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export type OriginalCombatEffectBlend =
|
||||
| "opaque"
|
||||
| "alpha-key"
|
||||
| "alpha"
|
||||
| "no-alpha-add"
|
||||
| "add"
|
||||
| "mod"
|
||||
| "mod-2x"
|
||||
| "blend-add";
|
||||
|
||||
export type OriginalCombatEffectEmitter = "none" | "plane" | "sphere" | "spline" | "bone";
|
||||
|
||||
export interface OriginalCombatEffectAtlas {
|
||||
readonly rows: number;
|
||||
readonly columns: number;
|
||||
readonly rotation: number;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectTexture {
|
||||
readonly type: number;
|
||||
readonly flags: number;
|
||||
readonly filename: string;
|
||||
readonly sourcePath?: string;
|
||||
readonly sourceSha256?: string;
|
||||
readonly url?: string;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectAnimation {
|
||||
readonly index: number;
|
||||
readonly id: number;
|
||||
readonly variationIndex: number;
|
||||
readonly durationMs: number;
|
||||
readonly moveSpeed: number;
|
||||
readonly flags: number;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectParticleSamples {
|
||||
readonly emissionSpeed: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly speedVariation: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly verticalRange: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly horizontalRange: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly gravity: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly lifespan: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly emissionRate: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly emissionAreaLength: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly emissionAreaWidth: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly zSource: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
readonly color: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[];
|
||||
readonly alpha: readonly OriginalCombatEffectLifetimeSample<number>[];
|
||||
readonly scale: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[];
|
||||
readonly headCell: readonly OriginalCombatEffectLifetimeSample<number>[];
|
||||
readonly tailCell: readonly OriginalCombatEffectLifetimeSample<number>[];
|
||||
readonly enabled: readonly OriginalCombatEffectTimeSample<number>[];
|
||||
}
|
||||
|
||||
export type OriginalCombatEffectInterpolationMode = 0 | 1 | 2 | 3;
|
||||
export type OriginalCombatEffectAnimatedTrackName =
|
||||
| "emissionSpeed"
|
||||
| "speedVariation"
|
||||
| "verticalRange"
|
||||
| "horizontalRange"
|
||||
| "gravity"
|
||||
| "lifespan"
|
||||
| "emissionRate"
|
||||
| "emissionAreaLength"
|
||||
| "emissionAreaWidth"
|
||||
| "zSource"
|
||||
| "enabled";
|
||||
|
||||
export interface OriginalCombatEffectParticle {
|
||||
readonly id: number;
|
||||
readonly flags: number;
|
||||
readonly position: OriginalCombatEffectVector3;
|
||||
readonly parentBone: number;
|
||||
readonly textureIndex: number;
|
||||
readonly geometryModel: string;
|
||||
readonly recursionModel: string;
|
||||
readonly blendMode: number;
|
||||
readonly emitterType: number;
|
||||
readonly particleColorIndex: number;
|
||||
readonly particleType: number;
|
||||
readonly headOrTail: number;
|
||||
readonly textureTileRotation: number;
|
||||
readonly textureRows: number;
|
||||
readonly textureColumns: number;
|
||||
readonly lifespanVariation: number;
|
||||
readonly emissionRateVariation: number;
|
||||
readonly scaleVariation: OriginalCombatEffectVector2;
|
||||
readonly tailLength: number;
|
||||
readonly interpolation: Readonly<Record<
|
||||
OriginalCombatEffectAnimatedTrackName,
|
||||
OriginalCombatEffectInterpolationMode
|
||||
>>;
|
||||
readonly globalSequence: Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>>;
|
||||
readonly samples: OriginalCombatEffectParticleSamples;
|
||||
readonly blend: OriginalCombatEffectBlend;
|
||||
readonly emitter: OriginalCombatEffectEmitter;
|
||||
readonly atlas: OriginalCombatEffectAtlas;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectRuntimeMetadata {
|
||||
readonly renderer: "wow-m2-particles-v1";
|
||||
readonly readyParticleIndices: readonly number[];
|
||||
readonly unsupportedRibbonCount: number;
|
||||
readonly unsupportedGeometryVertexCount: number;
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectGraph {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "wow-m2-particle-effect";
|
||||
readonly source: "wow335";
|
||||
readonly sourceModel: string;
|
||||
readonly normalizedSourceModel: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly durationMs: number;
|
||||
readonly animations: readonly OriginalCombatEffectAnimation[];
|
||||
readonly textures: readonly OriginalCombatEffectTexture[];
|
||||
readonly particles: readonly OriginalCombatEffectParticle[];
|
||||
readonly runtime: OriginalCombatEffectRuntimeMetadata;
|
||||
}
|
||||
|
||||
const PARTICLE_TIME_SAMPLE_KEYS = [
|
||||
"emissionSpeed",
|
||||
"speedVariation",
|
||||
"verticalRange",
|
||||
"horizontalRange",
|
||||
"gravity",
|
||||
"lifespan",
|
||||
"emissionRate",
|
||||
"emissionAreaLength",
|
||||
"emissionAreaWidth",
|
||||
"zSource",
|
||||
] as const;
|
||||
|
||||
const PARTICLE_LIFETIME_SAMPLE_KEYS = ["color", "alpha", "scale"] as const;
|
||||
const PARTICLE_ANIMATED_TRACK_KEYS = [...PARTICLE_TIME_SAMPLE_KEYS, "enabled"] as const;
|
||||
|
||||
const BLEND_BY_CODE: readonly OriginalCombatEffectBlend[] = [
|
||||
"opaque",
|
||||
"alpha-key",
|
||||
"alpha",
|
||||
"no-alpha-add",
|
||||
"add",
|
||||
"mod",
|
||||
"mod-2x",
|
||||
"blend-add",
|
||||
];
|
||||
|
||||
const EMITTER_BY_CODE: readonly OriginalCombatEffectEmitter[] = [
|
||||
"none",
|
||||
"plane",
|
||||
"sphere",
|
||||
"spline",
|
||||
"bone",
|
||||
];
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type InterpolatedValue = number | OriginalCombatEffectVector2 | OriginalCombatEffectVector3;
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
label: string,
|
||||
requiredKeys: readonly string[],
|
||||
optionalKeys: readonly string[] = [],
|
||||
): JsonRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be an object.`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${label} must be a plain JSON object.`);
|
||||
}
|
||||
const record = value as JsonRecord;
|
||||
const allowed = new Set([...requiredKeys, ...optionalKeys]);
|
||||
for (const key of requiredKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
||||
throw new TypeError(`${label}.${key} is required.`);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) throw new TypeError(`${label}.${key} is not part of schema version 1.`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new TypeError(`${label} must be a finite number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerInRange(value: unknown, label: string, minimum: number, maximum: number): number {
|
||||
const parsed = finiteNumber(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new TypeError(`${label} must be an integer from ${minimum} through ${maximum}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function literalString<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string, allowEmpty = true): string {
|
||||
if (typeof value !== "string" || (!allowEmpty && value.trim().length === 0)) {
|
||||
throw new TypeError(`${label} must be ${allowEmpty ? "a string" : "a non-empty string"}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const digest = stringValue(value, label, false);
|
||||
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
||||
throw new TypeError(`${label} must be a lowercase SHA-256 digest.`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function vector2(value: unknown, label: string): OriginalCombatEffectVector2 {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new TypeError(`${label} must contain exactly two numbers.`);
|
||||
}
|
||||
return [finiteNumber(value[0], `${label}[0]`), finiteNumber(value[1], `${label}[1]`)];
|
||||
}
|
||||
|
||||
function vector3(value: unknown, label: string): OriginalCombatEffectVector3 {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
throw new TypeError(`${label} must contain exactly three numbers.`);
|
||||
}
|
||||
return [
|
||||
finiteNumber(value[0], `${label}[0]`),
|
||||
finiteNumber(value[1], `${label}[1]`),
|
||||
finiteNumber(value[2], `${label}[2]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseTimeSamples(value: unknown, label: string): readonly OriginalCombatEffectTimeSample<number>[] {
|
||||
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
||||
let previousTime = -Infinity;
|
||||
return value.map((entry, index) => {
|
||||
const sampleLabel = `${label}[${index}]`;
|
||||
const sample = exactObject(entry, sampleLabel, ["timeMs", "value"]);
|
||||
const timeMs = finiteNumber(sample.timeMs, `${sampleLabel}.timeMs`);
|
||||
if (timeMs < 0 || timeMs < previousTime) {
|
||||
throw new TypeError(`${sampleLabel}.timeMs must be non-negative and ordered.`);
|
||||
}
|
||||
previousTime = timeMs;
|
||||
return { timeMs, value: finiteNumber(sample.value, `${sampleLabel}.value`) };
|
||||
});
|
||||
}
|
||||
|
||||
function parseLifetimeSamples<T extends InterpolatedValue>(
|
||||
value: unknown,
|
||||
label: string,
|
||||
parseValue: (raw: unknown, valueLabel: string) => T,
|
||||
): readonly OriginalCombatEffectLifetimeSample<T>[] {
|
||||
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
||||
let previousTime = -Infinity;
|
||||
return value.map((entry, index) => {
|
||||
const sampleLabel = `${label}[${index}]`;
|
||||
const sample = exactObject(entry, sampleLabel, ["normalizedTime", "value"], ["timestamp"]);
|
||||
const normalizedTime = finiteNumber(sample.normalizedTime, `${sampleLabel}.normalizedTime`);
|
||||
if (normalizedTime < 0 || normalizedTime > 1 || normalizedTime < previousTime) {
|
||||
throw new TypeError(`${sampleLabel}.normalizedTime must be ordered from zero through one.`);
|
||||
}
|
||||
previousTime = normalizedTime;
|
||||
if (sample.timestamp !== undefined) {
|
||||
const timestamp = integerInRange(sample.timestamp, `${sampleLabel}.timestamp`, 0, 32_767);
|
||||
if (Math.abs(normalizedTime - timestamp / 32_767) > 1e-9) {
|
||||
throw new TypeError(`${sampleLabel}.normalizedTime does not match its timestamp.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
normalizedTime,
|
||||
value: parseValue(sample.value, `${sampleLabel}.value`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseAnimation(value: unknown, index: number): OriginalCombatEffectAnimation {
|
||||
const label = `effect graph.animations[${index}]`;
|
||||
const record = exactObject(value, label, ["index", "id", "variationIndex", "durationMs", "moveSpeed", "flags"]);
|
||||
return {
|
||||
index: integerInRange(record.index, `${label}.index`, 0, 0xffff_ffff),
|
||||
id: integerInRange(record.id, `${label}.id`, 0, 0xffff),
|
||||
variationIndex: integerInRange(record.variationIndex, `${label}.variationIndex`, 0, 0xffff),
|
||||
durationMs: integerInRange(record.durationMs, `${label}.durationMs`, 0, 0xffff_ffff),
|
||||
moveSpeed: finiteNumber(record.moveSpeed, `${label}.moveSpeed`),
|
||||
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTexture(value: unknown, index: number): OriginalCombatEffectTexture {
|
||||
const label = `effect graph.textures[${index}]`;
|
||||
const record = exactObject(
|
||||
value,
|
||||
label,
|
||||
["type", "flags", "filename"],
|
||||
["sourcePath", "sourceSha256", "url"],
|
||||
);
|
||||
const texture: OriginalCombatEffectTexture = {
|
||||
type: integerInRange(record.type, `${label}.type`, 0, 0xffff_ffff),
|
||||
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
||||
filename: stringValue(record.filename, `${label}.filename`),
|
||||
};
|
||||
const hasSourcePath = record.sourcePath !== undefined;
|
||||
const hasDigest = record.sourceSha256 !== undefined;
|
||||
const hasUrl = record.url !== undefined;
|
||||
if (hasDigest !== hasUrl || ((hasDigest || hasUrl) && !hasSourcePath)) {
|
||||
throw new TypeError(`${label} packaged texture metadata must be complete.`);
|
||||
}
|
||||
if (!hasSourcePath) return texture;
|
||||
const locatedTexture: OriginalCombatEffectTexture = {
|
||||
...texture,
|
||||
sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`, false),
|
||||
};
|
||||
if (!hasDigest) return locatedTexture;
|
||||
return {
|
||||
...locatedTexture,
|
||||
sourceSha256: sha256(record.sourceSha256, `${label}.sourceSha256`),
|
||||
url: stringValue(record.url, `${label}.url`, false),
|
||||
};
|
||||
}
|
||||
|
||||
function parseParticleSamples(value: unknown, label: string): OriginalCombatEffectParticleSamples {
|
||||
const record = exactObject(value, label, [
|
||||
...PARTICLE_TIME_SAMPLE_KEYS,
|
||||
...PARTICLE_LIFETIME_SAMPLE_KEYS,
|
||||
"headCell",
|
||||
"tailCell",
|
||||
"enabled",
|
||||
]);
|
||||
const timeSamples = Object.fromEntries(PARTICLE_TIME_SAMPLE_KEYS.map((key) => [
|
||||
key,
|
||||
parseTimeSamples(record[key], `${label}.${key}`),
|
||||
])) as unknown as Pick<OriginalCombatEffectParticleSamples, typeof PARTICLE_TIME_SAMPLE_KEYS[number]>;
|
||||
return {
|
||||
...timeSamples,
|
||||
color: parseLifetimeSamples(record.color, `${label}.color`, vector3),
|
||||
alpha: parseLifetimeSamples(record.alpha, `${label}.alpha`, finiteNumber),
|
||||
scale: parseLifetimeSamples(record.scale, `${label}.scale`, vector2),
|
||||
headCell: parseLifetimeSamples(record.headCell, `${label}.headCell`, finiteNumber),
|
||||
tailCell: parseLifetimeSamples(record.tailCell, `${label}.tailCell`, finiteNumber),
|
||||
enabled: parseTimeSamples(record.enabled, `${label}.enabled`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseParticleTrackMetadata(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>> {
|
||||
const record = exactObject(value, label, PARTICLE_ANIMATED_TRACK_KEYS);
|
||||
return Object.fromEntries(PARTICLE_ANIMATED_TRACK_KEYS.map((key) => [
|
||||
key,
|
||||
integerInRange(record[key], `${label}.${key}`, minimum, maximum),
|
||||
])) as Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>>;
|
||||
}
|
||||
|
||||
function parseParticle(
|
||||
value: unknown,
|
||||
index: number,
|
||||
textureCount: number,
|
||||
): OriginalCombatEffectParticle {
|
||||
const label = `effect graph.particles[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
"id",
|
||||
"flags",
|
||||
"position",
|
||||
"parentBone",
|
||||
"textureIndex",
|
||||
"geometryModel",
|
||||
"recursionModel",
|
||||
"blendMode",
|
||||
"emitterType",
|
||||
"particleColorIndex",
|
||||
"particleType",
|
||||
"headOrTail",
|
||||
"textureTileRotation",
|
||||
"textureRows",
|
||||
"textureColumns",
|
||||
"lifespanVariation",
|
||||
"emissionRateVariation",
|
||||
"scaleVariation",
|
||||
"tailLength",
|
||||
"interpolation",
|
||||
"globalSequence",
|
||||
"samples",
|
||||
]);
|
||||
const textureIndex = integerInRange(record.textureIndex, `${label}.textureIndex`, 0, 0xffff);
|
||||
if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`);
|
||||
const blendMode = integerInRange(record.blendMode, `${label}.blendMode`, 0, BLEND_BY_CODE.length - 1);
|
||||
const emitterType = integerInRange(record.emitterType, `${label}.emitterType`, 0, EMITTER_BY_CODE.length - 1);
|
||||
const textureRows = integerInRange(record.textureRows, `${label}.textureRows`, 1, 0xffff);
|
||||
const textureColumns = integerInRange(record.textureColumns, `${label}.textureColumns`, 1, 0xffff);
|
||||
const textureTileRotation = integerInRange(record.textureTileRotation, `${label}.textureTileRotation`, 0, 0xffff);
|
||||
return {
|
||||
id: integerInRange(record.id, `${label}.id`, 0, 0xffff_ffff),
|
||||
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
||||
position: vector3(record.position, `${label}.position`),
|
||||
parentBone: integerInRange(record.parentBone, `${label}.parentBone`, 0, 0xffff),
|
||||
textureIndex,
|
||||
geometryModel: stringValue(record.geometryModel, `${label}.geometryModel`),
|
||||
recursionModel: stringValue(record.recursionModel, `${label}.recursionModel`),
|
||||
blendMode,
|
||||
emitterType,
|
||||
particleColorIndex: integerInRange(record.particleColorIndex, `${label}.particleColorIndex`, 0, 0xffff),
|
||||
particleType: integerInRange(record.particleType, `${label}.particleType`, 0, 0xff),
|
||||
headOrTail: integerInRange(record.headOrTail, `${label}.headOrTail`, 0, 0xff),
|
||||
textureTileRotation,
|
||||
textureRows,
|
||||
textureColumns,
|
||||
lifespanVariation: finiteNumber(record.lifespanVariation, `${label}.lifespanVariation`),
|
||||
emissionRateVariation: finiteNumber(record.emissionRateVariation, `${label}.emissionRateVariation`),
|
||||
scaleVariation: vector2(record.scaleVariation, `${label}.scaleVariation`),
|
||||
tailLength: finiteNumber(record.tailLength, `${label}.tailLength`),
|
||||
interpolation: parseParticleTrackMetadata(
|
||||
record.interpolation,
|
||||
`${label}.interpolation`,
|
||||
0,
|
||||
3,
|
||||
) as OriginalCombatEffectParticle["interpolation"],
|
||||
globalSequence: parseParticleTrackMetadata(
|
||||
record.globalSequence,
|
||||
`${label}.globalSequence`,
|
||||
-32_768,
|
||||
32_767,
|
||||
),
|
||||
samples: parseParticleSamples(record.samples, `${label}.samples`),
|
||||
blend: BLEND_BY_CODE[blendMode],
|
||||
emitter: EMITTER_BY_CODE[emitterType],
|
||||
atlas: { rows: textureRows, columns: textureColumns, rotation: textureTileRotation },
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntime(
|
||||
value: unknown,
|
||||
particles: readonly OriginalCombatEffectParticle[],
|
||||
textures: readonly OriginalCombatEffectTexture[],
|
||||
): OriginalCombatEffectRuntimeMetadata {
|
||||
const label = "effect graph.runtime";
|
||||
const record = exactObject(value, label, [
|
||||
"renderer",
|
||||
"readyParticleIndices",
|
||||
"unsupportedRibbonCount",
|
||||
"unsupportedGeometryVertexCount",
|
||||
]);
|
||||
literalString(record.renderer, "wow-m2-particles-v1", `${label}.renderer`);
|
||||
if (!Array.isArray(record.readyParticleIndices)) {
|
||||
throw new TypeError(`${label}.readyParticleIndices must be an array.`);
|
||||
}
|
||||
let previousIndex = -1;
|
||||
const readyParticleIndices = record.readyParticleIndices.map((entry, index) => {
|
||||
const particleIndex = integerInRange(entry, `${label}.readyParticleIndices[${index}]`, 0, 0xffff_ffff);
|
||||
if (particleIndex >= particles.length) {
|
||||
throw new TypeError(`${label}.readyParticleIndices[${index}] has no matching particle.`);
|
||||
}
|
||||
if (particleIndex <= previousIndex) {
|
||||
throw new TypeError(`${label}.readyParticleIndices must be unique and ordered.`);
|
||||
}
|
||||
previousIndex = particleIndex;
|
||||
const texture = textures[particles[particleIndex].textureIndex];
|
||||
if (!texture?.url) {
|
||||
throw new TypeError(`${label}.readyParticleIndices[${index}] references an unpackaged texture.`);
|
||||
}
|
||||
return particleIndex;
|
||||
});
|
||||
return {
|
||||
renderer: "wow-m2-particles-v1",
|
||||
readyParticleIndices,
|
||||
unsupportedRibbonCount: integerInRange(
|
||||
record.unsupportedRibbonCount,
|
||||
`${label}.unsupportedRibbonCount`,
|
||||
0,
|
||||
0xffff_ffff,
|
||||
),
|
||||
unsupportedGeometryVertexCount: integerInRange(
|
||||
record.unsupportedGeometryVertexCount,
|
||||
`${label}.unsupportedGeometryVertexCount`,
|
||||
0,
|
||||
0xffff_ffff,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOriginalCombatEffectGraph(value: unknown): OriginalCombatEffectGraph {
|
||||
const graph = exactObject(value, "effect graph", [
|
||||
"schemaVersion",
|
||||
"kind",
|
||||
"source",
|
||||
"sourceModel",
|
||||
"normalizedSourceModel",
|
||||
"sourceSha256",
|
||||
"durationMs",
|
||||
"animations",
|
||||
"textures",
|
||||
"particles",
|
||||
"runtime",
|
||||
]);
|
||||
integerInRange(graph.schemaVersion, "effect graph.schemaVersion", 1, 1);
|
||||
literalString(graph.kind, "wow-m2-particle-effect", "effect graph.kind");
|
||||
literalString(graph.source, "wow335", "effect graph.source");
|
||||
if (!Array.isArray(graph.animations)) throw new TypeError("effect graph.animations must be an array.");
|
||||
if (!Array.isArray(graph.textures)) throw new TypeError("effect graph.textures must be an array.");
|
||||
if (!Array.isArray(graph.particles)) throw new TypeError("effect graph.particles must be an array.");
|
||||
const textures = graph.textures.map(parseTexture);
|
||||
const particles = graph.particles.map((particle, index) => parseParticle(particle, index, textures.length));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "wow-m2-particle-effect",
|
||||
source: "wow335",
|
||||
sourceModel: stringValue(graph.sourceModel, "effect graph.sourceModel", false),
|
||||
normalizedSourceModel: stringValue(
|
||||
graph.normalizedSourceModel,
|
||||
"effect graph.normalizedSourceModel",
|
||||
false,
|
||||
),
|
||||
sourceSha256: sha256(graph.sourceSha256, "effect graph.sourceSha256"),
|
||||
durationMs: integerInRange(graph.durationMs, "effect graph.durationMs", 0, 0xffff_ffff),
|
||||
animations: graph.animations.map(parseAnimation),
|
||||
textures,
|
||||
particles,
|
||||
runtime: parseRuntime(graph.runtime, particles, textures),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOriginalCombatEffectGraph(value: unknown): boolean {
|
||||
try {
|
||||
parseOriginalCombatEffectGraph(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function interpolateOriginalScalar(
|
||||
samples: readonly OriginalCombatEffectTimeSample<number>[],
|
||||
timeMs: number,
|
||||
interpolation: OriginalCombatEffectInterpolationMode = 1,
|
||||
): number | null {
|
||||
if (samples.length === 0) return null;
|
||||
const position = Number.isFinite(timeMs) ? Math.max(0, timeMs) : 0;
|
||||
if (position <= samples[0].timeMs) return samples[0].value;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const upper = samples[index];
|
||||
if (position > upper.timeMs) continue;
|
||||
const lower = samples[index - 1];
|
||||
const span = upper.timeMs - lower.timeMs;
|
||||
if (span <= 0) return upper.value;
|
||||
if (interpolation === 0) return position < upper.timeMs ? lower.value : upper.value;
|
||||
const progress = (position - lower.timeMs) / span;
|
||||
return lower.value + (upper.value - lower.value) * progress;
|
||||
}
|
||||
return samples[samples.length - 1].value;
|
||||
}
|
||||
|
||||
function interpolateValue<T extends InterpolatedValue>(lower: T, upper: T, progress: number): T {
|
||||
if (typeof lower === "number" && typeof upper === "number") {
|
||||
return (lower + (upper - lower) * progress) as T;
|
||||
}
|
||||
if (!Array.isArray(lower) || !Array.isArray(upper) || lower.length !== upper.length) {
|
||||
throw new TypeError("Lifetime sample values must have a consistent shape.");
|
||||
}
|
||||
return lower.map((component, index) => (
|
||||
component + (upper[index] - component) * progress
|
||||
)) as unknown as T;
|
||||
}
|
||||
|
||||
export function interpolateOriginalLifetime(
|
||||
samples: readonly OriginalCombatEffectLifetimeSample<number>[],
|
||||
normalizedTime: number,
|
||||
): number | null;
|
||||
export function interpolateOriginalLifetime(
|
||||
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[],
|
||||
normalizedTime: number,
|
||||
): OriginalCombatEffectVector2 | null;
|
||||
export function interpolateOriginalLifetime(
|
||||
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[],
|
||||
normalizedTime: number,
|
||||
): OriginalCombatEffectVector3 | null;
|
||||
export function interpolateOriginalLifetime(
|
||||
samples: readonly OriginalCombatEffectLifetimeSample<InterpolatedValue>[],
|
||||
normalizedTime: number,
|
||||
): InterpolatedValue | null {
|
||||
if (samples.length === 0) return null;
|
||||
const position = Number.isFinite(normalizedTime) ? Math.min(1, Math.max(0, normalizedTime)) : 0;
|
||||
if (position <= samples[0].normalizedTime) return samples[0].value;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const upper = samples[index];
|
||||
if (position > upper.normalizedTime) continue;
|
||||
const lower = samples[index - 1];
|
||||
const span = upper.normalizedTime - lower.normalizedTime;
|
||||
if (span <= 0) return upper.value;
|
||||
return interpolateValue(lower.value, upper.value, (position - lower.normalizedTime) / span);
|
||||
}
|
||||
return samples[samples.length - 1].value;
|
||||
}
|
||||
|
||||
export const ORIGINAL_PARTICLE_LIMIT_PER_EMITTER: Readonly<
|
||||
Record<Exclude<CombatVfxQuality, "off">, number>
|
||||
> = Object.freeze({ low: 48, high: 128 });
|
||||
|
||||
function maximumPositiveSample(samples: readonly OriginalCombatEffectTimeSample<number>[]): number {
|
||||
return samples.reduce((maximum, sample) => (
|
||||
Number.isFinite(sample.value) && sample.value > maximum ? sample.value : maximum
|
||||
), 0);
|
||||
}
|
||||
|
||||
export function originalEmitterParticleBudget(
|
||||
particle: Pick<OriginalCombatEffectParticle, "samples">,
|
||||
quality: CombatVfxQuality,
|
||||
): number {
|
||||
if (quality === "off") return 0;
|
||||
// Animated emitters commonly begin at zero, so readiness and capacity must
|
||||
// consider the whole track rather than treating the first key as authoritative.
|
||||
const peakEmissionRate = maximumPositiveSample(particle.samples.emissionRate);
|
||||
const peakLifespanSeconds = maximumPositiveSample(particle.samples.lifespan);
|
||||
if (peakEmissionRate <= 0 || peakLifespanSeconds <= 0) return 0;
|
||||
const requested = Math.max(1, Math.ceil(peakEmissionRate * peakLifespanSeconds));
|
||||
return Math.min(requested, ORIGINAL_PARTICLE_LIMIT_PER_EMITTER[quality]);
|
||||
}
|
||||
|
||||
export interface OriginalCombatEffectPhaseAsset {
|
||||
readonly effectGraphUrl?: string;
|
||||
}
|
||||
|
||||
export type OriginalCombatEffectRenderSelection =
|
||||
| { readonly kind: "generated" }
|
||||
| { readonly kind: "original"; readonly effectGraphUrl: string };
|
||||
|
||||
export function selectCombatEffectRender(
|
||||
mode: CombatVfxMode,
|
||||
phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined,
|
||||
): OriginalCombatEffectRenderSelection | null {
|
||||
if (mode === "generated") return { kind: "generated" };
|
||||
const authentic = phaseAssets?.find((asset) => (
|
||||
typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0
|
||||
));
|
||||
return authentic?.effectGraphUrl
|
||||
? { kind: "original", effectGraphUrl: authentic.effectGraphUrl }
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOriginalCombatMeshGraph } from "./originalCombatMeshRuntime";
|
||||
|
||||
const graphUrl = new URL(
|
||||
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holydivineshield-state-base-83b519d78c12.json",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
describe("original combat static-mesh contract", () => {
|
||||
it("parses the shipped Power Word: Shield mesh and its three original layers", () => {
|
||||
const graph = parseOriginalCombatMeshGraph(JSON.parse(readFileSync(graphUrl, "utf8")));
|
||||
|
||||
expect(graph.geometry.positions).toHaveLength(36);
|
||||
expect(graph.geometry.indices).toHaveLength(18);
|
||||
expect(graph.textures).toHaveLength(3);
|
||||
expect(graph.layers.map((layer) => layer.blendMode)).toEqual([4, 4, 4]);
|
||||
expect(graph.runtime.staticFrame).toBe("source-bind-pose");
|
||||
});
|
||||
|
||||
it("rejects geometry and draw references that leave the packaged source", () => {
|
||||
const invalidIndex = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
((invalidIndex.geometry as Record<string, unknown>).indices as number[])[0] = 12;
|
||||
expect(() => parseOriginalCombatMeshGraph(invalidIndex)).toThrow(/exceeds its vertices/);
|
||||
|
||||
const invalidLayer = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
(((invalidLayer.layers as Record<string, unknown>[])[0])).indexCount = 19;
|
||||
expect(() => parseOriginalCombatMeshGraph(invalidLayer)).toThrow(/draw range/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
export interface OriginalCombatMeshTexture {
|
||||
readonly type: number;
|
||||
readonly flags: number;
|
||||
readonly filename: string;
|
||||
readonly sourcePath: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshLayer {
|
||||
readonly submeshIndex: number;
|
||||
readonly indexStart: number;
|
||||
readonly indexCount: number;
|
||||
readonly priorityPlane: number;
|
||||
readonly shaderId: number;
|
||||
readonly colorIndex: number;
|
||||
readonly materialLayer: number;
|
||||
readonly materialFlags: number;
|
||||
readonly blendMode: number;
|
||||
readonly textureIndex: number;
|
||||
readonly textureCoordinateIndex: number;
|
||||
readonly textureWeightIndex: number;
|
||||
readonly textureTransformIndex: number;
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshGraph {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "wow-m2-static-mesh";
|
||||
readonly source: "wow335";
|
||||
readonly sourceModel: string;
|
||||
readonly normalizedSourceModel: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly sourceSkin: string;
|
||||
readonly sourceSkinSha256: string;
|
||||
readonly bounds: {
|
||||
readonly minimum: readonly [number, number, number];
|
||||
readonly maximum: readonly [number, number, number];
|
||||
readonly radius: number;
|
||||
};
|
||||
readonly geometry: {
|
||||
readonly positions: readonly number[];
|
||||
readonly normals: readonly number[];
|
||||
readonly uvs: readonly number[];
|
||||
readonly indices: readonly number[];
|
||||
};
|
||||
readonly textures: readonly OriginalCombatMeshTexture[];
|
||||
readonly layers: readonly OriginalCombatMeshLayer[];
|
||||
readonly runtime: {
|
||||
readonly renderer: "wow-m2-static-mesh-v1";
|
||||
readonly coordinateSpace: "wow-m2-source";
|
||||
readonly staticFrame: "source-bind-pose";
|
||||
};
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function exactObject(value: unknown, label: string, keys: readonly string[]): JsonRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be an object.`);
|
||||
}
|
||||
const record = value as JsonRecord;
|
||||
const expected = new Set(keys);
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) throw new TypeError(`${label}.${key} is required.`);
|
||||
}
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!expected.has(key)) throw new TypeError(`${label}.${key} is not part of the static-mesh schema.`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${label} must be finite.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string, minimum = 0): number {
|
||||
const resolved = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(resolved) || resolved < minimum) throw new TypeError(`${label} must be an integer >= ${minimum}.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${label} must be a non-empty string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
const resolved = stringValue(value, label);
|
||||
if (!/^[0-9a-f]{64}$/.test(resolved)) throw new TypeError(`${label} must be a lowercase SHA-256 digest.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function literal<T extends string | number>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function numberArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
||||
return value.map((entry, index) => numberValue(entry, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function vector3(value: unknown, label: string): readonly [number, number, number] {
|
||||
const resolved = numberArray(value, label);
|
||||
if (resolved.length !== 3) throw new TypeError(`${label} must contain three numbers.`);
|
||||
return [resolved[0], resolved[1], resolved[2]];
|
||||
}
|
||||
|
||||
function parseTexture(value: unknown, index: number): OriginalCombatMeshTexture {
|
||||
const label = `static mesh.textures[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
"type", "flags", "filename", "sourcePath", "sourceSha256", "url",
|
||||
]);
|
||||
return {
|
||||
type: integerValue(record.type, `${label}.type`),
|
||||
flags: integerValue(record.flags, `${label}.flags`),
|
||||
filename: stringValue(record.filename, `${label}.filename`),
|
||||
sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`),
|
||||
sourceSha256: digest(record.sourceSha256, `${label}.sourceSha256`),
|
||||
url: stringValue(record.url, `${label}.url`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLayer(
|
||||
value: unknown,
|
||||
index: number,
|
||||
indices: readonly number[],
|
||||
textureCount: number,
|
||||
): OriginalCombatMeshLayer {
|
||||
const label = `static mesh.layers[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
"submeshIndex", "indexStart", "indexCount", "priorityPlane", "shaderId", "colorIndex",
|
||||
"materialLayer", "materialFlags", "blendMode", "textureIndex", "textureCoordinateIndex",
|
||||
"textureWeightIndex", "textureTransformIndex",
|
||||
]);
|
||||
const indexStart = integerValue(record.indexStart, `${label}.indexStart`);
|
||||
const indexCount = integerValue(record.indexCount, `${label}.indexCount`);
|
||||
if (indexStart + indexCount > indices.length) throw new TypeError(`${label} draw range exceeds geometry indices.`);
|
||||
const textureIndex = integerValue(record.textureIndex, `${label}.textureIndex`);
|
||||
if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`);
|
||||
return {
|
||||
submeshIndex: integerValue(record.submeshIndex, `${label}.submeshIndex`),
|
||||
indexStart,
|
||||
indexCount,
|
||||
priorityPlane: integerValue(record.priorityPlane, `${label}.priorityPlane`, -128),
|
||||
shaderId: integerValue(record.shaderId, `${label}.shaderId`),
|
||||
colorIndex: integerValue(record.colorIndex, `${label}.colorIndex`, -32_768),
|
||||
materialLayer: integerValue(record.materialLayer, `${label}.materialLayer`),
|
||||
materialFlags: integerValue(record.materialFlags, `${label}.materialFlags`),
|
||||
blendMode: integerValue(record.blendMode, `${label}.blendMode`),
|
||||
textureIndex,
|
||||
textureCoordinateIndex: integerValue(record.textureCoordinateIndex, `${label}.textureCoordinateIndex`, -32_768),
|
||||
textureWeightIndex: integerValue(record.textureWeightIndex, `${label}.textureWeightIndex`, -32_768),
|
||||
textureTransformIndex: integerValue(record.textureTransformIndex, `${label}.textureTransformIndex`, -32_768),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMeshGraph {
|
||||
const graph = exactObject(value, "static mesh", [
|
||||
"schemaVersion", "kind", "source", "sourceModel", "normalizedSourceModel", "sourceSha256",
|
||||
"sourceSkin", "sourceSkinSha256", "bounds", "geometry", "textures", "layers", "runtime",
|
||||
]);
|
||||
literal(graph.schemaVersion, 1, "static mesh.schemaVersion");
|
||||
literal(graph.kind, "wow-m2-static-mesh", "static mesh.kind");
|
||||
literal(graph.source, "wow335", "static mesh.source");
|
||||
const bounds = exactObject(graph.bounds, "static mesh.bounds", ["minimum", "maximum", "radius"]);
|
||||
const geometry = exactObject(graph.geometry, "static mesh.geometry", ["positions", "normals", "uvs", "indices"]);
|
||||
const positions = numberArray(geometry.positions, "static mesh.geometry.positions");
|
||||
const normals = numberArray(geometry.normals, "static mesh.geometry.normals");
|
||||
const uvs = numberArray(geometry.uvs, "static mesh.geometry.uvs");
|
||||
if (!positions.length || positions.length % 3 !== 0) throw new TypeError("static mesh positions must contain vertices.");
|
||||
if (normals.length !== positions.length) throw new TypeError("static mesh normals must match positions.");
|
||||
if (uvs.length !== positions.length / 3 * 2) throw new TypeError("static mesh UVs must match positions.");
|
||||
if (!Array.isArray(geometry.indices) || !geometry.indices.length) throw new TypeError("static mesh indices must be populated.");
|
||||
const vertexCount = positions.length / 3;
|
||||
const indices = geometry.indices.map((entry, index) => {
|
||||
const resolved = integerValue(entry, `static mesh.geometry.indices[${index}]`);
|
||||
if (resolved >= vertexCount) throw new TypeError(`static mesh.geometry.indices[${index}] exceeds its vertices.`);
|
||||
return resolved;
|
||||
});
|
||||
if (!Array.isArray(graph.textures) || !graph.textures.length) throw new TypeError("static mesh textures must be populated.");
|
||||
const textures = graph.textures.map(parseTexture);
|
||||
if (!Array.isArray(graph.layers) || !graph.layers.length) throw new TypeError("static mesh layers must be populated.");
|
||||
const layers = graph.layers.map((layer, index) => parseLayer(layer, index, indices, textures.length));
|
||||
const runtime = exactObject(graph.runtime, "static mesh.runtime", [
|
||||
"renderer", "coordinateSpace", "staticFrame",
|
||||
]);
|
||||
literal(runtime.renderer, "wow-m2-static-mesh-v1", "static mesh.runtime.renderer");
|
||||
literal(runtime.coordinateSpace, "wow-m2-source", "static mesh.runtime.coordinateSpace");
|
||||
literal(runtime.staticFrame, "source-bind-pose", "static mesh.runtime.staticFrame");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "wow-m2-static-mesh",
|
||||
source: "wow335",
|
||||
sourceModel: stringValue(graph.sourceModel, "static mesh.sourceModel"),
|
||||
normalizedSourceModel: stringValue(graph.normalizedSourceModel, "static mesh.normalizedSourceModel"),
|
||||
sourceSha256: digest(graph.sourceSha256, "static mesh.sourceSha256"),
|
||||
sourceSkin: stringValue(graph.sourceSkin, "static mesh.sourceSkin"),
|
||||
sourceSkinSha256: digest(graph.sourceSkinSha256, "static mesh.sourceSkinSha256"),
|
||||
bounds: {
|
||||
minimum: vector3(bounds.minimum, "static mesh.bounds.minimum"),
|
||||
maximum: vector3(bounds.maximum, "static mesh.bounds.maximum"),
|
||||
radius: numberValue(bounds.radius, "static mesh.bounds.radius"),
|
||||
},
|
||||
geometry: { positions, normals, uvs, indices },
|
||||
textures,
|
||||
layers,
|
||||
runtime: {
|
||||
renderer: "wow-m2-static-mesh-v1",
|
||||
coordinateSpace: "wow-m2-source",
|
||||
staticFrame: "source-bind-pose",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2855,8 +2855,14 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.gm-admin-header__actions { display: flex; gap: 7px; }
|
||||
.gm-admin-layout { display: grid; grid-template-columns: minmax(205px, .72fr) minmax(330px, 1.55fr) minmax(240px, .82fr); min-height: 0; }
|
||||
.gm-admin-options, .gm-boss-browser, .gm-boss-detail { min-width: 0; min-height: 0; padding: 16px; }
|
||||
.gm-admin-options { overflow: auto; }
|
||||
.gm-admin-options, .gm-boss-browser { border-right: 1px solid var(--front-line); }
|
||||
.gm-admin-options h2, .gm-boss-browser h2, .gm-boss-detail h2 { margin: 4px 0 13px; font-family: Georgia, serif; font-size: 20px; font-weight: 400; }
|
||||
.gm-random-qa { display: grid; gap: 6px; margin: -2px 0 18px; padding-bottom: 17px; border-bottom: 1px solid var(--front-line); }
|
||||
.gm-random-qa h2 { margin-bottom: 2px; }
|
||||
.gm-random-qa > p:not(.front-kicker) { margin: 0 0 5px; color: var(--front-dim); font-size: 8px; line-height: 1.4; }
|
||||
.gm-random-qa .front-button { display: flex; align-items: center; justify-content: space-between; gap: 8px; text-align: left; }
|
||||
.gm-random-qa .front-button small { color: var(--front-dim); font-size: 7px; white-space: nowrap; }
|
||||
.gm-switch-row { display: grid; grid-template-columns: 25px minmax(0, 1fr); align-items: start; gap: 8px; padding: 9px 0; border-top: 1px solid rgba(190, 213, 172, .09); cursor: pointer; }
|
||||
.gm-switch-row input { width: 17px; height: 17px; accent-color: #91bc75; }
|
||||
.gm-switch-row span, .gm-switch-row strong, .gm-switch-row small { display: block; }
|
||||
@@ -2890,9 +2896,66 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.gm-status-buttons button, .gm-world-editor button { padding: 7px 9px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--front-cream); background: rgba(151, 182, 127, .07); cursor: pointer; }
|
||||
.gm-status-buttons button.is-active { border-color: rgba(184, 219, 144, .6); background: rgba(113, 158, 88, .26); }
|
||||
.gm-status-buttons button:disabled, .gm-world-editor button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.gm-review-note-editor { display: grid; gap: 6px; margin: 0 0 14px; padding: 10px; border: 1px solid rgba(190, 213, 172, .12); border-radius: 7px; background: rgba(1, 8, 5, .34); }
|
||||
.gm-review-note-editor header, .gm-review-note-editor footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.gm-review-note-editor header strong { font-size: 9px; }
|
||||
.gm-review-note-editor header small, .gm-review-note-editor footer small { color: var(--front-dim); font-size: 7px; line-height: 1.4; }
|
||||
.gm-review-note-editor textarea { width: 100%; min-height: 76px; padding: 8px; resize: vertical; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(2, 9, 6, .88); font: inherit; font-size: 8px; line-height: 1.45; }
|
||||
.gm-review-note-editor textarea:focus { border-color: rgba(169, 211, 132, .65); }
|
||||
.gm-review-note-editor footer button { flex: 0 0 auto; padding: 4px 6px; border: 1px solid var(--front-line); border-radius: 5px; color: var(--front-dim); background: transparent; cursor: pointer; font-size: 7px; }
|
||||
.gm-review-note-editor footer button:disabled { cursor: not-allowed; opacity: .38; }
|
||||
.gm-boss-actions { display: grid; gap: 7px; }
|
||||
.gm-admin-denied { display: grid; width: min(470px, calc(100% - 30px)); margin: auto; gap: 12px; padding: 26px; border: 1px solid var(--front-line); background: rgba(3, 10, 7, .75); }
|
||||
|
||||
.gm-review-card {
|
||||
position: absolute;
|
||||
top: max(276px, calc(env(safe-area-inset-top) + 264px));
|
||||
right: max(12px, env(safe-area-inset-right));
|
||||
z-index: 18;
|
||||
display: grid;
|
||||
width: min(248px, calc(100% - 24px));
|
||||
max-height: calc(100% - 84px);
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(184, 219, 144, .38);
|
||||
border-radius: 9px 3px 9px 3px;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(145deg, rgba(9, 25, 18, .94), rgba(4, 13, 10, .92));
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,.45), inset 0 0 18px rgba(133,181,102,.05);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.gm-review-card.is-not-ready { border-color: rgba(226, 112, 96, .46); }
|
||||
.gm-review-card > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.gm-review-card > header span { color: #aac99a; font-size: 7px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.gm-review-card > header b { color: #e6cf82; font-size: 7px; text-transform: uppercase; }
|
||||
.gm-review-card > strong { overflow: hidden; font: 13px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gm-review-card > small { overflow: hidden; color: var(--muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gm-review-card__statuses { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; }
|
||||
.gm-review-card__note { min-width: 0; border-top: 1px solid rgba(183,207,151,.12); border-bottom: 1px solid rgba(183,207,151,.12); }
|
||||
.gm-review-card__note summary { display: grid; grid-template-columns: auto auto minmax(0, 1fr); align-items: center; gap: 7px; padding: 6px 1px; cursor: pointer; list-style: none; }
|
||||
.gm-review-card__note summary::-webkit-details-marker { display: none; }
|
||||
.gm-review-card__note summary::before { content: "+"; color: #aac99a; font-size: 9px; }
|
||||
.gm-review-card__note[open] summary::before { content: "−"; }
|
||||
.gm-review-card__note[open] summary { justify-content: start; }
|
||||
.gm-review-card__note[open] summary::after { content: "Editing"; color: var(--muted); font-size: 7px; }
|
||||
.gm-review-card__note[open] summary small { display: none; }
|
||||
.gm-review-card__note summary span { color: #c8d9bb; font-size: 7px; font-weight: 700; white-space: nowrap; }
|
||||
.gm-review-card__note summary small { overflow: hidden; color: var(--muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gm-review-card__note textarea { display: block; width: 100%; min-height: 54px; max-height: 120px; padding: 6px; resize: vertical; border: 1px solid rgba(183,207,151,.2); border-radius: 5px; outline: 0; color: var(--ink); background: rgba(0,6,4,.72); font: inherit; font-size: 7px; line-height: 1.45; }
|
||||
.gm-review-card__note textarea:focus { border-color: rgba(184,219,144,.62); }
|
||||
.gm-review-card__note > div { display: flex; align-items: center; justify-content: space-between; gap: 7px; padding: 4px 0; }
|
||||
.gm-review-card__note > div span, .gm-review-card__note > p { margin: 0; color: var(--muted); font-size: 6px; line-height: 1.35; }
|
||||
.gm-review-card__note > div button { padding: 3px 5px; }
|
||||
.gm-review-card__note > p { padding: 0 1px 6px; }
|
||||
.gm-review-card button { min-width: 0; padding: 6px 5px; border: 1px solid rgba(183,207,151,.2); border-radius: 5px; color: var(--ink); background: rgba(151,182,127,.07); cursor: pointer; font-size: 7px; }
|
||||
.gm-review-card button:hover:not(:disabled), .gm-review-card button.is-active { border-color: rgba(184,219,144,.62); background: rgba(113,158,88,.25); }
|
||||
.gm-review-card button:disabled { cursor: not-allowed; opacity: .38; }
|
||||
.gm-review-card > p { margin: 0; color: #f0ad9f; font-size: 7px; line-height: 1.35; }
|
||||
.gm-review-card > footer { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.gm-review-card > footer button:first-child { color: #f3dda0; }
|
||||
|
||||
.gm-world-editor {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -2966,4 +3029,9 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.gm-boss-browser > header > div { grid-column: 1 / -1; }
|
||||
.gm-boss-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.gm-world-editor { inset: 5px; width: auto; }
|
||||
.gm-review-card { top: auto; right: 7px; bottom: max(66px, calc(env(safe-area-inset-bottom) + 54px)); width: min(220px, calc(100% - 14px)); padding: 8px; }
|
||||
}
|
||||
|
||||
@media (max-height: 560px) and (min-width: 601px) {
|
||||
.gm-review-card { top: auto; bottom: max(62px, calc(env(safe-area-inset-bottom) + 50px)); }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { enterGmManastormTest } from "../app/gmManastormRuntime";
|
||||
import { enterGmManastormTest, enterRandomGmReview } from "../app/gmManastormRuntime";
|
||||
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import {
|
||||
bossAdminRecordForStatus,
|
||||
GM_REVIEW_NOTE_MAX_LENGTH,
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormBossStatus,
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
configWithGmReviewNote,
|
||||
gmReviewQueue,
|
||||
type GmReviewKind,
|
||||
} from "../game/gmContentReview";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<ManastormBossStatus, string>> = {
|
||||
@@ -51,6 +57,9 @@ export function GmAdminScreen() {
|
||||
const [poolFilter, setPoolFilter] = useState<"all" | "public" | "excluded">("all");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
|
||||
const randomQueueCounts = useMemo(() => Object.fromEntries(
|
||||
(["dungeon", "boss", "manastorm"] as const).map((kind) => [kind, gmReviewQueue(kind, draft).length]),
|
||||
) as Readonly<Record<GmReviewKind, number>>, [draft]);
|
||||
const dungeonOptions = useMemo(() => [...new Map(catalog.map((entry) => [entry.dungeonTitle, entry.dungeonTitle])).keys()].sort(), [catalog]);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
@@ -60,7 +69,7 @@ export function GmAdminScreen() {
|
||||
&& (sourceFilter === "all" || entry.source.includes(sourceFilter))
|
||||
&& (runnableFilter === "all" || (runnableFilter === "runnable" ? entry.runnable : !entry.runnable))
|
||||
&& (poolFilter === "all" || (poolFilter === "public" ? entry.inPublicPool : !entry.inPublicPool))
|
||||
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")}`.toLowerCase().includes(query))
|
||||
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")} ${entry.reviewNote}`.toLowerCase().includes(query))
|
||||
));
|
||||
}, [catalog, dungeonFilter, poolFilter, runnableFilter, search, sourceFilter, statusFilter]);
|
||||
const selected = catalog.find((entry) => entry.key === selectedKey) ?? filtered[0] ?? null;
|
||||
@@ -96,6 +105,11 @@ export function GmAdminScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const setReviewNote = (key: string, note: string) => {
|
||||
const next = configWithGmReviewNote(draft, { kind: "boss", bossKey: key }, note);
|
||||
if (next) replaceDraft(next);
|
||||
};
|
||||
|
||||
const editInDungeon = () => {
|
||||
if (!selected?.dungeonId) return;
|
||||
setEditingBossKey(selected.key);
|
||||
@@ -126,6 +140,28 @@ export function GmAdminScreen() {
|
||||
|
||||
<main className="gm-admin-layout">
|
||||
<aside className="gm-admin-options">
|
||||
<section className="gm-random-qa" aria-labelledby="gm-random-qa-title">
|
||||
<p className="front-kicker">Random QA</p>
|
||||
<h2 id="gm-random-qa-title">Review queue</h2>
|
||||
<p>Needs tested first, then Not ready. Ready content stays out of the draw.</p>
|
||||
{(["dungeon", "boss", "manastorm"] as const).map((kind) => {
|
||||
const label = kind === "dungeon" ? "Dungeon" : kind === "boss" ? "Boss" : "Manastorm";
|
||||
const count = randomQueueCounts[kind];
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className="front-button"
|
||||
disabled={dirty || saving || count === 0}
|
||||
title={dirty ? "Save changes before testing." : count === 0 ? `All runnable ${label.toLowerCase()} content is Ready.` : undefined}
|
||||
onClick={() => enterRandomGmReview(kind)}
|
||||
>
|
||||
<span>Random {label}</span>
|
||||
<small>{count ? `${count} queued` : "All reviewed"}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<p className="front-kicker">Global switches</p>
|
||||
<h2>Storm rules</h2>
|
||||
{GLOBAL_OPTIONS.map((option) => (
|
||||
@@ -220,6 +256,27 @@ export function GmAdminScreen() {
|
||||
>{STATUS_LABELS[status]}</button>
|
||||
))}
|
||||
</div>
|
||||
<section className="gm-review-note-editor" aria-label={`Review note for ${selected.name}`}>
|
||||
<header>
|
||||
<strong>Review note</strong>
|
||||
<small>{selected.reviewNote.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</small>
|
||||
</header>
|
||||
<textarea
|
||||
aria-label={`Review note for ${selected.name}`}
|
||||
value={selected.reviewNote}
|
||||
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
|
||||
rows={5}
|
||||
disabled={saving}
|
||||
placeholder="Record what needs testing or fixing."
|
||||
onChange={(event) => setReviewNote(selected.key, event.currentTarget.value)}
|
||||
/>
|
||||
<footer>
|
||||
<small>{selected.reviewedAt && selected.reviewedBy
|
||||
? `Last reviewed by ${selected.reviewedBy} · ${new Date(selected.reviewedAt).toLocaleString()}`
|
||||
: "Not yet reviewed."}</small>
|
||||
<button type="button" disabled={saving || !selected.reviewNote} onClick={() => setReviewNote(selected.key, "")}>Clear note</button>
|
||||
</footer>
|
||||
</section>
|
||||
<div className="gm-boss-actions">
|
||||
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={editInDungeon}>Edit in dungeon</button>
|
||||
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={enterSelectedDungeon}>Enter dungeon</button>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import { enterRandomGmReview } from "../app/gmManastormRuntime";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import {
|
||||
configWithGmReviewNote,
|
||||
configWithGmReviewStatus,
|
||||
describeGmReviewTarget,
|
||||
gmReviewQueue,
|
||||
} from "../game/gmContentReview";
|
||||
import {
|
||||
GM_REVIEW_NOTE_MAX_LENGTH,
|
||||
isGameMasterSession,
|
||||
type GmReadinessStatus,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<GmReadinessStatus, string>> = {
|
||||
ready: "Ready",
|
||||
"needs-tested": "Needs tested",
|
||||
"not-ready": "Not ready",
|
||||
};
|
||||
|
||||
const KIND_LABELS = {
|
||||
dungeon: "Dungeon review",
|
||||
boss: "Boss review",
|
||||
manastorm: "Manastorm review",
|
||||
} as const;
|
||||
|
||||
export function GmReviewCard() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const target = useGameStore((state) => state.activeGmReviewTarget);
|
||||
const config = useManastormAdminStore((state) => state.config);
|
||||
const draft = useManastormAdminStore((state) => state.draft);
|
||||
const dirty = useManastormAdminStore((state) => state.dirty);
|
||||
const saving = useManastormAdminStore((state) => state.saving);
|
||||
const error = useManastormAdminStore((state) => state.error);
|
||||
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
||||
const publish = useManastormAdminStore((state) => state.publish);
|
||||
const descriptor = useMemo(
|
||||
() => target ? describeGmReviewTarget(target, draft) : null,
|
||||
[draft, target],
|
||||
);
|
||||
const nextCount = useMemo(
|
||||
() => target ? gmReviewQueue(target.kind, config, target).length : 0,
|
||||
[config, target],
|
||||
);
|
||||
|
||||
if (!target || !descriptor || !isGameMasterSession(session)) return null;
|
||||
|
||||
const setStatus = (status: GmReadinessStatus) => {
|
||||
if (descriptor.status === status) return;
|
||||
const next = configWithGmReviewStatus(draft, target, status);
|
||||
if (next) replaceDraft(next);
|
||||
};
|
||||
|
||||
const setNote = (note: string) => {
|
||||
const next = configWithGmReviewNote(draft, target, note);
|
||||
if (next) replaceDraft(next);
|
||||
};
|
||||
|
||||
const targetKey = target.kind === "dungeon" ? target.dungeonId : target.bossKey;
|
||||
const notePreview = descriptor.note.replace(/\s+/g, " ").trim();
|
||||
const reviewMetadata = descriptor.reviewedAt && descriptor.reviewedBy
|
||||
? `${descriptor.reviewedBy} · ${new Date(descriptor.reviewedAt).toLocaleString()}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`gm-review-card is-${descriptor.status}`}
|
||||
aria-label={`${KIND_LABELS[target.kind]} for ${descriptor.name}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onKeyUp={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<span>{KIND_LABELS[target.kind]}</span>
|
||||
<b>{dirty ? "Unsaved" : STATUS_LABELS[descriptor.status]}</b>
|
||||
</header>
|
||||
<strong>{descriptor.name}</strong>
|
||||
<small>{descriptor.dungeonTitle} · Map {descriptor.mapId}</small>
|
||||
<div className="gm-review-card__statuses" aria-label="Readiness">
|
||||
{(["ready", "needs-tested", "not-ready"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={descriptor.status === status ? "is-active" : ""}
|
||||
disabled={saving || (status === "ready" && !descriptor.runnable)}
|
||||
onClick={() => setStatus(status)}
|
||||
>{STATUS_LABELS[status]}</button>
|
||||
))}
|
||||
</div>
|
||||
<details key={`${target.kind}:${targetKey}`} className="gm-review-card__note">
|
||||
<summary>
|
||||
<span>Review note</span>
|
||||
<small title={notePreview || undefined}>{notePreview || "Add context"}</small>
|
||||
</summary>
|
||||
<textarea
|
||||
aria-label={`Review note for ${descriptor.name}`}
|
||||
value={descriptor.note}
|
||||
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
disabled={saving}
|
||||
placeholder="What needs testing or fixing?"
|
||||
onChange={(event) => setNote(event.currentTarget.value)}
|
||||
/>
|
||||
<div>
|
||||
<span>{descriptor.note.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</span>
|
||||
<button type="button" disabled={saving || !descriptor.note} onClick={() => setNote("")}>Clear note</button>
|
||||
</div>
|
||||
{reviewMetadata ? <p>Last reviewed by {reviewMetadata}</p> : <p>Not yet reviewed.</p>}
|
||||
</details>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => { void publish(session); }}
|
||||
>{saving ? "Publishing…" : "Save & publish"}</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dirty || saving || nextCount === 0}
|
||||
title={nextCount === 0 ? `No remaining ${target.kind} review targets.` : undefined}
|
||||
onClick={() => enterRandomGmReview(target.kind)}
|
||||
>Test another</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, type CombatVfxMode } from "../game/combatPresentation";
|
||||
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
|
||||
import { OptionsPanel, nextCombatVfxMode } from "./OptionsPanel";
|
||||
|
||||
function renderOptions(mode: CombatVfxMode): string {
|
||||
return renderToStaticMarkup(createElement(OptionsPanel, {
|
||||
settings: DEFAULT_GAMEPLAY_SETTINGS,
|
||||
onChange: () => undefined,
|
||||
onReset: () => undefined,
|
||||
presentationSettings: { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, vfxMode: mode },
|
||||
onPresentationChange: () => undefined,
|
||||
onPresentationReset: () => undefined,
|
||||
open: true,
|
||||
onClose: () => undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("combat visual mode option", () => {
|
||||
it("cycles between generated and strict original selections", () => {
|
||||
expect(nextCombatVfxMode("generated")).toBe("original");
|
||||
expect(nextCombatVfxMode("original")).toBe("generated");
|
||||
});
|
||||
|
||||
it("renders generated as the default selection", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
|
||||
expect(markup).toContain("Spell visual mode");
|
||||
expect(markup).toContain("Use the current generated combat effects.");
|
||||
expect(markup).toContain(">Generated</button>");
|
||||
});
|
||||
|
||||
it("states that original mode never substitutes generated effects", () => {
|
||||
const markup = renderOptions("original");
|
||||
|
||||
expect(markup).toContain("Use only packaged original source effects.");
|
||||
expect(markup).toContain("Unsupported spells show no visual effect.");
|
||||
expect(markup).toContain(">Original</button>");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import {
|
||||
type CombatActorGroup,
|
||||
type CombatPresentationSettings,
|
||||
type CombatVfxMode,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
@@ -67,6 +68,10 @@ const TOGGLES: readonly ToggleOption[] = [
|
||||
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
|
||||
|
||||
export function nextCombatVfxMode(mode: CombatVfxMode): CombatVfxMode {
|
||||
return mode === "generated" ? "original" : "generated";
|
||||
}
|
||||
|
||||
function actorGroupLabel(group: CombatActorGroup): string {
|
||||
return group === "enemy" ? "Enemies" : `${group[0].toUpperCase()}${group.slice(1)}`;
|
||||
}
|
||||
@@ -97,6 +102,9 @@ function OptionsDialog({
|
||||
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
|
||||
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
|
||||
};
|
||||
const cycleVfxMode = () => onPresentationChange({
|
||||
vfxMode: nextCombatVfxMode(presentationSettings.vfxMode),
|
||||
});
|
||||
const cycleGameplayFrameRate = () => onChange({
|
||||
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
|
||||
});
|
||||
@@ -116,6 +124,7 @@ function OptionsDialog({
|
||||
{ id: "option-scale-up", run: () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }) },
|
||||
{ id: "option-minimap", run: () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up" }) },
|
||||
{ id: "option-threat-position", run: () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION }) },
|
||||
{ id: "option-vfx-mode", run: cycleVfxMode },
|
||||
{ id: "option-vfx-quality", run: cycleVfxQuality },
|
||||
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
|
||||
{
|
||||
@@ -220,6 +229,19 @@ function OptionsDialog({
|
||||
}))}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Combat presentation</h3>
|
||||
<div className="option-row">
|
||||
<span>
|
||||
<strong>Spell visual mode</strong>
|
||||
<small>{presentationSettings.vfxMode === "original"
|
||||
? "Use only packaged original source effects. Unsupported spells show no visual effect."
|
||||
: "Use the current generated combat effects."}</small>
|
||||
</span>
|
||||
{optionButton(
|
||||
"option-vfx-mode",
|
||||
presentationSettings.vfxMode === "original" ? "Original" : "Generated",
|
||||
cycleVfxMode,
|
||||
)}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
|
||||
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
|
||||
|
||||
Reference in new issue
Block a user