Release Healer Man 0.1.12

This commit is contained in:
phenom committed 2026-09-01 14:35:56 -04:00
1 parent 13970d03c8
commit 65e24f644e
201 files changed
+5061 -123

No files matched your search

+43
View File
@@ -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([]);
});
});
+35
View File
@@ -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);
}
+4
View File
@@ -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,
});
}
+136
View File
@@ -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);
});
});
+179
View File
@@ -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 },
},
};
}
+56
View File
@@ -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);
+86 -11
View File
@@ -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),
+19 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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,