forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 & losses</button>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user