Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 18:08:43 -04:00
1 parent 3e30af4c98
commit 97ee0c13f9
25 files changed
+576 -179

No files matched your search

+2 -2
View File
@@ -14,11 +14,11 @@ export function FrontSurface({ children, className = "", bottom = false, ariaLab
);
}
export function BrandMark({ compact = false }: { compact?: boolean }) {
export function BrandMark({ compact = false, tagline = "Answer the call" }: { compact?: boolean; tagline?: string | null }) {
return (
<div className={`front-brand ${compact ? "front-brand--compact" : ""}`}>
<span className="brand-sigil" aria-hidden="true">+</span>
<span><small>Answer the call</small><strong>Healer Man</strong></span>
<span>{tagline && <small>{tagline}</small>}<strong>Healer Man</strong></span>
</div>
);
}
+22 -28
View File
@@ -1,9 +1,9 @@
import { lazy, Suspense, useMemo, useState } from "react";
import { classById, FACTIONS, raceById } from "../app/characterCatalog";
import { classById } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
@@ -170,8 +170,9 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
onBack: returnToCharacters,
});
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
const specialization = specializationById(character?.specializationId);
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
return (
<DualDisplayFrame
@@ -179,11 +180,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
top={
<FrontSurface className="main-menu-screen" ariaLabel="Healer Man main menu">
<header className="main-menu-header">
<BrandMark compact />
<div>
<span>Adventurer selected</span>
<strong>Choose your next journey</strong>
</div>
<BrandMark compact tagline={null} />
<ControllerButton
controlId="back-to-roster"
selectedId={controller.selectedId}
@@ -195,30 +192,27 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
</ControllerButton>
</header>
<aside className="main-menu-character" aria-label="Selected character">
<div className="main-menu-character__preview">
<CharacterPreview character={character} />
</div>
{character && race && characterClass && (
<div className="main-menu-character__summary">
<p className="front-kicker">Ready for adventure</p>
<h1>{character.name}</h1>
<strong style={{ color: characterClass.color }}>
Level {character.level} {race.name} {characterClass.name}
</strong>
<small>{FACTIONS[race.faction].name} / {character.gender}</small>
{isRom && <small>RoM / {character.secondaryClassId ? `${characterClass.name} + ${classById(character.secondaryClassId).name}` : "Secondary class not chosen"}</small>}
</div>
<aside className="main-menu-sidebar" aria-label="Character and online group">
{character && characterClass && (
<section className="main-menu-character" aria-label="Selected character">
<span className="main-menu-character__icon" style={{ borderColor: characterClass.color }}>
<img src={specializationIconUrl(character.classId, character.specializationId)} alt="" aria-hidden="true" />
</span>
<div className="main-menu-character__summary">
<small>Level {character.level}</small>
<h1>{character.name}</h1>
<strong style={{ color: characterClass.color }}>
{characterClass.name}{isRom
? secondaryClass ? ` / ${secondaryClass.name}` : " / No secondary class"
: specialization ? ` / ${specialization.displayName}` : ""}
</strong>
</div>
</section>
)}
<OnlineGroupPanel defaultOpen />
</aside>
<main className="main-menu-modes">
<header>
<p className="front-kicker">Main menu</p>
<h2>Ways to play</h2>
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-companion-readiness" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReadiness}>
+5 -2
View File
@@ -9,9 +9,12 @@ import {
type PartyRole,
} from "../game/partyRoles";
export function OnlineGroupPanel({ forceAiFill = false }: { readonly forceAiFill?: boolean }) {
export function OnlineGroupPanel({ forceAiFill = false, defaultOpen = false }: {
readonly forceAiFill?: boolean;
readonly defaultOpen?: boolean;
}) {
const [username, setUsername] = useState("");
const [panelOpen, setPanelOpen] = useState(false);
const [panelOpen, setPanelOpen] = useState(defaultOpen);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const snapshot = useOnlineGroupStore((state) => state.snapshot);
+22 -13
View File
@@ -8,22 +8,21 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
import { useContentManagerSnapshot } from "../content/useContentManager";
import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId,
} from "../game/dungeonContentSource";
import { randomizedBiteSizedCreatureCatalog } from "../game/dungeons/randomizedBiteSizedStockades";
import { dungeonById } from "../game/dungeonCatalog";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
installDungeonShellRuntime();
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const CANDIDATES = randomizedBiteSizedCreatureCatalog(requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID));
const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) {
useContentManagerSnapshot();
@@ -51,11 +50,21 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const launch = async (role: PartyRole) => {
if (!character || !DUNGEON?.available) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
bossDifficulty,
freshRun: true,
});
const runId = globalThis.crypto.randomUUID();
setDownloading(true); setError("");
try {
await ensureRandomizedBiteSizedRunContent(runId);
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
bossDifficulty,
freshRun: true,
runId,
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The randomized creature download failed.");
} finally {
setDownloading(false);
}
return;
}
if (!await updateOnlineMember(session, character, role)) return;
@@ -99,9 +108,9 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</header>
<main className="randomized-bite-sized-screen__layout">
<section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different cellblock every run</p>
<p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1>
<p>Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.</p>
<p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</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>
@@ -116,11 +125,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!installed ? (
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install Stockades content"}</button>
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install randomized dungeon content"}</button>
) : roles.length ? (
<div className="randomized-bite-sized-screen__roles">
<strong>Enter as</strong>
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available} onClick={() => void launch(role)}>{PARTY_ROLE_LABELS[role]}</button>)}
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
</div>
) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>}
{!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>}
+3 -9
View File
@@ -6,17 +6,11 @@ import {
useRandomizedCreatureReadiness,
} from "../app/randomizedCreatureReadiness";
import { useShellStore } from "../app/shellStore";
import { STORMWIND_STOCKADE_DUNGEON_ID } from "../game/dungeonContentSource";
import {
randomizedBiteSizedCreatureCatalog,
type RandomizedCreatureKind,
} from "../game/dungeons/randomizedBiteSizedStockades";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { RandomizedCreatureKind } from "../game/dungeons/randomizedBiteSizedStockades";
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface";
const CREATURES = randomizedBiteSizedCreatureCatalog(
requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID),
);
const CREATURES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
type KindFilter = "all" | RandomizedCreatureKind;
type StatusFilter = "all" | CreatureReadinessStatus;
+7
View File
@@ -125,6 +125,13 @@
.roster-character-info { width: min(215px, 42%); padding: 13px; }
.roster-character-info h1 { font-size: 30px; }
.roster-footer { padding-top: 6px; }
.main-menu-screen { grid-template-columns: minmax(0, 1fr) 285px; grid-template-rows: 50px minmax(0, 1fr) 24px; gap: 8px; }
.main-menu-sidebar { grid-column: 2; grid-row: 2; display: block; max-height: none; overflow-y: auto; }
.main-menu-character { grid-template-columns: 48px minmax(0, 1fr); gap: 9px; padding: 10px; }
.main-menu-character__icon { width: 46px; height: 46px; }
.main-menu-character__summary h1 { font-size: 21px; }
.main-menu-modes { grid-column: 1; grid-row: 2; padding: 0 2px 8px 0; }
.main-menu-mode-grid { margin-top: 0; }
.character-create-screen { grid-template-columns: 225px minmax(220px, 1fr) 260px; grid-template-rows: 50px 3px minmax(0, 1fr) 60px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 4; }
.creator-races { grid-column: 1; }