From 0e36ca1a4184c78b5fc19059683de6febd8bd923 Mon Sep 17 00:00:00 2001 From: Warren H Date: Sun, 19 Jul 2026 18:48:28 -0400 Subject: [PATCH] Release v0.1.21 2026-07-19 --- package.json | 2 +- server/game-api.mjs | 704 ++++++++++++++++++- server/game-api.test.mjs | 515 +++++++++++++- src/App.tsx | 255 ++++++- src/components/AbilityButton.tsx | 12 +- src/components/BottomScreen.tsx | 54 +- src/components/FrontEnd.tsx | 75 +- src/components/RoguelikePvpPanels.tsx | 363 ++++++++++ src/components/TopScreen.tsx | 70 +- src/frontend/data.ts | 13 +- src/frontend/onlineRepository.ts | 140 +++- src/frontend/roguelikePvpMatchmaking.test.ts | 283 ++++++++ src/frontend/roguelikePvpMatchmaking.ts | 298 ++++++++ src/frontend/saveRepository.test.ts | 17 +- src/frontend/saveRepository.ts | 7 +- src/frontend/store.ts | 30 +- src/frontend/types.ts | 5 +- src/game/progression/hunterStats.test.ts | 9 +- src/game/progression/hunterStats.ts | 17 + src/game/roguelikePvp.test.ts | 116 +++ src/game/roguelikePvp.ts | 322 +++++++++ src/game/roguelikePvpStore.test.ts | 283 ++++++++ src/game/runModes.ts | 2 +- src/game/store.ts | 627 ++++++++++++++++- src/game/types.ts | 4 +- src/game/useGameLoop.ts | 88 ++- src/platform/BottomDisplayApp.tsx | 12 +- src/platform/dualScreenSync.test.ts | 23 + src/platform/dualScreenSync.ts | 35 +- src/styles.css | 440 ++++++++++++ 30 files changed, 4701 insertions(+), 120 deletions(-) create mode 100644 src/components/RoguelikePvpPanels.tsx create mode 100644 src/frontend/roguelikePvpMatchmaking.test.ts create mode 100644 src/frontend/roguelikePvpMatchmaking.ts create mode 100644 src/game/roguelikePvp.test.ts create mode 100644 src/game/roguelikePvp.ts create mode 100644 src/game/roguelikePvpStore.test.ts diff --git a/package.json b/package.json index e5e98d6..f5cf8ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.20", + "version": "0.1.21", "type": "module", "scripts": { "predev": "node scripts/sync_basis_transcoder.mjs", diff --git a/server/game-api.mjs b/server/game-api.mjs index e48edf1..db554da 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -8,6 +8,61 @@ const MAX_JSON_BYTES = 1024 * 1024; const AUTH_WINDOW_MS = 15 * 60 * 1000; const AUTH_ATTEMPTS_PER_WINDOW = 20; const HOCKEY_PVP_COUNTDOWN_MS = 5_000; +const ROGUELIKE_PVP_MODE = "roguelike-pvp"; +const ROGUELIKE_PVP_COUNTDOWN_MS = 5_000; +const ROGUELIKE_PVP_DRAFT_MS = 15_000; +const ROGUELIKE_PVP_DISCONNECT_GRACE_MS = 15_000; +const ROGUELIKE_PVP_CONNECTED_WINDOW_MS = 3_000; +const ROGUELIKE_PVP_QUEUE_TTL_MS = 30_000; +const ROGUELIKE_PVP_MATCH_TTL_MS = 10 * 60_000; +const ROGUELIKE_PVP_MAX_SNAPSHOT_BYTES = 2_048; +const HEALER_CLASS_IDS = new Set(["priest", "druid", "shaman", "paladin", "chronomancer"]); +const ROGUELIKE_PVP_PHASES = new Set(["countdown", "combat", "draft", "won", "lost"]); +const ROGUELIKE_PVP_BUFF_IDS = [ + "mend-echo", + "mend-efficiency", + "mend-cast-speed", + "renew-spread", + "renew-duration", + "renew-potency", + "shield-echo", + "shield-potency", + "shield-guard", + "purify-renew", + "purify-shield", + "purify-chain", + "radiance-cooldown", + "radiance-renew", + "radiance-shield", + "barrier-cooldown", + "barrier-duration", + "barrier-regen", +]; +const ROGUELIKE_PVP_BUFF_ID_SET = new Set(ROGUELIKE_PVP_BUFF_IDS); +const ROGUELIKE_PVP_SINGLE_RANK_BUFF_IDS = new Set([ + "purify-renew", + "purify-shield", + "purify-chain", + "radiance-renew", +]); +const ROGUELIKE_PVP_CURSE_IDS = ["ability1", "ability2", "ability3", "ability4", "ability5", "ability6"] + .flatMap((abilityId) => [`${abilityId}-mana-cost`, `${abilityId}-cooldown`]); +const ROGUELIKE_PVP_CURSE_ID_SET = new Set(ROGUELIKE_PVP_CURSE_IDS); +const ROGUELIKE_PVP_SUPPORTED_BUFF_IDS = { + priest: new Set(ROGUELIKE_PVP_BUFF_IDS), + druid: new Set(ROGUELIKE_PVP_BUFF_IDS), + shaman: new Set(ROGUELIKE_PVP_BUFF_IDS), + paladin: new Set([ + "mend-echo", "mend-efficiency", "mend-cast-speed", + "purify-renew", "purify-shield", "purify-chain", + "barrier-cooldown", "barrier-duration", + ]), + chronomancer: new Set([ + "mend-echo", "mend-efficiency", "mend-cast-speed", + "purify-renew", "purify-shield", "purify-chain", + "radiance-cooldown", "barrier-cooldown", + ]), +}; const authAttempts = new Map(); function apiError(message, status = 400) { @@ -180,16 +235,167 @@ function validateSlotId(value) { return slotId; } +function validateHealerClassId(value) { + const healerClassId = String(value ?? ""); + if (!HEALER_CLASS_IDS.has(healerClassId)) throw apiError("Healer class is invalid."); + return healerClassId; +} + +function validateRoguelikePvpMode(value) { + if (value !== ROGUELIKE_PVP_MODE) throw apiError("PVP queue mode is invalid."); + return ROGUELIKE_PVP_MODE; +} + +function validateRoguelikePvpGeneration(value) { + const generation = Number(value); + if (!Number.isSafeInteger(generation) || generation < 1) { + throw apiError("Roguelike PVP match generation is invalid."); + } + return generation; +} + +function validateRoguelikePvpRound(value) { + const round = Number(value); + if (!Number.isSafeInteger(round) || round < 1 || round > 100_000) { + throw apiError("Roguelike PVP round is invalid."); + } + return round; +} + +function validateRoguelikePvpSnapshot(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw apiError("Roguelike PVP snapshot is invalid."); + } + if (Buffer.byteLength(JSON.stringify(value), "utf8") > ROGUELIKE_PVP_MAX_SNAPSHOT_BYTES) { + throw apiError("Roguelike PVP snapshot is too large.", 413); + } + const allowedKeys = new Set([ + "sequence", + "round", + "phase", + "partyHp", + "bossHp", + "bossMaxHp", + "defeatedBosses", + ]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw apiError("Roguelike PVP snapshot contains unsupported data."); + } + const sequence = value.sequence; + if (typeof value.round !== "number") throw apiError("Roguelike PVP round is invalid."); + const round = validateRoguelikePvpRound(value.round); + const phase = String(value.phase ?? ""); + const partyHp = value.partyHp; + const bossHp = value.bossHp; + const bossMaxHp = value.bossMaxHp; + const defeatedBosses = value.defeatedBosses; + if (!Number.isSafeInteger(sequence) || sequence < 1) { + throw apiError("Roguelike PVP snapshot sequence is invalid."); + } + if (!ROGUELIKE_PVP_PHASES.has(phase)) throw apiError("Roguelike PVP snapshot phase is invalid."); + if (!Array.isArray(partyHp) || partyHp.length !== 5 + || partyHp.some((hp) => typeof hp !== "number" || !Number.isFinite(hp) || hp < 0 || hp > 1)) { + throw apiError("Roguelike PVP party health is invalid."); + } + if (!Number.isFinite(bossHp) || !Number.isFinite(bossMaxHp) + || bossHp < 0 || bossMaxHp < 0 || bossHp > bossMaxHp) { + throw apiError("Roguelike PVP boss health is invalid."); + } + if (!Number.isSafeInteger(defeatedBosses) || defeatedBosses < 0) { + throw apiError("Roguelike PVP defeated boss count is invalid."); + } + if (phase === "won") { + throw apiError("Roguelike PVP wins are adjudicated by the match server."); + } + if (phase === "lost" && partyHp.some((hp) => hp !== 0)) { + throw apiError("Roguelike PVP loss requires all five party members at zero health."); + } + return { + sequence, + round, + phase, + partyHp: [...partyHp], + bossHp, + bossMaxHp, + defeatedBosses, + }; +} + +function validateRoguelikePvpDraftSelection(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw apiError("Roguelike PVP draft selection is invalid."); + } + const buffId = value.buffId === null ? null : String(value.buffId ?? ""); + const curseId = value.curseId === null ? null : String(value.curseId ?? ""); + if (buffId !== null && !ROGUELIKE_PVP_BUFF_ID_SET.has(buffId) + || curseId !== null && !ROGUELIKE_PVP_CURSE_ID_SET.has(curseId)) { + throw apiError("Roguelike PVP draft selection is invalid."); + } + if (value.autoPicked !== undefined && typeof value.autoPicked !== "boolean") { + throw apiError("Roguelike PVP auto-pick marker is invalid."); + } + return { buffId, curseId, autoPicked: value.autoPicked === true }; +} + +function createRoguelikePvpSeededRandom(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 0x100000000; + }; +} + +function selectRoguelikePvpPool(pool, random, count) { + const available = [...pool]; + const selected = []; + while (selected.length < count && available.length > 0) { + const index = Math.floor(random() * available.length); + selected.push(available[index]); + available.splice(index, 1); + } + return selected; +} + +function roguelikePvpDraftOffers(match, side, round) { + const progress = match.draftProgress; + const buffRanks = progress.buffRanks[side]; + const curseRanks = progress.curseRanks[side]; + const random = createRoguelikePvpSeededRandom( + (match.seed ^ Math.imul(round, 0x7f4a7c15)) >>> 0, + ); + const availableBuffs = ROGUELIKE_PVP_BUFF_IDS.filter((buffId) => { + const maxRank = ROGUELIKE_PVP_SINGLE_RANK_BUFF_IDS.has(buffId) ? 1 : 3; + return Math.max(0, Math.floor(buffRanks[buffId] ?? 0)) < maxRank; + }); + // Client draft generation shuffles the complete uncapped catalog before + // filtering class-specific no-op buffs. Mirror that order exactly. + const shuffledBuffs = selectRoguelikePvpPool(availableBuffs, random, availableBuffs.length); + const supportedBuffs = ROGUELIKE_PVP_SUPPORTED_BUFF_IDS[match.players[side].healerClassId]; + const buffChoices = shuffledBuffs.filter((buffId) => supportedBuffs.has(buffId)).slice(0, 3); + const availableCurses = ROGUELIKE_PVP_CURSE_IDS.filter( + (curseId) => Math.max(0, Math.floor(curseRanks[curseId] ?? 0)) < 3, + ); + const curseChoices = selectRoguelikePvpPool(availableCurses, random, 3); + return { buffChoices, curseChoices }; +} + +function roguelikePvpBossCountForRound(round) { + return round % 5 === 0 ? 3 : 2; +} + function validateSave(value, slotId) { const schemaVersion = Number(value?.schemaVersion); - if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) { + if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6 && schemaVersion !== 7) { throw apiError("Save snapshot is invalid."); } if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { throw apiError("Save snapshot has no hunter name."); } - return { ...value, schemaVersion: 6 }; + return { ...value, schemaVersion: 7 }; } function normalizeNonNegativeInteger(value) { @@ -229,9 +435,12 @@ function mergeLeaderboardHighWater(database, accountId, slotId, save) { : storedAether; return { ...save, - schemaVersion: 6, + schemaVersion: 7, stats: { ...stats, + roguelikePvpWins: normalizeNonNegativeInteger(stats.roguelikePvpWins), + roguelikePvpLosses: normalizeNonNegativeInteger(stats.roguelikePvpLosses), + highestRoguelikePvpRound: normalizeNonNegativeInteger(stats.highestRoguelikePvpRound), highestBlockbreakerBricks: Math.max( normalizeNonNegativeInteger(stats.highestBlockbreakerBricks), normalizeNonNegativeInteger(blockbreaker?.highestBricks), @@ -639,6 +848,430 @@ export function createGameApiHandler(options = {}) { database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); const hockeyPvpTickets = new Map(); const hockeyPvpMatches = new Map(); + const roguelikePvpTickets = new Map(); + const roguelikePvpMatches = new Map(); + const roguelikePvpNow = typeof options.roguelikePvpNow === "function" + ? options.roguelikePvpNow + : Date.now; + + function cleanupRoguelikePvp(now = roguelikePvpNow()) { + for (const [matchId, match] of roguelikePvpMatches) { + if (now - match.lastActivityAtMs <= ROGUELIKE_PVP_MATCH_TTL_MS) continue; + roguelikePvpMatches.delete(matchId); + roguelikePvpTickets.delete(match.players.host.id); + roguelikePvpTickets.delete(match.players.guest.id); + } + for (const [ticketId, ticket] of roguelikePvpTickets) { + const expiredWaitingTicket = !ticket.matchId && now - ticket.createdAtMs > ROGUELIKE_PVP_QUEUE_TTL_MS; + const missingMatch = ticket.matchId && !roguelikePvpMatches.has(ticket.matchId); + if (ticket.cancelled || expiredWaitingTicket || missingMatch) roguelikePvpTickets.delete(ticketId); + } + } + + function roguelikePvpQueueResult(ticket) { + const match = ticket.matchId ? roguelikePvpMatches.get(ticket.matchId) : null; + if (!match) return { ticketId: ticket.id, status: "waiting" }; + const opponentSide = ticket.side === "host" ? "guest" : "host"; + const opponent = match.players[opponentSide]; + return { + ticketId: ticket.id, + status: "matched", + match: { + id: match.id, + mode: match.mode, + seed: match.seed, + generation: match.generation, + countdownEndsAtMs: match.countdownEndsAtMs, + opponentName: opponent.hunterName, + opponentHealerClassId: opponent.healerClassId, + role: ticket.side, + }, + }; + } + + function joinRoguelikePvpQueue(session, payload) { + const mode = validateRoguelikePvpMode(payload?.mode); + const slotId = validateSlotId(payload?.slotId); + const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20); + const healerClassId = validateHealerClassId(payload?.healerClassId); + if (!hunterName) throw apiError("Hunter name is required."); + const now = roguelikePvpNow(); + cleanupRoguelikePvp(now); + const existing = [...roguelikePvpTickets.values()].find((ticket) => + ticket.accountId === session.accountId && ticket.mode === mode && !ticket.cancelled); + if (existing) return roguelikePvpQueueResult(existing); + + const opponent = [...roguelikePvpTickets.values()].find((ticket) => + ticket.mode === mode && !ticket.matchId && !ticket.cancelled && ticket.accountId !== session.accountId); + const ticket = { + id: randomBytes(18).toString("base64url"), + mode, + accountId: session.accountId, + username: session.username, + slotId, + hunterName, + healerClassId, + createdAtMs: now, + matchId: null, + side: null, + cancelled: false, + }; + roguelikePvpTickets.set(ticket.id, ticket); + if (!opponent) return roguelikePvpQueueResult(ticket); + + const matchId = randomBytes(18).toString("base64url"); + const match = { + id: matchId, + mode, + seed: randomBytes(4).readUInt32BE(0) || 1, + generation: 1, + countdownEndsAtMs: now + ROGUELIKE_PVP_COUNTDOWN_MS, + createdAtMs: now, + lastActivityAtMs: now, + players: { host: opponent, guest: ticket }, + snapshots: { host: null, guest: null }, + lastSeenAtMs: { host: now, guest: now }, + drafts: new Map(), + draftProgress: { + completedRound: 0, + buffRanks: { host: {}, guest: {} }, + curseRanks: { host: {}, guest: {} }, + }, + outcome: null, + rematch: null, + }; + opponent.matchId = matchId; + opponent.side = "host"; + ticket.matchId = matchId; + ticket.side = "guest"; + roguelikePvpMatches.set(matchId, match); + return roguelikePvpQueueResult(ticket); + } + + function requireRoguelikePvpTicket(session, ticketId) { + cleanupRoguelikePvp(); + const ticket = roguelikePvpTickets.get(ticketId); + if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) { + throw apiError("Roguelike PVP queue ticket not found.", 404); + } + return ticket; + } + + function requireRoguelikePvpMatch(session, matchId) { + cleanupRoguelikePvp(); + const match = roguelikePvpMatches.get(matchId); + if (!match) throw apiError("Roguelike PVP match not found.", 404); + const side = match.players.host.accountId === session.accountId + ? "host" + : match.players.guest.accountId === session.accountId + ? "guest" + : null; + if (!side) throw apiError("Roguelike PVP match access denied.", 403); + return { match, side }; + } + + function touchRoguelikePvpMatch(match, side, now) { + match.lastActivityAtMs = now; + match.lastSeenAtMs[side] = now; + } + + function freezeRoguelikePvpOutcome(match, winner, loser, reason, now) { + if (!match.outcome) match.outcome = { winner, loser, reason, atMs: now }; + return match.outcome; + } + + function roguelikePvpMatchStatus(match, side, now) { + const opponentSide = side === "host" ? "guest" : "host"; + const opponentLastSeenAtMs = match.lastSeenAtMs[opponentSide]; + const disconnectDeadlineAtMs = opponentLastSeenAtMs + ROGUELIKE_PVP_DISCONNECT_GRACE_MS; + if (!match.outcome && now >= disconnectDeadlineAtMs) { + freezeRoguelikePvpOutcome(match, side, opponentSide, "disconnect", now); + } + // Keep the original response union so existing clients resolve any + // authoritative terminal result without a protocol migration. + const status = !match.outcome + ? "active" + : match.outcome.winner === side + ? "won-by-forfeit" + : "lost-by-forfeit"; + const opponentConnection = match.outcome?.reason === "disconnect" && match.outcome.loser === opponentSide + ? "forfeited" + : now - opponentLastSeenAtMs > ROGUELIKE_PVP_CONNECTED_WINDOW_MS + ? "grace" + : "connected"; + return { + status, + opponentConnection, + opponentLastSeenAtMs, + disconnectDeadlineAtMs, + outcomeReason: match.outcome?.reason ?? null, + }; + } + + function validateRoguelikePvpSnapshotProgress(match, previous, snapshot) { + const completedRound = match.draftProgress.completedRound; + const lowestRound = Math.max(1, completedRound); + const highestRound = completedRound + 1; + if (snapshot.round < lowestRound || snapshot.round > highestRound + || previous && snapshot.round < previous.round) { + throw apiError("Roguelike PVP snapshot round is ahead of match progress.", 409); + } + } + + function exchangeRoguelikePvpState(session, matchId, payload) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(payload?.generation); + if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); + const snapshot = validateRoguelikePvpSnapshot(payload?.snapshot); + const previous = match.snapshots[side]; + if (previous && snapshot.sequence <= previous.sequence) { + throw apiError("Roguelike PVP snapshot sequence is stale.", 409); + } + validateRoguelikePvpSnapshotProgress(match, previous, snapshot); + const now = roguelikePvpNow(); + // A grace deadline is an earlier terminal event than a snapshot arriving + // after it, preserving disconnect-forfeit behavior at the boundary. + roguelikePvpMatchStatus(match, side, now); + match.snapshots[side] = snapshot; + if (!match.outcome && snapshot.phase === "lost") { + const opponentSide = side === "host" ? "guest" : "host"; + // First accepted valid terminal report is final. If both parties wipe + // between exchanges, request acceptance order is the stable tie-break. + freezeRoguelikePvpOutcome(match, opponentSide, side, "party-wipe", now); + } + const matchStatus = roguelikePvpMatchStatus(match, side, now); + touchRoguelikePvpMatch(match, side, now); + const opponentSide = side === "host" ? "guest" : "host"; + return { + ...matchStatus, + serverTimeMs: now, + opponentSnapshot: match.snapshots[opponentSide], + hostSnapshot: match.snapshots.host, + }; + } + + function createRoguelikePvpDraft(match, round, now) { + const existing = match.drafts.get(round); + if (existing) return existing; + if (match.outcome) throw apiError("Roguelike PVP match is already complete.", 409); + if (round !== match.draftProgress.completedRound + 1) { + throw apiError("Roguelike PVP draft round is ahead of match progress.", 409); + } + const draft = { + round, + deadlineAtMs: now + ROGUELIKE_PVP_DRAFT_MS, + submissions: { host: null, guest: null }, + offers: { + host: roguelikePvpDraftOffers(match, "host", round), + guest: roguelikePvpDraftOffers(match, "guest", round), + }, + }; + match.drafts.set(round, draft); + return draft; + } + + function resolveExpiredRoguelikePvpDraft(match, draft, now) { + if (now < draft.deadlineAtMs) return; + for (const side of ["host", "guest"]) { + if (draft.submissions[side]) continue; + const offers = draft.offers[side]; + const selection = { + buffId: offers.buffChoices[0] ?? null, + curseId: offers.curseChoices[0] ?? null, + autoPicked: true, + }; + validateRoguelikePvpDraftOffer(draft, side, selection); + draft.submissions[side] = selection; + applyRoguelikePvpDraftRanks(match, side, selection); + } + if (draft.submissions.host && draft.submissions.guest) { + match.draftProgress.completedRound = Math.max(match.draftProgress.completedRound, draft.round); + } + } + + function roguelikePvpDraftResult(match, draft, side, now) { + resolveExpiredRoguelikePvpDraft(match, draft, now); + const opponentSide = side === "host" ? "guest" : "host"; + const localSelection = draft.submissions[side]; + const opponentSelection = draft.submissions[opponentSide]; + const revealed = Boolean(localSelection && opponentSelection); + return { + status: revealed ? "revealed" : "waiting", + round: draft.round, + deadlineAtMs: draft.deadlineAtMs, + deadlineExpired: now >= draft.deadlineAtMs, + submitted: Boolean(localSelection), + opponentSubmitted: Boolean(opponentSelection), + buffChoices: [...draft.offers[side].buffChoices], + curseChoices: [...draft.offers[side].curseChoices], + ...(revealed ? { selection: localSelection, opponentSelection } : {}), + }; + } + + function requireRoguelikePvpDraftIntermission(match, side, round) { + if (match.outcome) throw apiError("Roguelike PVP match is already complete.", 409); + const snapshot = match.snapshots[side]; + if (!snapshot || snapshot.round !== round || snapshot.phase !== "draft" + || snapshot.bossHp !== 0 + || snapshot.defeatedBosses < roguelikePvpBossCountForRound(round)) { + throw apiError("Roguelike PVP draft requires the current cleared-round intermission.", 409); + } + } + + function validateRoguelikePvpDraftOffer(draft, side, selection) { + const offers = draft.offers[side]; + const validBuff = offers.buffChoices.length === 0 + ? selection.buffId === null + : selection.buffId !== null && offers.buffChoices.includes(selection.buffId); + const validCurse = offers.curseChoices.length === 0 + ? selection.curseId === null + : selection.curseId !== null && offers.curseChoices.includes(selection.curseId); + if (!validBuff || !validCurse) { + throw apiError("Roguelike PVP draft selection was not offered."); + } + } + + function applyRoguelikePvpDraftRanks(match, side, selection) { + if (selection.buffId) { + const ranks = match.draftProgress.buffRanks[side]; + ranks[selection.buffId] = (ranks[selection.buffId] ?? 0) + 1; + } + if (selection.curseId) { + const ranks = match.draftProgress.curseRanks[side]; + ranks[selection.curseId] = (ranks[selection.curseId] ?? 0) + 1; + } + } + + function openRoguelikePvpDraft(session, matchId, roundValue, payload) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(payload?.generation); + if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); + const round = validateRoguelikePvpRound(roundValue); + const now = roguelikePvpNow(); + requireRoguelikePvpDraftIntermission(match, side, round); + const draft = createRoguelikePvpDraft(match, round, now); + touchRoguelikePvpMatch(match, side, now); + return roguelikePvpDraftResult(match, draft, side, now); + } + + function pollRoguelikePvpDraft(session, matchId, roundValue, generationValue) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(generationValue); + if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); + const round = validateRoguelikePvpRound(roundValue); + requireRoguelikePvpDraftIntermission(match, side, round); + const draft = match.drafts.get(round); + if (!draft) throw apiError("Roguelike PVP draft is not open.", 404); + const now = roguelikePvpNow(); + touchRoguelikePvpMatch(match, side, now); + return roguelikePvpDraftResult(match, draft, side, now); + } + + function submitRoguelikePvpDraft(session, matchId, roundValue, payload) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(payload?.generation); + if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); + const round = validateRoguelikePvpRound(roundValue); + const selection = validateRoguelikePvpDraftSelection(payload?.selection); + const now = roguelikePvpNow(); + requireRoguelikePvpDraftIntermission(match, side, round); + const draft = createRoguelikePvpDraft(match, round, now); + resolveExpiredRoguelikePvpDraft(match, draft, now); + const existing = draft.submissions[side]; + if (existing) { + if (existing.buffId !== selection.buffId || existing.curseId !== selection.curseId + || existing.autoPicked !== selection.autoPicked) { + throw apiError("Roguelike PVP draft selection is already locked.", 409); + } + } else { + if (now >= draft.deadlineAtMs && !selection.autoPicked) { + throw apiError("Roguelike PVP draft deadline has passed; an auto-pick is required.", 409); + } + validateRoguelikePvpDraftOffer(draft, side, selection); + draft.submissions[side] = selection; + applyRoguelikePvpDraftRanks(match, side, selection); + if (draft.submissions.host && draft.submissions.guest) { + match.draftProgress.completedRound = Math.max(match.draftProgress.completedRound, round); + } + } + touchRoguelikePvpMatch(match, side, now); + return roguelikePvpDraftResult(match, draft, side, now); + } + + function roguelikePvpRematchResult(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, + mode: match.mode, + seed: rematch.seed, + generation: rematch.toGeneration, + countdownEndsAtMs: rematch.countdownEndsAtMs, + opponentName: match.players[opponentSide].hunterName, + opponentHealerClassId: match.players[opponentSide].healerClassId, + role: side, + }, + }; + } + + function requestRoguelikePvpRematch(session, matchId, payload) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(payload?.generation); + const now = roguelikePvpNow(); + touchRoguelikePvpMatch(match, side, now); + if (generation < match.generation) { + if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) { + throw apiError("Roguelike PVP match generation is stale.", 409); + } + return roguelikePvpRematchResult(match, side, generation); + } + if (generation > match.generation) throw apiError("Roguelike 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) { + match.rematch.ready = true; + match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1; + match.rematch.countdownEndsAtMs = now + ROGUELIKE_PVP_COUNTDOWN_MS; + match.seed = match.rematch.seed; + match.generation = match.rematch.toGeneration; + match.countdownEndsAtMs = match.rematch.countdownEndsAtMs; + match.snapshots = { host: null, guest: null }; + match.lastSeenAtMs = { host: now, guest: now }; + match.drafts = new Map(); + match.draftProgress = { + completedRound: 0, + buffRanks: { host: {}, guest: {} }, + curseRanks: { host: {}, guest: {} }, + }; + match.outcome = null; + } + return roguelikePvpRematchResult(match, side, generation); + } + + function cancelRoguelikePvpRematch(session, matchId, payload) { + const { match, side } = requireRoguelikePvpMatch(session, matchId); + const generation = validateRoguelikePvpGeneration(payload?.generation); + const now = roguelikePvpNow(); + touchRoguelikePvpMatch(match, side, now); + if (match.rematch?.fromGeneration === generation && !match.rematch.ready) { + match.rematch.requested[side] = false; + } + return { ok: true }; + } function queueResult(ticket) { const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null; @@ -830,6 +1463,69 @@ export function createGameApiHandler(options = {}) { } const session = requireSession(database, request); + if (path === "/api/roguelike-pvp/queue" && request.method === "POST") { + return sendJson(response, 200, joinRoguelikePvpQueue(session, await readJson(request))); + } + const roguelikeQueueMatch = path.match(/^\/api\/roguelike-pvp\/queue\/([A-Za-z0-9_-]+)$/); + if (roguelikeQueueMatch && request.method === "GET") { + return sendJson(response, 200, roguelikePvpQueueResult(requireRoguelikePvpTicket(session, roguelikeQueueMatch[1]))); + } + if (roguelikeQueueMatch && request.method === "DELETE") { + const ticket = requireRoguelikePvpTicket(session, roguelikeQueueMatch[1]); + if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409); + ticket.cancelled = true; + roguelikePvpTickets.delete(ticket.id); + return sendJson(response, 200, { ok: true }); + } + const roguelikeStateMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/); + if (roguelikeStateMatch && request.method === "PUT") { + return sendJson(response, 200, exchangeRoguelikePvpState( + session, + roguelikeStateMatch[1], + await readJson(request), + )); + } + const roguelikeDraftOpenMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/drafts\/([1-9][0-9]*)\/open$/); + if (roguelikeDraftOpenMatch && request.method === "POST") { + return sendJson(response, 200, openRoguelikePvpDraft( + session, + roguelikeDraftOpenMatch[1], + roguelikeDraftOpenMatch[2], + await readJson(request), + )); + } + const roguelikeDraftMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/drafts\/([1-9][0-9]*)$/); + if (roguelikeDraftMatch && request.method === "GET") { + return sendJson(response, 200, pollRoguelikePvpDraft( + session, + roguelikeDraftMatch[1], + roguelikeDraftMatch[2], + url.searchParams.get("generation"), + )); + } + if (roguelikeDraftMatch && request.method === "PUT") { + return sendJson(response, 200, submitRoguelikePvpDraft( + session, + roguelikeDraftMatch[1], + roguelikeDraftMatch[2], + await readJson(request), + )); + } + const roguelikeRematchMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/); + if (roguelikeRematchMatch && request.method === "POST") { + return sendJson(response, 200, requestRoguelikePvpRematch( + session, + roguelikeRematchMatch[1], + await readJson(request), + )); + } + if (roguelikeRematchMatch && request.method === "DELETE") { + return sendJson(response, 200, cancelRoguelikePvpRematch( + session, + roguelikeRematchMatch[1], + await readJson(request), + )); + } if (path === "/api/hockey-pvp/queue" && request.method === "POST") { return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request))); } @@ -930,6 +1626,8 @@ export function createGameApiHandler(options = {}) { close: () => { hockeyPvpTickets.clear(); hockeyPvpMatches.clear(); + roguelikePvpTickets.clear(); + roguelikePvpMatches.clear(); database.close(); }, }; diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs index decdf3c..ea2e3ad 100644 --- a/server/game-api.test.mjs +++ b/server/game-api.test.mjs @@ -7,7 +7,8 @@ import { after, before, test } from "node:test"; import { createGameApiHandler } from "./game-api.mjs"; const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-")); -const api = createGameApiHandler({ dataDirectory }); +let roguelikePvpNowMs = 1_000_000; +const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs }); const server = createServer((request, response) => { void api.handle(request, response, () => { response.statusCode = 404; @@ -36,7 +37,7 @@ async function json(path, init = {}) { function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) { return { - schemaVersion: 6, + schemaVersion: 7, slotId, hunterName, stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest }, @@ -191,7 +192,7 @@ test("accounts, server saves, and top-five plus current rankings work end to end assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks); assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds); assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore); - assert.equal(legacyUpload.body.save.schemaVersion, 6); + assert.equal(legacyUpload.body.save.schemaVersion, 7); assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore); assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave); assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration); @@ -296,6 +297,514 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async assert.equal(freshExchange.response.status, 200); }); +test("Roguelike PVP isolates matchmaking, validates progress, hides drafts, and handles rematch lifecycle", async () => { + const registerPlayer = async (username) => { + const registration = await json("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: `long-password-${username}` }), + }); + assert.equal(registration.response.status, 201); + return registration.body.token; + }; + const snapshot = (sequence, overrides = {}) => ({ + sequence, + round: 1, + phase: "combat", + partyHp: [1, 0.9, 0.8, 0.7, 0.6], + bossHp: 350, + bossMaxHp: 500, + defeatedBosses: 0, + ...overrides, + }); + + const alphaToken = await registerPlayer("rogue_pvp_alpha"); + const betaToken = await registerPlayer("rogue_pvp_beta"); + const invalidMode = await json("/api/roguelike-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "hockey-healing-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "priest" }), + }); + assert.equal(invalidMode.response.status, 400); + + const alphaQueue = await json("/api/roguelike-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "druid" }), + }); + assert.equal(alphaQueue.body.status, "waiting"); + const betaQueue = await json("/api/roguelike-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "roguelike-pvp", slotId: 2, hunterName: "Beta", healerClassId: "shaman" }), + }); + assert.equal(betaQueue.body.status, "matched"); + assert.equal(betaQueue.body.match.mode, "roguelike-pvp"); + assert.equal(betaQueue.body.match.role, "guest"); + assert.equal(betaQueue.body.match.opponentName, "Alpha"); + assert.equal(betaQueue.body.match.opponentHealerClassId, "druid"); + assert.equal(betaQueue.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000); + + const alphaMatched = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, { + headers: { Authorization: `Bearer ${alphaToken}` }, + }); + assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id); + assert.equal(alphaMatched.body.match.role, "host"); + assert.equal(alphaMatched.body.match.opponentHealerClassId, "shaman"); + assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs); + const matchId = alphaMatched.body.match.id; + + const hostState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }), + }); + assert.equal(hostState.response.status, 200); + assert.equal(hostState.body.status, "active"); + assert.equal(hostState.body.opponentSnapshot, null); + const guestState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: snapshot(1, { bossHp: 280 }) }), + }); + assert.deepEqual(guestState.body.opponentSnapshot, snapshot(1)); + assert.deepEqual(guestState.body.hostSnapshot, snapshot(1)); + + const staleState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }), + }); + assert.equal(staleState.response.status, 409); + const invalidState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: snapshot(2, { partyHp: [1, 1] }) }), + }); + assert.equal(invalidState.response.status, 400); + + const roundOneDraftSnapshot = (sequence) => snapshot(sequence, { + phase: "draft", + bossHp: 0, + defeatedBosses: 2, + }); + const hostRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }), + }); + assert.equal(hostRoundOneDraftState.response.status, 200); + const guestRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }), + }); + assert.equal(guestRoundOneDraftState.response.status, 200); + + const openedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(openedDraft.body.status, "waiting"); + assert.equal(openedDraft.body.deadlineAtMs, roguelikePvpNowMs + 15_000); + assert.equal(openedDraft.body.buffChoices.length, 3); + assert.equal(openedDraft.body.curseChoices.length, 3); + + const futureDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(futureDraft.response.status, 409); + + const nonexistentSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { buffId: "not-a-real-buff", curseId: openedDraft.body.curseChoices[0] }, + }), + }); + assert.equal(nonexistentSelection.response.status, 400); + + const nonOfferedBuffId = [ + "mend-echo", "mend-efficiency", "mend-cast-speed", "renew-spread", + "renew-duration", "renew-potency", "shield-echo", "shield-potency", + "shield-guard", "purify-renew", "purify-shield", "purify-chain", + "radiance-cooldown", "radiance-renew", "radiance-shield", + "barrier-cooldown", "barrier-duration", "barrier-regen", + ].find((buffId) => !openedDraft.body.buffChoices.includes(buffId)); + const nonOfferedSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { buffId: nonOfferedBuffId, curseId: openedDraft.body.curseChoices[0] }, + }), + }); + assert.equal(nonOfferedSelection.response.status, 400); + + const alphaRoundOneSelection = { + buffId: openedDraft.body.buffChoices[0], + curseId: openedDraft.body.curseChoices[0], + }; + const alphaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: alphaRoundOneSelection, + }), + }); + assert.equal(alphaDraft.body.status, "waiting"); + assert.equal(alphaDraft.body.submitted, true); + assert.equal("selection" in alphaDraft.body, false); + assert.equal("opponentSelection" in alphaDraft.body, false); + + const betaDraftPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, { + headers: { Authorization: `Bearer ${betaToken}` }, + }); + assert.equal(betaDraftPoll.body.opponentSubmitted, true); + assert.equal("opponentSelection" in betaDraftPoll.body, false); + + const betaRoundOneSelection = { + buffId: betaDraftPoll.body.buffChoices[0], + curseId: betaDraftPoll.body.curseChoices[0], + }; + + const betaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: betaRoundOneSelection, + }), + }); + assert.equal(betaDraft.body.status, "revealed"); + assert.deepEqual(betaDraft.body.selection, { + ...betaRoundOneSelection, + autoPicked: false, + }); + assert.deepEqual(betaDraft.body.opponentSelection, { + ...alphaRoundOneSelection, + autoPicked: false, + }); + const changedBuffId = alphaRoundOneSelection.buffId === "mend-echo" ? "mend-efficiency" : "mend-echo"; + const changedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { buffId: changedBuffId, curseId: alphaRoundOneSelection.curseId }, + }), + }); + assert.equal(changedDraft.response.status, 409); + + const roundTwoDraftSnapshot = (sequence) => snapshot(sequence, { + round: 2, + phase: "draft", + bossHp: 0, + defeatedBosses: 2, + }); + await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }), + }); + await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }), + }); + const openedRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(openedRoundTwoDraft.response.status, 200); + const betaRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, { + headers: { Authorization: `Bearer ${betaToken}` }, + }); + roguelikePvpNowMs += 15_000; + const lateManualDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { + buffId: openedRoundTwoDraft.body.buffChoices[0], + curseId: openedRoundTwoDraft.body.curseChoices[0], + }, + }), + }); + assert.equal(lateManualDraft.response.status, 409); + const alphaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { + buffId: openedRoundTwoDraft.body.buffChoices[0], + curseId: openedRoundTwoDraft.body.curseChoices[0], + autoPicked: true, + }, + }), + }); + assert.equal(alphaAutoDraft.body.deadlineExpired, true); + const betaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { + buffId: betaRoundTwoDraft.body.buffChoices[0], + curseId: betaRoundTwoDraft.body.curseChoices[0], + autoPicked: true, + }, + }), + }); + assert.equal(betaAutoDraft.body.status, "revealed"); + assert.equal(betaAutoDraft.body.opponentSelection.autoPicked, true); + + const clientAuthoredWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + snapshot: snapshot(4, { round: 3, phase: "won" }), + }), + }); + assert.equal(clientAuthoredWin.response.status, 400); + + const incompletePartyLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0.01] }), + }), + }); + assert.equal(incompletePartyLoss.response.status, 400); + + const hostPartyWipe = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }), + }), + }); + assert.equal(hostPartyWipe.body.status, "lost-by-forfeit"); + assert.equal(hostPartyWipe.body.outcomeReason, "party-wipe"); + + // First accepted valid wipe is the stable simultaneous-wipe tie-break. + // A later opposing wipe cannot oscillate or reverse the frozen result. + const guestPartyWipeAfterOutcome = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }), + }), + }); + assert.equal(guestPartyWipeAfterOutcome.body.status, "won-by-forfeit"); + assert.equal(guestPartyWipeAfterOutcome.body.outcomeReason, "party-wipe"); + + const alphaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(alphaRematch.body.status, "waiting"); + const betaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(betaRematch.body.status, "matched"); + assert.equal(betaRematch.body.match.generation, 2); + assert.equal(betaRematch.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000); + const alphaRematchReady = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + assert.equal(alphaRematchReady.body.match.seed, betaRematch.body.match.seed); + + const staleGeneration = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }), + }); + assert.equal(staleGeneration.response.status, 409); + await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }), + }); + roguelikePvpNowMs += 15_001; + const hostForfeitWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 2, snapshot: snapshot(2) }), + }); + assert.equal(hostForfeitWin.body.status, "won-by-forfeit"); + assert.equal(hostForfeitWin.body.opponentConnection, "forfeited"); + const guestForfeitLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }), + }); + assert.equal(guestForfeitLoss.body.status, "lost-by-forfeit"); + + roguelikePvpNowMs += 10 * 60_000 + 1; + const expiredMatch = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, { + headers: { Authorization: `Bearer ${alphaToken}` }, + }); + assert.equal(expiredMatch.response.status, 404); +}); + +test("Roguelike PVP draft deadline deterministically resolves missing submissions", async () => { + const registerPlayer = async (username) => { + const registration = await json("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: `long-password-${username}` }), + }); + assert.equal(registration.response.status, 201); + return registration.body.token; + }; + const draftSnapshot = (sequence, round) => ({ + sequence, + round, + phase: "draft", + partyHp: [1, 0.9, 0.8, 0.7, 0.6], + bossHp: 0, + bossMaxHp: 500, + defeatedBosses: 2, + }); + + const hostToken = await registerPlayer("rogue_deadline_host"); + const guestToken = await registerPlayer("rogue_deadline_guest"); + const hostQueue = await json("/api/roguelike-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Host", healerClassId: "priest" }), + }); + const guestQueue = await json("/api/roguelike-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${guestToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Guest", healerClassId: "druid" }), + }); + assert.equal(hostQueue.body.status, "waiting"); + assert.equal(guestQueue.body.status, "matched"); + const matchId = guestQueue.body.match.id; + + for (const [token, snapshot] of [ + [hostToken, draftSnapshot(1, 1)], + [guestToken, draftSnapshot(1, 1)], + ]) { + const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot }), + }); + assert.equal(state.response.status, 200); + } + + const hostRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, { + method: "POST", + headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + const guestRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, { + headers: { Authorization: `Bearer ${guestToken}` }, + }); + roguelikePvpNowMs = hostRoundOne.body.deadlineAtMs; + + const expiredHostPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, { + headers: { Authorization: `Bearer ${hostToken}` }, + }); + assert.equal(expiredHostPoll.body.status, "revealed"); + assert.equal(expiredHostPoll.body.deadlineExpired, true); + assert.deepEqual(expiredHostPoll.body.selection, { + buffId: hostRoundOne.body.buffChoices[0], + curseId: hostRoundOne.body.curseChoices[0], + autoPicked: true, + }); + assert.deepEqual(expiredHostPoll.body.opponentSelection, { + buffId: guestRoundOne.body.buffChoices[0], + curseId: guestRoundOne.body.curseChoices[0], + autoPicked: true, + }); + + const mutateServerPick = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, { + method: "PUT", + headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + generation: 1, + selection: { + buffId: hostRoundOne.body.buffChoices[1], + curseId: hostRoundOne.body.curseChoices[1], + autoPicked: true, + }, + }), + }); + assert.equal(mutateServerPick.response.status, 409); + + const expiredGuestPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, { + headers: { Authorization: `Bearer ${guestToken}` }, + }); + assert.equal(expiredGuestPoll.body.status, "revealed"); + + for (const [token, snapshot] of [ + [hostToken, draftSnapshot(2, 2)], + [guestToken, draftSnapshot(2, 2)], + ]) { + const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, snapshot }), + }); + assert.equal(state.response.status, 200); + } + + const hostRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, { + method: "POST", + headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1 }), + }); + const hostManualSelection = { + buffId: hostRoundTwo.body.buffChoices[1], + curseId: hostRoundTwo.body.curseChoices[1], + }; + const hostSubmission = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, { + method: "PUT", + headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ generation: 1, selection: hostManualSelection }), + }); + assert.equal(hostSubmission.body.status, "waiting"); + const guestRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, { + headers: { Authorization: `Bearer ${guestToken}` }, + }); + roguelikePvpNowMs = hostRoundTwo.body.deadlineAtMs; + + const guestAutoResolved = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, { + headers: { Authorization: `Bearer ${guestToken}` }, + }); + assert.equal(guestAutoResolved.body.status, "revealed"); + assert.deepEqual(guestAutoResolved.body.selection, { + buffId: guestRoundTwo.body.buffChoices[0], + curseId: guestRoundTwo.body.curseChoices[0], + autoPicked: true, + }); + assert.deepEqual(guestAutoResolved.body.opponentSelection, { + ...hostManualSelection, + autoPicked: false, + }); +}); + test("invalid credentials cannot access server saves", async () => { const login = await json("/api/auth/login", { method: "POST", diff --git a/src/App.tsx b/src/App.tsx index eccd9a6..6b48056 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import packageJson from "../package.json"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { FrontEnd } from "./components/FrontEnd"; import { useActiveHunter, useFrontendStore } from "./frontend/store"; -import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store"; +import { getHockeyPvpNetworkSnapshot, getRoguelikePvpNetworkSnapshot, useGameStore } from "./game/store"; import type { BossId } from "./game/types"; import type { DifficultySlug } from "./game/progression/loot"; import { useActionBindings } from "./game/useGameLoop"; @@ -12,8 +12,11 @@ import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync"; import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp"; import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp"; -import { onlineRepository } from "./frontend/onlineRepository"; +import { onlineRepository, type RoguelikePvpWireSnapshot } from "./frontend/onlineRepository"; import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking"; +import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot, type RoguelikePvpStatus } from "./game/roguelikePvp"; +import type { RoguelikePvpMatchConfig } from "./frontend/roguelikePvpMatchmaking"; +import { createClassInventory } from "./game/healers"; const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); @@ -32,6 +35,87 @@ function TacticalLoadingScreen() { return
IHLoading field consoleGameplay remains active
; } +function RoguelikePvpDraftPreview() { + useEffect(() => { + useGameStore.getState().configureHealer( + "paladin", + "Preview Healer", + createClassInventory("paladin"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { + matchId: null, + seed: 2, + generation: 1, + opponentName: "Rival Chrona", + opponentHealerClassId: "chronomancer", + role: "cpu", + countdownEndsAtMs: 0, + }, + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + phase: "intermission", + activeTab: "combat", + roguelikePvp: { + ...state.roguelikePvp, + status: "drafting", + round: 1, + buffChoices: ["mend-echo", "purify-chain", "barrier-duration"], + curseChoices: ["ability1-mana-cost", "ability3-cooldown", "ability6-mana-cost"], + selectedBuffId: "mend-echo", + selectedCurseId: "ability1-mana-cost", + draftStep: "buff", + draftDeadlineAtMs: Date.now() + 120_000, + localDraftLocked: false, + opponentDraftLocked: false, + opponentBossHp: 0, + }, + })); + }, []); + + return }> undefined} />; +} + +function roguelikePvpWirePhase(status: RoguelikePvpStatus): RoguelikePvpWireSnapshot["phase"] { + // Clients may concede with `lost`; the server adjudicates winners. + if (status === "lost") return status; + if (status === "won") return "combat"; + if (status === "drafting") return "draft"; + if (status === "countdown" || status === "inactive") return "countdown"; + return "combat"; +} + +function roguelikePvpStatusFromWire(phase: RoguelikePvpWireSnapshot["phase"]): RoguelikePvpStatus { + if (phase === "draft") return "drafting"; + return phase; +} + +function roguelikePvpRemoteFromWire( + snapshot: RoguelikePvpWireSnapshot, + seed: number, +): RoguelikePvpRemoteSnapshot { + const bossIds = roguelikePvpBossesForRound(seed, snapshot.round); + const partyHpPercent = snapshot.partyHp.reduce((total, value) => total + value, 0) / snapshot.partyHp.length * 100; + return { + sequence: snapshot.sequence, + time: 0, + status: roguelikePvpStatusFromWire(snapshot.phase), + progress: { + round: snapshot.round, + bossesDefeated: snapshot.defeatedBosses, + livingPartyMembers: snapshot.partyHp.filter((value) => value > 0).length, + partyHpPercent, + bosses: [{ id: bossIds[0], hp: snapshot.bossHp, maxHp: snapshot.bossMaxHp }], + }, + buffRanks: {}, + curseRanks: {}, + draftSubmission: null, + }; +} + function MainApp() { useForcedThorDisplays(); useAuthoritativeDualScreenSync(); @@ -45,6 +129,7 @@ function MainApp() { const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat); + const recordRoguelikePvpResult = useFrontendStore((state) => state.recordRoguelikePvpResult); const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat); const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult); const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill); @@ -54,6 +139,7 @@ function MainApp() { const gamePhase = useGameStore((state) => state.phase); const gameRunMode = useGameStore((state) => state.runMode); const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); + const roguelikePvpCountdownEndsAtMs = useGameStore((state) => state.roguelikePvp.countdownEndsAtMs); const rewardedBossInstances = useRef(new Set()); const hockeyPvpPostMatchOperation = useRef(null); const screenRef = useRef(screen); @@ -63,6 +149,10 @@ function MainApp() { hockeyPvpPostMatchOperation.current = null; const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); const game = useGameStore.getState(); + if (game.runMode === "roguelike-pvp" + && (game.phase === "combat" || game.phase === "intermission")) { + game.resolveRoguelikePvpMatch(false); + } // RPG Roguelike equipment belongs only to its current run. Never leak it // into the hunter's permanent inventory when leaving the expedition. if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory); @@ -131,7 +221,7 @@ function MainApp() { launchHockeyPvpMatch(match); }); }, [accountId, hunter, launchHockeyPvpMatch]); - const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { + const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => { if (!hunter) return; const progress = hunter.healers[hunter.activeClassId]; const selectedMode = useFrontendStore.getState().selectedMode; @@ -143,6 +233,8 @@ function MainApp() { ? "hockey-healing" : selectedMode === "hockey-healing-pvp" ? "hockey-healing-pvp" + : selectedMode === "roguelike-pvp" + ? "roguelike-pvp" : selectedMode === "blockbreaker" ? "blockbreaker" : selectedMode === "aether-assault" @@ -161,7 +253,7 @@ function MainApp() { runMode, hunter.gearProgress, launchDifficulty, - hockeyPvpMatch, + pvpMatch, ); touchActiveSave(); navigate("game"); @@ -169,8 +261,8 @@ function MainApp() { useEffect(() => { const onDualScreenLaunch = (event: Event) => { - const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail; - if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch); + const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail; + if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.pvpMatch ?? detail.hockeyPvpMatch); else launchGame(detail); }; window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); @@ -229,13 +321,143 @@ function MainApp() { }; }, [screen]); + useEffect(() => { + if (screen !== "game") return; + let stopped = false; + let exchangeActive = false; + let terminalLossReported = false; + const exchange = async () => { + if (stopped || exchangeActive) return; + const state = useGameStore.getState(); + const pvp = state.roguelikePvp; + if (state.runMode !== "roguelike-pvp" || !pvp.matchId || pvp.role === "cpu") return; + if (state.phase === "victory" || pvp.status === "won") return; + if ((state.phase === "defeat" || pvp.status === "lost") && terminalLossReported) return; + const snapshot = getRoguelikePvpNetworkSnapshot(); + if (!snapshot) return; + const bossHp = snapshot.progress.bosses.reduce((total, boss) => total + boss.hp, 0); + const bossMaxHp = snapshot.progress.bosses.reduce((total, boss) => total + boss.maxHp, 0); + const partyHp = state.party.map((member) => Math.max(0, Math.min(1, member.hp / Math.max(1, member.maxHp)))) as RoguelikePvpWireSnapshot["partyHp"]; + const wireSnapshot: RoguelikePvpWireSnapshot = { + sequence: snapshot.sequence, + round: snapshot.progress.round, + phase: roguelikePvpWirePhase(snapshot.status), + partyHp, + bossHp, + bossMaxHp, + defeatedBosses: snapshot.progress.bossesDefeated, + }; + exchangeActive = true; + try { + const result = await onlineRepository.exchangeRoguelikePvpState(pvp.matchId, pvp.generation, wireSnapshot); + if (stopped) return; + if (wireSnapshot.phase === "lost") terminalLossReported = true; + const game = useGameStore.getState(); + game.setRoguelikePvpConnectionStatus(result.opponentConnection === "connected" ? "online" : "disconnected"); + if (result.status !== "active") { + terminalLossReported = true; + game.resolveRoguelikePvpMatch(result.status === "won-by-forfeit"); + return; + } + if (result.opponentSnapshot) { + game.applyRoguelikePvpRemoteSnapshot(roguelikePvpRemoteFromWire(result.opponentSnapshot, pvp.seed)); + } + } catch { + if (!stopped) useGameStore.getState().setRoguelikePvpConnectionStatus("disconnected"); + } finally { + exchangeActive = false; + } + }; + void exchange(); + const timer = window.setInterval(() => { void exchange(); }, 160); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [screen]); + + useEffect(() => { + if (screen !== "game") return; + let stopped = false; + let requestActive = false; + const openedRounds = new Set(); + const submittedRounds = new Set(); + const syncDraft = async () => { + if (stopped || requestActive) return; + const state = useGameStore.getState(); + const pvp = state.roguelikePvp; + if (state.runMode !== "roguelike-pvp" + || state.phase !== "intermission" + || !pvp.matchId + || pvp.role === "cpu") return; + requestActive = true; + try { + let result; + if (!openedRounds.has(state.round)) { + result = await onlineRepository.openRoguelikePvpDraft(pvp.matchId, pvp.generation, state.round); + openedRounds.add(state.round); + } else if (pvp.localDraftLocked && !submittedRounds.has(state.round)) { + submittedRounds.add(state.round); + result = await onlineRepository.submitRoguelikePvpDraft( + pvp.matchId, + pvp.generation, + state.round, + { + buffId: pvp.selectedBuffId, + curseId: pvp.selectedCurseId, + autoPicked: pvp.draftDeadlineAtMs > 0 && Date.now() >= pvp.draftDeadlineAtMs, + }, + ); + } else { + result = await onlineRepository.pollRoguelikePvpDraft(pvp.matchId, pvp.generation, state.round); + } + if (stopped) return; + if (pvp.localDraftLocked) { + if (result.submitted) submittedRounds.add(state.round); + else submittedRounds.delete(state.round); + } + const game = useGameStore.getState(); + game.syncRoguelikePvpDraft(result.deadlineAtMs, result.opponentSubmitted); + if (result.status === "revealed" && result.selection && result.opponentSelection) { + game.applyRoguelikePvpDraftReveal({ + round: result.round, + local: { + round: result.round, + buffId: result.selection.buffId, + curseId: result.selection.curseId, + }, + opponent: { + round: result.round, + buffId: result.opponentSelection.buffId, + curseId: result.opponentSelection.curseId, + }, + }); + } + } catch { + if (!stopped) useGameStore.getState().setRoguelikePvpConnectionStatus("disconnected"); + } finally { + requestActive = false; + } + }; + void syncDraft(); + const timer = window.setInterval(() => { void syncDraft(); }, 180); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [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 (state.phase !== "briefing" + || state.runMode !== "hockey-healing-pvp" && state.runMode !== "roguelike-pvp") return; + const countdownEndsAtMs = state.runMode === "roguelike-pvp" + ? state.roguelikePvp.countdownEndsAtMs + : state.hockeyPvp.countdownEndsAtMs; + const remaining = countdownEndsAtMs - Date.now(); if (remaining <= 0) { state.startEncounter(); return; @@ -246,7 +468,7 @@ function MainApp() { return () => { if (timer !== undefined) window.clearTimeout(timer); }; - }, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, screen]); + }, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, roguelikePvpCountdownEndsAtMs, screen]); useActionBindings(screen === "game", leaveGame); @@ -286,6 +508,11 @@ function MainApp() { && state.phase !== previousState.phase) { recordHockeyPvpResult(state.phase === "victory"); } + if (state.runMode === "roguelike-pvp" + && (state.phase === "victory" || state.phase === "defeat") + && state.phase !== previousState.phase) { + recordRoguelikePvpResult(state.phase === "victory", state.round); + } if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") { recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score); } @@ -294,7 +521,7 @@ function MainApp() { } // RPG rewards are generated inside the run reducer. Permanent boss loot // here would duplicate its chest and break run-only progression. - if (state.runMode === "rpg-roguelike") return; + if (state.runMode === "rpg-roguelike" || state.runMode === "roguelike-pvp") return; const bossCount = 1 + state.additionalBosses.length; if (state.boss.hp <= 0 && previousState.boss.hp > 0) { const primaryInstanceId = state.bossInstanceId; @@ -317,7 +544,7 @@ function MainApp() { recordBossVictory(entry.boss.id, rewardDifficulty); } }); - }, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]); + }, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRoguelikePvpResult, recordRogueTrialsEndlessDefeat]); return (
@@ -333,8 +560,12 @@ function MainApp() { } export default function App() { - if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") { + const preview = import.meta.env.DEV ? new URLSearchParams(window.location.search).get("preview") : null; + if (preview === "healer-models") { return ; } + if (preview === "roguelike-pvp-draft") { + return ; + } return ; } diff --git a/src/components/AbilityButton.tsx b/src/components/AbilityButton.tsx index de0ddd3..58df450 100644 --- a/src/components/AbilityButton.tsx +++ b/src/components/AbilityButton.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from "react"; import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings"; import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers"; import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike"; +import { compileRoguelikePvpCurses, roguelikePvpAbilityCooldown, roguelikePvpAbilityManaCost } from "../game/roguelikePvp"; import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store"; import type { AbilitySlotId } from "../game/types"; @@ -18,6 +19,8 @@ export function AbilityButton({ abilityId, compact = false }: { abilityId: Abili const activeCast = useGameStore((state) => state.activeCast); const castAbility = useGameStore((state) => state.castAbility); const runModifiers = useGameStore((state) => state.runModifiers); + const runMode = useGameStore((state) => state.runMode); + const receivedCurseRanks = useGameStore((state) => state.roguelikePvp.receivedCurseRanks); const healerMechanic = useGameStore((state) => state.healerMechanic); const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`; @@ -33,10 +36,15 @@ export function AbilityButton({ abilityId, compact = false }: { abilityId: Abili } const remaining = abilityRemaining(abilityId, time, cooldowns); - const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers); + const compiledCurses = compileRoguelikePvpCurses(receivedCurseRanks); + const manaCost = runMode === "roguelike-pvp" + ? roguelikePvpAbilityManaCost(abilityId, ability.mana, runModifiers, compiledCurses) + : runAbilityManaCost(abilityId, ability.mana, runModifiers); const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0; const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime; - const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers); + const cooldownDuration = runMode === "roguelike-pvp" + ? roguelikePvpAbilityCooldown(abilityId, ability.cooldown, runModifiers, compiledCurses) + : runAbilityCooldown(abilityId, ability.cooldown, runModifiers); const globalRemaining = Math.max(0, globalCooldownUntil - time); const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0; const invalidTarget = ability.targeting === "ally" && selected.hp <= 0; diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index 76f4a74..5996d15 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -40,6 +40,10 @@ import { AbilityButton } from "./AbilityButton"; import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting"; import { isSingleScreenLayout } from "../platform/displayLayout"; import { subscribeControllerToken } from "../input/controller"; +import { + RoguelikePvpDraftPanel, + RoguelikePvpTacticalPanel, +} from "./RoguelikePvpPanels"; function moveTacticalSelection(direction: 1 | -1) { const store = useGameStore.getState(); @@ -80,6 +84,9 @@ function useSingleScreenTacticalInput( 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.runMode === "roguelike-pvp") { + if (store.roguelikePvp.role === "cpu") store.restart(); + else exitRef.current?.(); } else if (store.phase === "victory" || store.phase === "defeat") store.restart(); }; const onKeyDown = (event: KeyboardEvent) => { @@ -279,6 +286,7 @@ function BriefingPanel() { const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const bossNames = bosses.map((boss) => boss.name).join(" & "); + const runMode = useGameStore((state) => state.runMode); const activityMode = useGameStore((state) => state.activityMode); const hockeyMode = activityMode === "hockey-healing"; const pvpMode = activityMode === "hockey-healing-pvp"; @@ -287,14 +295,19 @@ function BriefingPanel() { const opponentName = useGameStore((state) => state.hockeyPvp.opponentName); const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs); + const roguelikePvp = useGameStore((state) => state.roguelikePvp); + const roguelikePvpMode = runMode === "roguelike-pvp"; + const roguelikePvpCountdownSeconds = useHockeyPvpCountdownSeconds(roguelikePvpMode, roguelikePvp.countdownEndsAtMs); + const competitivePvpMode = pvpMode || roguelikePvpMode; + const competitivePvpCountdown = roguelikePvpMode ? roguelikePvpCountdownSeconds : pvpCountdownSeconds; return (
{healer.icon}
Chosen discipline

{healer.specialization}

-

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

- +

{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.` : roguelikePvpMode ? `Face ${roguelikePvp.opponentName} through matching seeded encounters. After every clear, choose one blessing for yourself and secretly inflict one ability curse on your rival. Last five-person formation standing wins.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}}

+
Prepared skills6 equipped
@@ -338,6 +351,7 @@ function EndPanel({ const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); const hockey = useGameStore((state) => state.hockey); const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const roguelikePvp = useGameStore((state) => state.roguelikePvp); const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); const requeueSeconds = useHockeyPvpCountdownSeconds( hockeyPvp.postMatchStatus === "requeueing", @@ -350,20 +364,21 @@ function EndPanel({ const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker"; const aetherDefeat = phase === "defeat" && activityMode === "aether-assault"; const pvpMatch = activityMode === "hockey-healing-pvp"; - const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch; + const roguelikePvpMatch = runMode === "roguelike-pvp"; + const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch && !roguelikePvpMatch; const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0; const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`; return ( -
+
{phase === "victory" ? "✦" : "×"} - {showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} -

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

+ {showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch || roguelikePvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} +

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

Duration{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")} - {hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`} - {hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}–${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"} + {hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : roguelikePvpMatch ? "Round reached" : "Party vitality"}{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded}` : roguelikePvpMatch ? round : `${Math.round((totalHp / totalMax) * 100)}%`} + {hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : roguelikePvpMatch ? "Active burdens" : endlessDefeat ? "Endless kills" : "Boss"}{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}–${hockeyPvp.opponentBossKills}` : roguelikePvpMatch ? Object.values(roguelikePvp.receivedCurseRanks).filter((rank) => (rank ?? 0) > 0).length : phase === "victory" ? "Defeated" : "Standing"}
- {(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && } + {!roguelikePvpMatch && (phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && } {showEndlessChoice ?
+
: roguelikePvpMatch ?
+ {roguelikePvp.role === "cpu" && } +
: pvpMatch ? <>
{hockeyPvp.postMatchStatus === "waiting-rematch" @@ -430,8 +448,11 @@ function CombatPanel({ onExit, onHockeyPvpAction }: { onHockeyPvpAction?: (action: Exclude) => void; }) { const phase = useGameStore((state) => state.phase); + const runMode = useGameStore((state) => state.runMode); if (phase === "briefing") return ; - if (phase === "intermission") return ; + if (phase === "intermission") return runMode === "roguelike-pvp" + ? + : ; if (phase === "victory" || phase === "defeat") return ; return
; } @@ -830,10 +851,15 @@ export function BottomScreen({ onExit, onHockeyPvpAction }: {
- {activeTab === "combat" && } - {activeTab === "map" && } - {activeTab === "pack" && activityMode !== "hockey-healing-pvp" && } - {activeTab === "pvp" && activityMode === "hockey-healing-pvp" && } + {phase === "intermission" && runMode === "roguelike-pvp" + ? + : <> + {activeTab === "combat" && } + {activeTab === "map" && } + {activeTab === "pack" && activityMode !== "hockey-healing-pvp" && } + {activeTab === "pvp" && activityMode === "hockey-healing-pvp" && } + {activeTab === "pvp" && runMode === "roguelike-pvp" && } + }
{paused && ( )} - {launchLabel}{queueing ? `CPU fallback in ${Math.max(0, ((HOCKEY_PVP_QUEUE_TIMEOUT_MS - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`} + {launchLabel}{queueing ? `CPU fallback in ${Math.max(0, (((isRoguelikePvp ? ROGUELIKE_PVP_QUEUE_TIMEOUT_MS : HOCKEY_PVP_QUEUE_TIMEOUT_MS) - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`} {message &&
{message}
} } @@ -1789,19 +1833,20 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
Run preparation{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}
{contextRules.map(([title, copy], index) =>
0{index + 1}{title}{copy}
)} -
Equipped role{healer.specialization} · Level {progress?.level ?? 1}{isHockeyPvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}
+
Equipped role{healer.specialization} · Level {progress?.level ?? 1}{isHockeyPvp || isRoguelikePvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}
{isDungeon &&
Guaranteed reward{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500
} {isHockey &&
Every boss killNormal boss loot awardedGuaranteed 1–3 group drops · Independent pet chance 1 in 500
} {isBlockbreaker &&
Ranked recordsOverall score · Bricks · Survival10 points per brick ladder · +0.1× every 30 seconds
} {isAetherAssault &&
Ranked recordOverall score · Wave at bestKill streak raises multiplier · Ship damage resets it
} {isHockeyPvp &&
Ranked recordsWins / losses · Lifetime PVP boss killsOnline leaderboards publish through active hunter save
} + {isRoguelikePvp &&
Competitive recordWins / losses · Highest roundEndless mirrored rounds award no permanent boss loot
}
} /> ); } -export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) { +export function FrontEnd({ onLaunch }: { onLaunch: FrontEndLaunchHandler }) { const screen = useFrontendStore((state) => state.screen); if (screen === "login") return ; if (screen === "saves") return ; diff --git a/src/components/RoguelikePvpPanels.tsx b/src/components/RoguelikePvpPanels.tsx new file mode 100644 index 0000000..317399e --- /dev/null +++ b/src/components/RoguelikePvpPanels.tsx @@ -0,0 +1,363 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { HEALER_CLASSES } from "../game/healers"; +import { + RUN_BUFFS, + effectiveRunBuffRank, + formatRunBuffEffect, +} from "../game/roguelike"; +import { + ROGUELIKE_PVP_CURSES, + ROGUELIKE_PVP_CURSE_ORDER, + formatRoguelikePvpCurseEffect, +} from "../game/roguelikePvp"; +import { useGameStore } from "../game/store"; +import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; + +const clampPercent = (value: number) => Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)); + +function percentOf(value: number, maximum: number) { + return maximum > 0 ? clampPercent((value / maximum) * 100) : 0; +} + +function labelToken(value: string) { + return value + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function useDeadlineSeconds(deadlineAtMs: number) { + const calculate = () => Math.max(0, Math.ceil((deadlineAtMs - Date.now()) / 1_000)); + const [remaining, setRemaining] = useState(calculate); + + useEffect(() => { + setRemaining(calculate()); + if (deadlineAtMs <= Date.now()) return; + const timer = window.setInterval(() => setRemaining(calculate()), 250); + return () => window.clearInterval(timer); + }, [deadlineAtMs]); + + return remaining; +} + +function ProgressMeter({ label, value, tone }: { label: string; value: number; tone: "local" | "rival" }) { + const percent = clampPercent(value); + return ( + + + {Math.round(percent)}% + + ); +} + +function EmptyDraftChoice({ kind }: { kind: "buff" | "curse" }) { + return ( +
+ {kind === "buff" ? "✦" : "⌁"} + + {kind === "buff" ? "Blessings mastered" : "Curse pool exhausted"} + No selection required. Continue draft. + +
+ ); +} + +function DraftStepRail({ step }: { step: "buff" | "curse" | "review" }) { + const steps = ["buff", "curse", "review"] as const; + const activeIndex = steps.indexOf(step); + return ( +
    + {steps.map((entry, index) => ( +
  1. + {index < activeIndex ? "✓" : index + 1} + {entry === "buff" ? "Bless" : entry === "curse" ? "Sabotage" : "Lock"} +
  2. + ))} +
+ ); +} + +/** Lower-display draft. Controller routing updates the same selected IDs as pointer input. */ +export function RoguelikePvpDraftPanel({ className = "" }: { className?: string }) { + const healerClassId = useGameStore((state) => state.healerClassId); + const opponentHealerClassId = useGameStore((state) => state.roguelikePvp.opponentHealerClassId); + const runBuffRanks = useGameStore((state) => state.runBuffRanks); + const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId); + const opponentName = useGameStore((state) => state.roguelikePvp.opponentName); + const round = useGameStore((state) => state.roguelikePvp.round); + const choices = useGameStore((state) => state.roguelikePvp.buffChoices); + const curseChoices = useGameStore((state) => state.roguelikePvp.curseChoices); + const selectedBuffId = useGameStore((state) => state.roguelikePvp.selectedBuffId); + const selectedCurseId = useGameStore((state) => state.roguelikePvp.selectedCurseId); + const draftStep = useGameStore((state) => state.roguelikePvp.draftStep); + const draftDeadlineAtMs = useGameStore((state) => state.roguelikePvp.draftDeadlineAtMs); + const localDraftLocked = useGameStore((state) => state.roguelikePvp.localDraftLocked); + const opponentDraftLocked = useGameStore((state) => state.roguelikePvp.opponentDraftLocked); + const selectBuff = useGameStore((state) => state.selectRoguelikePvpBuff); + const selectCurse = useGameStore((state) => state.selectRoguelikePvpCurse); + const setDraftStep = useGameStore((state) => state.setRoguelikePvpDraftStep); + const submitDraft = useGameStore((state) => state.submitRoguelikePvpDraft); + const remainingSeconds = useDeadlineSeconds(draftDeadlineAtMs); + const abilities = HEALER_CLASSES[healerClassId].abilities; + const opponentAbilities = HEALER_CLASSES[opponentHealerClassId].abilities; + const selectedBuff = selectedBuffId ? RUN_BUFFS[selectedBuffId] : null; + const selectedCurse = selectedCurseId ? ROGUELIKE_PVP_CURSES[selectedCurseId] : null; + const buffReady = selectedBuffId !== null || choices.length === 0; + const curseReady = selectedCurseId !== null || curseChoices.length === 0; + + if (localDraftLocked) { + return ( +
+
+ Round {round} complete + +
+ +

{opponentDraftLocked ? "Both drafts sealed" : `Waiting for ${opponentName}`}

+

{opponentDraftLocked ? "Revealing sabotage and preparing mirrored encounters." : "Your choices stay hidden until rival locks or timer expires."}

+
+ {selectedBuff?.icon ?? "✦"}Your blessing{selectedBuff?.name ?? "Mastered"} + + {selectedCurse?.icon ?? "⌁"}Sent to rival{selectedCurse?.name ?? "None"} +
+
+ ); + } + + return ( +
+
+ Round {round} cleared · Next: {round + 1} + + +
+ + {draftStep === "buff" && ( +
+
+ Choose for yourself +

Claim a blessing

+

Strengthen one equipped ability for every later round.

+
+
+ {choices.length > 0 ? choices.map((buffId) => { + const buff = RUN_BUFFS[buffId]; + const currentRank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId); + const nextRank = Math.min(buff.maxRank, currentRank + 1); + const ability = abilities[buff.abilitySlotId]; + return ( + + ); + }) : } +
+
+ ← / → Choose {DEFAULT_CONTROLLER_GLYPHS.confirm} Select + +
+
+ )} + + {draftStep === "curse" && ( +
+
+ Inflict on {opponentName} +

Choose their burden

+

Curse one rival ability. Repeated curses stack to rank 3.

+
+
+ {curseChoices.length > 0 ? curseChoices.map((curseId) => { + const curse = ROGUELIKE_PVP_CURSES[curseId]; + const ability = opponentAbilities[curse.abilitySlotId]; + return ( + + ); + }) : } +
+
+ + ← / → Choose {DEFAULT_CONTROLLER_GLYPHS.confirm} Select + +
+
+ )} + + {draftStep === "review" && ( +
+
+ Hidden until both players lock +

Seal round {round + 1}

+

Confirm blessing and sabotage. Locked choices cannot change.

+
+
+
+ {selectedBuff?.icon ?? "✦"} + Your blessing + {selectedBuff ? `${abilities[selectedBuff.abilitySlotId].shortName}: ${selectedBuff.name}` : "No blessing required"} +

{selectedBuff?.summary ?? "Blessing catalog mastered."}

+
+ +
+ {selectedCurse?.icon ?? "⌁"} + {opponentName}'s burden + {selectedCurse ? `${opponentAbilities[selectedCurse.abilitySlotId].shortName}: ${selectedCurse.name}` : "No curse required"} +

{selectedCurse?.summary ?? "Curse catalog exhausted."}

+
+
+
+ + Choices reveal together + +
+
+ )} +
+ ); +} + +/** Compact top-display rivalry HUD. Intended to replace the normal objective chip. */ +export function RoguelikePvpStatusStrip({ className = "" }: { className?: string }) { + const boss = useGameStore((state) => state.boss); + const additionalBosses = useGameStore((state) => state.additionalBosses); + const party = useGameStore((state) => state.party); + const pvp = useGameStore((state) => state.roguelikePvp); + const localPartyMaximum = party.reduce((total, member) => total + member.maxHp, 0); + const localPartyHealth = party.reduce((total, member) => total + member.hp, 0); + const localPartyPercent = percentOf(localPartyHealth, localPartyMaximum); + const localBossHealth = boss.hp + additionalBosses.reduce((total, entry) => total + entry.boss.hp, 0); + const localBossMaximum = boss.maxHp + additionalBosses.reduce((total, entry) => total + entry.boss.maxHp, 0); + const localBossPercent = percentOf(localBossHealth, localBossMaximum); + const rivalBossPercent = percentOf(pvp.opponentBossHp, pvp.opponentBossMaxHp); + + return ( + + ); +} + +/** Lower-display live opponent telemetry and received-curse ledger. */ +export function RoguelikePvpTacticalPanel({ className = "" }: { className?: string }) { + const boss = useGameStore((state) => state.boss); + const additionalBosses = useGameStore((state) => state.additionalBosses); + const party = useGameStore((state) => state.party); + const healerClassId = useGameStore((state) => state.healerClassId); + const pvp = useGameStore((state) => state.roguelikePvp); + const localPartyMaximum = party.reduce((total, member) => total + member.maxHp, 0); + const localPartyHealth = party.reduce((total, member) => total + member.hp, 0); + const localPartyPercent = percentOf(localPartyHealth, localPartyMaximum); + const localBossHealth = boss.hp + additionalBosses.reduce((total, entry) => total + entry.boss.hp, 0); + const localBossMaximum = boss.maxHp + additionalBosses.reduce((total, entry) => total + entry.boss.maxHp, 0); + const localBossPercent = percentOf(localBossHealth, localBossMaximum); + const rivalBossPercent = percentOf(pvp.opponentBossHp, pvp.opponentBossMaxHp); + const activeCurses = ROGUELIKE_PVP_CURSE_ORDER.filter((curseId) => (pvp.receivedCurseRanks[curseId] ?? 0) > 0); + const abilities = HEALER_CLASSES[healerClassId].abilities; + + return ( +
+
+ Competitive runRift Ledger + Round {pvp.round} + {labelToken(pvp.connectionStatus)} +
+ +
+
+
Your formationRound {pvp.round}
+
Boss
+
Party
+
+ +
+
{pvp.opponentName}Round {pvp.opponentRound}
+
Boss
+
Party
+
+
+ +
+
Enemy sabotageActive burdens{activeCurses.length}
+ {activeCurses.length > 0 ? ( +
+ {activeCurses.map((curseId) => { + const curse = ROGUELIKE_PVP_CURSES[curseId]; + const rank = pvp.receivedCurseRanks[curseId] ?? 0; + const ability = abilities[curse.abilitySlotId]; + return ( +
+ {curse.icon} + {ability.shortName} · Rank {rank}/{curse.maxRank}{curse.name} + {formatRoguelikePvpCurseEffect(curseId, rank, ability.shortName)} +
+ ); + })} +
+ ) : ( +
No active burdensFirst rival curse arrives after round clear.
+ )} +
+ +
+ Your progress + Rival progress + {labelToken(pvp.status)} +
+
+ ); +} + +/** Top-display intermission projection while drafting on the lower surface. */ +export function RoguelikePvpDraftWaitingOverlay({ className = "" }: { className?: string }) { + const pvp = useGameStore((state) => state.roguelikePvp); + const remainingSeconds = useDeadlineSeconds(pvp.draftDeadlineAtMs); + return ( +
+ + Round {pvp.round} cleared +

{pvp.localDraftLocked ? "Draft sealed" : "Choose boon and burden"}

+

{pvp.localDraftLocked + ? pvp.opponentDraftLocked ? "Both players locked. Revealing choices…" : `Waiting for ${pvp.opponentName} to lock.` + : "Use lower display to empower your build and sabotage your rival."}

+ + {pvp.opponentDraftLocked ? `${pvp.opponentName} locked` : `${pvp.opponentName} choosing`} +
+ ); +} diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index 41a5b81..6a47166 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -16,6 +16,11 @@ import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanic import { isSingleScreenLayout } from "../platform/displayLayout"; import { ABILITY_ORDER } from "../game/data"; import { AbilityButton } from "./AbilityButton"; +import { + RoguelikePvpDraftWaitingOverlay, + RoguelikePvpStatusStrip, +} from "./RoguelikePvpPanels"; +import { requestDisplaySurface } from "../platform/displayRouting"; const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })))); GameScene.displayName = "MemoizedGameScene"; @@ -170,6 +175,7 @@ function PhaseOverlay() { const blockbreaker = useGameStore((state) => state.blockbreaker); const aetherAssault = useGameStore((state) => state.aetherAssault); const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const roguelikePvp = useGameStore((state) => state.roguelikePvp); const pvpCountdownSeconds = useHockeyPvpCountdownSeconds( activityMode === "hockey-healing-pvp" && phase === "briefing", hockeyPvp.countdownEndsAtMs, @@ -178,10 +184,16 @@ function PhaseOverlay() { hockeyPvp.postMatchStatus === "requeueing", hockeyPvp.postMatchQueueEndsAtMs, ); + const roguelikePvpCountdownSeconds = useHockeyPvpCountdownSeconds( + runMode === "roguelike-pvp" && phase === "briefing", + roguelikePvp.countdownEndsAtMs, + ); const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); const singleScreen = isSingleScreenLayout(); if (runMode === "rpg-roguelike") return null; - if (phase === "intermission") return ; + if (phase === "intermission") return runMode === "roguelike-pvp" + ? + : ; const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const room = bossRoomFor(primaryBoss.id); @@ -190,6 +202,7 @@ function PhaseOverlay() { const blockbreakerMode = activityMode === "blockbreaker"; const aetherAssaultMode = activityMode === "aether-assault"; const pvpMode = activityMode === "hockey-healing-pvp"; + const roguelikePvpMode = runMode === "roguelike-pvp"; const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode; const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode; const briefingMode = hockeyMode @@ -200,20 +213,22 @@ function PhaseOverlay() { ? "Endless Arcade Assault" : pvpMode ? `Versus ${hockeyPvp.opponentName}` + : roguelikePvpMode + ? `Versus ${roguelikePvp.opponentName}` : runMode === "rogue-trials" ? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round" : bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial; if (phase === "combat") return null; const title = phase === "briefing" - ? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name + ? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : roguelikePvpMode ? "Roguelike PVP" : room.name : phase === "victory" - ? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken` - : hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken"; + ? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode || roguelikePvpMode ? "Match Won" : `${bossNames} Broken` + : hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode || roguelikePvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken"; const eyebrow = phase === "briefing" - ? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}` + ? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : roguelikePvpMode ? `${briefingMode} · Mirrored Rift` : `${briefingMode} · ${room.biome}` : phase === "victory" - ? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete" - : hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed"; + ? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : roguelikePvpMode ? `Round ${round} · Rift Race Victory` : "Encounter Complete" + : hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : roguelikePvpMode ? `Round ${round} · Rift Race Defeat` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed"; const copy = phase === "briefing" ? hockeyMode ? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced." @@ -223,9 +238,11 @@ function PhaseOverlay() { ? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses." : pvpMode ? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly." + : roguelikePvpMode + ? "Two five-person parties face the same seeded encounters. After each clear, claim one blessing and secretly send one ability curse to your rival. Last formation standing wins." : definitions.map((boss) => boss.briefing).join(" ") : phase === "victory" - ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured." + ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : roguelikePvpMode ? `${roguelikePvp.opponentName}'s formation fell first.` : "Five entered. Five endured." : hockeyMode ? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.` : blockbreakerMode @@ -233,24 +250,28 @@ function PhaseOverlay() { : aetherAssaultMode ? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.` : pvpMode ? `${hockeyPvp.opponentName} kept their party standing.` + : roguelikePvpMode ? `${roguelikePvp.opponentName} kept their formation alive through round ${round}.` : 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}` + const competitivePvpBriefing = pvpMode || roguelikePvpMode; + const competitivePvpCountdown = roguelikePvpMode ? roguelikePvpCountdownSeconds : pvpCountdownSeconds; + const briefingPrompt = competitivePvpBriefing + ? competitivePvpCountdown > 0 + ? `Match starts automatically in ${competitivePvpCountdown}` : "Match starting now" : singleScreen ? "Press Start / Enter to begin" : "Begin from lower display"; const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); + const roguelikePvpEnded = roguelikePvpMode && (phase === "victory" || phase === "defeat"); return (
{eyebrow}

{title}

{copy}

- {pvpMode && phase === "briefing" &&
+ {competitivePvpBriefing && phase === "briefing" &&
Match starts in - {pvpCountdownSeconds} + {competitivePvpCountdown} seconds
} {pvpEnded && <> @@ -279,8 +300,8 @@ function PhaseOverlay() { {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"} + ? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "D-pad chooses · Confirm selects · Menu exits" : roguelikePvpEnded ? roguelikePvp.role === "cpu" ? "Press Start / Enter to run again" : "Press Back / Menu to exit" : "Press Start / Enter to restart" + : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : roguelikePvpEnded ? "Choose next action on lower display" : "Restart from lower display"}
); } @@ -440,6 +461,7 @@ export function TopScreen({ const blockbreaker = useGameStore((state) => state.blockbreaker); const aetherAssault = useGameStore((state) => state.aetherAssault); const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const roguelikePvp = useGameStore((state) => state.roguelikePvp); const time = useGameStore((state) => state.time); const setPaused = useGameStore((state) => state.setPaused); const rpgRun = useGameStore((state) => state.rpgRun); @@ -451,8 +473,20 @@ export function TopScreen({ const blockbreakerMode = activityMode === "blockbreaker"; const aetherAssaultMode = activityMode === "aether-assault"; const pvpMode = activityMode === "hockey-healing-pvp"; + const roguelikePvpMode = runMode === "roguelike-pvp"; + const onlineRoguelikePvp = roguelikePvpMode && roguelikePvp.role !== "cpu"; const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`; + + const previousPhaseRef = useRef(phase); + useEffect(() => { + const previousPhase = previousPhaseRef.current; + previousPhaseRef.current = phase; + if (!roguelikePvpMode || !isSingleScreenLayout()) return; + if (phase === "intermission" && previousPhase !== "intermission") requestDisplaySurface("bottom"); + if (previousPhase === "intermission" && phase === "combat") requestDisplaySurface("top"); + }, [phase, roguelikePvpMode]); + return (
}> @@ -462,7 +496,9 @@ export function TopScreen({
-
{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}–${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
+ {roguelikePvpMode && phase === "combat" + ? + :
{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}–${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
} @@ -470,7 +506,7 @@ export function TopScreen({ {onExit && }
diff --git a/src/frontend/data.ts b/src/frontend/data.ts index b75a717..b2066d3 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -117,11 +117,11 @@ export const MODE_COPY: Record; + opponentSelection?: Required; +} + interface TokenStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -246,6 +311,79 @@ export class OnlineRepository { body: JSON.stringify({ generation }), }); } + + joinRoguelikePvpQueue( + slotId: SaveSlotId, + hunterName: string, + healerClassId: HealerClassId, + ): Promise { + return this.request("/api/roguelike-pvp/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "roguelike-pvp", slotId, hunterName, healerClassId }), + }); + } + + pollRoguelikePvpQueue(ticketId: string): Promise { + return this.request(`/api/roguelike-pvp/queue/${encodeURIComponent(ticketId)}`); + } + + cancelRoguelikePvpQueue(ticketId: string): Promise { + return this.request(`/api/roguelike-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" }); + } + + exchangeRoguelikePvpState( + matchId: string, + generation: number, + snapshot: RoguelikePvpWireSnapshot, + ): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/state`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation, snapshot }), + }); + } + + openRoguelikePvpDraft(matchId: string, generation: number, round: number): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/drafts/${round}/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), + }); + } + + pollRoguelikePvpDraft(matchId: string, generation: number, round: number): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/drafts/${round}?generation=${generation}`); + } + + submitRoguelikePvpDraft( + matchId: string, + generation: number, + round: number, + selection: RoguelikePvpDraftSelection, + ): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/drafts/${round}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation, selection }), + }); + } + + requestRoguelikePvpRematch(matchId: string, generation: number): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/rematch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), + }); + } + + cancelRoguelikePvpRematch(matchId: string, generation: number): Promise { + return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/rematch`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ generation }), + }); + } } export const onlineRepository = new OnlineRepository(); diff --git a/src/frontend/roguelikePvpMatchmaking.test.ts b/src/frontend/roguelikePvpMatchmaking.test.ts new file mode 100644 index 0000000..8f70b06 --- /dev/null +++ b/src/frontend/roguelikePvpMatchmaking.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + startRoguelikePvpMatchmaking, + startRoguelikePvpRematch, +} from "./roguelikePvpMatchmaking"; + +describe("Roguelike PVP matchmaking", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("falls back offline to a CPU match after five seconds with a shared countdown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const operation = startRoguelikePvpMatchmaking({ + slotId: 2, + hunterName: "Aelia", + healerClassId: "druid", + online: false, + random: () => 0.5, + cpuName: () => "CPU Briar", + }); + + await vi.advanceTimersByTimeAsync(5_000); + await expect(operation.result).resolves.toMatchObject({ + matchId: null, + mode: "roguelike-pvp", + generation: 1, + role: "cpu", + opponentName: "CPU Briar", + opponentHealerClassId: "druid", + countdownEndsAtMs: 11_000, + }); + }); + + it("queues with slot, hunter, and healer class then polls an isolated online match", async () => { + vi.useFakeTimers(); + const joinRoguelikePvpQueue = vi.fn().mockResolvedValue({ + ticketId: "ticket-1", + status: "waiting", + }); + const pollRoguelikePvpQueue = vi.fn().mockResolvedValue({ + ticketId: "ticket-1", + status: "matched", + match: { + id: "match-1", + mode: "roguelike-pvp", + seed: 42, + generation: 1, + countdownEndsAtMs: 8_000, + opponentName: "Rival", + opponentHealerClassId: "shaman", + role: "host", + }, + }); + const operation = startRoguelikePvpMatchmaking({ + slotId: 3, + hunterName: "Willow", + healerClassId: "paladin", + online: true, + pollMs: 350, + repository: { + joinRoguelikePvpQueue, + pollRoguelikePvpQueue, + cancelRoguelikePvpQueue: vi.fn().mockResolvedValue(undefined), + }, + }); + + await vi.advanceTimersByTimeAsync(350); + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-1", + seed: 42, + opponentName: "Rival", + opponentHealerClassId: "shaman", + role: "host", + }); + expect(joinRoguelikePvpQueue).toHaveBeenCalledWith(3, "Willow", "paladin"); + expect(pollRoguelikePvpQueue).toHaveBeenCalledWith("ticket-1"); + }); + + it("cancels the server ticket when the CPU timeout wins", async () => { + vi.useFakeTimers(); + const cancelRoguelikePvpQueue = vi.fn().mockResolvedValue(undefined); + const operation = startRoguelikePvpMatchmaking({ + slotId: 1, + hunterName: "Aelia", + healerClassId: "priest", + online: true, + timeoutMs: 5_000, + random: () => 0.25, + repository: { + joinRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-timeout", status: "waiting" }), + pollRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-timeout", status: "waiting" }), + cancelRoguelikePvpQueue, + }, + }); + + await vi.advanceTimersByTimeAsync(5_000); + await operation.result; + expect(cancelRoguelikePvpQueue).toHaveBeenCalledWith("ticket-timeout"); + }); + + it("keeps a match returned by an in-flight poll at the CPU fallback boundary", async () => { + vi.useFakeTimers(); + let resolveBoundaryPoll!: (value: { + ticketId: string; + status: "matched"; + match: { + id: string; + mode: "roguelike-pvp"; + seed: number; + generation: number; + countdownEndsAtMs: number; + opponentName: string; + opponentHealerClassId: "shaman"; + role: "guest"; + }; + }) => void; + const pollRoguelikePvpQueue = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveBoundaryPoll = resolve; + })); + const cancelRoguelikePvpQueue = vi.fn().mockResolvedValue(undefined); + const operation = startRoguelikePvpMatchmaking({ + slotId: 1, + hunterName: "Aelia", + healerClassId: "priest", + online: true, + timeoutMs: 5_000, + pollMs: 4_999, + repository: { + joinRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-boundary", status: "waiting" }), + pollRoguelikePvpQueue, + cancelRoguelikePvpQueue, + }, + }); + + await vi.advanceTimersByTimeAsync(4_999); + expect(pollRoguelikePvpQueue).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + resolveBoundaryPoll({ + ticketId: "ticket-boundary", + status: "matched", + match: { + id: "match-boundary", + mode: "roguelike-pvp", + seed: 73, + generation: 1, + countdownEndsAtMs: 9_000, + opponentName: "Boundary Rival", + opponentHealerClassId: "shaman", + role: "guest", + }, + }); + + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-boundary", + seed: 73, + role: "guest", + }); + expect(cancelRoguelikePvpQueue).not.toHaveBeenCalled(); + }); + + it("launches online when atomic cancellation reports a just-paired match", async () => { + vi.useFakeTimers(); + const cancelRoguelikePvpQueue = vi.fn().mockResolvedValue({ + ticketId: "ticket-cancel-match", + status: "matched", + match: { + id: "match-cancel", + mode: "roguelike-pvp", + seed: 91, + generation: 1, + countdownEndsAtMs: 10_000, + opponentName: "Cancel Rival", + opponentHealerClassId: "chronomancer", + role: "host", + }, + }); + const operation = startRoguelikePvpMatchmaking({ + slotId: 2, + hunterName: "Willow", + healerClassId: "paladin", + online: true, + timeoutMs: 5_000, + pollMs: 10_000, + repository: { + joinRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-cancel-match", status: "waiting" }), + pollRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-cancel-match", status: "waiting" }), + cancelRoguelikePvpQueue, + }, + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-cancel", + seed: 91, + role: "host", + }); + expect(cancelRoguelikePvpQueue).toHaveBeenCalledWith("ticket-cancel-match"); + }); + + it("recovers a match when cancellation fails because pairing won the race", async () => { + vi.useFakeTimers(); + const pollRoguelikePvpQueue = vi.fn() + .mockResolvedValueOnce({ ticketId: "ticket-race", status: "waiting" }) + .mockResolvedValueOnce({ + ticketId: "ticket-race", + status: "matched", + match: { + id: "match-race", + mode: "roguelike-pvp", + seed: 117, + generation: 1, + countdownEndsAtMs: 10_000, + opponentName: "Race Rival", + opponentHealerClassId: "druid", + role: "guest", + }, + }); + const cancelRoguelikePvpQueue = vi.fn().mockRejectedValue(new Error("Matched queue cannot be cancelled.")); + const operation = startRoguelikePvpMatchmaking({ + slotId: 3, + hunterName: "Aelia", + healerClassId: "priest", + online: true, + timeoutMs: 5_000, + pollMs: 10_000, + repository: { + joinRoguelikePvpQueue: vi.fn().mockResolvedValue({ ticketId: "ticket-race", status: "waiting" }), + pollRoguelikePvpQueue, + cancelRoguelikePvpQueue, + }, + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-race", + seed: 117, + role: "guest", + }); + expect(pollRoguelikePvpQueue).toHaveBeenCalledTimes(2); + }); + + it("polls until both players lock an online rematch generation", async () => { + vi.useFakeTimers(); + const requestRoguelikePvpRematch = vi.fn() + .mockResolvedValueOnce({ status: "waiting" }) + .mockResolvedValueOnce({ + status: "matched", + match: { + id: "match-1", + mode: "roguelike-pvp", + seed: 84, + generation: 2, + countdownEndsAtMs: 12_000, + opponentName: "Rival", + opponentHealerClassId: "chronomancer", + role: "guest", + }, + }); + const operation = startRoguelikePvpRematch({ + matchId: "match-1", + generation: 1, + pollMs: 350, + repository: { + requestRoguelikePvpRematch, + cancelRoguelikePvpRematch: vi.fn().mockResolvedValue(undefined), + }, + }); + + await vi.advanceTimersByTimeAsync(350); + await expect(operation.result).resolves.toMatchObject({ + matchId: "match-1", + generation: 2, + seed: 84, + role: "guest", + }); + expect(requestRoguelikePvpRematch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/frontend/roguelikePvpMatchmaking.ts b/src/frontend/roguelikePvpMatchmaking.ts new file mode 100644 index 0000000..9dbf8af --- /dev/null +++ b/src/frontend/roguelikePvpMatchmaking.ts @@ -0,0 +1,298 @@ +import type { HealerClassId } from "../game/types"; +import { + onlineRepository, + type OnlineRepository, + type RoguelikePvpOnlineMatch, + type RoguelikePvpOnlineRole, + type RoguelikePvpQueueResult, +} from "./onlineRepository"; +import type { SaveSlotId } from "./types"; + +export const ROGUELIKE_PVP_COUNTDOWN_MS = 5_000; +export const ROGUELIKE_PVP_QUEUE_TIMEOUT_MS = 5_000; + +const CPU_NAMES = ["CPU Aster", "CPU Briar", "CPU Cinder", "CPU Rowan", "CPU Willow"] as const; + +export type RoguelikePvpRole = RoguelikePvpOnlineRole | "cpu"; + +export interface RoguelikePvpMatchConfig { + matchId: string | null; + mode: "roguelike-pvp"; + seed: number; + generation: number; + countdownEndsAtMs: number; + opponentName: string; + opponentHealerClassId: HealerClassId; + role: RoguelikePvpRole; +} + +type QueueRepository = Pick & { + cancelRoguelikePvpQueue: ( + ticketId: string, + ) => Promise; + }; +type RematchRepository = Pick; + +export interface RoguelikePvpMatchOperation { + result: Promise; + cancel: () => void; +} + +export function randomRoguelikePvpCpuName(random: () => number = Math.random): string { + const index = Math.min(CPU_NAMES.length - 1, Math.max(0, Math.floor(random() * CPU_NAMES.length))); + return CPU_NAMES[index]; +} + +export function onlineRoguelikePvpMatchConfig(match: RoguelikePvpOnlineMatch): RoguelikePvpMatchConfig { + return { + matchId: match.id, + mode: match.mode, + seed: match.seed, + generation: match.generation, + countdownEndsAtMs: match.countdownEndsAtMs, + opponentName: match.opponentName, + opponentHealerClassId: match.opponentHealerClassId, + role: match.role, + }; +} + +export function startRoguelikePvpMatchmaking(options: { + slotId: SaveSlotId; + hunterName: string; + healerClassId: HealerClassId; + online: boolean; + repository?: QueueRepository; + timeoutMs?: number; + pollMs?: number; + onElapsed?: (elapsedMs: number) => void; + onOnlineUnavailable?: () => void; + random?: () => number; + cpuName?: () => string; + cpuHealerClassId?: HealerClassId; +}): RoguelikePvpMatchOperation { + const repository = options.repository ?? onlineRepository; + const timeoutMs = options.timeoutMs ?? ROGUELIKE_PVP_QUEUE_TIMEOUT_MS; + const pollMs = options.pollMs ?? 350; + const random = options.random ?? Math.random; + const cpuName = options.cpuName ?? (() => randomRoguelikePvpCpuName(random)); + const startedAt = Date.now(); + let active = true; + let ticketId: string | null = null; + let pollTimer: ReturnType | null = null; + let fallbackTimer: ReturnType | null = null; + let clockTimer: ReturnType | null = null; + let joinTask: Promise | null = null; + let activePoll: Promise | null = null; + let fallbackStarted = false; + let settle: (match: RoguelikePvpMatchConfig | 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: RoguelikePvpMatchConfig | null) => { + if (!active) return; + active = false; + clearTimers(); + settle(match); + }; + const cancelTicket = () => { + const currentTicketId = ticketId; + ticketId = null; + if (currentTicketId) void repository.cancelRoguelikePvpQueue(currentTicketId).catch(() => undefined); + }; + const finishCpuMatch = () => { + if (!active) return; + finish({ + matchId: null, + mode: "roguelike-pvp", + seed: Math.max(1, Math.floor(random() * 0xffffffff)), + generation: 1, + countdownEndsAtMs: Date.now() + ROGUELIKE_PVP_COUNTDOWN_MS, + opponentName: cpuName(), + opponentHealerClassId: options.cpuHealerClassId ?? options.healerClassId, + role: "cpu", + }); + }; + const finishOnlineMatch = (queued: RoguelikePvpQueueResult | void): boolean => { + if (!queued?.match) return false; + ticketId = null; + finish(onlineRoguelikePvpMatchConfig(queued.match)); + return true; + }; + const fallbackToCpu = async () => { + if (!active || fallbackStarted) return; + fallbackStarted = true; + if (pollTimer !== null) clearTimeout(pollTimer); + pollTimer = null; + + if (!options.online) { + finishCpuMatch(); + return; + } + + await joinTask; + if (!active) return; + await activePoll; + if (!active) return; + + const currentTicketId = ticketId; + if (!currentTicketId) { + finishCpuMatch(); + return; + } + + try { + const finalPoll = await repository.pollRoguelikePvpQueue(currentTicketId); + if (!active || finishOnlineMatch(finalPoll)) return; + } catch { + options.onOnlineUnavailable?.(); + } + if (!active) return; + + try { + const cancellation = await repository.cancelRoguelikePvpQueue(currentTicketId); + if (!active || finishOnlineMatch(cancellation)) return; + if (ticketId === currentTicketId) ticketId = null; + finishCpuMatch(); + return; + } catch { + options.onOnlineUnavailable?.(); + } + if (!active) return; + + // A matched ticket cannot be cancelled. Re-read it before choosing CPU so a + // server-side match created at the timeout boundary is never abandoned. + try { + const recovered = await repository.pollRoguelikePvpQueue(currentTicketId); + if (!active || finishOnlineMatch(recovered)) return; + } catch { + options.onOnlineUnavailable?.(); + } + if (ticketId === currentTicketId) ticketId = null; + finishCpuMatch(); + }; + const result = new Promise((resolve) => { + settle = resolve; + fallbackTimer = setTimeout(() => { void fallbackToCpu(); }, timeoutMs); + if (options.onElapsed) { + options.onElapsed(0); + clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100); + } + if (!options.online) return; + joinTask = (async () => { + try { + const joined = await repository.joinRoguelikePvpQueue( + options.slotId, + options.hunterName, + options.healerClassId, + ); + if (!active) { + if (!joined.match) void repository.cancelRoguelikePvpQueue(joined.ticketId).catch(() => undefined); + return; + } + ticketId = joined.ticketId; + if (joined.match) { + finish(onlineRoguelikePvpMatchConfig(joined.match)); + return; + } + const poll = async () => { + if (!active || fallbackStarted || !ticketId) return; + try { + const queued = await repository.pollRoguelikePvpQueue(ticketId); + if (!active) return; + if (queued.match) { + finish(onlineRoguelikePvpMatchConfig(queued.match)); + return; + } + } catch { + options.onOnlineUnavailable?.(); + } + if (active && !fallbackStarted) { + pollTimer = setTimeout(() => { + const pending = poll(); + activePoll = pending; + void pending.finally(() => { + if (activePoll === pending) activePoll = null; + }); + }, pollMs); + } + }; + if (!fallbackStarted) { + pollTimer = setTimeout(() => { + const pending = poll(); + activePoll = pending; + void pending.finally(() => { + if (activePoll === pending) activePoll = null; + }); + }, pollMs); + } + } catch { + options.onOnlineUnavailable?.(); + } + })(); + }); + + return { + result, + cancel: () => { + if (!active) return; + cancelTicket(); + finish(null); + }, + }; +} + +export function startRoguelikePvpRematch(options: { + matchId: string; + generation: number; + repository?: RematchRepository; + pollMs?: number; + onUnavailable?: () => void; +}): RoguelikePvpMatchOperation { + const repository = options.repository ?? onlineRepository; + const pollMs = options.pollMs ?? 350; + let active = true; + let pollTimer: ReturnType | null = null; + let settle: (match: RoguelikePvpMatchConfig | null) => void = () => undefined; + const result = new Promise((resolve) => { + settle = resolve; + const poll = async () => { + if (!active) return; + try { + const rematch = await repository.requestRoguelikePvpRematch(options.matchId, options.generation); + if (!active) { + void repository.cancelRoguelikePvpRematch(options.matchId, options.generation).catch(() => undefined); + return; + } + if (rematch.match) { + active = false; + if (pollTimer !== null) clearTimeout(pollTimer); + settle(onlineRoguelikePvpMatchConfig(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.cancelRoguelikePvpRematch(options.matchId, options.generation).catch(() => undefined); + settle(null); + }, + }; +} diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index ce070ff..599dbcf 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -55,7 +55,7 @@ describe("SaveRepository", () => { expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); expect(repository.listLocal()[0].local?.updatedAt).toBe(now); expect(repository.listLocal()[0].local).toMatchObject({ - schemaVersion: 6, + schemaVersion: 7, stats: { highestAetherAssaultScore: 0, highestAetherAssaultWaveAtBest: 0, @@ -112,7 +112,7 @@ describe("SaveRepository", () => { storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(6); + expect(migrated.schemaVersion).toBe(7); expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z"); expect(migrated.healers.priest.level).toBe(37); for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) { @@ -186,7 +186,7 @@ describe("SaveRepository", () => { expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand); }); - it("resets every legacy save into fresh v6 progression while preserving identity and timestamp", () => { + it("resets every legacy save into fresh v7 progression while preserving identity and timestamp", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Legacy"); @@ -208,7 +208,7 @@ describe("SaveRepository", () => { storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(6); + expect(migrated.schemaVersion).toBe(7); expect(migrated.hunterName).toBe("Legacy"); expect(migrated.activeClassId).toBe("priest"); expect(migrated.playSeconds).toBe(0); @@ -226,6 +226,9 @@ describe("SaveRepository", () => { hockeyHealingPvpWins: 0, hockeyHealingPvpLosses: 0, hockeyHealingPvpBossKills: 0, + roguelikePvpWins: 0, + roguelikePvpLosses: 0, + highestRoguelikePvpRound: 0, highestBlockbreakerBricks: 0, longestBlockbreakerSeconds: 0, highestBlockbreakerScore: 0, @@ -236,7 +239,7 @@ describe("SaveRepository", () => { expect(migrated.materials).toEqual([]); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); - expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(6); + expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(7); }); it("preserves valid v5 progression and group-drop inventory", () => { @@ -260,7 +263,7 @@ describe("SaveRepository", () => { storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(6); + expect(migrated.schemaVersion).toBe(7); expect(migrated.healers.priest.level).toBe(8); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14); @@ -288,7 +291,7 @@ describe("SaveRepository", () => { storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(6); + expect(migrated.schemaVersion).toBe(7); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); diff --git a/src/frontend/saveRepository.ts b/src/frontend/saveRepository.ts index 73cfc41..6cad49c 100644 --- a/src/frontend/saveRepository.ts +++ b/src/frontend/saveRepository.ts @@ -122,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null { if (!value || typeof value !== "object") return null; const candidate = value as LegacyHunterSave; if (!candidate.slotId || !candidate.hunterName) return null; - if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6) { + if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6 && candidate.schemaVersion !== 7) { try { return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName); } catch { @@ -133,7 +133,7 @@ function normalizeSave(value: unknown): HunterSave | null { const bossKills = normalizeBossKills(candidate.stats?.bossKills); const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest"; return { - schemaVersion: 6, + schemaVersion: 7, slotId: candidate.slotId, hunterName: candidate.hunterName, activeClassId, @@ -158,6 +158,9 @@ function normalizeSave(value: unknown): HunterSave | null { hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)), hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)), hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)), + roguelikePvpWins: Math.max(0, Math.floor(candidate.stats?.roguelikePvpWins ?? 0)), + roguelikePvpLosses: Math.max(0, Math.floor(candidate.stats?.roguelikePvpLosses ?? 0)), + highestRoguelikePvpRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikePvpRound ?? 0)), highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)), longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0), highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)), diff --git a/src/frontend/store.ts b/src/frontend/store.ts index ad50a4b..4a341e1 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -31,7 +31,7 @@ import { infusionsForOwner, } from "../game/progression/infusions"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; -import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats"; +import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch, roguelikePvpRecordAfterMatch } from "../game/progression/hunterStats"; import { cloneCharacterAppearance, type CharacterAppearanceV1, @@ -187,6 +187,7 @@ export interface FrontendState { recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void; recordHockeyPvpResult: (won: boolean) => void; recordHockeyPvpBossKill: () => void; + recordRoguelikePvpResult: (won: boolean, round: number) => void; recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void; recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void; clearRecentRewards: () => void; @@ -668,6 +669,31 @@ export const useFrontendStore = create((set, get) => ({ markSaveSyncPending(activeSlotId); set((state) => ({ slots: refreshLocalSlots(state.slots) })); }, + recordRoguelikePvpResult: (won, round) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => { + const record = roguelikePvpRecordAfterMatch( + save.stats.roguelikePvpWins, + save.stats.roguelikePvpLosses, + save.stats.highestRoguelikePvpRound, + won, + round, + ); + return { + ...save, + stats: { + ...save.stats, + roguelikePvpWins: record.wins, + roguelikePvpLosses: record.losses, + highestRoguelikePvpRound: record.highestRound, + }, + }; + }); + if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, recordBlockbreakerDefeat: (bricks, durationSeconds, score) => { const { activeSlotId } = get(); if (!activeSlotId) return; @@ -771,6 +797,7 @@ export type FrontendSnapshot = Omit { it("records the reached defeat round without lowering a previous best", () => { @@ -46,6 +46,13 @@ describe("Healing Hockey PVP records", () => { }); }); +describe("Roguelike PVP records", () => { + it("increments the result and preserves the highest reached round", () => { + expect(roguelikePvpRecordAfterMatch(2, 3, 7, true, 11)).toEqual({ wins: 3, losses: 3, highestRound: 11 }); + expect(roguelikePvpRecordAfterMatch(3, 3, 11, false, 4)).toEqual({ wins: 3, losses: 4, highestRound: 11 }); + }); +}); + describe("Blockbreaker records", () => { it("keeps bricks, duration, and score as independent lifetime highs", () => { expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({ diff --git a/src/game/progression/hunterStats.ts b/src/game/progression/hunterStats.ts index 486f7d5..05ef9a8 100644 --- a/src/game/progression/hunterStats.ts +++ b/src/game/progression/hunterStats.ts @@ -40,6 +40,23 @@ export function hockeyPvpRecordAfterMatch(currentWins: number, currentLosses: nu return won ? { wins: wins + 1, losses } : { wins, losses: losses + 1 }; } +export function roguelikePvpRecordAfterMatch( + currentWins: number, + currentLosses: number, + currentHighestRound: number, + won: boolean, + reachedRound: number, +) { + const record = hockeyPvpRecordAfterMatch(currentWins, currentLosses, won); + return { + ...record, + highestRound: Math.max( + Math.max(0, Math.floor(Number(currentHighestRound) || 0)), + Math.max(1, Math.floor(Number(reachedRound) || 1)), + ), + }; +} + export interface BlockbreakerRecords { bricks: number; durationSeconds: number; diff --git a/src/game/roguelikePvp.test.ts b/src/game/roguelikePvp.test.ts new file mode 100644 index 0000000..2033db8 --- /dev/null +++ b/src/game/roguelikePvp.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { compileRunModifiers } from "./roguelike"; +import { + ROGUELIKE_PVP_CURSE_MAX_RANK, + ROGUELIKE_PVP_CURSE_ORDER, + ROGUELIKE_PVP_CURSES, + compileRoguelikePvpCurses, + createSeededRandom, + formatRoguelikePvpCurseEffect, + increaseRoguelikePvpCurseRank, + isCompatibleRoguelikePvpBossRound, + roguelikePvpAbilityCooldown, + roguelikePvpAbilityManaCost, + roguelikePvpBossCountForRound, + roguelikePvpBossesForRound, + roguelikePvpCurseRank, + selectCpuRoguelikePvpDraft, + selectRoguelikePvpCurseDraft, + type RoguelikePvpCurseRanks, +} from "./roguelikePvp"; + +describe("Roguelike PVP domain", () => { + it("defines two rank-three curses for every ability slot", () => { + expect(ROGUELIKE_PVP_CURSE_ORDER).toHaveLength(12); + expect(new Set(ROGUELIKE_PVP_CURSE_ORDER)).toHaveLength(12); + expect(ROGUELIKE_PVP_CURSE_ORDER.every((id) => ROGUELIKE_PVP_CURSES[id].maxRank === 3)).toBe(true); + }); + + it("clamps persisted curse ranks and never increments beyond cap", () => { + const overRanked: RoguelikePvpCurseRanks = { + "ability1-mana-cost": 99, + "ability2-cooldown": -4, + "ability3-mana-cost": Number.NaN, + }; + + expect(roguelikePvpCurseRank(overRanked, "ability1-mana-cost")).toBe(ROGUELIKE_PVP_CURSE_MAX_RANK); + expect(roguelikePvpCurseRank(overRanked, "ability2-cooldown")).toBe(0); + expect(roguelikePvpCurseRank(overRanked, "ability3-mana-cost")).toBe(0); + expect(increaseRoguelikePvpCurseRank(overRanked, "ability1-mana-cost")["ability1-mana-cost"]).toBe(99); + + let ranks: RoguelikePvpCurseRanks = {}; + for (let index = 0; index < 5; index += 1) { + ranks = increaseRoguelikePvpCurseRank(ranks, "ability4-cooldown"); + } + expect(ranks["ability4-cooldown"]).toBe(3); + expect(formatRoguelikePvpCurseEffect("ability4-cooldown", 3, "Purify")).toBe("+95% Purify cooldown"); + }); + + it("offers only non-maxed curses and supports deterministic injected randomness", () => { + const maxed = Object.fromEntries( + ROGUELIKE_PVP_CURSE_ORDER.map((id) => [id, ROGUELIKE_PVP_CURSE_MAX_RANK]), + ) as RoguelikePvpCurseRanks; + maxed["ability6-cooldown"] = 2; + + expect(selectRoguelikePvpCurseDraft(maxed, () => 0)).toEqual(["ability6-cooldown"]); + maxed["ability6-cooldown"] = 3; + expect(selectRoguelikePvpCurseDraft(maxed, () => 0)).toEqual([]); + + const first = selectRoguelikePvpCurseDraft({}, createSeededRandom(8128)); + const second = selectRoguelikePvpCurseDraft({}, createSeededRandom(8128)); + expect(first).toEqual(second); + expect(first).toHaveLength(3); + expect(new Set(first)).toHaveLength(3); + }); + + it("composes positive run buffs with per-slot cost and cooldown curses", () => { + const runModifiers = compileRunModifiers({ + "mend-efficiency": 1, + "radiance-cooldown": 1, + }); + const curses = compileRoguelikePvpCurses({ + "ability1-mana-cost": 2, + "ability5-cooldown": 1, + "ability6-mana-cost": 3, + }); + + expect(roguelikePvpAbilityManaCost("ability1", 100, runModifiers, curses)).toBe(118); + expect(roguelikePvpAbilityManaCost("ability2", 30, runModifiers, curses)).toBe(30); + expect(roguelikePvpAbilityManaCost("ability6", 20, runModifiers, curses)).toBe(40); + expect(roguelikePvpAbilityManaCost("ability6", 0, runModifiers, curses)).toBe(0); + expect(roguelikePvpAbilityCooldown("ability5", 20, runModifiers, curses)).toBeCloseTo(20); + expect(roguelikePvpAbilityCooldown("ability2", 10, runModifiers, curses)).toBe(10); + }); + + it("derives mirrored compatible boss rounds from the shared seed", () => { + for (let round = 1; round <= 20; round += 1) { + const host = roguelikePvpBossesForRound(4242, round); + const guest = roguelikePvpBossesForRound(4242, round); + expect(host).toEqual(guest); + expect(host).toHaveLength(roguelikePvpBossCountForRound(round)); + expect(new Set(host)).toHaveLength(host.length); + expect(isCompatibleRoguelikePvpBossRound(host)).toBe(true); + } + }); + + it("uses a boss trio every fifth round and pairs on all other rounds", () => { + expect(Array.from({ length: 12 }, (_, index) => roguelikePvpBossCountForRound(index + 1))).toEqual([ + 2, 2, 2, 2, 3, + 2, 2, 2, 2, 3, + 2, 2, + ]); + }); + + it("selects CPU buff and curse submissions deterministically", () => { + const buffs = ["mend-echo", "renew-duration", "barrier-regen"] as const; + const curses = ["ability1-mana-cost", "ability3-cooldown", "ability6-cooldown"] as const; + const first = selectCpuRoguelikePvpDraft(99, 7, buffs, curses); + const second = selectCpuRoguelikePvpDraft(99, 7, buffs, curses); + + expect(first).toEqual(second); + expect(first.round).toBe(7); + expect(buffs).toContain(first.buffId); + expect(curses).toContain(first.curseId); + expect(selectCpuRoguelikePvpDraft(99, 7, [], [])).toEqual({ round: 7, buffId: null, curseId: null }); + }); +}); diff --git a/src/game/roguelikePvp.ts b/src/game/roguelikePvp.ts new file mode 100644 index 0000000..e8f9997 --- /dev/null +++ b/src/game/roguelikePvp.ts @@ -0,0 +1,322 @@ +import { canAddBossToEncounter } from "./bossSelection"; +import { + runAbilityCooldown, + runAbilityManaCost, + selectRunBuffDraft, + selectUnseenBosses, + type CompiledRunModifiers, +} from "./roguelike"; +import type { + AbilitySlotId, + BossId, + HealerClassId, + RunBuffId, + RunBuffRanks, +} from "./types"; + +export const ROGUELIKE_PVP_ABILITY_SLOTS = [ + "ability1", + "ability2", + "ability3", + "ability4", + "ability5", + "ability6", +] as const satisfies readonly AbilitySlotId[]; + +export const ROGUELIKE_PVP_CURSE_MAX_RANK = 3; +export const ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK = 1.25; +export const ROGUELIKE_PVP_TRIO_CADENCE = 5; + +export type RoguelikePvpCurseEffectKind = "mana-cost" | "cooldown"; +export type RoguelikePvpCurseId = `${AbilitySlotId}-${RoguelikePvpCurseEffectKind}`; +export type RoguelikePvpCurseRanks = Partial>; +export type RoguelikePvpRole = "cpu" | "host" | "guest"; +export type RoguelikePvpStatus = "inactive" | "countdown" | "combat" | "drafting" | "won" | "lost"; + +export interface RoguelikePvpCurseDefinition { + id: RoguelikePvpCurseId; + abilitySlotId: AbilitySlotId; + effectKind: RoguelikePvpCurseEffectKind; + name: string; + icon: string; + summary: string; + detail: string; + accent: string; + maxRank: typeof ROGUELIKE_PVP_CURSE_MAX_RANK; +} + +export interface CompiledRoguelikePvpCurses { + manaCostMultipliers: Record; + cooldownMultipliers: Record; +} + +export interface RoguelikePvpMatchConfig { + matchId: string | null; + seed: number; + generation?: number; + opponentName: string; + opponentHealerClassId?: HealerClassId; + role: RoguelikePvpRole; + countdownEndsAtMs?: number; +} + +export interface RoguelikePvpBossProgress { + id: BossId; + hp: number; + maxHp: number; +} + +export interface RoguelikePvpProgress { + round: number; + bossesDefeated: number; + livingPartyMembers: number; + partyHpPercent?: number; + bosses: readonly RoguelikePvpBossProgress[]; +} + +export interface RoguelikePvpDraftChoices { + round: number; + buffChoices: readonly RunBuffId[]; + curseChoices: readonly RoguelikePvpCurseId[]; +} + +/** The curse in a submission always targets the opposing party. */ +export interface RoguelikePvpDraftSubmission { + round: number; + buffId: RunBuffId | null; + curseId: RoguelikePvpCurseId | null; +} + +export interface RoguelikePvpDraftReveal { + round: number; + local: RoguelikePvpDraftSubmission; + opponent: RoguelikePvpDraftSubmission; +} + +export interface RoguelikePvpRemoteSnapshot { + sequence: number; + time: number; + status: RoguelikePvpStatus; + progress: RoguelikePvpProgress; + buffRanks: RunBuffRanks; + curseRanks: RoguelikePvpCurseRanks; + draftSubmission: RoguelikePvpDraftSubmission | null; +} + +const DEFAULT_ABILITY_NAMES: Record = { + ability1: "Ability 1", + ability2: "Ability 2", + ability3: "Ability 3", + ability4: "Ability 4", + ability5: "Ability 5", + ability6: "Ability 6", +}; + +const curse = ( + abilitySlotId: AbilitySlotId, + effectKind: RoguelikePvpCurseEffectKind, +): RoguelikePvpCurseDefinition => { + const id = `${abilitySlotId}-${effectKind}` as RoguelikePvpCurseId; + const abilityName = DEFAULT_ABILITY_NAMES[abilitySlotId]; + const manaCost = effectKind === "mana-cost"; + return { + id, + abilitySlotId, + effectKind, + name: `${abilityName} ${manaCost ? "Burden" : "Delay"}`, + icon: manaCost ? "△" : "◷", + summary: `+25% ${manaCost ? "mana cost" : "cooldown"} per rank`, + detail: `${abilityName} ${manaCost ? "mana cost" : "cooldown"} is multiplied by 1.25 per rank.`, + accent: manaCost ? "#ff8b70" : "#d88cff", + maxRank: ROGUELIKE_PVP_CURSE_MAX_RANK, + }; +}; + +const ROGUELIKE_PVP_CURSE_DEFINITIONS = ROGUELIKE_PVP_ABILITY_SLOTS.flatMap((abilitySlotId) => [ + curse(abilitySlotId, "mana-cost"), + curse(abilitySlotId, "cooldown"), +]); + +export const ROGUELIKE_PVP_CURSE_ORDER = ROGUELIKE_PVP_CURSE_DEFINITIONS.map(({ id }) => id); + +export const ROGUELIKE_PVP_CURSES = Object.fromEntries( + ROGUELIKE_PVP_CURSE_DEFINITIONS.map((definition) => [definition.id, definition]), +) as Record; + +function safeRank(value: number | undefined) { + return Number.isFinite(value) ? Math.max(0, Math.floor(value ?? 0)) : 0; +} + +function safeUnitSample(random: () => number) { + const sample = random(); + return Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0; +} + +function normalizedRound(round: number) { + return Number.isFinite(round) ? Math.max(1, Math.floor(round)) : 1; +} + +function normalizedSeed(seed: number) { + return Number.isFinite(seed) ? Math.floor(Math.abs(seed)) >>> 0 : 0; +} + +function mixSeed(seed: number, round: number, salt = 0) { + let value = (normalizedSeed(seed) ^ Math.imul(normalizedRound(round), 0x9e3779b9) ^ salt) >>> 0; + value ^= value >>> 16; + value = Math.imul(value, 0x21f0aaad) >>> 0; + value ^= value >>> 15; + value = Math.imul(value, 0x735a2d97) >>> 0; + value ^= value >>> 15; + return value >>> 0; +} + +export function roguelikePvpCurseRank(ranks: RoguelikePvpCurseRanks, curseId: RoguelikePvpCurseId): number { + return Math.min(ROGUELIKE_PVP_CURSES[curseId].maxRank, safeRank(ranks[curseId])); +} + +export function increaseRoguelikePvpCurseRank( + ranks: RoguelikePvpCurseRanks, + curseId: RoguelikePvpCurseId, +): RoguelikePvpCurseRanks { + const current = roguelikePvpCurseRank(ranks, curseId); + if (current >= ROGUELIKE_PVP_CURSES[curseId].maxRank) return { ...ranks }; + return { ...ranks, [curseId]: current + 1 }; +} + +export function compileRoguelikePvpCurses(ranks: RoguelikePvpCurseRanks): CompiledRoguelikePvpCurses { + const manaCostMultipliers = {} as Record; + const cooldownMultipliers = {} as Record; + for (const abilitySlotId of ROGUELIKE_PVP_ABILITY_SLOTS) { + manaCostMultipliers[abilitySlotId] = ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK + ** roguelikePvpCurseRank(ranks, `${abilitySlotId}-mana-cost`); + cooldownMultipliers[abilitySlotId] = ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK + ** roguelikePvpCurseRank(ranks, `${abilitySlotId}-cooldown`); + } + return { manaCostMultipliers, cooldownMultipliers }; +} + +export function formatRoguelikePvpCurseEffect( + curseId: RoguelikePvpCurseId, + requestedRank: number, + abilityName?: string, +): string { + const rank = Math.max(1, Math.min(ROGUELIKE_PVP_CURSE_MAX_RANK, safeRank(requestedRank))); + const definition = ROGUELIKE_PVP_CURSES[curseId]; + const increase = Math.round((ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK ** rank - 1) * 100); + const name = abilityName ?? DEFAULT_ABILITY_NAMES[definition.abilitySlotId]; + return `+${increase}% ${name} ${definition.effectKind === "mana-cost" ? "mana cost" : "cooldown"}`; +} + +export function selectRoguelikePvpCurseDraft( + ranks: RoguelikePvpCurseRanks, + random: () => number = Math.random, + count = 3, +): RoguelikePvpCurseId[] { + const pool = ROGUELIKE_PVP_CURSE_ORDER.filter( + (id) => roguelikePvpCurseRank(ranks, id) < ROGUELIKE_PVP_CURSES[id].maxRank, + ); + const choices: RoguelikePvpCurseId[] = []; + const requestedCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; + while (choices.length < requestedCount && pool.length > 0) { + const index = Math.floor(safeUnitSample(random) * pool.length); + choices.push(pool[index]); + pool.splice(index, 1); + } + return choices; +} + +export function selectRoguelikePvpDraftChoices( + round: number, + buffRanks: RunBuffRanks, + curseRanks: RoguelikePvpCurseRanks, + passiveInfusionId: RunBuffId | null = null, + random: () => number = Math.random, + count = 3, + allowedBuffIds?: readonly RunBuffId[], +): RoguelikePvpDraftChoices { + const allowed = allowedBuffIds ? new Set(allowedBuffIds) : null; + const buffChoices = allowed + ? selectRunBuffDraft(buffRanks, passiveInfusionId, random, Number.MAX_SAFE_INTEGER) + .filter((buffId) => allowed.has(buffId)) + .slice(0, count) + : selectRunBuffDraft(buffRanks, passiveInfusionId, random, count); + return { + round: normalizedRound(round), + buffChoices, + curseChoices: selectRoguelikePvpCurseDraft(curseRanks, random, count), + }; +} + +/** Mulberry32 PRNG. Same numeric seed produces the same platform-independent sequence. */ +export function createSeededRandom(seed: number): () => number { + let state = normalizedSeed(seed); + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 0x100000000; + }; +} + +export function roguelikePvpBossCountForRound(round: number) { + return normalizedRound(round) % ROGUELIKE_PVP_TRIO_CADENCE === 0 ? 3 : 2; +} + +/** Both match peers can derive an identical, compatible encounter from seed + round. */ +export function roguelikePvpBossesForRound(seed: number, round: number): BossId[] { + const normalized = normalizedRound(round); + return selectUnseenBosses( + roguelikePvpBossCountForRound(normalized), + [], + createSeededRandom(mixSeed(seed, normalized, 0xb055)), + ); +} + +export function roguelikePvpAbilityManaCost( + abilitySlotId: AbilitySlotId, + baseCost: number, + runModifiers: CompiledRunModifiers, + curses: CompiledRoguelikePvpCurses, +): number { + const buffedCost = runAbilityManaCost(abilitySlotId, baseCost, runModifiers); + if (buffedCost <= 0) return 0; + return Math.max(1, Math.ceil(buffedCost * curses.manaCostMultipliers[abilitySlotId])); +} + +export function roguelikePvpAbilityCooldown( + abilitySlotId: AbilitySlotId, + baseCooldown: number, + runModifiers: CompiledRunModifiers, + curses: CompiledRoguelikePvpCurses, +): number { + return runAbilityCooldown(abilitySlotId, baseCooldown, runModifiers) + * curses.cooldownMultipliers[abilitySlotId]; +} + +export function selectCpuRoguelikePvpDraft( + seed: number, + round: number, + buffChoices: readonly RunBuffId[], + curseChoices: readonly RoguelikePvpCurseId[], +): RoguelikePvpDraftSubmission { + const normalized = normalizedRound(round); + const random = createSeededRandom(mixSeed(seed, normalized, 0xc0ffee)); + const pick = (choices: readonly T[]): T | null => choices.length + ? choices[Math.floor(random() * choices.length)] + : null; + return { + round: normalized, + buffId: pick(buffChoices), + curseId: pick(curseChoices), + }; +} + +export function isCompatibleRoguelikePvpBossRound(bossIds: readonly BossId[]) { + const selected: BossId[] = []; + for (const bossId of bossIds) { + if (!canAddBossToEncounter(selected, bossId)) return false; + selected.push(bossId); + } + return true; +} diff --git a/src/game/roguelikePvpStore.test.ts b/src/game/roguelikePvpStore.test.ts new file mode 100644 index 0000000..23716d0 --- /dev/null +++ b/src/game/roguelikePvpStore.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createClassInventory } from "./healers"; +import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot } from "./roguelikePvp"; +import { useGameStore } from "./store"; + +const CPU_MATCH = { + matchId: null, + seed: 73_421, + generation: 1, + opponentName: "CPU Rowan", + opponentHealerClassId: "paladin" as const, + role: "cpu" as const, + countdownEndsAtMs: 0, +}; + +function configureCpuMatch() { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + CPU_MATCH, + ); +} + +describe("Roguelike PVP store integration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + configureCpuMatch(); + }); + + it("starts both racers on the same deterministic seeded boss pair", () => { + const state = useGameStore.getState(); + expect([state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]) + .toEqual(roguelikePvpBossesForRound(CPU_MATCH.seed, 1)); + expect(state.roguelikePvp.opponentBossHp).toBe(state.roguelikePvp.opponentBossMaxHp); + expect(state.roguelikePvp.opponentName).toBe("CPU Rowan"); + expect(state.roguelikePvp.opponentHealerClassId).toBe("paladin"); + expect(state.passiveRunBuffId).toBeNull(); + }); + + it("honors the shared countdown before combat", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, countdownEndsAtMs: 5_000 }, + ); + useGameStore.getState().startEncounter(); + expect(useGameStore.getState().phase).toBe("briefing"); + now.mockReturnValue(5_000); + useGameStore.getState().startEncounter(); + expect(useGameStore.getState().phase).toBe("combat"); + }); + + it("applies received mana-cost and cooldown burdens to the matching ability slot", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + roguelikePvp: { + ...state.roguelikePvp, + receivedCurseRanks: { + "ability5-mana-cost": 1, + "ability5-cooldown": 1, + }, + }, + })); + const before = useGameStore.getState(); + expect(before.castAbility("ability5")).toBe(true); + const after = useGameStore.getState(); + expect(before.mana - after.mana).toBe(15); + expect(after.cooldowns.ability5 - before.time).toBe(17.5); + }); + + it("locks one blessing and one burden, reveals CPU choices, then starts next round", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + roguelikePvp: { ...state.roguelikePvp, opponentBossHp: 0 }, + })); + useGameStore.getState().tick(0.01); + const draft = useGameStore.getState().roguelikePvp; + expect(useGameStore.getState().phase).toBe("intermission"); + expect(draft.buffChoices).toHaveLength(3); + expect(draft.curseChoices).toHaveLength(3); + expect(useGameStore.getState().submitRoguelikePvpDraft()).toBe(true); + + const next = useGameStore.getState(); + expect(next.phase).toBe("combat"); + expect(next.round).toBe(2); + expect(next.runBuffRanks[draft.selectedBuffId!]).toBe(1); + expect(next.roguelikePvp.sentCurseRanks[draft.selectedCurseId!]).toBe(1); + expect(Object.values(next.roguelikePvp.receivedCurseRanks)).toContain(1); + }); + + it("does not offer Paladin blessings whose underlying Rogue Trials effect is a no-op", () => { + useGameStore.getState().configureHealer( + "paladin", + "Aelia", + createClassInventory("paladin"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, seed: 2 }, + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + })); + useGameStore.getState().tick(0.01); + const unsupported = new Set([ + "renew-spread", "renew-duration", "renew-potency", + "shield-echo", "shield-potency", "shield-guard", + "radiance-cooldown", "radiance-renew", "radiance-shield", + "barrier-regen", + ]); + expect(useGameStore.getState().roguelikePvp.buffChoices.every((buffId) => !unsupported.has(buffId))).toBe(true); + }); + + it("requires the full five-person formation to fall", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + party: state.party.map((member) => ({ ...member, hp: member.id === "aelia" ? member.hp : 0 })), + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().phase).toBe("combat"); + + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ ...member, hp: 0 })), + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().phase).toBe("defeat"); + expect(useGameStore.getState().roguelikePvp.status).toBe("lost"); + }); + + it("auto-locks default online draft choices when the reveal timer expires", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, matchId: "rift-timer", role: "host" }, + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + })); + useGameStore.getState().tick(0.01); + const deadline = useGameStore.getState().roguelikePvp.draftDeadlineAtMs; + vi.spyOn(Date, "now").mockReturnValue(deadline); + useGameStore.getState().tick(0.01); + + const draft = useGameStore.getState().roguelikePvp; + expect(draft.localDraftLocked).toBe(true); + expect(draft.draftStep).toBe("review"); + expect(draft.selectedBuffId).toBe(draft.buffChoices[0]); + expect(draft.selectedCurseId).toBe(draft.curseChoices[0]); + }); + + it("keeps an online wipe provisional until the server adjudicates simultaneous losses", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, matchId: "rift-wipe", role: "host" }, + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ ...member, hp: 0 })), + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().phase).toBe("combat"); + expect(useGameStore.getState().roguelikePvp.status).toBe("lost"); + + useGameStore.getState().resolveRoguelikePvpMatch(true); + expect(useGameStore.getState().phase).toBe("victory"); + expect(useGameStore.getState().roguelikePvp.status).toBe("won"); + }); + + it("ends an online match when remote status reports defeat", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, matchId: "rift-1", role: "host" }, + ); + useGameStore.getState().startEncounter(); + const remote: RoguelikePvpRemoteSnapshot = { + sequence: 1, + time: 1, + status: "lost", + progress: { + round: 1, + bossesDefeated: 0, + livingPartyMembers: 0, + partyHpPercent: 0, + bosses: [{ id: useGameStore.getState().boss.id, hp: 100, maxHp: 100 }], + }, + buffRanks: {}, + curseRanks: {}, + draftSubmission: null, + }; + useGameStore.getState().applyRoguelikePvpRemoteSnapshot(remote); + expect(useGameStore.getState().phase).toBe("victory"); + expect(useGameStore.getState().roguelikePvp.status).toBe("won"); + + useGameStore.getState().applyRoguelikePvpRemoteSnapshot({ + ...remote, + sequence: 2, + status: "won", + progress: { + ...remote.progress, + livingPartyMembers: 5, + partyHpPercent: 100, + }, + }); + expect(useGameStore.getState().phase).toBe("victory"); + expect(useGameStore.getState().roguelikePvp.status).toBe("won"); + }); + + it("does not trust an opponent victory claim or an inconsistent defeat claim", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "roguelike-pvp", + undefined, + "initiate", + { ...CPU_MATCH, matchId: "rift-2", role: "guest" }, + ); + useGameStore.getState().startEncounter(); + const state = useGameStore.getState(); + const base: RoguelikePvpRemoteSnapshot = { + sequence: 1, + time: 1, + status: "won", + progress: { + round: 1, + bossesDefeated: 0, + livingPartyMembers: 5, + partyHpPercent: 100, + bosses: [{ id: state.boss.id, hp: 100, maxHp: 100 }], + }, + buffRanks: {}, + curseRanks: {}, + draftSubmission: null, + }; + useGameStore.getState().applyRoguelikePvpRemoteSnapshot(base); + expect(useGameStore.getState().phase).toBe("combat"); + + useGameStore.getState().applyRoguelikePvpRemoteSnapshot({ + ...base, + sequence: 2, + status: "lost", + }); + expect(useGameStore.getState().phase).toBe("combat"); + }); +}); diff --git a/src/game/runModes.ts b/src/game/runModes.ts index 4c9ad20..035479d 100644 --- a/src/game/runModes.ts +++ b/src/game/runModes.ts @@ -6,7 +6,7 @@ export function isPvpRunMode(runMode: RunMode): boolean { } export function defaultGameplayActivity(runMode: RunMode): GameplayActivity { - if (runMode === "hockey-healing" || runMode === "hockey-healing-pvp" || runMode === "blockbreaker" || runMode === "aether-assault") { + if (runMode === "roguelike-pvp" || runMode === "hockey-healing" || runMode === "hockey-healing-pvp" || runMode === "blockbreaker" || runMode === "aether-assault") { return runMode; } return "boss"; diff --git a/src/game/store.ts b/src/game/store.ts index 726dd2e..003682d 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -45,6 +45,7 @@ import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState"; import { + RUN_BUFF_ORDER, RUN_BUFFS, bossHealthMultiplier, compileRunModifiers, @@ -86,6 +87,25 @@ import { type HockeyPvpRemoteSnapshot, type HockeyPvpState, } from "./hockeyHealingPvp"; +import { + ROGUELIKE_PVP_CURSES, + compileRoguelikePvpCurses, + createSeededRandom, + increaseRoguelikePvpCurseRank, + roguelikePvpAbilityCooldown, + roguelikePvpAbilityManaCost, + roguelikePvpBossesForRound, + selectCpuRoguelikePvpDraft, + selectRoguelikePvpDraftChoices, + type RoguelikePvpCurseId, + type RoguelikePvpCurseRanks, + type RoguelikePvpDraftReveal, + type RoguelikePvpDraftSubmission, + type RoguelikePvpMatchConfig, + type RoguelikePvpRemoteSnapshot, + type RoguelikePvpRole, + type RoguelikePvpStatus, +} from "./roguelikePvp"; import type { ActiveCast, AbilityLoadout, @@ -165,6 +185,39 @@ export interface HockeyPvpOpponentState { partyCombat: PartyCombatState; } +export type RoguelikePvpConnectionStatus = "cpu" | "connecting" | "online" | "disconnected"; + +export interface RoguelikePvpState { + matchId: string | null; + seed: number; + generation: number; + role: RoguelikePvpRole; + opponentName: string; + opponentHealerClassId: HealerClassId; + status: RoguelikePvpStatus; + round: number; + countdownEndsAtMs: number; + buffChoices: RunBuffId[]; + curseChoices: RoguelikePvpCurseId[]; + selectedBuffId: RunBuffId | null; + selectedCurseId: RoguelikePvpCurseId | null; + draftStep: "buff" | "curse" | "review"; + draftDeadlineAtMs: number; + localDraftLocked: boolean; + opponentDraftLocked: boolean; + opponentRound: number; + opponentBossHp: number; + opponentBossMaxHp: number; + opponentPartyHpPercent: number; + connectionStatus: RoguelikePvpConnectionStatus; + receivedCurseRanks: RoguelikePvpCurseRanks; + sentCurseRanks: RoguelikePvpCurseRanks; + opponentBuffRanks: RunBuffRanks; + opponentDraftSubmission: RoguelikePvpDraftSubmission | null; + networkSequence: number; + nextCpuHealAt: number; +} + export interface RpgSpellResources { verdancy: number; tidalSurge: number; @@ -200,6 +253,7 @@ export interface GameState { aetherAssault: AetherAssaultState; hockeyPvp: HockeyPvpState; hockeyPvpOpponent: HockeyPvpOpponentState; + roguelikePvp: RoguelikePvpState; runBuffRanks: RunBuffRanks; draftBuffIds: RunBuffId[]; selectedRunBuffId: RunBuffId | null; @@ -233,7 +287,7 @@ export interface GameState { activeCast: ActiveCast | null; barrier: BarrierState; healerMechanic: HealerMechanicState; - configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => void; + configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => void; startEncounter: () => void; restart: () => void; tick: (delta: number) => void; @@ -247,6 +301,15 @@ export interface GameState { setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void; setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void; applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void; + selectRoguelikePvpBuff: (buffId: RunBuffId) => void; + selectRoguelikePvpCurse: (curseId: RoguelikePvpCurseId) => void; + setRoguelikePvpDraftStep: (step: "buff" | "curse" | "review") => void; + submitRoguelikePvpDraft: () => boolean; + applyRoguelikePvpDraftReveal: (reveal: RoguelikePvpDraftReveal) => boolean; + applyRoguelikePvpRemoteSnapshot: (snapshot: RoguelikePvpRemoteSnapshot) => void; + syncRoguelikePvpDraft: (deadlineAtMs: number, opponentDraftLocked: boolean) => void; + resolveRoguelikePvpMatch: (won: boolean) => void; + setRoguelikePvpConnectionStatus: (status: RoguelikePvpConnectionStatus) => void; setPaused: (paused: boolean) => void; togglePause: () => void; setPauseSelection: (selection: "resume" | "exit") => void; @@ -273,6 +336,7 @@ const emptyCooldowns = (): Record => ({ export const GLOBAL_COOLDOWN_SECONDS = 0.5; export const RUN_BUFF_INPUT_LOCK_MS = 2_500; +export const ROGUELIKE_PVP_DRAFT_SECONDS = 15; export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana"; export const BARRIER_RADIUS = 4; @@ -365,12 +429,58 @@ export function healMember(member: PartyMember, amount: number): PartyMember { return { ...member, hp: Math.min(member.maxHp, member.hp + amount) }; } -function effectiveHealingMultiplier(state: Pick): number { - if (state.runMode !== "hockey-healing-pvp") return state.healingMultiplier; - return state.healingMultiplier * hockeyPvpHealingEffectiveness( - state.endlessBossKills, - state.hockeyPvp.opponentBossKills, - ); +function effectiveHealingMultiplier(state: Pick): number { + if (state.runMode === "hockey-healing-pvp") { + return state.healingMultiplier * hockeyPvpHealingEffectiveness( + state.endlessBossKills, + state.hockeyPvp.opponentBossKills, + ); + } + if (state.runMode === "roguelike-pvp") { + const dampening = Math.min(0.5, Math.max(0, state.round - 5) * 0.05); + return state.healingMultiplier * (1 - dampening); + } + return state.healingMultiplier; +} + +function createRoguelikePvpState( + match: RoguelikePvpMatchConfig | undefined, + round: number, + opponentBossMaxHp: number, + healerClassId: HealerClassId, +): RoguelikePvpState { + const role = match?.role ?? "cpu"; + const countdownEndsAtMs = match?.countdownEndsAtMs ?? 0; + return { + matchId: match?.matchId ?? null, + seed: match?.seed ?? 1, + generation: match?.generation ?? 1, + role, + opponentName: match?.opponentName ?? "CPU Willow", + opponentHealerClassId: match?.opponentHealerClassId ?? healerClassId, + status: countdownEndsAtMs > Date.now() ? "countdown" : "inactive", + round, + countdownEndsAtMs, + buffChoices: [], + curseChoices: [], + selectedBuffId: null, + selectedCurseId: null, + draftStep: "buff", + draftDeadlineAtMs: 0, + localDraftLocked: false, + opponentDraftLocked: false, + opponentRound: round, + opponentBossHp: opponentBossMaxHp, + opponentBossMaxHp, + opponentPartyHpPercent: 100, + connectionStatus: role === "cpu" ? "cpu" : "connecting", + receivedCurseRanks: {}, + sentCurseRanks: {}, + opponentBuffRanks: {}, + opponentDraftSubmission: null, + networkSequence: 0, + nextCpuHealAt: 1.1, + }; } export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) { @@ -547,14 +657,21 @@ function initialState( gearProgress: GearProgress = createDefaultGearProgress(), requestedDifficultySlug: DifficultySlug = "initiate", seenBossIds: readonly BossId[] = [], - hockeyPvpMatch?: HockeyPvpMatchConfig, + pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig, requestedAbilityLoadout?: AbilityLoadout, ) { const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug); const difficulty = DIFFICULTY_BY_SLUG[difficultySlug]; - const bossIds = normalizeBossIds(requestedBossIds); + const hockeyPvpMatch = runMode === "hockey-healing-pvp" ? pvpMatch as HockeyPvpMatchConfig | undefined : undefined; + const roguelikePvpMatch = runMode === "roguelike-pvp" ? pvpMatch as RoguelikePvpMatchConfig | undefined : undefined; + const bossIds = runMode === "roguelike-pvp" + ? roguelikePvpBossesForRound(roguelikePvpMatch?.seed ?? 1, round) + : normalizeBossIds(requestedBossIds); const activityMode = defaultGameplayActivity(runMode); - const hockeyLayout = activityMode !== "boss"; + const hockeyLayout = activityMode === "hockey-healing" + || activityMode === "hockey-healing-pvp" + || activityMode === "blockbreaker" + || activityMode === "aether-assault"; const layout: EncounterLayout = hockeyLayout ? "hockey" : "standard"; const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( bossId, @@ -578,7 +695,7 @@ function initialState( : []; const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers); const opponentParty = applyGearHealth( - freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? "CPU Willow"), + freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? roguelikePvpMatch?.opponentName ?? "CPU Willow"), gearModifiers, ); const opponentBoss = createEncounterBoss( @@ -628,6 +745,12 @@ function initialState( bossMotion: opponentBoss.motion, partyCombat: createPartyCombatState(opponentParty), } as HockeyPvpOpponentState, + roguelikePvp: createRoguelikePvpState( + roguelikePvpMatch, + round, + encounterBosses.reduce((total, entry) => total + entry.boss.maxHp, 0), + healerClassId, + ), runBuffRanks: { ...runBuffRanks }, draftBuffIds, selectedRunBuffId: draftBuffIds[0] ?? null, @@ -637,7 +760,8 @@ function initialState( healingMultiplier: gearModifiers.aelia.healingPower, difficultySlug, difficultyDamageMultiplier: difficulty.damageMultiplier - * (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1), + * (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1) + * (runMode === "roguelike-pvp" ? 1 + Math.max(0, round - 1) * 0.06 : 1), gearProgress, gearModifiers, time: 0, @@ -665,6 +789,165 @@ function initialState( }; } +function roguelikePvpMatchConfig(state: Pick): RoguelikePvpMatchConfig { + const pvp = state.roguelikePvp; + return { + matchId: pvp.matchId, + seed: pvp.seed, + generation: pvp.generation, + opponentName: pvp.opponentName, + opponentHealerClassId: pvp.opponentHealerClassId, + role: pvp.role, + countdownEndsAtMs: 0, + }; +} + +function supportedRoguelikePvpBuffIds(healerClassId: HealerClassId): readonly RunBuffId[] { + if (healerClassId === "priest" || healerClassId === "druid" || healerClassId === "shaman") return RUN_BUFF_ORDER; + const supported = healerClassId === "paladin" + ? new Set([ + "mend-echo", "mend-efficiency", "mend-cast-speed", + "purify-renew", "purify-shield", "purify-chain", + "barrier-cooldown", "barrier-duration", + ]) + : new Set([ + "mend-echo", "mend-efficiency", "mend-cast-speed", + "purify-renew", "purify-shield", "purify-chain", + "radiance-cooldown", "barrier-cooldown", + ]); + return RUN_BUFF_ORDER.filter((buffId) => supported.has(buffId)); +} + +function createNextRoguelikePvpRound( + state: GameState, + reveal: RoguelikePvpDraftReveal, +) { + if (reveal.round !== state.round) return null; + if (reveal.local.buffId !== null && !Object.prototype.hasOwnProperty.call(RUN_BUFFS, reveal.local.buffId)) return null; + if (reveal.opponent.buffId !== null && !Object.prototype.hasOwnProperty.call(RUN_BUFFS, reveal.opponent.buffId)) return null; + if (reveal.local.curseId !== null && !Object.prototype.hasOwnProperty.call(ROGUELIKE_PVP_CURSES, reveal.local.curseId)) return null; + if (reveal.opponent.curseId !== null && !Object.prototype.hasOwnProperty.call(ROGUELIKE_PVP_CURSES, reveal.opponent.curseId)) return null; + if (reveal.local.buffId !== null && !state.roguelikePvp.buffChoices.includes(reveal.local.buffId)) return null; + if (reveal.local.curseId !== null && !state.roguelikePvp.curseChoices.includes(reveal.local.curseId)) return null; + const nextRound = state.round + 1; + const runBuffRanks = reveal.local.buffId + ? increaseRunBuffRank(state.runBuffRanks, reveal.local.buffId) + : { ...state.runBuffRanks }; + const receivedCurseRanks = reveal.opponent.curseId + ? increaseRoguelikePvpCurseRank(state.roguelikePvp.receivedCurseRanks, reveal.opponent.curseId) + : { ...state.roguelikePvp.receivedCurseRanks }; + const sentCurseRanks = reveal.local.curseId + ? increaseRoguelikePvpCurseRank(state.roguelikePvp.sentCurseRanks, reveal.local.curseId) + : { ...state.roguelikePvp.sentCurseRanks }; + const opponentBuffRanks = reveal.opponent.buffId + ? increaseRunBuffRank(state.roguelikePvp.opponentBuffRanks, reveal.opponent.buffId) + : { ...state.roguelikePvp.opponentBuffRanks }; + const bossIds = roguelikePvpBossesForRound(state.roguelikePvp.seed, nextRound); + const base = initialState( + state.healerClassId, + state.playerName, + state.inventory, + bossIds, + "roguelike-pvp", + nextRound, + runBuffRanks, + state.gearProgress, + state.difficultySlug, + state.seenBossIds, + roguelikePvpMatchConfig(state), + state.abilityLoadout, + ); + const opponentBossMaxHp = base.boss.maxHp + + base.additionalBosses.reduce((total, entry) => total + entry.boss.maxHp, 0); + return { + ...base, + phase: "combat" as GamePhase, + activeTab: "combat" as BottomTab, + roguelikePvp: { + ...base.roguelikePvp, + status: "combat" as const, + connectionStatus: state.roguelikePvp.connectionStatus, + receivedCurseRanks, + sentCurseRanks, + opponentBuffRanks, + opponentRound: nextRound, + opponentBossHp: opponentBossMaxHp, + opponentBossMaxHp, + opponentPartyHpPercent: 100, + networkSequence: state.roguelikePvp.networkSequence, + }, + combatLog: [{ + id: Date.now(), + time: 0, + message: `Draft revealed. Round ${nextRound}: ${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")}.`, + tone: "danger" as const, + }], + }; +} + +function cpuRoguelikePvpSubmission(state: Pick): RoguelikePvpDraftSubmission { + const pvp = state.roguelikePvp; + const choices = selectRoguelikePvpDraftChoices( + state.round, + pvp.opponentBuffRanks, + pvp.receivedCurseRanks, + null, + createSeededRandom((pvp.seed ^ Math.imul(state.round, 0x51f15e)) >>> 0), + 3, + supportedRoguelikePvpBuffIds(state.healerClassId), + ); + return selectCpuRoguelikePvpDraft(pvp.seed ^ 0x6c8e9cf5, state.round, choices.buffChoices, choices.curseChoices); +} + +function resolveCpuRoguelikePvpDraft(state: GameState) { + const local: RoguelikePvpDraftSubmission = { + round: state.round, + buffId: state.roguelikePvp.selectedBuffId, + curseId: state.roguelikePvp.selectedCurseId, + }; + return createNextRoguelikePvpRound(state, { + round: state.round, + local, + opponent: cpuRoguelikePvpSubmission(state), + }); +} + +function advanceCpuRoguelikePvp( + state: Pick, + delta: number, +): RoguelikePvpState { + const pvp = state.roguelikePvp; + if (pvp.role !== "cpu" || pvp.opponentBossHp <= 0 || pvp.opponentPartyHpPercent <= 0 || delta <= 0) return pvp; + const compiledCurses = compileRoguelikePvpCurses(pvp.sentCurseRanks); + const manaBurden = Object.values(compiledCurses.manaCostMultipliers).reduce((total, value) => total + value, 0) / 6; + const cooldownBurden = Object.values(compiledCurses.cooldownMultipliers).reduce((total, value) => total + value, 0) / 6; + const burden = Math.sqrt(manaBurden * cooldownBurden); + const buffRanks = Object.values(pvp.opponentBuffRanks).reduce((total, rank) => total + Math.max(0, rank ?? 0), 0); + const blessingPower = 1 + buffRanks * 0.025; + const expectedClearSeconds = Math.max(22, (35 + state.round * 1.8) * burden / blessingPower); + const bossDamage = pvp.opponentBossMaxHp / expectedClearSeconds * delta; + const opponentBossHp = Math.max(0, pvp.opponentBossHp - bossDamage); + let opponentPartyHpPercent = Math.max( + 0, + pvp.opponentPartyHpPercent - (0.9 + state.round * 0.12) * delta, + ); + let nextCpuHealAt = pvp.nextCpuHealAt; + const nextTime = state.time + delta; + while (nextCpuHealAt <= nextTime) { + opponentPartyHpPercent = Math.min(100, opponentPartyHpPercent + 1.45 * blessingPower / burden); + nextCpuHealAt += 1.1; + } + const cleared = opponentBossHp <= 0; + return { + ...pvp, + opponentBossHp, + opponentPartyHpPercent, + nextCpuHealAt, + opponentDraftLocked: cleared ? true : pvp.opponentDraftLocked, + opponentDraftSubmission: cleared ? cpuRoguelikePvpSubmission(state) : pvp.opponentDraftSubmission, + }; +} + function rpgActivityForRun(run: RpgRoguelikeRunState): GameplayActivity { if (run.phase !== "challenge-active" && run.phase !== "challenge-briefing") return "boss"; const challengeId = run.currentChallenge?.objective.challengeId; @@ -799,8 +1082,19 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part export const useGameStore = create((set, get) => ({ ...initialState(), - configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", hockeyPvpMatch) => { - const base = initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug, [], hockeyPvpMatch); + configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", pvpMatch) => { + const resolvedPvpMatch = runMode === "roguelike-pvp" && !pvpMatch + ? { + matchId: null, + seed: (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0, + generation: 1, + opponentName: "CPU Willow", + opponentHealerClassId: healerClassId, + role: "cpu" as const, + countdownEndsAtMs: Date.now() + 3_000, + } + : pvpMatch; + const base = initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug, [], resolvedPvpMatch); if (runMode !== "rpg-roguelike") { set(base); return; @@ -823,13 +1117,16 @@ export const useGameStore = create((set, get) => ({ if (current.runMode === "hockey-healing-pvp" && current.phase === "briefing" && Date.now() < current.hockeyPvp.countdownEndsAtMs) return; + if (current.runMode === "roguelike-pvp" + && current.phase === "briefing" + && Date.now() < current.roguelikePvp.countdownEndsAtMs) return; if (current.runMode === "rpg-roguelike" && current.rpgRun) { if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" }); else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" }); else if (current.rpgRun.phase === "boss-cleared") current.dispatchRpgAction({ type: "reward-open" }); return; } - const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds, hockeyPvp } = get(); + const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds, hockeyPvp, roguelikePvp } = get(); const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; set({ ...initialState( @@ -845,17 +1142,26 @@ export const useGameStore = create((set, get) => ({ seenBossIds, runMode === "hockey-healing-pvp" ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } - : undefined, + : runMode === "roguelike-pvp" + ? { matchId: roguelikePvp.matchId, seed: roguelikePvp.seed, generation: roguelikePvp.generation, opponentName: roguelikePvp.opponentName, opponentHealerClassId: roguelikePvp.opponentHealerClassId, role: roguelikePvp.role, countdownEndsAtMs: roguelikePvp.countdownEndsAtMs } + : undefined, abilityLoadout, ), phase: "combat", activeTab: "combat", + ...(runMode === "roguelike-pvp" ? { + roguelikePvp: { + ...roguelikePvp, + status: "combat" as const, + connectionStatus: roguelikePvp.role === "cpu" ? "cpu" as const : "online" as const, + }, + } : {}), combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], }); }, restart: () => { - const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug, hockeyPvp } = get(); + const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug, hockeyPvp, roguelikePvp } = get(); if (runMode === "rpg-roguelike") { const base = initialState( healerClassId, @@ -884,6 +1190,8 @@ export const useGameStore = create((set, get) => ({ ? selectRandomBossPair() : runMode === "hockey-healing-pvp" ? [hockeyPvpBossAt(hockeyPvp.seed, 0)] + : runMode === "roguelike-pvp" + ? roguelikePvpBossesForRound(roguelikePvp.seed, 1) : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; set(initialState( healerClassId, @@ -898,7 +1206,19 @@ export const useGameStore = create((set, get) => ({ [], runMode === "hockey-healing-pvp" ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } - : undefined, + : runMode === "roguelike-pvp" + ? { + matchId: roguelikePvp.matchId, + seed: roguelikePvp.role === "cpu" + ? Math.max(1, Math.floor(Math.random() * 0xffffffff)) + : roguelikePvp.seed, + generation: roguelikePvp.role === "cpu" ? roguelikePvp.generation + 1 : roguelikePvp.generation, + opponentName: roguelikePvp.opponentName, + opponentHealerClassId: roguelikePvp.opponentHealerClassId, + role: roguelikePvp.role, + countdownEndsAtMs: Date.now() + 3_000, + } + : undefined, abilityLoadout, )); }, @@ -1183,6 +1503,123 @@ export const useGameStore = create((set, get) => ({ }; }), + selectRoguelikePvpBuff: (buffId) => set((state) => { + if (state.runMode !== "roguelike-pvp" + || state.phase !== "intermission" + || state.roguelikePvp.localDraftLocked + || !state.roguelikePvp.buffChoices.includes(buffId)) return state; + return { roguelikePvp: { ...state.roguelikePvp, selectedBuffId: buffId } }; + }), + selectRoguelikePvpCurse: (curseId) => set((state) => { + if (state.runMode !== "roguelike-pvp" + || state.phase !== "intermission" + || state.roguelikePvp.localDraftLocked + || !state.roguelikePvp.curseChoices.includes(curseId)) return state; + return { roguelikePvp: { ...state.roguelikePvp, selectedCurseId: curseId } }; + }), + setRoguelikePvpDraftStep: (draftStep) => set((state) => { + const pvp = state.roguelikePvp; + if (state.runMode !== "roguelike-pvp" || state.phase !== "intermission" || pvp.localDraftLocked) return state; + const buffReady = pvp.selectedBuffId !== null || pvp.buffChoices.length === 0; + const curseReady = pvp.selectedCurseId !== null || pvp.curseChoices.length === 0; + if ((draftStep === "curse" && !buffReady) || (draftStep === "review" && (!buffReady || !curseReady))) return state; + return { roguelikePvp: { ...pvp, draftStep } }; + }), + submitRoguelikePvpDraft: () => { + const state = get(); + const pvp = state.roguelikePvp; + if (state.runMode !== "roguelike-pvp" || state.phase !== "intermission" || pvp.localDraftLocked) return false; + const buffReady = pvp.selectedBuffId !== null || pvp.buffChoices.length === 0; + const curseReady = pvp.selectedCurseId !== null || pvp.curseChoices.length === 0; + if (!buffReady || !curseReady) return false; + const lockedState = { + ...pvp, + localDraftLocked: true, + draftStep: "review" as const, + opponentDraftLocked: pvp.role === "cpu" && pvp.opponentBossHp <= 0 + ? true + : pvp.opponentDraftLocked, + }; + if (pvp.role === "cpu" && pvp.opponentBossHp <= 0) { + const next = resolveCpuRoguelikePvpDraft({ ...state, roguelikePvp: lockedState }); + if (next) set(next); + return Boolean(next); + } + set({ roguelikePvp: lockedState }); + return true; + }, + applyRoguelikePvpDraftReveal: (reveal) => { + const state = get(); + if (state.runMode !== "roguelike-pvp" + || state.roguelikePvp.role === "cpu" + || state.phase !== "intermission" + || !state.roguelikePvp.localDraftLocked) return false; + const next = createNextRoguelikePvpRound(state, reveal); + if (!next) return false; + set(next); + return true; + }, + applyRoguelikePvpRemoteSnapshot: (snapshot) => set((state) => { + const pvp = state.roguelikePvp; + if (state.runMode !== "roguelike-pvp" || pvp.role === "cpu" || snapshot.sequence <= pvp.networkSequence) return state; + const opponentBossHp = snapshot.progress.bosses.reduce((total, boss) => total + Math.max(0, boss.hp), 0); + const opponentBossMaxHp = snapshot.progress.bosses.reduce((total, boss) => total + Math.max(0, boss.maxHp), 0); + const terminal = state.phase === "victory" || state.phase === "defeat" + || pvp.status === "won" || pvp.status === "lost"; + // A peer cannot declare our loss. Local combat or the match server owns that + // outcome. A reported loss is accepted only when its formation is actually at 0. + const opponentLost = !terminal + && snapshot.status === "lost" + && snapshot.progress.livingPartyMembers === 0 + && (snapshot.progress.partyHpPercent ?? 0) <= 0; + return { + phase: opponentLost ? "victory" : state.phase, + roguelikePvp: { + ...pvp, + status: opponentLost ? "won" : pvp.status, + opponentRound: snapshot.progress.round, + opponentBossHp, + opponentBossMaxHp, + opponentPartyHpPercent: Math.max(0, Math.min(100, snapshot.progress.partyHpPercent ?? snapshot.progress.livingPartyMembers * 20)), + opponentDraftLocked: snapshot.draftSubmission !== null, + opponentDraftSubmission: snapshot.draftSubmission, + networkSequence: snapshot.sequence, + connectionStatus: "online", + }, + }; + }), + syncRoguelikePvpDraft: (draftDeadlineAtMs, opponentDraftLocked) => set((state) => { + if (state.runMode !== "roguelike-pvp" || state.phase !== "intermission") return state; + return { + roguelikePvp: { + ...state.roguelikePvp, + draftDeadlineAtMs: Math.max(0, draftDeadlineAtMs), + opponentDraftLocked, + }, + }; + }), + resolveRoguelikePvpMatch: (won) => set((state) => { + if (state.runMode !== "roguelike-pvp" || state.phase === "victory" || state.phase === "defeat") return state; + return { + phase: won ? "victory" : "defeat", + activeCast: null, + roguelikePvp: { + ...state.roguelikePvp, + status: won ? "won" : "lost", + connectionStatus: "disconnected", + }, + combatLog: addLog( + state.combatLog, + state.time, + won ? `${state.roguelikePvp.opponentName} forfeits. PVP victory.` : "Connection forfeited. Match lost.", + won ? "good" : "danger", + ), + }; + }), + setRoguelikePvpConnectionStatus: (connectionStatus) => set((state) => state.runMode === "roguelike-pvp" + ? { roguelikePvp: { ...state.roguelikePvp, connectionStatus } } + : state), + castAbility: (abilitySlotId) => { const state = get(); if (state.phase !== "combat") return false; @@ -1197,7 +1634,14 @@ export const useGameStore = create((set, get) => ({ const spellPower = state.rpgRun ? spellRankPowerMultiplier(state.rpgRun.spellRanks, ability.id) : 1; - const manaCost = runAbilityManaCost(abilitySlotId, ability.mana, state.runModifiers); + const manaCost = state.runMode === "roguelike-pvp" + ? roguelikePvpAbilityManaCost( + abilitySlotId, + ability.mana, + state.runModifiers, + compileRoguelikePvpCurses(state.roguelikePvp.receivedCurseRanks), + ) + : runAbilityManaCost(abilitySlotId, ability.mana, state.runModifiers); const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId); const selected = state.party[selectedIndex]; @@ -1536,9 +1980,17 @@ export const useGameStore = create((set, get) => ({ break; } + const abilityCooldown = state.runMode === "roguelike-pvp" + ? roguelikePvpAbilityCooldown( + abilitySlotId, + ability.cooldown, + state.runModifiers, + compileRoguelikePvpCurses(state.roguelikePvp.receivedCurseRanks), + ) + : runAbilityCooldown(abilitySlotId, ability.cooldown, state.runModifiers); cooldowns[abilitySlotId] = ability.cooldown > 0 ? state.time - + runAbilityCooldown(abilitySlotId, ability.cooldown, state.runModifiers) + + abilityCooldown * (state.rpgRun ? spellRankCooldownMultiplier(state.rpgRun.spellRanks, ability.id) : 1) * state.gearModifiers.aelia.cooldown : 0; @@ -1567,7 +2019,42 @@ export const useGameStore = create((set, get) => ({ tick: (delta) => { const state = get(); - if (state.phase !== "combat" || state.paused || delta <= 0) return; + if (state.paused || delta <= 0) return; + if (state.runMode === "roguelike-pvp" && state.phase === "intermission") { + const elapsed = Math.min(delta, 2); + let roguelikePvp = advanceCpuRoguelikePvp(state, elapsed); + if (!roguelikePvp.localDraftLocked + && roguelikePvp.draftDeadlineAtMs > 0 + && Date.now() >= roguelikePvp.draftDeadlineAtMs) { + roguelikePvp = { + ...roguelikePvp, + selectedBuffId: roguelikePvp.selectedBuffId ?? roguelikePvp.buffChoices[0] ?? null, + selectedCurseId: roguelikePvp.selectedCurseId ?? roguelikePvp.curseChoices[0] ?? null, + draftStep: "review", + localDraftLocked: true, + }; + } + const nextState = { ...state, time: state.time + elapsed, roguelikePvp }; + if (roguelikePvp.opponentPartyHpPercent <= 0) { + set({ + time: nextState.time, + phase: "victory", + roguelikePvp: { ...roguelikePvp, status: "won" }, + combatLog: addLog(state.combatLog, state.time, `${roguelikePvp.opponentName}'s formation falls. PVP victory.`, "good"), + }); + return; + } + if (roguelikePvp.role === "cpu" && roguelikePvp.opponentBossHp <= 0 && roguelikePvp.localDraftLocked) { + const next = resolveCpuRoguelikePvpDraft(nextState); + if (next) { + set(next); + return; + } + } + set({ time: nextState.time, roguelikePvp }); + return; + } + if (state.phase !== "combat") return; const oldTime = state.time; const time = oldTime + Math.min(delta, 2); @@ -1610,6 +2097,7 @@ export const useGameStore = create((set, get) => ({ let endlessBossKills = state.endlessBossKills; let endlessSpawnSequence = state.endlessSpawnSequence; let hockeyPvp = { ...state.hockeyPvp }; + let roguelikePvp = advanceCpuRoguelikePvp(state, time - oldTime); let hockeyPvpOpponent: HockeyPvpOpponentState = { party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })), partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions), @@ -2128,6 +2616,7 @@ export const useGameStore = create((set, get) => ({ const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost"; const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost"; const pvpMode = state.activityMode === "hockey-healing-pvp"; + const roguelikePvpMode = state.runMode === "roguelike-pvp"; const localPvpTeamDefeated = pvpMode && allCompanionsDefeated; const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party); const rpgChallengeActive = rpgRun?.phase === "challenge-active"; @@ -2210,6 +2699,54 @@ export const useGameStore = create((set, get) => ({ } else if (rpgRun?.phase === "boss-cleared") { phase = "combat"; endlessMode = false; + } else if (roguelikePvpMode) { + if (partyWiped) { + const firstOnlineWipe = roguelikePvp.status !== "lost"; + roguelikePvp.status = "lost"; + if (roguelikePvp.role === "cpu") { + phase = "defeat"; + combatLog = addLog(combatLog, time, `${roguelikePvp.opponentName} wins the rift race.`, "danger"); + } else { + // Online loss is provisional until the server freezes a winner. This + // lets the server resolve simultaneous wipes without both peers + // recording defeat or overwriting an already displayed result. + phase = "combat"; + if (firstOnlineWipe) { + combatLog = addLog(combatLog, time, "Formation fell. Awaiting match adjudication.", "danger"); + } + } + } else if (roguelikePvp.role === "cpu" && roguelikePvp.opponentPartyHpPercent <= 0) { + phase = "victory"; + roguelikePvp.status = "won"; + combatLog = addLog(combatLog, time, `${roguelikePvp.opponentName}'s formation falls. PVP victory.`, "good"); + } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { + const choices = selectRoguelikePvpDraftChoices( + state.round, + state.runBuffRanks, + roguelikePvp.sentCurseRanks, + state.passiveRunBuffId, + createSeededRandom((roguelikePvp.seed ^ Math.imul(state.round, 0x7f4a7c15)) >>> 0), + 3, + supportedRoguelikePvpBuffIds(state.healerClassId), + ); + phase = "intermission"; + runBuffInputUnlockAt = 0; + roguelikePvp = { + ...roguelikePvp, + status: "drafting", + round: state.round, + buffChoices: [...choices.buffChoices], + curseChoices: [...choices.curseChoices], + selectedBuffId: choices.buffChoices[0] ?? null, + selectedCurseId: choices.curseChoices[0] ?? null, + draftStep: "buff", + draftDeadlineAtMs: Date.now() + ROGUELIKE_PVP_DRAFT_SECONDS * 1_000, + localDraftLocked: false, + }; + combatLog = addLog(combatLog, time, "Rift cleared. Choose one blessing and one rival burden.", "good"); + } else { + phase = "combat"; + } } else if (pvpMode) { if (localPvpTeamDefeated) { phase = "defeat"; @@ -2270,7 +2807,7 @@ export const useGameStore = create((set, get) => ({ endlessMode, rpgRun, rpgFocusId, - activeTab: pvpMode && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab, + activeTab: (pvpMode || roguelikePvpMode) && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab, endlessBossKills, endlessSpawnSequence, hockey, @@ -2278,6 +2815,7 @@ export const useGameStore = create((set, get) => ({ aetherAssault, hockeyPvp, hockeyPvpOpponent, + roguelikePvp, runBuffInputUnlockAt, mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)), activeCast, @@ -2305,6 +2843,15 @@ export type GameSnapshot = Omit entry.boss)]; + return { + sequence: Date.now(), + time: state.time, + status: state.roguelikePvp.status, + progress: { + round: state.round, + bossesDefeated: bosses.filter((boss) => boss.hp <= 0).length, + livingPartyMembers: state.party.filter((member) => member.hp > 0).length, + partyHpPercent: state.party.reduce((total, member) => total + member.hp / Math.max(1, member.maxHp), 0) / state.party.length * 100, + bosses: bosses.map((boss) => ({ id: boss.id, hp: boss.hp, maxHp: boss.maxHp })), + }, + buffRanks: { ...state.runBuffRanks }, + curseRanks: { ...state.roguelikePvp.receivedCurseRanks }, + draftSubmission: state.roguelikePvp.localDraftLocked + ? { + round: state.round, + buffId: state.roguelikePvp.selectedBuffId, + curseId: state.roguelikePvp.selectedCurseId, + } + : null, + }; +} + export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record) { return Math.max(0, cooldowns[abilitySlotId] - time); } diff --git a/src/game/types.ts b/src/game/types.ts index 4239117..29e91a6 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -94,8 +94,8 @@ export type BossMechanicId = | "soul-siphon"; export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; -export type RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; -export type GameplayActivity = "boss" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; +export type RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "roguelike-pvp" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; +export type GameplayActivity = "boss" | "roguelike-pvp" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; export type RunBuffId = | "mend-echo" | "mend-efficiency" diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index 041dc78..4d64f1e 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -9,6 +9,7 @@ import { getDisplaySurface } from "../platform/displayRouting"; import { isSingleScreenLayout } from "../platform/displayLayout"; import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync"; import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp"; +import { isPvpRunMode } from "./runModes"; function tacticalOverlayOwnsInput() { const store = useGameStore.getState(); @@ -26,6 +27,51 @@ function cycleRunBuff(direction: 1 | -1) { store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); } +function cycleRoguelikePvpDraftChoice(direction: 1 | -1) { + const store = useGameStore.getState(); + const pvp = store.roguelikePvp; + if (pvp.localDraftLocked) return; + if (pvp.draftStep === "buff") { + if (pvp.buffChoices.length === 0) return; + const currentIndex = pvp.selectedBuffId ? pvp.buffChoices.indexOf(pvp.selectedBuffId) : -1; + const nextIndex = currentIndex < 0 + ? direction === 1 ? 0 : pvp.buffChoices.length - 1 + : (currentIndex + direction + pvp.buffChoices.length) % pvp.buffChoices.length; + store.selectRoguelikePvpBuff(pvp.buffChoices[nextIndex]); + return; + } + if (pvp.draftStep !== "curse" || pvp.curseChoices.length === 0) return; + const currentIndex = pvp.selectedCurseId ? pvp.curseChoices.indexOf(pvp.selectedCurseId) : -1; + const nextIndex = currentIndex < 0 + ? direction === 1 ? 0 : pvp.curseChoices.length - 1 + : (currentIndex + direction + pvp.curseChoices.length) % pvp.curseChoices.length; + store.selectRoguelikePvpCurse(pvp.curseChoices[nextIndex]); +} + +function advanceRoguelikePvpDraft() { + const store = useGameStore.getState(); + const pvp = store.roguelikePvp; + if (pvp.localDraftLocked) return; + if (pvp.draftStep === "buff") store.setRoguelikePvpDraftStep("curse"); + else if (pvp.draftStep === "curse") store.setRoguelikePvpDraftStep("review"); + else store.submitRoguelikePvpDraft(); +} + +function retreatRoguelikePvpDraft() { + const store = useGameStore.getState(); + const pvp = store.roguelikePvp; + if (pvp.localDraftLocked) return false; + if (pvp.draftStep === "review") { + store.setRoguelikePvpDraftStep("curse"); + return true; + } + if (pvp.draftStep === "curse") { + store.setRoguelikePvpDraftStep("buff"); + return true; + } + return false; +} + function rpgInputIsGated() { const run = useGameStore.getState().rpgRun; return Boolean(run && run.phase !== "challenge-active" && run.phase !== "boss-combat"); @@ -80,7 +126,14 @@ export function useActionBindings(enabled = true, onExit?: () => void) { return; } if (store.phase === "intermission") { - if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); + if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault(); + if (store.runMode === "roguelike-pvp") { + if (key === "arrowleft" || key === "arrowup") cycleRoguelikePvpDraftChoice(-1); + if (key === "arrowright" || key === "arrowdown") cycleRoguelikePvpDraftChoice(1); + if (key === "enter") advanceRoguelikePvpDraft(); + if (key === "escape" && !retreatRoguelikePvpDraft()) exitRef.current?.(); + return; + } if (isRunBuffInputLocked(store)) return; if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1); @@ -114,6 +167,15 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (key === "escape") exitRef.current?.(); return; } + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "roguelike-pvp") { + if (["enter", "escape"].includes(key)) event.preventDefault(); + if (key === "enter") { + if (store.roguelikePvp.role === "cpu") store.restart(); + else exitRef.current?.(); + } + if (key === "escape") exitRef.current?.(); + return; + } const numberIndex = Number(event.key) - 1; if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) { store.castAbility(ABILITY_ORDER[numberIndex]); @@ -130,12 +192,12 @@ export function useActionBindings(enabled = true, onExit?: () => void) { store.setActiveTab(store.activeTab === "map" ? "combat" : "map"); break; case "i": - if (store.runMode !== "hockey-healing-pvp") { + if (!isPvpRunMode(store.runMode)) { store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack"); } break; case "p": - if (store.runMode === "hockey-healing-pvp") { + if (isPvpRunMode(store.runMode)) { store.setActiveTab(store.activeTab === "pvp" ? "combat" : "pvp"); } break; @@ -144,7 +206,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (store.phase === "victory" || store.phase === "defeat") store.restart(); break; case "escape": - if (store.phase === "combat") store.setPaused(true); + if (store.phase === "combat" && store.runMode === "roguelike-pvp" && store.roguelikePvp.role !== "cpu") exitRef.current?.(); + else if (store.phase === "combat") store.setPaused(true); else exitRef.current?.(); break; } @@ -179,6 +242,13 @@ export function useActionBindings(enabled = true, onExit?: () => void) { return; } if (store.phase === "intermission") { + if (store.runMode === "roguelike-pvp") { + if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRoguelikePvpDraftChoice(-1); + if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRoguelikePvpDraftChoice(1); + if (!repeat && token === "Button0") advanceRoguelikePvpDraft(); + if (!repeat && token === "Button1" && !retreatRoguelikePvpDraft()) exitRef.current?.(); + return; + } if (isRunBuffInputLocked(store)) return; if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1); @@ -210,6 +280,15 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (!repeat && token === "Button1") exitRef.current?.(); return; } + if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "roguelike-pvp") { + if (repeat) return; + if (token === "Button0" || token === "Button9") { + if (store.roguelikePvp.role === "cpu") store.restart(); + else exitRef.current?.(); + } + if (token === "Button1") exitRef.current?.(); + return; + } if (repeat) return; if (token.startsWith("Button")) { const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))]; @@ -221,6 +300,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) { if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) { if (store.phase === "briefing") store.startEncounter(); else if (store.phase === "victory" || store.phase === "defeat") store.restart(); + else if (store.phase === "combat" && store.runMode === "roguelike-pvp" && store.roguelikePvp.role !== "cpu") exitRef.current?.(); else if (store.phase === "combat") store.setPaused(true); } }), [enabled]); diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 9f7b61f..b7d352b 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -11,6 +11,7 @@ import { useForcedThorDisplays } from "./useThorDualScreen"; import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; +import type { RoguelikePvpMatchConfig } from "../game/roguelikePvp"; const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; @@ -70,8 +71,8 @@ export function BottomDisplayApp() { channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); }, []); - const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { - postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, hockeyPvpMatch }); + const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => { + postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, pvpMatch }); }, [postFrontendCommand]); useEffect(() => { @@ -195,6 +196,13 @@ export function BottomDisplayApp() { }, setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }), setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }), + selectRoguelikePvpBuff: (buffId) => postCommand({ name: "selectRoguelikePvpBuff", buffId }), + selectRoguelikePvpCurse: (curseId) => postCommand({ name: "selectRoguelikePvpCurse", curseId }), + setRoguelikePvpDraftStep: (step) => postCommand({ name: "setRoguelikePvpDraftStep", step }), + submitRoguelikePvpDraft: () => { + postCommand({ name: "submitRoguelikePvpDraft" }); + return false; + }, dispatchRpgAction: (action) => { postCommand({ name: "dispatchRpgAction", action }); return false; diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index fefd109..ea59236 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -30,6 +30,8 @@ function snapshot(): BottomGameSnapshot { endlessMode: false, endlessBossKills: 0, endlessChoiceSelection: "continue", + runBuffRanks: {}, + passiveRunBuffId: null, runModifiers: { mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, @@ -92,6 +94,7 @@ function snapshot(): BottomGameSnapshot { bossMotion: createBossMotionState("bulldrome"), partyCombat: createPartyCombatState(freshParty()), }, + roguelikePvp: structuredClone(useGameStore.getState().roguelikePvp), }; } @@ -119,6 +122,10 @@ describe("dual-screen game snapshots", () => { const originalContinue = useGameStore.getState().continueRoguelikeRound; const originalStartEndless = useGameStore.getState().startRogueTrialsEndless; const originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection; + const originalSelectRoguelikePvpBuff = useGameStore.getState().selectRoguelikePvpBuff; + const originalSelectRoguelikePvpCurse = useGameStore.getState().selectRoguelikePvpCurse; + const originalSetRoguelikePvpDraftStep = useGameStore.getState().setRoguelikePvpDraftStep; + const originalSubmitRoguelikePvpDraft = useGameStore.getState().submitRoguelikePvpDraft; const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView; @@ -129,6 +136,10 @@ describe("dual-screen game snapshots", () => { continueRoguelikeRound: () => { calls.push("continue"); return true; }, startRogueTrialsEndless: () => { calls.push("endless"); return true; }, setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); }, + selectRoguelikePvpBuff: (buffId) => { calls.push(`rogue-buff:${buffId}`); }, + selectRoguelikePvpCurse: (curseId) => { calls.push(`rogue-curse:${curseId}`); }, + setRoguelikePvpDraftStep: (step) => { calls.push(`rogue-step:${step}`); }, + submitRoguelikePvpDraft: () => { calls.push("rogue-submit"); return true; }, }); useFrontendStore.setState({ selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, @@ -141,6 +152,10 @@ describe("dual-screen game snapshots", () => { executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "startRogueTrialsEndless" }); executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" }); + executeGameCommand({ name: "selectRoguelikePvpBuff", buffId: "mend-efficiency" }); + executeGameCommand({ name: "selectRoguelikePvpCurse", curseId: "ability1-mana-cost" }); + executeGameCommand({ name: "setRoguelikePvpDraftStep", step: "curse" }); + executeGameCommand({ name: "submitRoguelikePvpDraft" }); executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" }); @@ -150,6 +165,10 @@ describe("dual-screen game snapshots", () => { "continue", "endless", "pvp:requeue", + "rogue-buff:mend-efficiency", + "rogue-curse:ability1-mana-cost", + "rogue-step:curse", + "rogue-submit", "ability:ability3", "passive:shield-guard", "profile-view:stats", @@ -161,6 +180,10 @@ describe("dual-screen game snapshots", () => { continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless, setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection, + selectRoguelikePvpBuff: originalSelectRoguelikePvpBuff, + selectRoguelikePvpCurse: originalSelectRoguelikePvpCurse, + setRoguelikePvpDraftStep: originalSetRoguelikePvpDraftStep, + submitRoguelikePvpDraft: originalSubmitRoguelikePvpDraft, }); useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 287c145..5f159d0 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -10,6 +10,7 @@ import type { GearOwnerId, GearSlotId } from "../game/progression/gear"; import type { DifficultySlug } from "../game/progression/loot"; import type { BossGroupId } from "../game/bossCatalog"; import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp"; +import type { RoguelikePvpCurseId, RoguelikePvpMatchConfig } from "../game/roguelikePvp"; import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike"; import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance"; @@ -31,6 +32,10 @@ export type GameCommand = | { name: "startRogueTrialsEndless" } | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" } | { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection } + | { name: "selectRoguelikePvpBuff"; buffId: RunBuffId } + | { name: "selectRoguelikePvpCurse"; curseId: RoguelikePvpCurseId } + | { name: "setRoguelikePvpDraftStep"; step: "buff" | "curse" | "review" } + | { name: "submitRoguelikePvpDraft" } | { name: "dispatchRpgAction"; action: RpgRoguelikeAction } | { name: "setRpgFocusId"; focusId: string } | { name: "cycleRpgFocus"; direction: 1 | -1 } @@ -79,7 +84,14 @@ export type FrontendCommand = | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "hockeyPvpPostMatch"; action: Exclude } | { name: "exitGame" } - | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig }; + | { + name: "launchGame"; + bossIds: readonly BossId[]; + difficultySlug?: DifficultySlug; + pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig; + /** Legacy Hockey-only field retained for older companion builds. */ + hockeyPvpMatch?: HockeyPvpMatchConfig; + }; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game"; @@ -122,6 +134,10 @@ export function executeGameCommand(command: GameCommand) { case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break; case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break; case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break; + case "selectRoguelikePvpBuff": game.selectRoguelikePvpBuff(command.buffId); break; + case "selectRoguelikePvpCurse": game.selectRoguelikePvpCurse(command.curseId); break; + case "setRoguelikePvpDraftStep": game.setRoguelikePvpDraftStep(command.step); break; + case "submitRoguelikePvpDraft": game.submitRoguelikePvpDraft(); break; case "dispatchRpgAction": game.dispatchRpgAction(command.action); break; case "setRpgFocusId": game.setRpgFocusId(command.focusId); break; case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break; @@ -174,7 +190,12 @@ export function executeFrontendCommand(command: FrontendCommand) { case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); break; case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break; - case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break; + case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { + bossIds: command.bossIds, + difficultySlug: command.difficultySlug, + pvpMatch: command.pvpMatch ?? command.hockeyPvpMatch, + hockeyPvpMatch: command.hockeyPvpMatch, + } })); break; } } @@ -202,6 +223,8 @@ export type BottomGameSnapshot = Pick; const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ - "bossId", "bossInstanceId", "paused", "healerClassId", "abilityLoadout", "phase", "round", "runMode", "activityMode", "rpgRun", "rpgFocusId", "rpgSpellResources", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses", + "bossId", "bossInstanceId", "paused", "healerClassId", "abilityLoadout", "phase", "round", "runMode", "activityMode", "rpgRun", "rpgFocusId", "rpgSpellResources", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runBuffRanks", "passiveRunBuffId", "runModifiers", "time", "party", "boss", "additionalBosses", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", - "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "healerMechanic", "hockey", "blockbreaker", "aetherAssault", "hockeyPvp", "hockeyPvpOpponent", + "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "healerMechanic", "hockey", "blockbreaker", "aetherAssault", "hockeyPvp", "hockeyPvpOpponent", "roguelikePvp", ]; function structurallyEqual(left: unknown, right: unknown): boolean { @@ -270,6 +294,8 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot { endlessMode: state.endlessMode, endlessBossKills: state.endlessBossKills, endlessChoiceSelection: state.endlessChoiceSelection, + runBuffRanks: state.runBuffRanks, + passiveRunBuffId: state.passiveRunBuffId, runModifiers: state.runModifiers, time: state.time, party: state.party, @@ -295,6 +321,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot { aetherAssault: state.aetherAssault, hockeyPvp: state.hockeyPvp, hockeyPvpOpponent: state.hockeyPvpOpponent, + roguelikePvp: state.roguelikePvp, }; } diff --git a/src/styles.css b/src/styles.css index 35cc14e..07f7d28 100644 --- a/src/styles.css +++ b/src/styles.css @@ -249,6 +249,25 @@ html[data-display-layout="single"] .app-header { bottom: auto; } +/* Keep Return in the lower header without covering its rightmost tab. At the + 620 x 540 Thor fallback viewport the contextual surface nearly fills the + screen, so the header explicitly reserves the toggle's controller target. */ +@media (max-width: 820px) { + .single-display-frame.context-open .single-context-toggle { + top: max(24px, env(safe-area-inset-top)); + min-width: 82px; + padding: 6px 8px; + } + + .single-display-frame.context-open .single-context-toggle small { + display: none; + } + + .single-display-frame.context-open .single-context-surface .lower-header { + padding-right: 104px; + } +} + html[data-display-layout="single"] .top-party { width: clamp(185px, 20cqw, 330px); gap: clamp(3px, .55cqh, 7px); @@ -3179,6 +3198,427 @@ html[data-display-layout="single"] .encounter-callout { .mode-loot-preview b { font-size: 10px; } .mode-loot-preview small { color: #71867e; font-size: 7px; } +/* Roguelike PVP — gold blessing / crimson sabotage */ + +.roguelike-pvp-draft, +.roguelike-pvp-tactical, +.roguelike-pvp-status-strip, +.roguelike-pvp-waiting-overlay { + --roguelike-pvp-local: #e8c872; + --roguelike-pvp-local-soft: #68cbb2; + --roguelike-pvp-rival: #ed729f; + --roguelike-pvp-curse: #df675f; +} + +.roguelike-pvp-draft { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + background: + radial-gradient(circle at 18% -10%, rgba(232, 200, 114, .15), transparent 36%), + radial-gradient(circle at 86% 8%, rgba(223, 103, 95, .12), transparent 40%), + linear-gradient(145deg, #0a1714, #060b0a 58%, #170d10); +} + +.roguelike-pvp-draft::before, +.roguelike-pvp-tactical::before { + position: absolute; + inset: 0; + content: ""; + pointer-events: none; + background: repeating-linear-gradient(118deg, rgba(255, 255, 255, .014) 0 1px, transparent 1px 9px); +} + +.roguelike-pvp-draft-header { + position: relative; + z-index: 1; + min-height: 58px; + display: grid; + grid-template-columns: minmax(120px, 1fr) auto minmax(50px, 1fr); + align-items: center; + gap: 12px; + padding: 8px 4.5%; + border-bottom: 1px solid rgba(197, 218, 210, .14); + background: linear-gradient(90deg, rgba(54, 45, 20, .18), rgba(8, 18, 15, .82) 48%, rgba(74, 23, 31, .18)); +} + +.roguelike-pvp-draft-header > span { + color: #a28e59; + font-size: 7px; + font-weight: 700; + letter-spacing: .13em; + text-transform: uppercase; +} + +.roguelike-pvp-draft-header > time { + justify-self: end; + min-width: 42px; + padding: 5px 7px; + border: 1px solid rgba(223, 103, 95, .35); + color: #ffd3cf; + background: rgba(66, 20, 26, .36); + font: 700 13px "Rajdhani", sans-serif; + text-align: center; +} + +.roguelike-pvp-step-rail { + display: grid; + grid-template-columns: repeat(3, minmax(58px, 1fr)); + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.roguelike-pvp-step-rail li { + position: relative; + display: grid; + grid-template-columns: 17px auto; + align-items: center; + justify-content: center; + gap: 4px; + color: #52675f; + font-size: 6px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.roguelike-pvp-step-rail li:not(:last-child)::after { + position: absolute; + top: 50%; + right: -9px; + width: 18px; + height: 1px; + content: ""; + background: #32443e; +} + +.roguelike-pvp-step-rail b { + width: 17px; + height: 17px; + display: grid; + place-items: center; + border: 1px solid #40544d; + border-radius: 50%; + font-size: 7px; +} + +.roguelike-pvp-step-rail li.is-active { color: #f4e7bf; } +.roguelike-pvp-step-rail li.is-active b { border-color: var(--roguelike-pvp-local); color: #0b110e; background: var(--roguelike-pvp-local); box-shadow: 0 0 10px rgba(232, 200, 114, .34); } +.roguelike-pvp-step-rail li.is-complete { color: #70bca8; } +.roguelike-pvp-step-rail li.is-complete b { border-color: #5ea590; color: #96dbc8; background: rgba(34, 91, 75, .35); } + +.roguelike-pvp-draft-body, +.roguelike-pvp-review { + position: relative; + z-index: 1; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; + padding: 13px 4.5% 12px; +} + +.roguelike-pvp-draft-copy { text-align: center; } +.roguelike-pvp-draft-copy > small { color: var(--roguelike-pvp-local); font-size: 7px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; } +.roguelike-pvp-draft-copy.is-curse > small { color: #f18b85; } +.roguelike-pvp-draft-copy h2 { margin: 1px 0; color: #f2f7f4; font: 500 clamp(17px, 3.5cqw, 22px) "Cinzel", serif; } +.roguelike-pvp-draft-copy p { margin: 0; color: #758a82; font-size: clamp(8px, 1.5cqw, 10px); } + +.roguelike-pvp-choice-grid { + min-height: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} + +.roguelike-pvp-choice-grid.choice-count-1 { grid-template-columns: minmax(0, 250px); justify-content: center; } +.roguelike-pvp-choice-grid.choice-count-2 { grid-template-columns: repeat(2, minmax(0, 240px)); justify-content: center; } + +.roguelike-pvp-choice { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: 31px minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); + align-content: start; + gap: 6px 7px; + padding: 10px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 58%); + border-top: 3px solid var(--roguelike-pvp-choice-accent); + color: #deebe6; + background: + linear-gradient(150deg, color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 88%), transparent 48%), + rgba(6, 15, 13, .91); + box-shadow: inset 0 0 24px rgba(255, 255, 255, .018); + text-align: left; + cursor: pointer; + transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease; +} + +.roguelike-pvp-choice.is-curse { background: linear-gradient(150deg, color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 86%), transparent 48%), rgba(19, 9, 12, .93); } +.roguelike-pvp-choice:hover { border-color: var(--roguelike-pvp-choice-accent); } +.roguelike-pvp-choice.is-controller-selected, +.roguelike-pvp-choice:focus-visible { border-color: var(--roguelike-pvp-choice-accent); outline: 2px solid #fff0b8; outline-offset: 2px; transform: translateY(-2px); box-shadow: 0 0 18px color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 75%); } +.roguelike-pvp-choice > i { grid-row: 1 / 3; width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--roguelike-pvp-choice-accent); color: var(--roguelike-pvp-choice-accent); background: rgba(3, 9, 8, .6); font: normal 15px "Cinzel", serif; } +.roguelike-pvp-choice > span { min-width: 0; display: grid; } +.roguelike-pvp-choice small { overflow: hidden; color: var(--roguelike-pvp-choice-accent); font-size: 6px; letter-spacing: .08em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.roguelike-pvp-choice strong { overflow: hidden; font: 600 clamp(9px, 1.7cqw, 11px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-choice > b { grid-column: 1 / -1; color: #e9f1ed; font-size: clamp(8px, 1.45cqw, 9px); line-height: 1.15; } +.roguelike-pvp-choice > p { grid-column: 1 / -1; margin: 0; overflow: hidden; color: #72877f; font-size: clamp(7px, 1.25cqw, 8px); line-height: 1.25; } + +.roguelike-pvp-draft-empty { + grid-column: 1 / -1; + place-self: center; + min-width: min(310px, 100%); + display: grid; + grid-template-columns: 37px 1fr; + align-items: center; + gap: 10px; + padding: 14px; + border: 1px solid rgba(232, 200, 114, .26); + background: rgba(21, 27, 18, .55); +} + +.roguelike-pvp-draft-empty.is-curse { border-color: rgba(223, 103, 95, .3); background: rgba(34, 15, 18, .56); } +.roguelike-pvp-draft-empty > i { color: var(--roguelike-pvp-local); font: normal 25px "Cinzel", serif; text-align: center; } +.roguelike-pvp-draft-empty.is-curse > i { color: var(--roguelike-pvp-curse); } +.roguelike-pvp-draft-empty > span { display: grid; } +.roguelike-pvp-draft-empty strong { font: 600 12px "Cinzel", serif; } +.roguelike-pvp-draft-empty small { color: #71867e; font-size: 8px; } + +.roguelike-pvp-draft-actions { + min-height: 42px; + display: grid; + grid-template-columns: auto minmax(140px, 1fr) auto; + align-items: center; + gap: 9px; +} + +.roguelike-pvp-draft-actions > span { justify-self: center; color: #697d76; font-size: 7px; letter-spacing: .05em; text-transform: uppercase; } +.roguelike-pvp-draft-actions > span b { color: #dce8e3; } +.roguelike-pvp-draft-actions > span i { display: inline-block; width: 3px; height: 3px; margin: 0 5px; border-radius: 50%; background: var(--roguelike-pvp-local); vertical-align: middle; } +.roguelike-pvp-draft-actions button { min-height: 40px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 11px; border: 1px solid #3d514a; color: #9dafaa; background: rgba(8, 19, 16, .82); font-size: 8px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; cursor: pointer; } +.roguelike-pvp-draft-actions button.is-primary { grid-column: 3; min-width: 132px; border-color: #d6b968; color: #131a15; background: linear-gradient(110deg, #f3d884, #bd9943); } +.roguelike-pvp-draft-actions button.is-back { grid-column: 1; } +.roguelike-pvp-draft-actions button.is-controller-selected, +.roguelike-pvp-draft-actions button:focus-visible { outline: 2px solid #fff0b8; outline-offset: 2px; } +.roguelike-pvp-draft-actions button:disabled { cursor: not-allowed; filter: grayscale(.7); opacity: .35; } + +.roguelike-pvp-draft.is-buff .roguelike-pvp-draft-actions > span { grid-column: 1 / 3; } +.roguelike-pvp-review { gap: 12px; } +.roguelike-pvp-review-cards { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr); align-items: stretch; gap: 9px; } +.roguelike-pvp-review-cards > b { align-self: center; color: #9f596a; font: 600 9px "Cinzel", serif; text-align: center; } +.roguelike-pvp-review-cards article { min-width: 0; display: grid; grid-template-columns: 39px minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); align-content: center; gap: 2px 10px; padding: 16px; border: 1px solid color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 58%); border-left: 3px solid var(--roguelike-pvp-choice-accent); background: linear-gradient(115deg, color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 88%), rgba(6, 15, 13, .88) 60%); } +.roguelike-pvp-review-cards article.is-curse { background: linear-gradient(115deg, color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 86%), rgba(21, 9, 13, .9) 60%); } +.roguelike-pvp-review-cards article > i { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--roguelike-pvp-choice-accent); color: var(--roguelike-pvp-choice-accent); font: normal 18px "Cinzel", serif; } +.roguelike-pvp-review-cards article > small { color: var(--roguelike-pvp-choice-accent); font-size: 7px; letter-spacing: .12em; text-transform: uppercase; } +.roguelike-pvp-review-cards article > strong { overflow: hidden; font: 600 clamp(10px, 2cqw, 13px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-review-cards article > p { grid-column: 1 / -1; margin: 8px 0 0; color: #84978f; font-size: clamp(8px, 1.5cqw, 10px); } +.roguelike-pvp-draft-actions.is-review > span { text-align: center; } + +.roguelike-pvp-draft-locked { + grid-template-rows: auto auto auto auto minmax(0, 1fr); + place-items: center; + align-content: start; + text-align: center; +} + +.roguelike-pvp-draft-locked .roguelike-pvp-draft-header { width: 100%; grid-template-columns: 1fr auto; } +.roguelike-pvp-lock-sigil { position: relative; width: 72px; height: 72px; display: grid; place-items: center; margin-top: 28px; } +.roguelike-pvp-lock-sigil > i { position: absolute; color: rgba(232, 200, 114, .2); font: normal 68px "Cinzel", serif; } +.roguelike-pvp-lock-sigil > b { position: relative; color: var(--roguelike-pvp-local); font-size: 8px; letter-spacing: .14em; } +.roguelike-pvp-draft-locked h2 { margin: 7px 0 3px; font: 500 clamp(18px, 4cqw, 25px) "Cinzel", serif; } +.roguelike-pvp-draft-locked > p { max-width: 380px; margin: 0; color: #83968f; font-size: clamp(8px, 1.6cqw, 10px); } +.roguelike-pvp-locked-picks { align-self: center; width: min(500px, 86%); display: grid; grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); align-items: stretch; gap: 8px; margin-top: 19px; } +.roguelike-pvp-locked-picks > b { align-self: center; color: #9f596a; font: 600 9px "Cinzel", serif; } +.roguelike-pvp-locked-picks > span { min-width: 0; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 1px 8px; padding: 10px; border: 1px solid rgba(232, 200, 114, .3); border-left: 3px solid var(--roguelike-pvp-local); background: rgba(42, 35, 17, .3); text-align: left; } +.roguelike-pvp-locked-picks > span.is-curse { border-color: rgba(223, 103, 95, .3); border-left-color: var(--roguelike-pvp-curse); background: rgba(50, 18, 22, .3); } +.roguelike-pvp-locked-picks i { grid-row: 1 / 3; align-self: center; color: var(--roguelike-pvp-local); font: normal 18px "Cinzel", serif; text-align: center; } +.roguelike-pvp-locked-picks .is-curse i { color: var(--roguelike-pvp-curse); } +.roguelike-pvp-locked-picks small { color: #798d85; font-size: 6px; letter-spacing: .1em; text-transform: uppercase; } +.roguelike-pvp-locked-picks strong { overflow: hidden; font: 600 10px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } + +.roguelike-pvp-meter { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 27px; align-items: center; gap: 5px; } +.roguelike-pvp-meter > i { position: relative; height: 5px; overflow: hidden; border: 1px solid rgba(168, 199, 188, .18); background: rgba(0, 0, 0, .56); } +.roguelike-pvp-meter > i > b { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #458f7d, var(--roguelike-pvp-local-soft)); transition: width 160ms linear; } +.roguelike-pvp-meter.is-rival > i > b { background: linear-gradient(90deg, #803554, var(--roguelike-pvp-rival)); } +.roguelike-pvp-meter > em { color: #9eb0aa; font-size: 7px; font-style: normal; font-weight: 700; text-align: right; } + +.roguelike-pvp-status-strip { + position: absolute; + top: 13%; + right: 2.3%; + z-index: 4; + width: clamp(218px, 25%, 280px); + padding: 7px 8px 8px; + border: 1px solid rgba(237, 114, 159, .32); + border-right: 3px solid var(--roguelike-pvp-rival); + color: #e8f0ed; + background: linear-gradient(110deg, rgba(7, 18, 15, .9), rgba(40, 12, 24, .9)); + box-shadow: 0 7px 20px rgba(0, 0, 0, .35); + pointer-events: none; + text-shadow: 0 1px 3px #000; +} + +.roguelike-pvp-status-strip > header { display: grid; grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr) 7px; align-items: center; gap: 5px; padding-bottom: 5px; border-bottom: 1px solid rgba(237, 114, 159, .17); } +.roguelike-pvp-status-strip > header > span { min-width: 0; display: grid; } +.roguelike-pvp-status-strip > header > span:nth-of-type(2) { text-align: right; } +.roguelike-pvp-status-strip > header small { overflow: hidden; color: #9a8a66; font-size: 5px; letter-spacing: .1em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.roguelike-pvp-status-strip > header span:nth-of-type(2) small { color: #c9819e; } +.roguelike-pvp-status-strip > header strong { overflow: hidden; font: 600 8px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-status-strip > header > b { color: #a95a74; font: 600 6px "Cinzel", serif; text-align: center; } +.roguelike-pvp-status-strip > header > i, +.roguelike-pvp-connection > i { width: 6px; height: 6px; border-radius: 50%; background: #687a73; box-shadow: 0 0 5px rgba(104, 122, 115, .45); } +.roguelike-pvp-status-strip [data-connection="online"], +.roguelike-pvp-connection [data-connection="online"] { background: #75d3a3; box-shadow: 0 0 6px rgba(117, 211, 163, .72); } +.roguelike-pvp-status-strip [data-connection="cpu"], +.roguelike-pvp-connection [data-connection="cpu"] { background: #69bfe5; box-shadow: 0 0 6px rgba(105, 191, 229, .68); } +.roguelike-pvp-status-strip [data-connection="reconnecting"], +.roguelike-pvp-connection [data-connection="reconnecting"] { background: #e8c872; box-shadow: 0 0 6px rgba(232, 200, 114, .72); } +.roguelike-pvp-status-strip [data-connection="disconnected"], +.roguelike-pvp-connection [data-connection="disconnected"] { background: #e5685d; box-shadow: 0 0 6px rgba(229, 104, 93, .72); } +.roguelike-pvp-status-sides { display: grid; gap: 4px; padding-top: 5px; } +.roguelike-pvp-status-sides > span { min-width: 0; display: grid; grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 5px; } +.roguelike-pvp-status-sides > span > small { overflow: hidden; color: #c7d4cf; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-status-sides > span:nth-child(2) > small { color: #efa1bf; } +.roguelike-pvp-status-sides .roguelike-pvp-meter { grid-template-columns: minmax(0, 1fr); gap: 0; } +.roguelike-pvp-status-sides .roguelike-pvp-meter::before { color: #647970; font-size: 4px; line-height: 1; text-transform: uppercase; } +.roguelike-pvp-status-sides .roguelike-pvp-meter:nth-of-type(1)::before { content: "Boss"; } +.roguelike-pvp-status-sides .roguelike-pvp-meter:nth-of-type(2)::before { content: "Party"; } +.roguelike-pvp-status-sides .roguelike-pvp-meter > em { display: none; } + +.roguelike-pvp-tactical { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto 132px minmax(0, 1fr) auto; + gap: 10px; + padding: 14px 4.2% 10px; + overflow: hidden; + background: + radial-gradient(circle at 8% 0%, rgba(232, 200, 114, .12), transparent 36%), + radial-gradient(circle at 94% 4%, rgba(237, 114, 159, .12), transparent 40%), + linear-gradient(145deg, #091713, #080d0c 60%, #160b11); +} + +.roguelike-pvp-tactical > header, +.roguelike-pvp-curse-ledger > header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.roguelike-pvp-tactical > header { min-height: 37px; padding-bottom: 9px; border-bottom: 1px solid rgba(184, 210, 200, .15); } +.roguelike-pvp-tactical > header > span:first-child, +.roguelike-pvp-curse-ledger > header > span { display: grid; } +.roguelike-pvp-tactical > header small, +.roguelike-pvp-curse-ledger > header small { color: #71867e; font-size: 6px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } +.roguelike-pvp-tactical > header strong, +.roguelike-pvp-curse-ledger > header strong { font: 600 13px "Cinzel", serif; } +.roguelike-pvp-tactical > header > b { color: var(--roguelike-pvp-local); font-size: 8px; letter-spacing: .12em; text-transform: uppercase; } +.roguelike-pvp-connection { display: flex !important; align-items: center; gap: 5px; } +.roguelike-pvp-connection small { color: #99aaa4 !important; letter-spacing: .08em !important; } + +.roguelike-pvp-race-board { position: relative; z-index: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); gap: 8px; } +.roguelike-pvp-race-board > b { align-self: center; color: #a85874; font: 600 7px "Cinzel", serif; text-align: center; } +.roguelike-pvp-race-board article { min-width: 0; display: grid; grid-template-rows: auto 1fr 1fr; gap: 7px; padding: 10px; border: 1px solid rgba(232, 200, 114, .25); border-left: 3px solid var(--roguelike-pvp-local); background: linear-gradient(110deg, rgba(49, 40, 17, .26), rgba(7, 17, 14, .76)); } +.roguelike-pvp-race-board article.is-rival { border-color: rgba(237, 114, 159, .25); border-left-color: var(--roguelike-pvp-rival); background: linear-gradient(110deg, rgba(63, 19, 36, .32), rgba(13, 10, 13, .78)); } +.roguelike-pvp-race-board article > header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.roguelike-pvp-race-board article > header small { overflow: hidden; color: #a99359; font-size: 6px; font-weight: 700; letter-spacing: .1em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.roguelike-pvp-race-board article.is-rival > header small { color: #d888a7; } +.roguelike-pvp-race-board article > header strong { font: 600 9px "Cinzel", serif; white-space: nowrap; } +.roguelike-pvp-race-board article > div { min-width: 0; display: grid; grid-template-columns: 31px minmax(0, 1fr); align-items: center; gap: 6px; } +.roguelike-pvp-race-board article > div > span { color: #71867e; font-size: 6px; font-weight: 700; text-transform: uppercase; } +.roguelike-pvp-race-board .roguelike-pvp-meter > i { height: 7px; } + +.roguelike-pvp-curse-ledger { position: relative; z-index: 1; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 7px; } +.roguelike-pvp-curse-ledger > header { min-height: 32px; } +.roguelike-pvp-curse-ledger > header strong { font-size: 11px; } +.roguelike-pvp-curse-ledger > header > b { min-width: 24px; height: 20px; display: grid; place-items: center; border: 1px solid rgba(223, 103, 95, .35); color: #f59b94; background: rgba(65, 19, 24, .34); font-size: 8px; } +.roguelike-pvp-curse-list { min-height: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(34px, 1fr); gap: 5px; overflow: hidden; } +.roguelike-pvp-curse-list article { min-width: 0; display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 5px 7px; border: 1px solid color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 72%); border-left: 2px solid var(--roguelike-pvp-choice-accent); background: linear-gradient(90deg, color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 91%), rgba(15, 9, 11, .74)); } +.roguelike-pvp-curse-list article > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--roguelike-pvp-choice-accent), transparent 45%); color: var(--roguelike-pvp-choice-accent); font: normal 11px "Cinzel", serif; } +.roguelike-pvp-curse-list article > span { min-width: 0; display: grid; } +.roguelike-pvp-curse-list article small { overflow: hidden; color: #aa7b7c; font-size: 5px; letter-spacing: .06em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.roguelike-pvp-curse-list article strong { overflow: hidden; font: 600 8px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-curse-list article > b { max-width: 98px; overflow: hidden; color: #e6b1ad; font-size: 6px; text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.roguelike-pvp-curse-empty { min-height: 0; display: grid; grid-template-columns: 39px auto; place-content: center; align-items: center; gap: 10px; border: 1px dashed rgba(143, 171, 160, .2); background: rgba(8, 17, 14, .4); } +.roguelike-pvp-curse-empty > i { width: 37px; height: 37px; display: grid; place-items: center; border: 1px solid #3d534b; color: #6d827a; font: normal 17px "Cinzel", serif; } +.roguelike-pvp-curse-empty > span { display: grid; } +.roguelike-pvp-curse-empty strong { font: 600 10px "Cinzel", serif; } +.roguelike-pvp-curse-empty small { color: #71867e; font-size: 7px; } +.roguelike-pvp-tactical > footer { position: relative; z-index: 1; min-height: 21px; display: flex; align-items: center; gap: 14px; padding-top: 7px; border-top: 1px solid rgba(184, 210, 200, .12); color: #697d76; font-size: 6px; text-transform: uppercase; } +.roguelike-pvp-tactical > footer span { display: flex; align-items: center; gap: 4px; } +.roguelike-pvp-tactical > footer i { width: 8px; height: 3px; background: var(--roguelike-pvp-rival); } +.roguelike-pvp-tactical > footer span:first-child i { background: var(--roguelike-pvp-local-soft); } +.roguelike-pvp-tactical > footer b { margin-left: auto; color: #a9bbb4; letter-spacing: .08em; } + +.roguelike-pvp-waiting-overlay { + position: absolute; + z-index: 11; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + padding: 6%; + color: #eef6f2; + background: radial-gradient(circle at 50% 38%, rgba(113, 38, 60, .22), transparent 28%), rgba(3, 9, 8, .94); + pointer-events: auto; + text-align: center; +} + +.roguelike-pvp-waiting-overlay > i { color: #d36d92; font: normal clamp(31px, 6cqw, 50px) "Cinzel", serif; } +.roguelike-pvp-waiting-overlay > span { margin-top: 9px; color: var(--roguelike-pvp-local); font-size: 8px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; } +.roguelike-pvp-waiting-overlay h1 { margin: 3px 0; font: 500 clamp(22px, 4cqw, 34px) "Cinzel", serif; } +.roguelike-pvp-waiting-overlay p { max-width: 470px; margin: 0; color: #879a93; font-size: clamp(9px, 1.4cqw, 12px); } +.roguelike-pvp-waiting-overlay time { margin-top: 15px; color: #f4d892; font: 600 clamp(25px, 4cqw, 38px) "Cinzel", serif; } +.roguelike-pvp-waiting-overlay small { color: #9f7890; font-size: 7px; letter-spacing: .1em; text-transform: uppercase; } + +@container lower-screen (max-width: 560px) { + .roguelike-pvp-draft-header { grid-template-columns: 1fr auto; gap: 6px; padding-inline: 3%; } + .roguelike-pvp-draft-header > span { display: none; } + .roguelike-pvp-step-rail { grid-template-columns: repeat(3, minmax(48px, 1fr)); } + .roguelike-pvp-draft-body, + .roguelike-pvp-review { gap: 7px; padding: 9px 3% 8px; } + .roguelike-pvp-choice-grid { gap: 5px; } + .roguelike-pvp-choice { grid-template-columns: 24px minmax(0, 1fr); gap: 4px 5px; padding: 6px; } + .roguelike-pvp-choice > i { width: 23px; height: 23px; font-size: 11px; } + .roguelike-pvp-choice > p { display: none; } + .roguelike-pvp-draft-actions { gap: 5px; } + .roguelike-pvp-draft-actions > span { display: none; } + .roguelike-pvp-draft-actions button.is-primary { grid-column: 2 / 4; } + .roguelike-pvp-draft.is-buff .roguelike-pvp-draft-actions button.is-primary { grid-column: 1 / -1; } + .roguelike-pvp-review-cards { grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr); gap: 5px; } + .roguelike-pvp-review-cards article { grid-template-columns: 30px minmax(0, 1fr); gap: 2px 6px; padding: 9px; } + .roguelike-pvp-review-cards article > i { width: 29px; height: 29px; font-size: 13px; } + .roguelike-pvp-review-cards article > p { font-size: 7px; } + .roguelike-pvp-tactical { grid-template-rows: auto 118px minmax(0, 1fr) auto; gap: 6px; padding: 9px 3% 7px; } + .roguelike-pvp-race-board article { gap: 5px; padding: 7px; } + .roguelike-pvp-curse-list article > b { max-width: 72px; } +} + +html[data-display-layout="single"] .roguelike-pvp-status-strip { top: max(66px, 12cqh); right: max(8px, env(safe-area-inset-right)); width: clamp(205px, 29cqw, 300px); } + +@media (max-width: 760px) { + .roguelike-pvp-status-strip { top: 13%; right: 1.5%; width: clamp(165px, 39%, 220px); padding: 5px 6px; } + .roguelike-pvp-status-strip > header { grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr) 6px; } + .roguelike-pvp-status-sides > span { grid-template-columns: 43px minmax(0, 1fr) minmax(0, 1fr); gap: 3px; } + .roguelike-pvp-status-sides > span > small { font-size: 4px; } + .roguelike-pvp-status-sides .roguelike-pvp-meter::before { display: none; } + .roguelike-pvp-status-sides .roguelike-pvp-meter > i { height: 3px; } +} + +@media (prefers-reduced-motion: reduce) { + .roguelike-pvp-choice, + .roguelike-pvp-meter > i > b { transition: none; } + .roguelike-pvp-choice.is-controller-selected, + .roguelike-pvp-choice:focus-visible { transform: none; } +} + .gear-surface { padding: 0 28px; } .gear-surface .front-screen-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; } .gear-mode-tabs { display: flex; gap: 4px; }