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

+59 -2
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { enterGmManastormTest } from "../app/gmManastormRuntime";
import { enterGmManastormTest, enterRandomGmReview } from "../app/gmManastormRuntime";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
import { useShellStore } from "../app/shellStore";
import {
bossAdminRecordForStatus,
GM_REVIEW_NOTE_MAX_LENGTH,
isGameMasterSession,
resolvedBossAdminCatalog,
type ManastormBossStatus,
@@ -11,6 +12,11 @@ import {
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { useGameStore } from "../game/store";
import {
configWithGmReviewNote,
gmReviewQueue,
type GmReviewKind,
} from "../game/gmContentReview";
import { BrandMark, FrontSurface } from "./FrontSurface";
const STATUS_LABELS: Readonly<Record<ManastormBossStatus, string>> = {
@@ -51,6 +57,9 @@ export function GmAdminScreen() {
const [poolFilter, setPoolFilter] = useState<"all" | "public" | "excluded">("all");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
const randomQueueCounts = useMemo(() => Object.fromEntries(
(["dungeon", "boss", "manastorm"] as const).map((kind) => [kind, gmReviewQueue(kind, draft).length]),
) as Readonly<Record<GmReviewKind, number>>, [draft]);
const dungeonOptions = useMemo(() => [...new Map(catalog.map((entry) => [entry.dungeonTitle, entry.dungeonTitle])).keys()].sort(), [catalog]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
@@ -60,7 +69,7 @@ export function GmAdminScreen() {
&& (sourceFilter === "all" || entry.source.includes(sourceFilter))
&& (runnableFilter === "all" || (runnableFilter === "runnable" ? entry.runnable : !entry.runnable))
&& (poolFilter === "all" || (poolFilter === "public" ? entry.inPublicPool : !entry.inPublicPool))
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")}`.toLowerCase().includes(query))
&& (!query || `${entry.name} ${entry.dungeonTitle} ${entry.source.join(" ")} ${entry.reviewNote}`.toLowerCase().includes(query))
));
}, [catalog, dungeonFilter, poolFilter, runnableFilter, search, sourceFilter, statusFilter]);
const selected = catalog.find((entry) => entry.key === selectedKey) ?? filtered[0] ?? null;
@@ -96,6 +105,11 @@ export function GmAdminScreen() {
});
};
const setReviewNote = (key: string, note: string) => {
const next = configWithGmReviewNote(draft, { kind: "boss", bossKey: key }, note);
if (next) replaceDraft(next);
};
const editInDungeon = () => {
if (!selected?.dungeonId) return;
setEditingBossKey(selected.key);
@@ -126,6 +140,28 @@ export function GmAdminScreen() {
<main className="gm-admin-layout">
<aside className="gm-admin-options">
<section className="gm-random-qa" aria-labelledby="gm-random-qa-title">
<p className="front-kicker">Random QA</p>
<h2 id="gm-random-qa-title">Review queue</h2>
<p>Needs tested first, then Not ready. Ready content stays out of the draw.</p>
{(["dungeon", "boss", "manastorm"] as const).map((kind) => {
const label = kind === "dungeon" ? "Dungeon" : kind === "boss" ? "Boss" : "Manastorm";
const count = randomQueueCounts[kind];
return (
<button
key={kind}
type="button"
className="front-button"
disabled={dirty || saving || count === 0}
title={dirty ? "Save changes before testing." : count === 0 ? `All runnable ${label.toLowerCase()} content is Ready.` : undefined}
onClick={() => enterRandomGmReview(kind)}
>
<span>Random {label}</span>
<small>{count ? `${count} queued` : "All reviewed"}</small>
</button>
);
})}
</section>
<p className="front-kicker">Global switches</p>
<h2>Storm rules</h2>
{GLOBAL_OPTIONS.map((option) => (
@@ -220,6 +256,27 @@ export function GmAdminScreen() {
>{STATUS_LABELS[status]}</button>
))}
</div>
<section className="gm-review-note-editor" aria-label={`Review note for ${selected.name}`}>
<header>
<strong>Review note</strong>
<small>{selected.reviewNote.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</small>
</header>
<textarea
aria-label={`Review note for ${selected.name}`}
value={selected.reviewNote}
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
rows={5}
disabled={saving}
placeholder="Record what needs testing or fixing."
onChange={(event) => setReviewNote(selected.key, event.currentTarget.value)}
/>
<footer>
<small>{selected.reviewedAt && selected.reviewedBy
? `Last reviewed by ${selected.reviewedBy} · ${new Date(selected.reviewedAt).toLocaleString()}`
: "Not yet reviewed."}</small>
<button type="button" disabled={saving || !selected.reviewNote} onClick={() => setReviewNote(selected.key, "")}>Clear note</button>
</footer>
</section>
<div className="gm-boss-actions">
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={editInDungeon}>Edit in dungeon</button>
<button type="button" className="front-button" disabled={!selected.dungeonId} onClick={enterSelectedDungeon}>Enter dungeon</button>
+130
View File
@@ -0,0 +1,130 @@
import { useMemo } from "react";
import { enterRandomGmReview } from "../app/gmManastormRuntime";
import { useShellStore } from "../app/shellStore";
import {
configWithGmReviewNote,
configWithGmReviewStatus,
describeGmReviewTarget,
gmReviewQueue,
} from "../game/gmContentReview";
import {
GM_REVIEW_NOTE_MAX_LENGTH,
isGameMasterSession,
type GmReadinessStatus,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { useGameStore } from "../game/store";
const STATUS_LABELS: Readonly<Record<GmReadinessStatus, string>> = {
ready: "Ready",
"needs-tested": "Needs tested",
"not-ready": "Not ready",
};
const KIND_LABELS = {
dungeon: "Dungeon review",
boss: "Boss review",
manastorm: "Manastorm review",
} as const;
export function GmReviewCard() {
const session = useShellStore((state) => state.session);
const target = useGameStore((state) => state.activeGmReviewTarget);
const config = useManastormAdminStore((state) => state.config);
const draft = useManastormAdminStore((state) => state.draft);
const dirty = useManastormAdminStore((state) => state.dirty);
const saving = useManastormAdminStore((state) => state.saving);
const error = useManastormAdminStore((state) => state.error);
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
const publish = useManastormAdminStore((state) => state.publish);
const descriptor = useMemo(
() => target ? describeGmReviewTarget(target, draft) : null,
[draft, target],
);
const nextCount = useMemo(
() => target ? gmReviewQueue(target.kind, config, target).length : 0,
[config, target],
);
if (!target || !descriptor || !isGameMasterSession(session)) return null;
const setStatus = (status: GmReadinessStatus) => {
if (descriptor.status === status) return;
const next = configWithGmReviewStatus(draft, target, status);
if (next) replaceDraft(next);
};
const setNote = (note: string) => {
const next = configWithGmReviewNote(draft, target, note);
if (next) replaceDraft(next);
};
const targetKey = target.kind === "dungeon" ? target.dungeonId : target.bossKey;
const notePreview = descriptor.note.replace(/\s+/g, " ").trim();
const reviewMetadata = descriptor.reviewedAt && descriptor.reviewedBy
? `${descriptor.reviewedBy} · ${new Date(descriptor.reviewedAt).toLocaleString()}`
: null;
return (
<section
className={`gm-review-card is-${descriptor.status}`}
aria-label={`${KIND_LABELS[target.kind]} for ${descriptor.name}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onKeyUp={(event) => event.stopPropagation()}
>
<header>
<span>{KIND_LABELS[target.kind]}</span>
<b>{dirty ? "Unsaved" : STATUS_LABELS[descriptor.status]}</b>
</header>
<strong>{descriptor.name}</strong>
<small>{descriptor.dungeonTitle} · Map {descriptor.mapId}</small>
<div className="gm-review-card__statuses" aria-label="Readiness">
{(["ready", "needs-tested", "not-ready"] as const).map((status) => (
<button
key={status}
type="button"
className={descriptor.status === status ? "is-active" : ""}
disabled={saving || (status === "ready" && !descriptor.runnable)}
onClick={() => setStatus(status)}
>{STATUS_LABELS[status]}</button>
))}
</div>
<details key={`${target.kind}:${targetKey}`} className="gm-review-card__note">
<summary>
<span>Review note</span>
<small title={notePreview || undefined}>{notePreview || "Add context"}</small>
</summary>
<textarea
aria-label={`Review note for ${descriptor.name}`}
value={descriptor.note}
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
rows={3}
disabled={saving}
placeholder="What needs testing or fixing?"
onChange={(event) => setNote(event.currentTarget.value)}
/>
<div>
<span>{descriptor.note.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</span>
<button type="button" disabled={saving || !descriptor.note} onClick={() => setNote("")}>Clear note</button>
</div>
{reviewMetadata ? <p>Last reviewed by {reviewMetadata}</p> : <p>Not yet reviewed.</p>}
</details>
{error ? <p role="alert">{error}</p> : null}
<footer>
<button
type="button"
disabled={!dirty || saving}
onClick={() => { void publish(session); }}
>{saving ? "Publishing…" : "Save & publish"}</button>
<button
type="button"
disabled={dirty || saving || nextCount === 0}
title={nextCount === 0 ? `No remaining ${target.kind} review targets.` : undefined}
onClick={() => enterRandomGmReview(target.kind)}
>Test another</button>
</footer>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, type CombatVfxMode } from "../game/combatPresentation";
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
import { OptionsPanel, nextCombatVfxMode } from "./OptionsPanel";
function renderOptions(mode: CombatVfxMode): string {
return renderToStaticMarkup(createElement(OptionsPanel, {
settings: DEFAULT_GAMEPLAY_SETTINGS,
onChange: () => undefined,
onReset: () => undefined,
presentationSettings: { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, vfxMode: mode },
onPresentationChange: () => undefined,
onPresentationReset: () => undefined,
open: true,
onClose: () => undefined,
}));
}
describe("combat visual mode option", () => {
it("cycles between generated and strict original selections", () => {
expect(nextCombatVfxMode("generated")).toBe("original");
expect(nextCombatVfxMode("original")).toBe("generated");
});
it("renders generated as the default selection", () => {
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
expect(markup).toContain("Spell visual mode");
expect(markup).toContain("Use the current generated combat effects.");
expect(markup).toContain(">Generated</button>");
});
it("states that original mode never substitutes generated effects", () => {
const markup = renderOptions("original");
expect(markup).toContain("Use only packaged original source effects.");
expect(markup).toContain("Unsupported spells show no visual effect.");
expect(markup).toContain(">Original</button>");
});
});
+22
View File
@@ -9,6 +9,7 @@ import { ControllerButton } from "./ControllerButton";
import {
type CombatActorGroup,
type CombatPresentationSettings,
type CombatVfxMode,
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
@@ -67,6 +68,10 @@ const TOGGLES: readonly ToggleOption[] = [
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
export function nextCombatVfxMode(mode: CombatVfxMode): CombatVfxMode {
return mode === "generated" ? "original" : "generated";
}
function actorGroupLabel(group: CombatActorGroup): string {
return group === "enemy" ? "Enemies" : `${group[0].toUpperCase()}${group.slice(1)}`;
}
@@ -97,6 +102,9 @@ function OptionsDialog({
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
};
const cycleVfxMode = () => onPresentationChange({
vfxMode: nextCombatVfxMode(presentationSettings.vfxMode),
});
const cycleGameplayFrameRate = () => onChange({
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
});
@@ -116,6 +124,7 @@ function OptionsDialog({
{ id: "option-scale-up", run: () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }) },
{ id: "option-minimap", run: () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up" }) },
{ id: "option-threat-position", run: () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION }) },
{ id: "option-vfx-mode", run: cycleVfxMode },
{ id: "option-vfx-quality", run: cycleVfxQuality },
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
{
@@ -220,6 +229,19 @@ function OptionsDialog({
}))}
</div>
<h3 className="options-panel__section-title">Combat presentation</h3>
<div className="option-row">
<span>
<strong>Spell visual mode</strong>
<small>{presentationSettings.vfxMode === "original"
? "Use only packaged original source effects. Unsupported spells show no visual effect."
: "Use the current generated combat effects."}</small>
</span>
{optionButton(
"option-vfx-mode",
presentationSettings.vfxMode === "original" ? "Original" : "Generated",
cycleVfxMode,
)}
</div>
<div className="option-row">
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}