Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 14:31:15 -04:00
1 parent ab1434a3b8
commit e284f4fc76
60 files changed
+2704 -118

No files matched your search

+16 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
import { raceAffixBoonUntil } from "../game/dungeonRaceAffixBuffs";
import { useGameStore } from "../game/store";
import "./dungeonRace.css";
@@ -18,18 +20,30 @@ export function DungeonRaceHud({ region }: { readonly region: "status" | "overla
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
const shownTime = race.clearDurationMs ?? elapsed;
const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
const affixes = race.affixIds?.map((id) => dungeonRaceAffix(id)).filter((entry) => entry !== undefined) ?? [];
if (region === "status") return (
<section className="dungeon-race-hud" aria-label="Dungeon race status">
<span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span>
<strong>{formatDungeonDuration(shownTime)}</strong>
<small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small>
<small className="dungeon-race-hud__affixes">Mechanics: {affixes.map((affix) => affix.name).join(" + ")}</small>
{raceAffixBoonUntil() > now && <small className="dungeon-race-hud__boon">
Party haste · {Math.ceil((raceAffixBoonUntil() - now) / 1_000)}s</small>}
</section>
);
return <>
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>}
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">
<strong>Race mechanics</strong>
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
<b>{affix.name}</b> — {affix.description}</p>)}</div>
<small>Waiting for the rival party to be ready…</small>
</div>}
{remaining !== null && remaining > 0 && race.status === "countdown" &&
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>}
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong>
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
<b>{affix.name}</b> — {affix.description}</p>)}</div>
</div>}
{toast && <div className={`dungeon-race-toast dungeon-race-toast--${race.result}`} role="status" aria-live="polite">
{race.result === "win" ? "You win" : "You lose"}
{race.result === "loss" ? <small>Keep going to claim the boss card.</small> : null}
+7 -2
View File
@@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog";
@@ -14,7 +15,8 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
readonly affixIds?: readonly string[] }
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
@@ -76,7 +78,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
<main className="dungeon-race-screen__layout">
<section className="dungeon-race-screen__setup">
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss.</p>
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss. Each race rolls one kill mechanic and one movement mechanic for both sides.</p>
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
@@ -133,9 +135,12 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
{boardScope === "dungeon" && boardView === "times" && <th>Mechanics</th>}
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
{boardScope === "dungeon" && boardView === "times" && <td>{entry.affixIds?.length
? entry.affixIds.map((id) => dungeonRaceAffix(id)?.name ?? id).join(" + ") : "Legacy"}</td>}
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
</section>
</main>
+23 -1
View File
@@ -16,6 +16,8 @@ import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { recentPracticeRaceCombatLog } from "../game/dungeonRaceCombatLog";
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
export interface RunPlacementState {
readonly scope: LeaderboardScope | "race";
@@ -26,13 +28,14 @@ export interface RunPlacementState {
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
startingAnother = false, anotherMessage }: {
onExportCombatLog, startingAnother = false, anotherMessage }: {
readonly progress: DungeonRunProgress;
readonly ranking: RunPlacementState;
readonly onFinish: () => void;
readonly onLoot: () => void;
readonly onRetry: () => void;
readonly onAnother?: () => void;
readonly onExportCombatLog?: () => void;
readonly startingAnother?: boolean;
readonly anotherMessage?: string;
}) {
@@ -80,10 +83,18 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
{ranking.scope === "race" && ranking.raceResult === "loss" ? <p className="dungeon-results__note">Your boss card and loot are still yours.</p> : null}
{ranking.scope === "race" && <div className="dungeon-results__note">Mechanics:
{useDungeonRaceStore.getState().affixIds?.map((id) => {
const affix = dungeonRaceAffix(id);
return <p key={id}><b>{affix?.name ?? id}</b>{affix ? ` — ${affix.description}` : ""}</p>;
}) ?? " None"}
</div>}
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
<footer>
{onExportCombatLog ? <ControllerButton {...controls} controlId="run-combat-log" className="button"
onClick={onExportCombatLog}>Download combat log</ControllerButton> : null}
{onAnother ? <ControllerButton {...controls} controlId="run-another" className="button button--primary"
disabled={startingAnother} onClick={onAnother}>
{startingAnother ? "Starting next dungeon…" : randomizedBiteSized
@@ -139,6 +150,17 @@ export function DungeonRunResults() {
? "The party leader can start the next random Bite-Sized dungeon."
: "";
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
onExportCombatLog={raceRun && race.mode === "practice" && recentPracticeRaceCombatLog()
? () => {
const log = recentPracticeRaceCombatLog();
if (!log) return;
const href = URL.createObjectURL(new Blob([JSON.stringify(log, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `${log.dungeonId}-${log.runId}-combat.json`;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(href), 0);
} : undefined}
onAnother={canStartAnother ? () => {
setStartingAnother(true);
setAnotherError("");
+5
View File
@@ -2,8 +2,13 @@
.dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78}
.dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1}
.dungeon-race-hud small{font-size:10px;white-space:nowrap}
.dungeon-race-hud .dungeon-race-hud__affixes{max-width:220px;white-space:normal;color:#ffdf9b;letter-spacing:.03em;line-height:1.2}
.dungeon-race-hud__boon{color:#f5cc56}
.race-affix-world-countdown{color:white;background:#101015e8;border:1px solid #c3a462;border-radius:6px;padding:2px 6px;font-size:11px;font-weight:700;white-space:nowrap}
.dungeon-race-wait,.dungeon-race-countdown{position:absolute;z-index:55;top:38%;left:50%;transform:translateX(-50%);padding:14px 22px;border:1px solid #ab945f;border-radius:12px;background:#091217ef;color:#f5e8ca;text-align:center;pointer-events:none}
.dungeon-race-countdown strong{display:block;font-size:42px}
.dungeon-race-countdown__affixes{max-width:440px;text-align:left;font-size:12px}
.dungeon-race-countdown__affixes p{margin:5px 0}
.dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none}
.dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8}
.dungeon-race-toast small{font-size:11px;color:#f5e8ca}