From 018e060cdd347d72dbdbfdb2f22188026f470f26 Mon Sep 17 00:00:00 2001 From: Warren H Date: Sun, 19 Jul 2026 16:37:37 -0400 Subject: [PATCH] Release v0.1.20 2026-07-19 --- package.json | 2 +- server/game-api.mjs | 77 +++++++++ server/game-api.test.mjs | 46 +++++- src/App.tsx | 88 +++++++++- src/components/BottomScreen.tsx | 107 +++++++++++-- src/components/FrontEnd.tsx | 95 +++-------- src/components/TopScreen.tsx | 44 ++++- src/frontend/hockeyPvpMatchmaking.test.ts | 66 ++++++++ src/frontend/hockeyPvpMatchmaking.ts | 187 ++++++++++++++++++++++ src/frontend/onlineRepository.ts | 42 +++-- src/game/hockeyHealingPvp.test.ts | 8 + src/game/hockeyHealingPvp.ts | 23 +++ src/game/mana.ts | 5 + src/game/manaBalance.test.ts | 63 ++++++++ src/game/rpgRoguelikeStore.test.ts | 3 + src/game/store.test.ts | 13 +- src/game/store.ts | 27 +++- src/game/useGameLoop.ts | 31 ++++ src/platform/BottomDisplayApp.tsx | 3 +- src/platform/dualScreenSync.test.ts | 10 +- src/platform/dualScreenSync.ts | 11 +- src/styles.css | 54 +++++++ 22 files changed, 884 insertions(+), 121 deletions(-) create mode 100644 src/frontend/hockeyPvpMatchmaking.test.ts create mode 100644 src/frontend/hockeyPvpMatchmaking.ts create mode 100644 src/game/mana.ts create mode 100644 src/game/manaBalance.test.ts diff --git a/package.json b/package.json index 08d08b3..e5e98d6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.19", + "version": "0.1.20", "type": "module", "scripts": { "predev": "node scripts/sync_basis_transcoder.mjs", diff --git a/server/game-api.mjs b/server/game-api.mjs index ba667d3..e48edf1 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -651,6 +651,7 @@ export function createGameApiHandler(options = {}) { match: { id: match.id, seed: match.seed, + generation: match.generation, countdownEndsAtMs: match.countdownEndsAtMs, opponentName: opponent.hunterName, role: ticket.side, @@ -694,10 +695,12 @@ export function createGameApiHandler(options = {}) { const match = { id: matchId, seed: randomBytes(4).readUInt32BE(0) || 1, + generation: 1, createdAt: now, countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS, players: { host: opponent, guest: ticket }, snapshots: { host: null, guest: null }, + rematch: null, }; opponent.matchId = matchId; opponent.side = "host"; @@ -725,6 +728,72 @@ export function createGameApiHandler(options = {}) { return { match, side }; } + function hockeyPvpRematchResult(match, side, requestedGeneration) { + const rematch = match.rematch; + if (!rematch || rematch.fromGeneration !== requestedGeneration || !rematch.ready) { + return { status: "waiting" }; + } + const opponentSide = side === "host" ? "guest" : "host"; + return { + status: "matched", + match: { + id: match.id, + seed: rematch.seed, + generation: rematch.toGeneration, + countdownEndsAtMs: rematch.countdownEndsAtMs, + opponentName: match.players[opponentSide].hunterName, + role: side, + }, + }; + } + + function requestHockeyPvpRematch(session, matchId, payload) { + const { match, side } = requireHockeyPvpMatch(session, matchId); + const generation = Number(payload?.generation); + if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid."); + + if (generation < match.generation) { + if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) { + throw apiError("PVP match generation is stale.", 409); + } + return hockeyPvpRematchResult(match, side, generation); + } + if (generation > match.generation) throw apiError("PVP match generation is invalid.", 409); + + if (!match.rematch || match.rematch.fromGeneration !== generation) { + match.rematch = { + fromGeneration: generation, + toGeneration: generation + 1, + requested: { host: false, guest: false }, + ready: false, + seed: 0, + countdownEndsAtMs: 0, + }; + } + match.rematch.requested[side] = true; + if (!match.rematch.ready && match.rematch.requested.host && match.rematch.requested.guest) { + const now = Date.now(); + match.rematch.ready = true; + match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1; + match.rematch.countdownEndsAtMs = now + HOCKEY_PVP_COUNTDOWN_MS; + match.seed = match.rematch.seed; + match.generation = match.rematch.toGeneration; + match.countdownEndsAtMs = match.rematch.countdownEndsAtMs; + match.snapshots = { host: null, guest: null }; + } + return hockeyPvpRematchResult(match, side, generation); + } + + function cancelHockeyPvpRematch(session, matchId, payload) { + const { match, side } = requireHockeyPvpMatch(session, matchId); + const generation = Number(payload?.generation); + if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid."); + if (match.rematch?.fromGeneration === generation && !match.rematch.ready) { + match.rematch.requested[side] = false; + } + return { ok: true }; + } + async function handle(request, response, next) { if (!request.url?.startsWith("/api/")) return next(); setCorsHeaders(request, response); @@ -779,6 +848,7 @@ export function createGameApiHandler(options = {}) { if (pvpStateMatch && request.method === "PUT") { const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]); const payload = await readJson(request); + if (payload?.generation !== match.generation) throw apiError("PVP match generation is stale.", 409); if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid."); match.snapshots[side] = payload.snapshot; return sendJson(response, 200, { @@ -786,6 +856,13 @@ export function createGameApiHandler(options = {}) { hostSnapshot: match.snapshots.host, }); } + const pvpRematchMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/); + if (pvpRematchMatch && request.method === "POST") { + return sendJson(response, 200, requestHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request))); + } + if (pvpRematchMatch && request.method === "DELETE") { + return sendJson(response, 200, cancelHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request))); + } if (path === "/api/saves" && request.method === "GET") { return sendJson(response, 200, { slots: listSaves(database, session.accountId) }); } diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs index 8c88d9f..decdf3c 100644 --- a/server/game-api.test.mjs +++ b/server/game-api.test.mjs @@ -228,6 +228,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async assert.equal(betaQueue.body.status, "matched"); assert.equal(betaQueue.body.match.role, "guest"); assert.equal(betaQueue.body.match.opponentName, "Alpha"); + assert.equal(betaQueue.body.match.generation, 1); assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now()); const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, { @@ -237,21 +238,62 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async assert.equal(alphaMatched.body.match.role, "host"); assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id); assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed); + assert.equal(alphaMatched.body.match.generation, betaQueue.body.match.generation); assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs); const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } }; await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { method: "PUT", headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ snapshot: hostSnapshot }), + body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }), }); const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { method: "PUT", headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }), + body: JSON.stringify({ generation: 1, snapshot: { sequence: 1, party: [] } }), }); assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot); assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot); + + const alphaRematchWaiting = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(alphaRematchWaiting.body.status, "waiting"); + + const betaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(betaRematchReady.body.status, "matched"); + assert.equal(betaRematchReady.body.match.generation, 2); + assert.ok(betaRematchReady.body.match.countdownEndsAtMs > Date.now()); + + const alphaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(alphaRematchReady.body.status, "matched"); + assert.equal(alphaRematchReady.body.match.generation, 2); + assert.equal(alphaRematchReady.body.match.seed, betaRematchReady.body.match.seed); + assert.equal(alphaRematchReady.body.match.countdownEndsAtMs, betaRematchReady.body.match.countdownEndsAtMs); + + const staleExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }), + }); + assert.equal(staleExchange.response.status, 409); + + const freshExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 2, snapshot: hostSnapshot }), + }); + assert.equal(freshExchange.response.status, 200); }); test("invalid credentials cannot access server saves", async () => { diff --git a/src/App.tsx b/src/App.tsx index b1816a1..eccd9a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,12 @@ 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_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync"; -import { startSaveSyncCoordinator } from "./frontend/saveSync"; +import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH_EVENT } from "./platform/dualScreenSync"; +import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync"; import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp"; +import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp"; import { onlineRepository } from "./frontend/onlineRepository"; +import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking"; const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); @@ -49,10 +51,16 @@ function MainApp() { const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat); const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); + const gamePhase = useGameStore((state) => state.phase); + const gameRunMode = useGameStore((state) => state.runMode); + const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); const rewardedBossInstances = useRef(new Set()); + const hockeyPvpPostMatchOperation = useRef(null); const screenRef = useRef(screen); screenRef.current = screen; const leaveGame = useCallback(() => { + hockeyPvpPostMatchOperation.current?.cancel(); + hockeyPvpPostMatchOperation.current = null; const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); const game = useGameStore.getState(); // RPG Roguelike equipment belongs only to its current run. Never leak it @@ -62,6 +70,67 @@ function MainApp() { navigate("home"); if (accountId && activeSlotId) void uploadSlot(activeSlotId); }, [navigate, touchActiveSave, updateActiveHealerInventory]); + + const launchHockeyPvpMatch = useCallback((match: HockeyPvpMatchConfig) => { + if (!hunter) return; + const progress = hunter.healers[hunter.activeClassId]; + rewardedBossInstances.current.clear(); + clearRecentRewards(); + useGameStore.getState().configureHealer( + hunter.activeClassId, + hunter.hunterName, + progress.inventory, + [hockeyPvpBossAt(match.seed, 0)], + "hockey-healing-pvp", + hunter.gearProgress, + "initiate", + match, + ); + touchActiveSave(); + }, [clearRecentRewards, hunter, touchActiveSave]); + + const handleHockeyPvpPostMatchAction = useCallback((action: "rematch" | "requeue") => { + const game = useGameStore.getState(); + if (game.runMode !== "hockey-healing-pvp" + || game.phase !== "victory" && game.phase !== "defeat" + || !hunter) return; + game.setHockeyPvpPostMatchSelection(action); + hockeyPvpPostMatchOperation.current?.cancel(); + hockeyPvpPostMatchOperation.current = null; + + if (action === "rematch" && (game.hockeyPvp.role === "cpu" || !game.hockeyPvp.matchId)) { + launchHockeyPvpMatch({ + matchId: null, + seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)), + generation: game.hockeyPvp.generation + 1, + opponentName: game.hockeyPvp.opponentName, + role: "cpu", + countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS, + }); + return; + } + + const operation = action === "rematch" + ? startHockeyPvpRematch({ + matchId: game.hockeyPvp.matchId!, + generation: game.hockeyPvp.generation, + }) + : startHockeyPvpMatchmaking({ + slotId: hunter.slotId, + hunterName: hunter.hunterName, + online: Boolean(accountId && networkAppearsOnline()), + }); + game.setHockeyPvpPostMatchStatus( + action === "rematch" ? "waiting-rematch" : "requeueing", + action === "requeue" ? Date.now() + HOCKEY_PVP_QUEUE_TIMEOUT_MS : 0, + ); + hockeyPvpPostMatchOperation.current = operation; + void operation.result.then((match) => { + if (!match || hockeyPvpPostMatchOperation.current !== operation) return; + hockeyPvpPostMatchOperation.current = null; + launchHockeyPvpMatch(match); + }); + }, [accountId, hunter, launchHockeyPvpMatch]); const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { if (!hunter) return; const progress = hunter.healers[hunter.activeClassId]; @@ -113,6 +182,14 @@ function MainApp() { return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame); }, [leaveGame]); + useEffect(() => { + const onPostMatchAction = (event: Event) => { + handleHockeyPvpPostMatchAction((event as CustomEvent<"rematch" | "requeue">).detail); + }; + window.addEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction); + return () => window.removeEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction); + }, [handleHockeyPvpPostMatchAction]); + useEffect(() => { if (!accountId) return; return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId)); @@ -126,11 +203,12 @@ function MainApp() { if (stopped || exchangeActive) return; const state = useGameStore.getState(); if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return; + if (state.phase !== "briefing" && state.phase !== "combat") return; const snapshot = getHockeyPvpNetworkSnapshot(); if (!snapshot) return; exchangeActive = true; try { - const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot); + const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, state.hockeyPvp.generation, snapshot); if (!stopped && result.opponentSnapshot) { useGameStore.getState().applyHockeyPvpRemoteSnapshot( result.opponentSnapshot, @@ -168,7 +246,7 @@ function MainApp() { return () => { if (timer !== undefined) window.clearTimeout(timer); }; - }, [screen]); + }, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, screen]); useActionBindings(screen === "game", leaveGame); @@ -248,7 +326,7 @@ function MainApp() {

Offline-first healer roguelike v{packageJson.version}

{screen === "game" - ? }>} bottom={}>} /> + ? }>} bottom={}>} /> : } ); diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index b76b294..76f4a74 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { ABILITY_ORDER } from "../game/data"; import { HEALER_CLASSES } from "../game/healers"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; @@ -20,6 +20,8 @@ import { HOCKEY_PVP_GOAL_HALF_WIDTH, HOCKEY_PVP_GOAL_Z, HOCKEY_PVP_SIDE_OFFSET_Z, + cycleHockeyPvpPostMatchSelection, + type HockeyPvpPostMatchSelection, } from "../game/hockeyHealingPvp"; import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown"; import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs"; @@ -51,7 +53,14 @@ function moveTacticalSelection(direction: 1 | -1) { store.selectItem(store.inventory[nextIndex].id); } -function useSingleScreenTacticalInput() { +function useSingleScreenTacticalInput( + onHockeyPvpAction?: (action: Exclude) => void, + onExit?: () => void, +) { + const actionRef = useRef(onHockeyPvpAction); + const exitRef = useRef(onExit); + actionRef.current = onHockeyPvpAction; + exitRef.current = onExit; useEffect(() => { if (!isSingleScreenLayout()) return; let active = getDisplaySurface() === "bottom"; @@ -68,7 +77,10 @@ function useSingleScreenTacticalInput() { const activatePhaseAction = () => { const store = useGameStore.getState(); if (store.phase === "briefing") store.startEncounter(); - else if (store.phase === "victory" || store.phase === "defeat") store.restart(); + else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { + if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.(); + else actionRef.current?.(store.hockeyPvp.postMatchSelection); + } else if (store.phase === "victory" || store.phase === "defeat") store.restart(); }; const onKeyDown = (event: KeyboardEvent) => { if (!active || event.repeat) return; @@ -78,6 +90,18 @@ function useSingleScreenTacticalInput() { || store.phase === "intermission" || store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return; const key = event.key.toLowerCase(); + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { + if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault(); + if (key === "arrowleft" || key === "arrowup") { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1)); + } + if (key === "arrowright" || key === "arrowdown") { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1)); + } + if (key === "enter") activatePhaseAction(); + if (key === "escape") exitRef.current?.(); + return; + } if (key === "arrowleft") cycleTab(-1); else if (key === "arrowright") cycleTab(1); else if (key === "arrowup") moveTacticalSelection(-1); @@ -93,6 +117,15 @@ function useSingleScreenTacticalInput() { || store.runMode === "rpg-roguelike" || store.phase === "intermission" || store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return; + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { + if (["Button12", "Button14"].includes(token)) { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1)); + } else if (["Button13", "Button15"].includes(token)) { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1)); + } else if (!repeat && token === "Button0") activatePhaseAction(); + else if (!repeat && token === "Button1") exitRef.current?.(); + return; + } if (token === "Button14") cycleTab(-1); else if (token === "Button15") cycleTab(1); else if (token === "Button12") moveTacticalSelection(-1); @@ -261,7 +294,7 @@ function BriefingPanel() { Chosen discipline

{healer.specialization}

{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}}

