Compare commits

..
4 Commits
Author SHA1 Message Date
Warren H 018e060cdd Release v0.1.20 2026-07-19 2026-07-19 16:37:37 -04:00
Warren H 437e70fc58 Release v0.1.19 2026-07-19 2026-07-19 14:15:30 -04:00
Warren H 7b522e3bc8 Release v0.1.18 2026-07-19 2026-07-19 13:58:47 -04:00
Warren H 016a012c78 Release v0.1.17 2026-07-19 2026-07-19 13:02:46 -04:00
45 changed files with 2320 additions and 246 deletions
+14 -8
View File
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
pnpm android:install
```
The first Android milestone uses the complete single-display fallback. Press
Select (or Tab with a keyboard) to switch between the main game surface and the
620 × 540 tactical surface. Native routing to both physical Thor displays is the
next milestone; it needs two Android display contexts backed by one shared game
state rather than two independent WebViews.
The Android host routes the main and tactical surfaces to separate physical
Thor displays while preserving one authoritative game state. If only one
display is available, Select (or Tab with a keyboard) opens the tactical surface
over the main game view.
PC and handheld browsers use the Thor top screen as a responsive, full-viewport
game surface. The compact ability strip keeps combat controls visible; Select
or Tab opens party, map, inventory, and other tactical detail. Use
`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
browser QA.
## TrueNAS deployment
@@ -140,6 +145,7 @@ outside the repository.
- `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / `START`: begin or reset encounter
@@ -185,6 +191,6 @@ build. Remove the switch to return to modular rendering.
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
- Lower-screen typography scales against its own container, never the main page viewport
Current Android build is an installable single-display test host. Shipping to both
physical Thor displays still needs distinct Android display contexts that project
one authoritative game state.
The Android build uses distinct display contexts that project one authoritative
game state across both physical Thor displays. PC and Steam Deck use the same top
surface with an adaptive tactical overlay.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.16",
"version": "0.1.20",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
+80
View File
@@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20;
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
const authAttempts = new Map();
function apiError(message, status = 400) {
@@ -650,6 +651,8 @@ export function createGameApiHandler(options = {}) {
match: {
id: match.id,
seed: match.seed,
generation: match.generation,
countdownEndsAtMs: match.countdownEndsAtMs,
opponentName: opponent.hunterName,
role: ticket.side,
},
@@ -692,9 +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";
@@ -722,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);
@@ -776,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, {
@@ -783,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) });
}
+46 -2
View File
@@ -228,6 +228,8 @@ 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}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
@@ -236,20 +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 () => {
+105 -4
View File
@@ -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 })));
@@ -26,6 +28,10 @@ function GameLoadingScreen() {
);
}
function TacticalLoadingScreen() {
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
}
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -45,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<string>());
const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(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
@@ -58,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];
@@ -109,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));
@@ -122,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,
@@ -147,6 +229,25 @@ function MainApp() {
};
}, [screen]);
useEffect(() => {
if (screen !== "game") return;
let timer: number | undefined;
const autoStart = () => {
const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || state.phase !== "briefing") return;
const remaining = state.hockeyPvp.countdownEndsAtMs - Date.now();
if (remaining <= 0) {
state.startEncounter();
return;
}
timer = window.setTimeout(autoStart, remaining + 16);
};
autoStart();
return () => {
if (timer !== undefined) window.clearTimeout(timer);
};
}, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, screen]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -225,7 +326,7 @@ function MainApp() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} onHockeyPvpAction={handleHockeyPvpPostMatchAction} /></Suspense>} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
+72
View File
@@ -0,0 +1,72 @@
import type { CSSProperties } from "react";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
import type { AbilitySlotId } from "../game/types";
export function AbilityButton({ abilityId, compact = false }: { abilityId: AbilitySlotId; compact?: boolean }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`;
if (!ability) {
return (
<button className={`${classes} is-empty`} disabled aria-label={`Empty ${abilityId}`}>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon"></span>
<span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
</button>
);
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`${classes} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
+170 -72
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react";
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -20,8 +20,11 @@ import {
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
cycleHockeyPvpPostMatchSelection,
type HockeyPvpPostMatchSelection,
} from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs";
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
@@ -31,9 +34,113 @@ import {
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
import { AbilityButton } from "./AbilityButton";
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller";
function moveTacticalSelection(direction: 1 | -1) {
const store = useGameStore.getState();
if (store.activeTab === "combat") {
store.cycleMember(direction);
return;
}
if (store.activeTab !== "pack" || store.inventory.length === 0) return;
const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
store.selectItem(store.inventory[nextIndex].id);
}
function useSingleScreenTacticalInput(
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => 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";
const unsubscribeSurface = subscribeDisplaySurface((surface) => { active = surface === "bottom"; });
const cycleTab = (direction: 1 | -1) => {
const store = useGameStore.getState();
if (direction === 1) store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode));
else {
const tabs = bottomTabsFor(store.runMode);
const currentIndex = tabs.indexOf(store.activeTab);
store.setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
}
};
const activatePhaseAction = () => {
const store = useGameStore.getState();
if (store.phase === "briefing") store.startEncounter();
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;
const store = useGameStore.getState();
if (store.paused
|| store.runMode === "rpg-roguelike"
|| 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);
else if (key === "arrowdown") moveTacticalSelection(1);
else if (key === "enter") activatePhaseAction();
else return;
event.preventDefault();
};
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (!active || repeat && !["Button12", "Button13", "Button14", "Button15"].includes(token)) return;
const store = useGameStore.getState();
if (store.paused
|| 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);
else if (token === "Button13") moveTacticalSelection(1);
else if (!repeat && token === "Button0") activatePhaseAction();
else if (!repeat && token === "Button1") requestDisplaySurface("top");
});
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
unsubscribeController();
unsubscribeSurface();
};
}, []);
}
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -113,62 +220,6 @@ function PartyList() {
);
}
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
@@ -234,6 +285,8 @@ function BriefingPanel() {
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
return (
<div className="briefing-panel">
<div className="briefing-class">
@@ -241,7 +294,7 @@ function BriefingPanel() {
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{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(" ")}</>}</p>
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
<button className="start-button" onClick={startEncounter} disabled={pvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? pvpCountdownSeconds > 0 ? `Match starts in ${pvpCountdownSeconds} seconds` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{pvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -260,7 +313,13 @@ function BriefingPanel() {
);
}
function EndPanel({ onExit }: { onExit?: () => void }) {
function EndPanel({
onExit,
onHockeyPvpAction,
}: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
}) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
@@ -279,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;
@@ -286,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 (
<div className={`end-panel end-${phase}`}>
<div className={`end-panel end-${phase} ${pvpMatch ? "is-pvp" : ""}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{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"}</small>
<h2>{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"}</h2>
@@ -312,9 +376,36 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
</div> : pvpMatch ? <>
<div className="pvp-post-match-status" role="status" aria-live="polite">
{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."}
</div>
<div className="end-actions pvp-end-actions">
<button
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
onClick={() => onHockeyPvpAction?.("rematch")}
><span>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</span><small>Same opponent</small></button>
<button
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
disabled={hockeyPvp.postMatchStatus === "requeueing"}
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
onClick={() => onHockeyPvpAction?.("requeue")}
><span>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${requeueSeconds}s` : "Requeue"}</span><small>Find another rival</small></button>
<button
className={`secondary ${hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setHockeyPvpPostMatchSelection("menu")}
onClick={onExit}
>Main menu</button>
</div>
</> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
@@ -334,11 +425,14 @@ function IntermissionStatusPanel() {
);
}
function CombatPanel({ onExit }: { onExit?: () => void }) {
function CombatPanel({ onExit, onHockeyPvpAction }: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
}) {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -705,7 +799,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
);
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
export function BottomScreen({ onExit, onHockeyPvpAction }: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
} = {}) {
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
@@ -732,7 +830,7 @@ export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
+53 -12
View File
@@ -1,39 +1,80 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { resolveDisplayLayout } from "../platform/displayLayout";
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");
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
const params = new URLSearchParams(window.location.search);
const dedicatedSurface = params.get("display");
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface;
const showSurface = useCallback((surface: DisplaySurface) => {
activeSurfaceRef.current = surface;
setActiveSurface(surface);
}, []);
const toggleSurface = useCallback(() => {
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
}, []);
useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
if (layout === "thor-preview") return;
requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (token === "Button8" && !repeat) toggle();
if (token === "Button8" && !repeat) toggleSurface();
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab" || event.repeat) return;
if (event.repeat) return;
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
event.preventDefault();
requestDisplaySurface("top");
return;
}
if (event.key !== "Tab") return;
event.preventDefault();
toggle();
toggleSurface();
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface();
unsubscribeController();
requestDisplaySurface("top");
};
}, [dedicatedSurface]);
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
}
if (layout === "single") {
const contextOpen = activeSurface === "bottom";
return (
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
<div className="single-primary-surface">{top}</div>
{contextOpen && (
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button
className="single-context-toggle"
onClick={toggleSurface}
aria-expanded={contextOpen}
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
>
<b>{contextOpen ? "Return" : contextLabel}</b>
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
}
return (
<div className={`device-frame active-${activeSurface}`}>
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
<button
className="native-display-switch"
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
+18 -73
View File
@@ -53,9 +53,9 @@ import {
HOCKEY_PVP_GOAL_DAMAGE,
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,
@@ -1544,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<string | null>(null);
const queuePollTimer = useRef<number | null>(null);
const queueCpuTimer = useRef<number | null>(null);
const queueClockTimer = useRef<number | null>(null);
const queueOperation = useRef<HockeyPvpMatchOperation | null>(null);
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -1567,41 +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",
});
};
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.");
@@ -1612,52 +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,
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,
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();
+74 -2
View File
@@ -7,10 +7,15 @@ import { tankAuraProtects } from "../game/partyCombat";
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";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { ABILITY_ORDER } from "../game/data";
import { AbilityButton } from "./AbilityButton";
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
@@ -165,6 +170,16 @@ function PhaseOverlay() {
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
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 <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
@@ -219,13 +234,64 @@ function PhaseOverlay() {
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
const briefingPrompt = pvpMode
? pvpCountdownSeconds > 0
? `Match starts automatically in ${pvpCountdownSeconds}`
: "Match starting now"
: singleScreen
? "Press Start / Enter to begin"
: "Begin from lower display";
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
{pvpMode && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${pvpCountdownSeconds} seconds`}>
<span>Match starts in</span>
<strong>{pvpCountdownSeconds}</strong>
<small>seconds</small>
</div>}
{pvpEnded && <>
<div className="top-pvp-end-actions">
<button
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
onClick={() => requestHockeyPvpPostMatchAction("rematch")}
><strong>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</strong><small>Same opponent</small></button>
<button
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
disabled={hockeyPvp.postMatchStatus === "requeueing"}
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
onClick={() => requestHockeyPvpPostMatchAction("requeue")}
><strong>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${pvpRequeueSeconds}s` : "Requeue"}</strong><small>Find another rival</small></button>
</div>
<div className="top-pvp-post-match-status" role="status" aria-live="polite">
{hockeyPvp.postMatchStatus === "waiting-rematch"
? `Waiting for ${hockeyPvp.opponentName}`
: hockeyPvp.postMatchStatus === "requeueing"
? `Searching queue · CPU fallback in ${pvpRequeueSeconds}s`
: "Choose next match"}
</div>
</>}
<small>{phase === "briefing"
? briefingPrompt
: singleScreen
? 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"}</small>
</div>
);
}
function SingleScreenAbilityBar() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
return (
<div className="single-ability-bar" aria-label="Equipped abilities">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} compact />)}
</div>
);
}
@@ -385,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 (
<section className="display top-display" aria-label="Main game viewport">
@@ -400,8 +467,13 @@ export function TopScreen({
<DampeningIndicator />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
{onExit && <button
className={`game-menu-button ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }}
onClick={() => phase === "combat" ? setPaused(true) : onExit()}
><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<SingleScreenAbilityBar />
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => (
<FocusButton
key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
>
<i style={{ background: member.color }} />
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
<PartyRoleBadge role={member.role} />
<b className="rpg-picked-remove" aria-hidden="true">×</b>
</FocusButton>
))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card"
style={runAccentStyle(summary.accent)}
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton>
);
})}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action"
disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} />
);
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<header>
<h3>Party</h3>
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
</header>
<div className="rpg-tactical-party-grid">
{run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}`}
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton>
) : undefined} />
))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return (
<FocusButton
key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
>
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<i>{role.icon}</i>
<span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton>
);
})}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton>
} />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) {
const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
if (parent.scrollWidth > parent.clientWidth) {
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3>
<p>{entry.className}</p>
{!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
}
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
const comparison = compareRunGear(run.equipment, item);
const ownerName = gearOwnerName(run, item);
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
const change = comparison.delta >= 0
? `Gain ${comparison.delta} percentage points`
: `Lose ${Math.abs(comparison.delta)} percentage points`;
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
}
export function GearStatComparison({ run, item }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
}) {
const ownerName = gearOwnerName(run, item);
const comparison = compareRunGear(run.equipment, item);
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
return (
<span
className="rpg-gear-comparison"
aria-label={gearComparisonLabel(run, item)}
>
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
<span>
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
<i aria-hidden="true"></i>
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
</span>
</span>
);
}
export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
{price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div>
{action}
</article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600;
}
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3,
.rpg-spell-card h3,
.rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase;
}
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip,
.rpg-empty-chip,
.rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip {
position: relative;
padding: 4px 18px 4px 10px;
padding: 4px 6px 4px 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center;
gap: 5px;
cursor: pointer;
text-align: left;
}
@@ -414,18 +465,24 @@
width: 3px;
}
.rpg-picked-chip > small {
.rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-picked-chip > b {
position: absolute;
right: 6px;
.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e;
font-size: 14px;
}
.rpg-empty-chip {
@@ -870,7 +927,7 @@
}
.rpg-gear-card {
min-height: 72px;
min-height: 112px;
padding: 8px 8px 22px 43px;
}
@@ -904,6 +961,12 @@
font-size: 9px;
}
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list {
min-height: 0;
overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px;
}
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list {
display: grid;
gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
}
.rpg-tactical-reward {
min-height: 94px;
min-height: 130px;
padding: 9px 64px 9px 49px;
position: relative;
cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase;
}
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rpg-run-tactical .rpg-gear-card {
min-height: 80px;
min-height: 112px;
}
.rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px;
}
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip,
.rpg-spellbook-strip {
overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px;
}
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout {
grid-template-columns: 1fr;
overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b {
display: none;
}
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
}
/* Single-display browser fallback gets a usable full-height draft surface. */
+66
View File
@@ -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);
});
});
+187
View File
@@ -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<OnlineRepository,
"joinHockeyPvpQueue" | "pollHockeyPvpQueue" | "cancelHockeyPvpQueue">;
type RematchRepository = Pick<OnlineRepository,
"requestHockeyPvpRematch" | "cancelHockeyPvpRematch">;
export interface HockeyPvpMatchOperation {
result: Promise<HockeyPvpMatchConfig | null>;
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<typeof setTimeout> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
let clockTimer: ReturnType<typeof setInterval> | 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<HockeyPvpMatchConfig | null>((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<typeof setTimeout> | null = null;
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
const result = new Promise<HockeyPvpMatchConfig | null>((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);
},
};
}
+33 -8
View File
@@ -30,15 +30,24 @@ export interface LeaderboardResult {
current: LeaderboardEntry | null;
}
export interface HockeyPvpOnlineMatch {
id: string;
seed: number;
generation: number;
countdownEndsAtMs: number;
opponentName: string;
role: Exclude<HockeyPvpRole, "cpu">;
}
export interface HockeyPvpQueueResult {
ticketId: string;
status: "waiting" | "matched";
match?: {
id: string;
seed: number;
opponentName: string;
role: Exclude<HockeyPvpRole, "cpu">;
};
match?: HockeyPvpOnlineMatch;
}
export interface HockeyPvpRematchResult {
status: "waiting" | "matched";
match?: HockeyPvpOnlineMatch;
}
export interface HockeyPvpExchangeResult {
@@ -214,11 +223,27 @@ export class OnlineRepository {
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
}
exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
exchangeHockeyPvpState(matchId: string, generation: number, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
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<HockeyPvpRematchResult> {
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<void> {
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation }),
});
}
}
+43 -2
View File
@@ -4,19 +4,36 @@ import {
HOCKEY_PVP_GOAL_Z,
advanceHockeyPvpPuck,
createHockeyPvpState,
cycleHockeyPvpPostMatchSelection,
hockeyPvpBossAt,
hockeyPvpCountdownSeconds,
hockeyPvpDampeningPercent,
hockeyPvpHealingEffectiveness,
hockeyPvpPuckSpeed,
mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
} from "./hockeyHealingPvp";
describe("Healing Hockey PVP", () => {
it("starts rallies at the faster default puck speed", () => {
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
expect(hockeyPvpPuckSpeed(0)).toBe(7.2);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2);
expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
});
it("counts down five whole seconds and never returns a negative value", () => {
expect(hockeyPvpCountdownSeconds(10_000, 5_000)).toBe(5);
expect(hockeyPvpCountdownSeconds(10_000, 9_001)).toBe(1);
expect(hockeyPvpCountdownSeconds(10_000, 10_000)).toBe(0);
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", () => {
@@ -76,4 +93,28 @@ describe("Healing Hockey PVP", () => {
lastGoalSide: "local",
});
});
it("predicts guest movement between snapshots and soft-corrects small drift", () => {
const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
guest.puckVelocity = [3, 6];
const predicted = advanceHockeyPvpPuck(guest, {
delta: 0.1,
localPlayerPosition: [0, 8.5],
localAimDirection: [0, -1],
opponentPlayerPosition: [0, 8.5],
opponentAimDirection: [0, -1],
});
expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
expect(predicted.localGoalsConceded).toBe(0);
const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
authoritative.goalSequence += 1;
authoritative.puckPosition = [0, 0];
expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
});
});
+93 -3
View File
@@ -4,12 +4,16 @@ 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;
}
export interface HockeyPvpPuckState {
@@ -25,7 +29,12 @@ 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;
@@ -59,13 +68,18 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
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.2;
const MAX_SPEED = 11.5;
const STARTING_SPEED = 7.8;
const MAX_SPEED = 12.2;
const MAX_SUBSTEPS = 10;
const MAX_SUBSTEP_DISTANCE = 0.32;
const GUEST_RECONCILIATION_BLEND = 0.35;
const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
@@ -95,6 +109,21 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
}
export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Date.now()) {
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;
@@ -107,7 +136,12 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
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,
@@ -161,6 +195,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
}
function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
puckVelocity: [...source.puckVelocity],
};
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
const subDelta = delta / substeps;
const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
for (let index = 0; index < substeps; index += 1) {
const end: WorldPosition = [
state.puckPosition[0] + state.puckVelocity[0] * subDelta,
state.puckPosition[1] + state.puckVelocity[1] * subDelta,
];
if (end[0] < minX || end[0] > maxX) {
end[0] = Math.max(minX, Math.min(maxX, end[0]));
state.puckVelocity[0] *= -1;
}
if (end[1] >= HOCKEY_PVP_GOAL_Z) {
end[1] = HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
} else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
end[1] = -HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
}
state.puckPosition = end;
}
return state;
}
export function reconcileHockeyPvpPuck(
predicted: HockeyPvpPuckState,
authoritative: HockeyPvpPuckState,
): HockeyPvpPuckState {
const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
|| Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
return {
...authoritative,
puckPosition: shouldSnap
? [...authoritative.puckPosition]
: [
predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
],
puckVelocity: [...authoritative.puckVelocity],
};
}
export function advanceHockeyPvpPuck(
source: HockeyPvpState,
step: {
@@ -171,7 +258,10 @@ export function advanceHockeyPvpPuck(
opponentAimDirection: WorldPosition;
},
): HockeyPvpState {
if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source;
if (source.status !== "live" || step.delta <= 0) return source;
// Guest predicts travel only. Host snapshots remain authoritative for contacts,
// goals, damage, and rally counters.
if (source.role === "guest") return predictGuestPuck(source, step.delta);
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
+85 -4
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createClassInventory } from "./healers";
import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp";
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
import { upcomingEncounterMechanic, useGameStore } from "./store";
import { freshParty } from "./data";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
@@ -23,6 +23,8 @@ function createMaxedGear() {
}
describe("Healing Hockey PVP encounter integration", () => {
afterEach(() => vi.restoreAllMocks());
beforeEach(() => {
useGameStore.getState().configureHealer(
"priest",
@@ -42,6 +44,28 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
});
it("ignores start input until shared five-second countdown ends", () => {
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, countdownEndsAtMs: 10_000 },
);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("briefing");
now.mockReturnValue(10_000);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("combat");
});
it("normalizes both parties to default base gear without changing saved upgrades", () => {
@@ -170,13 +194,16 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
});
it("wins when opponent party falls", () => {
it("wins when opponent companions fall while rival healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.getState().setActiveTab("map");
useGameStore.setState((state) => ({
hockeyPvpOpponent: {
...state.hockeyPvpOpponent,
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })),
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
},
}));
useGameStore.getState().tick(0.01);
@@ -185,6 +212,60 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().activeTab).toBe("combat");
});
it("loses when local companions fall while healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
});
it("wins an online match when remote companions fall while rival healer remains alive", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, matchId: "online-match", role: "guest" },
);
useGameStore.getState().startEncounter();
const state = useGameStore.getState();
const snapshot: HockeyPvpRemoteSnapshot = {
sequence: 1,
time: state.time,
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
boss: {
id: state.hockeyPvpOpponent.boss.id,
name: state.hockeyPvpOpponent.boss.name,
hp: state.hockeyPvpOpponent.boss.hp,
maxHp: state.hockeyPvpOpponent.boss.maxHp,
},
bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
bossMode: state.hockeyPvpOpponent.bossMotion.mode,
bossKills: 0,
playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
aimDirection: [...state.hockeyPvp.opponentAimDirection],
};
useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().hockeyPvp.status).toBe("won");
});
it("keeps HUD mechanic data defined during instant boss replacement", () => {
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
+5
View File
@@ -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;
+63
View File
@@ -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<HealerClassId, Record<AbilitySlotId, number>>;
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);
}
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run";
export * from "./playSpace";
export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId];
}
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem(
id: string,
ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots,
autoEquipRunGear,
challengeObjective,
compareRunGear,
createRandomState,
createRpgRoguelikeRun,
createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker);
});
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun,
createRunGearItem,
moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun,
rpgFocusId,
rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
}
describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run";
import {
MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action(
rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`,
`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited
? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
});
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`,
`Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId },
)) : [];
return [
+29 -1
View File
@@ -1,9 +1,11 @@
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 } from "./rpgRoguelike";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store";
function finishRpgDrafts() {
@@ -111,6 +113,32 @@ 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", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
const challenge = useGameStore.getState();
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
expect(challenge.additionalBosses).toHaveLength(1);
expect(challenge.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
));
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
completeCurrentChallenge();
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
const bossRoom = useGameStore.getState();
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
expect(bossRoom.additionalBosses).toHaveLength(0);
expect(bossRoom.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
));
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
});
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
+23 -2
View File
@@ -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, 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";
@@ -61,6 +61,27 @@ describe("Disc Priest combat simulation", () => {
});
});
it("regenerates mana at one-third the previous global rate", () => {
useGameStore.setState((state) => ({
mana: 0,
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
useGameStore.getState().tick(1);
expect(MANA_REGEN_PER_SECOND).toBeCloseTo(3.2 / 3);
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);
@@ -549,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);
+44 -15
View File
@@ -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";
@@ -70,6 +71,7 @@ import {
type HockeyHealingState,
} from "./hockeyHealing";
import {
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
HOCKEY_PVP_GOAL_DAMAGE,
advanceHockeyPvpCpuGoalie,
advanceHockeyPvpPuck,
@@ -77,7 +79,10 @@ import {
hockeyPvpHealingEffectiveness,
hockeyPvpBossAt,
mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
type HockeyPvpMatchConfig,
type HockeyPvpPostMatchSelection,
type HockeyPvpPostMatchStatus,
type HockeyPvpRemoteSnapshot,
type HockeyPvpState,
} from "./hockeyHealingPvp";
@@ -120,7 +125,6 @@ import {
} from "./aetherAssault";
import {
assignRosterToCombatSlots,
BOSSES_PER_ACT,
createRpgRoguelikeRun,
reduceRpgRoguelikeRun,
selectCurrentBossId,
@@ -136,6 +140,7 @@ import {
spellRankPowerMultiplier,
type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -239,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;
@@ -266,6 +273,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
@@ -581,7 +589,7 @@ function initialState(
0,
"hockey",
);
const maxMana = 100;
const maxMana = BASE_MANA_POOL;
return {
bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
@@ -628,7 +636,8 @@ function initialState(
runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier,
difficultyDamageMultiplier: difficulty.damageMultiplier
* (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
gearProgress,
gearModifiers,
time: 0,
@@ -700,9 +709,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner]
: [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard";
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT);
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14)
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier;
const modeDifficulty = rpgEncounterDifficulty(
challenge ? "hallway-challenge" : "boss-room",
run.bossIndex,
);
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId,
index,
@@ -721,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,
@@ -747,6 +759,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party,
gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower,
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout),
@@ -807,6 +820,9 @@ export const useGameStore = create<GameState>((set, get) => ({
startEncounter: () => {
const current = get();
if (current.runMode === "hockey-healing-pvp"
&& current.phase === "briefing"
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return;
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
@@ -828,7 +844,7 @@ export const useGameStore = create<GameState>((set, get) => ({
difficultySlug,
seenBossIds,
runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: undefined,
abilityLoadout,
),
@@ -881,7 +897,7 @@ export const useGameStore = create<GameState>((set, get) => ({
difficultySlug,
[],
runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: undefined,
abilityLoadout,
));
@@ -1103,10 +1119,18 @@ export const useGameStore = create<GameState>((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
? mirrorHockeyPvpPuck(hostPuck)
? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
: undefined;
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
@@ -1121,8 +1145,8 @@ export const useGameStore = create<GameState>((set, get) => ({
})
: state.party;
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
const opponentWiped = isPartyWiped(opponentParty);
const localWiped = isPartyWiped(party);
const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
const localWiped = areAllNonHealerAlliesDefeated(party);
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
return {
party,
@@ -2104,7 +2128,8 @@ export const useGameStore = create<GameState>((set, get) => ({
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
const pvpMode = state.activityMode === "hockey-healing-pvp";
const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party);
const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active";
const rpgBossActive = rpgRun?.phase === "boss-combat";
if (rpgChallengeActive && rpgRun) {
@@ -2186,7 +2211,7 @@ export const useGameStore = create<GameState>((set, get) => ({
phase = "combat";
endlessMode = false;
} else if (pvpMode) {
if (partyWiped) {
if (localPvpTeamDefeated) {
phase = "defeat";
hockeyPvp.status = "lost";
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
@@ -2254,7 +2279,7 @@ export const useGameStore = create<GameState>((set, get) => ({
hockeyPvp,
hockeyPvpOpponent,
runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast,
combatLog,
scenePulse: pulse,
@@ -2277,6 +2302,8 @@ export type GameSnapshot = Omit<GameState,
| "selectItem"
| "setPlayerPosition"
| "setHockeyAimDirection"
| "setHockeyPvpPostMatchSelection"
| "setHockeyPvpPostMatchStatus"
| "applyHockeyPvpRemoteSnapshot"
| "setPaused"
| "togglePause"
@@ -2306,6 +2333,8 @@ export function getGameSnapshot(): GameSnapshot {
selectItem: _selectItem,
setPlayerPosition: _setPlayerPosition,
setHockeyAimDirection: _setHockeyAimDirection,
setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection,
setHockeyPvpPostMatchStatus: _setHockeyPvpPostMatchStatus,
applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
setPaused: _setPaused,
togglePause: _togglePause,
+45
View File
@@ -5,6 +5,18 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
import { cycleBottomTab } from "./bottomTabs";
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
import { getDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
function tacticalOverlayOwnsInput() {
const store = useGameStore.getState();
if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
if (store.paused || store.phase === "intermission") return false;
if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
}
function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState();
@@ -29,15 +41,23 @@ function activateRpgFocus(onExit?: () => 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;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return;
if (event.defaultPrevented) return;
if (event.repeat) return;
const store = useGameStore.getState();
const key = event.key.toLowerCase();
if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false);
@@ -82,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]);
@@ -124,6 +156,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
if (!enabled) return;
const store = useGameStore.getState();
if (isSingleScreenLayout() && token === "Button8") return;
if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
@@ -165,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))];
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
const [seconds, setSeconds] = useState(() =>
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
useEffect(() => {
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
update();
if (!active) return;
const timer = window.setInterval(update, 100);
return () => window.clearInterval(timer);
}, [active, countdownEndsAtMs]);
return active ? seconds : 0;
}
+3
View File
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display");
const displayLayout = currentDisplayLayout();
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform");
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode;
}
document.documentElement.dataset.displayLayout = displayLayout;
startControllerInput();
+2 -1
View File
@@ -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 (
<main className="bottom-display-root">
{surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} onHockeyPvpAction={(action) => postFrontendCommand({ name: "hockeyPvpPostMatch", action })} /></Suspense>
: surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveDisplayLayout } from "./displayLayout";
describe("resolveDisplayLayout", () => {
it("uses the top-screen-first single layout for ordinary browsers", () => {
expect(resolveDisplayLayout({})).toBe("single");
expect(resolveDisplayLayout({ layout: "single" })).toBe("single");
});
it("keeps the dual-screen hardware mockup behind an explicit preview", () => {
expect(resolveDisplayLayout({ layout: "thor-preview" })).toBe("thor-preview");
expect(resolveDisplayLayout({ layout: "dual" })).toBe("thor-preview");
});
it("never overrides a dedicated Android display surface", () => {
expect(resolveDisplayLayout({ display: "top", layout: "single" })).toBe("dedicated");
expect(resolveDisplayLayout({ display: "bottom", layout: "thor-preview" })).toBe("dedicated");
});
});
+29
View File
@@ -0,0 +1,29 @@
export type DisplayLayout = "single" | "thor-preview" | "dedicated";
export interface DisplayLayoutRequest {
display?: string | null;
layout?: string | null;
}
/**
* Physical Thor surfaces are selected explicitly by the Android host. Every
* ordinary browser viewport is a single-screen game unless a developer asks
* for the dual-screen hardware preview.
*/
export function resolveDisplayLayout({ display, layout }: DisplayLayoutRequest): DisplayLayout {
if (display === "top" || display === "bottom") return "dedicated";
if (layout === "thor-preview" || layout === "dual") return "thor-preview";
return "single";
}
export function currentDisplayLayout(search = window.location.search): DisplayLayout {
const params = new URLSearchParams(search);
return resolveDisplayLayout({
display: params.get("display"),
layout: params.get("layout"),
});
}
export function isSingleScreenLayout(search = window.location.search) {
return currentDisplayLayout(search) === "single";
}
+10 -1
View File
@@ -1,13 +1,22 @@
export type DisplaySurface = "top" | "bottom";
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
let currentSurface: DisplaySurface = "top";
export function requestDisplaySurface(surface: DisplaySurface) {
currentSurface = surface;
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
}
export function getDisplaySurface() {
return currentSurface;
}
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail);
const onSurface = (event: Event) => {
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
listener(currentSurface);
};
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
}
+9 -1
View File
@@ -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,
+10 -1
View File
@@ -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<HockeyPvpPostMatchSelection, "menu"> }
| { 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<HockeyPvpPostMatchSelection, "menu">) {
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<BottomGameSnapshot> }
@@ -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;
}
+319
View File
@@ -118,6 +118,224 @@ button:focus-visible {
display: none;
}
/* PC and handheld browsers use the Thor top surface as the canonical game
viewport. The lower surface becomes a disclosed tactical layer. */
html[data-display-layout="single"],
html[data-display-layout="single"] body,
html[data-display-layout="single"] #root,
html[data-display-layout="single"] .app-shell,
.single-display-frame,
.single-primary-surface {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
html[data-display-layout="single"] .app-shell {
padding: 0;
}
html[data-display-layout="single"] .app-header {
display: none;
}
.single-display-frame {
position: relative;
isolation: isolate;
background: #030706;
}
.single-primary-surface {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.single-primary-surface > .display,
.single-primary-surface > .top-display,
.single-primary-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-primary-surface > .top-display {
container-name: single-game-screen;
container-type: size;
}
.single-context-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
}
.single-context-backdrop {
position: absolute;
inset: 0;
border: 0;
background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
backdrop-filter: blur(5px);
cursor: pointer;
}
.single-context-surface {
position: relative;
z-index: 1;
width: min(100%, calc((100dvh - 28px) * 31 / 27));
max-width: 760px;
max-height: calc(100dvh - 28px);
aspect-ratio: 31 / 27;
overflow: hidden;
border: 1px solid rgba(232, 200, 114, 0.42);
border-radius: 8px;
background: #07110f;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
}
.single-context-surface > .bottom-display,
.single-context-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-context-toggle {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 100;
min-width: 94px;
display: grid;
gap: 1px;
padding: 7px 10px;
border: 1px solid rgba(232, 200, 114, 0.62);
border-radius: 4px;
color: var(--ink);
background: rgba(3, 9, 8, 0.92);
box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
text-align: left;
cursor: pointer;
}
.single-context-toggle b {
color: var(--gold-strong);
font-size: 10px;
line-height: 1;
letter-spacing: .08em;
text-transform: uppercase;
}
.single-context-toggle small {
color: var(--muted);
font-size: 7px;
line-height: 1.2;
}
.single-display-frame.context-open .single-context-toggle {
top: max(18px, env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
bottom: auto;
}
html[data-display-layout="single"] .top-party {
width: clamp(185px, 20cqw, 330px);
gap: clamp(3px, .55cqh, 7px);
}
html[data-display-layout="single"] .top-party-member {
min-height: clamp(38px, 7.8cqh, 72px);
grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
gap: clamp(5px, .75cqw, 11px);
padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
}
html[data-display-layout="single"] .portrait-dot {
width: clamp(26px, 3cqw, 48px);
height: clamp(26px, 3cqw, 48px);
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy strong {
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy small {
font-size: clamp(7px, .68cqw, 11px);
}
html[data-display-layout="single"] .microbar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(5px, .58cqh, 8px);
}
html[data-display-layout="single"] .microbar.player-health-bar {
bottom: clamp(9px, 1.05cqh, 15px);
}
html[data-display-layout="single"] .player-mana-bar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(3px, .38cqh, 6px);
}
html[data-display-layout="single"] .boss-bar-wrap {
width: min(39%, 660px);
}
html[data-display-layout="single"] .boss-name {
font-size: clamp(7px, .62cqw, 11px);
}
html[data-display-layout="single"] .boss-name strong {
font-size: clamp(12px, 1.2cqw, 20px);
}
html[data-display-layout="single"] .objective-chip span {
font-size: clamp(6px, .58cqw, 10px);
}
html[data-display-layout="single"] .objective-chip strong {
font-size: clamp(8px, .82cqw, 14px);
}
html[data-display-layout="single"] .casting-bar {
bottom: clamp(76px, 11cqh, 126px);
}
html[data-display-layout="single"] .control-hint {
display: none;
}
html[data-display-layout="single"] .encounter-callout {
bottom: clamp(74px, 10cqh, 118px);
}
@media (min-width: 1200px) and (min-height: 700px) {
.single-context-layer {
justify-items: end;
padding-right: max(24px, env(safe-area-inset-right));
}
.single-context-surface {
width: min(56vw, 720px);
}
}
.screen-label {
width: 100%;
display: flex;
@@ -683,6 +901,49 @@ button:focus-visible {
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)); }
@@ -1066,6 +1327,19 @@ button:focus-visible {
gap: 4%;
}
.single-ability-bar {
position: absolute;
z-index: 4;
bottom: max(12px, env(safe-area-inset-bottom));
left: 50%;
width: min(64vw, 760px);
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: clamp(3px, .45vw, 7px);
transform: translateX(-50%);
pointer-events: auto;
}
.ability {
--ability-color: #ddd;
position: relative;
@@ -1086,6 +1360,40 @@ button:focus-visible {
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
}
.ability.is-compact {
height: clamp(48px, 7.2dvh, 68px);
grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
gap: clamp(3px, .45vw, 7px);
padding: 5px;
background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
}
.ability.is-compact .ability-icon {
width: clamp(27px, 3.1vw, 37px);
height: clamp(27px, 3.1vw, 37px);
font-size: clamp(14px, 1.45vw, 20px);
}
.ability.is-compact .ability-copy strong {
font-size: clamp(7px, .72vw, 11px);
}
.ability.is-compact .ability-copy small {
display: none;
}
.ability.is-compact .ability-key,
.ability.is-compact .ability-pad {
font-size: clamp(5px, .46vw, 7px);
}
.ability.is-compact .cooldown-mask b {
width: clamp(28px, 3vw, 38px);
height: clamp(28px, 3vw, 38px);
font-size: clamp(11px, 1.1vw, 16px);
}
.ability::after {
content: "";
position: absolute;
@@ -1298,9 +1606,19 @@ button:focus-visible {
.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;
@@ -2596,6 +2914,7 @@ button:focus-visible {
}
.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;