Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

-2
View File
@@ -5,7 +5,6 @@ import { useManastormAdminStore } from "../game/manastormAdminStore";
const ManastormSelectScreen = lazy(() => import("../ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("../ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("../ArenaRuntime"));
const GmAdminScreen = lazy(() => import("../ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShelvedTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
@@ -28,6 +27,5 @@ export function ShelvedModeRoute({
if (phase === "manastorms") screen = <ManastormSelectScreen />;
else if (phase === "arenas") screen = <ArenaSelectScreen />;
else if (phase === "arena-game") screen = <ArenaRuntime />;
else if (phase === "gm-admin") screen = <GmAdminScreen />;
return <Suspense fallback={<ShelvedTransition />}>{screen}</Suspense>;
}
+192
View File
@@ -0,0 +1,192 @@
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource;
readonly level: number;
readonly partyConfigurations: number;
readonly simulationsPerParty: number;
readonly durationSeconds: number;
readonly partySeed: number;
readonly bossCategory: BalanceLabBossCategory;
readonly bossKeys: readonly string[];
}
export interface BalanceLabJob {
readonly id: string;
readonly status: BalanceLabJobStatus;
readonly createdAt: string;
readonly startedAt: string | null;
readonly completedAt: string | null;
readonly requestedBy: string;
readonly spec: BalanceLabJobSpec;
readonly progress: {
readonly party: number;
readonly partiesCompleted: number;
readonly partiesTotal: number;
readonly simulationsCompleted: number;
readonly simulationsTotal: number;
};
readonly artifacts: readonly string[];
readonly error: string | null;
readonly logTail: readonly string[];
readonly summary: BalanceLabReportSummary | null;
}
export interface BalanceLabReportSummary {
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly classSpecs: number;
readonly classAbilities: number;
readonly bossPressureMedianDtps: number;
}
export interface BalanceLabBossResult {
readonly bossKey: string;
readonly bossName: string;
readonly category: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly runs: number;
readonly incomingDtps: number;
readonly averageIncomingDamage: number;
readonly partyDps: number;
readonly bossDpsTaken: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly averageTimeToDefeat: number | null;
readonly bossMaxHealth: number;
readonly pressureVsMedian: number | null;
readonly pressureFlag: string;
}
export interface BalanceLabClassSpecResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly partyConfigurations: number;
readonly runs: number;
readonly dps: number;
readonly bossDps: number;
readonly hps: number;
readonly dtps: number;
readonly totalDamage: number;
readonly totalDamageTaken: number;
readonly totalHealing: number;
readonly totalAbsorbs: number;
readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null;
readonly damageFlag: string;
}
export interface BalanceLabPartyResult {
readonly partyConfigurationId: string;
readonly source: string;
readonly members: string;
readonly runs: number;
readonly partyDps: number;
readonly incomingDtps: number;
readonly defeatRate: number;
readonly wipeRate: number;
readonly averageDeaths: number;
readonly reportPath: string;
}
export interface BalanceLabClassAbilityResult {
readonly source: string;
readonly buildId: string;
readonly classId: string;
readonly className: string;
readonly specialization: string;
readonly role: string;
readonly abilityId: string;
readonly abilityName: string;
readonly icon: string;
readonly categories: string;
readonly cooldownSeconds: number | null;
readonly partyConfigurations: number;
readonly bosses: number;
readonly runs: number;
readonly availableRuns: number;
readonly activeRuns: number;
readonly usageRate: number;
readonly casts: number;
readonly castsPerMinute: number;
readonly hits: number;
readonly hitsPerRun: number;
readonly rawDamage: number;
readonly effectiveDamage: number;
readonly dps: number;
readonly damageShare: number;
readonly averageDamagePerHit: number;
readonly rawHealing: number;
readonly effectiveHealing: number;
readonly overheal: number;
readonly absorbs: number;
readonly status: "unused" | "damage" | "support" | "used";
readonly unusedAvailable: boolean;
}
export interface BalanceLabSuiteReport {
readonly schemaVersion: number;
readonly generatedAt: string;
readonly engine: string;
readonly level: number;
readonly summary: BalanceLabReportSummary;
readonly bosses: readonly BalanceLabBossResult[];
readonly classSpecs: readonly BalanceLabClassSpecResult[];
readonly classAbilities?: readonly BalanceLabClassAbilityResult[];
readonly partyConfigurations: readonly BalanceLabPartyResult[];
}
export async function listBalanceLabJobs(token: string): Promise<readonly BalanceLabJob[]> {
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
"/api/gm/balance-lab/jobs",
{},
token,
)).jobs;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs",
{ method: "POST", body: JSON.stringify(spec) },
token,
)).job;
}
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
{},
token,
)).job;
}
export async function getBalanceLabReport(id: string, token: string): Promise<BalanceLabSuiteReport> {
return requestOnlineJson<BalanceLabSuiteReport>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/suite-report.json`,
{},
token,
);
}
export async function fetchBalanceLabArtifact(id: string, name: string, token: string): Promise<Blob> {
const response = await fetch(
`${onlineApiOrigin()}/api/gm/balance-lab/jobs/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Could not download ${name} (${response.status}).`);
}
return response.blob();
}
+87
View File
@@ -0,0 +1,87 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import type {
DungeonReplaySummary,
DungeonReplayV1,
DungeonRouteApproval,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
function gmToken(session: PlayerSession | null): string {
if (session?.kind !== "account" || !session.accessToken || !session.roles?.includes("gm")) {
throw new Error("Game Master access is required.");
}
return session.accessToken;
}
export async function fetchDungeonRouteApprovals(): Promise<readonly DungeonRouteApproval[]> {
return (await requestOnlineJson<{ approvals: DungeonRouteApproval[] }>("/api/dungeon-route-approvals")).approvals;
}
export async function fetchGmDungeonRoutes(session: PlayerSession | null): Promise<{
readonly reviews: readonly DungeonRouteReview[];
readonly latestReplays: readonly DungeonReplaySummary[];
}> {
return requestOnlineJson("/api/gm/dungeon-routes", {}, gmToken(session));
}
export async function putDungeonRouteReview(
session: PlayerSession | null,
route: { readonly dungeonId: string; readonly routeId: string; readonly routeHash: string },
update: {
readonly status: DungeonRouteReviewStatus;
readonly expectedRevision: number;
readonly note?: string;
readonly criticalOverrideNote?: string;
},
): Promise<DungeonRouteReview> {
const response = await requestOnlineJson<{ review: DungeonRouteReview }>(
`/api/gm/dungeon-routes/${encodeURIComponent(route.dungeonId)}/${encodeURIComponent(route.routeId)}/review`,
{ method: "PUT", body: JSON.stringify({ ...update, routeHash: route.routeHash }) },
gmToken(session),
);
return response.review;
}
export async function fetchDungeonSimulationReplays(
session: PlayerSession | null,
dungeonId?: string,
): Promise<readonly DungeonReplaySummary[]> {
const query = dungeonId ? `?dungeon=${encodeURIComponent(dungeonId)}` : "";
return (await requestOnlineJson<{ replays: DungeonReplaySummary[] }>(
`/api/gm/dungeon-sim/replays${query}`,
{},
gmToken(session),
)).replays;
}
export async function saveDungeonSimulationReplay(
session: PlayerSession | null,
replay: DungeonReplayV1,
): Promise<DungeonReplaySummary> {
return (await requestOnlineJson<{ replay: DungeonReplaySummary }>(
"/api/gm/dungeon-sim/replays",
{ method: "POST", body: JSON.stringify(replay) },
gmToken(session),
)).replay;
}
export function fetchDungeonSimulationReplay(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonReplayV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, {}, gmToken(session));
}
export function fetchDungeonSimulationReport(
session: PlayerSession | null,
replayId: string,
): Promise<DungeonSimulationReportV1> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}/report`, {}, gmToken(session));
}
export function deleteDungeonSimulationReplay(session: PlayerSession | null, replayId: string): Promise<{ readonly ok: true }> {
return requestOnlineJson(`/api/gm/dungeon-sim/replays/${encodeURIComponent(replayId)}`, { method: "DELETE" }, gmToken(session));
}
+71
View File
@@ -0,0 +1,71 @@
import { touchCharacter } from "./accountRepository";
import { useShellStore } from "./shellStore";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import type { DungeonReplayV1, DungeonSimulationMode } from "../game/dungeonSimulationTypes";
import { defaultPartyRoleForClass } from "../game/partyRoles";
import { useGameStore } from "../game/store";
function gmCharacter() {
const shell = useShellStore.getState();
if (shell.session?.kind !== "account" || !shell.session.accessToken || !shell.session.roles?.includes("gm")) {
shell.setNotice("Game Master access is required.");
return null;
}
const character = shell.selectedCharacterId
? touchCharacter(shell.session.ownerId, shell.selectedCharacterId)
?? shell.characters.find((candidate) => candidate.id === shell.selectedCharacterId)
: null;
if (!character) shell.setNotice("Select a character before launching a simulation.");
return character;
}
export function enterDungeonSimulation(
dungeonId: string,
mode: DungeonSimulationMode,
seed: string,
): boolean {
const character = gmCharacter();
const route = dungeonRouteDefinition(dungeonId);
if (!character || !route || !seed.trim()) return false;
if (!activateDungeonSession(dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startLive({ dungeonId, route, mode, seed: seed.trim() });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
export function enterDungeonSimulationReplay(replay: DungeonReplayV1): boolean {
const character = gmCharacter();
if (!character || !dungeonRouteDefinition(replay.dungeonId, replay.routeId)) return false;
if (!activateDungeonSession(replay.dungeonId, undefined, {
gmTestMode: true,
dungeonSimulation: true,
})) {
useShellStore.getState().setNotice("The replay dungeon package is unavailable on this client.");
return false;
}
useDungeonSimulationStore.getState().startReplay(replay);
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
activeCharacter: character,
activeDungeonId: replay.dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId),
notice: "",
});
return true;
}
+15 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
isShelvedShellPhase,
sessionCanUseGmTools,
sessionCanUseShelvedGmTools,
SHELVED_MODES_ENABLED,
} from "./releaseFeatures";
describe("release feature boundary", () => {
it("recognizes every legacy shelved phase", () => {
expect(["manastorms", "arenas", "arena-game", "gm-admin"].every(isShelvedShellPhase)).toBe(true);
expect(["manastorms", "arenas", "arena-game"].every(isShelvedShellPhase)).toBe(true);
expect(isShelvedShellPhase("gm-admin")).toBe(false);
expect(isShelvedShellPhase("main-menu")).toBe(false);
expect(isShelvedShellPhase("game")).toBe(false);
});
@@ -24,4 +26,16 @@ describe("release feature boundary", () => {
expect(sessionCanUseShelvedGmTools({ ...session, roles: [] })).toBe(false);
expect(sessionCanUseShelvedGmTools({ ...session, accessToken: "" })).toBe(false);
});
it("exposes dungeon GM tools in normal releases to authenticated GMs", () => {
const session = {
kind: "account",
ownerId: "gm-account",
displayName: "Phenom",
accessToken: "token",
roles: ["gm"],
} as const;
expect(sessionCanUseGmTools(session)).toBe(true);
expect(sessionCanUseGmTools({ ...session, roles: [] })).toBe(false);
});
});
+8 -1
View File
@@ -4,7 +4,6 @@ export const SHELVED_SHELL_PHASES = Object.freeze([
"manastorms",
"arenas",
"arena-game",
"gm-admin",
] as const);
export type ShelvedShellPhase = (typeof SHELVED_SHELL_PHASES)[number];
@@ -21,3 +20,11 @@ export function sessionCanUseShelvedGmTools(session: {
return SHELVED_MODES_ENABLED
&& Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function sessionCanUseGmTools(session: {
readonly kind: string;
readonly accessToken?: string;
readonly roles?: readonly string[];
} | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
+2 -2
View File
@@ -59,7 +59,7 @@ import {
logoutOnlineSession,
refreshOnlineSession,
} from "./onlineAccountClient";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
@@ -366,7 +366,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!sessionCanUseShelvedGmTools(session)) {
if (!sessionCanUseGmTools(session)) {
set({ notice: "Game Master access is required." });
return false;
}