Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -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,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>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user