Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user