Compare commits

..
1 Commits
Author SHA1 Message Date
Warren H 77fd434226 Release v0.1.7 2026-07-12 2026-07-12 23:39:57 -04:00
31 changed files with 545 additions and 113 deletions
+2 -2
View File
@@ -131,10 +131,10 @@ outside the repository.
- `WASD` / left stick: move
- `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
- Gamepad: PlayStation ``, ``, ``, ``, `L1`, `R1` map to those abilities
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / Start: begin or reset encounter
- `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory.
+12
View File
@@ -54,3 +54,15 @@ CREATE TABLE IF NOT EXISTS roguelike_records (
CREATE INDEX IF NOT EXISTS roguelike_rank_idx
ON roguelike_records (highest_round DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.6",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+38
View File
@@ -217,6 +217,14 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
highest_round = excluded.highest_round,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestRound);
const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills);
database.prepare(`
INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
}
function writeSave(database, accountId, slotId, rawSave) {
@@ -324,6 +332,32 @@ function roguelikeLeaderboard(database, accountId, slotId) {
};
}
function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_boss_kills AS highestBossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM rogue_trials_endless_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_boss_kills > 0
)
SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "rogue-trials-endless",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")),
current: current ? leaderboardEntry(current, "highestBossKills") : null,
};
}
export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
@@ -387,6 +421,10 @@ export function createGameApiHandler(options = {}) {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
+14 -4
View File
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
return { response, body };
}
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) {
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
return {
schemaVersion: 5,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound },
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
};
}
@@ -62,13 +62,14 @@ test("accounts, server saves, and top-five plus current rankings work end to end
const token = registration.body.token;
const kills = 60 - index * 10;
const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
const upload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }),
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound });
players.push({ token, kills, highestRound, highestEndlessKills });
}
const current = players[5];
@@ -87,6 +88,15 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(rogueBoard.body.current.rank, 6);
assert.equal(rogueBoard.body.current.value, current.highestRound);
const endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
assert.equal(endlessBoard.body.top.length, 5);
assert.equal(endlessBoard.body.top[0].value, 24);
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
+21 -9
View File
@@ -8,7 +8,7 @@ import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -33,6 +33,7 @@ export default function App() {
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
@@ -69,6 +70,11 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
useEffect(() => {
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -79,8 +85,11 @@ export default function App() {
useEffect(() => {
return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat";
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
if (state.phase === "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
rewardedBossInstances.current.clear();
}
if (startedFreshEncounter) clearRecentRewards();
@@ -88,11 +97,14 @@ export default function App() {
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`;
const primaryInstanceId = state.bossInstanceId;
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId);
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
@@ -102,14 +114,14 @@ export default function App() {
const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId);
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat]);
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return (
<main className="app-shell">
@@ -118,7 +130,7 @@ export default function App() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
+37 -14
View File
@@ -6,6 +6,7 @@ import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../g
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -170,7 +171,7 @@ function BriefingPanel() {
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -189,29 +190,51 @@ function BriefingPanel() {
);
}
function EndPanel() {
function EndPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
</div>
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
}
@@ -225,16 +248,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div>
);
}
function CombatPanel() {
function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -340,7 +363,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
export function BottomScreen() {
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
@@ -358,13 +381,13 @@ export function BottomScreen() {
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel />}
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small>
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
@@ -67,7 +68,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button>
)}
</div>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
+36 -19
View File
@@ -37,6 +37,7 @@ import {
import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
@@ -76,7 +77,7 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
}
function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b></b> Navigate</span></div>;
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function LoginScreen() {
@@ -335,7 +336,7 @@ function SaveScreen() {
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
</FocusButton>
<div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
@@ -399,7 +400,6 @@ function HomeScreen() {
top={
<FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
<div className="mode-grid">
{HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
@@ -441,6 +441,8 @@ function HomeScreen() {
);
}
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
@@ -449,11 +451,11 @@ function ProfileScreen() {
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<BossId | "roguelike">("roguelike");
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]);
useEffect(() => {
@@ -467,6 +469,8 @@ function ProfileScreen() {
setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => {
if (cancelled) return;
@@ -484,12 +488,13 @@ function ProfileScreen() {
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(collectionView === "stats" ? [
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: `stat-${collection.bosses[0].bossId}` } },
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`,
up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
},
})),
@@ -503,6 +508,16 @@ function ProfileScreen() {
const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
return (
<DualDisplayFrame
@@ -512,7 +527,7 @@ function ProfileScreen() {
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{collectionView === "loot" ? <>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
@@ -540,18 +555,19 @@ function ProfileScreen() {
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Highest roguelike round {hunter.stats.highestRoguelikeRound}</b></div>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section>
<section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}</strong></span><b>{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}</b></header>
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}</em></div>
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section>
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
@@ -567,6 +583,7 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
</div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
@@ -701,7 +718,7 @@ function GearScreen() {
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
@@ -764,7 +781,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -795,7 +812,7 @@ function SettingsScreen() {
<DualDisplayFrame
top={
<FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
@@ -808,9 +825,9 @@ function SettingsScreen() {
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b></b><i className="b">B</i></span><i className="a">A</i></div>
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b></b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
</div>
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>Start</b> Pause / menu</span></div>
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
@@ -908,7 +925,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
? [
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."],
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
]
: isPve
? [
@@ -925,7 +942,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<DualDisplayFrame
top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
@@ -973,7 +990,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{message && <div className="front-notice">{message}</div>}
</FrontSurface>
}
+20 -12
View File
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -109,6 +110,9 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -116,6 +120,8 @@ function PhaseOverlay() {
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
@@ -123,25 +129,25 @@ function PhaseOverlay() {
const title = phase === "briefing"
? room.name
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" ");
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
</div>
);
}
@@ -168,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button>
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>A</small></button>
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
<footer><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
</div>
</div>
);
@@ -187,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
@@ -197,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>}
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<PhaseOverlay />
<PauseOverlay onExit={onExit} />
+1 -1
View File
@@ -7,7 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
expect(MODE_COPY["rogue-trials"].detail).toContain("unseen");
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
});
+3 -2
View File
@@ -75,8 +75,8 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
"rogue-trials": {
eyebrow: "14 hunters · five-round PVE trial",
title: "Rogue Trials",
description: "Build through four randomized dual-boss rounds, then face three bosses together in a final trial.",
detail: "Round 5 trio always uses bosses unseen during that run",
description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
detail: "Endless mode replaces every fallen boss and tracks your best kill count",
status: "Playable now",
},
dungeons: {
@@ -139,6 +139,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
healingDone: 0,
bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
},
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
+5 -1
View File
@@ -22,7 +22,7 @@ export interface LeaderboardEntry {
}
export interface LeaderboardResult {
kind: "boss" | "roguelike";
kind: "boss" | "roguelike" | "rogue-trials-endless";
bossId?: BossId;
top: LeaderboardEntry[];
current: LeaderboardEntry | null;
@@ -147,6 +147,10 @@ export class OnlineRepository {
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
}
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
}
export const onlineRepository = new OnlineRepository();
+3 -1
View File
@@ -108,7 +108,7 @@ describe("SaveRepository", () => {
expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0 });
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
@@ -122,6 +122,7 @@ describe("SaveRepository", () => {
const drop = groupDrop("charge", "veteran");
created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.stats.highestRogueTrialsEndlessKills = 14;
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
@@ -130,6 +131,7 @@ describe("SaveRepository", () => {
expect(migrated.schemaVersion).toBe(5);
expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog);
});
+1
View File
@@ -150,6 +150,7 @@ function normalizeSave(value: unknown): HunterSave | null {
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills,
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
},
materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog,
+18 -2
View File
@@ -13,7 +13,7 @@ import {
infusionsForOwner,
} from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
const repository = new SaveRepository();
const accounts = new AccountRepository();
@@ -125,6 +125,7 @@ export interface FrontendState {
touchActiveSave: () => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
recordRoguelikeDefeat: (round: number) => void;
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
clearRecentRewards: () => void;
clearNotice: () => void;
}
@@ -388,7 +389,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
});
set((state) => ({
slots: refreshLocalSlots(state.slots),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
}));
return awarded;
@@ -406,6 +407,19 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordRogueTrialsEndlessDefeat: (bossKills) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
},
}));
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }),
}));
@@ -442,6 +456,7 @@ export type FrontendSnapshot = Omit<FrontendState,
| "touchActiveSave"
| "recordBossVictory"
| "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "clearRecentRewards"
| "clearNotice"
>;
@@ -479,6 +494,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice,
...snapshot
+1
View File
@@ -42,6 +42,7 @@ export interface HunterStats {
healingDone: number;
bossKills: Record<string, number>;
highestRoguelikeRound: number;
highestRogueTrialsEndlessKills: number;
}
export interface HealerProgress {
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
describe("PlayStation controller ability bindings", () => {
it("keeps prompts aligned with standard gamepad button indices", () => {
expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
});
expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([
"□",
"△",
"○",
"✕",
"L1",
"R1",
]);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { AbilityId } from "./types";
interface AbilityControllerBinding {
buttonIndex: number;
glyph: string;
}
export const ABILITY_CONTROLLER_BINDINGS: Record<AbilityId, AbilityControllerBinding> = {
mend: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft },
renew: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop },
shield: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight },
purify: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom },
radiance: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder },
barrier: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder },
};
export const ABILITY_BY_CONTROLLER_BUTTON = Object.fromEntries(
Object.entries(ABILITY_CONTROLLER_BINDINGS).map(([abilityId, binding]) => [binding.buttonIndex, abilityId]),
) as Partial<Record<number, AbilityId>>;
+7 -6
View File
@@ -1,12 +1,13 @@
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" },
mend: { id: "mend", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.mend.glyph, targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.renew.glyph, targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.shield.glyph, targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.purify.glyph, targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.radiance.glyph, targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.barrier.glyph, targeting: "party" },
};
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { highestRoguelikeRoundAfterDefeat } from "./hunterStats";
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "./hunterStats";
describe("roguelike hunter records", () => {
it("records the reached defeat round without lowering a previous best", () => {
@@ -13,3 +13,16 @@ describe("roguelike hunter records", () => {
expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8);
});
});
describe("Rogue Trials endless hunter records", () => {
it("keeps the highest boss count from one endless run", () => {
expect(highestEndlessBossKillsAfterDefeat(0, 17)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 9)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 23)).toBe(23);
});
it("normalizes invalid and fractional kill counts", () => {
expect(highestEndlessBossKillsAfterDefeat(Number.NaN, Number.NaN)).toBe(0);
expect(highestEndlessBossKillsAfterDefeat(4.9, 8.9)).toBe(8);
});
});
+6
View File
@@ -3,3 +3,9 @@ export function highestRoguelikeRoundAfterDefeat(currentRecord: number, reachedR
const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1));
return Math.max(normalizedRecord, normalizedRound);
}
export function highestEndlessBossKillsAfterDefeat(currentRecord: number, bossKills: number): number {
const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0));
const normalizedKills = Math.max(0, Math.floor(Number(bossKills) || 0));
return Math.max(normalizedRecord, normalizedKills);
}
+61
View File
@@ -707,6 +707,67 @@ describe("Rogue Trials", () => {
expect(useGameStore.getState().phase).toBe("victory");
});
it("offers endless mode, counts each kill, and refills the dead boss slot", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
"rogue-trials",
);
useGameStore.setState({ round: 5, phase: "victory" });
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
const started = useGameStore.getState();
expect(started.phase).toBe("combat");
expect(started.endlessMode).toBe(true);
expect(started.endlessBossKills).toBe(0);
expect(started.additionalBosses).toHaveLength(2);
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
for (let step = 0; step < 50 && useGameStore.getState().endlessBossKills === 0; step += 1) {
useGameStore.getState().tick(0.1);
}
const defeated = useGameStore.getState();
expect(defeated.endlessBossKills).toBe(1);
expect(defeated.boss.hp).toBe(0);
const defeatedInstanceId = defeated.bossInstanceId;
useGameStore.getState().tick(0.05);
const replaced = useGameStore.getState();
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
expect(replaced.endlessBossKills).toBe(1);
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("ends endless mode when the party falls without losing its kill count", () => {
useGameStore.setState((state) => ({
round: 5,
phase: "victory",
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
useGameStore.setState((state) => ({
endlessBossKills: 7,
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
});
it("restarts a completed trial with a fresh two-boss first round", () => {
useGameStore.getState().configureHealer(
"priest",
+113 -2
View File
@@ -25,6 +25,7 @@ import {
selectRunBuffDraft,
selectRandomBossPair,
selectRogueTrialsBosses,
selectUnseenBosses,
ROGUE_TRIALS_TRIO_ROUND,
type CompiledRunModifiers,
} from "./roguelike";
@@ -67,6 +68,7 @@ export interface AdditionalBossState {
export interface GameState {
bossId: BossId;
bossInstanceId: string;
paused: boolean;
pauseSelection: "resume" | "exit";
healerClassId: HealerClassId;
@@ -75,6 +77,10 @@ export interface GameState {
runMode: RunMode;
round: number;
seenBossIds: BossId[];
endlessMode: boolean;
endlessBossKills: number;
endlessSpawnSequence: number;
endlessChoiceSelection: "continue" | "quit";
runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId | null;
@@ -123,6 +129,8 @@ export interface GameState {
setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean;
continueRoguelikeRound: () => boolean;
startRogueTrialsEndless: () => boolean;
setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
}
const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -305,6 +313,7 @@ function initialState(
const maxMana = 100;
return {
bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
paused: false,
pauseSelection: "resume" as const,
healerClassId,
@@ -313,6 +322,10 @@ function initialState(
runMode,
round,
seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
endlessMode: false,
endlessBossKills: 0,
endlessSpawnSequence: 0,
endlessChoiceSelection: "continue" as const,
runBuffRanks: { ...runBuffRanks },
draftBuffIds,
selectedRunBuffId: draftBuffIds[0] ?? null,
@@ -441,6 +454,46 @@ export const useGameStore = create<GameState>((set, get) => ({
});
return true;
},
startRogueTrialsEndless: () => {
const state = get();
if (state.runMode !== "rogue-trials"
|| state.round !== ROGUE_TRIALS_TRIO_ROUND
|| state.phase !== "victory"
|| state.endlessMode) return false;
const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => {
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
});
const primary = encounterBosses[0];
set({
bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
boss: primary.boss,
bossMotion: primary.motion,
additionalBosses: encounterBosses.slice(1),
phase: "combat",
endlessMode: true,
endlessBossKills: 0,
endlessSpawnSequence: encounterBosses.length,
partyCombat: createPartyCombatState(state.party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds),
playerPosition: [0, 4.5],
activeCast: null,
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: state.time,
message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
tone: "danger",
}],
});
return true;
},
setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
setPlayerPosition: (playerPosition) => set((state) => {
playerPosition = clampToArena(playerPosition);
const current = state.playerPosition;
@@ -601,6 +654,7 @@ export const useGameStore = create<GameState>((set, get) => ({
// as well doubled short-lived allocations for every simulation step.
let party = state.party.map((member) => ({ ...member }));
let boss = { ...state.boss };
let bossInstanceId = state.bossInstanceId;
let bossMotion = { ...state.bossMotion };
let additionalBosses = state.additionalBosses.map((entry) => ({
...entry,
@@ -614,6 +668,36 @@ export const useGameStore = create<GameState>((set, get) => ({
let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents;
let barrier = { ...state.barrier };
let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence;
if (state.endlessMode) {
const slots: AdditionalBossState[] = [
{ instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < slots.length; index += 1) {
if (slots[index].boss.hp > 0) continue;
const activeBossIds = slots
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
.map((entry) => entry.boss.id);
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds])[0];
endlessSpawnSequence += 1;
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const replacement = createEncounterBoss(
replacementId,
index,
slots.length,
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
);
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
}
boss = slots[0].boss;
bossInstanceId = slots[0].instanceId;
bossMotion = slots[0].motion;
additionalBosses = slots.slice(1);
}
if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
@@ -683,7 +767,7 @@ export const useGameStore = create<GameState>((set, get) => ({
});
const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
{ instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < encounterBosses.length; index += 1) {
@@ -733,13 +817,32 @@ export const useGameStore = create<GameState>((set, get) => ({
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
}
boss = encounterBosses[0].boss;
bossInstanceId = encounterBosses[0].instanceId;
bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!;
let phase: GamePhase = state.phase;
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
let newlyDefeatedBossCount = 0;
if (state.endlessMode) {
for (let index = 0; index < encounterBosses.length; index += 1) {
const current = encounterBosses[index];
const previous = index === 0
? { instanceId: state.bossInstanceId, boss: state.boss }
: state.additionalBosses[index - 1];
if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
newlyDefeatedBossCount += 1;
combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
}
}
endlessBossKills += newlyDefeatedBossCount;
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
phase = "defeat";
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the formation fell.`, "danger");
} else if (state.endlessMode) {
phase = "combat";
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
@@ -752,6 +855,8 @@ export const useGameStore = create<GameState>((set, get) => ({
set({
time,
party,
bossId: boss.id,
bossInstanceId,
boss,
additionalBosses,
partyCombat,
@@ -759,6 +864,8 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions,
bossMotion,
phase,
endlessBossKills,
endlessSpawnSequence,
runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
activeCast,
@@ -786,6 +893,8 @@ export type GameSnapshot = Omit<GameState,
| "setSelectedRunBuff"
| "chooseRunBuff"
| "continueRoguelikeRound"
| "startRogueTrialsEndless"
| "setEndlessChoiceSelection"
>;
export function getGameSnapshot(): GameSnapshot {
@@ -806,6 +915,8 @@ export function getGameSnapshot(): GameSnapshot {
setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff,
continueRoguelikeRound: _continueRoguelikeRound,
startRogueTrialsEndless: _startRogueTrialsEndless,
setEndlessChoiceSelection: _setEndlessChoiceSelection,
...snapshot
} = useGameStore.getState();
return snapshot;
+23 -11
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { subscribeControllerToken } from "../input/controller";
import { ABILITY_ORDER } from "./data";
import { isRunBuffInputLocked, useGameStore } from "./store";
import type { AbilityId } from "./types";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState();
@@ -12,15 +12,6 @@ function cycleRunBuff(direction: 1 | -1) {
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
}
const gamepadAbilityMap: Record<number, AbilityId> = {
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
};
export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit);
exitRef.current = onExit;
@@ -53,6 +44,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (key === "escape") exitRef.current?.();
return;
}
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") store.setEndlessChoiceSelection("continue");
if (key === "arrowright" || key === "arrowdown") store.setEndlessChoiceSelection("quit");
if (key === "enter") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (key === "escape") exitRef.current?.();
return;
}
const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]);
@@ -110,9 +112,19 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (!repeat && token === "Button1") exitRef.current?.();
return;
}
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) store.setEndlessChoiceSelection("continue");
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) store.setEndlessChoiceSelection("quit");
if (!repeat && token === "Button0") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (!repeat && token === "Button1") exitRef.current?.();
return;
}
if (repeat) return;
if (token.startsWith("Button")) {
const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
if (ability && store.phase === "combat") store.castAbility(ability);
}
if (token === "Button12") store.cycleMember(-1);
+12
View File
@@ -0,0 +1,12 @@
export const DEFAULT_CONTROLLER_GLYPHS = {
confirm: "✕",
back: "○",
faceBottom: "✕",
faceRight: "○",
faceLeft: "□",
faceTop: "△",
leftShoulder: "L1",
rightShoulder: "R1",
select: "SELECT",
start: "START",
} as const;
+9 -3
View File
@@ -9,6 +9,7 @@ import type { BossId } from "../game/types";
import type { DifficultySlug } from "../game/progression/loot";
import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
@@ -45,8 +46,8 @@ function CompanionStandby({ screen, hunterName, notice }: {
</main>
<footer>
<span><b></b> Navigate</span>
<span><b>A</b> Select</span>
<span><b>B</b> Back</span>
<span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>
</footer>
</section>
);
@@ -168,6 +169,11 @@ export function BottomDisplayApp() {
postCommand({ name: "continueRoguelikeRound" });
return false;
},
startRogueTrialsEndless: () => {
postCommand({ name: "startRogueTrialsEndless" });
return false;
},
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
});
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") {
@@ -221,7 +227,7 @@ export function BottomDisplayApp() {
return (
<main className="bottom-display-root">
{surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen /></Suspense>
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
: surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />}
+12 -3
View File
@@ -6,10 +6,14 @@ import { useFrontendStore } from "../frontend/store";
function snapshot(): BottomGameSnapshot {
return {
bossId: "bulldrome",
bossInstanceId: "boss-0-bulldrome",
paused: false,
healerClassId: "priest",
phase: "combat",
round: 1,
endlessMode: false,
endlessBossKills: 0,
endlessChoiceSelection: "continue",
runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
@@ -67,21 +71,26 @@ describe("dual-screen game snapshots", () => {
it("routes maxed-run continuation and passive filter commands", () => {
const originalContinue = useGameStore.getState().continueRoguelikeRound;
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
const calls: string[] = [];
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } });
useGameStore.setState({
continueRoguelikeRound: () => { calls.push("continue"); return true; },
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
});
useFrontendStore.setState({
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
});
executeGameCommand({ name: "continueRoguelikeRound" });
executeGameCommand({ name: "startRogueTrialsEndless" });
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]);
expect(calls).toEqual(["continue", "endless", "ability:shield", "passive:shield-guard"]);
useGameStore.setState({ continueRoguelikeRound: originalContinue });
useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
});
});
+17 -2
View File
@@ -23,7 +23,9 @@ export type GameCommand =
| { name: "setPauseSelection"; selection: "resume" | "exit" }
| { name: "setSelectedRunBuff"; buffId: RunBuffId }
| { name: "chooseRunBuff"; buffId: RunBuffId }
| { name: "continueRoguelikeRound" };
| { name: "continueRoguelikeRound" }
| { name: "startRogueTrialsEndless" }
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" };
export type FrontendCommand =
| { name: "signIn"; username: string; password: string }
@@ -52,9 +54,11 @@ export type FrontendCommand =
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
| { name: "selectHealerClass"; classId: HealerClassId }
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
| { name: "exitGame" }
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
export type DualScreenMessage =
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
@@ -86,6 +90,8 @@ export function executeGameCommand(command: GameCommand) {
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
}
}
@@ -118,6 +124,7 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break;
}
}
@@ -132,10 +139,14 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
export type BottomGameSnapshot = Pick<GameState,
| "bossId"
| "bossInstanceId"
| "paused"
| "healerClassId"
| "phase"
| "round"
| "endlessMode"
| "endlessBossKills"
| "endlessChoiceSelection"
| "runModifiers"
| "time"
| "party"
@@ -158,7 +169,7 @@ export type BottomGameSnapshot = Pick<GameState,
>;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
"bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses",
"bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses",
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
];
@@ -184,10 +195,14 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
const state = useGameStore.getState();
return {
bossId: state.bossId,
bossInstanceId: state.bossInstanceId,
paused: state.paused,
healerClassId: state.healerClassId,
phase: state.phase,
round: state.round,
endlessMode: state.endlessMode,
endlessBossKills: state.endlessBossKills,
endlessChoiceSelection: state.endlessChoiceSelection,
runModifiers: state.runModifiers,
time: state.time,
party: state.party,
+10 -14
View File
@@ -1075,6 +1075,7 @@ button:focus-visible {
.end-actions { display: flex; gap: 9px; margin-top: 18px; }
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
.buff-draft {
height: 100%;
@@ -1755,10 +1756,7 @@ button:focus-visible {
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
.home-header > span b { color: #dce9e4; }
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
.home-title { padding: 17px 2px 12px; }
.home-title span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
.home-title h1 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: 25px; font-weight: 500; }
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; }
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; margin-top: 18px; }
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
.mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; }
.mode-card.is-wide { grid-row: 1 / 3; }
@@ -1869,7 +1867,7 @@ button:focus-visible {
.boss-stats-heading { padding-top: 10px; }
.boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; }
.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; }
.boss-stat-selector button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
.boss-stat-selector button { width: 100%; min-height: 51px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); }
.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; }
.boss-stat-selector button > span { min-width: 0; display: grid; }
@@ -1895,7 +1893,7 @@ button:focus-visible {
.leaderboard-empty { min-height: 200px !important; border: 0 !important; }
.profile-context { padding: 0 5.5% 18px; }
.profile-context .context-header { margin: 0 -5.8%; }
.profile-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid var(--line); }
.profile-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--line); }
.profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); }
.profile-stats span:last-child { border-right: 0; }
.profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; }
@@ -1942,10 +1940,10 @@ button:focus-visible {
.pad-diagram b,
.face-diagram b { color: #40534c; }
.face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; }
.face-diagram .a { color: #67c394; }
.face-diagram .b { color: #d66b61; }
.face-diagram .x { color: #5db1d0; }
.face-diagram .y { color: #d6ba67; }
.face-diagram .triangle { color: #70c18b; }
.face-diagram .circle { color: #dc6f78; }
.face-diagram .cross { color: #78a9dd; }
.face-diagram .square { color: #cf8fc5; }
.mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; }
.mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); }
.mapping-list b { color: #d6e3de; }
@@ -2083,9 +2081,7 @@ button:focus-visible {
.save-footer .controller-legend { display: none; }
.home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; }
.home-title { padding: 6px 0; }
.home-title h1 { font-size: 12px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
@@ -2134,7 +2130,7 @@ button:focus-visible {
.boss-stats-heading { padding-top: 3px; }
.boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; }
.boss-stat-selector { gap: 2px; }
.boss-stat-selector button { min-height: 34px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
.boss-stat-selector button { min-height: 33px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
.boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; }
.boss-stat-selector strong { font-size: 6px; }
.boss-stat-selector small { font-size: 4px; }