diff --git a/README.md b/README.md index ca4a057..891feee 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/db/schema.sql b/db/schema.sql index 3c7073b..a391353 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); diff --git a/package.json b/package.json index ed82459..02887d3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/game-api.mjs b/server/game-api.mjs index ec7e28c..273ed34 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -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; diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs index 613abcf..e4015bb 100644 --- a/server/game-api.test.mjs +++ b/server/game-api.test.mjs @@ -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}` }, }); diff --git a/src/App.tsx b/src/App.tsx index 078fe62..9d9c5f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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()); 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 (
@@ -118,7 +130,7 @@ export default function App() {

Offline-first healer roguelike v{packageJson.version}

{screen === "game" - ? }>} bottom={} /> + ? }>} bottom={} /> : }
); diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index 355d599..cb73246 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -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() { Chosen discipline

{healer.specialization}

{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}

- +
Prepared skills6 equipped
@@ -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 (
{phase === "victory" ? "✦" : "×"} - {phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} -

{phase === "victory" ? "Five souls endure" : "The vault claims its due"}

+ {showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} +

{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}

Duration{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")} Party vitality{Math.round((totalHp / totalMax) * 100)}% - Boss{phase === "victory" ? "Defeated" : "Standing"} + {endlessDefeat ? "Endless kills" : "Boss"}{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}
{phase === "victory" && } -
+ {showEndlessChoice ?
+ + +
:
- -
+ +
}
); } @@ -225,16 +248,16 @@ function IntermissionStatusPanel() {

Choose on top display

Next encounter stays locked until one blessing is claimed.

- Use D-pad to choose · A to claim + Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim
); } -function CombatPanel() { +function CombatPanel({ onExit }: { onExit?: () => void }) { const phase = useGameStore((state) => state.phase); if (phase === "briefing") return ; if (phase === "intermission") return ; - if (phase === "victory" || phase === "defeat") return ; + if (phase === "victory" || phase === "defeat") return ; return
; } @@ -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() {
- {activeTab === "combat" && } + {activeTab === "combat" && } {activeTab === "map" && } {activeTab === "pack" && }
{paused && ( )} diff --git a/src/components/BuffDraftPanel.tsx b/src/components/BuffDraftPanel.tsx index 9be8443..335c415 100644 --- a/src/components/BuffDraftPanel.tsx +++ b/src/components/BuffDraftPanel.tsx @@ -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 }) { )} -
{inputLocked ? Choices ready in a moment… : <>{choices.length > 0 && <>← / → Choose } A / ENTER {choices.length > 0 ? "Claim" : "Continue"}}
+
{inputLocked ? Choices ready in a moment… : <>{choices.length > 0 && <>← / → Choose } {DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER {choices.length > 0 ? "Claim" : "Continue"}}
); } diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx index d5847e6..1059e10 100644 --- a/src/components/DualDisplayFrame.tsx +++ b/src/components/DualDisplayFrame.tsx @@ -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"} > - {activeSurface === "top" ? "Tactical" : "Main"}SELECT / TAB + {activeSurface === "top" ? "Tactical" : "Main"}{DEFAULT_CONTROLLER_GLYPHS.select} / TAB ); diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index 1c8e0d9..bb0bd01 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -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
A Select{back && B Back} Navigate
; + return
{DEFAULT_CONTROLLER_GLYPHS.confirm} Select{back && {DEFAULT_CONTROLLER_GLYPHS.back} Back} Navigate
; } function LoginScreen() { @@ -335,7 +336,7 @@ function SaveScreen() { {selected.online &&
ONLINE{selected.online.hunterName}
}
hasLocal ? playSlot(selectedSlotId) : openCreation()}> - {hasLocal ? "Continue offline save" : "Create new hunter"}A + {hasLocal ? "Continue offline save" : "Create new hunter"}{DEFAULT_CONTROLLER_GLYPHS.confirm}
uploadSlot(selectedSlotId)}>↑ Sync offline to server @@ -399,7 +400,6 @@ function HomeScreen() { top={
Welcome back, {hunter.hunterName}{accountId ? "● SYNC READY" : "○ OFFLINE"}
-
Choose your hunt

Where are you needed?

{HOME_MODES.map((mode) => ( 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("roguelike"); + const [selectedStat, setSelectedStat] = useState("roguelike"); const [leaderboard, setLeaderboard] = useState(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,7 +469,9 @@ function ProfileScreen() { setLeaderboardStatus("Loading overall rankings…"); const request = selectedStat === "roguelike" ? onlineRepository.roguelikeLeaderboard(hunter.slotId) - : onlineRepository.bossLeaderboard(selectedStat, hunter.slotId); + : selectedStat === "rogue-trials-endless" + ? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId) + : onlineRepository.bossLeaderboard(selectedStat, hunter.slotId); void request.then((result) => { if (cancelled) return; setLeaderboard(result); @@ -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 ( setCollectionView("trophies")}>Trophy Case setCollectionView("stats")}>Boss Stats setCollectionView("loot")}>Group Loot -
navigate("home")}>B · Back +
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back {collectionView === "loot" ? <>
Shared group drops · Core: {collection.coreMechanic}

Group {collection.groupLetter} · {collection.groupName}

{earned} / {collection.drops.length} discovered
@@ -540,18 +555,19 @@ function ProfileScreen() {
Each guardian keeps its own trophy.Defeat that boss for a 1 in 500 pet roll.
: <> -
Lifetime records · Overall leaderboards

Boss Stats

Highest roguelike round {hunter.stats.highestRoguelikeRound}
+
Lifetime records · Overall leaderboards

Boss Stats

Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills
setSelectedStat("roguelike")}>RoguelikeHighest round before defeat{hunter.stats.highestRoguelikeRound} + setSelectedStat("rogue-trials-endless")}>Trials EndlessMost bosses in one run{hunter.stats.highestRogueTrialsEndlessKills} {collection.bosses.map((boss) => setSelectedStat(boss.bossId)}>{BOSS_DEFINITIONS[boss.bossId].icon}{boss.bossName}Lifetime boss kills{boss.kills})}
-
Overall Top 5{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}
+
Overall Top 5{selectedStatLabel}{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}
{leaderboardStatus ?
{leaderboardStatus}
:
{leaderboard?.top.length ? leaderboard.top.map((entry) =>
#{entry.rank}{entry.hunterName}{entry.username}{entry.value}
) :
No ranked hunters yet.
}
} -
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{accountId ?? "Offline hunter"}{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}
+
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{accountId ?? "Offline hunter"}{selectedStatValue}
Rankings update with server saves.Top five always shown; your row stays visible at any rank.
@@ -567,6 +583,7 @@ function ProfileScreen() { Allies saved{hunter.stats.alliesSaved} Healing done{hunter.stats.healingDone.toLocaleString()} Highest roguelike round{hunter.stats.highestRoguelikeRound} + Endless best{hunter.stats.highestRogueTrialsEndlessKills}
Mechanic groups{collections.map((group) => ( setGroupId(group.groupId)}> @@ -701,7 +718,7 @@ function GearScreen() { -
Group drop workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>B · Back
+
Group drop workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{GEAR_OWNER_ORDER.map((ownerId) => { @@ -764,7 +781,7 @@ function GearScreen() { return
= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}>{owned >= cost.quantity ? "✓" : "×"}{cost.itemName}{owned} owned · {cost.quantity} needed{owned}/{cost.quantity}
; }) :
Maximum rank reachedNo more materials required.+{MAX_GEAR_LEVEL}
}
- {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}} + {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!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"}}
{notice || "Gear changes save locally and apply when next encounter starts."}
} @@ -795,7 +812,7 @@ function SettingsScreen() { -
Field configuration

Settings

navigate("home")}>B · Back
+
Field configuration

Settings

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
Audio
Master volumeAll music, effects, and voice
updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−{settings.masterVolume}% updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+
Display & accessibility updateSetting("reducedMotion", !settings.reducedMotion)} /> updateSetting("damageNumbers", !settings.damageNumbers)} /> updateSetting("largeText", !settings.largeText)} />
@@ -808,9 +825,9 @@ function SettingsScreen() {
ControllerBUILT-IN THOR PAD
-
YXBA
+
{DEFAULT_CONTROLLER_GLYPHS.faceTop}{DEFAULT_CONTROLLER_GLYPHS.faceLeft}{DEFAULT_CONTROLLER_GLYPHS.faceRight}{DEFAULT_CONTROLLER_GLYPHS.faceBottom}
-
A Confirm / cast PurifyB Back / cast ShieldD-Pad Navigate / target partyRight stick Rotate cameraStart Pause / menu
+
{DEFAULT_CONTROLLER_GLYPHS.confirm} Confirm / cast Purify{DEFAULT_CONTROLLER_GLYPHS.back} Back / cast ShieldD-Pad Navigate / target partyRight stick Rotate camera{DEFAULT_CONTROLLER_GLYPHS.start} Pause / menu
No click-to-focus requiredController input routes through app-level actions.
} @@ -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 -
Game mode

{mode.title}

navigate("home")}>B · Back
+
Game mode

{mode.title}

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{!isDungeon &&
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
} {isDungeon && (
@@ -973,7 +990,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi {DIFFICULTIES.map((difficulty) => selectDifficulty(difficulty.slug)}>{difficulty.name}iLvl {difficulty.itemLevel})}
)} - {launchLabel}{mode.status} · A + {launchLabel}{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm} {message &&
{message}
} } diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index ff8f9de..8303fdd 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -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 ; @@ -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 (
{eyebrow}

{title}

{copy}

- {phase === "briefing" ? "Begin from lower display" : "Restart from lower display"} + {phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}
); } @@ -168,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) { onFocus={() => setPauseSelection("resume")} onPointerEnter={() => setPauseSelection("resume")} onClick={() => setPaused(false)} - >Resume START / ESC + >Resume {DEFAULT_CONTROLLER_GLYPHS.start} / ESC + >Return to main menu {DEFAULT_CONTROLLER_GLYPHS.confirm}
-
↑ / ↓ Choose A / ENTER Confirm
+
↑ / ↓ Choose {DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER Confirm
); @@ -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 (
@@ -197,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
-
{runMode !== "encounter" ? `Round ${round}` : "Objective"}{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
+
{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
WASD Move Q / E Target 1–6 Cast
- {onExit && } + {onExit && }
diff --git a/src/frontend/data.test.ts b/src/frontend/data.test.ts index 2efb8b2..0fe1820 100644 --- a/src/frontend/data.test.ts +++ b/src/frontend/data.test.ts @@ -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"); }); diff --git a/src/frontend/data.ts b/src/frontend/data.ts index 251109c..270d069 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -75,8 +75,8 @@ export const MODE_COPY: Record { return this.request(`/api/leaderboards/roguelike?slot=${slotId}`); } + + rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`); + } } export const onlineRepository = new OnlineRepository(); diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index 59f5626..5cf87e1 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -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); }); diff --git a/src/frontend/saveRepository.ts b/src/frontend/saveRepository.ts index e4c3c8e..f4fdddc 100644 --- a/src/frontend/saveRepository.ts +++ b/src/frontend/saveRepository.ts @@ -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, diff --git a/src/frontend/store.ts b/src/frontend/store.ts index ea32697..ba9fc03 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -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((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((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; @@ -479,6 +494,7 @@ export function getFrontendSnapshot(): FrontendSnapshot { touchActiveSave: _touchActiveSave, recordBossVictory: _recordBossVictory, recordRoguelikeDefeat: _recordRoguelikeDefeat, + recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat, clearRecentRewards: _clearRecentRewards, clearNotice: _clearNotice, ...snapshot diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 74fddfa..d74ca73 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -42,6 +42,7 @@ export interface HunterStats { healingDone: number; bossKills: Record; highestRoguelikeRound: number; + highestRogueTrialsEndlessKills: number; } export interface HealerProgress { diff --git a/src/game/controllerBindings.test.ts b/src/game/controllerBindings.test.ts new file mode 100644 index 0000000..c84ba1e --- /dev/null +++ b/src/game/controllerBindings.test.ts @@ -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", + ]); + }); +}); diff --git a/src/game/controllerBindings.ts b/src/game/controllerBindings.ts new file mode 100644 index 0000000..22d21aa --- /dev/null +++ b/src/game/controllerBindings.ts @@ -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 = { + 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>; diff --git a/src/game/healers.ts b/src/game/healers.ts index 91ec84f..4a4a3cb 100644 --- a/src/game/healers.ts +++ b/src/game/healers.ts @@ -1,12 +1,13 @@ import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types"; +import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings"; const bindings: Record> = { - 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 { diff --git a/src/game/progression/hunterStats.test.ts b/src/game/progression/hunterStats.test.ts index d135f36..01e03c9 100644 --- a/src/game/progression/hunterStats.test.ts +++ b/src/game/progression/hunterStats.test.ts @@ -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); + }); +}); diff --git a/src/game/progression/hunterStats.ts b/src/game/progression/hunterStats.ts index 63eba3a..83e4809 100644 --- a/src/game/progression/hunterStats.ts +++ b/src/game/progression/hunterStats.ts @@ -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); +} diff --git a/src/game/store.test.ts b/src/game/store.test.ts index d06fbc0..bb92691 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -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", diff --git a/src/game/store.ts b/src/game/store.ts index 7d2e5e3..2be43df 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -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 => ({ @@ -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((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((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((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((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((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((set, get) => ({ set({ time, party, + bossId: boss.id, + bossInstanceId, boss, additionalBosses, partyCombat, @@ -759,6 +864,8 @@ export const useGameStore = create((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; 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; diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index 2237372..253ade2 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -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 = { - 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); diff --git a/src/input/controllerGlyphs.ts b/src/input/controllerGlyphs.ts new file mode 100644 index 0000000..592cc09 --- /dev/null +++ b/src/input/controllerGlyphs.ts @@ -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; diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index a317dd9..5cbd593 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -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 }: {
Navigate - A Select - B Back + {DEFAULT_CONTROLLER_GLYPHS.confirm} Select + {DEFAULT_CONTROLLER_GLYPHS.back} Back
); @@ -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) => { if (event.data.type === "authoritative-ready") { @@ -221,7 +227,7 @@ export function BottomDisplayApp() { return (
{surface.screen === "game" - ? }> + ? }> postFrontendCommand({ name: "exitGame" })} /> : surface.notice === "Linking upper display…" ? : } diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index 26f028a..2e52147 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -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 }); }); }); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 62f8fc0..8691ed7 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -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 } @@ -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; 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, diff --git a/src/styles.css b/src/styles.css index 1121ec4..d600759 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }