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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user