Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e36ca1a41 | ||
|
|
018e060cdd | ||
|
|
437e70fc58 |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.18",
|
"version": "0.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
+781
-3
@@ -7,6 +7,62 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
|
const 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) {
|
||||||
@@ -179,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) {
|
||||||
@@ -228,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),
|
||||||
@@ -638,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;
|
||||||
@@ -650,6 +1284,8 @@ export function createGameApiHandler(options = {}) {
|
|||||||
match: {
|
match: {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
seed: match.seed,
|
seed: match.seed,
|
||||||
|
generation: match.generation,
|
||||||
|
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||||
opponentName: opponent.hunterName,
|
opponentName: opponent.hunterName,
|
||||||
role: ticket.side,
|
role: ticket.side,
|
||||||
},
|
},
|
||||||
@@ -692,9 +1328,12 @@ export function createGameApiHandler(options = {}) {
|
|||||||
const match = {
|
const match = {
|
||||||
id: matchId,
|
id: matchId,
|
||||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||||
|
generation: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
players: { host: opponent, guest: ticket },
|
players: { host: opponent, guest: ticket },
|
||||||
snapshots: { host: null, guest: null },
|
snapshots: { host: null, guest: null },
|
||||||
|
rematch: null,
|
||||||
};
|
};
|
||||||
opponent.matchId = matchId;
|
opponent.matchId = matchId;
|
||||||
opponent.side = "host";
|
opponent.side = "host";
|
||||||
@@ -722,6 +1361,72 @@ export function createGameApiHandler(options = {}) {
|
|||||||
return { match, side };
|
return { match, side };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hockeyPvpRematchResult(match, side, requestedGeneration) {
|
||||||
|
const rematch = match.rematch;
|
||||||
|
if (!rematch || rematch.fromGeneration !== requestedGeneration || !rematch.ready) {
|
||||||
|
return { status: "waiting" };
|
||||||
|
}
|
||||||
|
const opponentSide = side === "host" ? "guest" : "host";
|
||||||
|
return {
|
||||||
|
status: "matched",
|
||||||
|
match: {
|
||||||
|
id: match.id,
|
||||||
|
seed: rematch.seed,
|
||||||
|
generation: rematch.toGeneration,
|
||||||
|
countdownEndsAtMs: rematch.countdownEndsAtMs,
|
||||||
|
opponentName: match.players[opponentSide].hunterName,
|
||||||
|
role: side,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHockeyPvpRematch(session, matchId, payload) {
|
||||||
|
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||||
|
const generation = Number(payload?.generation);
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||||
|
|
||||||
|
if (generation < match.generation) {
|
||||||
|
if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) {
|
||||||
|
throw apiError("PVP match generation is stale.", 409);
|
||||||
|
}
|
||||||
|
return hockeyPvpRematchResult(match, side, generation);
|
||||||
|
}
|
||||||
|
if (generation > match.generation) throw apiError("PVP match generation is invalid.", 409);
|
||||||
|
|
||||||
|
if (!match.rematch || match.rematch.fromGeneration !== generation) {
|
||||||
|
match.rematch = {
|
||||||
|
fromGeneration: generation,
|
||||||
|
toGeneration: generation + 1,
|
||||||
|
requested: { host: false, guest: false },
|
||||||
|
ready: false,
|
||||||
|
seed: 0,
|
||||||
|
countdownEndsAtMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match.rematch.requested[side] = true;
|
||||||
|
if (!match.rematch.ready && match.rematch.requested.host && match.rematch.requested.guest) {
|
||||||
|
const now = Date.now();
|
||||||
|
match.rematch.ready = true;
|
||||||
|
match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1;
|
||||||
|
match.rematch.countdownEndsAtMs = now + HOCKEY_PVP_COUNTDOWN_MS;
|
||||||
|
match.seed = match.rematch.seed;
|
||||||
|
match.generation = match.rematch.toGeneration;
|
||||||
|
match.countdownEndsAtMs = match.rematch.countdownEndsAtMs;
|
||||||
|
match.snapshots = { host: null, guest: null };
|
||||||
|
}
|
||||||
|
return hockeyPvpRematchResult(match, side, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelHockeyPvpRematch(session, matchId, payload) {
|
||||||
|
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||||
|
const generation = Number(payload?.generation);
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||||
|
if (match.rematch?.fromGeneration === generation && !match.rematch.ready) {
|
||||||
|
match.rematch.requested[side] = false;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(request, response, next) {
|
async function handle(request, response, next) {
|
||||||
if (!request.url?.startsWith("/api/")) return next();
|
if (!request.url?.startsWith("/api/")) return next();
|
||||||
setCorsHeaders(request, response);
|
setCorsHeaders(request, response);
|
||||||
@@ -758,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)));
|
||||||
}
|
}
|
||||||
@@ -776,6 +1544,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
if (pvpStateMatch && request.method === "PUT") {
|
if (pvpStateMatch && request.method === "PUT") {
|
||||||
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
||||||
const payload = await readJson(request);
|
const payload = await readJson(request);
|
||||||
|
if (payload?.generation !== match.generation) throw apiError("PVP match generation is stale.", 409);
|
||||||
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
|
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
|
||||||
match.snapshots[side] = payload.snapshot;
|
match.snapshots[side] = payload.snapshot;
|
||||||
return sendJson(response, 200, {
|
return sendJson(response, 200, {
|
||||||
@@ -783,6 +1552,13 @@ export function createGameApiHandler(options = {}) {
|
|||||||
hostSnapshot: match.snapshots.host,
|
hostSnapshot: match.snapshots.host,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const pvpRematchMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/);
|
||||||
|
if (pvpRematchMatch && request.method === "POST") {
|
||||||
|
return sendJson(response, 200, requestHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||||
|
}
|
||||||
|
if (pvpRematchMatch && request.method === "DELETE") {
|
||||||
|
return sendJson(response, 200, cancelHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||||
|
}
|
||||||
if (path === "/api/saves" && request.method === "GET") {
|
if (path === "/api/saves" && request.method === "GET") {
|
||||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||||
}
|
}
|
||||||
@@ -850,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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+558
-5
@@ -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);
|
||||||
@@ -228,6 +229,8 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(betaQueue.body.status, "matched");
|
assert.equal(betaQueue.body.status, "matched");
|
||||||
assert.equal(betaQueue.body.match.role, "guest");
|
assert.equal(betaQueue.body.match.role, "guest");
|
||||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||||
|
assert.equal(betaQueue.body.match.generation, 1);
|
||||||
|
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||||
@@ -236,20 +239,570 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(alphaMatched.body.match.role, "host");
|
assert.equal(alphaMatched.body.match.role, "host");
|
||||||
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
||||||
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
||||||
|
assert.equal(alphaMatched.body.match.generation, betaQueue.body.match.generation);
|
||||||
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ snapshot: hostSnapshot }),
|
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
||||||
});
|
});
|
||||||
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
|
body: JSON.stringify({ generation: 1, snapshot: { sequence: 1, party: [] } }),
|
||||||
});
|
});
|
||||||
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
||||||
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
||||||
|
|
||||||
|
const alphaRematchWaiting = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematchWaiting.body.status, "waiting");
|
||||||
|
|
||||||
|
const betaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(betaRematchReady.body.status, "matched");
|
||||||
|
assert.equal(betaRematchReady.body.match.generation, 2);
|
||||||
|
assert.ok(betaRematchReady.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
|
const alphaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(alphaRematchReady.body.status, "matched");
|
||||||
|
assert.equal(alphaRematchReady.body.match.generation, 2);
|
||||||
|
assert.equal(alphaRematchReady.body.match.seed, betaRematchReady.body.match.seed);
|
||||||
|
assert.equal(alphaRematchReady.body.match.countdownEndsAtMs, betaRematchReady.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
|
const staleExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(staleExchange.response.status, 409);
|
||||||
|
|
||||||
|
const freshExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation: 2, snapshot: hostSnapshot }),
|
||||||
|
});
|
||||||
|
assert.equal(freshExchange.response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("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 () => {
|
||||||
|
|||||||
+341
-13
@@ -3,15 +3,20 @@ 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";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH_EVENT } from "./platform/dualScreenSync";
|
||||||
import { startSaveSyncCoordinator } from "./frontend/saveSync";
|
import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
|
||||||
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
|
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
|
||||||
import { onlineRepository } from "./frontend/onlineRepository";
|
import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp";
|
||||||
|
import { onlineRepository, type RoguelikePvpWireSnapshot } from "./frontend/onlineRepository";
|
||||||
|
import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking";
|
||||||
|
import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot, type RoguelikePvpStatus } from "./game/roguelikePvp";
|
||||||
|
import type { RoguelikePvpMatchConfig } from "./frontend/roguelikePvpMatchmaking";
|
||||||
|
import { createClassInventory } from "./game/healers";
|
||||||
|
|
||||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
const 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 })));
|
||||||
@@ -30,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();
|
||||||
@@ -43,18 +129,30 @@ 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);
|
||||||
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
|
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
|
||||||
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
|
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
|
||||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||||
|
const gamePhase = useGameStore((state) => state.phase);
|
||||||
|
const gameRunMode = useGameStore((state) => state.runMode);
|
||||||
|
const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const 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 screenRef = useRef(screen);
|
const screenRef = useRef(screen);
|
||||||
screenRef.current = screen;
|
screenRef.current = screen;
|
||||||
const leaveGame = useCallback(() => {
|
const leaveGame = useCallback(() => {
|
||||||
|
hockeyPvpPostMatchOperation.current?.cancel();
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
|
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);
|
||||||
@@ -62,7 +160,68 @@ function MainApp() {
|
|||||||
navigate("home");
|
navigate("home");
|
||||||
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
||||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
||||||
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
|
|
||||||
|
const launchHockeyPvpMatch = useCallback((match: HockeyPvpMatchConfig) => {
|
||||||
|
if (!hunter) return;
|
||||||
|
const progress = hunter.healers[hunter.activeClassId];
|
||||||
|
rewardedBossInstances.current.clear();
|
||||||
|
clearRecentRewards();
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
hunter.activeClassId,
|
||||||
|
hunter.hunterName,
|
||||||
|
progress.inventory,
|
||||||
|
[hockeyPvpBossAt(match.seed, 0)],
|
||||||
|
"hockey-healing-pvp",
|
||||||
|
hunter.gearProgress,
|
||||||
|
"initiate",
|
||||||
|
match,
|
||||||
|
);
|
||||||
|
touchActiveSave();
|
||||||
|
}, [clearRecentRewards, hunter, touchActiveSave]);
|
||||||
|
|
||||||
|
const handleHockeyPvpPostMatchAction = useCallback((action: "rematch" | "requeue") => {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
if (game.runMode !== "hockey-healing-pvp"
|
||||||
|
|| game.phase !== "victory" && game.phase !== "defeat"
|
||||||
|
|| !hunter) return;
|
||||||
|
game.setHockeyPvpPostMatchSelection(action);
|
||||||
|
hockeyPvpPostMatchOperation.current?.cancel();
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
|
|
||||||
|
if (action === "rematch" && (game.hockeyPvp.role === "cpu" || !game.hockeyPvp.matchId)) {
|
||||||
|
launchHockeyPvpMatch({
|
||||||
|
matchId: null,
|
||||||
|
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||||
|
generation: game.hockeyPvp.generation + 1,
|
||||||
|
opponentName: game.hockeyPvp.opponentName,
|
||||||
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = action === "rematch"
|
||||||
|
? startHockeyPvpRematch({
|
||||||
|
matchId: game.hockeyPvp.matchId!,
|
||||||
|
generation: game.hockeyPvp.generation,
|
||||||
|
})
|
||||||
|
: startHockeyPvpMatchmaking({
|
||||||
|
slotId: hunter.slotId,
|
||||||
|
hunterName: hunter.hunterName,
|
||||||
|
online: Boolean(accountId && networkAppearsOnline()),
|
||||||
|
});
|
||||||
|
game.setHockeyPvpPostMatchStatus(
|
||||||
|
action === "rematch" ? "waiting-rematch" : "requeueing",
|
||||||
|
action === "requeue" ? Date.now() + HOCKEY_PVP_QUEUE_TIMEOUT_MS : 0,
|
||||||
|
);
|
||||||
|
hockeyPvpPostMatchOperation.current = operation;
|
||||||
|
void operation.result.then((match) => {
|
||||||
|
if (!match || hockeyPvpPostMatchOperation.current !== operation) return;
|
||||||
|
hockeyPvpPostMatchOperation.current = null;
|
||||||
|
launchHockeyPvpMatch(match);
|
||||||
|
});
|
||||||
|
}, [accountId, hunter, launchHockeyPvpMatch]);
|
||||||
|
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, 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;
|
||||||
@@ -74,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"
|
||||||
@@ -92,7 +253,7 @@ function MainApp() {
|
|||||||
runMode,
|
runMode,
|
||||||
hunter.gearProgress,
|
hunter.gearProgress,
|
||||||
launchDifficulty,
|
launchDifficulty,
|
||||||
hockeyPvpMatch,
|
pvpMatch,
|
||||||
);
|
);
|
||||||
touchActiveSave();
|
touchActiveSave();
|
||||||
navigate("game");
|
navigate("game");
|
||||||
@@ -100,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);
|
||||||
@@ -113,6 +274,14 @@ function MainApp() {
|
|||||||
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
}, [leaveGame]);
|
}, [leaveGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPostMatchAction = (event: Event) => {
|
||||||
|
handleHockeyPvpPostMatchAction((event as CustomEvent<"rematch" | "requeue">).detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||||
|
return () => window.removeEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||||
|
}, [handleHockeyPvpPostMatchAction]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
|
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
|
||||||
@@ -126,11 +295,12 @@ function MainApp() {
|
|||||||
if (stopped || exchangeActive) return;
|
if (stopped || exchangeActive) return;
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
|
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
|
||||||
|
if (state.phase !== "briefing" && state.phase !== "combat") return;
|
||||||
const snapshot = getHockeyPvpNetworkSnapshot();
|
const snapshot = getHockeyPvpNetworkSnapshot();
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
exchangeActive = true;
|
exchangeActive = true;
|
||||||
try {
|
try {
|
||||||
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot);
|
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, state.hockeyPvp.generation, snapshot);
|
||||||
if (!stopped && result.opponentSnapshot) {
|
if (!stopped && result.opponentSnapshot) {
|
||||||
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
|
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
|
||||||
result.opponentSnapshot,
|
result.opponentSnapshot,
|
||||||
@@ -151,6 +321,155 @@ 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(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const autoStart = () => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
if (state.phase !== "briefing"
|
||||||
|
|| state.runMode !== "hockey-healing-pvp" && state.runMode !== "roguelike-pvp") return;
|
||||||
|
const countdownEndsAtMs = state.runMode === "roguelike-pvp"
|
||||||
|
? state.roguelikePvp.countdownEndsAtMs
|
||||||
|
: state.hockeyPvp.countdownEndsAtMs;
|
||||||
|
const remaining = countdownEndsAtMs - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
state.startEncounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(autoStart, remaining + 16);
|
||||||
|
};
|
||||||
|
autoStart();
|
||||||
|
return () => {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, roguelikePvpCountdownEndsAtMs, screen]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,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);
|
||||||
}
|
}
|
||||||
@@ -197,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;
|
||||||
@@ -220,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">
|
||||||
@@ -229,15 +553,19 @@ function MainApp() {
|
|||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{screen === "game"
|
||||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} /></Suspense>} /></Suspense>
|
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} onHockeyPvpAction={handleHockeyPvpPostMatchAction} /></Suspense>} /></Suspense>
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+128
-22
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { ABILITY_ORDER } from "../game/data";
|
import { ABILITY_ORDER } from "../game/data";
|
||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||||
|
cycleHockeyPvpPostMatchSelection,
|
||||||
|
type HockeyPvpPostMatchSelection,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||||
import {
|
import {
|
||||||
BLOCKBREAKER_BREACH_DAMAGE,
|
BLOCKBREAKER_BREACH_DAMAGE,
|
||||||
@@ -37,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();
|
||||||
@@ -50,7 +57,14 @@ function moveTacticalSelection(direction: 1 | -1) {
|
|||||||
store.selectItem(store.inventory[nextIndex].id);
|
store.selectItem(store.inventory[nextIndex].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSingleScreenTacticalInput() {
|
function useSingleScreenTacticalInput(
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void,
|
||||||
|
onExit?: () => void,
|
||||||
|
) {
|
||||||
|
const actionRef = useRef(onHockeyPvpAction);
|
||||||
|
const exitRef = useRef(onExit);
|
||||||
|
actionRef.current = onHockeyPvpAction;
|
||||||
|
exitRef.current = onExit;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSingleScreenLayout()) return;
|
if (!isSingleScreenLayout()) return;
|
||||||
let active = getDisplaySurface() === "bottom";
|
let active = getDisplaySurface() === "bottom";
|
||||||
@@ -67,7 +81,13 @@ function useSingleScreenTacticalInput() {
|
|||||||
const activatePhaseAction = () => {
|
const activatePhaseAction = () => {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
if (store.phase === "briefing") store.startEncounter();
|
if (store.phase === "briefing") store.startEncounter();
|
||||||
else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
|
||||||
|
else actionRef.current?.(store.hockeyPvp.postMatchSelection);
|
||||||
|
} else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "roguelike-pvp") {
|
||||||
|
if (store.roguelikePvp.role === "cpu") store.restart();
|
||||||
|
else exitRef.current?.();
|
||||||
|
} else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||||
};
|
};
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!active || event.repeat) return;
|
if (!active || event.repeat) return;
|
||||||
@@ -77,6 +97,18 @@ function useSingleScreenTacticalInput() {
|
|||||||
|| store.phase === "intermission"
|
|| store.phase === "intermission"
|
||||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
|
||||||
|
if (key === "arrowleft" || key === "arrowup") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
}
|
||||||
|
if (key === "arrowright" || key === "arrowdown") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
}
|
||||||
|
if (key === "enter") activatePhaseAction();
|
||||||
|
if (key === "escape") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === "arrowleft") cycleTab(-1);
|
if (key === "arrowleft") cycleTab(-1);
|
||||||
else if (key === "arrowright") cycleTab(1);
|
else if (key === "arrowright") cycleTab(1);
|
||||||
else if (key === "arrowup") moveTacticalSelection(-1);
|
else if (key === "arrowup") moveTacticalSelection(-1);
|
||||||
@@ -92,6 +124,15 @@ function useSingleScreenTacticalInput() {
|
|||||||
|| store.runMode === "rpg-roguelike"
|
|| store.runMode === "rpg-roguelike"
|
||||||
|| store.phase === "intermission"
|
|| store.phase === "intermission"
|
||||||
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["Button12", "Button14"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
} else if (["Button13", "Button15"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
} else if (!repeat && token === "Button0") activatePhaseAction();
|
||||||
|
else if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (token === "Button14") cycleTab(-1);
|
if (token === "Button14") cycleTab(-1);
|
||||||
else if (token === "Button15") cycleTab(1);
|
else if (token === "Button15") cycleTab(1);
|
||||||
else if (token === "Button12") moveTacticalSelection(-1);
|
else if (token === "Button12") moveTacticalSelection(-1);
|
||||||
@@ -245,20 +286,28 @@ 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";
|
||||||
const blockbreakerMode = activityMode === "blockbreaker";
|
const blockbreakerMode = activityMode === "blockbreaker";
|
||||||
const aetherMode = activityMode === "aether-assault";
|
const aetherMode = activityMode === "aether-assault";
|
||||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||||
|
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||||
|
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}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter} disabled={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>
|
||||||
@@ -277,7 +326,13 @@ function BriefingPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EndPanel({ onExit }: { onExit?: () => void }) {
|
function EndPanel({
|
||||||
|
onExit,
|
||||||
|
onHockeyPvpAction,
|
||||||
|
}: {
|
||||||
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
}) {
|
||||||
const hunter = useActiveHunter();
|
const hunter = useActiveHunter();
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
@@ -296,6 +351,12 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
const hockey = useGameStore((state) => state.hockey);
|
const hockey = useGameStore((state) => state.hockey);
|
||||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||||
|
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
|
||||||
|
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
|
||||||
|
const requeueSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
hockeyPvp.postMatchStatus === "requeueing",
|
||||||
|
hockeyPvp.postMatchQueueEndsAtMs,
|
||||||
|
);
|
||||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
@@ -303,20 +364,21 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
||||||
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
||||||
const pvpMatch = activityMode === "hockey-healing-pvp";
|
const pvpMatch = activityMode === "hockey-healing-pvp";
|
||||||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
|
const 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}`}>
|
<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" : ""}
|
||||||
@@ -329,9 +391,39 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
||||||
onClick={onExit}
|
onClick={onExit}
|
||||||
>Quit to Main Menu</button>
|
>Quit to Main Menu</button>
|
||||||
</div> : <div className="end-actions">
|
</div> : roguelikePvpMatch ? <div className="end-actions pvp-end-actions">
|
||||||
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
|
{roguelikePvp.role === "cpu" && <button className="is-controller-selected" onClick={restart}><span>Run again</span><small>Same CPU rival</small></button>}
|
||||||
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
|
<button className={roguelikePvp.role === "cpu" ? "secondary" : "is-controller-selected"} onClick={onExit}>Main menu</button>
|
||||||
|
</div> : pvpMatch ? <>
|
||||||
|
<div className="pvp-post-match-status" role="status" aria-live="polite">
|
||||||
|
{hockeyPvp.postMatchStatus === "waiting-rematch"
|
||||||
|
? `Waiting for ${hockeyPvp.opponentName} to accept rematch…`
|
||||||
|
: hockeyPvp.postMatchStatus === "requeueing"
|
||||||
|
? `Searching queue · CPU fallback in ${requeueSeconds}s`
|
||||||
|
: "Choose rematch or enter queue for another opponent."}
|
||||||
|
</div>
|
||||||
|
<div className="end-actions pvp-end-actions">
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
|
||||||
|
onClick={() => onHockeyPvpAction?.("rematch")}
|
||||||
|
><span>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</span><small>Same opponent</small></button>
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "requeueing"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
|
||||||
|
onClick={() => onHockeyPvpAction?.("requeue")}
|
||||||
|
><span>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${requeueSeconds}s` : "Requeue"}</span><small>Find another rival</small></button>
|
||||||
|
<button
|
||||||
|
className={`secondary ${hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("menu")}
|
||||||
|
onClick={onExit}
|
||||||
|
>Main menu</button>
|
||||||
|
</div>
|
||||||
|
</> : <div className="end-actions">
|
||||||
|
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||||
|
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -351,11 +443,17 @@ function IntermissionStatusPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatPanel({ onExit }: { onExit?: () => void }) {
|
function CombatPanel({ onExit, onHockeyPvpAction }: {
|
||||||
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
}) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
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"
|
||||||
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
|
? <RoguelikePvpDraftPanel />
|
||||||
|
: <IntermissionStatusPanel />;
|
||||||
|
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
|
||||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,8 +820,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
export function BottomScreen({ onExit, onHockeyPvpAction }: {
|
||||||
useSingleScreenTacticalInput();
|
onExit?: () => void;
|
||||||
|
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||||
|
} = {}) {
|
||||||
|
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
|
||||||
const activeTab = useGameStore((state) => state.activeTab);
|
const activeTab = useGameStore((state) => state.activeTab);
|
||||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
@@ -750,10 +851,15 @@ export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="lower-content">
|
<main className="lower-content">
|
||||||
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
{phase === "intermission" && runMode === "roguelike-pvp"
|
||||||
|
? <RoguelikePvpDraftPanel />
|
||||||
|
: <>
|
||||||
|
{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">
|
||||||
|
|||||||
+66
-76
@@ -53,9 +53,16 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_DAMAGE,
|
HOCKEY_PVP_GOAL_DAMAGE,
|
||||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
randomHockeyPvpCpuName,
|
|
||||||
type HockeyPvpMatchConfig,
|
type HockeyPvpMatchConfig,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking";
|
||||||
|
import {
|
||||||
|
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,10 +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 queueTicket = useRef<string | null>(null);
|
const queueOperation = useRef<HockeyPvpMatchOperation | RoguelikePvpMatchOperation | null>(null);
|
||||||
const queuePollTimer = useRef<number | null>(null);
|
|
||||||
const queueCpuTimer = useRef<number | null>(null);
|
|
||||||
const queueClockTimer = useRef<number | null>(null);
|
|
||||||
const mode = MODE_COPY[modeId];
|
const mode = MODE_COPY[modeId];
|
||||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||||
const progress = hunter?.healers[hunter.activeClassId];
|
const progress = hunter?.healers[hunter.activeClassId];
|
||||||
@@ -1559,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];
|
||||||
@@ -1567,41 +1580,27 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||||||
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
||||||
};
|
};
|
||||||
const clearQueueTimers = () => {
|
const completeHockeyPvpQueue = (match: HockeyPvpMatchConfig) => {
|
||||||
if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current);
|
|
||||||
if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current);
|
|
||||||
if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current);
|
|
||||||
queuePollTimer.current = null;
|
|
||||||
queueCpuTimer.current = null;
|
|
||||||
queueClockTimer.current = null;
|
|
||||||
};
|
|
||||||
const completePvpQueue = (match: HockeyPvpMatchConfig) => {
|
|
||||||
if (!queueActive.current) return;
|
if (!queueActive.current) return;
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current = null;
|
||||||
setQueueing(false);
|
setQueueing(false);
|
||||||
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
||||||
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
|
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
|
||||||
};
|
};
|
||||||
const fallbackToCpu = () => {
|
const completeRoguelikePvpQueue = (match: RoguelikePvpMatchConfig) => {
|
||||||
if (!queueActive.current) return;
|
if (!queueActive.current) return;
|
||||||
const ticketId = queueTicket.current;
|
queueActive.current = false;
|
||||||
queueTicket.current = null;
|
queueOperation.current = null;
|
||||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
setQueueing(false);
|
||||||
completePvpQueue({
|
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
||||||
matchId: null,
|
onLaunch(roguelikePvpBossesForRound(match.seed, 1), "initiate", match);
|
||||||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
|
||||||
opponentName: randomHockeyPvpCpuName(),
|
|
||||||
role: "cpu",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const cancelPvpQueue = () => {
|
const cancelPvpQueue = () => {
|
||||||
if (!queueActive.current) return;
|
if (!queueActive.current) return;
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current?.cancel();
|
||||||
const ticketId = queueTicket.current;
|
queueOperation.current = null;
|
||||||
queueTicket.current = null;
|
|
||||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
|
||||||
setQueueing(false);
|
setQueueing(false);
|
||||||
setQueueElapsed(0);
|
setQueueElapsed(0);
|
||||||
setMessage("Matchmaking cancelled.");
|
setMessage("Matchmaking cancelled.");
|
||||||
@@ -1612,52 +1611,33 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
setQueueing(true);
|
setQueueing(true);
|
||||||
setQueueElapsed(0);
|
setQueueElapsed(0);
|
||||||
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
||||||
const startedAt = Date.now();
|
const online = Boolean(accountId && networkAppearsOnline());
|
||||||
queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100);
|
const operation = isRoguelikePvp
|
||||||
queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS);
|
? startRoguelikePvpMatchmaking({
|
||||||
if (!accountId || !networkAppearsOnline()) return;
|
slotId: hunter.slotId,
|
||||||
try {
|
hunterName: hunter.hunterName,
|
||||||
const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName);
|
healerClassId: hunter.activeClassId,
|
||||||
if (!queueActive.current) return;
|
online,
|
||||||
queueTicket.current = joined.ticketId;
|
onElapsed: setQueueElapsed,
|
||||||
if (joined.match) {
|
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
|
||||||
completePvpQueue({
|
})
|
||||||
matchId: joined.match.id,
|
: startHockeyPvpMatchmaking({
|
||||||
seed: joined.match.seed,
|
slotId: hunter.slotId,
|
||||||
opponentName: joined.match.opponentName,
|
hunterName: hunter.hunterName,
|
||||||
role: joined.match.role,
|
online,
|
||||||
|
onElapsed: setQueueElapsed,
|
||||||
|
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
|
||||||
});
|
});
|
||||||
return;
|
queueOperation.current = operation;
|
||||||
}
|
const match = await operation.result;
|
||||||
const poll = async () => {
|
if (!match || queueOperation.current !== operation) return;
|
||||||
if (!queueActive.current || !queueTicket.current) return;
|
if (isRoguelikePvp) completeRoguelikePvpQueue(match as RoguelikePvpMatchConfig);
|
||||||
try {
|
else completeHockeyPvpQueue(match as HockeyPvpMatchConfig);
|
||||||
const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current);
|
|
||||||
if (!queueActive.current) return;
|
|
||||||
if (result.match) {
|
|
||||||
completePvpQueue({
|
|
||||||
matchId: result.match.id,
|
|
||||||
seed: result.match.seed,
|
|
||||||
opponentName: result.match.opponentName,
|
|
||||||
role: result.match.role,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Five-second CPU fallback remains authoritative during transient outages.
|
|
||||||
}
|
|
||||||
if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350);
|
|
||||||
};
|
|
||||||
queuePollTimer.current = window.setTimeout(poll, 350);
|
|
||||||
} catch {
|
|
||||||
setMessage("Online queue unavailable. CPU fallback still searching…");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
queueActive.current = false;
|
queueActive.current = false;
|
||||||
clearQueueTimers();
|
queueOperation.current?.cancel();
|
||||||
const ticketId = queueTicket.current;
|
queueOperation.current = null;
|
||||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
|
||||||
}, []);
|
}, []);
|
||||||
const leaveMode = () => {
|
const leaveMode = () => {
|
||||||
cancelPvpQueue();
|
cancelPvpQueue();
|
||||||
@@ -1669,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.");
|
||||||
};
|
};
|
||||||
@@ -1720,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"
|
||||||
@@ -1734,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";
|
||||||
@@ -1761,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."],
|
||||||
@@ -1836,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>
|
||||||
}
|
}
|
||||||
@@ -1844,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>1–3 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>1–3 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 1–3 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 1–3 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 />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+103
-13
@@ -7,6 +7,8 @@ import { tankAuraProtects } from "../game/partyCombat";
|
|||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
|
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||||
@@ -14,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";
|
||||||
@@ -168,9 +175,25 @@ 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(
|
||||||
|
activityMode === "hockey-healing-pvp" && phase === "briefing",
|
||||||
|
hockeyPvp.countdownEndsAtMs,
|
||||||
|
);
|
||||||
|
const pvpRequeueSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
hockeyPvp.postMatchStatus === "requeueing",
|
||||||
|
hockeyPvp.postMatchQueueEndsAtMs,
|
||||||
|
);
|
||||||
|
const roguelikePvpCountdownSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
runMode === "roguelike-pvp" && phase === "briefing",
|
||||||
|
roguelikePvp.countdownEndsAtMs,
|
||||||
|
);
|
||||||
|
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);
|
||||||
@@ -179,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
|
||||||
@@ -189,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."
|
||||||
@@ -212,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
|
||||||
@@ -222,16 +250,58 @@ 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 competitivePvpBriefing = pvpMode || roguelikePvpMode;
|
||||||
|
const competitivePvpCountdown = roguelikePvpMode ? roguelikePvpCountdownSeconds : pvpCountdownSeconds;
|
||||||
|
const briefingPrompt = competitivePvpBriefing
|
||||||
|
? competitivePvpCountdown > 0
|
||||||
|
? `Match starts automatically in ${competitivePvpCountdown}`
|
||||||
|
: "Match starting now"
|
||||||
|
: singleScreen
|
||||||
|
? "Press Start / Enter to begin"
|
||||||
|
: "Begin from lower display";
|
||||||
|
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
|
||||||
|
const roguelikePvpEnded = roguelikePvpMode && (phase === "victory" || phase === "defeat");
|
||||||
return (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<p>{copy}</p>
|
||||||
<small>{singleScreen
|
{competitivePvpBriefing && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${competitivePvpCountdown} seconds`}>
|
||||||
? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
<span>Match starts in</span>
|
||||||
: phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
<strong>{competitivePvpCountdown}</strong>
|
||||||
|
<small>seconds</small>
|
||||||
|
</div>}
|
||||||
|
{pvpEnded && <>
|
||||||
|
<div className="top-pvp-end-actions">
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
|
||||||
|
onClick={() => requestHockeyPvpPostMatchAction("rematch")}
|
||||||
|
><strong>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</strong><small>Same opponent</small></button>
|
||||||
|
<button
|
||||||
|
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
|
||||||
|
disabled={hockeyPvp.postMatchStatus === "requeueing"}
|
||||||
|
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
|
||||||
|
onClick={() => requestHockeyPvpPostMatchAction("requeue")}
|
||||||
|
><strong>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${pvpRequeueSeconds}s` : "Requeue"}</strong><small>Find another rival</small></button>
|
||||||
|
</div>
|
||||||
|
<div className="top-pvp-post-match-status" role="status" aria-live="polite">
|
||||||
|
{hockeyPvp.postMatchStatus === "waiting-rematch"
|
||||||
|
? `Waiting for ${hockeyPvp.opponentName}…`
|
||||||
|
: hockeyPvp.postMatchStatus === "requeueing"
|
||||||
|
? `Searching queue · CPU fallback in ${pvpRequeueSeconds}s`
|
||||||
|
: "Choose next match"}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<small>{phase === "briefing"
|
||||||
|
? briefingPrompt
|
||||||
|
: singleScreen
|
||||||
|
? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "D-pad chooses · Confirm selects · Menu exits" : roguelikePvpEnded ? roguelikePvp.role === "cpu" ? "Press Start / Enter to run again" : "Press Back / Menu to exit" : "Press Start / Enter to restart"
|
||||||
|
: showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : roguelikePvpEnded ? "Choose next action on lower display" : "Restart from lower display"}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -391,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);
|
||||||
@@ -402,7 +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 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" />}>
|
||||||
@@ -412,12 +496,18 @@ 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 />
|
||||||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||||
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
{onExit && <button
|
||||||
|
className={`game-menu-button ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
|
||||||
|
onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }}
|
||||||
|
onClick={() => phase === "combat" && !onlineRoguelikePvp ? setPaused(true) : onExit()}
|
||||||
|
><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||||
</div>
|
</div>
|
||||||
<SingleScreenAbilityBar />
|
<SingleScreenAbilityBar />
|
||||||
<GoalPopup />
|
<GoalPopup />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { startHockeyPvpMatchmaking, startHockeyPvpRematch } from "./hockeyPvpMatchmaking";
|
||||||
|
|
||||||
|
describe("Hockey PVP matchmaking", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a CPU match with a visible five-second start countdown", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(1_000);
|
||||||
|
const operation = startHockeyPvpMatchmaking({
|
||||||
|
slotId: 1,
|
||||||
|
hunterName: "Aelia",
|
||||||
|
online: false,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
random: () => 0.5,
|
||||||
|
cpuName: () => "CPU Sage",
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await expect(operation.result).resolves.toMatchObject({
|
||||||
|
role: "cpu",
|
||||||
|
opponentName: "CPU Sage",
|
||||||
|
generation: 1,
|
||||||
|
countdownEndsAtMs: 11_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls until both players accept an online rematch", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const requestHockeyPvpRematch = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ status: "waiting" })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: "matched",
|
||||||
|
match: {
|
||||||
|
id: "match-1",
|
||||||
|
seed: 22,
|
||||||
|
generation: 2,
|
||||||
|
countdownEndsAtMs: 10_000,
|
||||||
|
opponentName: "Rival",
|
||||||
|
role: "host",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const operation = startHockeyPvpRematch({
|
||||||
|
matchId: "match-1",
|
||||||
|
generation: 1,
|
||||||
|
pollMs: 350,
|
||||||
|
repository: {
|
||||||
|
requestHockeyPvpRematch,
|
||||||
|
cancelHockeyPvpRematch: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
await expect(operation.result).resolves.toMatchObject({
|
||||||
|
matchId: "match-1",
|
||||||
|
seed: 22,
|
||||||
|
generation: 2,
|
||||||
|
opponentName: "Rival",
|
||||||
|
role: "host",
|
||||||
|
});
|
||||||
|
expect(requestHockeyPvpRematch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
|
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||||
|
randomHockeyPvpCpuName,
|
||||||
|
type HockeyPvpMatchConfig,
|
||||||
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import {
|
||||||
|
onlineRepository,
|
||||||
|
type HockeyPvpOnlineMatch,
|
||||||
|
type OnlineRepository,
|
||||||
|
} from "./onlineRepository";
|
||||||
|
import type { SaveSlotId } from "./types";
|
||||||
|
|
||||||
|
type QueueRepository = Pick<OnlineRepository,
|
||||||
|
"joinHockeyPvpQueue" | "pollHockeyPvpQueue" | "cancelHockeyPvpQueue">;
|
||||||
|
type RematchRepository = Pick<OnlineRepository,
|
||||||
|
"requestHockeyPvpRematch" | "cancelHockeyPvpRematch">;
|
||||||
|
|
||||||
|
export interface HockeyPvpMatchOperation {
|
||||||
|
result: Promise<HockeyPvpMatchConfig | null>;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineHockeyPvpMatchConfig(match: HockeyPvpOnlineMatch): HockeyPvpMatchConfig {
|
||||||
|
return {
|
||||||
|
matchId: match.id,
|
||||||
|
seed: match.seed,
|
||||||
|
generation: match.generation,
|
||||||
|
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||||
|
opponentName: match.opponentName,
|
||||||
|
role: match.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHockeyPvpMatchmaking(options: {
|
||||||
|
slotId: SaveSlotId;
|
||||||
|
hunterName: string;
|
||||||
|
online: boolean;
|
||||||
|
repository?: QueueRepository;
|
||||||
|
timeoutMs?: number;
|
||||||
|
pollMs?: number;
|
||||||
|
onElapsed?: (elapsedMs: number) => void;
|
||||||
|
onOnlineUnavailable?: () => void;
|
||||||
|
random?: () => number;
|
||||||
|
cpuName?: () => string;
|
||||||
|
}): HockeyPvpMatchOperation {
|
||||||
|
const repository = options.repository ?? onlineRepository;
|
||||||
|
const timeoutMs = options.timeoutMs ?? HOCKEY_PVP_QUEUE_TIMEOUT_MS;
|
||||||
|
const pollMs = options.pollMs ?? 350;
|
||||||
|
const random = options.random ?? Math.random;
|
||||||
|
const cpuName = options.cpuName ?? randomHockeyPvpCpuName;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let active = true;
|
||||||
|
let ticketId: string | null = null;
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (pollTimer !== null) clearTimeout(pollTimer);
|
||||||
|
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
||||||
|
if (clockTimer !== null) clearInterval(clockTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
fallbackTimer = null;
|
||||||
|
clockTimer = null;
|
||||||
|
};
|
||||||
|
const finish = (match: HockeyPvpMatchConfig | null) => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
clearTimers();
|
||||||
|
settle(match);
|
||||||
|
};
|
||||||
|
const cancelTicket = () => {
|
||||||
|
const currentTicketId = ticketId;
|
||||||
|
ticketId = null;
|
||||||
|
if (currentTicketId) void repository.cancelHockeyPvpQueue(currentTicketId).catch(() => undefined);
|
||||||
|
};
|
||||||
|
const fallbackToCpu = () => {
|
||||||
|
if (!active) return;
|
||||||
|
cancelTicket();
|
||||||
|
finish({
|
||||||
|
matchId: null,
|
||||||
|
seed: Math.max(1, Math.floor(random() * 0xffffffff)),
|
||||||
|
generation: 1,
|
||||||
|
opponentName: cpuName(),
|
||||||
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
|
||||||
|
settle = resolve;
|
||||||
|
fallbackTimer = setTimeout(fallbackToCpu, timeoutMs);
|
||||||
|
if (options.onElapsed) {
|
||||||
|
options.onElapsed(0);
|
||||||
|
clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100);
|
||||||
|
}
|
||||||
|
if (!options.online) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const joined = await repository.joinHockeyPvpQueue(options.slotId, options.hunterName);
|
||||||
|
if (!active) {
|
||||||
|
if (!joined.match) void repository.cancelHockeyPvpQueue(joined.ticketId).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ticketId = joined.ticketId;
|
||||||
|
if (joined.match) {
|
||||||
|
finish(onlineHockeyPvpMatchConfig(joined.match));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const poll = async () => {
|
||||||
|
if (!active || !ticketId) return;
|
||||||
|
try {
|
||||||
|
const queued = await repository.pollHockeyPvpQueue(ticketId);
|
||||||
|
if (!active) return;
|
||||||
|
if (queued.match) {
|
||||||
|
finish(onlineHockeyPvpMatchConfig(queued.match));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
options.onOnlineUnavailable?.();
|
||||||
|
}
|
||||||
|
if (active) pollTimer = setTimeout(poll, pollMs);
|
||||||
|
};
|
||||||
|
pollTimer = setTimeout(poll, pollMs);
|
||||||
|
} catch {
|
||||||
|
options.onOnlineUnavailable?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
cancel: () => {
|
||||||
|
if (!active) return;
|
||||||
|
cancelTicket();
|
||||||
|
finish(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHockeyPvpRematch(options: {
|
||||||
|
matchId: string;
|
||||||
|
generation: number;
|
||||||
|
repository?: RematchRepository;
|
||||||
|
pollMs?: number;
|
||||||
|
onUnavailable?: () => void;
|
||||||
|
}): HockeyPvpMatchOperation {
|
||||||
|
const repository = options.repository ?? onlineRepository;
|
||||||
|
const pollMs = options.pollMs ?? 350;
|
||||||
|
let active = true;
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
|
||||||
|
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
|
||||||
|
settle = resolve;
|
||||||
|
const poll = async () => {
|
||||||
|
if (!active) return;
|
||||||
|
try {
|
||||||
|
const rematch = await repository.requestHockeyPvpRematch(options.matchId, options.generation);
|
||||||
|
if (!active) {
|
||||||
|
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rematch.match) {
|
||||||
|
active = false;
|
||||||
|
settle(onlineHockeyPvpMatchConfig(rematch.match));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
options.onUnavailable?.();
|
||||||
|
}
|
||||||
|
if (active) pollTimer = setTimeout(poll, pollMs);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
cancel: () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pollTimer !== null) clearTimeout(pollTimer);
|
||||||
|
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
|
||||||
|
settle(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -30,15 +31,24 @@ export interface LeaderboardResult {
|
|||||||
current: LeaderboardEntry | null;
|
current: LeaderboardEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HockeyPvpOnlineMatch {
|
||||||
|
id: string;
|
||||||
|
seed: number;
|
||||||
|
generation: number;
|
||||||
|
countdownEndsAtMs: number;
|
||||||
|
opponentName: string;
|
||||||
|
role: Exclude<HockeyPvpRole, "cpu">;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HockeyPvpQueueResult {
|
export interface HockeyPvpQueueResult {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
status: "waiting" | "matched";
|
status: "waiting" | "matched";
|
||||||
match?: {
|
match?: HockeyPvpOnlineMatch;
|
||||||
id: string;
|
}
|
||||||
seed: number;
|
|
||||||
opponentName: string;
|
export interface HockeyPvpRematchResult {
|
||||||
role: Exclude<HockeyPvpRole, "cpu">;
|
status: "waiting" | "matched";
|
||||||
};
|
match?: HockeyPvpOnlineMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpExchangeResult {
|
export interface HockeyPvpExchangeResult {
|
||||||
@@ -46,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;
|
||||||
@@ -214,11 +288,100 @@ export class OnlineRepository {
|
|||||||
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
|
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
|
exchangeHockeyPvpState(matchId: string, generation: number, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
|
||||||
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ snapshot }),
|
body: JSON.stringify({ generation, snapshot }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHockeyPvpRematch(matchId: string, generation: number): Promise<HockeyPvpRematchResult> {
|
||||||
|
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelHockeyPvpRematch(matchId: string, generation: number): Promise<void> {
|
||||||
|
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ generation }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
advanceHockeyPvpPuck,
|
advanceHockeyPvpPuck,
|
||||||
createHockeyPvpState,
|
createHockeyPvpState,
|
||||||
|
cycleHockeyPvpPostMatchSelection,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
|
hockeyPvpCountdownSeconds,
|
||||||
hockeyPvpDampeningPercent,
|
hockeyPvpDampeningPercent,
|
||||||
hockeyPvpHealingEffectiveness,
|
hockeyPvpHealingEffectiveness,
|
||||||
hockeyPvpPuckSpeed,
|
hockeyPvpPuckSpeed,
|
||||||
@@ -20,6 +22,20 @@ describe("Healing Hockey PVP", () => {
|
|||||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts down five whole seconds and never returns a negative value", () => {
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 5_000)).toBe(5);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 9_001)).toBe(1);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 10_000)).toBe(0);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 12_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycles rematch, requeue, and menu choices deterministically", () => {
|
||||||
|
expect(cycleHockeyPvpPostMatchSelection("rematch", 1)).toBe("requeue");
|
||||||
|
expect(cycleHockeyPvpPostMatchSelection("requeue", 1)).toBe("menu");
|
||||||
|
expect(cycleHockeyPvpPostMatchSelection("menu", 1)).toBe("rematch");
|
||||||
|
expect(cycleHockeyPvpPostMatchSelection("rematch", -1)).toBe("menu");
|
||||||
|
});
|
||||||
|
|
||||||
it("adds five percent global dampening for every boss killed by either party", () => {
|
it("adds five percent global dampening for every boss killed by either party", () => {
|
||||||
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
||||||
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types
|
|||||||
|
|
||||||
export type HockeyPvpRole = "cpu" | "host" | "guest";
|
export type HockeyPvpRole = "cpu" | "host" | "guest";
|
||||||
export type HockeyPvpGoalSide = "local" | "opponent";
|
export type HockeyPvpGoalSide = "local" | "opponent";
|
||||||
|
export type HockeyPvpPostMatchSelection = "rematch" | "requeue" | "menu";
|
||||||
|
export type HockeyPvpPostMatchStatus = "idle" | "waiting-rematch" | "requeueing";
|
||||||
|
|
||||||
export interface HockeyPvpMatchConfig {
|
export interface HockeyPvpMatchConfig {
|
||||||
matchId: string | null;
|
matchId: string | null;
|
||||||
seed: number;
|
seed: number;
|
||||||
|
generation?: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: HockeyPvpRole;
|
role: HockeyPvpRole;
|
||||||
|
countdownEndsAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpPuckState {
|
export interface HockeyPvpPuckState {
|
||||||
@@ -25,7 +29,12 @@ export interface HockeyPvpPuckState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
||||||
|
countdownEndsAtMs: number;
|
||||||
|
generation: number;
|
||||||
status: "inactive" | "live" | "won" | "lost";
|
status: "inactive" | "live" | "won" | "lost";
|
||||||
|
postMatchSelection: HockeyPvpPostMatchSelection;
|
||||||
|
postMatchStatus: HockeyPvpPostMatchStatus;
|
||||||
|
postMatchQueueEndsAtMs: number;
|
||||||
aimDirection: WorldPosition;
|
aimDirection: WorldPosition;
|
||||||
opponentBossKills: number;
|
opponentBossKills: number;
|
||||||
opponentPlayerPosition: WorldPosition;
|
opponentPlayerPosition: WorldPosition;
|
||||||
@@ -61,7 +70,9 @@ export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
|||||||
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
||||||
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
||||||
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
|
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
|
||||||
|
export const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
||||||
|
export const HOCKEY_PVP_POST_MATCH_SELECTIONS = ["rematch", "requeue", "menu"] as const;
|
||||||
|
|
||||||
const STARTING_SPEED = 7.8;
|
const STARTING_SPEED = 7.8;
|
||||||
const MAX_SPEED = 12.2;
|
const MAX_SPEED = 12.2;
|
||||||
@@ -98,6 +109,21 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
|
|||||||
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Date.now()) {
|
||||||
|
return Math.max(0, Math.ceil((countdownEndsAtMs - nowMs) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleHockeyPvpPostMatchSelection(
|
||||||
|
selection: HockeyPvpPostMatchSelection,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): HockeyPvpPostMatchSelection {
|
||||||
|
const currentIndex = HOCKEY_PVP_POST_MATCH_SELECTIONS.indexOf(selection);
|
||||||
|
return HOCKEY_PVP_POST_MATCH_SELECTIONS[
|
||||||
|
(Math.max(0, currentIndex) + direction + HOCKEY_PVP_POST_MATCH_SELECTIONS.length)
|
||||||
|
% HOCKEY_PVP_POST_MATCH_SELECTIONS.length
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
||||||
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
||||||
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
||||||
@@ -110,7 +136,12 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
|
|||||||
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
||||||
return {
|
return {
|
||||||
...match,
|
...match,
|
||||||
|
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
|
||||||
|
generation: config?.generation ?? 1,
|
||||||
status: config ? "live" : "inactive",
|
status: config ? "live" : "inactive",
|
||||||
|
postMatchSelection: "rematch",
|
||||||
|
postMatchStatus: "idle",
|
||||||
|
postMatchQueueEndsAtMs: 0,
|
||||||
puckPosition: [0, 0],
|
puckPosition: [0, 0],
|
||||||
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
||||||
localReturns: 0,
|
localReturns: 0,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
|
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
|
||||||
import { upcomingEncounterMechanic, useGameStore } from "./store";
|
import { upcomingEncounterMechanic, useGameStore } from "./store";
|
||||||
@@ -23,6 +23,8 @@ function createMaxedGear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Healing Hockey PVP encounter integration", () => {
|
describe("Healing Hockey PVP encounter integration", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
"priest",
|
"priest",
|
||||||
@@ -45,6 +47,27 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores start input until shared five-second countdown ends", () => {
|
||||||
|
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
[hockeyPvpBossAt(MATCH.seed, 0)],
|
||||||
|
"hockey-healing-pvp",
|
||||||
|
undefined,
|
||||||
|
"initiate",
|
||||||
|
{ ...MATCH, countdownEndsAtMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("briefing");
|
||||||
|
|
||||||
|
now.mockReturnValue(10_000);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("combat");
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
||||||
const maxedGear = createMaxedGear();
|
const maxedGear = createMaxedGear();
|
||||||
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Shared healer resource tuning for every encounter and run mode. */
|
||||||
|
export const BASE_MANA_POOL = 150;
|
||||||
|
|
||||||
|
/** Global in-combat regeneration, expressed as mana restored per second. */
|
||||||
|
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers";
|
||||||
|
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||||
|
import type { AbilitySlotId, HealerClassId } from "./types";
|
||||||
|
|
||||||
|
const LONG_DUAL_BOSS_SECONDS = 100;
|
||||||
|
const MINIMUM_ENDING_RESERVE = 20;
|
||||||
|
const MAXIMUM_ENDING_RESERVE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-pressure reference rotations for the longest intended dual-boss fight.
|
||||||
|
* Counts include spot healing, maintenance effects, four cleanses, repeated
|
||||||
|
* group recovery, and both available one-minute cooldown casts.
|
||||||
|
*/
|
||||||
|
const REFERENCE_ROTATIONS = {
|
||||||
|
priest: { ability1: 8, ability2: 8, ability3: 5, ability4: 4, ability5: 5, ability6: 2 },
|
||||||
|
druid: { ability1: 7, ability2: 9, ability3: 9, ability4: 4, ability5: 5, ability6: 2 },
|
||||||
|
shaman: { ability1: 7, ability2: 9, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
|
||||||
|
paladin: { ability1: 10, ability2: 20, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
|
||||||
|
chronomancer: { ability1: 7, ability2: 8, ability3: 8, ability4: 4, ability5: 5, ability6: 2 },
|
||||||
|
} as const satisfies Record<HealerClassId, Record<AbilitySlotId, number>>;
|
||||||
|
|
||||||
|
function rotationManaCost(classId: HealerClassId): number {
|
||||||
|
const abilities = HEALER_CLASSES[classId].abilities;
|
||||||
|
return Object.entries(REFERENCE_ROTATIONS[classId]).reduce(
|
||||||
|
(total, [slotId, casts]) => total + abilities[slotId as AbilitySlotId].mana * casts,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dual-boss healer mana balance", () => {
|
||||||
|
const availableMana = BASE_MANA_POOL + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
|
||||||
|
|
||||||
|
it.each(HEALER_CLASS_ORDER)("funds %s's high-pressure 100-second rotation with a useful reserve", (classId) => {
|
||||||
|
const rotation = REFERENCE_ROTATIONS[classId];
|
||||||
|
const abilities = HEALER_CLASSES[classId].abilities;
|
||||||
|
const totalCasts = Object.values(rotation).reduce((total, casts) => total + casts, 0);
|
||||||
|
const remainingMana = availableMana - rotationManaCost(classId);
|
||||||
|
|
||||||
|
expect(totalCasts).toBeLessThanOrEqual(LONG_DUAL_BOSS_SECONDS / 0.5);
|
||||||
|
expect(Object.values(rotation).every((casts) => casts > 0)).toBe(true);
|
||||||
|
for (const [slotId, casts] of Object.entries(rotation)) {
|
||||||
|
const cooldown = abilities[slotId as AbilitySlotId].cooldown;
|
||||||
|
if (cooldown <= 0) continue;
|
||||||
|
const maximumCasts = Math.floor((LONG_DUAL_BOSS_SECONDS - Number.EPSILON) / cooldown) + 1;
|
||||||
|
expect(casts).toBeLessThanOrEqual(maximumCasts);
|
||||||
|
}
|
||||||
|
expect(remainingMana).toBeGreaterThanOrEqual(MINIMUM_ENDING_RESERVE);
|
||||||
|
expect(remainingMana).toBeLessThanOrEqual(MAXIMUM_ENDING_RESERVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps reference rotation costs close enough that no class gets a dominant mana advantage", () => {
|
||||||
|
const costs = HEALER_CLASS_ORDER.map(rotationManaCost);
|
||||||
|
expect(Math.max(...costs) - Math.min(...costs)).toBeLessThanOrEqual(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects why the previous 100 mana pool was insufficient", () => {
|
||||||
|
const previousBudget = 100 + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
|
||||||
|
for (const classId of HEALER_CLASS_ORDER) {
|
||||||
|
expect(rotationManaCost(classId)).toBeGreaterThan(previousBudget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
|
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
|
||||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
|
import { BASE_MANA_POOL } from "./mana";
|
||||||
import { createDefaultGearProgress } from "./progression/gear";
|
import { createDefaultGearProgress } from "./progression/gear";
|
||||||
import { equipPassiveInfusion } from "./progression/infusions";
|
import { equipPassiveInfusion } from "./progression/infusions";
|
||||||
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
|
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
|
||||||
@@ -112,6 +113,8 @@ describe("RPG Roguelike store integration", () => {
|
|||||||
expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true);
|
expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true);
|
||||||
expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds);
|
expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds);
|
||||||
expect(state.endlessMode).toBe(true);
|
expect(state.endlessMode).toBe(true);
|
||||||
|
expect(state.maxMana).toBe(BASE_MANA_POOL);
|
||||||
|
expect(state.mana).toBe(BASE_MANA_POOL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
|
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BULL_CHARGE } from "./bossMechanics";
|
import { BULL_CHARGE } from "./bossMechanics";
|
||||||
import { distance, pointToSegmentDistance } from "./geometry";
|
import { distance, pointToSegmentDistance } from "./geometry";
|
||||||
import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
import { BASE_MANA_POOL, BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
||||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||||
import { healingEffect } from "./healerEffects";
|
import { healingEffect } from "./healerEffects";
|
||||||
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
||||||
@@ -73,6 +73,15 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3);
|
expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts every healer class with the shared 150 mana pool", () => {
|
||||||
|
for (const classId of Object.keys(HEALER_CLASSES) as (keyof typeof HEALER_CLASSES)[]) {
|
||||||
|
useGameStore.getState().configureHealer(classId, "Aelia", createClassInventory(classId));
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
expect(state.maxMana).toBe(BASE_MANA_POOL);
|
||||||
|
expect(state.mana).toBe(BASE_MANA_POOL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a three-second Purify cooldown for every healer class", () => {
|
it("uses a three-second Purify cooldown for every healer class", () => {
|
||||||
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
||||||
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
||||||
@@ -561,7 +570,7 @@ describe("Roguelike ability buffs", () => {
|
|||||||
useGameStore.getState().selectMember("brann");
|
useGameStore.getState().selectMember("brann");
|
||||||
|
|
||||||
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
|
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
|
||||||
expect(useGameStore.getState().mana).toBe(97);
|
expect(useGameStore.getState().mana).toBe(BASE_MANA_POOL - 3);
|
||||||
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
|
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
|
||||||
useGameStore.getState().tick(0.22);
|
useGameStore.getState().tick(0.22);
|
||||||
|
|
||||||
|
|||||||
+624
-21
@@ -40,10 +40,12 @@ import {
|
|||||||
resolveTimeLoop,
|
resolveTimeLoop,
|
||||||
startTimeLoop,
|
startTimeLoop,
|
||||||
} from "./healerMechanics";
|
} from "./healerMechanics";
|
||||||
|
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||||
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
|
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
|
||||||
import {
|
import {
|
||||||
|
RUN_BUFF_ORDER,
|
||||||
RUN_BUFFS,
|
RUN_BUFFS,
|
||||||
bossHealthMultiplier,
|
bossHealthMultiplier,
|
||||||
compileRunModifiers,
|
compileRunModifiers,
|
||||||
@@ -80,9 +82,30 @@ import {
|
|||||||
mirrorHockeyPvpPuck,
|
mirrorHockeyPvpPuck,
|
||||||
reconcileHockeyPvpPuck,
|
reconcileHockeyPvpPuck,
|
||||||
type HockeyPvpMatchConfig,
|
type HockeyPvpMatchConfig,
|
||||||
|
type HockeyPvpPostMatchSelection,
|
||||||
|
type HockeyPvpPostMatchStatus,
|
||||||
type HockeyPvpRemoteSnapshot,
|
type HockeyPvpRemoteSnapshot,
|
||||||
type HockeyPvpState,
|
type HockeyPvpState,
|
||||||
} from "./hockeyHealingPvp";
|
} from "./hockeyHealingPvp";
|
||||||
|
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,
|
||||||
@@ -162,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;
|
||||||
@@ -197,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;
|
||||||
@@ -230,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;
|
||||||
@@ -241,7 +298,18 @@ export interface GameState {
|
|||||||
selectItem: (itemId: string) => void;
|
selectItem: (itemId: string) => void;
|
||||||
setPlayerPosition: (position: [number, number]) => void;
|
setPlayerPosition: (position: [number, number]) => void;
|
||||||
setHockeyAimDirection: (direction: [number, number]) => void;
|
setHockeyAimDirection: (direction: [number, number]) => void;
|
||||||
|
setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void;
|
||||||
|
setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void;
|
||||||
applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void;
|
applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void;
|
||||||
|
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;
|
||||||
@@ -268,7 +336,8 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
|
|||||||
|
|
||||||
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
||||||
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
||||||
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
|
export const ROGUELIKE_PVP_DRAFT_SECONDS = 15;
|
||||||
|
export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||||
|
|
||||||
export const BARRIER_RADIUS = 4;
|
export const BARRIER_RADIUS = 4;
|
||||||
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
||||||
@@ -360,13 +429,59 @@ 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) {
|
||||||
return barrier.kind === "barrier" && healerFieldContains(position, barrier, time);
|
return barrier.kind === "barrier" && healerFieldContains(position, barrier, time);
|
||||||
@@ -542,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,
|
||||||
@@ -573,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(
|
||||||
@@ -584,7 +706,7 @@ function initialState(
|
|||||||
0,
|
0,
|
||||||
"hockey",
|
"hockey",
|
||||||
);
|
);
|
||||||
const maxMana = 100;
|
const maxMana = BASE_MANA_POOL;
|
||||||
return {
|
return {
|
||||||
bossId: primary.boss.id,
|
bossId: primary.boss.id,
|
||||||
bossInstanceId: primary.instanceId,
|
bossInstanceId: primary.instanceId,
|
||||||
@@ -623,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,
|
||||||
@@ -632,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,
|
||||||
@@ -660,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;
|
||||||
@@ -728,7 +1016,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
|
|||||||
? { ...member, hp: member.maxHp * healerRatio }
|
? { ...member, hp: member.maxHp * healerRatio }
|
||||||
: member);
|
: member);
|
||||||
const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
|
const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
|
||||||
const maxMana = 100;
|
const maxMana = BASE_MANA_POOL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rpgRun: run,
|
rpgRun: run,
|
||||||
@@ -794,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;
|
||||||
@@ -815,13 +1114,19 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
|
|
||||||
startEncounter: () => {
|
startEncounter: () => {
|
||||||
const current = get();
|
const current = get();
|
||||||
|
if (current.runMode === "hockey-healing-pvp"
|
||||||
|
&& current.phase === "briefing"
|
||||||
|
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return;
|
||||||
|
if (current.runMode === "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(
|
||||||
@@ -836,18 +1141,27 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
seenBossIds,
|
seenBossIds,
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
|
: 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,
|
||||||
@@ -876,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,
|
||||||
@@ -889,7 +1205,19 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
[],
|
[],
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
|
: 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,
|
||||||
));
|
));
|
||||||
@@ -1111,6 +1439,14 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
const hockey = setHockeyAim(state.hockey, direction);
|
const hockey = setHockeyAim(state.hockey, direction);
|
||||||
return hockey === state.hockey ? state : { hockey };
|
return hockey === state.hockey ? state : { hockey };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setHockeyPvpPostMatchSelection: (postMatchSelection) => set((state) => ({
|
||||||
|
hockeyPvp: { ...state.hockeyPvp, postMatchSelection },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setHockeyPvpPostMatchStatus: (postMatchStatus, postMatchQueueEndsAtMs = 0) => set((state) => ({
|
||||||
|
hockeyPvp: { ...state.hockeyPvp, postMatchStatus, postMatchQueueEndsAtMs },
|
||||||
|
})),
|
||||||
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
||||||
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
||||||
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
||||||
@@ -1167,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;
|
||||||
@@ -1181,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];
|
||||||
|
|
||||||
@@ -1520,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;
|
||||||
@@ -1551,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);
|
||||||
@@ -1594,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),
|
||||||
@@ -2112,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";
|
||||||
@@ -2194,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";
|
||||||
@@ -2254,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,
|
||||||
@@ -2262,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,
|
||||||
@@ -2286,7 +2840,18 @@ export type GameSnapshot = Omit<GameState,
|
|||||||
| "selectItem"
|
| "selectItem"
|
||||||
| "setPlayerPosition"
|
| "setPlayerPosition"
|
||||||
| "setHockeyAimDirection"
|
| "setHockeyAimDirection"
|
||||||
|
| "setHockeyPvpPostMatchSelection"
|
||||||
|
| "setHockeyPvpPostMatchStatus"
|
||||||
| "applyHockeyPvpRemoteSnapshot"
|
| "applyHockeyPvpRemoteSnapshot"
|
||||||
|
| "selectRoguelikePvpBuff"
|
||||||
|
| "selectRoguelikePvpCurse"
|
||||||
|
| "setRoguelikePvpDraftStep"
|
||||||
|
| "submitRoguelikePvpDraft"
|
||||||
|
| "applyRoguelikePvpDraftReveal"
|
||||||
|
| "applyRoguelikePvpRemoteSnapshot"
|
||||||
|
| "syncRoguelikePvpDraft"
|
||||||
|
| "resolveRoguelikePvpMatch"
|
||||||
|
| "setRoguelikePvpConnectionStatus"
|
||||||
| "setPaused"
|
| "setPaused"
|
||||||
| "togglePause"
|
| "togglePause"
|
||||||
| "setPauseSelection"
|
| "setPauseSelection"
|
||||||
@@ -2315,7 +2880,18 @@ export function getGameSnapshot(): GameSnapshot {
|
|||||||
selectItem: _selectItem,
|
selectItem: _selectItem,
|
||||||
setPlayerPosition: _setPlayerPosition,
|
setPlayerPosition: _setPlayerPosition,
|
||||||
setHockeyAimDirection: _setHockeyAimDirection,
|
setHockeyAimDirection: _setHockeyAimDirection,
|
||||||
|
setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection,
|
||||||
|
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,
|
||||||
@@ -2363,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
@@ -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"
|
||||||
|
|||||||
+115
-4
@@ -7,6 +7,9 @@ import { cycleBottomTab } from "./bottomTabs";
|
|||||||
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
||||||
import { getDisplaySurface } from "../platform/displayRouting";
|
import { getDisplaySurface } from "../platform/displayRouting";
|
||||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||||
|
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||||
|
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
|
||||||
|
import { isPvpRunMode } from "./runModes";
|
||||||
|
|
||||||
function tacticalOverlayOwnsInput() {
|
function tacticalOverlayOwnsInput() {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
@@ -24,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");
|
||||||
@@ -39,6 +87,12 @@ function activateRpgFocus(onExit?: () => void) {
|
|||||||
else onExit?.();
|
else onExit?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activateHockeyPvpPostMatch(onExit?: () => void) {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
if (store.hockeyPvp.postMatchSelection === "menu") onExit?.();
|
||||||
|
else requestHockeyPvpPostMatchAction(store.hockeyPvp.postMatchSelection);
|
||||||
|
}
|
||||||
|
|
||||||
export function useActionBindings(enabled = true, onExit?: () => void) {
|
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||||
const exitRef = useRef(onExit);
|
const exitRef = useRef(onExit);
|
||||||
exitRef.current = onExit;
|
exitRef.current = onExit;
|
||||||
@@ -72,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);
|
||||||
@@ -94,6 +155,27 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (key === "escape") exitRef.current?.();
|
if (key === "escape") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
|
||||||
|
if (key === "arrowleft" || key === "arrowup") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
}
|
||||||
|
if (key === "arrowright" || key === "arrowdown") {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
}
|
||||||
|
if (key === "enter") activateHockeyPvpPostMatch(exitRef.current);
|
||||||
|
if (key === "escape") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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]);
|
||||||
@@ -110,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;
|
||||||
@@ -124,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;
|
||||||
}
|
}
|
||||||
@@ -159,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);
|
||||||
@@ -179,6 +269,26 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (!repeat && token === "Button1") exitRef.current?.();
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||||
|
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||||
|
}
|
||||||
|
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) {
|
||||||
|
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||||
|
}
|
||||||
|
if (!repeat && (token === "Button0" || token === "Button9")) activateHockeyPvpPostMatch(exitRef.current);
|
||||||
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((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))];
|
||||||
@@ -190,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]);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
|
||||||
|
|
||||||
|
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
|
||||||
|
const [seconds, setSeconds] = useState(() =>
|
||||||
|
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
update();
|
||||||
|
if (!active) return;
|
||||||
|
const timer = window.setInterval(update, 100);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active, countdownEndsAtMs]);
|
||||||
|
|
||||||
|
return active ? seconds : 0;
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
@@ -194,6 +195,14 @@ export function BottomDisplayApp() {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
||||||
|
setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }),
|
||||||
|
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;
|
||||||
@@ -254,7 +263,7 @@ export function BottomDisplayApp() {
|
|||||||
return (
|
return (
|
||||||
<main className="bottom-display-root">
|
<main className="bottom-display-root">
|
||||||
{surface.screen === "game"
|
{surface.screen === "game"
|
||||||
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
|
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} onHockeyPvpAction={(action) => postFrontendCommand({ name: "hockeyPvpPostMatch", action })} /></Suspense>
|
||||||
: surface.notice === "Linking upper display…"
|
: surface.notice === "Linking upper display…"
|
||||||
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +121,11 @@ describe("dual-screen game snapshots", () => {
|
|||||||
it("routes maxed-run continuation and passive filter commands", () => {
|
it("routes maxed-run continuation and passive filter commands", () => {
|
||||||
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
||||||
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
||||||
|
const originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection;
|
||||||
|
const 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;
|
||||||
@@ -127,6 +135,11 @@ describe("dual-screen game snapshots", () => {
|
|||||||
useGameStore.setState({
|
useGameStore.setState({
|
||||||
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
||||||
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
||||||
|
setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); },
|
||||||
|
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}`); },
|
||||||
@@ -138,6 +151,11 @@ describe("dual-screen game snapshots", () => {
|
|||||||
|
|
||||||
executeGameCommand({ name: "continueRoguelikeRound" });
|
executeGameCommand({ name: "continueRoguelikeRound" });
|
||||||
executeGameCommand({ name: "startRogueTrialsEndless" });
|
executeGameCommand({ name: "startRogueTrialsEndless" });
|
||||||
|
executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" });
|
||||||
|
executeGameCommand({ name: "selectRoguelikePvpBuff", buffId: "mend-efficiency" });
|
||||||
|
executeGameCommand({ name: "selectRoguelikePvpCurse", curseId: "ability1-mana-cost" });
|
||||||
|
executeGameCommand({ name: "setRoguelikePvpDraftStep", step: "curse" });
|
||||||
|
executeGameCommand({ name: "submitRoguelikePvpDraft" });
|
||||||
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" });
|
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" });
|
||||||
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
||||||
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
|
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
|
||||||
@@ -146,6 +164,11 @@ describe("dual-screen game snapshots", () => {
|
|||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"continue",
|
"continue",
|
||||||
"endless",
|
"endless",
|
||||||
|
"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",
|
||||||
@@ -153,7 +176,15 @@ describe("dual-screen game snapshots", () => {
|
|||||||
"profile-stat:broodfang-spider",
|
"profile-stat:broodfang-spider",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
|
useGameStore.setState({
|
||||||
|
continueRoguelikeRound: originalContinue,
|
||||||
|
startRogueTrialsEndless: originalStartEndless,
|
||||||
|
setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection,
|
||||||
|
selectRoguelikePvpBuff: originalSelectRoguelikePvpBuff,
|
||||||
|
selectRoguelikePvpCurse: originalSelectRoguelikePvpCurse,
|
||||||
|
setRoguelikePvpDraftStep: originalSetRoguelikePvpDraftStep,
|
||||||
|
submitRoguelikePvpDraft: originalSubmitRoguelikePvpDraft,
|
||||||
|
});
|
||||||
useFrontendStore.setState({
|
useFrontendStore.setState({
|
||||||
selectPassiveAbility: originalSelectAbility,
|
selectPassiveAbility: originalSelectAbility,
|
||||||
selectPassiveInfusion: originalSelectPassive,
|
selectPassiveInfusion: originalSelectPassive,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
|||||||
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
||||||
import type { DifficultySlug } from "../game/progression/loot";
|
import type { DifficultySlug } from "../game/progression/loot";
|
||||||
import type { BossGroupId } from "../game/bossCatalog";
|
import type { BossGroupId } from "../game/bossCatalog";
|
||||||
import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp";
|
import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp";
|
||||||
|
import type { 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";
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ export type GameCommand =
|
|||||||
| { name: "continueRoguelikeRound" }
|
| { name: "continueRoguelikeRound" }
|
||||||
| { name: "startRogueTrialsEndless" }
|
| { name: "startRogueTrialsEndless" }
|
||||||
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
|
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
|
||||||
|
| { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection }
|
||||||
|
| { name: "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 }
|
||||||
@@ -76,11 +82,24 @@ export type FrontendCommand =
|
|||||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||||
|
| { name: "hockeyPvpPostMatch"; action: Exclude<HockeyPvpPostMatchSelection, "menu"> }
|
||||||
| { name: "exitGame" }
|
| { name: "exitGame" }
|
||||||
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig };
|
| {
|
||||||
|
name: "launchGame";
|
||||||
|
bossIds: readonly BossId[];
|
||||||
|
difficultySlug?: DifficultySlug;
|
||||||
|
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";
|
||||||
|
export const HOCKEY_PVP_POST_MATCH_EVENT = "iwt:hockey-pvp-post-match";
|
||||||
|
|
||||||
|
export function requestHockeyPvpPostMatchAction(action: Exclude<HockeyPvpPostMatchSelection, "menu">) {
|
||||||
|
window.dispatchEvent(new CustomEvent(HOCKEY_PVP_POST_MATCH_EVENT, { detail: action }));
|
||||||
|
}
|
||||||
|
|
||||||
export type DualScreenMessage =
|
export type DualScreenMessage =
|
||||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
||||||
@@ -114,6 +133,11 @@ export function executeGameCommand(command: GameCommand) {
|
|||||||
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
||||||
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
||||||
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
||||||
|
case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break;
|
||||||
|
case "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;
|
||||||
@@ -164,8 +188,14 @@ export function executeFrontendCommand(command: FrontendCommand) {
|
|||||||
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||||
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
||||||
|
case "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); break;
|
||||||
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
|
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
|
||||||
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break;
|
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: {
|
||||||
|
bossIds: command.bossIds,
|
||||||
|
difficultySlug: command.difficultySlug,
|
||||||
|
pvpMatch: command.pvpMatch ?? command.hockeyPvpMatch,
|
||||||
|
hockeyPvpMatch: command.hockeyPvpMatch,
|
||||||
|
} })); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +223,8 @@ export type BottomGameSnapshot = Pick<GameState,
|
|||||||
| "endlessMode"
|
| "endlessMode"
|
||||||
| "endlessBossKills"
|
| "endlessBossKills"
|
||||||
| "endlessChoiceSelection"
|
| "endlessChoiceSelection"
|
||||||
|
| "runBuffRanks"
|
||||||
|
| "passiveRunBuffId"
|
||||||
| "runModifiers"
|
| "runModifiers"
|
||||||
| "time"
|
| "time"
|
||||||
| "party"
|
| "party"
|
||||||
@@ -218,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 {
|
||||||
@@ -261,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,
|
||||||
@@ -286,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+494
@@ -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);
|
||||||
@@ -901,6 +920,49 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-match-countdown {
|
||||||
|
min-width: 150px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
padding: 8px 24px 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
border: 1px solid rgba(231, 198, 111, 0.7);
|
||||||
|
background: rgba(4, 13, 11, 0.76);
|
||||||
|
box-shadow: 0 0 30px rgba(231, 198, 111, 0.13), inset 0 0 18px rgba(231, 198, 111, 0.06);
|
||||||
|
text-shadow: 0 2px 10px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-match-countdown span { grid-column: 1 / -1; color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||||
|
.pvp-match-countdown strong { color: #fff7dc; font-family: "Cinzel", serif; font-size: clamp(44px, 7vw, 74px); line-height: 0.9; }
|
||||||
|
.phase-overlay .pvp-match-countdown small { padding-bottom: 4px; color: #b7a878; font-size: 8px; }
|
||||||
|
|
||||||
|
.top-pvp-end-actions {
|
||||||
|
width: min(390px, 72%);
|
||||||
|
margin: 15px 0 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-pvp-end-actions button {
|
||||||
|
min-height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid #566c63;
|
||||||
|
color: #dce9e4;
|
||||||
|
background: rgba(5, 17, 14, 0.88);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-pvp-end-actions button strong { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.top-pvp-end-actions button small { color: #788e85; font-size: 7px; }
|
||||||
|
.top-pvp-end-actions button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; background: rgba(77, 65, 30, 0.78); }
|
||||||
|
.top-pvp-end-actions button:disabled { cursor: default; opacity: 0.7; }
|
||||||
|
.top-pvp-post-match-status { min-height: 16px; margin-bottom: 4px; color: #aebfb8; font-size: 9px; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
.phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); }
|
.phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); }
|
||||||
.phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); }
|
.phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); }
|
||||||
.phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); }
|
.phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); }
|
||||||
@@ -1563,9 +1625,19 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
||||||
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||||
.end-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
.end-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||||
|
.end-actions button:disabled { cursor: default; opacity: 0.62; }
|
||||||
.endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; }
|
.endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; }
|
||||||
.endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; }
|
.endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; }
|
||||||
|
|
||||||
|
.end-panel.is-pvp { padding-block: 4%; }
|
||||||
|
.end-panel.is-pvp h2 { margin-bottom: 10px; }
|
||||||
|
.pvp-post-match-status { min-height: 15px; margin-top: 12px; color: #9eb2aa; font-size: 9px; letter-spacing: 0.04em; }
|
||||||
|
.pvp-end-actions { width: min(100%, 470px); grid-template-columns: 1fr 1fr auto; }
|
||||||
|
.pvp-end-actions button { min-height: 46px; display: grid; place-items: center; gap: 2px; padding: 8px 14px; }
|
||||||
|
.pvp-end-actions button span { font-size: 9px; }
|
||||||
|
.pvp-end-actions button small { color: rgba(19, 23, 15, 0.62); font-size: 7px; letter-spacing: 0.04em; }
|
||||||
|
.pvp-end-actions button.secondary small { color: #6f837b; }
|
||||||
|
|
||||||
.buff-draft {
|
.buff-draft {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2861,6 +2933,7 @@ html[data-display-layout="single"] .encounter-callout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-menu-button small { color: #71857e; font-size: 6px; }
|
.game-menu-button small { color: #71857e; font-size: 6px; }
|
||||||
|
.game-menu-button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||||
|
|
||||||
.game-loading {
|
.game-loading {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3125,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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user