Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018e060cdd | ||
|
|
437e70fc58 |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.18",
|
"version": "0.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
const authAttempts = new Map();
|
const authAttempts = new Map();
|
||||||
|
|
||||||
function apiError(message, status = 400) {
|
function apiError(message, status = 400) {
|
||||||
@@ -650,6 +651,8 @@ export function createGameApiHandler(options = {}) {
|
|||||||
match: {
|
match: {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
seed: match.seed,
|
seed: match.seed,
|
||||||
|
generation: match.generation,
|
||||||
|
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||||
opponentName: opponent.hunterName,
|
opponentName: opponent.hunterName,
|
||||||
role: ticket.side,
|
role: ticket.side,
|
||||||
},
|
},
|
||||||
@@ -692,9 +695,12 @@ export function createGameApiHandler(options = {}) {
|
|||||||
const match = {
|
const match = {
|
||||||
id: matchId,
|
id: matchId,
|
||||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||||
|
generation: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
players: { host: opponent, guest: ticket },
|
players: { host: opponent, guest: ticket },
|
||||||
snapshots: { host: null, guest: null },
|
snapshots: { host: null, guest: null },
|
||||||
|
rematch: null,
|
||||||
};
|
};
|
||||||
opponent.matchId = matchId;
|
opponent.matchId = matchId;
|
||||||
opponent.side = "host";
|
opponent.side = "host";
|
||||||
@@ -722,6 +728,72 @@ export function createGameApiHandler(options = {}) {
|
|||||||
return { match, side };
|
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) {
|
async function handle(request, response, next) {
|
||||||
if (!request.url?.startsWith("/api/")) return next();
|
if (!request.url?.startsWith("/api/")) return next();
|
||||||
setCorsHeaders(request, response);
|
setCorsHeaders(request, response);
|
||||||
@@ -776,6 +848,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
if (pvpStateMatch && request.method === "PUT") {
|
if (pvpStateMatch && request.method === "PUT") {
|
||||||
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
||||||
const payload = await readJson(request);
|
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.");
|
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
|
||||||
match.snapshots[side] = payload.snapshot;
|
match.snapshots[side] = payload.snapshot;
|
||||||
return sendJson(response, 200, {
|
return sendJson(response, 200, {
|
||||||
@@ -783,6 +856,13 @@ export function createGameApiHandler(options = {}) {
|
|||||||
hostSnapshot: match.snapshots.host,
|
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") {
|
if (path === "/api/saves" && request.method === "GET") {
|
||||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.status, "matched");
|
||||||
assert.equal(betaQueue.body.match.role, "guest");
|
assert.equal(betaQueue.body.match.role, "guest");
|
||||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
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}`, {
|
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
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.role, "host");
|
||||||
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
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.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 } };
|
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
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`, {
|
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
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.opponentSnapshot, hostSnapshot);
|
||||||
assert.deepEqual(guestExchange.body.hostSnapshot, 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 () => {
|
test("invalid credentials cannot access server saves", async () => {
|
||||||
|
|||||||
+101
-4
@@ -8,10 +8,12 @@ import type { BossId } from "./game/types";
|
|||||||
import type { DifficultySlug } from "./game/progression/loot";
|
import type { DifficultySlug } from "./game/progression/loot";
|
||||||
import { useActionBindings } from "./game/useGameLoop";
|
import { useActionBindings } from "./game/useGameLoop";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH_EVENT } from "./platform/dualScreenSync";
|
||||||
import { startSaveSyncCoordinator } from "./frontend/saveSync";
|
import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
|
||||||
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
|
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 { onlineRepository } from "./frontend/onlineRepository";
|
||||||
|
import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking";
|
||||||
|
|
||||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||||
@@ -49,10 +51,16 @@ function MainApp() {
|
|||||||
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
|
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
|
||||||
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
|
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
|
||||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
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 rewardedBossInstances = useRef(new Set<string>());
|
||||||
|
const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null);
|
||||||
const screenRef = useRef(screen);
|
const screenRef = useRef(screen);
|
||||||
screenRef.current = screen;
|
screenRef.current = screen;
|
||||||
const leaveGame = useCallback(() => {
|
const leaveGame = useCallback(() => {
|
||||||
|
hockeyPvpPostMatchOperation.current?.cancel();
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
// RPG Roguelike equipment belongs only to its current run. Never leak it
|
// RPG Roguelike equipment belongs only to its current run. Never leak it
|
||||||
@@ -62,6 +70,67 @@ function MainApp() {
|
|||||||
navigate("home");
|
navigate("home");
|
||||||
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
||||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
}, [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) => {
|
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
|
||||||
if (!hunter) return;
|
if (!hunter) return;
|
||||||
const progress = hunter.healers[hunter.activeClassId];
|
const progress = hunter.healers[hunter.activeClassId];
|
||||||
@@ -113,6 +182,14 @@ function MainApp() {
|
|||||||
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
|
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
|
||||||
@@ -126,11 +203,12 @@ function MainApp() {
|
|||||||
if (stopped || exchangeActive) return;
|
if (stopped || exchangeActive) return;
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
|
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();
|
const snapshot = getHockeyPvpNetworkSnapshot();
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
exchangeActive = true;
|
exchangeActive = true;
|
||||||
try {
|
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) {
|
if (!stopped && result.opponentSnapshot) {
|
||||||
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
|
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
|
||||||
result.opponentSnapshot,
|
result.opponentSnapshot,
|
||||||
@@ -151,6 +229,25 @@ function MainApp() {
|
|||||||
};
|
};
|
||||||
}, [screen]);
|
}, [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);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,7 +326,7 @@ function MainApp() {
|
|||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{screen === "game"
|
||||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} /></Suspense>} /></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} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { ABILITY_ORDER } from "../game/data";
|
import { ABILITY_ORDER } from "../game/data";
|
||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||||
|
cycleHockeyPvpPostMatchSelection,
|
||||||
|
type HockeyPvpPostMatchSelection,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||||
import {
|
import {
|
||||||
BLOCKBREAKER_BREACH_DAMAGE,
|
BLOCKBREAKER_BREACH_DAMAGE,
|
||||||
@@ -50,7 +53,14 @@ function moveTacticalSelection(direction: 1 | -1) {
|
|||||||
store.selectItem(store.inventory[nextIndex].id);
|
store.selectItem(store.inventory[nextIndex].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSingleScreenTacticalInput() {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isSingleScreenLayout()) return;
|
if (!isSingleScreenLayout()) return;
|
||||||
let active = getDisplaySurface() === "bottom";
|
let active = getDisplaySurface() === "bottom";
|
||||||
@@ -67,7 +77,10 @@ function useSingleScreenTacticalInput() {
|
|||||||
const activatePhaseAction = () => {
|
const activatePhaseAction = () => {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
if (store.phase === "briefing") store.startEncounter();
|
if (store.phase === "briefing") store.startEncounter();
|
||||||
else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
|
||||||
|
else actionRef.current?.(store.hockeyPvp.postMatchSelection);
|
||||||
|
} else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||||
};
|
};
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!active || event.repeat) return;
|
if (!active || event.repeat) return;
|
||||||
@@ -77,6 +90,18 @@ function useSingleScreenTacticalInput() {
|
|||||||
|| store.phase === "intermission"
|
|| store.phase === "intermission"
|
||||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||||
const key = event.key.toLowerCase();
|
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);
|
if (key === "arrowleft") cycleTab(-1);
|
||||||
else if (key === "arrowright") cycleTab(1);
|
else if (key === "arrowright") cycleTab(1);
|
||||||
else if (key === "arrowup") moveTacticalSelection(-1);
|
else if (key === "arrowup") moveTacticalSelection(-1);
|
||||||
@@ -92,6 +117,15 @@ function useSingleScreenTacticalInput() {
|
|||||||
|| store.runMode === "rpg-roguelike"
|
|| store.runMode === "rpg-roguelike"
|
||||||
|| store.phase === "intermission"
|
|| store.phase === "intermission"
|
||||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
|| 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);
|
if (token === "Button14") cycleTab(-1);
|
||||||
else if (token === "Button15") cycleTab(1);
|
else if (token === "Button15") cycleTab(1);
|
||||||
else if (token === "Button12") moveTacticalSelection(-1);
|
else if (token === "Button12") moveTacticalSelection(-1);
|
||||||
@@ -251,6 +285,8 @@ function BriefingPanel() {
|
|||||||
const blockbreakerMode = activityMode === "blockbreaker";
|
const blockbreakerMode = activityMode === "blockbreaker";
|
||||||
const aetherMode = activityMode === "aether-assault";
|
const aetherMode = activityMode === "aether-assault";
|
||||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||||
|
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||||
return (
|
return (
|
||||||
<div className="briefing-panel">
|
<div className="briefing-panel">
|
||||||
<div className="briefing-class">
|
<div className="briefing-class">
|
||||||
@@ -258,7 +294,7 @@ function BriefingPanel() {
|
|||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<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>
|
<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>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
@@ -277,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 hunter = useActiveHunter();
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
@@ -296,6 +338,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
const hockey = useGameStore((state) => state.hockey);
|
const hockey = useGameStore((state) => state.hockey);
|
||||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
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 blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
@@ -303,11 +350,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
||||||
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
||||||
const pvpMatch = activityMode === "hockey-healing-pvp";
|
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 endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||||
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||||
return (
|
return (
|
||||||
<div className={`end-panel end-${phase}`}>
|
<div className={`end-panel end-${phase} ${pvpMatch ? "is-pvp" : ""}`}>
|
||||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
<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>
|
<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>
|
<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>
|
||||||
@@ -329,9 +376,36 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
||||||
onClick={onExit}
|
onClick={onExit}
|
||||||
>Quit to Main Menu</button>
|
>Quit to Main Menu</button>
|
||||||
</div> : <div className="end-actions">
|
</div> : pvpMatch ? <>
|
||||||
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
|
<div className="pvp-post-match-status" role="status" aria-live="polite">
|
||||||
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
|
{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>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -351,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);
|
const phase = useGameStore((state) => state.phase);
|
||||||
if (phase === "briefing") return <BriefingPanel />;
|
if (phase === "briefing") return <BriefingPanel />;
|
||||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
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>;
|
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,8 +799,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
export function BottomScreen({ onExit, onHockeyPvpAction }: {
|
||||||
useSingleScreenTacticalInput();
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
} = {}) {
|
||||||
|
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
|
||||||
const activeTab = useGameStore((state) => state.activeTab);
|
const activeTab = useGameStore((state) => state.activeTab);
|
||||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
@@ -750,7 +830,7 @@ export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="lower-content">
|
<main className="lower-content">
|
||||||
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
{activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
|
||||||
{activeTab === "map" && <MapPanel />}
|
{activeTab === "map" && <MapPanel />}
|
||||||
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
|
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
|
||||||
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
|
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
|
||||||
|
|||||||
+18
-73
@@ -53,9 +53,9 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_DAMAGE,
|
HOCKEY_PVP_GOAL_DAMAGE,
|
||||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
randomHockeyPvpCpuName,
|
|
||||||
type HockeyPvpMatchConfig,
|
type HockeyPvpMatchConfig,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking";
|
||||||
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
||||||
import {
|
import {
|
||||||
APPEARANCE_SLOT_DEFINITIONS,
|
APPEARANCE_SLOT_DEFINITIONS,
|
||||||
@@ -1544,10 +1544,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
const [queueing, setQueueing] = useState(false);
|
const [queueing, setQueueing] = useState(false);
|
||||||
const [queueElapsed, setQueueElapsed] = useState(0);
|
const [queueElapsed, setQueueElapsed] = useState(0);
|
||||||
const queueActive = useRef(false);
|
const queueActive = useRef(false);
|
||||||
const queueTicket = useRef<string | null>(null);
|
const queueOperation = useRef<HockeyPvpMatchOperation | null>(null);
|
||||||
const queuePollTimer = useRef<number | null>(null);
|
|
||||||
const queueCpuTimer = useRef<number | null>(null);
|
|
||||||
const queueClockTimer = useRef<number | null>(null);
|
|
||||||
const mode = MODE_COPY[modeId];
|
const mode = MODE_COPY[modeId];
|
||||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||||
const progress = hunter?.healers[hunter.activeClassId];
|
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"]) => {
|
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||||||
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
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) => {
|
const completePvpQueue = (match: HockeyPvpMatchConfig) => {
|
||||||
if (!queueActive.current) return;
|
if (!queueActive.current) return;
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current = null;
|
||||||
setQueueing(false);
|
setQueueing(false);
|
||||||
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
||||||
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
|
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 = () => {
|
const cancelPvpQueue = () => {
|
||||||
if (!queueActive.current) return;
|
if (!queueActive.current) return;
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current?.cancel();
|
||||||
const ticketId = queueTicket.current;
|
queueOperation.current = null;
|
||||||
queueTicket.current = null;
|
|
||||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
|
||||||
setQueueing(false);
|
setQueueing(false);
|
||||||
setQueueElapsed(0);
|
setQueueElapsed(0);
|
||||||
setMessage("Matchmaking cancelled.");
|
setMessage("Matchmaking cancelled.");
|
||||||
@@ -1612,52 +1587,22 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
setQueueing(true);
|
setQueueing(true);
|
||||||
setQueueElapsed(0);
|
setQueueElapsed(0);
|
||||||
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
||||||
const startedAt = Date.now();
|
const operation = startHockeyPvpMatchmaking({
|
||||||
queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100);
|
slotId: hunter.slotId,
|
||||||
queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS);
|
hunterName: hunter.hunterName,
|
||||||
if (!accountId || !networkAppearsOnline()) return;
|
online: Boolean(accountId && networkAppearsOnline()),
|
||||||
try {
|
onElapsed: setQueueElapsed,
|
||||||
const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName);
|
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
|
||||||
if (!queueActive.current) return;
|
});
|
||||||
queueTicket.current = joined.ticketId;
|
queueOperation.current = operation;
|
||||||
if (joined.match) {
|
const match = await operation.result;
|
||||||
completePvpQueue({
|
if (!match || queueOperation.current !== operation) return;
|
||||||
matchId: joined.match.id,
|
completePvpQueue(match);
|
||||||
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…");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current?.cancel();
|
||||||
const ticketId = queueTicket.current;
|
queueOperation.current = null;
|
||||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
|
||||||
}, []);
|
}, []);
|
||||||
const leaveMode = () => {
|
const leaveMode = () => {
|
||||||
cancelPvpQueue();
|
cancelPvpQueue();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { tankAuraProtects } from "../game/partyCombat";
|
|||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
|
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||||
@@ -168,6 +170,15 @@ function PhaseOverlay() {
|
|||||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
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();
|
const singleScreen = isSingleScreenLayout();
|
||||||
if (runMode === "rpg-roguelike") return null;
|
if (runMode === "rpg-roguelike") return null;
|
||||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||||
@@ -223,15 +234,53 @@ function PhaseOverlay() {
|
|||||||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||||||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||||||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
: 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 (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<p>{copy}</p>
|
||||||
<small>{singleScreen
|
{pvpMode && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${pvpCountdownSeconds} seconds`}>
|
||||||
? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
<span>Match starts in</span>
|
||||||
: 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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,6 +451,7 @@ export function TopScreen({
|
|||||||
const blockbreakerMode = activityMode === "blockbreaker";
|
const blockbreakerMode = activityMode === "blockbreaker";
|
||||||
const aetherAssaultMode = activityMode === "aether-assault";
|
const aetherAssaultMode = activityMode === "aether-assault";
|
||||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
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")}`;
|
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
||||||
return (
|
return (
|
||||||
<section className="display top-display" aria-label="Main game viewport">
|
<section className="display top-display" aria-label="Main game viewport">
|
||||||
@@ -417,7 +467,11 @@ export function TopScreen({
|
|||||||
<DampeningIndicator />
|
<DampeningIndicator />
|
||||||
<CastingBar />
|
<CastingBar />
|
||||||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</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>
|
</div>
|
||||||
<SingleScreenAbilityBar />
|
<SingleScreenAbilityBar />
|
||||||
<GoalPopup />
|
<GoalPopup />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,15 +30,24 @@ export interface LeaderboardResult {
|
|||||||
current: LeaderboardEntry | null;
|
current: LeaderboardEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HockeyPvpOnlineMatch {
|
||||||
|
id: string;
|
||||||
|
seed: number;
|
||||||
|
generation: number;
|
||||||
|
countdownEndsAtMs: number;
|
||||||
|
opponentName: string;
|
||||||
|
role: Exclude<HockeyPvpRole, "cpu">;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HockeyPvpQueueResult {
|
export interface HockeyPvpQueueResult {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
status: "waiting" | "matched";
|
status: "waiting" | "matched";
|
||||||
match?: {
|
match?: HockeyPvpOnlineMatch;
|
||||||
id: string;
|
}
|
||||||
seed: number;
|
|
||||||
opponentName: string;
|
export interface HockeyPvpRematchResult {
|
||||||
role: Exclude<HockeyPvpRole, "cpu">;
|
status: "waiting" | "matched";
|
||||||
};
|
match?: HockeyPvpOnlineMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpExchangeResult {
|
export interface HockeyPvpExchangeResult {
|
||||||
@@ -214,11 +223,27 @@ export class OnlineRepository {
|
|||||||
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
|
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`, {
|
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
advanceHockeyPvpPuck,
|
advanceHockeyPvpPuck,
|
||||||
createHockeyPvpState,
|
createHockeyPvpState,
|
||||||
|
cycleHockeyPvpPostMatchSelection,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
|
hockeyPvpCountdownSeconds,
|
||||||
hockeyPvpDampeningPercent,
|
hockeyPvpDampeningPercent,
|
||||||
hockeyPvpHealingEffectiveness,
|
hockeyPvpHealingEffectiveness,
|
||||||
hockeyPvpPuckSpeed,
|
hockeyPvpPuckSpeed,
|
||||||
@@ -20,6 +22,20 @@ describe("Healing Hockey PVP", () => {
|
|||||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(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", () => {
|
it("adds five percent global dampening for every boss killed by either party", () => {
|
||||||
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
||||||
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types
|
|||||||
|
|
||||||
export type HockeyPvpRole = "cpu" | "host" | "guest";
|
export type HockeyPvpRole = "cpu" | "host" | "guest";
|
||||||
export type HockeyPvpGoalSide = "local" | "opponent";
|
export type HockeyPvpGoalSide = "local" | "opponent";
|
||||||
|
export type HockeyPvpPostMatchSelection = "rematch" | "requeue" | "menu";
|
||||||
|
export type HockeyPvpPostMatchStatus = "idle" | "waiting-rematch" | "requeueing";
|
||||||
|
|
||||||
export interface HockeyPvpMatchConfig {
|
export interface HockeyPvpMatchConfig {
|
||||||
matchId: string | null;
|
matchId: string | null;
|
||||||
seed: number;
|
seed: number;
|
||||||
|
generation?: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: HockeyPvpRole;
|
role: HockeyPvpRole;
|
||||||
|
countdownEndsAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpPuckState {
|
export interface HockeyPvpPuckState {
|
||||||
@@ -25,7 +29,12 @@ export interface HockeyPvpPuckState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
||||||
|
countdownEndsAtMs: number;
|
||||||
|
generation: number;
|
||||||
status: "inactive" | "live" | "won" | "lost";
|
status: "inactive" | "live" | "won" | "lost";
|
||||||
|
postMatchSelection: HockeyPvpPostMatchSelection;
|
||||||
|
postMatchStatus: HockeyPvpPostMatchStatus;
|
||||||
|
postMatchQueueEndsAtMs: number;
|
||||||
aimDirection: WorldPosition;
|
aimDirection: WorldPosition;
|
||||||
opponentBossKills: number;
|
opponentBossKills: number;
|
||||||
opponentPlayerPosition: WorldPosition;
|
opponentPlayerPosition: WorldPosition;
|
||||||
@@ -61,7 +70,9 @@ export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
|||||||
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
||||||
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
||||||
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 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_QUEUE_TIMEOUT_MS = 5_000;
|
||||||
|
export const HOCKEY_PVP_POST_MATCH_SELECTIONS = ["rematch", "requeue", "menu"] as const;
|
||||||
|
|
||||||
const STARTING_SPEED = 7.8;
|
const STARTING_SPEED = 7.8;
|
||||||
const MAX_SPEED = 12.2;
|
const MAX_SPEED = 12.2;
|
||||||
@@ -98,6 +109,21 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
|
|||||||
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
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 {
|
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
||||||
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
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;
|
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
||||||
@@ -110,7 +136,12 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
|
|||||||
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
||||||
return {
|
return {
|
||||||
...match,
|
...match,
|
||||||
|
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
|
||||||
|
generation: config?.generation ?? 1,
|
||||||
status: config ? "live" : "inactive",
|
status: config ? "live" : "inactive",
|
||||||
|
postMatchSelection: "rematch",
|
||||||
|
postMatchStatus: "idle",
|
||||||
|
postMatchQueueEndsAtMs: 0,
|
||||||
puckPosition: [0, 0],
|
puckPosition: [0, 0],
|
||||||
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
||||||
localReturns: 0,
|
localReturns: 0,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } 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 { upcomingEncounterMechanic, useGameStore } from "./store";
|
||||||
@@ -23,6 +23,8 @@ function createMaxedGear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Healing Hockey PVP encounter integration", () => {
|
describe("Healing Hockey PVP encounter integration", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
"priest",
|
"priest",
|
||||||
@@ -45,6 +47,27 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
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", () => {
|
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
||||||
const maxedGear = createMaxedGear();
|
const maxedGear = createMaxedGear();
|
||||||
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
|
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
|
||||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
|
import { BASE_MANA_POOL } from "./mana";
|
||||||
import { createDefaultGearProgress } from "./progression/gear";
|
import { createDefaultGearProgress } from "./progression/gear";
|
||||||
import { equipPassiveInfusion } from "./progression/infusions";
|
import { equipPassiveInfusion } from "./progression/infusions";
|
||||||
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
|
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
|
||||||
@@ -112,6 +113,8 @@ describe("RPG Roguelike store integration", () => {
|
|||||||
expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true);
|
expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true);
|
||||||
expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds);
|
expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds);
|
||||||
expect(state.endlessMode).toBe(true);
|
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", () => {
|
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BULL_CHARGE } from "./bossMechanics";
|
import { BULL_CHARGE } from "./bossMechanics";
|
||||||
import { distance, pointToSegmentDistance } from "./geometry";
|
import { distance, pointToSegmentDistance } from "./geometry";
|
||||||
import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
import { BASE_MANA_POOL, BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
||||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||||
import { healingEffect } from "./healerEffects";
|
import { healingEffect } from "./healerEffects";
|
||||||
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
||||||
@@ -73,6 +73,15 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
expect(useGameStore.getState().mana).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", () => {
|
it("uses a three-second Purify cooldown for every healer class", () => {
|
||||||
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
||||||
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
||||||
@@ -561,7 +570,7 @@ describe("Roguelike ability buffs", () => {
|
|||||||
useGameStore.getState().selectMember("brann");
|
useGameStore.getState().selectMember("brann");
|
||||||
|
|
||||||
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
|
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);
|
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
|
||||||
useGameStore.getState().tick(0.22);
|
useGameStore.getState().tick(0.22);
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -40,6 +40,7 @@ import {
|
|||||||
resolveTimeLoop,
|
resolveTimeLoop,
|
||||||
startTimeLoop,
|
startTimeLoop,
|
||||||
} from "./healerMechanics";
|
} from "./healerMechanics";
|
||||||
|
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||||
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
|
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
|
||||||
@@ -80,6 +81,8 @@ import {
|
|||||||
mirrorHockeyPvpPuck,
|
mirrorHockeyPvpPuck,
|
||||||
reconcileHockeyPvpPuck,
|
reconcileHockeyPvpPuck,
|
||||||
type HockeyPvpMatchConfig,
|
type HockeyPvpMatchConfig,
|
||||||
|
type HockeyPvpPostMatchSelection,
|
||||||
|
type HockeyPvpPostMatchStatus,
|
||||||
type HockeyPvpRemoteSnapshot,
|
type HockeyPvpRemoteSnapshot,
|
||||||
type HockeyPvpState,
|
type HockeyPvpState,
|
||||||
} from "./hockeyHealingPvp";
|
} from "./hockeyHealingPvp";
|
||||||
@@ -241,6 +244,8 @@ export interface GameState {
|
|||||||
selectItem: (itemId: string) => void;
|
selectItem: (itemId: string) => void;
|
||||||
setPlayerPosition: (position: [number, number]) => void;
|
setPlayerPosition: (position: [number, number]) => void;
|
||||||
setHockeyAimDirection: (direction: [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;
|
applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void;
|
||||||
setPaused: (paused: boolean) => void;
|
setPaused: (paused: boolean) => void;
|
||||||
togglePause: () => void;
|
togglePause: () => void;
|
||||||
@@ -268,7 +273,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
|
|||||||
|
|
||||||
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
||||||
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
||||||
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
|
export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||||
|
|
||||||
export const BARRIER_RADIUS = 4;
|
export const BARRIER_RADIUS = 4;
|
||||||
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
||||||
@@ -584,7 +589,7 @@ function initialState(
|
|||||||
0,
|
0,
|
||||||
"hockey",
|
"hockey",
|
||||||
);
|
);
|
||||||
const maxMana = 100;
|
const maxMana = BASE_MANA_POOL;
|
||||||
return {
|
return {
|
||||||
bossId: primary.boss.id,
|
bossId: primary.boss.id,
|
||||||
bossInstanceId: primary.instanceId,
|
bossInstanceId: primary.instanceId,
|
||||||
@@ -728,7 +733,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
|
|||||||
? { ...member, hp: member.maxHp * healerRatio }
|
? { ...member, hp: member.maxHp * healerRatio }
|
||||||
: member);
|
: member);
|
||||||
const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
|
const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
|
||||||
const maxMana = 100;
|
const maxMana = BASE_MANA_POOL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rpgRun: run,
|
rpgRun: run,
|
||||||
@@ -815,6 +820,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
|
|
||||||
startEncounter: () => {
|
startEncounter: () => {
|
||||||
const current = get();
|
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.runMode === "rpg-roguelike" && current.rpgRun) {
|
||||||
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
||||||
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
||||||
@@ -836,7 +844,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
seenBossIds,
|
seenBossIds,
|
||||||
runMode === "hockey-healing-pvp"
|
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,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
),
|
),
|
||||||
@@ -889,7 +897,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
[],
|
[],
|
||||||
runMode === "hockey-healing-pvp"
|
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,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
));
|
));
|
||||||
@@ -1111,6 +1119,14 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
const hockey = setHockeyAim(state.hockey, direction);
|
const hockey = setHockeyAim(state.hockey, direction);
|
||||||
return hockey === state.hockey ? state : { hockey };
|
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) => {
|
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
||||||
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
||||||
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
||||||
@@ -2286,6 +2302,8 @@ export type GameSnapshot = Omit<GameState,
|
|||||||
| "selectItem"
|
| "selectItem"
|
||||||
| "setPlayerPosition"
|
| "setPlayerPosition"
|
||||||
| "setHockeyAimDirection"
|
| "setHockeyAimDirection"
|
||||||
|
| "setHockeyPvpPostMatchSelection"
|
||||||
|
| "setHockeyPvpPostMatchStatus"
|
||||||
| "applyHockeyPvpRemoteSnapshot"
|
| "applyHockeyPvpRemoteSnapshot"
|
||||||
| "setPaused"
|
| "setPaused"
|
||||||
| "togglePause"
|
| "togglePause"
|
||||||
@@ -2315,6 +2333,8 @@ export function getGameSnapshot(): GameSnapshot {
|
|||||||
selectItem: _selectItem,
|
selectItem: _selectItem,
|
||||||
setPlayerPosition: _setPlayerPosition,
|
setPlayerPosition: _setPlayerPosition,
|
||||||
setHockeyAimDirection: _setHockeyAimDirection,
|
setHockeyAimDirection: _setHockeyAimDirection,
|
||||||
|
setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection,
|
||||||
|
setHockeyPvpPostMatchStatus: _setHockeyPvpPostMatchStatus,
|
||||||
applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
|
applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
|
||||||
setPaused: _setPaused,
|
setPaused: _setPaused,
|
||||||
togglePause: _togglePause,
|
togglePause: _togglePause,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { cycleBottomTab } from "./bottomTabs";
|
|||||||
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
||||||
import { getDisplaySurface } from "../platform/displayRouting";
|
import { getDisplaySurface } from "../platform/displayRouting";
|
||||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||||
|
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||||
|
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
|
||||||
|
|
||||||
function tacticalOverlayOwnsInput() {
|
function tacticalOverlayOwnsInput() {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
@@ -39,6 +41,12 @@ function activateRpgFocus(onExit?: () => void) {
|
|||||||
else onExit?.();
|
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) {
|
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||||
const exitRef = useRef(onExit);
|
const exitRef = useRef(onExit);
|
||||||
exitRef.current = onExit;
|
exitRef.current = onExit;
|
||||||
@@ -94,6 +102,18 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (key === "escape") exitRef.current?.();
|
if (key === "escape") exitRef.current?.();
|
||||||
return;
|
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;
|
const numberIndex = Number(event.key) - 1;
|
||||||
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
||||||
store.castAbility(ABILITY_ORDER[numberIndex]);
|
store.castAbility(ABILITY_ORDER[numberIndex]);
|
||||||
@@ -179,6 +199,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (!repeat && token === "Button1") exitRef.current?.();
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
return;
|
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 (repeat) return;
|
||||||
if (token.startsWith("Button")) {
|
if (token.startsWith("Button")) {
|
||||||
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
|
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -194,6 +194,7 @@ export function BottomDisplayApp() {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
||||||
|
setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }),
|
||||||
dispatchRpgAction: (action) => {
|
dispatchRpgAction: (action) => {
|
||||||
postCommand({ name: "dispatchRpgAction", action });
|
postCommand({ name: "dispatchRpgAction", action });
|
||||||
return false;
|
return false;
|
||||||
@@ -254,7 +255,7 @@ export function BottomDisplayApp() {
|
|||||||
return (
|
return (
|
||||||
<main className="bottom-display-root">
|
<main className="bottom-display-root">
|
||||||
{surface.screen === "game"
|
{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…"
|
: surface.notice === "Linking upper display…"
|
||||||
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ describe("dual-screen game snapshots", () => {
|
|||||||
it("routes maxed-run continuation and passive filter commands", () => {
|
it("routes maxed-run continuation and passive filter commands", () => {
|
||||||
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
||||||
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
||||||
|
const originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection;
|
||||||
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
||||||
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
||||||
const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView;
|
const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView;
|
||||||
@@ -127,6 +128,7 @@ describe("dual-screen game snapshots", () => {
|
|||||||
useGameStore.setState({
|
useGameStore.setState({
|
||||||
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
||||||
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
||||||
|
setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); },
|
||||||
});
|
});
|
||||||
useFrontendStore.setState({
|
useFrontendStore.setState({
|
||||||
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
||||||
@@ -138,6 +140,7 @@ describe("dual-screen game snapshots", () => {
|
|||||||
|
|
||||||
executeGameCommand({ name: "continueRoguelikeRound" });
|
executeGameCommand({ name: "continueRoguelikeRound" });
|
||||||
executeGameCommand({ name: "startRogueTrialsEndless" });
|
executeGameCommand({ name: "startRogueTrialsEndless" });
|
||||||
|
executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" });
|
||||||
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" });
|
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" });
|
||||||
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
||||||
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
|
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
|
||||||
@@ -146,6 +149,7 @@ describe("dual-screen game snapshots", () => {
|
|||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"continue",
|
"continue",
|
||||||
"endless",
|
"endless",
|
||||||
|
"pvp:requeue",
|
||||||
"ability:ability3",
|
"ability:ability3",
|
||||||
"passive:shield-guard",
|
"passive:shield-guard",
|
||||||
"profile-view:stats",
|
"profile-view:stats",
|
||||||
@@ -153,7 +157,11 @@ describe("dual-screen game snapshots", () => {
|
|||||||
"profile-stat:broodfang-spider",
|
"profile-stat:broodfang-spider",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
|
useGameStore.setState({
|
||||||
|
continueRoguelikeRound: originalContinue,
|
||||||
|
startRogueTrialsEndless: originalStartEndless,
|
||||||
|
setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection,
|
||||||
|
});
|
||||||
useFrontendStore.setState({
|
useFrontendStore.setState({
|
||||||
selectPassiveAbility: originalSelectAbility,
|
selectPassiveAbility: originalSelectAbility,
|
||||||
selectPassiveInfusion: originalSelectPassive,
|
selectPassiveInfusion: originalSelectPassive,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
|||||||
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
||||||
import type { DifficultySlug } from "../game/progression/loot";
|
import type { DifficultySlug } from "../game/progression/loot";
|
||||||
import type { BossGroupId } from "../game/bossCatalog";
|
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 { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike";
|
||||||
import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance";
|
import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance";
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export type GameCommand =
|
|||||||
| { name: "continueRoguelikeRound" }
|
| { name: "continueRoguelikeRound" }
|
||||||
| { name: "startRogueTrialsEndless" }
|
| { name: "startRogueTrialsEndless" }
|
||||||
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
|
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
|
||||||
|
| { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection }
|
||||||
| { name: "dispatchRpgAction"; action: RpgRoguelikeAction }
|
| { name: "dispatchRpgAction"; action: RpgRoguelikeAction }
|
||||||
| { name: "setRpgFocusId"; focusId: string }
|
| { name: "setRpgFocusId"; focusId: string }
|
||||||
| { name: "cycleRpgFocus"; direction: 1 | -1 }
|
| { name: "cycleRpgFocus"; direction: 1 | -1 }
|
||||||
@@ -76,11 +77,17 @@ export type FrontendCommand =
|
|||||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||||
|
| { name: "hockeyPvpPostMatch"; action: Exclude<HockeyPvpPostMatchSelection, "menu"> }
|
||||||
| { name: "exitGame" }
|
| { name: "exitGame" }
|
||||||
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig };
|
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig };
|
||||||
|
|
||||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||||
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-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 =
|
export type DualScreenMessage =
|
||||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
| { 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 "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
||||||
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
||||||
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
||||||
|
case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break;
|
||||||
case "dispatchRpgAction": game.dispatchRpgAction(command.action); break;
|
case "dispatchRpgAction": game.dispatchRpgAction(command.action); break;
|
||||||
case "setRpgFocusId": game.setRpgFocusId(command.focusId); break;
|
case "setRpgFocusId": game.setRpgFocusId(command.focusId); break;
|
||||||
case "cycleRpgFocus": game.cycleRpgFocus(command.direction); 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 "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||||
case "updateSetting": frontend.updateSetting(command.key, command.value); 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 "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;
|
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -901,6 +901,49 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
text-transform: uppercase;
|
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-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-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)); }
|
.phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); }
|
||||||
@@ -1563,9 +1606,19 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
.end-actions button { 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.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||||
.end-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
.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 { 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; }
|
.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 {
|
.buff-draft {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2861,6 +2914,7 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-menu-button small { color: #71857e; font-size: 6px; }
|
.game-menu-button 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 {
|
.game-loading {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user