Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import { createElement, Fragment, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const content = vi.hoisted(() => ({
installed: true,
update: true,
bundled: false,
install: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
}));
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => false },
}));
vi.mock("../content/contentManager", () => ({
checkForContentUpdates: vi.fn(async () => null),
contentPackHasUpdate: () => content.update,
contentPackIsBundled: () => content.bundled,
contentPackIsInstalled: () => content.installed,
installContentPack: content.install,
removeContentPack: content.remove,
}));
vi.mock("../content/useContentManager", () => ({
useContentManagerSnapshot: () => ({
revision: 1,
checking: false,
manifest: {
version: "content-test",
packs: [{
id: "characters:test",
label: "Test Characters",
bytes: 1024,
fileCount: 1,
bundledOnAndroid: false,
dependencies: [],
}],
},
bundledManifest: null,
installingPackId: null,
downloadedBytes: 0,
totalBytes: 0,
error: "",
}),
}));
vi.mock("../components/DualDisplayFrame", () => ({
DualDisplayFrame: ({ top }: { readonly top: ReactNode }) => createElement(Fragment, null, top),
}));
vi.mock("./LazyLocalCompanionPanel", () => ({
LazyLocalCompanionPanel: () => null,
}));
import { ContentManagerScreen } from "./ContentManagerScreen";
describe("browser Downloads actions", () => {
beforeEach(() => {
content.installed = true;
content.update = true;
content.bundled = false;
});
it("offers an update action when an installed browser pack has changed", () => {
const markup = renderToStaticMarkup(createElement(ContentManagerScreen, { onClose: () => undefined }));
expect(markup).toContain("Update available");
expect(markup).toContain(">Update</button>");
expect(markup).toContain(">Remove</button>");
});
it("offers download and remove actions for optional browser packs", () => {
content.installed = false;
content.update = false;
let markup = renderToStaticMarkup(createElement(ContentManagerScreen, { onClose: () => undefined }));
expect(markup).toContain("Not installed");
expect(markup).toContain(">Download</button>");
content.installed = true;
markup = renderToStaticMarkup(createElement(ContentManagerScreen, { onClose: () => undefined }));
expect(markup).toContain("Installed — offline ready");
expect(markup).toContain(">Remove</button>");
});
it("does not offer removal for bundled browser basics", () => {
content.update = false;
content.bundled = true;
const markup = renderToStaticMarkup(createElement(ContentManagerScreen, { onClose: () => undefined }));
expect(markup).toContain("Included — always available");
expect(markup).not.toContain(">Remove</button>");
});
});
+19 -15
View File
@@ -3,7 +3,8 @@ import { useMemo } from "react";
import {
checkForContentUpdates,
contentPackHasUpdate,
contentPackIsUsable,
contentPackIsBundled,
contentPackIsInstalled,
installContentPack,
removeContentPack,
} from "../content/contentManager";
@@ -40,10 +41,10 @@ export function ContentManagerScreen({ onClose }: { readonly onClose: () => void
<p className="front-kicker">Local-first installation</p>
<h1>Choose what stays on this device</h1>
<p>
Installed adventures work without a server connection. Updates download to a temporary file,
Installed content packs work without a server connection. Updates download to a temporary staging area,
verify their SHA-256, and only then replace the active version.
</p>
{!native && <p className="front-notice">The browser build already includes its complete content catalog.</p>}
{!native && <p className="front-notice">Basic content is included. Optional packs can be downloaded for offline play and removed when you want the storage back.</p>}
<div className="content-manager-summary__actions">
<button type="button" className="front-button" disabled={state.checking} onClick={() => void checkForContentUpdates()}>
{state.checking ? "Checking..." : "Check for updates"}
@@ -61,8 +62,9 @@ export function ContentManagerScreen({ onClose }: { readonly onClose: () => void
<section className="content-manager-packs" aria-label="Content packs">
{packs.length === 0 && <p className="content-manager-empty">Connect once to discover optional downloads. Bundled starter content remains playable offline.</p>}
{packs.map((pack) => {
const installed = contentPackIsUsable(pack.id);
const installed = contentPackIsInstalled(pack.id);
const update = contentPackHasUpdate(pack.id);
const bundled = contentPackIsBundled(pack.id);
const busy = state.installingPackId !== null;
return (
<article key={pack.id} className="content-pack-row">
@@ -71,18 +73,20 @@ export function ContentManagerScreen({ onClose }: { readonly onClose: () => void
<small>{formatBytes(pack.bytes)} / {pack.fileCount} files</small>
</div>
<span className={installed ? "is-installed" : ""}>
{update ? "Update available" : installed ? "Installed — offline ready" : "Not installed"}
{update ? "Update available" : bundled ? "Included — always available" : installed ? "Installed — offline ready" : "Not installed"}
</span>
{native && (!installed || update) && (
<button type="button" className="front-button front-button--primary" disabled={busy} onClick={() => void installContentPack(pack.id).catch(() => undefined)}>
{state.installingPackId === pack.id ? "Downloading..." : update ? "Update" : "Download"}
</button>
)}
{native && installed && !pack.bundledOnAndroid && !update && (
<button type="button" className="front-button" disabled={busy} onClick={() => void removeContentPack(pack.id).catch(() => undefined)}>
Remove
</button>
)}
<div className="content-pack-row__actions">
{(!installed || update) && (
<button type="button" className="front-button front-button--primary" disabled={busy} onClick={() => void installContentPack(pack.id).catch(() => undefined)}>
{state.installingPackId === pack.id ? "Downloading..." : update ? "Update" : "Download"}
</button>
)}
{installed && !bundled && (
<button type="button" className="front-button" disabled={busy} onClick={() => void removeContentPack(pack.id).catch(() => undefined)}>
Remove
</button>
)}
</div>
</article>
);
})}
+37
View File
@@ -0,0 +1,37 @@
import { useMemo } from "react";
import { encounterTimeline } from "../game/encounterTimeline";
import { useCombatStore } from "../game/combatStore";
function mechanicGlyph(shape: "circle" | "cone" | "line" | undefined): string {
if (shape === "cone") return "◢";
if (shape === "line") return "▰";
if (shape === "circle") return "●";
return "◆";
}
export function EncounterTimeline({ now }: { readonly now: number }) {
const mobs = useCombatStore((state) => state.mobs);
const model = useMemo(() => encounterTimeline(mobs, now), [mobs, now]);
if (!model) return null;
return (
<section className="encounter-timeline" aria-label={`${model.bossName} mechanic timeline`}>
<header><span>Mechanics</span><strong>{model.bossName}</strong></header>
<div className="encounter-timeline__rows">
{model.rows.map((row) => (
<div
key={row.id}
className={`encounter-timeline__row encounter-timeline__row--${row.kind}`}
data-school={row.school}
>
<span className="encounter-timeline__glyph" aria-hidden="true">
{mechanicGlyph(row.telegraphShape)}
</span>
<span className="encounter-timeline__name">{row.name}</span>
<strong>{row.value}</strong>
<i aria-hidden="true"><b style={{ transform: `scaleX(${row.progress})` }} /></i>
</div>
))}
</div>
</section>
);
}
+2
View File
@@ -18,6 +18,7 @@ import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar"
import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline";
import { PlayerFrame } from "./PlayerFrame";
import { PartyFrames } from "./PartyFrames";
import { TimedEffectStrip } from "./TimedEffectStrip";
@@ -355,6 +356,7 @@ export function Hud() {
onOpenMap={toggleMap}
/>
<PlayerStatusPanel now={clock} />
<EncounterTimeline now={clock} />
</div>
{target && settings.showTargetFrame && (
+30 -5
View File
@@ -1,16 +1,24 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const combatMedia = vi.hoisted(() => ({ ready: false }));
vi.mock("../game/combatMediaPack", async (importOriginal) => ({
...(await importOriginal<typeof import("../game/combatMediaPack")>()),
combatMediaPackReady: () => combatMedia.ready,
}));
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 {
function renderOptions(mode: CombatVfxMode, originalMediaEnabled = false): string {
return renderToStaticMarkup(createElement(OptionsPanel, {
settings: DEFAULT_GAMEPLAY_SETTINGS,
onChange: () => undefined,
onReset: () => undefined,
presentationSettings: { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, vfxMode: mode },
presentationSettings: { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled, vfxMode: mode },
onPresentationChange: () => undefined,
onPresentationReset: () => undefined,
open: true,
@@ -19,6 +27,10 @@ function renderOptions(mode: CombatVfxMode): string {
}
describe("combat visual mode option", () => {
beforeEach(() => {
combatMedia.ready = false;
});
it("cycles between generated and strict original selections", () => {
expect(nextCombatVfxMode("generated")).toBe("original");
expect(nextCombatVfxMode("original")).toBe("generated");
@@ -28,15 +40,28 @@ describe("combat visual mode option", () => {
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("Original spell effects &amp; sounds");
expect(markup).toContain("Not downloaded by default.");
expect(markup).toContain(">Download</button>");
expect(markup).toContain("Generated effects are used until the optional original media pack is downloaded.");
expect(markup).toContain(">Generated</button>");
});
it("states that original mode never substitutes generated effects", () => {
const markup = renderOptions("original");
combatMedia.ready = true;
const markup = renderOptions("original", true);
expect(markup).toContain("Use only packaged original source effects.");
expect(markup).toContain("Unsupported spells show no visual effect.");
expect(markup).toContain(">Original</button>");
expect(markup).toContain(">Remove</button>");
});
it("offers to enable an already downloaded pack without downloading it again", () => {
combatMedia.ready = true;
const markup = renderOptions("generated", false);
expect(markup).toContain("Stored on this device. Enable the pack");
expect(markup).toContain(">Enable</button>");
});
});
+81 -6
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useGameStore } from "../game/store";
import {
DEFAULT_THREAT_METER_POSITION,
@@ -20,6 +20,11 @@ import {
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "../game/frameRateSettings";
import {
combatMediaPackReady,
downloadCombatMediaPack,
removeCombatMediaPack,
} from "../game/combatMediaPack";
export interface OptionsPanelSettings {
showMobHealthBars: boolean;
@@ -68,6 +73,12 @@ const TOGGLES: readonly ToggleOption[] = [
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
type CombatMediaActionState =
| { readonly kind: "idle" }
| { readonly kind: "downloading"; readonly completed: number; readonly total: number }
| { readonly kind: "removing" }
| { readonly kind: "error"; readonly message: string };
export function nextCombatVfxMode(mode: CombatVfxMode): CombatVfxMode {
return mode === "generated" ? "original" : "generated";
}
@@ -90,6 +101,9 @@ function OptionsDialog({
onPresentationReset,
onClose,
}: Required<Omit<OptionsPanelProps, "open">>) {
const [combatMediaAction, setCombatMediaAction] = useState<CombatMediaActionState>({ kind: "idle" });
const combatMediaInstalled = combatMediaPackReady();
const originalMediaReady = presentationSettings.originalMediaEnabled && combatMediaInstalled;
const patchGroupBoolean = (
field: "vfxEnabled" | "audioEnabled",
group: CombatActorGroup,
@@ -102,9 +116,47 @@ 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 cycleVfxMode = () => {
if (!originalMediaReady) return;
onPresentationChange({ vfxMode: nextCombatVfxMode(presentationSettings.vfxMode) });
};
const toggleOriginalMedia = () => {
if (combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing") return;
if (combatMediaInstalled && !presentationSettings.originalMediaEnabled) {
onPresentationChange({ originalMediaEnabled: true });
return;
}
if (combatMediaInstalled) {
setCombatMediaAction({ kind: "removing" });
void removeCombatMediaPack().then(() => {
onPresentationChange({ originalMediaEnabled: false, vfxMode: "generated" });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
message: error instanceof Error ? error.message : "Could not remove the local combat media.",
}));
return;
}
setCombatMediaAction({ kind: "downloading", completed: 0, total: 1 });
void downloadCombatMediaPack(({ completed, total }) => {
setCombatMediaAction({ kind: "downloading", completed, total });
}).then(() => {
onPresentationChange({ originalMediaEnabled: true });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
message: error instanceof Error ? error.message : "Could not download the combat media.",
}));
};
const combatMediaButtonLabel = combatMediaAction.kind === "downloading"
? `Downloading ${Math.round(combatMediaAction.completed / Math.max(1, combatMediaAction.total) * 100)}%`
: combatMediaAction.kind === "removing"
? "Removing…"
: combatMediaInstalled && !presentationSettings.originalMediaEnabled
? "Enable"
: combatMediaInstalled
? "Remove"
: "Download";
const cycleGameplayFrameRate = () => onChange({
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
});
@@ -124,6 +176,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-original-media", run: toggleOriginalMedia },
{ id: "option-vfx-mode", run: cycleVfxMode },
{ id: "option-vfx-quality", run: cycleVfxQuality },
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
@@ -145,7 +198,7 @@ function OptionsDialog({
},
]),
{ id: "option-reset", run: () => { onReset(); onPresentationReset(); } },
], [onChange, onPresentationChange, onPresentationReset, onReset, presentationSettings, settings]);
], [combatMediaAction.kind, onChange, onPresentationChange, onPresentationReset, onReset, presentationSettings, settings]);
const controller = useMenuController(actions, { columns: 1, onBack: onClose });
const optionButton = (id: string, label: string, run: () => void, disabled = false) => (
@@ -229,10 +282,31 @@ function OptionsDialog({
}))}
</div>
<h3 className="options-panel__section-title">Combat presentation</h3>
<div className="option-row">
<span>
<strong>Original spell effects &amp; sounds</strong>
<small>{originalMediaReady
? "Stored on this device. Original visuals and authentic combat sounds are available."
: combatMediaInstalled
? "Stored on this device. Enable the pack to use original visuals and authentic combat sounds."
: "Not downloaded by default. Download this optional media pack to unlock original visuals and authentic combat sounds."}</small>
{combatMediaAction.kind === "error"
? <small role="status">{combatMediaAction.message}</small>
: null}
</span>
{optionButton(
"option-original-media",
combatMediaButtonLabel,
toggleOriginalMedia,
combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing",
)}
</div>
<div className="option-row">
<span>
<strong>Spell visual mode</strong>
<small>{presentationSettings.vfxMode === "original"
<small>{!originalMediaReady
? "Generated effects are used until the optional original media pack is downloaded."
: presentationSettings.vfxMode === "original"
? "Use only packaged original source effects. Unsupported spells show no visual effect."
: "Use the current generated combat effects."}</small>
</span>
@@ -240,6 +314,7 @@ function OptionsDialog({
"option-vfx-mode",
presentationSettings.vfxMode === "original" ? "Original" : "Generated",
cycleVfxMode,
!originalMediaReady,
)}
</div>
<div className="option-row">