Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 21:48:00 -04:00
1 parent 15a5254380
commit 9d7e0e2f9e
71 files changed
+3490 -81

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { isLayoutBiteSizedDungeon } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
export function useBiteSizedBoardSelection(dungeonId: string) {
const config = useBiteSizedLayoutsStore((s) => s.config);
const history = useBiteSizedLayoutsStore((s) => s.historicalSpots);
const [spotId, setSpotId] = useState("");
const [revision, setRevision] = useState<number | null>(null);
const enabled = isLayoutBiteSizedDungeon(dungeonId);
useEffect(() => { if (enabled) void useBiteSizedLayoutsStore.getState().loadHistory(); }, [enabled, dungeonId]);
const spots = [...config.spots, ...history].filter((s) => dungeonId.startsWith("randomized-") || s.dungeonId === dungeonId)
.filter((s, i, all) => all.findIndex((other) => other.id === s.id) === i);
const spot = spots.find((s) => s.id === spotId) ?? spots[0];
const revisions = [...new Set([...history, ...config.spots].filter((s) => s.id === spot?.id).map((s) => s.revision))].sort((a, b) => b - a);
const spotRevision = revision !== null && revisions.includes(revision) ? revision : spot?.revision;
return { enabled, spots, spotId: enabled ? spot?.id : undefined, spotRevision: enabled ? spotRevision : undefined,
revisions, selectSpot: (id: string) => { setSpotId(id); setRevision(null); }, selectRevision: setRevision };
}
export function BiteSizedBoardFilters({ selection }: { readonly selection: ReturnType<typeof useBiteSizedBoardSelection> }) {
if (!selection.enabled) return null;
return <div className="bite-sized-board-filters" style={{ display: "flex", gap: 12, padding: "12px 0", flexWrap: "wrap" }}>
<label>Spot<select aria-label="Leaderboard spot" value={selection.spotId} onChange={(e) => selection.selectSpot(e.target.value)}>
{selection.spots.map((s) => <option key={s.id} value={s.id}>{s.name} · {s.sourceDungeonId}</option>)}
</select></label>
<label>Gameplay revision<select aria-label="Leaderboard revision" value={selection.spotRevision} onChange={(e) => selection.selectRevision(Number(e.target.value))}>
{selection.revisions.map((r) => <option key={r} value={r}>Revision {r}</option>)}
</select></label>
</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, selectBiteSizedRun, type BiteSizedSpot, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { useShellStore } from "../app/shellStore";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useGameStore } from "../game/store";
import { enterBiteSizedEditorSpot, leaveBiteSizedEditor } from "../app/biteSizedEditorRuntime";
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
import { FrontSurface } from "./FrontSurface";
import { releasePointerLock } from "../game/inputManager";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import "./biteSizedEditor.css";
export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean }) {
const layouts = useBiteSizedLayoutsStore();
const session = useShellStore((s) => s.session);
const grounded = useGameStore((s) => s.playerGrounded);
const testComplete = useDungeonRunStore((s) => s.progress?.finishedAt != null);
const [dungeonId, setDungeonId] = useState(BITE_SIZED_LAYOUT_CATALOG.dungeons[0].id);
const [collapsed, setCollapsed] = useState(false);
const [search, setSearch] = useState("");
const [crossDungeon, setCrossDungeon] = useState(false);
const [creatureKey, setCreatureKey] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const current = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
const activeDungeon = inGame && current ? current.dungeonId : dungeonId;
const spots = layouts.draft.spots.filter((s) => s.dungeonId === activeDungeon);
const spot = current?.dungeonId === activeDungeon ? current : spots[0];
const selectedPoint = layouts.editingPointId;
const mob = spot?.mobs.find((m) => m.id === selectedPoint);
const position = mob?.position ?? (selectedPoint === "boss" ? spot?.bossSpawn.footPosition : spot?.partySpawn.footPosition);
const yaw = mob?.yaw ?? (selectedPoint === "boss" ? spot?.bossSpawn.yaw : spot?.partySpawn.yaw) ?? 0;
const creatures = useMemo(() => BITE_SIZED_LAYOUT_CATALOG.creatures.filter((c) => c.kind === "mob"
&& (crossDungeon || c.sourceDungeonId === spot?.sourceDungeonId)
&& `${c.name} ${c.dungeonTitle}`.toLowerCase().includes(search.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name)), [crossDungeon, search, spot?.sourceDungeonId]);
const creature = creatures.find((c) => `${c.sourceDungeonId}:${c.entityId}` === creatureKey) ?? creatures[0];
useEffect(() => { if (!inGame) void useBiteSizedLayoutsStore.getState().refresh(); }, [inGame]);
useEffect(() => () => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); }, []);
if (!sessionCanUseGmTools(session) || (inGame && !layouts.editorMode)) return null;
const attempt = async (action: () => Promise<unknown>) => {
setBusy(true); setMessage("");
try { await action(); } catch (error) { setMessage(error instanceof Error ? error.message : "Action failed."); }
finally { setBusy(false); }
};
const select = (id: string) => useBiteSizedLayoutsStore.setState({ editingSpotId: id, editingPointId: "party" });
const updatePoint = (next: LayoutVector, facing = yaw) => {
if (!spot) return;
const changed = selectedPoint === "boss" ? { ...spot, bossSpawn: { footPosition: next, yaw: facing } }
: mob ? { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, position: next, yaw: facing } : m) }
: { ...spot, partySpawn: { footPosition: next, yaw: facing } };
layouts.updateSpot(changed);
};
const capture = () => { const game = useGameStore.getState(); if (game.playerGrounded) updatePoint([...game.playerPosition], game.cameraYaw); };
const preparePreview = async (next: BiteSizedSpot) => {
const currentDraft = useBiteSizedLayoutsStore.getState().draft;
const draft = { ...currentDraft, spots: currentDraft.spots.map((s) => s.id === next.id ? next : s) };
const selection = selectBiteSizedRun(draft, next.dungeonId, "gm-preview", { spotId: next.id });
await prepareBiteSizedLayoutContent(next.dungeonId, "gm-preview", selection);
};
const addMob = () => {
if (!spot || !creature) return;
const game = useGameStore.getState();
const id = `mob-${crypto.randomUUID()}`;
const next = { ...spot, mobs: [...spot.mobs, { id, sourceDungeonId: creature.sourceDungeonId, entityId: creature.entityId,
position: inGame ? [...game.playerPosition] as LayoutVector : spot.partySpawn.footPosition, yaw: inGame ? game.cameraYaw : 0 }] };
void attempt(async () => { await preparePreview(next); layouts.updateSpot(next); useBiteSizedLayoutsStore.setState({ editingPointId: id }); });
};
const createSpot = (duplicate: boolean) => {
if (!spot) return;
const id = `spot-${crypto.randomUUID()}`;
const game = useGameStore.getState();
const next: BiteSizedSpot = duplicate ? { ...spot, id, revision: 0, name: `${spot.name.slice(0, 90)} copy`, mobs: spot.mobs.map((m) => ({ ...m, id: `mob-${crypto.randomUUID()}` })) }
: { ...spot, id, revision: 0, name: "New spot", legacyLayout: false, mobs: [], bossActivation: "after-mobs",
partySpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn,
bossSpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn };
layouts.replaceDraft({ schemaVersion: 1, spots: [...layouts.draft.spots, next] }); select(id);
if (inGame) void attempt(() => enterBiteSizedEditorSpot(id));
};
const publish = () => void attempt(async () => {
const draft = normalizeBiteSizedConfig(useBiteSizedLayoutsStore.getState().draft);
for (const changed of draft.spots) {
const previous = layouts.config.spots.find((s) => s.id === changed.id);
if (!previous || biteSizedGameplayKey(previous) !== biteSizedGameplayKey(changed)) await preparePreview(changed);
}
await validateChangedBiteSizedSpots(draft.spots, layouts.config.spots);
if (await layouts.publish(session)) setMessage("Published. New runs use these layouts; active runs keep their saved spot.");
});
const content = <section className={`bite-sized-editor ${inGame ? "bite-sized-editor--world" : ""} ${collapsed ? "is-collapsed" : ""}`}
aria-label="Bite-Sized Editor" onPointerDown={() => releasePointerLock()}
onFocusCapture={() => { releasePointerLock(); useBiteSizedLayoutsStore.setState({ panelFocused: true }); }}
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) useBiteSizedLayoutsStore.setState({ panelFocused: false }); }}>
<header><div><p className="eyebrow">GM world authoring</p><h1>Bite-Sized Editor</h1></div>
{inGame && <button onClick={() => { setCollapsed(!collapsed); useBiteSizedLayoutsStore.setState({ panelFocused: false }); }} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button>}
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
{!collapsed && <div className="bite-sized-editor__body">
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
{spot && <>
<div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id))}>{layouts.editorMode === "test" ? "Return to editing" : "Go to spot"}</button>
<button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id, "test"))}>Test run</button></div>
{layouts.editorMode !== "test" && <>
<label>Spot name<input value={spot.name} maxLength={100} onChange={(e) => layouts.updateSpot({ ...spot, name: e.target.value })} /></label>
<div className="bite-sized-editor__actions"><button disabled={busy || spots.length >= 80} onClick={() => createSpot(false)}>New spot</button><button disabled={busy || spots.length >= 80} onClick={() => createSpot(true)}>Duplicate spot</button>
<button disabled={busy || spots.length <= 1} onClick={() => { layouts.replaceDraft({ schemaVersion: 1, spots: layouts.draft.spots.filter((s) => s.id !== spot.id) }); const next = spots.find((s) => s.id !== spot.id)!; select(next.id); if (inGame) void attempt(() => enterBiteSizedEditorSpot(next.id)); }}>Remove spot</button></div>
<label>Boss arrival<select aria-label="Boss arrival" value={spot.bossActivation} onChange={(e) => layouts.updateSpot({ ...spot, bossActivation: e.target.value as BiteSizedSpot["bossActivation"] })}><option value="after-mobs">After all mobs die</option><option value="immediate">Present from start</option></select></label>
<div className="bite-sized-editor__points"><button aria-pressed={selectedPoint === "party"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "party" })}>Party start</button><button aria-pressed={selectedPoint === "boss"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "boss" })}>Random boss spawn</button></div>
<label>Mob points ({spot.mobs.length})<select value={mob?.id ?? ""} onChange={(e) => useBiteSizedLayoutsStore.setState({ editingPointId: e.target.value || "party" })}><option value="">Select a mob point</option>{spot.mobs.map((m, i) => <option key={m.id} value={m.id}>{i + 1}. {BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === m.sourceDungeonId && c.entityId === m.entityId)?.name ?? m.entityId}</option>)}</select></label>
{position && <fieldset><legend>{mob ? "Mob spawn" : selectedPoint === "boss" ? "Boss spawn" : "Party anchor"}</legend>
<div className="bite-sized-editor__coordinates">{["X", "Y", "Z"].map((axis, i) => <label key={axis}>{axis}<input aria-label={`Spawn ${axis}`} type="number" step="0.1" value={position[i]} onChange={(e) => { const n = e.currentTarget.valueAsNumber; if (Number.isFinite(n)) { const next = [...position] as [number, number, number]; next[i] = n; updatePoint(next); } }} /></label>)}</div>
<label>Facing<input aria-label="Spawn facing" type="number" step="0.05" value={yaw} onChange={(e) => { if (Number.isFinite(e.currentTarget.valueAsNumber)) updatePoint(position, e.currentTarget.valueAsNumber); }} /></label>
<div className="bite-sized-editor__actions"><button disabled={!inGame || !grounded} onClick={capture}>Set here</button><button disabled={!inGame} onClick={() => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); useGameStore.getState().relocatePlayer({ footPosition: position, yaw }); }}>Go to point</button></div>
</fieldset>}
<h2>Dungeon creatures</h2><label>Search mobs<input aria-label="Search mobs" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Name or source dungeon" /></label>
<label className="bite-sized-editor__check"><input type="checkbox" checked={crossDungeon} onChange={(e) => setCrossDungeon(e.target.checked)} />Other dungeons</label>
<label>Mob type<select aria-label="Mob type" value={creature ? `${creature.sourceDungeonId}:${creature.entityId}` : ""} onChange={(e) => setCreatureKey(e.target.value)}>{creatures.map((c) => <option key={`${c.sourceDungeonId}:${c.entityId}`} value={`${c.sourceDungeonId}:${c.entityId}`}>{c.name}{crossDungeon ? ` · ${c.dungeonTitle}` : ""}</option>)}</select></label>
<div className="bite-sized-editor__actions"><button disabled={busy || !creature || (inGame && !grounded) || spot.mobs.length >= 500} onClick={addMob}>{inGame ? "Add mob here" : "Add mob point"}</button>
{mob && <button disabled={busy || !creature} onClick={() => void attempt(async () => { const next = { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, sourceDungeonId: creature!.sourceDungeonId, entityId: creature!.entityId } : m) }; await preparePreview(next); layouts.updateSpot(next); })}>Change mob type</button>}</div>
{mob && <div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => { const id = `mob-${crypto.randomUUID()}`; layouts.updateSpot({ ...spot, mobs: [...spot.mobs, { ...mob, id, position: [mob.position[0] + 1, mob.position[1], mob.position[2]] }] }); useBiteSizedLayoutsStore.setState({ editingPointId: id }); }}>Duplicate mob point</button><button onClick={() => { layouts.updateSpot({ ...spot, mobs: spot.mobs.filter((m) => m.id !== mob.id) }); useBiteSizedLayoutsStore.setState({ editingPointId: "party" }); }}>Remove mob point</button></div>}
<p className="bite-sized-editor__hint">Bosses are rolled per run. Randomized mode uses these points with creatures from its selected pool.</p>
</>}
</>}
{(message || layouts.error) && <p role="status">{message || layouts.error}</p>}
<footer><button disabled={!layouts.dirty || busy || layouts.saving} onClick={() => { layouts.discard(); setMessage("Draft discarded."); }}>Discard</button><button disabled={busy || layouts.loading} onClick={() => void layouts.refresh()}>Refresh published</button><button disabled={!layouts.dirty || busy || layouts.saving} onClick={publish}>{busy || layouts.saving ? "Working…" : "Save & publish"}</button></footer>
</div>}
</section>;
return inGame ? content : <FrontSurface className="bite-sized-editor-browser" ariaLabel="Bite-Sized spot browser">{content}</FrontSurface>;
}
+6 -2
View File
@@ -13,6 +13,7 @@ import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/p
import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
@@ -46,18 +47,20 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
const spotBoard = useBiteSizedBoardSelection(boardDungeonId);
useEffect(() => {
const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}) } : {}) });
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}),
...(boardView === "times" && spotBoard.spotId ? { spot: spotBoard.spotId, spotRevision: String(spotBoard.spotRevision) } : {}) } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId]);
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId, spotBoard.spotId, spotBoard.spotRevision]);
const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : [];
@@ -121,6 +124,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
</section>
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
<h2>Leaderboards</h2>
{boardScope === "dungeon" && boardView === "times" && <BiteSizedBoardFilters selection={spotBoard} />}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardScope === "overall"} onClick={() => setBoardScope("overall")}>Overall wins</button>
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
+1
View File
@@ -68,6 +68,7 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
<p>The final boss is defeated. Your expedition is complete.</p>
{progress.biteSizedSelection && <p>{progress.biteSizedSelection.spot.name} · layout revision {progress.biteSizedSelection.spot.revision}</p>}
</header>
<div className="dungeon-results__score">
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
+23 -5
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { classById, raceById } from "../app/characterCatalog";
@@ -12,7 +13,7 @@ import {
type DungeonCategoryId,
type DungeonId,
} from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { dungeonDefinitionById, legacyBiteSizedRunSelection } from "../game/dungeonRegistry";
import {
partyRolesForClass,
PARTY_ROLE_LABELS,
@@ -38,6 +39,7 @@ import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
installDungeonShellRuntime();
@@ -81,6 +83,7 @@ export function DungeonSelectScreen() {
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
const [downloadError, setDownloadError] = useState("");
const [downloading, setDownloading] = useState(false);
const [preparingSpot, setPreparingSpot] = useState(false);
const categoryDungeons = dungeonsByCategory(selectedCategory);
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
@@ -105,8 +108,9 @@ export function DungeonSelectScreen() {
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].id);
};
const spotBoard = useBiteSizedBoardSelection(selectedDungeon?.id ?? "");
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal",
clearFilter, bossDifficulty, selectedBoss?.id);
clearFilter, bossDifficulty, selectedBoss?.id, spotBoard.spotId, spotBoard.spotRevision);
const toggleRun = (id: string) => setExpandedRuns((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
@@ -140,9 +144,20 @@ export function DungeonSelectScreen() {
};
const continueAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!selectedDungeon?.available || !character || preparingSpot) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty });
setPreparingSpot(true);
useShellStore.getState().setNotice("Preparing dungeon and creature downloads...");
try {
const checkpoint = character.dungeonRunCheckpoint;
const resume = checkpoint?.dungeonId === selectedDungeon.id && checkpoint.run?.finishedAt == null
&& (leaderboardDifficulty?.id === undefined || checkpoint.difficultyId === leaderboardDifficulty.id)
&& checkpoint.run?.bossDifficulty === bossDifficulty ? checkpoint.run : undefined;
const runId = resume?.id ?? crypto.randomUUID();
const biteSizedSelection = await prepareBiteSizedLayoutContent(selectedDungeon.id, runId, resume ? resume.biteSizedSelection ?? legacyBiteSizedRunSelection(selectedDungeon.id, runId) : undefined);
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty, runId, ...(biteSizedSelection ? { biteSizedSelection } : {}) });
} catch (error) { useShellStore.getState().setNotice(error instanceof Error ? error.message : "Could not prepare the spot."); }
finally { setPreparingSpot(false); }
return;
}
if (!await updateOnlineMember(session, character, role)) return;
@@ -240,6 +255,7 @@ export function DungeonSelectScreen() {
? availableRoles.map((role) => ({
id: `dungeon-role-${role}`,
run: () => enterAsRole(role),
enabled: !preparingSpot,
}))
: [{
id: "choose-dungeon-role",
@@ -251,7 +267,7 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
], [availableRoles, categoryDungeons, downloading, preparingSpot, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload
@@ -389,6 +405,7 @@ export function DungeonSelectScreen() {
select={controller.select}
className={`dungeon-role-option dungeon-role-option--${role}`}
type="button"
disabled={preparingSpot}
onClick={() => enterAsRole(role)}
>
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
@@ -433,6 +450,7 @@ export function DungeonSelectScreen() {
</section>
{leaderboardOpen && (
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
<BiteSizedBoardFilters selection={spotBoard} />
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
+1
View File
@@ -225,6 +225,7 @@ export function DungeonSimulationAdminScreen() {
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
<button className="front-button" type="button" onClick={() => setQueuePolicyOpen(true)}>Random queues</button>
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</button>
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
+1
View File
@@ -130,6 +130,7 @@ export function GmAdminScreen() {
<BrandMark compact />
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
<div className="gm-admin-header__actions">
<button className="front-button" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
<button className="front-button" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
{saving ? "Publishing…" : "Save & publish"}
+5
View File
@@ -222,6 +222,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
] : gmMenuOpen ? [
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
...(isGm ? [{ id: "gm-menu-bite-sized", run: () => useShellStore.setState({ phase: "bite-sized-editor" }) }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
@@ -445,6 +446,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={openGmAdmin}>
<b aria-hidden="true">GM</b><span><strong>GM Tools</strong><small>Run dungeon simulations, inspect findings, and review recordings.</small></span><em>{isGm ? "Authorized" : "GM account required"}</em>
</ControllerButton>
{isGm && <ControllerButton controlId="gm-menu-bite-sized" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>
<b aria-hidden="true">BS</b><span><strong>Bite-Sized Editor</strong><small>Visit spots and place party, mob, and boss spawns.</small></span><em>Author</em>
</ControllerButton>}
<ControllerButton controlId="gm-menu-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Fight a selected or random encounter in an empty testing room.</small></span><em>Open</em>
+3 -2
View File
@@ -21,6 +21,7 @@ import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
import { BITE_SIZED_LAYOUT_CATALOG } from "../../shared/bite-sized-layouts.mjs";
installDungeonShellRuntime();
@@ -50,7 +51,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const dungeonId = pool === "all" ? RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID : RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID;
const DUNGEON = dungeonById(dungeonId);
const CANDIDATES = pool === "all" ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG : BITE_SIZED_RANDOMIZED_CREATURE_CATALOG;
const installed = dungeonContentIsUsable(dungeonId);
const installed = BITE_SIZED_LAYOUT_CATALOG.dungeons.some((d) => dungeonContentIsUsable(d.sourceDungeonId));
const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
@@ -124,7 +125,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
<section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1>
<p>Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.</p>
<p>Visit a random saved spot in an eligible dungeon. Each mob point and the final boss draw creatures from the pool you choose.</p>
<div className="randomized-bite-sized-screen__odds">
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
+28
View File
@@ -0,0 +1,28 @@
.bite-sized-editor { --spot-accent: #e8c580; color: #f4ebd8; background: rgba(22, 21, 26, .96); border: 1px solid #635443; border-radius: 12px; padding: 14px; width: min(680px, 94vw); margin: 20px auto; box-shadow: 0 12px 40px #0008; }
.bite-sized-editor--world { position: absolute; z-index: 45; top: 12px; right: 12px; width: min(330px, 30vw); max-height: calc(100dvh - 76px); margin: 0; display: flex; flex-direction: column; }
.bite-sized-editor--world header { flex-shrink: 0; }
.bite-sized-editor--world .bite-sized-editor__body { min-height: 0; max-height: none; flex: 1 1 auto; }
.bite-sized-editor header { display: flex; gap: 8px; align-items: center; }
.bite-sized-editor header > div { flex: 1; }
.bite-sized-editor h1 { font-size: 19px; margin: 2px 0 8px; }
.bite-sized-editor h2 { font-size: 15px; margin: 14px 0 5px; color: var(--spot-accent); }
.bite-sized-editor__body { overflow-y: auto; max-height: calc(100dvh - 110px); display: grid; gap: 9px; padding-right: 3px; }
.bite-sized-editor label { display: grid; gap: 4px; font-size: 12px; }
.bite-sized-editor input, .bite-sized-editor select { min-width: 0; width: 100%; box-sizing: border-box; padding: 7px; color: #f4ebd8; background: #17171d; border: 1px solid #615747; border-radius: 5px; }
.bite-sized-editor button { color: #f4ebd8; background: #3b332a; border: 1px solid #74614c; border-radius: 5px; padding: 7px 9px; font: inherit; font-size: 12px; cursor: pointer; }
.bite-sized-editor button:disabled { opacity: .45; cursor: default; }
.bite-sized-editor button[aria-pressed="true"] { border-color: var(--spot-accent); background: #55412b; }
.bite-sized-editor fieldset { border: 1px solid #615747; border-radius: 8px; display: grid; gap: 8px; padding: 10px; }
.bite-sized-editor__coordinates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.bite-sized-editor__actions, .bite-sized-editor__points, .bite-sized-editor footer { display: flex; gap: 6px; flex-wrap: wrap; }
.bite-sized-editor__status, .bite-sized-editor__hint { font-size: 12px; color: #cfbda1; margin: 3px 0; }
.bite-sized-editor .bite-sized-editor__check { display: flex; gap: 8px; align-items: center; }
.bite-sized-editor__check input { width: auto; }
.bite-sized-editor footer { border-top: 1px solid #615747; padding-top: 10px; }
.bite-sized-editor footer button:last-child { background: #70562d; }
.bite-sized-editor--world.is-collapsed { width: 235px; }
@media (max-width: 760px) {
.bite-sized-editor--world { width: min(300px, 83vw); max-height: 72dvh; }
.bite-sized-editor--world .bite-sized-editor__body { max-height: 58dvh; }
.bite-sized-editor--world.is-collapsed { width: 210px; }
}
+5 -5
View File
@@ -4,13 +4,13 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter,
bossDifficulty = 1, bossId?: string) {
bossDifficulty = 1, bossId?: string, spotId?: string, spotRevision?: number) {
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
data: { entries: [], total: 0 }, scope: "world", loading: true,
});
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId]);
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId, spotId, spotRevision]);
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
useEffect(() => {
const refresh = () => setRevision((value) => value + 1);
@@ -22,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
let live = true;
const timeout = setTimeout(() => controller.abort(), 8000);
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId)
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId, spotId, spotRevision)
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId),
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId, spotId, spotRevision),
scope: "device", loading: false }); });
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
return () => { live = false; controller.abort(); clearTimeout(timeout); };
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId]);
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId, spotId, spotRevision]);
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
}