forked from phenom/healer-man
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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user