Compare commits

...
1 Commits
Author SHA1 Message Date
Warren H 0e36ca1a41 Release v0.1.21 2026-07-19 2026-07-19 18:48:28 -04:00
30 changed files with 4701 additions and 120 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.20", "version": "0.1.21",
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs", "predev": "node scripts/sync_basis_transcoder.mjs",
+701 -3
View File
@@ -8,6 +8,61 @@ const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000; const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20; const AUTH_ATTEMPTS_PER_WINDOW = 20;
const HOCKEY_PVP_COUNTDOWN_MS = 5_000; const 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(); const authAttempts = new Map();
function apiError(message, status = 400) { function apiError(message, status = 400) {
@@ -180,16 +235,167 @@ function validateSlotId(value) {
return slotId; 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) { function validateSave(value, slotId) {
const schemaVersion = Number(value?.schemaVersion); 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."); throw apiError("Save snapshot is invalid.");
} }
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
throw apiError("Save snapshot has no hunter name."); throw apiError("Save snapshot has no hunter name.");
} }
return { ...value, schemaVersion: 6 }; return { ...value, schemaVersion: 7 };
} }
function normalizeNonNegativeInteger(value) { function normalizeNonNegativeInteger(value) {
@@ -229,9 +435,12 @@ function mergeLeaderboardHighWater(database, accountId, slotId, save) {
: storedAether; : storedAether;
return { return {
...save, ...save,
schemaVersion: 6, schemaVersion: 7,
stats: { stats: {
...stats, ...stats,
roguelikePvpWins: normalizeNonNegativeInteger(stats.roguelikePvpWins),
roguelikePvpLosses: normalizeNonNegativeInteger(stats.roguelikePvpLosses),
highestRoguelikePvpRound: normalizeNonNegativeInteger(stats.highestRoguelikePvpRound),
highestBlockbreakerBricks: Math.max( highestBlockbreakerBricks: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerBricks), normalizeNonNegativeInteger(stats.highestBlockbreakerBricks),
normalizeNonNegativeInteger(blockbreaker?.highestBricks), normalizeNonNegativeInteger(blockbreaker?.highestBricks),
@@ -639,6 +848,430 @@ export function createGameApiHandler(options = {}) {
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
const hockeyPvpTickets = new Map(); const hockeyPvpTickets = new Map();
const hockeyPvpMatches = 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) { function queueResult(ticket) {
const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null; const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null;
@@ -830,6 +1463,69 @@ export function createGameApiHandler(options = {}) {
} }
const session = requireSession(database, request); 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") { if (path === "/api/hockey-pvp/queue" && request.method === "POST") {
return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request))); return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request)));
} }
@@ -930,6 +1626,8 @@ export function createGameApiHandler(options = {}) {
close: () => { close: () => {
hockeyPvpTickets.clear(); hockeyPvpTickets.clear();
hockeyPvpMatches.clear(); hockeyPvpMatches.clear();
roguelikePvpTickets.clear();
roguelikePvpMatches.clear();
database.close(); database.close();
}, },
}; };
+512 -3
View File
@@ -7,7 +7,8 @@ import { after, before, test } from "node:test";
import { createGameApiHandler } from "./game-api.mjs"; import { createGameApiHandler } from "./game-api.mjs";
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-")); 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) => { const server = createServer((request, response) => {
void api.handle(request, response, () => { void api.handle(request, response, () => {
response.statusCode = 404; 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) { 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 { return {
schemaVersion: 6, schemaVersion: 7,
slotId, slotId,
hunterName, hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest }, 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.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds); assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore); 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.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave); assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration); 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); 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 () => { test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", { const login = await json("/api/auth/login", {
method: "POST", method: "POST",
+243 -12
View File
@@ -3,7 +3,7 @@ import packageJson from "../package.json";
import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd"; import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store"; 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 { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot"; import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop"; import { useActionBindings } from "./game/useGameLoop";
@@ -12,8 +12,11 @@ import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH
import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync"; import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp"; import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp"; import { 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 { 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 TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -32,6 +35,87 @@ function TacticalLoadingScreen() {
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>; return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
} }
function 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 <Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={() => undefined} /></Suspense>;
}
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() { function MainApp() {
useForcedThorDisplays(); useForcedThorDisplays();
useAuthoritativeDualScreenSync(); useAuthoritativeDualScreenSync();
@@ -45,6 +129,7 @@ function MainApp() {
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat); const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const recordRoguelikePvpResult = useFrontendStore((state) => state.recordRoguelikePvpResult);
const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat); const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat);
const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult); const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult);
const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill); const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill);
@@ -54,6 +139,7 @@ function MainApp() {
const gamePhase = useGameStore((state) => state.phase); const gamePhase = useGameStore((state) => state.phase);
const gameRunMode = useGameStore((state) => state.runMode); const gameRunMode = useGameStore((state) => state.runMode);
const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
const roguelikePvpCountdownEndsAtMs = useGameStore((state) => state.roguelikePvp.countdownEndsAtMs);
const rewardedBossInstances = useRef(new Set<string>()); const rewardedBossInstances = useRef(new Set<string>());
const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null); const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null);
const screenRef = useRef(screen); const screenRef = useRef(screen);
@@ -63,6 +149,10 @@ function MainApp() {
hockeyPvpPostMatchOperation.current = null; hockeyPvpPostMatchOperation.current = null;
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
const game = useGameStore.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 // RPG Roguelike equipment belongs only to its current run. Never leak it
// into the hunter's permanent inventory when leaving the expedition. // into the hunter's permanent inventory when leaving the expedition.
if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory); if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory);
@@ -131,7 +221,7 @@ function MainApp() {
launchHockeyPvpMatch(match); launchHockeyPvpMatch(match);
}); });
}, [accountId, hunter, launchHockeyPvpMatch]); }, [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; if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId]; const progress = hunter.healers[hunter.activeClassId];
const selectedMode = useFrontendStore.getState().selectedMode; const selectedMode = useFrontendStore.getState().selectedMode;
@@ -143,6 +233,8 @@ function MainApp() {
? "hockey-healing" ? "hockey-healing"
: selectedMode === "hockey-healing-pvp" : selectedMode === "hockey-healing-pvp"
? "hockey-healing-pvp" ? "hockey-healing-pvp"
: selectedMode === "roguelike-pvp"
? "roguelike-pvp"
: selectedMode === "blockbreaker" : selectedMode === "blockbreaker"
? "blockbreaker" ? "blockbreaker"
: selectedMode === "aether-assault" : selectedMode === "aether-assault"
@@ -161,7 +253,7 @@ function MainApp() {
runMode, runMode,
hunter.gearProgress, hunter.gearProgress,
launchDifficulty, launchDifficulty,
hockeyPvpMatch, pvpMatch,
); );
touchActiveSave(); touchActiveSave();
navigate("game"); navigate("game");
@@ -169,8 +261,8 @@ function MainApp() {
useEffect(() => { useEffect(() => {
const onDualScreenLaunch = (event: Event) => { const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail; 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.hockeyPvpMatch); if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.pvpMatch ?? detail.hockeyPvpMatch);
else launchGame(detail); else launchGame(detail);
}; };
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
@@ -229,13 +321,143 @@ function MainApp() {
}; };
}, [screen]); }, [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<number>();
const submittedRounds = new Set<number>();
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(() => { useEffect(() => {
if (screen !== "game") return; if (screen !== "game") return;
let timer: number | undefined; let timer: number | undefined;
const autoStart = () => { const autoStart = () => {
const state = useGameStore.getState(); const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || state.phase !== "briefing") return; if (state.phase !== "briefing"
const remaining = state.hockeyPvp.countdownEndsAtMs - Date.now(); || 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) { if (remaining <= 0) {
state.startEncounter(); state.startEncounter();
return; return;
@@ -246,7 +468,7 @@ function MainApp() {
return () => { return () => {
if (timer !== undefined) window.clearTimeout(timer); if (timer !== undefined) window.clearTimeout(timer);
}; };
}, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, screen]); }, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, roguelikePvpCountdownEndsAtMs, screen]);
useActionBindings(screen === "game", leaveGame); useActionBindings(screen === "game", leaveGame);
@@ -286,6 +508,11 @@ function MainApp() {
&& state.phase !== previousState.phase) { && state.phase !== previousState.phase) {
recordHockeyPvpResult(state.phase === "victory"); 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") { if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score); 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 // RPG rewards are generated inside the run reducer. Permanent boss loot
// here would duplicate its chest and break run-only progression. // 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; const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) { if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = state.bossInstanceId; const primaryInstanceId = state.bossInstanceId;
@@ -317,7 +544,7 @@ function MainApp() {
recordBossVictory(entry.boss.id, rewardDifficulty); 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 ( return (
<main className="app-shell"> <main className="app-shell">
@@ -333,8 +560,12 @@ function MainApp() {
} }
export default function App() { 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 <Suspense fallback={null}><HealerModelGallery /></Suspense>; return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
} }
if (preview === "roguelike-pvp-draft") {
return <RoguelikePvpDraftPreview />;
}
return <MainApp />; return <MainApp />;
} }
+10 -2
View File
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings"; import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers"; import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike"; import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { compileRoguelikePvpCurses, roguelikePvpAbilityCooldown, roguelikePvpAbilityManaCost } from "../game/roguelikePvp";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store"; import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
import type { AbilitySlotId } from "../game/types"; 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 activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility); const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers); 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 healerMechanic = useGameStore((state) => state.healerMechanic);
const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`; 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 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 baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime; 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 globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0; const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0; const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
+36 -10
View File
@@ -40,6 +40,10 @@ import { AbilityButton } from "./AbilityButton";
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting"; import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout"; import { isSingleScreenLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import {
RoguelikePvpDraftPanel,
RoguelikePvpTacticalPanel,
} from "./RoguelikePvpPanels";
function moveTacticalSelection(direction: 1 | -1) { function moveTacticalSelection(direction: 1 | -1) {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -80,6 +84,9 @@ function useSingleScreenTacticalInput(
else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") { else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.(); if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
else actionRef.current?.(store.hockeyPvp.postMatchSelection); 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(); } else if (store.phase === "victory" || store.phase === "defeat") store.restart();
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -279,6 +286,7 @@ function BriefingPanel() {
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & "); const bossNames = bosses.map((boss) => boss.name).join(" & ");
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode); const activityMode = useGameStore((state) => state.activityMode);
const hockeyMode = activityMode === "hockey-healing"; const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp"; const pvpMode = activityMode === "hockey-healing-pvp";
@@ -287,14 +295,19 @@ function BriefingPanel() {
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName); const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, 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 ( return (
<div className="briefing-panel"> <div className="briefing-panel">
<div className="briefing-class"> <div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div> <div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span> <span>Chosen discipline</span>
<h2>{healer.specialization}</h2> <h2>{healer.specialization}</h2>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p> <p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : 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(" ")}</>}</p>
<button className="start-button" onClick={startEncounter} disabled={pvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? pvpCountdownSeconds > 0 ? `Match starts in ${pvpCountdownSeconds} seconds` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{pvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button> <button className="start-button" onClick={startEncounter} disabled={competitivePvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : competitivePvpMode ? competitivePvpCountdown > 0 ? `Match starts in ${competitivePvpCountdown} seconds` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{competitivePvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button>
</div> </div>
<div className="briefing-kit"> <div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div> <div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -338,6 +351,7 @@ function EndPanel({
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const hockey = useGameStore((state) => state.hockey); const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp); const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
const requeueSeconds = useHockeyPvpCountdownSeconds( const requeueSeconds = useHockeyPvpCountdownSeconds(
hockeyPvp.postMatchStatus === "requeueing", hockeyPvp.postMatchStatus === "requeueing",
@@ -350,20 +364,21 @@ function EndPanel({
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker"; const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault"; const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
const pvpMatch = activityMode === "hockey-healing-pvp"; const pvpMatch = activityMode === "hockey-healing-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch; const roguelikePvpMatch = runMode === "roguelike-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch && !roguelikePvpMatch;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0; const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`; const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return ( return (
<div className={`end-panel end-${phase} ${pvpMatch ? "is-pvp" : ""}`}> <div className={`end-panel end-${phase} ${pvpMatch || roguelikePvpMatch ? "is-pvp" : ""}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span> <span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small> <small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch || roguelikePvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2> <h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : 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"}</h2>
<div className="result-stats"> <div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span> <span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span> <span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : roguelikePvpMatch ? "Round reached" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded}` : roguelikePvpMatch ? round : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}</strong></span> <span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : roguelikePvpMatch ? "Active burdens" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}${hockeyPvp.opponentBossKills}` : roguelikePvpMatch ? Object.values(roguelikePvp.receivedCurseRanks).filter((rank) => (rank ?? 0) > 0).length : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div> </div>
{(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />} {!roguelikePvpMatch && (phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
{showEndlessChoice ? <div className="end-actions endless-choice-actions"> {showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button <button
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""} className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
@@ -376,6 +391,9 @@ function EndPanel({
onPointerEnter={() => setEndlessChoiceSelection("quit")} onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit} onClick={onExit}
>Quit to Main Menu</button> >Quit to Main Menu</button>
</div> : roguelikePvpMatch ? <div className="end-actions pvp-end-actions">
{roguelikePvp.role === "cpu" && <button className="is-controller-selected" onClick={restart}><span>Run again</span><small>Same CPU rival</small></button>}
<button className={roguelikePvp.role === "cpu" ? "secondary" : "is-controller-selected"} onClick={onExit}>Main menu</button>
</div> : pvpMatch ? <> </div> : pvpMatch ? <>
<div className="pvp-post-match-status" role="status" aria-live="polite"> <div className="pvp-post-match-status" role="status" aria-live="polite">
{hockeyPvp.postMatchStatus === "waiting-rematch" {hockeyPvp.postMatchStatus === "waiting-rematch"
@@ -430,8 +448,11 @@ function CombatPanel({ onExit, onHockeyPvpAction }: {
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void; onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
}) { }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
if (phase === "briefing") return <BriefingPanel />; if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />; if (phase === "intermission") return runMode === "roguelike-pvp"
? <RoguelikePvpDraftPanel />
: <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />; if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>; return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
} }
@@ -830,10 +851,15 @@ export function BottomScreen({ onExit, onHockeyPvpAction }: {
</nav> </nav>
</header> </header>
<main className="lower-content"> <main className="lower-content">
{phase === "intermission" && runMode === "roguelike-pvp"
? <RoguelikePvpDraftPanel />
: <>
{activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />} {activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
{activeTab === "map" && <MapPanel />} {activeTab === "map" && <MapPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />} {activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />} {activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
{activeTab === "pvp" && runMode === "roguelike-pvp" && <RoguelikePvpTacticalPanel />}
</>}
</main> </main>
{paused && ( {paused && (
<div className="lower-pause-overlay" aria-hidden="true"> <div className="lower-pause-overlay" aria-hidden="true">
+55 -10
View File
@@ -56,6 +56,13 @@ import {
type HockeyPvpMatchConfig, type HockeyPvpMatchConfig,
} from "../game/hockeyHealingPvp"; } from "../game/hockeyHealingPvp";
import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking"; import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking";
import {
ROGUELIKE_PVP_QUEUE_TIMEOUT_MS,
startRoguelikePvpMatchmaking,
type RoguelikePvpMatchConfig,
type RoguelikePvpMatchOperation,
} from "../frontend/roguelikePvpMatchmaking";
import { roguelikePvpBossesForRound } from "../game/roguelikePvp";
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker"; import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
import { import {
APPEARANCE_SLOT_DEFINITIONS, APPEARANCE_SLOT_DEFINITIONS,
@@ -1531,7 +1538,15 @@ function SettingsScreen() {
); );
} }
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) { export type FrontEndPvpMatchConfig = HockeyPvpMatchConfig | RoguelikePvpMatchConfig;
type FrontEndLaunchHandler = (
bossIds: readonly BossId[],
difficultySlug?: (typeof DIFFICULTIES)[number]["slug"],
pvpMatch?: FrontEndPvpMatchConfig,
) => void;
function ModeScreen({ onLaunch }: { onLaunch: FrontEndLaunchHandler }) {
const hunter = useActiveHunter(); const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId); const accountId = useFrontendStore((state) => state.accountId);
const modeId = useFrontendStore((state) => state.selectedMode); const modeId = useFrontendStore((state) => state.selectedMode);
@@ -1544,7 +1559,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const [queueing, setQueueing] = useState(false); const [queueing, setQueueing] = useState(false);
const [queueElapsed, setQueueElapsed] = useState(0); const [queueElapsed, setQueueElapsed] = useState(0);
const queueActive = useRef(false); const queueActive = useRef(false);
const queueOperation = useRef<HockeyPvpMatchOperation | null>(null); const queueOperation = useRef<HockeyPvpMatchOperation | RoguelikePvpMatchOperation | null>(null);
const mode = MODE_COPY[modeId]; const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest; const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId]; const progress = hunter?.healers[hunter.activeClassId];
@@ -1556,6 +1571,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const isDungeon = modeId === "dungeons"; const isDungeon = modeId === "dungeons";
const isHockey = modeId === "hockey-healing"; const isHockey = modeId === "hockey-healing";
const isHockeyPvp = modeId === "hockey-healing-pvp"; const isHockeyPvp = modeId === "hockey-healing-pvp";
const isRoguelikePvp = modeId === "roguelike-pvp";
const isBlockbreaker = modeId === "blockbreaker"; const isBlockbreaker = modeId === "blockbreaker";
const isAetherAssault = modeId === "aether-assault"; const isAetherAssault = modeId === "aether-assault";
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId]; const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
@@ -1564,7 +1580,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => { const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
}; };
const completePvpQueue = (match: HockeyPvpMatchConfig) => { const completeHockeyPvpQueue = (match: HockeyPvpMatchConfig) => {
if (!queueActive.current) return; if (!queueActive.current) return;
queueActive.current = false; queueActive.current = false;
queueOperation.current = null; queueOperation.current = null;
@@ -1572,6 +1588,14 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`); setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match); onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
}; };
const completeRoguelikePvpQueue = (match: RoguelikePvpMatchConfig) => {
if (!queueActive.current) return;
queueActive.current = false;
queueOperation.current = null;
setQueueing(false);
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
onLaunch(roguelikePvpBossesForRound(match.seed, 1), "initiate", match);
};
const cancelPvpQueue = () => { const cancelPvpQueue = () => {
if (!queueActive.current) return; if (!queueActive.current) return;
queueActive.current = false; queueActive.current = false;
@@ -1587,17 +1611,28 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
setQueueing(true); setQueueing(true);
setQueueElapsed(0); setQueueElapsed(0);
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…"); setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
const operation = startHockeyPvpMatchmaking({ const online = Boolean(accountId && networkAppearsOnline());
const operation = isRoguelikePvp
? startRoguelikePvpMatchmaking({
slotId: hunter.slotId, slotId: hunter.slotId,
hunterName: hunter.hunterName, hunterName: hunter.hunterName,
online: Boolean(accountId && networkAppearsOnline()), healerClassId: hunter.activeClassId,
online,
onElapsed: setQueueElapsed,
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
})
: startHockeyPvpMatchmaking({
slotId: hunter.slotId,
hunterName: hunter.hunterName,
online,
onElapsed: setQueueElapsed, onElapsed: setQueueElapsed,
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"), onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
}); });
queueOperation.current = operation; queueOperation.current = operation;
const match = await operation.result; const match = await operation.result;
if (!match || queueOperation.current !== operation) return; if (!match || queueOperation.current !== operation) return;
completePvpQueue(match); if (isRoguelikePvp) completeRoguelikePvpQueue(match as RoguelikePvpMatchConfig);
else completeHockeyPvpQueue(match as HockeyPvpMatchConfig);
}; };
useEffect(() => () => { useEffect(() => () => {
queueActive.current = false; queueActive.current = false;
@@ -1614,6 +1649,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
if (isBlockbreaker) return onLaunch(selectRandomBossPair(), "initiate"); if (isBlockbreaker) return onLaunch(selectRandomBossPair(), "initiate");
if (isAetherAssault) return onLaunch(selectRandomBossPair(), "initiate"); if (isAetherAssault) return onLaunch(selectRandomBossPair(), "initiate");
if (isHockeyPvp) return queueing ? cancelPvpQueue() : void startPvpQueue(); if (isHockeyPvp) return queueing ? cancelPvpQueue() : void startPvpQueue();
if (isRoguelikePvp) return queueing ? cancelPvpQueue() : void startPvpQueue();
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug); if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking is not available for this mode yet."); setMessage("Online matchmaking is not available for this mode yet.");
}; };
@@ -1665,7 +1701,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
})) : []), })) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } }, { id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: leaveMode, neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } }, { id: "back", run: leaveMode, neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isAetherAssault, isBlockbreaker, isDungeon, isHockey, isHockeyPvp, isPveRun, modeId, navigate, onLaunch, queueing, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); ], [bossGridColumns, isAetherAssault, isBlockbreaker, isDungeon, isHockey, isHockeyPvp, isPveRun, isRoguelikePvp, modeId, navigate, onLaunch, queueing, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: leaveMode }); const controller = useMenuController(actions, { onBack: leaveMode });
const launchLabel = isRogueTrials const launchLabel = isRogueTrials
? "Begin Rogue Trials" ? "Begin Rogue Trials"
@@ -1679,6 +1715,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
? "Begin Aether Assault" ? "Begin Aether Assault"
: isHockeyPvp : isHockeyPvp
? queueing ? "Cancel matchmaking" : "Enter online queue" ? queueing ? "Cancel matchmaking" : "Enter online queue"
: isRoguelikePvp
? queueing ? "Cancel matchmaking" : "Enter online queue"
: isDungeon : isDungeon
? `Challenge ${selectedBoss.name}` ? `Challenge ${selectedBoss.name}`
: "Enter matchmaking"; : "Enter matchmaking";
@@ -1706,6 +1744,12 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
["Escalating pressure", `Every boss kill by either party adds 5% global healing Dampening. Every net breach still deals ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`], ["Escalating pressure", `Every boss kill by either party adds 5% global healing Dampening. Every net breach still deals ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`],
["Online or CPU", "Queue searches online for five seconds. If no rival answers, a randomly named CPU healer takes far goal."], ["Online or CPU", "Queue searches online for five seconds. If no rival answers, a randomly named CPU healer takes far goal."],
] ]
: isRoguelikePvp
? [
["Mirrored survival", "Both five-person parties race through the same seeded boss rounds with normalized base gear."],
["Build and sabotage", "After every clear, choose one stacking buff for your party and one stacking curse for your rival."],
["Online or CPU", "Queue searches online for five seconds. If no rival answers, a CPU healer continues the endless race."],
]
: isHockey : isHockey
? [ ? [
["Wide goal defense", "Healer owns near half. Intercept every incoming puck before it reaches the wide blue goal."], ["Wide goal defense", "Healer owns near half. Intercept every incoming puck before it reaches the wide blue goal."],
@@ -1781,7 +1825,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{DIFFICULTIES.map((difficulty) => <ControllerButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} selectedId={controller.selectedId} select={controller.select} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></ControllerButton>)} {DIFFICULTIES.map((difficulty) => <ControllerButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} selectedId={controller.selectedId} select={controller.select} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></ControllerButton>)}
</div> </div>
)} )}
<ControllerButton id="launch" selectedId={controller.selectedId} select={controller.select} className={`mode-launch ${queueing ? "is-queueing" : ""}`} onClick={launch}><span>{launchLabel}</span><small>{queueing ? `CPU fallback in ${Math.max(0, ((HOCKEY_PVP_QUEUE_TIMEOUT_MS - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`}</small></ControllerButton> <ControllerButton id="launch" selectedId={controller.selectedId} select={controller.select} className={`mode-launch ${queueing ? "is-queueing" : ""}`} onClick={launch}><span>{launchLabel}</span><small>{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}`}</small></ControllerButton>
{message && <div className="front-notice">{message}</div>} {message && <div className="front-notice">{message}</div>}
</FrontSurface> </FrontSurface>
} }
@@ -1789,19 +1833,20 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}> <FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header> <header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)} {contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>{isHockeyPvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}</small></div> <div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>{isHockeyPvp || isRoguelikePvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>} {isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 13 group drops · Independent pet chance 1 in 500</small></div>} {isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 13 group drops · Independent pet chance 1 in 500</small></div>}
{isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>} {isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>}
{isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>} {isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>}
{isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>} {isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>}
{isRoguelikePvp && <div className="mode-loot-preview"><span>Competitive record</span><b>Wins / losses · Highest round</b><small>Endless mirrored rounds award no permanent boss loot</small></div>}
</FrontSurface> </FrontSurface>
} }
/> />
); );
} }
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); const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />; if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />; if (screen === "saves") return <SaveScreen />;
+363
View File
@@ -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 (
<span
className={`roguelike-pvp-meter is-${tone}`}
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<i><b style={{ width: `${percent}%` }} /></i>
<em>{Math.round(percent)}%</em>
</span>
);
}
function EmptyDraftChoice({ kind }: { kind: "buff" | "curse" }) {
return (
<div className={`roguelike-pvp-draft-empty is-${kind}`}>
<i>{kind === "buff" ? "✦" : "⌁"}</i>
<span>
<strong>{kind === "buff" ? "Blessings mastered" : "Curse pool exhausted"}</strong>
<small>No selection required. Continue draft.</small>
</span>
</div>
);
}
function DraftStepRail({ step }: { step: "buff" | "curse" | "review" }) {
const steps = ["buff", "curse", "review"] as const;
const activeIndex = steps.indexOf(step);
return (
<ol className="roguelike-pvp-step-rail" aria-label="Draft progress">
{steps.map((entry, index) => (
<li key={entry} className={`${entry === step ? "is-active" : ""} ${index < activeIndex ? "is-complete" : ""}`} aria-current={entry === step ? "step" : undefined}>
<b>{index < activeIndex ? "✓" : index + 1}</b>
<span>{entry === "buff" ? "Bless" : entry === "curse" ? "Sabotage" : "Lock"}</span>
</li>
))}
</ol>
);
}
/** 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 (
<section className={`roguelike-pvp-draft roguelike-pvp-draft-locked ${className}`.trim()} role="status" aria-live="polite">
<header className="roguelike-pvp-draft-header">
<span>Round {round} complete</span>
<time dateTime={`PT${remainingSeconds}S`}>{remainingSeconds}s</time>
</header>
<div className="roguelike-pvp-lock-sigil" aria-hidden="true"><i></i><b>LOCKED</b></div>
<h2>{opponentDraftLocked ? "Both drafts sealed" : `Waiting for ${opponentName}`}</h2>
<p>{opponentDraftLocked ? "Revealing sabotage and preparing mirrored encounters." : "Your choices stay hidden until rival locks or timer expires."}</p>
<div className="roguelike-pvp-locked-picks">
<span className="is-buff"><i>{selectedBuff?.icon ?? "✦"}</i><small>Your blessing</small><strong>{selectedBuff?.name ?? "Mastered"}</strong></span>
<b aria-hidden="true">VS</b>
<span className="is-curse"><i>{selectedCurse?.icon ?? "⌁"}</i><small>Sent to rival</small><strong>{selectedCurse?.name ?? "None"}</strong></span>
</div>
</section>
);
}
return (
<section className={`roguelike-pvp-draft is-${draftStep} ${className}`.trim()} role="dialog" aria-modal="true" aria-labelledby="roguelike-pvp-draft-title">
<header className="roguelike-pvp-draft-header">
<span>Round {round} cleared · Next: {round + 1}</span>
<DraftStepRail step={draftStep} />
<time dateTime={`PT${remainingSeconds}S`} aria-label={`${remainingSeconds} seconds remaining`}>{remainingSeconds}s</time>
</header>
{draftStep === "buff" && (
<div className="roguelike-pvp-draft-body">
<div className="roguelike-pvp-draft-copy">
<small>Choose for yourself</small>
<h2 id="roguelike-pvp-draft-title">Claim a blessing</h2>
<p>Strengthen one equipped ability for every later round.</p>
</div>
<div className={`roguelike-pvp-choice-grid choice-count-${choices.length}`}>
{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 (
<button
key={buffId}
className={`roguelike-pvp-choice is-buff ${selectedBuffId === buffId ? "is-controller-selected" : ""}`}
style={{ "--roguelike-pvp-choice-accent": buff.accent } as CSSProperties}
onPointerEnter={() => selectBuff(buffId)}
onClick={() => selectBuff(buffId)}
aria-pressed={selectedBuffId === buffId}
>
<i>{buff.icon}</i>
<span><small>{ability.shortName} · Rank {nextRank}/{buff.maxRank}</small><strong>{buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : <EmptyDraftChoice kind="buff" />}
</div>
<div className="roguelike-pvp-draft-actions">
<span><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<button className="is-primary" disabled={!buffReady} onClick={() => setDraftStep("curse")}>Choose rival curse <b></b></button>
</div>
</div>
)}
{draftStep === "curse" && (
<div className="roguelike-pvp-draft-body">
<div className="roguelike-pvp-draft-copy is-curse">
<small>Inflict on {opponentName}</small>
<h2 id="roguelike-pvp-draft-title">Choose their burden</h2>
<p>Curse one rival ability. Repeated curses stack to rank 3.</p>
</div>
<div className={`roguelike-pvp-choice-grid choice-count-${curseChoices.length}`}>
{curseChoices.length > 0 ? curseChoices.map((curseId) => {
const curse = ROGUELIKE_PVP_CURSES[curseId];
const ability = opponentAbilities[curse.abilitySlotId];
return (
<button
key={curseId}
className={`roguelike-pvp-choice is-curse ${selectedCurseId === curseId ? "is-controller-selected" : ""}`}
style={{ "--roguelike-pvp-choice-accent": curse.accent } as CSSProperties}
onPointerEnter={() => selectCurse(curseId)}
onClick={() => selectCurse(curseId)}
aria-pressed={selectedCurseId === curseId}
>
<i>{curse.icon}</i>
<span><small>{ability.shortName} · Add 1 rank</small><strong>{curse.name}</strong></span>
<b>{formatRoguelikePvpCurseEffect(curseId, 1, ability.shortName)}</b>
<p>{curse.detail}</p>
</button>
);
}) : <EmptyDraftChoice kind="curse" />}
</div>
<div className="roguelike-pvp-draft-actions">
<button className="is-back" onClick={() => setDraftStep("buff")}><b></b> Blessing</button>
<span><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<button className="is-primary" disabled={!curseReady} onClick={() => setDraftStep("review")}>Review draft <b></b></button>
</div>
</div>
)}
{draftStep === "review" && (
<div className="roguelike-pvp-review">
<div className="roguelike-pvp-draft-copy">
<small>Hidden until both players lock</small>
<h2 id="roguelike-pvp-draft-title">Seal round {round + 1}</h2>
<p>Confirm blessing and sabotage. Locked choices cannot change.</p>
</div>
<div className="roguelike-pvp-review-cards">
<article className="is-buff" style={{ "--roguelike-pvp-choice-accent": selectedBuff?.accent ?? "#e8c872" } as CSSProperties}>
<i>{selectedBuff?.icon ?? "✦"}</i>
<small>Your blessing</small>
<strong>{selectedBuff ? `${abilities[selectedBuff.abilitySlotId].shortName}: ${selectedBuff.name}` : "No blessing required"}</strong>
<p>{selectedBuff?.summary ?? "Blessing catalog mastered."}</p>
</article>
<b aria-hidden="true">VS</b>
<article className="is-curse" style={{ "--roguelike-pvp-choice-accent": selectedCurse?.accent ?? "#d76762" } as CSSProperties}>
<i>{selectedCurse?.icon ?? "⌁"}</i>
<small>{opponentName}'s burden</small>
<strong>{selectedCurse ? `${opponentAbilities[selectedCurse.abilitySlotId].shortName}: ${selectedCurse.name}` : "No curse required"}</strong>
<p>{selectedCurse?.summary ?? "Curse catalog exhausted."}</p>
</article>
</div>
<div className="roguelike-pvp-draft-actions is-review">
<button className="is-back" onClick={() => setDraftStep("curse")}><b></b> Change</button>
<span>Choices reveal together</span>
<button className="is-primary is-controller-selected" disabled={!buffReady || !curseReady} onClick={submitDraft}>Lock draft <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b></button>
</div>
</div>
)}
</section>
);
}
/** 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 (
<aside className={`roguelike-pvp-status-strip ${className}`.trim()} aria-label={`Roguelike PVP round ${pvp.round} against ${pvp.opponentName}`}>
<header>
<span><small>Round {pvp.round}</small><strong>Rift Race</strong></span>
<b>VS</b>
<span><small>{labelToken(pvp.status)}</small><strong>{pvp.opponentName}</strong></span>
<i data-connection={pvp.connectionStatus} title={labelToken(pvp.connectionStatus)} />
</header>
<div className="roguelike-pvp-status-sides">
<span><small>You · R{pvp.round}</small><ProgressMeter label={`Your boss at ${Math.round(localBossPercent)} percent`} value={localBossPercent} tone="local" /><ProgressMeter label={`Your party at ${Math.round(localPartyPercent)} percent`} value={localPartyPercent} tone="local" /></span>
<span><small>{pvp.opponentName} · R{pvp.opponentRound}</small><ProgressMeter label={`Rival boss at ${Math.round(rivalBossPercent)} percent`} value={rivalBossPercent} tone="rival" /><ProgressMeter label={`Rival party at ${Math.round(pvp.opponentPartyHpPercent)} percent`} value={pvp.opponentPartyHpPercent} tone="rival" /></span>
</div>
</aside>
);
}
/** 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 (
<section className={`roguelike-pvp-tactical ${className}`.trim()} aria-label="Roguelike PVP tactical display">
<header>
<span><small>Competitive run</small><strong>Rift Ledger</strong></span>
<b>Round {pvp.round}</b>
<span className="roguelike-pvp-connection"><i data-connection={pvp.connectionStatus} /><small>{labelToken(pvp.connectionStatus)}</small></span>
</header>
<div className="roguelike-pvp-race-board">
<article className="is-local">
<header><small>Your formation</small><strong>Round {pvp.round}</strong></header>
<div><span>Boss</span><ProgressMeter label={`Your boss at ${Math.round(localBossPercent)} percent`} value={localBossPercent} tone="local" /></div>
<div><span>Party</span><ProgressMeter label={`Your party at ${Math.round(localPartyPercent)} percent`} value={localPartyPercent} tone="local" /></div>
</article>
<b aria-hidden="true">VS</b>
<article className="is-rival">
<header><small>{pvp.opponentName}</small><strong>Round {pvp.opponentRound}</strong></header>
<div><span>Boss</span><ProgressMeter label={`Rival boss at ${Math.round(rivalBossPercent)} percent`} value={rivalBossPercent} tone="rival" /></div>
<div><span>Party</span><ProgressMeter label={`Rival party at ${Math.round(pvp.opponentPartyHpPercent)} percent`} value={pvp.opponentPartyHpPercent} tone="rival" /></div>
</article>
</div>
<div className="roguelike-pvp-curse-ledger">
<header><span><small>Enemy sabotage</small><strong>Active burdens</strong></span><b>{activeCurses.length}</b></header>
{activeCurses.length > 0 ? (
<div className="roguelike-pvp-curse-list">
{activeCurses.map((curseId) => {
const curse = ROGUELIKE_PVP_CURSES[curseId];
const rank = pvp.receivedCurseRanks[curseId] ?? 0;
const ability = abilities[curse.abilitySlotId];
return (
<article key={curseId} style={{ "--roguelike-pvp-choice-accent": curse.accent } as CSSProperties}>
<i>{curse.icon}</i>
<span><small>{ability.shortName} · Rank {rank}/{curse.maxRank}</small><strong>{curse.name}</strong></span>
<b>{formatRoguelikePvpCurseEffect(curseId, rank, ability.shortName)}</b>
</article>
);
})}
</div>
) : (
<div className="roguelike-pvp-curse-empty"><i></i><span><strong>No active burdens</strong><small>First rival curse arrives after round clear.</small></span></div>
)}
</div>
<footer>
<span><i /> Your progress</span>
<span><i /> Rival progress</span>
<b>{labelToken(pvp.status)}</b>
</footer>
</section>
);
}
/** 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 (
<div className={`roguelike-pvp-waiting-overlay ${className}`.trim()} role="status" aria-live="polite">
<i aria-hidden="true"></i>
<span>Round {pvp.round} cleared</span>
<h1>{pvp.localDraftLocked ? "Draft sealed" : "Choose boon and burden"}</h1>
<p>{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."}</p>
<time dateTime={`PT${remainingSeconds}S`}>{remainingSeconds}s</time>
<small>{pvp.opponentDraftLocked ? `${pvp.opponentName} locked` : `${pvp.opponentName} choosing`}</small>
</div>
);
}
+53 -17
View File
@@ -16,6 +16,11 @@ import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanic
import { isSingleScreenLayout } from "../platform/displayLayout"; import { isSingleScreenLayout } from "../platform/displayLayout";
import { ABILITY_ORDER } from "../game/data"; import { ABILITY_ORDER } from "../game/data";
import { AbilityButton } from "./AbilityButton"; 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 })))); const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene"; GameScene.displayName = "MemoizedGameScene";
@@ -170,6 +175,7 @@ function PhaseOverlay() {
const blockbreaker = useGameStore((state) => state.blockbreaker); const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault); const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp); const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds( const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
activityMode === "hockey-healing-pvp" && phase === "briefing", activityMode === "hockey-healing-pvp" && phase === "briefing",
hockeyPvp.countdownEndsAtMs, hockeyPvp.countdownEndsAtMs,
@@ -178,10 +184,16 @@ function PhaseOverlay() {
hockeyPvp.postMatchStatus === "requeueing", hockeyPvp.postMatchStatus === "requeueing",
hockeyPvp.postMatchQueueEndsAtMs, hockeyPvp.postMatchQueueEndsAtMs,
); );
const roguelikePvpCountdownSeconds = useHockeyPvpCountdownSeconds(
runMode === "roguelike-pvp" && phase === "briefing",
roguelikePvp.countdownEndsAtMs,
);
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection); const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
const singleScreen = isSingleScreenLayout(); const singleScreen = isSingleScreenLayout();
if (runMode === "rpg-roguelike") return null; if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />; if (phase === "intermission") return runMode === "roguelike-pvp"
? <RoguelikePvpDraftWaitingOverlay />
: <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id); const room = bossRoomFor(primaryBoss.id);
@@ -190,6 +202,7 @@ function PhaseOverlay() {
const blockbreakerMode = activityMode === "blockbreaker"; const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault"; const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp"; const pvpMode = activityMode === "hockey-healing-pvp";
const roguelikePvpMode = runMode === "roguelike-pvp";
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode; const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode; const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
const briefingMode = hockeyMode const briefingMode = hockeyMode
@@ -200,20 +213,22 @@ function PhaseOverlay() {
? "Endless Arcade Assault" ? "Endless Arcade Assault"
: pvpMode : pvpMode
? `Versus ${hockeyPvp.opponentName}` ? `Versus ${hockeyPvp.opponentName}`
: roguelikePvpMode
? `Versus ${roguelikePvp.opponentName}`
: runMode === "rogue-trials" : runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round" ? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial; : bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
if (phase === "combat") return null; if (phase === "combat") return null;
const title = phase === "briefing" 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" : phase === "victory"
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken` ? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode || roguelikePvpMode ? "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"; : 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" 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" : phase === "victory"
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete" ? 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` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed"; : 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" const copy = phase === "briefing"
? hockeyMode ? 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." ? "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." ? "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 : 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." ? "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(" ") : definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory" : 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 : hockeyMode
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.` ? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
: blockbreakerMode : blockbreakerMode
@@ -233,24 +250,28 @@ function PhaseOverlay() {
: aetherAssaultMode : aetherAssaultMode
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.` ? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.` : pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: 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(" "); : endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
const briefingPrompt = pvpMode const competitivePvpBriefing = pvpMode || roguelikePvpMode;
? pvpCountdownSeconds > 0 const competitivePvpCountdown = roguelikePvpMode ? roguelikePvpCountdownSeconds : pvpCountdownSeconds;
? `Match starts automatically in ${pvpCountdownSeconds}` const briefingPrompt = competitivePvpBriefing
? competitivePvpCountdown > 0
? `Match starts automatically in ${competitivePvpCountdown}`
: "Match starting now" : "Match starting now"
: singleScreen : singleScreen
? "Press Start / Enter to begin" ? "Press Start / Enter to begin"
: "Begin from lower display"; : "Begin from lower display";
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
const roguelikePvpEnded = roguelikePvpMode && (phase === "victory" || phase === "defeat");
return ( return (
<div className={`phase-overlay phase-${phase}`}> <div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div> <div className="phase-sigil"></div>
<span>{eyebrow}</span> <span>{eyebrow}</span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{copy}</p> <p>{copy}</p>
{pvpMode && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${pvpCountdownSeconds} seconds`}> {competitivePvpBriefing && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${competitivePvpCountdown} seconds`}>
<span>Match starts in</span> <span>Match starts in</span>
<strong>{pvpCountdownSeconds}</strong> <strong>{competitivePvpCountdown}</strong>
<small>seconds</small> <small>seconds</small>
</div>} </div>}
{pvpEnded && <> {pvpEnded && <>
@@ -279,8 +300,8 @@ function PhaseOverlay() {
<small>{phase === "briefing" <small>{phase === "briefing"
? briefingPrompt ? briefingPrompt
: singleScreen : 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" : 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" : "Restart from lower display"}</small> : 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"}</small>
</div> </div>
); );
} }
@@ -440,6 +461,7 @@ export function TopScreen({
const blockbreaker = useGameStore((state) => state.blockbreaker); const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault); const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp); const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const time = useGameStore((state) => state.time); const time = useGameStore((state) => state.time);
const setPaused = useGameStore((state) => state.setPaused); const setPaused = useGameStore((state) => state.setPaused);
const rpgRun = useGameStore((state) => state.rpgRun); const rpgRun = useGameStore((state) => state.rpgRun);
@@ -451,8 +473,20 @@ export function TopScreen({
const blockbreakerMode = activityMode === "blockbreaker"; const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault"; const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp"; const pvpMode = activityMode === "hockey-healing-pvp";
const roguelikePvpMode = runMode === "roguelike-pvp";
const onlineRoguelikePvp = roguelikePvpMode && roguelikePvp.role !== "cpu";
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat"); const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`; const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
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 ( return (
<section className="display top-display" aria-label="Main game viewport"> <section className="display top-display" aria-label="Main game viewport">
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}> <Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
@@ -462,7 +496,9 @@ export function TopScreen({
<div className="top-hud"> <div className="top-hud">
<CompactParty /> <CompactParty />
<BossBar /> <BossBar />
<div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{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"}</span><strong>{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"}</strong></div> {roguelikePvpMode && phase === "combat"
? <RoguelikePvpStatusStrip />
: <div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{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"}</span><strong>{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"}</strong></div>}
<EncounterCallout /> <EncounterCallout />
<DampeningIndicator /> <DampeningIndicator />
<CastingBar /> <CastingBar />
@@ -470,7 +506,7 @@ export function TopScreen({
{onExit && <button {onExit && <button
className={`game-menu-button ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`} className={`game-menu-button ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }} onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }}
onClick={() => phase === "combat" ? setPaused(true) : onExit()} onClick={() => phase === "combat" && !onlineRoguelikePvp ? setPaused(true) : onExit()}
><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>} ><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div> </div>
<SingleScreenAbilityBar /> <SingleScreenAbilityBar />
+8 -5
View File
@@ -117,11 +117,11 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
status: "Playable now", status: "Playable now",
}, },
"roguelike-pvp": { "roguelike-pvp": {
eyebrow: "3v3 · mirrored expeditions", eyebrow: "1v1 healer duel · five-person mirrored survival",
title: "Roguelike PvP", title: "Roguelike PvP",
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.", description: "Race a rival healer through identical endless boss rounds. After every clear, draft one stacking buff for your party and one curse for theirs.",
detail: "Draft order, normalized base gear, rival pressure, and sudden-death rules", detail: "Seeded encounters · secret buff-and-curse drafts · normalized base gear · CPU fallback",
status: "Mode shell ready", status: "Playable now",
}, },
"stadium-pvp": { "stadium-pvp": {
eyebrow: "5v5 · objective arena", eyebrow: "5v5 · objective arena",
@@ -150,7 +150,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName); const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required."); if (!normalizedName) throw new Error("Hunter name is required.");
return { return {
schemaVersion: 6, schemaVersion: 7,
slotId, slotId,
hunterName: normalizedName, hunterName: normalizedName,
activeClassId: "priest", activeClassId: "priest",
@@ -175,6 +175,9 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
hockeyHealingPvpWins: 0, hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0, hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0, hockeyHealingPvpBossKills: 0,
roguelikePvpWins: 0,
roguelikePvpLosses: 0,
highestRoguelikePvpRound: 0,
highestBlockbreakerBricks: 0, highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0, longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0, highestBlockbreakerScore: 0,
+139 -1
View File
@@ -1,7 +1,8 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import type { HunterSave, SaveSlotId } from "./types"; import type { HunterSave, SaveSlotId } from "./types";
import type { BossId } from "../game/types"; import type { BossId, HealerClassId, RunBuffId } from "../game/types";
import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp"; import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp";
import type { RoguelikePvpCurseId } from "../game/roguelikePvp";
export interface OnlineAccount { export interface OnlineAccount {
id: number; id: number;
@@ -55,6 +56,70 @@ export interface HockeyPvpExchangeResult {
hostSnapshot: HockeyPvpRemoteSnapshot | null; hostSnapshot: HockeyPvpRemoteSnapshot | null;
} }
export type RoguelikePvpOnlineRole = "host" | "guest";
export type RoguelikePvpSnapshotPhase = "countdown" | "combat" | "draft" | "won" | "lost";
export type RoguelikePvpMatchStatus = "active" | "won-by-forfeit" | "lost-by-forfeit";
export type RoguelikePvpOpponentConnection = "connected" | "grace" | "forfeited";
export interface RoguelikePvpWireSnapshot {
sequence: number;
round: number;
phase: RoguelikePvpSnapshotPhase;
partyHp: [number, number, number, number, number];
bossHp: number;
bossMaxHp: number;
defeatedBosses: number;
}
export interface RoguelikePvpOnlineMatch {
id: string;
mode: "roguelike-pvp";
seed: number;
generation: number;
countdownEndsAtMs: number;
opponentName: string;
opponentHealerClassId: HealerClassId;
role: RoguelikePvpOnlineRole;
}
export interface RoguelikePvpQueueResult {
ticketId: string;
status: "waiting" | "matched";
match?: RoguelikePvpOnlineMatch;
}
export interface RoguelikePvpRematchResult {
status: "waiting" | "matched";
match?: RoguelikePvpOnlineMatch;
}
export interface RoguelikePvpExchangeResult {
status: RoguelikePvpMatchStatus;
opponentConnection: RoguelikePvpOpponentConnection;
opponentLastSeenAtMs: number;
disconnectDeadlineAtMs: number;
serverTimeMs: number;
opponentSnapshot: RoguelikePvpWireSnapshot | null;
hostSnapshot: RoguelikePvpWireSnapshot | null;
}
export interface RoguelikePvpDraftSelection {
buffId: RunBuffId | null;
curseId: RoguelikePvpCurseId | null;
autoPicked?: boolean;
}
export interface RoguelikePvpDraftResult {
status: "waiting" | "revealed";
round: number;
deadlineAtMs: number;
deadlineExpired: boolean;
submitted: boolean;
opponentSubmitted: boolean;
selection?: Required<RoguelikePvpDraftSelection>;
opponentSelection?: Required<RoguelikePvpDraftSelection>;
}
interface TokenStorage { interface TokenStorage {
getItem(key: string): string | null; getItem(key: string): string | null;
setItem(key: string, value: string): void; setItem(key: string, value: string): void;
@@ -246,6 +311,79 @@ export class OnlineRepository {
body: JSON.stringify({ generation }), body: JSON.stringify({ generation }),
}); });
} }
joinRoguelikePvpQueue(
slotId: SaveSlotId,
hunterName: string,
healerClassId: HealerClassId,
): Promise<RoguelikePvpQueueResult> {
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<RoguelikePvpQueueResult> {
return this.request(`/api/roguelike-pvp/queue/${encodeURIComponent(ticketId)}`);
}
cancelRoguelikePvpQueue(ticketId: string): Promise<void> {
return this.request(`/api/roguelike-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
}
exchangeRoguelikePvpState(
matchId: string,
generation: number,
snapshot: RoguelikePvpWireSnapshot,
): Promise<RoguelikePvpExchangeResult> {
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<RoguelikePvpDraftResult> {
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<RoguelikePvpDraftResult> {
return this.request(`/api/roguelike-pvp/matches/${encodeURIComponent(matchId)}/drafts/${round}?generation=${generation}`);
}
submitRoguelikePvpDraft(
matchId: string,
generation: number,
round: number,
selection: RoguelikePvpDraftSelection,
): Promise<RoguelikePvpDraftResult> {
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<RoguelikePvpRematchResult> {
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<void> {
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(); export const onlineRepository = new OnlineRepository();
@@ -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);
});
});
+298
View File
@@ -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<OnlineRepository,
"joinRoguelikePvpQueue" | "pollRoguelikePvpQueue"> & {
cancelRoguelikePvpQueue: (
ticketId: string,
) => Promise<RoguelikePvpQueueResult | void>;
};
type RematchRepository = Pick<OnlineRepository,
"requestRoguelikePvpRematch" | "cancelRoguelikePvpRematch">;
export interface RoguelikePvpMatchOperation {
result: Promise<RoguelikePvpMatchConfig | null>;
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<typeof setTimeout> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
let clockTimer: ReturnType<typeof setInterval> | null = null;
let joinTask: Promise<void> | null = null;
let activePoll: Promise<void> | 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<RoguelikePvpMatchConfig | null>((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<typeof setTimeout> | null = null;
let settle: (match: RoguelikePvpMatchConfig | null) => void = () => undefined;
const result = new Promise<RoguelikePvpMatchConfig | null>((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);
},
};
}
+10 -7
View File
@@ -55,7 +55,7 @@ describe("SaveRepository", () => {
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
expect(repository.listLocal()[0].local?.updatedAt).toBe(now); expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
expect(repository.listLocal()[0].local).toMatchObject({ expect(repository.listLocal()[0].local).toMatchObject({
schemaVersion: 6, schemaVersion: 7,
stats: { stats: {
highestAetherAssaultScore: 0, highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0, highestAetherAssaultWaveAtBest: 0,
@@ -112,7 +112,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!; 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.updatedAt).toBe("2026-07-15T09:30:00.000Z");
expect(migrated.healers.priest.level).toBe(37); expect(migrated.healers.priest.level).toBe(37);
for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) { 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); 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 storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Legacy"); 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 })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!; const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6); expect(migrated.schemaVersion).toBe(7);
expect(migrated.hunterName).toBe("Legacy"); expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest"); expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0); expect(migrated.playSeconds).toBe(0);
@@ -226,6 +226,9 @@ describe("SaveRepository", () => {
hockeyHealingPvpWins: 0, hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0, hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0, hockeyHealingPvpBossKills: 0,
roguelikePvpWins: 0,
roguelikePvpLosses: 0,
highestRoguelikePvpRound: 0,
highestBlockbreakerBricks: 0, highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0, longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0, highestBlockbreakerScore: 0,
@@ -236,7 +239,7 @@ describe("SaveRepository", () => {
expect(migrated.materials).toEqual([]); expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); 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(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", () => { 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 } })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!; 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.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14); 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 } })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!; 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.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
+5 -2
View File
@@ -122,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const candidate = value as LegacyHunterSave; const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null; 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 { try {
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName); return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
} catch { } catch {
@@ -133,7 +133,7 @@ function normalizeSave(value: unknown): HunterSave | null {
const bossKills = normalizeBossKills(candidate.stats?.bossKills); const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest"; const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return { return {
schemaVersion: 6, schemaVersion: 7,
slotId: candidate.slotId, slotId: candidate.slotId,
hunterName: candidate.hunterName, hunterName: candidate.hunterName,
activeClassId, activeClassId,
@@ -158,6 +158,9 @@ function normalizeSave(value: unknown): HunterSave | null {
hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)), hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)),
hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)), hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)),
hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 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)), highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)),
longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0), longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0),
highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)), highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)),
+29 -1
View File
@@ -31,7 +31,7 @@ import {
infusionsForOwner, infusionsForOwner,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; 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 { import {
cloneCharacterAppearance, cloneCharacterAppearance,
type CharacterAppearanceV1, type CharacterAppearanceV1,
@@ -187,6 +187,7 @@ export interface FrontendState {
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void; recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
recordHockeyPvpResult: (won: boolean) => void; recordHockeyPvpResult: (won: boolean) => void;
recordHockeyPvpBossKill: () => void; recordHockeyPvpBossKill: () => void;
recordRoguelikePvpResult: (won: boolean, round: number) => void;
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void; recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void; recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
clearRecentRewards: () => void; clearRecentRewards: () => void;
@@ -668,6 +669,31 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
markSaveSyncPending(activeSlotId); markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) })); 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) => { recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
const { activeSlotId } = get(); const { activeSlotId } = get();
if (!activeSlotId) return; if (!activeSlotId) return;
@@ -771,6 +797,7 @@ export type FrontendSnapshot = Omit<FrontendState,
| "recordBossVictory" | "recordBossVictory"
| "recordRoguelikeDefeat" | "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat" | "recordRogueTrialsEndlessDefeat"
| "recordRoguelikePvpResult"
| "recordHockeyHealingDefeat" | "recordHockeyHealingDefeat"
| "recordHockeyPvpResult" | "recordHockeyPvpResult"
| "recordHockeyPvpBossKill" | "recordHockeyPvpBossKill"
@@ -828,6 +855,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
recordBossVictory: _recordBossVictory, recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat, recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat, recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
recordRoguelikePvpResult: _recordRoguelikePvpResult,
recordHockeyHealingDefeat: _recordHockeyHealingDefeat, recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
recordHockeyPvpResult: _recordHockeyPvpResult, recordHockeyPvpResult: _recordHockeyPvpResult,
recordHockeyPvpBossKill: _recordHockeyPvpBossKill, recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
+4 -1
View File
@@ -51,6 +51,9 @@ export interface HunterStats {
hockeyHealingPvpWins: number; hockeyHealingPvpWins: number;
hockeyHealingPvpLosses: number; hockeyHealingPvpLosses: number;
hockeyHealingPvpBossKills: number; hockeyHealingPvpBossKills: number;
roguelikePvpWins: number;
roguelikePvpLosses: number;
highestRoguelikePvpRound: number;
highestBlockbreakerBricks: number; highestBlockbreakerBricks: number;
longestBlockbreakerSeconds: number; longestBlockbreakerSeconds: number;
highestBlockbreakerScore: number; highestBlockbreakerScore: number;
@@ -66,7 +69,7 @@ export interface HealerProgress {
} }
export interface HunterSave { export interface HunterSave {
schemaVersion: 6; schemaVersion: 7;
slotId: SaveSlotId; slotId: SaveSlotId;
hunterName: string; hunterName: string;
activeClassId: HealerClassId; activeClassId: HealerClassId;
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "./hunterStats"; import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch, roguelikePvpRecordAfterMatch } from "./hunterStats";
describe("roguelike hunter records", () => { describe("roguelike hunter records", () => {
it("records the reached defeat round without lowering a previous best", () => { 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", () => { describe("Blockbreaker records", () => {
it("keeps bricks, duration, and score as independent lifetime highs", () => { it("keeps bricks, duration, and score as independent lifetime highs", () => {
expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({ expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({
+17
View File
@@ -40,6 +40,23 @@ export function hockeyPvpRecordAfterMatch(currentWins: number, currentLosses: nu
return won ? { wins: wins + 1, losses } : { wins, losses: losses + 1 }; 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 { export interface BlockbreakerRecords {
bricks: number; bricks: number;
durationSeconds: number; durationSeconds: number;
+116
View File
@@ -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 });
});
});
+322
View File
@@ -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<Record<RoguelikePvpCurseId, number>>;
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<AbilitySlotId, number>;
cooldownMultipliers: Record<AbilitySlotId, number>;
}
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<AbilitySlotId, string> = {
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<RoguelikePvpCurseId, RoguelikePvpCurseDefinition>;
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<AbilitySlotId, number>;
const cooldownMultipliers = {} as Record<AbilitySlotId, number>;
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 = <T>(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;
}
+283
View File
@@ -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");
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export function isPvpRunMode(runMode: RunMode): boolean {
} }
export function defaultGameplayActivity(runMode: RunMode): GameplayActivity { 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 runMode;
} }
return "boss"; return "boss";
+599 -16
View File
@@ -45,6 +45,7 @@ import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState"; import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
import { import {
RUN_BUFF_ORDER,
RUN_BUFFS, RUN_BUFFS,
bossHealthMultiplier, bossHealthMultiplier,
compileRunModifiers, compileRunModifiers,
@@ -86,6 +87,25 @@ import {
type HockeyPvpRemoteSnapshot, type HockeyPvpRemoteSnapshot,
type HockeyPvpState, type HockeyPvpState,
} from "./hockeyHealingPvp"; } 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 { import type {
ActiveCast, ActiveCast,
AbilityLoadout, AbilityLoadout,
@@ -165,6 +185,39 @@ export interface HockeyPvpOpponentState {
partyCombat: PartyCombatState; 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 { export interface RpgSpellResources {
verdancy: number; verdancy: number;
tidalSurge: number; tidalSurge: number;
@@ -200,6 +253,7 @@ export interface GameState {
aetherAssault: AetherAssaultState; aetherAssault: AetherAssaultState;
hockeyPvp: HockeyPvpState; hockeyPvp: HockeyPvpState;
hockeyPvpOpponent: HockeyPvpOpponentState; hockeyPvpOpponent: HockeyPvpOpponentState;
roguelikePvp: RoguelikePvpState;
runBuffRanks: RunBuffRanks; runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[]; draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId | null; selectedRunBuffId: RunBuffId | null;
@@ -233,7 +287,7 @@ export interface GameState {
activeCast: ActiveCast | null; activeCast: ActiveCast | null;
barrier: BarrierState; barrier: BarrierState;
healerMechanic: HealerMechanicState; 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; startEncounter: () => void;
restart: () => void; restart: () => void;
tick: (delta: number) => void; tick: (delta: number) => void;
@@ -247,6 +301,15 @@ export interface GameState {
setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void; setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void;
setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void; setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void;
applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => 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; setPaused: (paused: boolean) => void;
togglePause: () => void; togglePause: () => void;
setPauseSelection: (selection: "resume" | "exit") => void; setPauseSelection: (selection: "resume" | "exit") => void;
@@ -273,6 +336,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5; export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500; export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export const ROGUELIKE_PVP_DRAFT_SECONDS = 15;
export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana"; export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
export const BARRIER_RADIUS = 4; 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) }; return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
} }
function effectiveHealingMultiplier(state: Pick<GameState, "runMode" | "healingMultiplier" | "endlessBossKills" | "hockeyPvp">): number { function effectiveHealingMultiplier(state: Pick<GameState, "runMode" | "round" | "healingMultiplier" | "endlessBossKills" | "hockeyPvp">): number {
if (state.runMode !== "hockey-healing-pvp") return state.healingMultiplier; if (state.runMode === "hockey-healing-pvp") {
return state.healingMultiplier * hockeyPvpHealingEffectiveness( return state.healingMultiplier * hockeyPvpHealingEffectiveness(
state.endlessBossKills, state.endlessBossKills,
state.hockeyPvp.opponentBossKills, 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) { export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
@@ -547,14 +657,21 @@ function initialState(
gearProgress: GearProgress = createDefaultGearProgress(), gearProgress: GearProgress = createDefaultGearProgress(),
requestedDifficultySlug: DifficultySlug = "initiate", requestedDifficultySlug: DifficultySlug = "initiate",
seenBossIds: readonly BossId[] = [], seenBossIds: readonly BossId[] = [],
hockeyPvpMatch?: HockeyPvpMatchConfig, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig,
requestedAbilityLoadout?: AbilityLoadout, requestedAbilityLoadout?: AbilityLoadout,
) { ) {
const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug); const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
const difficulty = DIFFICULTY_BY_SLUG[difficultySlug]; 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 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 layout: EncounterLayout = hockeyLayout ? "hockey" : "standard";
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId, bossId,
@@ -578,7 +695,7 @@ function initialState(
: []; : [];
const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers); const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
const opponentParty = applyGearHealth( const opponentParty = applyGearHealth(
freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? "CPU Willow"), freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? roguelikePvpMatch?.opponentName ?? "CPU Willow"),
gearModifiers, gearModifiers,
); );
const opponentBoss = createEncounterBoss( const opponentBoss = createEncounterBoss(
@@ -628,6 +745,12 @@ function initialState(
bossMotion: opponentBoss.motion, bossMotion: opponentBoss.motion,
partyCombat: createPartyCombatState(opponentParty), partyCombat: createPartyCombatState(opponentParty),
} as HockeyPvpOpponentState, } as HockeyPvpOpponentState,
roguelikePvp: createRoguelikePvpState(
roguelikePvpMatch,
round,
encounterBosses.reduce((total, entry) => total + entry.boss.maxHp, 0),
healerClassId,
),
runBuffRanks: { ...runBuffRanks }, runBuffRanks: { ...runBuffRanks },
draftBuffIds, draftBuffIds,
selectedRunBuffId: draftBuffIds[0] ?? null, selectedRunBuffId: draftBuffIds[0] ?? null,
@@ -637,7 +760,8 @@ function initialState(
healingMultiplier: gearModifiers.aelia.healingPower, healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug, difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier 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, gearProgress,
gearModifiers, gearModifiers,
time: 0, time: 0,
@@ -665,6 +789,165 @@ function initialState(
}; };
} }
function roguelikePvpMatchConfig(state: Pick<GameState, "roguelikePvp">): 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<RunBuffId>([
"mend-echo", "mend-efficiency", "mend-cast-speed",
"purify-renew", "purify-shield", "purify-chain",
"barrier-cooldown", "barrier-duration",
])
: new Set<RunBuffId>([
"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<GameState, "round" | "healerClassId" | "roguelikePvp">): 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<GameState, "round" | "time" | "healerClassId" | "roguelikePvp">,
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 { function rpgActivityForRun(run: RpgRoguelikeRunState): GameplayActivity {
if (run.phase !== "challenge-active" && run.phase !== "challenge-briefing") return "boss"; if (run.phase !== "challenge-active" && run.phase !== "challenge-briefing") return "boss";
const challengeId = run.currentChallenge?.objective.challengeId; const challengeId = run.currentChallenge?.objective.challengeId;
@@ -799,8 +1082,19 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
export const useGameStore = create<GameState>((set, get) => ({ export const useGameStore = create<GameState>((set, get) => ({
...initialState(), ...initialState(),
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", hockeyPvpMatch) => { configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", pvpMatch) => {
const base = initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug, [], hockeyPvpMatch); 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") { if (runMode !== "rpg-roguelike") {
set(base); set(base);
return; return;
@@ -823,13 +1117,16 @@ export const useGameStore = create<GameState>((set, get) => ({
if (current.runMode === "hockey-healing-pvp" if (current.runMode === "hockey-healing-pvp"
&& current.phase === "briefing" && current.phase === "briefing"
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return; && 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.runMode === "rpg-roguelike" && current.rpgRun) {
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" }); if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" }); else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
else if (current.rpgRun.phase === "boss-cleared") current.dispatchRpgAction({ type: "reward-open" }); else if (current.rpgRun.phase === "boss-cleared") current.dispatchRpgAction({ type: "reward-open" });
return; 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)]; const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({ set({
...initialState( ...initialState(
@@ -845,17 +1142,26 @@ export const useGameStore = create<GameState>((set, get) => ({
seenBossIds, seenBossIds,
runMode === "hockey-healing-pvp" runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: 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, : undefined,
abilityLoadout, abilityLoadout,
), ),
phase: "combat", phase: "combat",
activeTab: "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" }], combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
}); });
}, },
restart: () => { 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") { if (runMode === "rpg-roguelike") {
const base = initialState( const base = initialState(
healerClassId, healerClassId,
@@ -884,6 +1190,8 @@ export const useGameStore = create<GameState>((set, get) => ({
? selectRandomBossPair() ? selectRandomBossPair()
: runMode === "hockey-healing-pvp" : runMode === "hockey-healing-pvp"
? [hockeyPvpBossAt(hockeyPvp.seed, 0)] ? [hockeyPvpBossAt(hockeyPvp.seed, 0)]
: runMode === "roguelike-pvp"
? roguelikePvpBossesForRound(roguelikePvp.seed, 1)
: [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set(initialState( set(initialState(
healerClassId, healerClassId,
@@ -898,6 +1206,18 @@ export const useGameStore = create<GameState>((set, get) => ({
[], [],
runMode === "hockey-healing-pvp" runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs } ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: 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, : undefined,
abilityLoadout, abilityLoadout,
)); ));
@@ -1183,6 +1503,123 @@ export const useGameStore = create<GameState>((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) => { castAbility: (abilitySlotId) => {
const state = get(); const state = get();
if (state.phase !== "combat") return false; if (state.phase !== "combat") return false;
@@ -1197,7 +1634,14 @@ export const useGameStore = create<GameState>((set, get) => ({
const spellPower = state.rpgRun const spellPower = state.rpgRun
? spellRankPowerMultiplier(state.rpgRun.spellRanks, ability.id) ? spellRankPowerMultiplier(state.rpgRun.spellRanks, ability.id)
: 1; : 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 selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
const selected = state.party[selectedIndex]; const selected = state.party[selectedIndex];
@@ -1536,9 +1980,17 @@ export const useGameStore = create<GameState>((set, get) => ({
break; 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 cooldowns[abilitySlotId] = ability.cooldown > 0
? state.time ? state.time
+ runAbilityCooldown(abilitySlotId, ability.cooldown, state.runModifiers) + abilityCooldown
* (state.rpgRun ? spellRankCooldownMultiplier(state.rpgRun.spellRanks, ability.id) : 1) * (state.rpgRun ? spellRankCooldownMultiplier(state.rpgRun.spellRanks, ability.id) : 1)
* state.gearModifiers.aelia.cooldown * state.gearModifiers.aelia.cooldown
: 0; : 0;
@@ -1567,7 +2019,42 @@ export const useGameStore = create<GameState>((set, get) => ({
tick: (delta) => { tick: (delta) => {
const state = get(); 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 oldTime = state.time;
const time = oldTime + Math.min(delta, 2); const time = oldTime + Math.min(delta, 2);
@@ -1610,6 +2097,7 @@ export const useGameStore = create<GameState>((set, get) => ({
let endlessBossKills = state.endlessBossKills; let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence; let endlessSpawnSequence = state.endlessSpawnSequence;
let hockeyPvp = { ...state.hockeyPvp }; let hockeyPvp = { ...state.hockeyPvp };
let roguelikePvp = advanceCpuRoguelikePvp(state, time - oldTime);
let hockeyPvpOpponent: HockeyPvpOpponentState = { let hockeyPvpOpponent: HockeyPvpOpponentState = {
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })), party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })),
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions), partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
@@ -2128,6 +2616,7 @@ export const useGameStore = create<GameState>((set, get) => ({
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost"; const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost"; const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
const pvpMode = state.activityMode === "hockey-healing-pvp"; const pvpMode = state.activityMode === "hockey-healing-pvp";
const roguelikePvpMode = state.runMode === "roguelike-pvp";
const localPvpTeamDefeated = pvpMode && allCompanionsDefeated; const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party); const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active"; const rpgChallengeActive = rpgRun?.phase === "challenge-active";
@@ -2210,6 +2699,54 @@ export const useGameStore = create<GameState>((set, get) => ({
} else if (rpgRun?.phase === "boss-cleared") { } else if (rpgRun?.phase === "boss-cleared") {
phase = "combat"; phase = "combat";
endlessMode = false; 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) { } else if (pvpMode) {
if (localPvpTeamDefeated) { if (localPvpTeamDefeated) {
phase = "defeat"; phase = "defeat";
@@ -2270,7 +2807,7 @@ export const useGameStore = create<GameState>((set, get) => ({
endlessMode, endlessMode,
rpgRun, rpgRun,
rpgFocusId, rpgFocusId,
activeTab: pvpMode && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab, activeTab: (pvpMode || roguelikePvpMode) && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab,
endlessBossKills, endlessBossKills,
endlessSpawnSequence, endlessSpawnSequence,
hockey, hockey,
@@ -2278,6 +2815,7 @@ export const useGameStore = create<GameState>((set, get) => ({
aetherAssault, aetherAssault,
hockeyPvp, hockeyPvp,
hockeyPvpOpponent, hockeyPvpOpponent,
roguelikePvp,
runBuffInputUnlockAt, runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)), mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast, activeCast,
@@ -2305,6 +2843,15 @@ export type GameSnapshot = Omit<GameState,
| "setHockeyPvpPostMatchSelection" | "setHockeyPvpPostMatchSelection"
| "setHockeyPvpPostMatchStatus" | "setHockeyPvpPostMatchStatus"
| "applyHockeyPvpRemoteSnapshot" | "applyHockeyPvpRemoteSnapshot"
| "selectRoguelikePvpBuff"
| "selectRoguelikePvpCurse"
| "setRoguelikePvpDraftStep"
| "submitRoguelikePvpDraft"
| "applyRoguelikePvpDraftReveal"
| "applyRoguelikePvpRemoteSnapshot"
| "syncRoguelikePvpDraft"
| "resolveRoguelikePvpMatch"
| "setRoguelikePvpConnectionStatus"
| "setPaused" | "setPaused"
| "togglePause" | "togglePause"
| "setPauseSelection" | "setPauseSelection"
@@ -2336,6 +2883,15 @@ export function getGameSnapshot(): GameSnapshot {
setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection, setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection,
setHockeyPvpPostMatchStatus: _setHockeyPvpPostMatchStatus, setHockeyPvpPostMatchStatus: _setHockeyPvpPostMatchStatus,
applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot, applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
selectRoguelikePvpBuff: _selectRoguelikePvpBuff,
selectRoguelikePvpCurse: _selectRoguelikePvpCurse,
setRoguelikePvpDraftStep: _setRoguelikePvpDraftStep,
submitRoguelikePvpDraft: _submitRoguelikePvpDraft,
applyRoguelikePvpDraftReveal: _applyRoguelikePvpDraftReveal,
applyRoguelikePvpRemoteSnapshot: _applyRoguelikePvpRemoteSnapshot,
syncRoguelikePvpDraft: _syncRoguelikePvpDraft,
resolveRoguelikePvpMatch: _resolveRoguelikePvpMatch,
setRoguelikePvpConnectionStatus: _setRoguelikePvpConnectionStatus,
setPaused: _setPaused, setPaused: _setPaused,
togglePause: _togglePause, togglePause: _togglePause,
setPauseSelection: _setPauseSelection, setPauseSelection: _setPauseSelection,
@@ -2383,6 +2939,33 @@ export function getHockeyPvpNetworkSnapshot(): HockeyPvpRemoteSnapshot | null {
}; };
} }
export function getRoguelikePvpNetworkSnapshot(): RoguelikePvpRemoteSnapshot | null {
const state = useGameStore.getState();
if (state.runMode !== "roguelike-pvp" || state.roguelikePvp.role === "cpu") return null;
const bosses = [state.boss, ...state.additionalBosses.map((entry) => 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<AbilitySlotId, number>) { export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record<AbilitySlotId, number>) {
return Math.max(0, cooldowns[abilitySlotId] - time); return Math.max(0, cooldowns[abilitySlotId] - time);
} }
+2 -2
View File
@@ -94,8 +94,8 @@ export type BossMechanicId =
| "soul-siphon"; | "soul-siphon";
export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type BossAnimationCue = "idle" | "move" | "attack" | "special";
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; 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 RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "roguelike-pvp" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault";
export type GameplayActivity = "boss" | "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 = export type RunBuffId =
| "mend-echo" | "mend-echo"
| "mend-efficiency" | "mend-efficiency"
+84 -4
View File
@@ -9,6 +9,7 @@ import { getDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout"; import { isSingleScreenLayout } from "../platform/displayLayout";
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync"; import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp"; import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
import { isPvpRunMode } from "./runModes";
function tacticalOverlayOwnsInput() { function tacticalOverlayOwnsInput() {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -26,6 +27,51 @@ function cycleRunBuff(direction: 1 | -1) {
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); 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() { function rpgInputIsGated() {
const run = useGameStore.getState().rpgRun; const run = useGameStore.getState().rpgRun;
return Boolean(run && run.phase !== "challenge-active" && run.phase !== "boss-combat"); return Boolean(run && run.phase !== "challenge-active" && run.phase !== "boss-combat");
@@ -80,7 +126,14 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
return; return;
} }
if (store.phase === "intermission") { 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 (isRunBuffInputLocked(store)) return;
if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1);
if (key === "arrowright" || key === "arrowdown") 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?.(); if (key === "escape") exitRef.current?.();
return; 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; const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) { if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]); store.castAbility(ABILITY_ORDER[numberIndex]);
@@ -130,12 +192,12 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
store.setActiveTab(store.activeTab === "map" ? "combat" : "map"); store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
break; break;
case "i": case "i":
if (store.runMode !== "hockey-healing-pvp") { if (!isPvpRunMode(store.runMode)) {
store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack"); store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack");
} }
break; break;
case "p": case "p":
if (store.runMode === "hockey-healing-pvp") { if (isPvpRunMode(store.runMode)) {
store.setActiveTab(store.activeTab === "pvp" ? "combat" : "pvp"); store.setActiveTab(store.activeTab === "pvp" ? "combat" : "pvp");
} }
break; break;
@@ -144,7 +206,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (store.phase === "victory" || store.phase === "defeat") store.restart(); if (store.phase === "victory" || store.phase === "defeat") store.restart();
break; break;
case "escape": 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?.(); else exitRef.current?.();
break; break;
} }
@@ -179,6 +242,13 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
return; return;
} }
if (store.phase === "intermission") { 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 (isRunBuffInputLocked(store)) return;
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1);
if (["Button13", "Button15", "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?.(); if (!repeat && token === "Button1") exitRef.current?.();
return; 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 (repeat) return;
if (token.startsWith("Button")) { if (token.startsWith("Button")) {
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))]; const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
@@ -221,6 +300,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) { if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) {
if (store.phase === "briefing") store.startEncounter(); if (store.phase === "briefing") store.startEncounter();
else if (store.phase === "victory" || store.phase === "defeat") store.restart(); else if (store.phase === "victory" || store.phase === "defeat") store.restart();
else if (store.phase === "combat" && store.runMode === "roguelike-pvp" && store.roguelikePvp.role !== "cpu") exitRef.current?.();
else if (store.phase === "combat") store.setPaused(true); else if (store.phase === "combat") store.setPaused(true);
} }
}), [enabled]); }), [enabled]);
+10 -2
View File
@@ -11,6 +11,7 @@ import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp";
import type { RoguelikePvpMatchConfig } from "../game/roguelikePvp";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
@@ -70,8 +71,8 @@ export function BottomDisplayApp() {
channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
}, []); }, []);
const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => {
postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, hockeyPvpMatch }); postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, pvpMatch });
}, [postFrontendCommand]); }, [postFrontendCommand]);
useEffect(() => { useEffect(() => {
@@ -195,6 +196,13 @@ export function BottomDisplayApp() {
}, },
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }), setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", 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) => { dispatchRpgAction: (action) => {
postCommand({ name: "dispatchRpgAction", action }); postCommand({ name: "dispatchRpgAction", action });
return false; return false;
+23
View File
@@ -30,6 +30,8 @@ function snapshot(): BottomGameSnapshot {
endlessMode: false, endlessMode: false,
endlessBossKills: 0, endlessBossKills: 0,
endlessChoiceSelection: "continue", endlessChoiceSelection: "continue",
runBuffRanks: {},
passiveRunBuffId: null,
runModifiers: { runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
@@ -92,6 +94,7 @@ function snapshot(): BottomGameSnapshot {
bossMotion: createBossMotionState("bulldrome"), bossMotion: createBossMotionState("bulldrome"),
partyCombat: createPartyCombatState(freshParty()), partyCombat: createPartyCombatState(freshParty()),
}, },
roguelikePvp: structuredClone(useGameStore.getState().roguelikePvp),
}; };
} }
@@ -119,6 +122,10 @@ describe("dual-screen game snapshots", () => {
const originalContinue = useGameStore.getState().continueRoguelikeRound; const originalContinue = useGameStore.getState().continueRoguelikeRound;
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless; const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
const originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection; const 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 originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView; const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView;
@@ -129,6 +136,10 @@ describe("dual-screen game snapshots", () => {
continueRoguelikeRound: () => { calls.push("continue"); return true; }, continueRoguelikeRound: () => { calls.push("continue"); return true; },
startRogueTrialsEndless: () => { calls.push("endless"); return true; }, startRogueTrialsEndless: () => { calls.push("endless"); return true; },
setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); }, 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({ useFrontendStore.setState({
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
@@ -141,6 +152,10 @@ describe("dual-screen game snapshots", () => {
executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "continueRoguelikeRound" });
executeGameCommand({ name: "startRogueTrialsEndless" }); executeGameCommand({ name: "startRogueTrialsEndless" });
executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" }); 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: "selectPassiveAbility", abilityId: "ability3" });
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" }); executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
@@ -150,6 +165,10 @@ describe("dual-screen game snapshots", () => {
"continue", "continue",
"endless", "endless",
"pvp:requeue", "pvp:requeue",
"rogue-buff:mend-efficiency",
"rogue-curse:ability1-mana-cost",
"rogue-step:curse",
"rogue-submit",
"ability:ability3", "ability:ability3",
"passive:shield-guard", "passive:shield-guard",
"profile-view:stats", "profile-view:stats",
@@ -161,6 +180,10 @@ describe("dual-screen game snapshots", () => {
continueRoguelikeRound: originalContinue, continueRoguelikeRound: originalContinue,
startRogueTrialsEndless: originalStartEndless, startRogueTrialsEndless: originalStartEndless,
setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection, setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection,
selectRoguelikePvpBuff: originalSelectRoguelikePvpBuff,
selectRoguelikePvpCurse: originalSelectRoguelikePvpCurse,
setRoguelikePvpDraftStep: originalSetRoguelikePvpDraftStep,
submitRoguelikePvpDraft: originalSubmitRoguelikePvpDraft,
}); });
useFrontendStore.setState({ useFrontendStore.setState({
selectPassiveAbility: originalSelectAbility, selectPassiveAbility: originalSelectAbility,
+31 -4
View File
@@ -10,6 +10,7 @@ import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
import type { DifficultySlug } from "../game/progression/loot"; import type { DifficultySlug } from "../game/progression/loot";
import type { BossGroupId } from "../game/bossCatalog"; import type { BossGroupId } from "../game/bossCatalog";
import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp"; import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp";
import type { RoguelikePvpCurseId, RoguelikePvpMatchConfig } from "../game/roguelikePvp";
import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike"; import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike";
import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance"; import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance";
@@ -31,6 +32,10 @@ export type GameCommand =
| { name: "startRogueTrialsEndless" } | { name: "startRogueTrialsEndless" }
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" } | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
| { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection } | { 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: "dispatchRpgAction"; action: RpgRoguelikeAction }
| { name: "setRpgFocusId"; focusId: string } | { name: "setRpgFocusId"; focusId: string }
| { name: "cycleRpgFocus"; direction: 1 | -1 } | { name: "cycleRpgFocus"; direction: 1 | -1 }
@@ -79,7 +84,14 @@ export type FrontendCommand =
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
| { name: "hockeyPvpPostMatch"; action: Exclude<HockeyPvpPostMatchSelection, "menu"> } | { name: "hockeyPvpPostMatch"; action: Exclude<HockeyPvpPostMatchSelection, "menu"> }
| { name: "exitGame" } | { name: "exitGame" }
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig }; | {
name: "launchGame";
bossIds: readonly BossId[];
difficultySlug?: DifficultySlug;
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_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game"; export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
@@ -122,6 +134,10 @@ export function executeGameCommand(command: GameCommand) {
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break; case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break; case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break; case "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 "dispatchRpgAction": game.dispatchRpgAction(command.action); break;
case "setRpgFocusId": game.setRpgFocusId(command.focusId); break; case "setRpgFocusId": game.setRpgFocusId(command.focusId); break;
case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break; case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break;
@@ -174,7 +190,12 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break;
case "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); break; case "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); break;
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break; case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break; case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: {
bossIds: command.bossIds,
difficultySlug: command.difficultySlug,
pvpMatch: command.pvpMatch ?? command.hockeyPvpMatch,
hockeyPvpMatch: command.hockeyPvpMatch,
} })); break;
} }
} }
@@ -202,6 +223,8 @@ export type BottomGameSnapshot = Pick<GameState,
| "endlessMode" | "endlessMode"
| "endlessBossKills" | "endlessBossKills"
| "endlessChoiceSelection" | "endlessChoiceSelection"
| "runBuffRanks"
| "passiveRunBuffId"
| "runModifiers" | "runModifiers"
| "time" | "time"
| "party" | "party"
@@ -227,12 +250,13 @@ export type BottomGameSnapshot = Pick<GameState,
| "aetherAssault" | "aetherAssault"
| "hockeyPvp" | "hockeyPvp"
| "hockeyPvpOpponent" | "hockeyPvpOpponent"
| "roguelikePvp"
>; >;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ 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", "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 { function structurallyEqual(left: unknown, right: unknown): boolean {
@@ -270,6 +294,8 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
endlessMode: state.endlessMode, endlessMode: state.endlessMode,
endlessBossKills: state.endlessBossKills, endlessBossKills: state.endlessBossKills,
endlessChoiceSelection: state.endlessChoiceSelection, endlessChoiceSelection: state.endlessChoiceSelection,
runBuffRanks: state.runBuffRanks,
passiveRunBuffId: state.passiveRunBuffId,
runModifiers: state.runModifiers, runModifiers: state.runModifiers,
time: state.time, time: state.time,
party: state.party, party: state.party,
@@ -295,6 +321,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
aetherAssault: state.aetherAssault, aetherAssault: state.aetherAssault,
hockeyPvp: state.hockeyPvp, hockeyPvp: state.hockeyPvp,
hockeyPvpOpponent: state.hockeyPvpOpponent, hockeyPvpOpponent: state.hockeyPvpOpponent,
roguelikePvp: state.roguelikePvp,
}; };
} }
+440
View File
@@ -249,6 +249,25 @@ html[data-display-layout="single"] .app-header {
bottom: auto; 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 { html[data-display-layout="single"] .top-party {
width: clamp(185px, 20cqw, 330px); width: clamp(185px, 20cqw, 330px);
gap: clamp(3px, .55cqh, 7px); 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 b { font-size: 10px; }
.mode-loot-preview small { color: #71867e; font-size: 7px; } .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 { padding: 0 28px; }
.gear-surface .front-screen-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; } .gear-surface .front-screen-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; }
.gear-mode-tabs { display: flex; gap: 4px; } .gear-mode-tabs { display: flex; gap: 4px; }