- +
Prepared skills6 equipped
@@ -280,7 +313,13 @@ function BriefingPanel() { ); } -function EndPanel({ onExit }: { onExit?: () => void }) { +function EndPanel({ + onExit, + onHockeyPvpAction, +}: { + onExit?: () => void; + onHockeyPvpAction?: (action: Exclude) => void; +}) { const hunter = useActiveHunter(); const phase = useGameStore((state) => state.phase); const runMode = useGameStore((state) => state.runMode); @@ -299,6 +338,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) { const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); const hockey = useGameStore((state) => state.hockey); const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); + const requeueSeconds = useHockeyPvpCountdownSeconds( + hockeyPvp.postMatchStatus === "requeueing", + hockeyPvp.postMatchQueueEndsAtMs, + ); const blockbreaker = useGameStore((state) => state.blockbreaker); const aetherAssault = useGameStore((state) => state.aetherAssault); const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode; @@ -306,11 +350,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) { const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker"; const aetherDefeat = phase === "defeat" && activityMode === "aether-assault"; const pvpMatch = activityMode === "hockey-healing-pvp"; - const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat; + const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch; const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0; const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`; return ( -
+
{phase === "victory" ? "✦" : "×"} {showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}

{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}

@@ -332,9 +376,36 @@ function EndPanel({ onExit }: { onExit?: () => void }) { onPointerEnter={() => setEndlessChoiceSelection("quit")} onClick={onExit} >Quit to Main Menu -
:
- - +
: pvpMatch ? <> +
+ {hockeyPvp.postMatchStatus === "waiting-rematch" + ? `Waiting for ${hockeyPvp.opponentName} to accept rematch…` + : hockeyPvp.postMatchStatus === "requeueing" + ? `Searching queue · CPU fallback in ${requeueSeconds}s` + : "Choose rematch or enter queue for another opponent."} +
+
+ + + +
+ :
+ +
}
); @@ -354,11 +425,14 @@ function IntermissionStatusPanel() { ); } -function CombatPanel({ onExit }: { onExit?: () => void }) { +function CombatPanel({ onExit, onHockeyPvpAction }: { + onExit?: () => void; + onHockeyPvpAction?: (action: Exclude) => 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
; } @@ -725,8 +799,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: { ); } -export function BottomScreen({ onExit }: { onExit?: () => void } = {}) { - useSingleScreenTacticalInput(); +export function BottomScreen({ onExit, onHockeyPvpAction }: { + onExit?: () => void; + onHockeyPvpAction?: (action: Exclude) => void; +} = {}) { + useSingleScreenTacticalInput(onHockeyPvpAction, onExit); const activeTab = useGameStore((state) => state.activeTab); const setActiveTab = useGameStore((state) => state.setActiveTab); const phase = useGameStore((state) => state.phase); @@ -753,7 +830,7 @@ export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
- {activeTab === "combat" && } + {activeTab === "combat" && } {activeTab === "map" && } {activeTab === "pack" && activityMode !== "hockey-healing-pvp" && } {activeTab === "pvp" && activityMode === "hockey-healing-pvp" && } diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index e5ddf7b..3f2d9ca 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -51,12 +51,11 @@ import { DualDisplayFrame } from "./DualDisplayFrame"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { HOCKEY_PVP_GOAL_DAMAGE, - HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt, - randomHockeyPvpCpuName, type HockeyPvpMatchConfig, } from "../game/hockeyHealingPvp"; +import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking"; import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker"; import { APPEARANCE_SLOT_DEFINITIONS, @@ -1545,10 +1544,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const [queueing, setQueueing] = useState(false); const [queueElapsed, setQueueElapsed] = useState(0); const queueActive = useRef(false); - const queueTicket = useRef(null); - const queuePollTimer = useRef(null); - const queueCpuTimer = useRef(null); - const queueClockTimer = useRef(null); + const queueOperation = useRef(null); const mode = MODE_COPY[modeId]; const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest; const progress = hunter?.healers[hunter.activeClassId]; @@ -1568,42 +1564,19 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => { selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); }; - const clearQueueTimers = () => { - if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current); - if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current); - if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current); - queuePollTimer.current = null; - queueCpuTimer.current = null; - queueClockTimer.current = null; - }; const completePvpQueue = (match: HockeyPvpMatchConfig) => { if (!queueActive.current) return; queueActive.current = false; - clearQueueTimers(); + queueOperation.current = null; setQueueing(false); setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`); onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match); }; - const fallbackToCpu = () => { - if (!queueActive.current) return; - const ticketId = queueTicket.current; - queueTicket.current = null; - if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); - completePvpQueue({ - matchId: null, - seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)), - opponentName: randomHockeyPvpCpuName(), - role: "cpu", - countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS, - }); - }; const cancelPvpQueue = () => { if (!queueActive.current) return; queueActive.current = false; - clearQueueTimers(); - const ticketId = queueTicket.current; - queueTicket.current = null; - if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); + queueOperation.current?.cancel(); + queueOperation.current = null; setQueueing(false); setQueueElapsed(0); setMessage("Matchmaking cancelled."); @@ -1614,54 +1587,22 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi setQueueing(true); setQueueElapsed(0); setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…"); - const startedAt = Date.now(); - queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100); - queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS); - if (!accountId || !networkAppearsOnline()) return; - try { - const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName); - if (!queueActive.current) return; - queueTicket.current = joined.ticketId; - if (joined.match) { - completePvpQueue({ - matchId: joined.match.id, - seed: joined.match.seed, - countdownEndsAtMs: joined.match.countdownEndsAtMs, - opponentName: joined.match.opponentName, - role: joined.match.role, - }); - return; - } - const poll = async () => { - if (!queueActive.current || !queueTicket.current) return; - try { - const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current); - if (!queueActive.current) return; - if (result.match) { - completePvpQueue({ - matchId: result.match.id, - seed: result.match.seed, - countdownEndsAtMs: result.match.countdownEndsAtMs, - opponentName: result.match.opponentName, - role: result.match.role, - }); - return; - } - } catch { - // Five-second CPU fallback remains authoritative during transient outages. - } - if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350); - }; - queuePollTimer.current = window.setTimeout(poll, 350); - } catch { - setMessage("Online queue unavailable. CPU fallback still searching…"); - } + const operation = startHockeyPvpMatchmaking({ + slotId: hunter.slotId, + hunterName: hunter.hunterName, + online: Boolean(accountId && networkAppearsOnline()), + onElapsed: setQueueElapsed, + onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"), + }); + queueOperation.current = operation; + const match = await operation.result; + if (!match || queueOperation.current !== operation) return; + completePvpQueue(match); }; useEffect(() => () => { queueActive.current = false; - clearQueueTimers(); - const ticketId = queueTicket.current; - if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); + queueOperation.current?.cancel(); + queueOperation.current = null; }, []); const leaveMode = () => { cancelPvpQueue(); diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index a3d8e5c..41a5b81 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -8,6 +8,7 @@ import { BuffDraftPanel } from "./BuffDraftPanel"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp"; import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown"; +import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync"; import { blockbreakerTimeMultiplier } from "../game/blockbreaker"; import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; import type { CharacterAppearanceV1 } from "../game/characterAppearance"; @@ -173,6 +174,11 @@ function PhaseOverlay() { activityMode === "hockey-healing-pvp" && phase === "briefing", hockeyPvp.countdownEndsAtMs, ); + const pvpRequeueSeconds = useHockeyPvpCountdownSeconds( + hockeyPvp.postMatchStatus === "requeueing", + hockeyPvp.postMatchQueueEndsAtMs, + ); + const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); const singleScreen = isSingleScreenLayout(); if (runMode === "rpg-roguelike") return null; if (phase === "intermission") return ; @@ -235,16 +241,45 @@ function PhaseOverlay() { : singleScreen ? "Press Start / Enter to begin" : "Begin from lower display"; + const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); return (
{eyebrow}

{title}

{copy}

+ {pvpMode && phase === "briefing" &&
+ Match starts in + {pvpCountdownSeconds} + seconds +
} + {pvpEnded && <> +
+ + +
+
+ {hockeyPvp.postMatchStatus === "waiting-rematch" + ? `Waiting for ${hockeyPvp.opponentName}…` + : hockeyPvp.postMatchStatus === "requeueing" + ? `Searching queue · CPU fallback in ${pvpRequeueSeconds}s` + : "Choose next match"} +
+ } {phase === "briefing" ? briefingPrompt : singleScreen - ? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart" + ? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "D-pad chooses · Confirm selects · Menu exits" : "Press Start / Enter to restart" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}
); @@ -416,6 +451,7 @@ export function TopScreen({ const blockbreakerMode = activityMode === "blockbreaker"; const aetherAssaultMode = activityMode === "aether-assault"; const pvpMode = activityMode === "hockey-healing-pvp"; + const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`; return (
@@ -431,7 +467,11 @@ export function TopScreen({
WASD Move{aetherAssaultMode ? " + auto-fire" : ""} Q / E Target 1–6 Cast
- {onExit && } + {onExit && }
diff --git a/src/frontend/hockeyPvpMatchmaking.test.ts b/src/frontend/hockeyPvpMatchmaking.test.ts new file mode 100644 index 0000000..0a2e14b --- /dev/null +++ b/src/frontend/hockeyPvpMatchmaking.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startHockeyPvpMatchmaking, startHockeyPvpRematch } from "./hockeyPvpMatchmaking"; + +describe("Hockey PVP matchmaking", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("falls back to a CPU match with a visible five-second start countdown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const operation = startHockeyPvpMatchmaking({ + slotId: 1, + hunterName: "Aelia", + online: false, + timeoutMs: 5_000, + random: () => 0.5, + cpuName: () => "CPU Sage", + }); + + await vi.advanceTimersByTimeAsync(5_000); + await expect(operation.result).resolves.toMatchObject({ + role: "cpu", + opponentName: "CPU Sage", + generation: 1, + countdownEndsAtMs: 11_000, + }); + }); + + it("polls until both players accept an online rematch", async () => { + vi.useFakeTimers(); + const requestHockeyPvpRematch = vi.fn() + .mockResolvedValueOnce({ status: "waiting" }) + .mockResolvedValueOnce({ + status: "matched", + match: { + id: "match-1", + seed: 22, + generation: 2, + countdownEndsAtMs: 10_000, + opponentName: "Rival", + role: "host", + }, + }); + const operation = startHockeyPvpRematch({ + matchId: "match-1", + generation: 1, + pollMs: 350, + repository: { + requestHockeyPvpRematch, + cancelHockeyPvpRematch: vi.fn().mockResolvedValue(undefined), + }, + }); + + await vi.advanceTimersByTimeAsync(350); + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-1", + seed: 22, + generation: 2, + opponentName: "Rival", + role: "host", + }); + expect(requestHockeyPvpRematch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/frontend/hockeyPvpMatchmaking.ts b/src/frontend/hockeyPvpMatchmaking.ts new file mode 100644 index 0000000..17ae684 --- /dev/null +++ b/src/frontend/hockeyPvpMatchmaking.ts @@ -0,0 +1,187 @@ +import { + HOCKEY_PVP_COUNTDOWN_MS, + HOCKEY_PVP_QUEUE_TIMEOUT_MS, + randomHockeyPvpCpuName, + type HockeyPvpMatchConfig, +} from "../game/hockeyHealingPvp"; +import { + onlineRepository, + type HockeyPvpOnlineMatch, + type OnlineRepository, +} from "./onlineRepository"; +import type { SaveSlotId } from "./types"; + +type QueueRepository = Pick; +type RematchRepository = Pick; + +export interface HockeyPvpMatchOperation { + result: Promise; + cancel: () => void; +} + +export function onlineHockeyPvpMatchConfig(match: HockeyPvpOnlineMatch): HockeyPvpMatchConfig { + return { + matchId: match.id, + seed: match.seed, + generation: match.generation, + countdownEndsAtMs: match.countdownEndsAtMs, + opponentName: match.opponentName, + role: match.role, + }; +} + +export function startHockeyPvpMatchmaking(options: { + slotId: SaveSlotId; + hunterName: string; + online: boolean; + repository?: QueueRepository; + timeoutMs?: number; + pollMs?: number; + onElapsed?: (elapsedMs: number) => void; + onOnlineUnavailable?: () => void; + random?: () => number; + cpuName?: () => string; +}): HockeyPvpMatchOperation { + const repository = options.repository ?? onlineRepository; + const timeoutMs = options.timeoutMs ?? HOCKEY_PVP_QUEUE_TIMEOUT_MS; + const pollMs = options.pollMs ?? 350; + const random = options.random ?? Math.random; + const cpuName = options.cpuName ?? randomHockeyPvpCpuName; + const startedAt = Date.now(); + let active = true; + let ticketId: string | null = null; + let pollTimer: ReturnType | null = null; + let fallbackTimer: ReturnType | null = null; + let clockTimer: ReturnType | null = null; + let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined; + + const clearTimers = () => { + if (pollTimer !== null) clearTimeout(pollTimer); + if (fallbackTimer !== null) clearTimeout(fallbackTimer); + if (clockTimer !== null) clearInterval(clockTimer); + pollTimer = null; + fallbackTimer = null; + clockTimer = null; + }; + const finish = (match: HockeyPvpMatchConfig | null) => { + if (!active) return; + active = false; + clearTimers(); + settle(match); + }; + const cancelTicket = () => { + const currentTicketId = ticketId; + ticketId = null; + if (currentTicketId) void repository.cancelHockeyPvpQueue(currentTicketId).catch(() => undefined); + }; + const fallbackToCpu = () => { + if (!active) return; + cancelTicket(); + finish({ + matchId: null, + seed: Math.max(1, Math.floor(random() * 0xffffffff)), + generation: 1, + opponentName: cpuName(), + role: "cpu", + countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS, + }); + }; + const result = new Promise((resolve) => { + settle = resolve; + fallbackTimer = setTimeout(fallbackToCpu, timeoutMs); + if (options.onElapsed) { + options.onElapsed(0); + clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100); + } + if (!options.online) return; + void (async () => { + try { + const joined = await repository.joinHockeyPvpQueue(options.slotId, options.hunterName); + if (!active) { + if (!joined.match) void repository.cancelHockeyPvpQueue(joined.ticketId).catch(() => undefined); + return; + } + ticketId = joined.ticketId; + if (joined.match) { + finish(onlineHockeyPvpMatchConfig(joined.match)); + return; + } + const poll = async () => { + if (!active || !ticketId) return; + try { + const queued = await repository.pollHockeyPvpQueue(ticketId); + if (!active) return; + if (queued.match) { + finish(onlineHockeyPvpMatchConfig(queued.match)); + return; + } + } catch { + options.onOnlineUnavailable?.(); + } + if (active) pollTimer = setTimeout(poll, pollMs); + }; + pollTimer = setTimeout(poll, pollMs); + } catch { + options.onOnlineUnavailable?.(); + } + })(); + }); + + return { + result, + cancel: () => { + if (!active) return; + cancelTicket(); + finish(null); + }, + }; +} + +export function startHockeyPvpRematch(options: { + matchId: string; + generation: number; + repository?: RematchRepository; + pollMs?: number; + onUnavailable?: () => void; +}): HockeyPvpMatchOperation { + const repository = options.repository ?? onlineRepository; + const pollMs = options.pollMs ?? 350; + let active = true; + let pollTimer: ReturnType | null = null; + let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined; + const result = new Promise((resolve) => { + settle = resolve; + const poll = async () => { + if (!active) return; + try { + const rematch = await repository.requestHockeyPvpRematch(options.matchId, options.generation); + if (!active) { + void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined); + return; + } + if (rematch.match) { + active = false; + settle(onlineHockeyPvpMatchConfig(rematch.match)); + return; + } + } catch { + options.onUnavailable?.(); + } + if (active) pollTimer = setTimeout(poll, pollMs); + }; + void poll(); + }); + + return { + result, + cancel: () => { + if (!active) return; + active = false; + if (pollTimer !== null) clearTimeout(pollTimer); + void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined); + settle(null); + }, + }; +} diff --git a/src/frontend/onlineRepository.ts b/src/frontend/onlineRepository.ts index 79c9259..941a3cc 100644 --- a/src/frontend/onlineRepository.ts +++ b/src/frontend/onlineRepository.ts @@ -30,16 +30,24 @@ export interface LeaderboardResult { current: LeaderboardEntry | null; } +export interface HockeyPvpOnlineMatch { + id: string; + seed: number; + generation: number; + countdownEndsAtMs: number; + opponentName: string; + role: Exclude; +} + export interface HockeyPvpQueueResult { ticketId: string; status: "waiting" | "matched"; - match?: { - id: string; - seed: number; - countdownEndsAtMs: number; - opponentName: string; - role: Exclude; - }; + match?: HockeyPvpOnlineMatch; +} + +export interface HockeyPvpRematchResult { + status: "waiting" | "matched"; + match?: HockeyPvpOnlineMatch; } export interface HockeyPvpExchangeResult { @@ -215,11 +223,27 @@ export class OnlineRepository { return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" }); } - exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise { + exchangeHockeyPvpState(matchId: string, generation: number, snapshot: HockeyPvpRemoteSnapshot): Promise { return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ snapshot }), + body: JSON.stringify({ generation, snapshot }), + }); + } + + requestHockeyPvpRematch(matchId: string, generation: number): Promise { + return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), + }); + } + + cancelHockeyPvpRematch(matchId: string, generation: number): Promise { + return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), }); } } diff --git a/src/game/hockeyHealingPvp.test.ts b/src/game/hockeyHealingPvp.test.ts index 9968bd5..5693f95 100644 --- a/src/game/hockeyHealingPvp.test.ts +++ b/src/game/hockeyHealingPvp.test.ts @@ -4,6 +4,7 @@ import { HOCKEY_PVP_GOAL_Z, advanceHockeyPvpPuck, createHockeyPvpState, + cycleHockeyPvpPostMatchSelection, hockeyPvpBossAt, hockeyPvpCountdownSeconds, hockeyPvpDampeningPercent, @@ -28,6 +29,13 @@ describe("Healing Hockey PVP", () => { expect(hockeyPvpCountdownSeconds(10_000, 12_000)).toBe(0); }); + it("cycles rematch, requeue, and menu choices deterministically", () => { + expect(cycleHockeyPvpPostMatchSelection("rematch", 1)).toBe("requeue"); + expect(cycleHockeyPvpPostMatchSelection("requeue", 1)).toBe("menu"); + expect(cycleHockeyPvpPostMatchSelection("menu", 1)).toBe("rematch"); + expect(cycleHockeyPvpPostMatchSelection("rematch", -1)).toBe("menu"); + }); + it("adds five percent global dampening for every boss killed by either party", () => { expect(hockeyPvpDampeningPercent(0, 0)).toBe(0); expect(hockeyPvpDampeningPercent(1, 0)).toBe(5); diff --git a/src/game/hockeyHealingPvp.ts b/src/game/hockeyHealingPvp.ts index f325efc..644d066 100644 --- a/src/game/hockeyHealingPvp.ts +++ b/src/game/hockeyHealingPvp.ts @@ -4,10 +4,13 @@ import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types export type HockeyPvpRole = "cpu" | "host" | "guest"; export type HockeyPvpGoalSide = "local" | "opponent"; +export type HockeyPvpPostMatchSelection = "rematch" | "requeue" | "menu"; +export type HockeyPvpPostMatchStatus = "idle" | "waiting-rematch" | "requeueing"; export interface HockeyPvpMatchConfig { matchId: string | null; seed: number; + generation?: number; opponentName: string; role: HockeyPvpRole; countdownEndsAtMs?: number; @@ -27,7 +30,11 @@ export interface HockeyPvpPuckState { export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState { countdownEndsAtMs: number; + generation: number; status: "inactive" | "live" | "won" | "lost"; + postMatchSelection: HockeyPvpPostMatchSelection; + postMatchStatus: HockeyPvpPostMatchStatus; + postMatchQueueEndsAtMs: number; aimDirection: WorldPosition; opponentBossKills: number; opponentPlayerPosition: WorldPosition; @@ -65,6 +72,7 @@ export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5; export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5; export const HOCKEY_PVP_COUNTDOWN_MS = 5_000; export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000; +export const HOCKEY_PVP_POST_MATCH_SELECTIONS = ["rematch", "requeue", "menu"] as const; const STARTING_SPEED = 7.8; const MAX_SPEED = 12.2; @@ -105,6 +113,17 @@ export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Dat return Math.max(0, Math.ceil((countdownEndsAtMs - nowMs) / 1_000)); } +export function cycleHockeyPvpPostMatchSelection( + selection: HockeyPvpPostMatchSelection, + direction: 1 | -1, +): HockeyPvpPostMatchSelection { + const currentIndex = HOCKEY_PVP_POST_MATCH_SELECTIONS.indexOf(selection); + return HOCKEY_PVP_POST_MATCH_SELECTIONS[ + (Math.max(0, currentIndex) + direction + HOCKEY_PVP_POST_MATCH_SELECTIONS.length) + % HOCKEY_PVP_POST_MATCH_SELECTIONS.length + ]; +} + function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition { const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH; const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z; @@ -118,7 +137,11 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt return { ...match, countdownEndsAtMs: config?.countdownEndsAtMs ?? 0, + generation: config?.generation ?? 1, status: config ? "live" : "inactive", + postMatchSelection: "rematch", + postMatchStatus: "idle", + postMatchQueueEndsAtMs: 0, puckPosition: [0, 0], puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0], localReturns: 0, diff --git a/src/game/mana.ts b/src/game/mana.ts new file mode 100644 index 0000000..4d2b64a --- /dev/null +++ b/src/game/mana.ts @@ -0,0 +1,5 @@ +/** Shared healer resource tuning for every encounter and run mode. */ +export const BASE_MANA_POOL = 150; + +/** Global in-combat regeneration, expressed as mana restored per second. */ +export const MANA_REGEN_PER_SECOND = 3.2 / 3; diff --git a/src/game/manaBalance.test.ts b/src/game/manaBalance.test.ts new file mode 100644 index 0000000..0abc0dd --- /dev/null +++ b/src/game/manaBalance.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers"; +import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana"; +import type { AbilitySlotId, HealerClassId } from "./types"; + +const LONG_DUAL_BOSS_SECONDS = 100; +const MINIMUM_ENDING_RESERVE = 20; +const MAXIMUM_ENDING_RESERVE = 50; + +/** + * High-pressure reference rotations for the longest intended dual-boss fight. + * Counts include spot healing, maintenance effects, four cleanses, repeated + * group recovery, and both available one-minute cooldown casts. + */ +const REFERENCE_ROTATIONS = { + priest: { ability1: 8, ability2: 8, ability3: 5, ability4: 4, ability5: 5, ability6: 2 }, + druid: { ability1: 7, ability2: 9, ability3: 9, ability4: 4, ability5: 5, ability6: 2 }, + shaman: { ability1: 7, ability2: 9, ability3: 4, ability4: 4, ability5: 6, ability6: 2 }, + paladin: { ability1: 10, ability2: 20, ability3: 4, ability4: 4, ability5: 6, ability6: 2 }, + chronomancer: { ability1: 7, ability2: 8, ability3: 8, ability4: 4, ability5: 5, ability6: 2 }, +} as const satisfies Record>; + +function rotationManaCost(classId: HealerClassId): number { + const abilities = HEALER_CLASSES[classId].abilities; + return Object.entries(REFERENCE_ROTATIONS[classId]).reduce( + (total, [slotId, casts]) => total + abilities[slotId as AbilitySlotId].mana * casts, + 0, + ); +} + +describe("dual-boss healer mana balance", () => { + const availableMana = BASE_MANA_POOL + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS; + + it.each(HEALER_CLASS_ORDER)("funds %s's high-pressure 100-second rotation with a useful reserve", (classId) => { + const rotation = REFERENCE_ROTATIONS[classId]; + const abilities = HEALER_CLASSES[classId].abilities; + const totalCasts = Object.values(rotation).reduce((total, casts) => total + casts, 0); + const remainingMana = availableMana - rotationManaCost(classId); + + expect(totalCasts).toBeLessThanOrEqual(LONG_DUAL_BOSS_SECONDS / 0.5); + expect(Object.values(rotation).every((casts) => casts > 0)).toBe(true); + for (const [slotId, casts] of Object.entries(rotation)) { + const cooldown = abilities[slotId as AbilitySlotId].cooldown; + if (cooldown <= 0) continue; + const maximumCasts = Math.floor((LONG_DUAL_BOSS_SECONDS - Number.EPSILON) / cooldown) + 1; + expect(casts).toBeLessThanOrEqual(maximumCasts); + } + expect(remainingMana).toBeGreaterThanOrEqual(MINIMUM_ENDING_RESERVE); + expect(remainingMana).toBeLessThanOrEqual(MAXIMUM_ENDING_RESERVE); + }); + + it("keeps reference rotation costs close enough that no class gets a dominant mana advantage", () => { + const costs = HEALER_CLASS_ORDER.map(rotationManaCost); + expect(Math.max(...costs) - Math.min(...costs)).toBeLessThanOrEqual(25); + }); + + it("detects why the previous 100 mana pool was insufficient", () => { + const previousBudget = 100 + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS; + for (const classId of HEALER_CLASS_ORDER) { + expect(rotationManaCost(classId)).toBeGreaterThan(previousBudget); + } + }); +}); diff --git a/src/game/rpgRoguelikeStore.test.ts b/src/game/rpgRoguelikeStore.test.ts index 92181b2..7a15a60 100644 --- a/src/game/rpgRoguelikeStore.test.ts +++ b/src/game/rpgRoguelikeStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena"; import { BOSS_DEFINITIONS } from "./bossCatalog"; import { createClassInventory } from "./healers"; +import { BASE_MANA_POOL } from "./mana"; import { createDefaultGearProgress } from "./progression/gear"; import { equipPassiveInfusion } from "./progression/infusions"; import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike"; @@ -112,6 +113,8 @@ describe("RPG Roguelike store integration", () => { expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true); expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds); expect(state.endlessMode).toBe(true); + expect(state.maxMana).toBe(BASE_MANA_POOL); + expect(state.mana).toBe(BASE_MANA_POOL); }); it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => { diff --git a/src/game/store.test.ts b/src/game/store.test.ts index b75d50c..9ce3d0d 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { BULL_CHARGE } from "./bossMechanics"; import { distance, pointToSegmentDistance } from "./geometry"; -import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store"; +import { BASE_MANA_POOL, BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store"; import { createClassInventory, HEALER_CLASSES } from "./healers"; import { healingEffect } from "./healerEffects"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; @@ -73,6 +73,15 @@ describe("Disc Priest combat simulation", () => { expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3); }); + it("starts every healer class with the shared 150 mana pool", () => { + for (const classId of Object.keys(HEALER_CLASSES) as (keyof typeof HEALER_CLASSES)[]) { + useGameStore.getState().configureHealer(classId, "Aelia", createClassInventory(classId)); + const state = useGameStore.getState(); + expect(state.maxMana).toBe(BASE_MANA_POOL); + expect(state.mana).toBe(BASE_MANA_POOL); + } + }); + it("uses a three-second Purify cooldown for every healer class", () => { expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3); expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3); @@ -561,7 +570,7 @@ describe("Roguelike ability buffs", () => { useGameStore.getState().selectMember("brann"); expect(useGameStore.getState().castAbility("ability1")).toBe(true); - expect(useGameStore.getState().mana).toBe(97); + expect(useGameStore.getState().mana).toBe(BASE_MANA_POOL - 3); expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3); useGameStore.getState().tick(0.22); diff --git a/src/game/store.ts b/src/game/store.ts index 3624bb1..726dd2e 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -40,6 +40,7 @@ import { resolveTimeLoop, startTimeLoop, } from "./healerMechanics"; +import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana"; import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState"; @@ -80,6 +81,8 @@ import { mirrorHockeyPvpPuck, reconcileHockeyPvpPuck, type HockeyPvpMatchConfig, + type HockeyPvpPostMatchSelection, + type HockeyPvpPostMatchStatus, type HockeyPvpRemoteSnapshot, type HockeyPvpState, } from "./hockeyHealingPvp"; @@ -241,6 +244,8 @@ export interface GameState { selectItem: (itemId: string) => void; setPlayerPosition: (position: [number, number]) => void; setHockeyAimDirection: (direction: [number, number]) => void; + setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void; + setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void; applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void; setPaused: (paused: boolean) => void; togglePause: () => void; @@ -268,7 +273,7 @@ const emptyCooldowns = (): Record => ({ export const GLOBAL_COOLDOWN_SECONDS = 0.5; export const RUN_BUFF_INPUT_LOCK_MS = 2_500; -export const MANA_REGEN_PER_SECOND = 3.2 / 3; +export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana"; export const BARRIER_RADIUS = 4; export const BARRIER_DAMAGE_REDUCTION = 0.3; @@ -584,7 +589,7 @@ function initialState( 0, "hockey", ); - const maxMana = 100; + const maxMana = BASE_MANA_POOL; return { bossId: primary.boss.id, bossInstanceId: primary.instanceId, @@ -728,7 +733,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part ? { ...member, hp: member.maxHp * healerRatio } : member); const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0; - const maxMana = 100; + const maxMana = BASE_MANA_POOL; return { rpgRun: run, @@ -839,7 +844,7 @@ export const useGameStore = create((set, get) => ({ difficultySlug, seenBossIds, runMode === "hockey-healing-pvp" - ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } + ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } : undefined, abilityLoadout, ), @@ -892,7 +897,7 @@ export const useGameStore = create((set, get) => ({ difficultySlug, [], runMode === "hockey-healing-pvp" - ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } + ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } : undefined, abilityLoadout, )); @@ -1114,6 +1119,14 @@ export const useGameStore = create((set, get) => ({ const hockey = setHockeyAim(state.hockey, direction); return hockey === state.hockey ? state : { hockey }; }), + + setHockeyPvpPostMatchSelection: (postMatchSelection) => set((state) => ({ + hockeyPvp: { ...state.hockeyPvp, postMatchSelection }, + })), + + setHockeyPvpPostMatchStatus: (postMatchStatus, postMatchQueueEndsAtMs = 0) => set((state) => ({ + hockeyPvp: { ...state.hockeyPvp, postMatchStatus, postMatchQueueEndsAtMs }, + })), applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => { if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state; const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck @@ -2289,6 +2302,8 @@ export type GameSnapshot = Omit void) { else onExit?.(); } +function activateHockeyPvpPostMatch(onExit?: () => void) { + const store = useGameStore.getState(); + if (store.hockeyPvp.postMatchSelection === "menu") onExit?.(); + else requestHockeyPvpPostMatchAction(store.hockeyPvp.postMatchSelection); +} + export function useActionBindings(enabled = true, onExit?: () => void) { const exitRef = useRef(onExit); exitRef.current = onExit; @@ -94,6 +102,18 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (key === "escape") exitRef.current?.(); return; } + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { + if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault(); + if (key === "arrowleft" || key === "arrowup") { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1)); + } + if (key === "arrowright" || key === "arrowdown") { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1)); + } + if (key === "enter") activateHockeyPvpPostMatch(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]); @@ -179,6 +199,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (!repeat && token === "Button1") exitRef.current?.(); return; } + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { + if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1)); + } + if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) { + store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1)); + } + if (!repeat && (token === "Button0" || token === "Button9")) activateHockeyPvpPostMatch(exitRef.current); + if (!repeat && token === "Button1") exitRef.current?.(); + return; + } if (repeat) return; if (token.startsWith("Button")) { const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))]; diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 912e122..9f7b61f 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -194,6 +194,7 @@ export function BottomDisplayApp() { return false; }, setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }), + setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }), dispatchRpgAction: (action) => { postCommand({ name: "dispatchRpgAction", action }); return false; @@ -254,7 +255,7 @@ export function BottomDisplayApp() { return (
{surface.screen === "game" - ? }> postFrontendCommand({ name: "exitGame" })} /> + ? }> postFrontendCommand({ name: "exitGame" })} onHockeyPvpAction={(action) => postFrontendCommand({ name: "hockeyPvpPostMatch", action })} /> : surface.notice === "Linking upper display…" ? : } diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index a7d07c4..fefd109 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -118,6 +118,7 @@ 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 originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection; const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView; @@ -127,6 +128,7 @@ describe("dual-screen game snapshots", () => { useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; }, startRogueTrialsEndless: () => { calls.push("endless"); return true; }, + setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); }, }); useFrontendStore.setState({ selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, @@ -138,6 +140,7 @@ describe("dual-screen game snapshots", () => { executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "startRogueTrialsEndless" }); + executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" }); executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" }); @@ -146,6 +149,7 @@ describe("dual-screen game snapshots", () => { expect(calls).toEqual([ "continue", "endless", + "pvp:requeue", "ability:ability3", "passive:shield-guard", "profile-view:stats", @@ -153,7 +157,11 @@ describe("dual-screen game snapshots", () => { "profile-stat:broodfang-spider", ]); - useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless }); + useGameStore.setState({ + continueRoguelikeRound: originalContinue, + startRogueTrialsEndless: originalStartEndless, + setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection, + }); useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive, diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index b78b8da..287c145 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -9,7 +9,7 @@ import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types"; import type { GearOwnerId, GearSlotId } from "../game/progression/gear"; import type { DifficultySlug } from "../game/progression/loot"; import type { BossGroupId } from "../game/bossCatalog"; -import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; +import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp"; import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike"; import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance"; @@ -30,6 +30,7 @@ export type GameCommand = | { name: "continueRoguelikeRound" } | { name: "startRogueTrialsEndless" } | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" } + | { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection } | { name: "dispatchRpgAction"; action: RpgRoguelikeAction } | { name: "setRpgFocusId"; focusId: string } | { name: "cycleRpgFocus"; direction: 1 | -1 } @@ -76,11 +77,17 @@ export type FrontendCommand = | { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "selectHealerClass"; classId: HealerClassId } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } + | { name: "hockeyPvpPostMatch"; action: Exclude } | { name: "exitGame" } | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig }; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game"; +export const HOCKEY_PVP_POST_MATCH_EVENT = "iwt:hockey-pvp-post-match"; + +export function requestHockeyPvpPostMatchAction(action: Exclude) { + window.dispatchEvent(new CustomEvent(HOCKEY_PVP_POST_MATCH_EVENT, { detail: action })); +} export type DualScreenMessage = | { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial } @@ -114,6 +121,7 @@ export function executeGameCommand(command: GameCommand) { case "continueRoguelikeRound": game.continueRoguelikeRound(); break; case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break; case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break; + case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break; case "dispatchRpgAction": game.dispatchRpgAction(command.action); break; case "setRpgFocusId": game.setRpgFocusId(command.focusId); break; case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break; @@ -164,6 +172,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 "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); 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, hockeyPvpMatch: command.hockeyPvpMatch } })); break; } diff --git a/src/styles.css b/src/styles.css index 6eff202..35cc14e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -901,6 +901,49 @@ html[data-display-layout="single"] .encounter-callout { text-transform: uppercase; } +.pvp-match-countdown { + min-width: 150px; + margin: 12px 0 8px; + padding: 8px 24px 10px; + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + border: 1px solid rgba(231, 198, 111, 0.7); + background: rgba(4, 13, 11, 0.76); + box-shadow: 0 0 30px rgba(231, 198, 111, 0.13), inset 0 0 18px rgba(231, 198, 111, 0.06); + text-shadow: 0 2px 10px #000; +} + +.pvp-match-countdown span { grid-column: 1 / -1; color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; } +.pvp-match-countdown strong { color: #fff7dc; font-family: "Cinzel", serif; font-size: clamp(44px, 7vw, 74px); line-height: 0.9; } +.phase-overlay .pvp-match-countdown small { padding-bottom: 4px; color: #b7a878; font-size: 8px; } + +.top-pvp-end-actions { + width: min(390px, 72%); + margin: 15px 0 8px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + pointer-events: auto; +} + +.top-pvp-end-actions button { + min-height: 48px; + display: grid; + place-items: center; + gap: 2px; + border: 1px solid #566c63; + color: #dce9e4; + background: rgba(5, 17, 14, 0.88); + cursor: pointer; +} + +.top-pvp-end-actions button strong { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; } +.top-pvp-end-actions button small { color: #788e85; font-size: 7px; } +.top-pvp-end-actions button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; background: rgba(77, 65, 30, 0.78); } +.top-pvp-end-actions button:disabled { cursor: default; opacity: 0.7; } +.top-pvp-post-match-status { min-height: 16px; margin-bottom: 4px; color: #aebfb8; font-size: 9px; letter-spacing: 0.06em; } + .phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); } .phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); } .phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); } @@ -1563,9 +1606,19 @@ html[data-display-layout="single"] .encounter-callout { .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-selected { outline: 2px solid #fff1b6; outline-offset: 2px; } +.end-actions button:disabled { cursor: default; opacity: 0.62; } .endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; } .endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; } +.end-panel.is-pvp { padding-block: 4%; } +.end-panel.is-pvp h2 { margin-bottom: 10px; } +.pvp-post-match-status { min-height: 15px; margin-top: 12px; color: #9eb2aa; font-size: 9px; letter-spacing: 0.04em; } +.pvp-end-actions { width: min(100%, 470px); grid-template-columns: 1fr 1fr auto; } +.pvp-end-actions button { min-height: 46px; display: grid; place-items: center; gap: 2px; padding: 8px 14px; } +.pvp-end-actions button span { font-size: 9px; } +.pvp-end-actions button small { color: rgba(19, 23, 15, 0.62); font-size: 7px; letter-spacing: 0.04em; } +.pvp-end-actions button.secondary small { color: #6f837b; } + .buff-draft { height: 100%; display: grid; @@ -2861,6 +2914,7 @@ html[data-display-layout="single"] .encounter-callout { } .game-menu-button small { color: #71857e; font-size: 6px; } +.game-menu-button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; } .game-loading { display: grid;