Release Healer Man 0.1.21

This commit is contained in:
phenom committed 2026-09-08 10:40:26 -04:00
1 parent 699fc858c9
commit f6f2375145
210 files changed
+976039 -6247

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), "utf8");
describe("boss guide UI integration", () => {
it("is reachable from pause and mounted as a DOM gameplay overlay", () => {
expect(read("../game/store.ts")).toContain('"boss-guide"');
expect(read("./PauseMenu.tsx")).toContain('openOverlay("boss-guide")');
expect(read("./GameplayPanels.tsx")).toContain('overlay === "boss-guide"');
expect(read("./BossGuidePanel.tsx")).toContain('role="dialog"');
});
it("exposes controller navigation and all mechanic verification fields", () => {
const source = read("./BossGuidePanel.tsx");
expect(source).toContain("useMenuController(actions");
expect(source).toContain("onBack: close");
expect(source).toContain("mechanic.trigger");
expect(source).toContain("mechanic.timing");
expect(source).toContain("mechanic.targetAndEffect");
expect(source).toContain("mechanic.telegraph");
expect(source).toContain("mechanic.counterplay");
expect(source).toContain("mechanic.testInstruction");
expect(source).toContain("Basic attacks only");
});
});
+181
View File
@@ -0,0 +1,181 @@
import { useMemo, useState } from "react";
import { buildBossGuideCatalog } from "../game/bossGuide";
import { BOSS_GUIDE_OVERRIDES } from "../game/bossGuideOverrides";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVERRIDES);
function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause");
const selectDungeon = (dungeonId: string) => {
const dungeon = GUIDE_CATALOG.find((candidate) => candidate.id === dungeonId);
setSelectedDungeonId(dungeonId);
setSelectedBossId(dungeon?.bosses[0]?.id ?? "");
};
const actions = useMemo<MenuAction[]>(() => {
const dungeonActions = GUIDE_CATALOG.map((dungeon, index): MenuAction => ({
id: `boss-guide-dungeon:${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
neighbors: {
up: `boss-guide-dungeon:${GUIDE_CATALOG[(index - 1 + GUIDE_CATALOG.length) % GUIDE_CATALOG.length].id}`,
down: `boss-guide-dungeon:${GUIDE_CATALOG[(index + 1) % GUIDE_CATALOG.length].id}`,
...(dungeon.id === selectedDungeon?.id && dungeon.bosses.length
? { right: `boss-guide-boss:${dungeon.bosses[0].id}` }
: {}),
},
}));
const bosses = selectedDungeon?.bosses ?? [];
const bossActions = bosses.map((boss, index): MenuAction => ({
id: `boss-guide-boss:${boss.id}`,
run: () => setSelectedBossId(boss.id),
neighbors: {
up: `boss-guide-boss:${bosses[(index - 1 + bosses.length) % bosses.length].id}`,
down: `boss-guide-boss:${bosses[(index + 1) % bosses.length].id}`,
left: `boss-guide-dungeon:${selectedDungeon!.id}`,
},
}));
return [...dungeonActions, ...bossActions];
}, [selectedDungeon]);
const controller = useMenuController(actions, {
initialId: `boss-guide-dungeon:${selectedDungeon?.id ?? ""}`,
onBack: close,
});
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel boss-guide-panel" role="dialog" aria-modal="true" aria-labelledby="boss-guide-title">
<header className="game-panel__header boss-guide-panel__header">
<div>
<p className="eyebrow">Encounter field manual</p>
<h2 id="boss-guide-title">Boss Guide</h2>
</div>
<p aria-live="polite">
<strong>{selectedDungeon?.bosses.length ?? 0}</strong>
<span>bosses in {selectedDungeon?.title ?? "this dungeon"}</span>
</p>
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
</header>
<div className="boss-guide-panel__layout">
<nav className="boss-guide-panel__dungeons" aria-label="Dungeons">
<h3>Dungeons</h3>
<div>
{GUIDE_CATALOG.map((dungeon) => {
const controlId = `boss-guide-dungeon:${dungeon.id}`;
return (
<ControllerButton
key={dungeon.id}
controlId={controlId}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={dungeon.id === selectedDungeon?.id ? "is-active" : ""}
aria-pressed={dungeon.id === selectedDungeon?.id}
onClick={() => selectDungeon(dungeon.id)}
>
<strong>{dungeon.title}</strong>
<small>{`${dungeon.bosses.length} ${dungeon.bosses.length === 1 ? "boss" : "bosses"}`}</small>
</ControllerButton>
);
})}
</div>
</nav>
<nav className="boss-guide-panel__bosses" aria-label={`${selectedDungeon?.title ?? "Dungeon"} bosses`}>
<h3>Bosses</h3>
<div>
{selectedDungeon?.bosses.map((boss, index) => {
const controlId = `boss-guide-boss:${boss.id}`;
return (
<ControllerButton
key={boss.id}
controlId={controlId}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={boss.id === selectedBoss?.id ? "is-active" : ""}
aria-pressed={boss.id === selectedBoss?.id}
onClick={() => setSelectedBossId(boss.id)}
>
<b>{index + 1}</b>
<span><strong>{boss.name}</strong><small>{`${boss.mechanics.length} special ${boss.mechanics.length === 1 ? "mechanic" : "mechanics"}`}</small></span>
</ControllerButton>
);
})}
{!selectedDungeon?.bosses.length ? (
<p className="boss-guide-panel__empty">No bosses are registered for this dungeon package.</p>
) : null}
</div>
</nav>
<main className="boss-guide-panel__details" aria-live="polite">
{selectedBoss ? (
<>
<header>
<p className="eyebrow">{selectedDungeon?.title}</p>
<h3>{selectedBoss.name}</h3>
{selectedBoss.title && selectedBoss.title !== selectedBoss.name ? <p>{selectedBoss.title}</p> : null}
</header>
{selectedBoss.mechanics.length ? (
<ol className="boss-guide-mechanics">
{selectedBoss.mechanics.map((mechanic, index) => (
<li key={mechanic.id} className={`boss-guide-mechanic boss-guide-mechanic--${mechanic.fidelity}`}>
<header>
<b>{index + 1}</b>
<h4>{mechanic.name}</h4>
<span title={mechanic.sourceDetail}>{mechanic.fidelityLabel}</span>
</header>
<dl>
<div><dt>Trigger</dt><dd>{mechanic.trigger}</dd></div>
<div><dt>Timing</dt><dd>{mechanic.timing}</dd></div>
<div><dt>Target / effect</dt><dd>{mechanic.targetAndEffect}</dd></div>
<div><dt>Warning area</dt><dd>{mechanic.telegraph}</dd></div>
</dl>
<div className="boss-guide-mechanic__response">
<p><strong>What to do</strong>{mechanic.counterplay}</p>
<p><strong>How to test it</strong>{mechanic.testInstruction}</p>
</div>
</li>
))}
</ol>
) : (
<div className="boss-guide-panel__no-specials" role="status">
<strong>Basic attacks only</strong>
<p>This boss has no special mechanics in the current runtime definition. There is no timer, health trigger, or warning area to verify yet.</p>
</div>
)}
</>
) : (
<div className="boss-guide-panel__no-specials" role="status">
<strong>No boss selected</strong>
<p>Select a dungeon with registered bosses to inspect its live mechanic definitions.</p>
</div>
)}
</main>
</div>
<footer className="game-panel__footer">
<span><kbd>↑↓</kbd> Browse</span><span><kbd>←→</kbd> Change column</span><span><kbd>A / Enter</kbd> Select</span><span><kbd>B / Esc</kbd> Pause menu</span>
</footer>
</section>
</div>
);
}
export function BossGuidePanel() {
const open = useGameStore((state) => state.overlay === "boss-guide");
return open ? <BossGuideDialog /> : null;
}
+38
View File
@@ -0,0 +1,38 @@
import { useSyncExternalStore } from "react";
import {
activeDungeonEncounterRevision,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import {
activeDungeonInteractionCandidate,
interactWithActiveDungeonObject,
} from "../game/dungeonWorldInteraction";
import { useGameStore } from "../game/store";
export function DungeonInteractionPrompt() {
const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode);
const overlay = useGameStore((state) => state.overlay);
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
void playerPosition;
const candidate = overlay ? null : activeDungeonInteractionCandidate();
if (!candidate) return null;
return (
<button
type="button"
className="dungeon-interaction-prompt"
onClick={() => interactWithActiveDungeonObject()}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
>
<span className="dungeon-interaction-prompt__key">{inputMode === "gamepad" ? "A" : "E"}</span>
<span>
<strong>{candidate.object.interaction!.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small>
</span>
</button>
);
}
+2
View File
@@ -14,6 +14,7 @@ import { EquipmentPanel } from "./EquipmentPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
const PartyManagementPanel = lazy(() => import("./PartyManagementPanel").then((module) => ({ default: module.PartyManagementPanel })));
const BossGuidePanel = lazy(() => import("./BossGuidePanel").then((module) => ({ default: module.BossGuidePanel })));
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
function loadTalentGameplayPanel() {
@@ -110,6 +111,7 @@ export function GameplayPanels() {
return (
<>
{overlay === "party" ? <Suspense fallback={null}><PartyManagementPanel /></Suspense> : null}
{overlay === "boss-guide" ? <Suspense fallback={null}><BossGuidePanel /></Suspense> : null}
<EquipmentPanel />
<AbilityBindingsPanel />
<SpellbookPanel abilities={spellbookAbilities} playerLevel={level} onActivate={castAbility} />
+3
View File
@@ -37,6 +37,7 @@ function PauseDialog() {
{ id: "pause-party", run: () => openOverlay("party") },
{ id: "pause-inventory", run: () => openOverlay("inventory") },
{ id: "pause-map", run: () => openOverlay("map") },
{ id: "pause-boss-guide", run: () => openOverlay("boss-guide") },
{ id: "pause-spellbook", run: () => openOverlay("spellbook") },
{ id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") },
@@ -71,6 +72,7 @@ function PauseDialog() {
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
{menuButton("pause-map", "Dungeon map", () => openOverlay("map"))}
{menuButton("pause-boss-guide", "Boss guide", () => openOverlay("boss-guide"))}
{menuButton("pause-spellbook", "Spellbook", () => openOverlay("spellbook"))}
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
@@ -90,6 +92,7 @@ function PauseDialog() {
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
<div><dt>Boss guide</dt><dd>Pause menu</dd></div>
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
<div><dt>Pause</dt><dd>Escape / Start</dd></div>
</dl>