Compare commits

..
6 Commits
Author SHA1 Message Date
Warren H 0e36ca1a41 Release v0.1.21 2026-07-19 2026-07-19 18:48:28 -04:00
Warren H 018e060cdd Release v0.1.20 2026-07-19 2026-07-19 16:37:37 -04:00
Warren H 437e70fc58 Release v0.1.19 2026-07-19 2026-07-19 14:15:30 -04:00
Warren H 7b522e3bc8 Release v0.1.18 2026-07-19 2026-07-19 13:58:47 -04:00
Warren H 016a012c78 Release v0.1.17 2026-07-19 2026-07-19 13:02:46 -04:00
Warren H 5045f17b94 Release v0.1.16 2026-07-18 2026-07-18 20:50:51 -04:00
63 changed files with 7145 additions and 349 deletions
+1
View File
@@ -10,6 +10,7 @@ vite.config.d.ts
.env .env
.env.* .env.*
!.env.example !.env.example
/git-token
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/public/basis/ /public/basis/
+24 -10
View File
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
pnpm android:install pnpm android:install
``` ```
The first Android milestone uses the complete single-display fallback. Press The Android host routes the main and tactical surfaces to separate physical
Select (or Tab with a keyboard) to switch between the main game surface and the Thor displays while preserving one authoritative game state. If only one
620 × 540 tactical surface. Native routing to both physical Thor displays is the display is available, Select (or Tab with a keyboard) opens the tactical surface
next milestone; it needs two Android display contexts backed by one shared game over the main game view.
state rather than two independent WebViews.
PC and handheld browsers use the Thor top screen as a responsive, full-viewport
game surface. The compact ability strip keeps combat controls visible; Select
or Tab opens party, map, inventory, and other tactical detail. Use
`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
browser QA.
## TrueNAS deployment ## TrueNAS deployment
@@ -97,8 +102,16 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
``` ```
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can Create `git-token` in the repository root. Paste only the Gitea token into it:
optionally override it for one run. Publish from `main`:
```text
gitea_token_value_goes_here
```
Do not add quotes or a `GITEA_TOKEN=` prefix. The exact `/git-token` path is
Git-ignored. Restrict local file access with `chmod 600 git-token`. The publisher
reads it automatically. `GITEA_TOKEN` remains available as an optional override.
Publish from `main` normally:
```bash ```bash
pnpm publish:gitea -- --message "Describe the update" pnpm publish:gitea -- --message "Describe the update"
@@ -132,6 +145,7 @@ outside the repository.
- `Q` and `E` / D-pad: cycle party target - `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal - `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities - Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
- `M`: tactical map - `M`: tactical map
- `I`: inventory and item tooltip - `I`: inventory and item tooltip
- `Enter` / `START`: begin or reset encounter - `Enter` / `START`: begin or reset encounter
@@ -177,6 +191,6 @@ build. Remove the switch to return to modular rendering.
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom - Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
- Lower-screen typography scales against its own container, never the main page viewport - Lower-screen typography scales against its own container, never the main page viewport
Current Android build is an installable single-display test host. Shipping to both The Android build uses distinct display contexts that project one authoritative
physical Thor displays still needs distinct Android display contexts that project game state across both physical Thor displays. PC and Steam Deck use the same top
one authoritative game state. surface with an adaptive tactical overlay.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.15", "version": "0.1.21",
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs", "predev": "node scripts/sync_basis_transcoder.mjs",
+42 -11
View File
@@ -11,6 +11,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -21,9 +22,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
ANDROID_ROOT = REPO_ROOT / "android" ANDROID_ROOT = REPO_ROOT / "android"
PACKAGE_JSON = REPO_ROOT / "package.json" PACKAGE_JSON = REPO_ROOT / "package.json"
GITEA_TOKEN_FILE = REPO_ROOT / "git-token"
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git" GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
GITEA_API = "https://git.whoagland.com/api/v1" GITEA_API = "https://git.whoagland.com/api/v1"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
GITEA_OWNER = "phenom" GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal-mmo" GITEA_REPO = "i-want-to-heal-mmo"
BRANCH = "main" BRANCH = "main"
@@ -34,6 +35,15 @@ TRUENAS_GITEA_REPO = Path(
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
class GiteaAPIError(SystemExit):
def __init__(self, method: str, path: str, status: int, details: str) -> None:
self.method = method
self.path = path
self.status = status
self.details = details
super().__init__(f"Gitea API {method} {path} failed ({status}): {details}")
def run( def run(
args: list[str], args: list[str],
*, *,
@@ -258,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
def gitea_token() -> str: def gitea_token() -> str:
token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip() token = os.environ.get("GITEA_TOKEN", "").strip()
if not token and GITEA_TOKEN_FILE.is_file():
token = GITEA_TOKEN_FILE.read_text().strip()
if not token: if not token:
raise SystemExit( raise SystemExit(
"GITEA_TOKEN is required to create the release and upload the APK. " f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
"Use --skip-release to push source/tag only." "set GITEA_TOKEN, or use --skip-release."
) )
return token return token
@@ -294,7 +306,7 @@ def gitea_request(
details = error.read().decode(errors="replace") details = error.read().decode(errors="replace")
if allow_not_found and error.code == 404: if allow_not_found and error.code == 404:
return None return None
raise SystemExit(f"Gitea API {method} {path} failed ({error.code}): {details}") from error raise GiteaAPIError(method, path, error.code, details) from error
return json.loads(payload) if payload else None return json.loads(payload) if payload else None
@@ -327,12 +339,31 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
"prerelease": True, "prerelease": True,
} }
).encode() ).encode()
result = gitea_request( path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases", result = None
token=token, for attempt in range(3):
method="POST", try:
body=payload, result = gitea_request(
) path,
token=token,
method="POST",
body=payload,
)
break
except GiteaAPIError as error:
tag_sync_race = (
error.status == 500
and "UQE_release_n" in error.details
and "23505" in error.details
)
if not tag_sync_race or attempt == 2:
raise
delay = 0.5 * (attempt + 1)
print(
f"Gitea tag sync still settling for {tag}; "
f"retrying release in {delay:g}s."
)
time.sleep(delay)
if not isinstance(result, dict): if not isinstance(result, dict):
raise SystemExit("Gitea returned an invalid release response") raise SystemExit("Gitea returned an invalid release response")
return result return result
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
from scripts import publish_gitea
class GiteaTokenTests(unittest.TestCase):
def test_reads_ignored_token_file(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = True
token_file.read_text.return_value = "local-token\n"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "local-token")
def test_environment_token_overrides_file(self) -> None:
token_file = MagicMock()
with (
patch.dict(os.environ, {"GITEA_TOKEN": "environment-token"}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "environment-token")
token_file.is_file.assert_not_called()
def test_requires_token(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = False
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
with self.assertRaisesRegex(SystemExit, "Gitea token is required"):
publish_gitea.gitea_token()
class CreateReleaseTests(unittest.TestCase):
def test_retries_postgres_tag_sync_collision(self) -> None:
collision = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
500,
'duplicate key violates "UQE_release_n" (23505)',
)
created = {"id": 15, "tag_name": "v0.1.15"}
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=[collision, created],
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
result = publish_gitea.create_release(
"v0.1.15",
"ea5d455",
"Release v0.1.15 2026-07-18",
"token",
)
self.assertEqual(result, created)
self.assertEqual(request.call_count, 2)
sleep.assert_called_once_with(0.5)
def test_does_not_retry_unrelated_api_error(self) -> None:
unauthorized = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
401,
"unauthorized",
)
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=unauthorized,
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
with self.assertRaises(publish_gitea.GiteaAPIError):
publish_gitea.create_release(
"v0.1.16",
"commit",
"Release v0.1.16",
"token",
)
request.assert_called_once()
sleep.assert_not_called()
if __name__ == "__main__":
unittest.main()
+781 -3
View File
@@ -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
View File
@@ -7,7 +7,8 @@ import { after, before, test } from "node:test";
import { createGameApiHandler } from "./game-api.mjs"; import { createGameApiHandler } from "./game-api.mjs";
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-")); const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
const api = createGameApiHandler({ dataDirectory }); let roguelikePvpNowMs = 1_000_000;
const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs });
const server = createServer((request, response) => { const server = createServer((request, response) => {
void api.handle(request, response, () => { void api.handle(request, response, () => {
response.statusCode = 404; response.statusCode = 404;
@@ -36,7 +37,7 @@ async function json(path, init = {}) {
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) { function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
return { return {
schemaVersion: 6, schemaVersion: 7,
slotId, slotId,
hunterName, hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest }, stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
@@ -191,7 +192,7 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks); assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds); assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore); assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
assert.equal(legacyUpload.body.save.schemaVersion, 6); assert.equal(legacyUpload.body.save.schemaVersion, 7);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore); assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave); assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration); assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
@@ -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 () => {
+345 -13
View File
@@ -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 })));
@@ -26,6 +31,91 @@ function GameLoadingScreen() {
); );
} }
function TacticalLoadingScreen() {
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
}
function 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();
@@ -39,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);
@@ -58,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;
@@ -70,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"
@@ -88,7 +253,7 @@ function MainApp() {
runMode, runMode,
hunter.gearProgress, hunter.gearProgress,
launchDifficulty, launchDifficulty,
hockeyPvpMatch, pvpMatch,
); );
touchActiveSave(); touchActiveSave();
navigate("game"); navigate("game");
@@ -96,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);
@@ -109,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));
@@ -122,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,
@@ -147,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(() => {
@@ -185,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);
} }
@@ -193,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;
@@ -216,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">
@@ -225,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 top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense> ? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} onHockeyPvpAction={handleHockeyPvpPostMatchAction} /></Suspense>} /></Suspense>
: <FrontEnd onLaunch={launchGame} />} : <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 />;
} }
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { compileRoguelikePvpCurses, roguelikePvpAbilityCooldown, roguelikePvpAbilityManaCost } from "../game/roguelikePvp";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
import type { AbilitySlotId } from "../game/types";
export function AbilityButton({ abilityId, compact = false }: { abilityId: AbilitySlotId; compact?: boolean }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const runMode = useGameStore((state) => state.runMode);
const receivedCurseRanks = useGameStore((state) => state.roguelikePvp.receivedCurseRanks);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`;
if (!ability) {
return (
<button className={`${classes} is-empty`} disabled aria-label={`Empty ${abilityId}`}>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon"></span>
<span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
</button>
);
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const compiledCurses = compileRoguelikePvpCurses(receivedCurseRanks);
const manaCost = runMode === "roguelike-pvp"
? roguelikePvpAbilityManaCost(abilityId, ability.mana, runModifiers, compiledCurses)
: runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runMode === "roguelike-pvp"
? roguelikePvpAbilityCooldown(abilityId, ability.cooldown, runModifiers, compiledCurses)
: runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`${classes} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
+206 -82
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react";
import { ABILITY_ORDER } from "../game/data"; import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers"; import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store"; import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types"; import type { BottomTab, PartyMember } from "../game/types";
import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -20,8 +20,11 @@ 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 { bottomTabsFor } from "../game/bottomTabs"; import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import { import {
BLOCKBREAKER_BREACH_DAMAGE, BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS, BLOCKBREAKER_BRICK_COLORS,
@@ -31,9 +34,120 @@ import {
blockbreakerTimeMultiplier, blockbreakerTimeMultiplier,
} from "../game/blockbreaker"; } from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals"; import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel"; import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics"; import { isBeaconOfLightTarget } from "../game/healerMechanics";
import { AbilityButton } from "./AbilityButton";
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller";
import {
RoguelikePvpDraftPanel,
RoguelikePvpTacticalPanel,
} from "./RoguelikePvpPanels";
function moveTacticalSelection(direction: 1 | -1) {
const store = useGameStore.getState();
if (store.activeTab === "combat") {
store.cycleMember(direction);
return;
}
if (store.activeTab !== "pack" || store.inventory.length === 0) return;
const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
store.selectItem(store.inventory[nextIndex].id);
}
function useSingleScreenTacticalInput(
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void,
onExit?: () => void,
) {
const actionRef = useRef(onHockeyPvpAction);
const exitRef = useRef(onExit);
actionRef.current = onHockeyPvpAction;
exitRef.current = onExit;
useEffect(() => {
if (!isSingleScreenLayout()) return;
let active = getDisplaySurface() === "bottom";
const unsubscribeSurface = subscribeDisplaySurface((surface) => { active = surface === "bottom"; });
const cycleTab = (direction: 1 | -1) => {
const store = useGameStore.getState();
if (direction === 1) store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode));
else {
const tabs = bottomTabsFor(store.runMode);
const currentIndex = tabs.indexOf(store.activeTab);
store.setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
}
};
const activatePhaseAction = () => {
const store = useGameStore.getState();
if (store.phase === "briefing") store.startEncounter();
else if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
else actionRef.current?.(store.hockeyPvp.postMatchSelection);
} else if ((store.phase === "victory" || store.phase === "defeat") && store.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) => {
if (!active || event.repeat) return;
const store = useGameStore.getState();
if (store.paused
|| store.runMode === "rpg-roguelike"
|| store.phase === "intermission"
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
const key = event.key.toLowerCase();
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") {
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
}
if (key === "arrowright" || key === "arrowdown") {
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
}
if (key === "enter") activatePhaseAction();
if (key === "escape") exitRef.current?.();
return;
}
if (key === "arrowleft") cycleTab(-1);
else if (key === "arrowright") cycleTab(1);
else if (key === "arrowup") moveTacticalSelection(-1);
else if (key === "arrowdown") moveTacticalSelection(1);
else if (key === "enter") activatePhaseAction();
else return;
event.preventDefault();
};
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (!active || repeat && !["Button12", "Button13", "Button14", "Button15"].includes(token)) return;
const store = useGameStore.getState();
if (store.paused
|| store.runMode === "rpg-roguelike"
|| store.phase === "intermission"
|| store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
if (["Button12", "Button14"].includes(token)) {
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
} else if (["Button13", "Button15"].includes(token)) {
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
} else if (!repeat && token === "Button0") activatePhaseAction();
else if (!repeat && token === "Button1") exitRef.current?.();
return;
}
if (token === "Button14") cycleTab(-1);
else if (token === "Button15") cycleTab(1);
else if (token === "Button12") moveTacticalSelection(-1);
else if (token === "Button13") moveTacticalSelection(1);
else if (!repeat && token === "Button0") activatePhaseAction();
else if (!repeat && token === "Button1") requestDisplaySurface("top");
});
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
unsubscribeController();
unsubscribeSurface();
};
}, []);
}
function RewardSummary() { function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards); const rewards = useFrontendStore((state) => state.recentRewards);
@@ -113,62 +227,6 @@ function PartyList() {
); );
} }
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
function AbilityTray() { function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId); const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId]; const healer = HEALER_CLASSES[healerClassId];
@@ -228,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>
@@ -260,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);
@@ -279,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;
@@ -286,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" : ""}
@@ -312,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>
); );
@@ -334,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>;
} }
@@ -705,7 +820,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
); );
} }
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) { export function BottomScreen({ onExit, onHockeyPvpAction }: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
} = {}) {
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
const activeTab = useGameStore((state) => state.activeTab); const 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);
@@ -732,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"
{activeTab === "map" && <MapPanel />} ? <RoguelikePvpDraftPanel />
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />} : <>
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />} {activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{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">
+53 -12
View File
@@ -1,39 +1,80 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) { export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display"); const params = new URLSearchParams(window.location.search);
const dedicatedSurface = params.get("display");
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top"); const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
const activeSurfaceRef = useRef(activeSurface); const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface; activeSurfaceRef.current = activeSurface;
const showSurface = useCallback((surface: DisplaySurface) => {
activeSurfaceRef.current = surface;
setActiveSurface(surface);
}, []);
const toggleSurface = useCallback(() => {
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
}, []);
useEffect(() => { useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return; if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top"); if (layout === "thor-preview") return;
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface); requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => { const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (token === "Button8" && !repeat) toggle(); if (token === "Button8" && !repeat) toggleSurface();
}); });
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab" || event.repeat) return; if (event.repeat) return;
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
event.preventDefault();
requestDisplaySurface("top");
return;
}
if (event.key !== "Tab") return;
event.preventDefault(); event.preventDefault();
toggle(); toggleSurface();
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface(); unsubscribeSurface();
unsubscribeController(); unsubscribeController();
requestDisplaySurface("top");
}; };
}, [dedicatedSurface]); }, [dedicatedSurface, layout, showSurface, toggleSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") { if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>; return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
} }
if (layout === "single") {
const contextOpen = activeSurface === "bottom";
return (
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
<div className="single-primary-surface">{top}</div>
{contextOpen && (
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button
className="single-context-toggle"
onClick={toggleSurface}
aria-expanded={contextOpen}
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
>
<b>{contextOpen ? "Return" : contextLabel}</b>
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
}
return ( return (
<div className={`device-frame active-${activeSurface}`}> <div className={`device-frame active-${activeSurface}`}>
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div> <div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div> <div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
<button <button
className="native-display-switch" className="native-display-switch"
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")} onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"} aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
> >
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small> <b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
+67 -77
View File
@@ -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,
return; onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
} });
const poll = async () => { queueOperation.current = operation;
if (!queueActive.current || !queueTicket.current) return; const match = await operation.result;
try { if (!match || queueOperation.current !== operation) return;
const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current); if (isRoguelikePvp) completeRoguelikePvpQueue(match as RoguelikePvpMatchConfig);
if (!queueActive.current) return; else completeHockeyPvpQueue(match as HockeyPvpMatchConfig);
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>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>} {isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 13 group drops · Independent pet chance 1 in 500</small></div>} {isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 13 group drops · Independent pet chance 1 in 500</small></div>}
{isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>} {isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>}
{isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>} {isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>}
{isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>} {isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>}
{isRoguelikePvp && <div className="mode-loot-preview"><span>Competitive record</span><b>Wins / losses · Highest round</b><small>Endless mirrored rounds award no permanent boss loot</small></div>}
</FrontSurface> </FrontSurface>
} }
/> />
); );
} }
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) { export function FrontEnd({ onLaunch }: { onLaunch: FrontEndLaunchHandler }) {
const screen = useFrontendStore((state) => state.screen); const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />; if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />; if (screen === "saves") return <SaveScreen />;
+363
View File
@@ -0,0 +1,363 @@
import { useEffect, useState, type CSSProperties } from "react";
import { HEALER_CLASSES } from "../game/healers";
import {
RUN_BUFFS,
effectiveRunBuffRank,
formatRunBuffEffect,
} from "../game/roguelike";
import {
ROGUELIKE_PVP_CURSES,
ROGUELIKE_PVP_CURSE_ORDER,
formatRoguelikePvpCurseEffect,
} from "../game/roguelikePvp";
import { useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const clampPercent = (value: number) => Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
function percentOf(value: number, maximum: number) {
return maximum > 0 ? clampPercent((value / maximum) * 100) : 0;
}
function labelToken(value: string) {
return value
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function useDeadlineSeconds(deadlineAtMs: number) {
const calculate = () => Math.max(0, Math.ceil((deadlineAtMs - Date.now()) / 1_000));
const [remaining, setRemaining] = useState(calculate);
useEffect(() => {
setRemaining(calculate());
if (deadlineAtMs <= Date.now()) return;
const timer = window.setInterval(() => setRemaining(calculate()), 250);
return () => window.clearInterval(timer);
}, [deadlineAtMs]);
return remaining;
}
function ProgressMeter({ label, value, tone }: { label: string; value: number; tone: "local" | "rival" }) {
const percent = clampPercent(value);
return (
<span
className={`roguelike-pvp-meter is-${tone}`}
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<i><b style={{ width: `${percent}%` }} /></i>
<em>{Math.round(percent)}%</em>
</span>
);
}
function EmptyDraftChoice({ kind }: { kind: "buff" | "curse" }) {
return (
<div className={`roguelike-pvp-draft-empty is-${kind}`}>
<i>{kind === "buff" ? "✦" : "⌁"}</i>
<span>
<strong>{kind === "buff" ? "Blessings mastered" : "Curse pool exhausted"}</strong>
<small>No selection required. Continue draft.</small>
</span>
</div>
);
}
function DraftStepRail({ step }: { step: "buff" | "curse" | "review" }) {
const steps = ["buff", "curse", "review"] as const;
const activeIndex = steps.indexOf(step);
return (
<ol className="roguelike-pvp-step-rail" aria-label="Draft progress">
{steps.map((entry, index) => (
<li key={entry} className={`${entry === step ? "is-active" : ""} ${index < activeIndex ? "is-complete" : ""}`} aria-current={entry === step ? "step" : undefined}>
<b>{index < activeIndex ? "✓" : index + 1}</b>
<span>{entry === "buff" ? "Bless" : entry === "curse" ? "Sabotage" : "Lock"}</span>
</li>
))}
</ol>
);
}
/** Lower-display draft. Controller routing updates the same selected IDs as pointer input. */
export function RoguelikePvpDraftPanel({ className = "" }: { className?: string }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const opponentHealerClassId = useGameStore((state) => state.roguelikePvp.opponentHealerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const opponentName = useGameStore((state) => state.roguelikePvp.opponentName);
const round = useGameStore((state) => state.roguelikePvp.round);
const choices = useGameStore((state) => state.roguelikePvp.buffChoices);
const curseChoices = useGameStore((state) => state.roguelikePvp.curseChoices);
const selectedBuffId = useGameStore((state) => state.roguelikePvp.selectedBuffId);
const selectedCurseId = useGameStore((state) => state.roguelikePvp.selectedCurseId);
const draftStep = useGameStore((state) => state.roguelikePvp.draftStep);
const draftDeadlineAtMs = useGameStore((state) => state.roguelikePvp.draftDeadlineAtMs);
const localDraftLocked = useGameStore((state) => state.roguelikePvp.localDraftLocked);
const opponentDraftLocked = useGameStore((state) => state.roguelikePvp.opponentDraftLocked);
const selectBuff = useGameStore((state) => state.selectRoguelikePvpBuff);
const selectCurse = useGameStore((state) => state.selectRoguelikePvpCurse);
const setDraftStep = useGameStore((state) => state.setRoguelikePvpDraftStep);
const submitDraft = useGameStore((state) => state.submitRoguelikePvpDraft);
const remainingSeconds = useDeadlineSeconds(draftDeadlineAtMs);
const abilities = HEALER_CLASSES[healerClassId].abilities;
const opponentAbilities = HEALER_CLASSES[opponentHealerClassId].abilities;
const selectedBuff = selectedBuffId ? RUN_BUFFS[selectedBuffId] : null;
const selectedCurse = selectedCurseId ? ROGUELIKE_PVP_CURSES[selectedCurseId] : null;
const buffReady = selectedBuffId !== null || choices.length === 0;
const curseReady = selectedCurseId !== null || curseChoices.length === 0;
if (localDraftLocked) {
return (
<section className={`roguelike-pvp-draft roguelike-pvp-draft-locked ${className}`.trim()} role="status" aria-live="polite">
<header className="roguelike-pvp-draft-header">
<span>Round {round} complete</span>
<time dateTime={`PT${remainingSeconds}S`}>{remainingSeconds}s</time>
</header>
<div className="roguelike-pvp-lock-sigil" aria-hidden="true"><i></i><b>LOCKED</b></div>
<h2>{opponentDraftLocked ? "Both drafts sealed" : `Waiting for ${opponentName}`}</h2>
<p>{opponentDraftLocked ? "Revealing sabotage and preparing mirrored encounters." : "Your choices stay hidden until rival locks or timer expires."}</p>
<div className="roguelike-pvp-locked-picks">
<span className="is-buff"><i>{selectedBuff?.icon ?? "✦"}</i><small>Your blessing</small><strong>{selectedBuff?.name ?? "Mastered"}</strong></span>
<b aria-hidden="true">VS</b>
<span className="is-curse"><i>{selectedCurse?.icon ?? "⌁"}</i><small>Sent to rival</small><strong>{selectedCurse?.name ?? "None"}</strong></span>
</div>
</section>
);
}
return (
<section className={`roguelike-pvp-draft is-${draftStep} ${className}`.trim()} role="dialog" aria-modal="true" aria-labelledby="roguelike-pvp-draft-title">
<header className="roguelike-pvp-draft-header">
<span>Round {round} cleared · Next: {round + 1}</span>
<DraftStepRail step={draftStep} />
<time dateTime={`PT${remainingSeconds}S`} aria-label={`${remainingSeconds} seconds remaining`}>{remainingSeconds}s</time>
</header>
{draftStep === "buff" && (
<div className="roguelike-pvp-draft-body">
<div className="roguelike-pvp-draft-copy">
<small>Choose for yourself</small>
<h2 id="roguelike-pvp-draft-title">Claim a blessing</h2>
<p>Strengthen one equipped ability for every later round.</p>
</div>
<div className={`roguelike-pvp-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const currentRank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, currentRank + 1);
const ability = abilities[buff.abilitySlotId];
return (
<button
key={buffId}
className={`roguelike-pvp-choice is-buff ${selectedBuffId === buffId ? "is-controller-selected" : ""}`}
style={{ "--roguelike-pvp-choice-accent": buff.accent } as CSSProperties}
onPointerEnter={() => selectBuff(buffId)}
onClick={() => selectBuff(buffId)}
aria-pressed={selectedBuffId === buffId}
>
<i>{buff.icon}</i>
<span><small>{ability.shortName} · Rank {nextRank}/{buff.maxRank}</small><strong>{buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : <EmptyDraftChoice kind="buff" />}
</div>
<div className="roguelike-pvp-draft-actions">
<span><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<button className="is-primary" disabled={!buffReady} onClick={() => setDraftStep("curse")}>Choose rival curse <b></b></button>
</div>
</div>
)}
{draftStep === "curse" && (
<div className="roguelike-pvp-draft-body">
<div className="roguelike-pvp-draft-copy is-curse">
<small>Inflict on {opponentName}</small>
<h2 id="roguelike-pvp-draft-title">Choose their burden</h2>
<p>Curse one rival ability. Repeated curses stack to rank 3.</p>
</div>
<div className={`roguelike-pvp-choice-grid choice-count-${curseChoices.length}`}>
{curseChoices.length > 0 ? curseChoices.map((curseId) => {
const curse = ROGUELIKE_PVP_CURSES[curseId];
const ability = opponentAbilities[curse.abilitySlotId];
return (
<button
key={curseId}
className={`roguelike-pvp-choice is-curse ${selectedCurseId === curseId ? "is-controller-selected" : ""}`}
style={{ "--roguelike-pvp-choice-accent": curse.accent } as CSSProperties}
onPointerEnter={() => selectCurse(curseId)}
onClick={() => selectCurse(curseId)}
aria-pressed={selectedCurseId === curseId}
>
<i>{curse.icon}</i>
<span><small>{ability.shortName} · Add 1 rank</small><strong>{curse.name}</strong></span>
<b>{formatRoguelikePvpCurseEffect(curseId, 1, ability.shortName)}</b>
<p>{curse.detail}</p>
</button>
);
}) : <EmptyDraftChoice kind="curse" />}
</div>
<div className="roguelike-pvp-draft-actions">
<button className="is-back" onClick={() => setDraftStep("buff")}><b></b> Blessing</button>
<span><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<button className="is-primary" disabled={!curseReady} onClick={() => setDraftStep("review")}>Review draft <b></b></button>
</div>
</div>
)}
{draftStep === "review" && (
<div className="roguelike-pvp-review">
<div className="roguelike-pvp-draft-copy">
<small>Hidden until both players lock</small>
<h2 id="roguelike-pvp-draft-title">Seal round {round + 1}</h2>
<p>Confirm blessing and sabotage. Locked choices cannot change.</p>
</div>
<div className="roguelike-pvp-review-cards">
<article className="is-buff" style={{ "--roguelike-pvp-choice-accent": selectedBuff?.accent ?? "#e8c872" } as CSSProperties}>
<i>{selectedBuff?.icon ?? "✦"}</i>
<small>Your blessing</small>
<strong>{selectedBuff ? `${abilities[selectedBuff.abilitySlotId].shortName}: ${selectedBuff.name}` : "No blessing required"}</strong>
<p>{selectedBuff?.summary ?? "Blessing catalog mastered."}</p>
</article>
<b aria-hidden="true">VS</b>
<article className="is-curse" style={{ "--roguelike-pvp-choice-accent": selectedCurse?.accent ?? "#d76762" } as CSSProperties}>
<i>{selectedCurse?.icon ?? "⌁"}</i>
<small>{opponentName}'s burden</small>
<strong>{selectedCurse ? `${opponentAbilities[selectedCurse.abilitySlotId].shortName}: ${selectedCurse.name}` : "No curse required"}</strong>
<p>{selectedCurse?.summary ?? "Curse catalog exhausted."}</p>
</article>
</div>
<div className="roguelike-pvp-draft-actions is-review">
<button className="is-back" onClick={() => setDraftStep("curse")}><b></b> Change</button>
<span>Choices reveal together</span>
<button className="is-primary is-controller-selected" disabled={!buffReady || !curseReady} onClick={submitDraft}>Lock draft <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b></button>
</div>
</div>
)}
</section>
);
}
/** Compact top-display rivalry HUD. Intended to replace the normal objective chip. */
export function RoguelikePvpStatusStrip({ className = "" }: { className?: string }) {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const party = useGameStore((state) => state.party);
const pvp = useGameStore((state) => state.roguelikePvp);
const localPartyMaximum = party.reduce((total, member) => total + member.maxHp, 0);
const localPartyHealth = party.reduce((total, member) => total + member.hp, 0);
const localPartyPercent = percentOf(localPartyHealth, localPartyMaximum);
const localBossHealth = boss.hp + additionalBosses.reduce((total, entry) => total + entry.boss.hp, 0);
const localBossMaximum = boss.maxHp + additionalBosses.reduce((total, entry) => total + entry.boss.maxHp, 0);
const localBossPercent = percentOf(localBossHealth, localBossMaximum);
const rivalBossPercent = percentOf(pvp.opponentBossHp, pvp.opponentBossMaxHp);
return (
<aside className={`roguelike-pvp-status-strip ${className}`.trim()} aria-label={`Roguelike PVP round ${pvp.round} against ${pvp.opponentName}`}>
<header>
<span><small>Round {pvp.round}</small><strong>Rift Race</strong></span>
<b>VS</b>
<span><small>{labelToken(pvp.status)}</small><strong>{pvp.opponentName}</strong></span>
<i data-connection={pvp.connectionStatus} title={labelToken(pvp.connectionStatus)} />
</header>
<div className="roguelike-pvp-status-sides">
<span><small>You · R{pvp.round}</small><ProgressMeter label={`Your boss at ${Math.round(localBossPercent)} percent`} value={localBossPercent} tone="local" /><ProgressMeter label={`Your party at ${Math.round(localPartyPercent)} percent`} value={localPartyPercent} tone="local" /></span>
<span><small>{pvp.opponentName} · R{pvp.opponentRound}</small><ProgressMeter label={`Rival boss at ${Math.round(rivalBossPercent)} percent`} value={rivalBossPercent} tone="rival" /><ProgressMeter label={`Rival party at ${Math.round(pvp.opponentPartyHpPercent)} percent`} value={pvp.opponentPartyHpPercent} tone="rival" /></span>
</div>
</aside>
);
}
/** Lower-display live opponent telemetry and received-curse ledger. */
export function RoguelikePvpTacticalPanel({ className = "" }: { className?: string }) {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const party = useGameStore((state) => state.party);
const healerClassId = useGameStore((state) => state.healerClassId);
const pvp = useGameStore((state) => state.roguelikePvp);
const localPartyMaximum = party.reduce((total, member) => total + member.maxHp, 0);
const localPartyHealth = party.reduce((total, member) => total + member.hp, 0);
const localPartyPercent = percentOf(localPartyHealth, localPartyMaximum);
const localBossHealth = boss.hp + additionalBosses.reduce((total, entry) => total + entry.boss.hp, 0);
const localBossMaximum = boss.maxHp + additionalBosses.reduce((total, entry) => total + entry.boss.maxHp, 0);
const localBossPercent = percentOf(localBossHealth, localBossMaximum);
const rivalBossPercent = percentOf(pvp.opponentBossHp, pvp.opponentBossMaxHp);
const activeCurses = ROGUELIKE_PVP_CURSE_ORDER.filter((curseId) => (pvp.receivedCurseRanks[curseId] ?? 0) > 0);
const abilities = HEALER_CLASSES[healerClassId].abilities;
return (
<section className={`roguelike-pvp-tactical ${className}`.trim()} aria-label="Roguelike PVP tactical display">
<header>
<span><small>Competitive run</small><strong>Rift Ledger</strong></span>
<b>Round {pvp.round}</b>
<span className="roguelike-pvp-connection"><i data-connection={pvp.connectionStatus} /><small>{labelToken(pvp.connectionStatus)}</small></span>
</header>
<div className="roguelike-pvp-race-board">
<article className="is-local">
<header><small>Your formation</small><strong>Round {pvp.round}</strong></header>
<div><span>Boss</span><ProgressMeter label={`Your boss at ${Math.round(localBossPercent)} percent`} value={localBossPercent} tone="local" /></div>
<div><span>Party</span><ProgressMeter label={`Your party at ${Math.round(localPartyPercent)} percent`} value={localPartyPercent} tone="local" /></div>
</article>
<b aria-hidden="true">VS</b>
<article className="is-rival">
<header><small>{pvp.opponentName}</small><strong>Round {pvp.opponentRound}</strong></header>
<div><span>Boss</span><ProgressMeter label={`Rival boss at ${Math.round(rivalBossPercent)} percent`} value={rivalBossPercent} tone="rival" /></div>
<div><span>Party</span><ProgressMeter label={`Rival party at ${Math.round(pvp.opponentPartyHpPercent)} percent`} value={pvp.opponentPartyHpPercent} tone="rival" /></div>
</article>
</div>
<div className="roguelike-pvp-curse-ledger">
<header><span><small>Enemy sabotage</small><strong>Active burdens</strong></span><b>{activeCurses.length}</b></header>
{activeCurses.length > 0 ? (
<div className="roguelike-pvp-curse-list">
{activeCurses.map((curseId) => {
const curse = ROGUELIKE_PVP_CURSES[curseId];
const rank = pvp.receivedCurseRanks[curseId] ?? 0;
const ability = abilities[curse.abilitySlotId];
return (
<article key={curseId} style={{ "--roguelike-pvp-choice-accent": curse.accent } as CSSProperties}>
<i>{curse.icon}</i>
<span><small>{ability.shortName} · Rank {rank}/{curse.maxRank}</small><strong>{curse.name}</strong></span>
<b>{formatRoguelikePvpCurseEffect(curseId, rank, ability.shortName)}</b>
</article>
);
})}
</div>
) : (
<div className="roguelike-pvp-curse-empty"><i></i><span><strong>No active burdens</strong><small>First rival curse arrives after round clear.</small></span></div>
)}
</div>
<footer>
<span><i /> Your progress</span>
<span><i /> Rival progress</span>
<b>{labelToken(pvp.status)}</b>
</footer>
</section>
);
}
/** Top-display intermission projection while drafting on the lower surface. */
export function RoguelikePvpDraftWaitingOverlay({ className = "" }: { className?: string }) {
const pvp = useGameStore((state) => state.roguelikePvp);
const remainingSeconds = useDeadlineSeconds(pvp.draftDeadlineAtMs);
return (
<div className={`roguelike-pvp-waiting-overlay ${className}`.trim()} role="status" aria-live="polite">
<i aria-hidden="true"></i>
<span>Round {pvp.round} cleared</span>
<h1>{pvp.localDraftLocked ? "Draft sealed" : "Choose boon and burden"}</h1>
<p>{pvp.localDraftLocked
? pvp.opponentDraftLocked ? "Both players locked. Revealing choices…" : `Waiting for ${pvp.opponentName} to lock.`
: "Use lower display to empower your build and sabotage your rival."}</p>
<time dateTime={`PT${remainingSeconds}S`}>{remainingSeconds}s</time>
<small>{pvp.opponentDraftLocked ? `${pvp.opponentName} locked` : `${pvp.opponentName} choosing`}</small>
</div>
);
}
+119 -11
View File
@@ -7,10 +7,20 @@ 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";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics"; import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { ABILITY_ORDER } from "../game/data";
import { AbilityButton } from "./AbilityButton";
import {
RoguelikePvpDraftWaitingOverlay,
RoguelikePvpStatusStrip,
} from "./RoguelikePvpPanels";
import { requestDisplaySurface } from "../platform/displayRouting";
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })))); const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene"; GameScene.displayName = "MemoizedGameScene";
@@ -165,8 +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();
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);
@@ -175,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
@@ -185,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."
@@ -208,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
@@ -218,14 +250,69 @@ 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>{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> {competitivePvpBriefing && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${competitivePvpCountdown} seconds`}>
<span>Match starts in</span>
<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>
);
}
function SingleScreenAbilityBar() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
return (
<div className="single-ability-bar" aria-label="Equipped abilities">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} compact />)}
</div> </div>
); );
} }
@@ -374,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);
@@ -385,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" />}>
@@ -395,13 +496,20 @@ 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>16</b> Cast</div> <div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>} {onExit && <button
className={`game-menu-button ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }}
onClick={() => phase === "combat" && !onlineRoguelikePvp ? setPaused(true) : onExit()}
><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div> </div>
<SingleScreenAbilityBar />
<GoalPopup /> <GoalPopup />
<BlockbreakerScorePopup /> <BlockbreakerScorePopup />
<AetherScorePopup /> <AetherScorePopup />
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext, type RpgRunUiContext,
type RpgRunUiProps, type RpgRunUiProps,
} from "./RpgRunUiShared"; } from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css"; import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: { function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action" className="rpg-card-action"
disabled={recruited && !canRemove} disabled={recruited && !canRemove}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`} label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton> ><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)} )}
/> />
); );
})} })}
</div> </div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}> <div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong> <strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => ( {run.roster.map((member) => (
<FocusButton <FocusButton
key={member.instanceId} key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`} className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove} disabled={!canRemove}
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton> >
<i style={{ background: member.color }} />
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
<PartyRoleBadge role={member.role} />
<b className="rpg-picked-remove" aria-hidden="true">×</b>
</FocusButton>
))} ))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)} {Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div> </div>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card" className="rpg-reward-card"
style={runAccentStyle(summary.accent)} style={runAccentStyle(summary.accent)}
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
> >
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b> <i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action" className="rpg-card-action"
disabled={disabled} disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton> ><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} /> } />
); );
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) { function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context; const { run } = context;
return ( return (
<section className="rpg-tactical-section"> <section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header> <header>
<h3>Party</h3>
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
</header>
<div className="rpg-tactical-party-grid"> <div className="rpg-tactical-party-grid">
{run.roster.map((member) => ( {run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? ( <PartyCard key={member.instanceId} member={member} compact action={interactive ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)} focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action" className="rpg-card-action"
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton> ><span>Remove</span></FocusButton>
) : undefined} /> ) : undefined} />
))} ))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => { {draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId); const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit); const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return ( return (
<FocusButton <FocusButton
key={candidate.candidateId} key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)} style={runAccentStyle(candidate.color)}
disabled={disabled} disabled={disabled}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
> >
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i> <i>{role.icon}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span> <span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b> <b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u> <u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton> </FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => { {chest.choices.map((choice) => {
const summary = rewardSummary(choice); const summary = rewardSummary(choice);
return ( return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}> <FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b> <i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid"> <div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => ( {shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={ <GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}> <FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span> <span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton> </FocusButton>
} /> } />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState, RpgRoguelikeRunState,
RunGearItem, RunGearItem,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike"; import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike"; import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike"; import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps { export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) { while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect(); const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) { const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top; if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom; else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
} }
if (parent.scrollWidth > parent.clientWidth) { if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left; if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right; else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
} }
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()} className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)} style={runAccentStyle(entry.color)}
> >
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div> <div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3> <h3>{entry.name}</h3>
<p>{entry.className}</p> <p>{entry.className}</p>
{!compact && ( {!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion"; return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
} }
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
const comparison = compareRunGear(run.equipment, item);
const ownerName = gearOwnerName(run, item);
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
const change = comparison.delta >= 0
? `Gain ${comparison.delta} percentage points`
: `Lose ${Math.abs(comparison.delta)} percentage points`;
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
}
export function GearStatComparison({ run, item }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
}) {
const ownerName = gearOwnerName(run, item);
const comparison = compareRunGear(run.equipment, item);
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
return (
<span
className="rpg-gear-comparison"
aria-label={gearComparisonLabel(run, item)}
>
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
<span>
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
<i aria-hidden="true"></i>
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
</span>
</span>
);
}
export function GearCard({ run, item, price, action }: { export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem; readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div> <div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small> <small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3> <h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p> {price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div> </div>
{action} {action}
</article> </article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker { .rpg-card-kicker {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 5px; gap: 5px;
color: var(--rarity-color, #e7e9ec); color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600; font-weight: 600;
} }
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3, .rpg-party-card h3,
.rpg-spell-card h3, .rpg-spell-card h3,
.rpg-gear-card h3 { .rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip, .rpg-picked-chip,
.rpg-empty-chip, .rpg-empty-chip,
.rpg-spellbook-chip { .rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip { .rpg-picked-chip {
position: relative; position: relative;
padding: 4px 18px 4px 10px; padding: 4px 6px 4px 10px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center; align-content: center;
gap: 5px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
@@ -414,18 +465,24 @@
width: 3px; width: 3px;
} }
.rpg-picked-chip > small { .rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden; overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.rpg-picked-chip > b { .rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
position: absolute; .rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
right: 6px;
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e; color: #82958e;
font-size: 14px;
} }
.rpg-empty-chip { .rpg-empty-chip {
@@ -870,7 +927,7 @@
} }
.rpg-gear-card { .rpg-gear-card {
min-height: 72px; min-height: 112px;
padding: 8px 8px 22px 43px; padding: 8px 8px 22px 43px;
} }
@@ -904,6 +961,12 @@
font-size: 9px; font-size: 9px;
} }
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list { .rpg-shop-side-list {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px; font-size: 9px;
} }
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list { .rpg-tactical-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
} }
.rpg-tactical-reward { .rpg-tactical-reward {
min-height: 94px; min-height: 130px;
padding: 9px 64px 9px 49px; padding: 9px 64px 9px 49px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid { .rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.rpg-run-tactical .rpg-gear-card { .rpg-run-tactical .rpg-gear-card {
min-height: 80px; min-height: 112px;
} }
.rpg-service-row { .rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px; min-height: 92px;
} }
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip, .rpg-picked-strip,
.rpg-spellbook-strip { .rpg-spellbook-strip {
overflow-x: auto; overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px; min-width: 100px;
} }
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout { .rpg-shop-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
overflow-y: auto; overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b { .rpg-tactical-offer > b {
display: none; display: none;
} }
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
} }
/* Single-display browser fallback gets a usable full-height draft surface. */ /* Single-display browser fallback gets a usable full-height draft surface. */
+8 -5
View File
@@ -117,11 +117,11 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
status: "Playable now", status: "Playable now",
}, },
"roguelike-pvp": { "roguelike-pvp": {
eyebrow: "3v3 · mirrored expeditions", eyebrow: "1v1 healer duel · five-person mirrored survival",
title: "Roguelike PvP", title: "Roguelike PvP",
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.", description: "Race a rival healer through identical endless boss rounds. After every clear, draft one stacking buff for your party and one curse for theirs.",
detail: "Draft order, normalized base gear, rival pressure, and sudden-death rules", detail: "Seeded encounters · secret buff-and-curse drafts · normalized base gear · CPU fallback",
status: "Mode shell ready", status: "Playable now",
}, },
"stadium-pvp": { "stadium-pvp": {
eyebrow: "5v5 · objective arena", eyebrow: "5v5 · objective arena",
@@ -150,7 +150,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName); const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required."); if (!normalizedName) throw new Error("Hunter name is required.");
return { return {
schemaVersion: 6, schemaVersion: 7,
slotId, slotId,
hunterName: normalizedName, hunterName: normalizedName,
activeClassId: "priest", activeClassId: "priest",
@@ -175,6 +175,9 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
hockeyHealingPvpWins: 0, hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0, hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0, hockeyHealingPvpBossKills: 0,
roguelikePvpWins: 0,
roguelikePvpLosses: 0,
highestRoguelikePvpRound: 0,
highestBlockbreakerBricks: 0, highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0, longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0, highestBlockbreakerScore: 0,
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startHockeyPvpMatchmaking, startHockeyPvpRematch } from "./hockeyPvpMatchmaking";
describe("Hockey PVP matchmaking", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("falls back to a CPU match with a visible five-second start countdown", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const operation = startHockeyPvpMatchmaking({
slotId: 1,
hunterName: "Aelia",
online: false,
timeoutMs: 5_000,
random: () => 0.5,
cpuName: () => "CPU Sage",
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(operation.result).resolves.toMatchObject({
role: "cpu",
opponentName: "CPU Sage",
generation: 1,
countdownEndsAtMs: 11_000,
});
});
it("polls until both players accept an online rematch", async () => {
vi.useFakeTimers();
const requestHockeyPvpRematch = vi.fn()
.mockResolvedValueOnce({ status: "waiting" })
.mockResolvedValueOnce({
status: "matched",
match: {
id: "match-1",
seed: 22,
generation: 2,
countdownEndsAtMs: 10_000,
opponentName: "Rival",
role: "host",
},
});
const operation = startHockeyPvpRematch({
matchId: "match-1",
generation: 1,
pollMs: 350,
repository: {
requestHockeyPvpRematch,
cancelHockeyPvpRematch: vi.fn().mockResolvedValue(undefined),
},
});
await vi.advanceTimersByTimeAsync(350);
await expect(operation.result).resolves.toMatchObject({
matchId: "match-1",
seed: 22,
generation: 2,
opponentName: "Rival",
role: "host",
});
expect(requestHockeyPvpRematch).toHaveBeenCalledTimes(2);
});
});
+187
View File
@@ -0,0 +1,187 @@
import {
HOCKEY_PVP_COUNTDOWN_MS,
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
randomHockeyPvpCpuName,
type HockeyPvpMatchConfig,
} from "../game/hockeyHealingPvp";
import {
onlineRepository,
type HockeyPvpOnlineMatch,
type OnlineRepository,
} from "./onlineRepository";
import type { SaveSlotId } from "./types";
type QueueRepository = Pick<OnlineRepository,
"joinHockeyPvpQueue" | "pollHockeyPvpQueue" | "cancelHockeyPvpQueue">;
type RematchRepository = Pick<OnlineRepository,
"requestHockeyPvpRematch" | "cancelHockeyPvpRematch">;
export interface HockeyPvpMatchOperation {
result: Promise<HockeyPvpMatchConfig | null>;
cancel: () => void;
}
export function onlineHockeyPvpMatchConfig(match: HockeyPvpOnlineMatch): HockeyPvpMatchConfig {
return {
matchId: match.id,
seed: match.seed,
generation: match.generation,
countdownEndsAtMs: match.countdownEndsAtMs,
opponentName: match.opponentName,
role: match.role,
};
}
export function startHockeyPvpMatchmaking(options: {
slotId: SaveSlotId;
hunterName: string;
online: boolean;
repository?: QueueRepository;
timeoutMs?: number;
pollMs?: number;
onElapsed?: (elapsedMs: number) => void;
onOnlineUnavailable?: () => void;
random?: () => number;
cpuName?: () => string;
}): HockeyPvpMatchOperation {
const repository = options.repository ?? onlineRepository;
const timeoutMs = options.timeoutMs ?? HOCKEY_PVP_QUEUE_TIMEOUT_MS;
const pollMs = options.pollMs ?? 350;
const random = options.random ?? Math.random;
const cpuName = options.cpuName ?? randomHockeyPvpCpuName;
const startedAt = Date.now();
let active = true;
let ticketId: string | null = null;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
let clockTimer: ReturnType<typeof setInterval> | null = null;
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
const clearTimers = () => {
if (pollTimer !== null) clearTimeout(pollTimer);
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
if (clockTimer !== null) clearInterval(clockTimer);
pollTimer = null;
fallbackTimer = null;
clockTimer = null;
};
const finish = (match: HockeyPvpMatchConfig | null) => {
if (!active) return;
active = false;
clearTimers();
settle(match);
};
const cancelTicket = () => {
const currentTicketId = ticketId;
ticketId = null;
if (currentTicketId) void repository.cancelHockeyPvpQueue(currentTicketId).catch(() => undefined);
};
const fallbackToCpu = () => {
if (!active) return;
cancelTicket();
finish({
matchId: null,
seed: Math.max(1, Math.floor(random() * 0xffffffff)),
generation: 1,
opponentName: cpuName(),
role: "cpu",
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
});
};
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
settle = resolve;
fallbackTimer = setTimeout(fallbackToCpu, timeoutMs);
if (options.onElapsed) {
options.onElapsed(0);
clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100);
}
if (!options.online) return;
void (async () => {
try {
const joined = await repository.joinHockeyPvpQueue(options.slotId, options.hunterName);
if (!active) {
if (!joined.match) void repository.cancelHockeyPvpQueue(joined.ticketId).catch(() => undefined);
return;
}
ticketId = joined.ticketId;
if (joined.match) {
finish(onlineHockeyPvpMatchConfig(joined.match));
return;
}
const poll = async () => {
if (!active || !ticketId) return;
try {
const queued = await repository.pollHockeyPvpQueue(ticketId);
if (!active) return;
if (queued.match) {
finish(onlineHockeyPvpMatchConfig(queued.match));
return;
}
} catch {
options.onOnlineUnavailable?.();
}
if (active) pollTimer = setTimeout(poll, pollMs);
};
pollTimer = setTimeout(poll, pollMs);
} catch {
options.onOnlineUnavailable?.();
}
})();
});
return {
result,
cancel: () => {
if (!active) return;
cancelTicket();
finish(null);
},
};
}
export function startHockeyPvpRematch(options: {
matchId: string;
generation: number;
repository?: RematchRepository;
pollMs?: number;
onUnavailable?: () => void;
}): HockeyPvpMatchOperation {
const repository = options.repository ?? onlineRepository;
const pollMs = options.pollMs ?? 350;
let active = true;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
settle = resolve;
const poll = async () => {
if (!active) return;
try {
const rematch = await repository.requestHockeyPvpRematch(options.matchId, options.generation);
if (!active) {
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
return;
}
if (rematch.match) {
active = false;
settle(onlineHockeyPvpMatchConfig(rematch.match));
return;
}
} catch {
options.onUnavailable?.();
}
if (active) pollTimer = setTimeout(poll, pollMs);
};
void poll();
});
return {
result,
cancel: () => {
if (!active) return;
active = false;
if (pollTimer !== null) clearTimeout(pollTimer);
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
settle(null);
},
};
}
+172 -9
View File
@@ -1,7 +1,8 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import type { HunterSave, SaveSlotId } from "./types"; import type { HunterSave, SaveSlotId } from "./types";
import type { BossId } from "../game/types"; import type { BossId, HealerClassId, RunBuffId } from "../game/types";
import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp"; import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp";
import type { RoguelikePvpCurseId } from "../game/roguelikePvp";
export interface OnlineAccount { export interface OnlineAccount {
id: number; id: number;
@@ -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);
});
});
+298
View File
@@ -0,0 +1,298 @@
import type { HealerClassId } from "../game/types";
import {
onlineRepository,
type OnlineRepository,
type RoguelikePvpOnlineMatch,
type RoguelikePvpOnlineRole,
type RoguelikePvpQueueResult,
} from "./onlineRepository";
import type { SaveSlotId } from "./types";
export const ROGUELIKE_PVP_COUNTDOWN_MS = 5_000;
export const ROGUELIKE_PVP_QUEUE_TIMEOUT_MS = 5_000;
const CPU_NAMES = ["CPU Aster", "CPU Briar", "CPU Cinder", "CPU Rowan", "CPU Willow"] as const;
export type RoguelikePvpRole = RoguelikePvpOnlineRole | "cpu";
export interface RoguelikePvpMatchConfig {
matchId: string | null;
mode: "roguelike-pvp";
seed: number;
generation: number;
countdownEndsAtMs: number;
opponentName: string;
opponentHealerClassId: HealerClassId;
role: RoguelikePvpRole;
}
type QueueRepository = Pick<OnlineRepository,
"joinRoguelikePvpQueue" | "pollRoguelikePvpQueue"> & {
cancelRoguelikePvpQueue: (
ticketId: string,
) => Promise<RoguelikePvpQueueResult | void>;
};
type RematchRepository = Pick<OnlineRepository,
"requestRoguelikePvpRematch" | "cancelRoguelikePvpRematch">;
export interface RoguelikePvpMatchOperation {
result: Promise<RoguelikePvpMatchConfig | null>;
cancel: () => void;
}
export function randomRoguelikePvpCpuName(random: () => number = Math.random): string {
const index = Math.min(CPU_NAMES.length - 1, Math.max(0, Math.floor(random() * CPU_NAMES.length)));
return CPU_NAMES[index];
}
export function onlineRoguelikePvpMatchConfig(match: RoguelikePvpOnlineMatch): RoguelikePvpMatchConfig {
return {
matchId: match.id,
mode: match.mode,
seed: match.seed,
generation: match.generation,
countdownEndsAtMs: match.countdownEndsAtMs,
opponentName: match.opponentName,
opponentHealerClassId: match.opponentHealerClassId,
role: match.role,
};
}
export function startRoguelikePvpMatchmaking(options: {
slotId: SaveSlotId;
hunterName: string;
healerClassId: HealerClassId;
online: boolean;
repository?: QueueRepository;
timeoutMs?: number;
pollMs?: number;
onElapsed?: (elapsedMs: number) => void;
onOnlineUnavailable?: () => void;
random?: () => number;
cpuName?: () => string;
cpuHealerClassId?: HealerClassId;
}): RoguelikePvpMatchOperation {
const repository = options.repository ?? onlineRepository;
const timeoutMs = options.timeoutMs ?? ROGUELIKE_PVP_QUEUE_TIMEOUT_MS;
const pollMs = options.pollMs ?? 350;
const random = options.random ?? Math.random;
const cpuName = options.cpuName ?? (() => randomRoguelikePvpCpuName(random));
const startedAt = Date.now();
let active = true;
let ticketId: string | null = null;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
let clockTimer: ReturnType<typeof setInterval> | null = null;
let joinTask: Promise<void> | null = null;
let activePoll: Promise<void> | null = null;
let fallbackStarted = false;
let settle: (match: RoguelikePvpMatchConfig | null) => void = () => undefined;
const clearTimers = () => {
if (pollTimer !== null) clearTimeout(pollTimer);
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
if (clockTimer !== null) clearInterval(clockTimer);
pollTimer = null;
fallbackTimer = null;
clockTimer = null;
};
const finish = (match: RoguelikePvpMatchConfig | null) => {
if (!active) return;
active = false;
clearTimers();
settle(match);
};
const cancelTicket = () => {
const currentTicketId = ticketId;
ticketId = null;
if (currentTicketId) void repository.cancelRoguelikePvpQueue(currentTicketId).catch(() => undefined);
};
const finishCpuMatch = () => {
if (!active) return;
finish({
matchId: null,
mode: "roguelike-pvp",
seed: Math.max(1, Math.floor(random() * 0xffffffff)),
generation: 1,
countdownEndsAtMs: Date.now() + ROGUELIKE_PVP_COUNTDOWN_MS,
opponentName: cpuName(),
opponentHealerClassId: options.cpuHealerClassId ?? options.healerClassId,
role: "cpu",
});
};
const finishOnlineMatch = (queued: RoguelikePvpQueueResult | void): boolean => {
if (!queued?.match) return false;
ticketId = null;
finish(onlineRoguelikePvpMatchConfig(queued.match));
return true;
};
const fallbackToCpu = async () => {
if (!active || fallbackStarted) return;
fallbackStarted = true;
if (pollTimer !== null) clearTimeout(pollTimer);
pollTimer = null;
if (!options.online) {
finishCpuMatch();
return;
}
await joinTask;
if (!active) return;
await activePoll;
if (!active) return;
const currentTicketId = ticketId;
if (!currentTicketId) {
finishCpuMatch();
return;
}
try {
const finalPoll = await repository.pollRoguelikePvpQueue(currentTicketId);
if (!active || finishOnlineMatch(finalPoll)) return;
} catch {
options.onOnlineUnavailable?.();
}
if (!active) return;
try {
const cancellation = await repository.cancelRoguelikePvpQueue(currentTicketId);
if (!active || finishOnlineMatch(cancellation)) return;
if (ticketId === currentTicketId) ticketId = null;
finishCpuMatch();
return;
} catch {
options.onOnlineUnavailable?.();
}
if (!active) return;
// A matched ticket cannot be cancelled. Re-read it before choosing CPU so a
// server-side match created at the timeout boundary is never abandoned.
try {
const recovered = await repository.pollRoguelikePvpQueue(currentTicketId);
if (!active || finishOnlineMatch(recovered)) return;
} catch {
options.onOnlineUnavailable?.();
}
if (ticketId === currentTicketId) ticketId = null;
finishCpuMatch();
};
const result = new Promise<RoguelikePvpMatchConfig | null>((resolve) => {
settle = resolve;
fallbackTimer = setTimeout(() => { void fallbackToCpu(); }, timeoutMs);
if (options.onElapsed) {
options.onElapsed(0);
clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100);
}
if (!options.online) return;
joinTask = (async () => {
try {
const joined = await repository.joinRoguelikePvpQueue(
options.slotId,
options.hunterName,
options.healerClassId,
);
if (!active) {
if (!joined.match) void repository.cancelRoguelikePvpQueue(joined.ticketId).catch(() => undefined);
return;
}
ticketId = joined.ticketId;
if (joined.match) {
finish(onlineRoguelikePvpMatchConfig(joined.match));
return;
}
const poll = async () => {
if (!active || fallbackStarted || !ticketId) return;
try {
const queued = await repository.pollRoguelikePvpQueue(ticketId);
if (!active) return;
if (queued.match) {
finish(onlineRoguelikePvpMatchConfig(queued.match));
return;
}
} catch {
options.onOnlineUnavailable?.();
}
if (active && !fallbackStarted) {
pollTimer = setTimeout(() => {
const pending = poll();
activePoll = pending;
void pending.finally(() => {
if (activePoll === pending) activePoll = null;
});
}, pollMs);
}
};
if (!fallbackStarted) {
pollTimer = setTimeout(() => {
const pending = poll();
activePoll = pending;
void pending.finally(() => {
if (activePoll === pending) activePoll = null;
});
}, pollMs);
}
} catch {
options.onOnlineUnavailable?.();
}
})();
});
return {
result,
cancel: () => {
if (!active) return;
cancelTicket();
finish(null);
},
};
}
export function startRoguelikePvpRematch(options: {
matchId: string;
generation: number;
repository?: RematchRepository;
pollMs?: number;
onUnavailable?: () => void;
}): RoguelikePvpMatchOperation {
const repository = options.repository ?? onlineRepository;
const pollMs = options.pollMs ?? 350;
let active = true;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let settle: (match: RoguelikePvpMatchConfig | null) => void = () => undefined;
const result = new Promise<RoguelikePvpMatchConfig | null>((resolve) => {
settle = resolve;
const poll = async () => {
if (!active) return;
try {
const rematch = await repository.requestRoguelikePvpRematch(options.matchId, options.generation);
if (!active) {
void repository.cancelRoguelikePvpRematch(options.matchId, options.generation).catch(() => undefined);
return;
}
if (rematch.match) {
active = false;
if (pollTimer !== null) clearTimeout(pollTimer);
settle(onlineRoguelikePvpMatchConfig(rematch.match));
return;
}
} catch {
options.onUnavailable?.();
}
if (active) pollTimer = setTimeout(poll, pollMs);
};
void poll();
});
return {
result,
cancel: () => {
if (!active) return;
active = false;
if (pollTimer !== null) clearTimeout(pollTimer);
void repository.cancelRoguelikePvpRematch(options.matchId, options.generation).catch(() => undefined);
settle(null);
},
};
}
+10 -7
View File
@@ -55,7 +55,7 @@ describe("SaveRepository", () => {
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
expect(repository.listLocal()[0].local?.updatedAt).toBe(now); expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
expect(repository.listLocal()[0].local).toMatchObject({ expect(repository.listLocal()[0].local).toMatchObject({
schemaVersion: 6, schemaVersion: 7,
stats: { stats: {
highestAetherAssaultScore: 0, highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0, highestAetherAssaultWaveAtBest: 0,
@@ -112,7 +112,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!; const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6); expect(migrated.schemaVersion).toBe(7);
expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z"); expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z");
expect(migrated.healers.priest.level).toBe(37); expect(migrated.healers.priest.level).toBe(37);
for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) { for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) {
@@ -186,7 +186,7 @@ describe("SaveRepository", () => {
expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand); expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand);
}); });
it("resets every legacy save into fresh v6 progression while preserving identity and timestamp", () => { it("resets every legacy save into fresh v7 progression while preserving identity and timestamp", () => {
const storage = memoryStorage(); const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Legacy"); const created = repository.create(1, "Legacy");
@@ -208,7 +208,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!; const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6); expect(migrated.schemaVersion).toBe(7);
expect(migrated.hunterName).toBe("Legacy"); expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest"); expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0); expect(migrated.playSeconds).toBe(0);
@@ -226,6 +226,9 @@ describe("SaveRepository", () => {
hockeyHealingPvpWins: 0, hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0, hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0, hockeyHealingPvpBossKills: 0,
roguelikePvpWins: 0,
roguelikePvpLosses: 0,
highestRoguelikePvpRound: 0,
highestBlockbreakerBricks: 0, highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0, longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0, highestBlockbreakerScore: 0,
@@ -236,7 +239,7 @@ describe("SaveRepository", () => {
expect(migrated.materials).toEqual([]); expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(6); expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(7);
}); });
it("preserves valid v5 progression and group-drop inventory", () => { it("preserves valid v5 progression and group-drop inventory", () => {
@@ -260,7 +263,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!; const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6); expect(migrated.schemaVersion).toBe(7);
expect(migrated.healers.priest.level).toBe(8); expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14); expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
@@ -288,7 +291,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!; const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6); expect(migrated.schemaVersion).toBe(7);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
+5 -2
View File
@@ -122,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const candidate = value as LegacyHunterSave; const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null; if (!candidate.slotId || !candidate.hunterName) return null;
if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6) { if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6 && candidate.schemaVersion !== 7) {
try { try {
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName); return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
} catch { } catch {
@@ -133,7 +133,7 @@ function normalizeSave(value: unknown): HunterSave | null {
const bossKills = normalizeBossKills(candidate.stats?.bossKills); const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest"; const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return { return {
schemaVersion: 6, schemaVersion: 7,
slotId: candidate.slotId, slotId: candidate.slotId,
hunterName: candidate.hunterName, hunterName: candidate.hunterName,
activeClassId, activeClassId,
@@ -158,6 +158,9 @@ function normalizeSave(value: unknown): HunterSave | null {
hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)), hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)),
hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)), hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)),
hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)), hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)),
roguelikePvpWins: Math.max(0, Math.floor(candidate.stats?.roguelikePvpWins ?? 0)),
roguelikePvpLosses: Math.max(0, Math.floor(candidate.stats?.roguelikePvpLosses ?? 0)),
highestRoguelikePvpRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikePvpRound ?? 0)),
highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)), highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)),
longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0), longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0),
highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)), highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)),
+29 -1
View File
@@ -31,7 +31,7 @@ import {
infusionsForOwner, infusionsForOwner,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats"; import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch, roguelikePvpRecordAfterMatch } from "../game/progression/hunterStats";
import { import {
cloneCharacterAppearance, cloneCharacterAppearance,
type CharacterAppearanceV1, type CharacterAppearanceV1,
@@ -187,6 +187,7 @@ export interface FrontendState {
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void; recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
recordHockeyPvpResult: (won: boolean) => void; recordHockeyPvpResult: (won: boolean) => void;
recordHockeyPvpBossKill: () => void; recordHockeyPvpBossKill: () => void;
recordRoguelikePvpResult: (won: boolean, round: number) => void;
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void; recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void; recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
clearRecentRewards: () => void; clearRecentRewards: () => void;
@@ -668,6 +669,31 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
markSaveSyncPending(activeSlotId); markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) })); set((state) => ({ slots: refreshLocalSlots(state.slots) }));
}, },
recordRoguelikePvpResult: (won, round) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => {
const record = roguelikePvpRecordAfterMatch(
save.stats.roguelikePvpWins,
save.stats.roguelikePvpLosses,
save.stats.highestRoguelikePvpRound,
won,
round,
);
return {
...save,
stats: {
...save.stats,
roguelikePvpWins: record.wins,
roguelikePvpLosses: record.losses,
highestRoguelikePvpRound: record.highestRound,
},
};
});
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordBlockbreakerDefeat: (bricks, durationSeconds, score) => { recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
const { activeSlotId } = get(); const { activeSlotId } = get();
if (!activeSlotId) return; if (!activeSlotId) return;
@@ -771,6 +797,7 @@ export type FrontendSnapshot = Omit<FrontendState,
| "recordBossVictory" | "recordBossVictory"
| "recordRoguelikeDefeat" | "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat" | "recordRogueTrialsEndlessDefeat"
| "recordRoguelikePvpResult"
| "recordHockeyHealingDefeat" | "recordHockeyHealingDefeat"
| "recordHockeyPvpResult" | "recordHockeyPvpResult"
| "recordHockeyPvpBossKill" | "recordHockeyPvpBossKill"
@@ -828,6 +855,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
recordBossVictory: _recordBossVictory, recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat, recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat, recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
recordRoguelikePvpResult: _recordRoguelikePvpResult,
recordHockeyHealingDefeat: _recordHockeyHealingDefeat, recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
recordHockeyPvpResult: _recordHockeyPvpResult, recordHockeyPvpResult: _recordHockeyPvpResult,
recordHockeyPvpBossKill: _recordHockeyPvpBossKill, recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
+4 -1
View File
@@ -51,6 +51,9 @@ export interface HunterStats {
hockeyHealingPvpWins: number; hockeyHealingPvpWins: number;
hockeyHealingPvpLosses: number; hockeyHealingPvpLosses: number;
hockeyHealingPvpBossKills: number; hockeyHealingPvpBossKills: number;
roguelikePvpWins: number;
roguelikePvpLosses: number;
highestRoguelikePvpRound: number;
highestBlockbreakerBricks: number; highestBlockbreakerBricks: number;
longestBlockbreakerSeconds: number; longestBlockbreakerSeconds: number;
highestBlockbreakerScore: number; highestBlockbreakerScore: number;
@@ -66,7 +69,7 @@ export interface HealerProgress {
} }
export interface HunterSave { export interface HunterSave {
schemaVersion: 6; schemaVersion: 7;
slotId: SaveSlotId; slotId: SaveSlotId;
hunterName: string; hunterName: string;
activeClassId: HealerClassId; activeClassId: HealerClassId;
+43 -2
View File
@@ -4,19 +4,36 @@ import {
HOCKEY_PVP_GOAL_Z, HOCKEY_PVP_GOAL_Z,
advanceHockeyPvpPuck, advanceHockeyPvpPuck,
createHockeyPvpState, createHockeyPvpState,
cycleHockeyPvpPostMatchSelection,
hockeyPvpBossAt, hockeyPvpBossAt,
hockeyPvpCountdownSeconds,
hockeyPvpDampeningPercent, hockeyPvpDampeningPercent,
hockeyPvpHealingEffectiveness, hockeyPvpHealingEffectiveness,
hockeyPvpPuckSpeed, hockeyPvpPuckSpeed,
mirrorHockeyPvpPuck, mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
} from "./hockeyHealingPvp"; } from "./hockeyHealingPvp";
describe("Healing Hockey PVP", () => { describe("Healing Hockey PVP", () => {
it("starts rallies at the faster default puck speed", () => { it("starts rallies at the faster default puck speed", () => {
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" }); const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
expect(hockeyPvpPuckSpeed(0)).toBe(7.2); expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2); 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", () => {
@@ -76,4 +93,28 @@ describe("Healing Hockey PVP", () => {
lastGoalSide: "local", lastGoalSide: "local",
}); });
}); });
it("predicts guest movement between snapshots and soft-corrects small drift", () => {
const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
guest.puckVelocity = [3, 6];
const predicted = advanceHockeyPvpPuck(guest, {
delta: 0.1,
localPlayerPosition: [0, 8.5],
localAimDirection: [0, -1],
opponentPlayerPosition: [0, 8.5],
opponentAimDirection: [0, -1],
});
expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
expect(predicted.localGoalsConceded).toBe(0);
const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
authoritative.goalSequence += 1;
authoritative.puckPosition = [0, 0];
expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
});
}); });
+93 -3
View File
@@ -4,12 +4,16 @@ import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types
export type HockeyPvpRole = "cpu" | "host" | "guest"; export type 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;
@@ -59,13 +68,18 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
export const HOCKEY_PVP_PUCK_RADIUS = 0.42; export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05; 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_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.2; const STARTING_SPEED = 7.8;
const MAX_SPEED = 11.5; const MAX_SPEED = 12.2;
const MAX_SUBSTEPS = 10; const MAX_SUBSTEPS = 10;
const MAX_SUBSTEP_DISTANCE = 0.32; const MAX_SUBSTEP_DISTANCE = 0.32;
const GUEST_RECONCILIATION_BLEND = 0.35;
const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const; const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number { export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
@@ -95,6 +109,21 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16); 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;
@@ -107,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,
@@ -161,6 +195,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns); state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
} }
function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
puckVelocity: [...source.puckVelocity],
};
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
const subDelta = delta / substeps;
const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
for (let index = 0; index < substeps; index += 1) {
const end: WorldPosition = [
state.puckPosition[0] + state.puckVelocity[0] * subDelta,
state.puckPosition[1] + state.puckVelocity[1] * subDelta,
];
if (end[0] < minX || end[0] > maxX) {
end[0] = Math.max(minX, Math.min(maxX, end[0]));
state.puckVelocity[0] *= -1;
}
if (end[1] >= HOCKEY_PVP_GOAL_Z) {
end[1] = HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
} else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
end[1] = -HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
}
state.puckPosition = end;
}
return state;
}
export function reconcileHockeyPvpPuck(
predicted: HockeyPvpPuckState,
authoritative: HockeyPvpPuckState,
): HockeyPvpPuckState {
const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
|| Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
return {
...authoritative,
puckPosition: shouldSnap
? [...authoritative.puckPosition]
: [
predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
],
puckVelocity: [...authoritative.puckVelocity],
};
}
export function advanceHockeyPvpPuck( export function advanceHockeyPvpPuck(
source: HockeyPvpState, source: HockeyPvpState,
step: { step: {
@@ -171,7 +258,10 @@ export function advanceHockeyPvpPuck(
opponentAimDirection: WorldPosition; opponentAimDirection: WorldPosition;
}, },
): HockeyPvpState { ): HockeyPvpState {
if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source; if (source.status !== "live" || step.delta <= 0) return source;
// Guest predicts travel only. Host snapshots remain authoritative for contacts,
// goals, damage, and rally counters.
if (source.role === "guest") return predictGuestPuck(source, step.delta);
const state: HockeyPvpState = { const state: HockeyPvpState = {
...source, ...source,
puckPosition: [...source.puckPosition], puckPosition: [...source.puckPosition],
+85 -4
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createClassInventory } from "./healers"; import { createClassInventory } from "./healers";
import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp"; import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
import { upcomingEncounterMechanic, useGameStore } from "./store"; import { upcomingEncounterMechanic, useGameStore } from "./store";
import { freshParty } from "./data"; import { freshParty } from "./data";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear"; import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
@@ -23,6 +23,8 @@ function createMaxedGear() {
} }
describe("Healing Hockey PVP encounter integration", () => { describe("Healing Hockey PVP encounter integration", () => {
afterEach(() => vi.restoreAllMocks());
beforeEach(() => { beforeEach(() => {
useGameStore.getState().configureHealer( useGameStore.getState().configureHealer(
"priest", "priest",
@@ -42,6 +44,28 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id); expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
expect(state.hockeyPvp.opponentName).toBe("CPU Aster"); expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
});
it("ignores start input until shared five-second countdown ends", () => {
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, countdownEndsAtMs: 10_000 },
);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("briefing");
now.mockReturnValue(10_000);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("combat");
}); });
it("normalizes both parties to default base gear without changing saved upgrades", () => { it("normalizes both parties to default base gear without changing saved upgrades", () => {
@@ -170,13 +194,16 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id); expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
}); });
it("wins when opponent party falls", () => { it("wins when opponent companions fall while rival healer remains alive", () => {
useGameStore.getState().startEncounter(); useGameStore.getState().startEncounter();
useGameStore.getState().setActiveTab("map"); useGameStore.getState().setActiveTab("map");
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
hockeyPvpOpponent: { hockeyPvpOpponent: {
...state.hockeyPvpOpponent, ...state.hockeyPvpOpponent,
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })), party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}, },
})); }));
useGameStore.getState().tick(0.01); useGameStore.getState().tick(0.01);
@@ -185,6 +212,60 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().activeTab).toBe("combat"); expect(useGameStore.getState().activeTab).toBe("combat");
}); });
it("loses when local companions fall while healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
});
it("wins an online match when remote companions fall while rival healer remains alive", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, matchId: "online-match", role: "guest" },
);
useGameStore.getState().startEncounter();
const state = useGameStore.getState();
const snapshot: HockeyPvpRemoteSnapshot = {
sequence: 1,
time: state.time,
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
boss: {
id: state.hockeyPvpOpponent.boss.id,
name: state.hockeyPvpOpponent.boss.name,
hp: state.hockeyPvpOpponent.boss.hp,
maxHp: state.hockeyPvpOpponent.boss.maxHp,
},
bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
bossMode: state.hockeyPvpOpponent.bossMotion.mode,
bossKills: 0,
playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
aimDirection: [...state.hockeyPvp.opponentAimDirection],
};
useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().hockeyPvp.status).toBe("won");
});
it("keeps HUD mechanic data defined during instant boss replacement", () => { it("keeps HUD mechanic data defined during instant boss replacement", () => {
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } })); useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({ expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
+5
View File
@@ -0,0 +1,5 @@
/** Shared healer resource tuning for every encounter and run mode. */
export const BASE_MANA_POOL = 150;
/** Global in-combat regeneration, expressed as mana restored per second. */
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers";
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
import type { AbilitySlotId, HealerClassId } from "./types";
const LONG_DUAL_BOSS_SECONDS = 100;
const MINIMUM_ENDING_RESERVE = 20;
const MAXIMUM_ENDING_RESERVE = 50;
/**
* High-pressure reference rotations for the longest intended dual-boss fight.
* Counts include spot healing, maintenance effects, four cleanses, repeated
* group recovery, and both available one-minute cooldown casts.
*/
const REFERENCE_ROTATIONS = {
priest: { ability1: 8, ability2: 8, ability3: 5, ability4: 4, ability5: 5, ability6: 2 },
druid: { ability1: 7, ability2: 9, ability3: 9, ability4: 4, ability5: 5, ability6: 2 },
shaman: { ability1: 7, ability2: 9, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
paladin: { ability1: 10, ability2: 20, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
chronomancer: { ability1: 7, ability2: 8, ability3: 8, ability4: 4, ability5: 5, ability6: 2 },
} as const satisfies Record<HealerClassId, Record<AbilitySlotId, number>>;
function rotationManaCost(classId: HealerClassId): number {
const abilities = HEALER_CLASSES[classId].abilities;
return Object.entries(REFERENCE_ROTATIONS[classId]).reduce(
(total, [slotId, casts]) => total + abilities[slotId as AbilitySlotId].mana * casts,
0,
);
}
describe("dual-boss healer mana balance", () => {
const availableMana = BASE_MANA_POOL + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
it.each(HEALER_CLASS_ORDER)("funds %s's high-pressure 100-second rotation with a useful reserve", (classId) => {
const rotation = REFERENCE_ROTATIONS[classId];
const abilities = HEALER_CLASSES[classId].abilities;
const totalCasts = Object.values(rotation).reduce((total, casts) => total + casts, 0);
const remainingMana = availableMana - rotationManaCost(classId);
expect(totalCasts).toBeLessThanOrEqual(LONG_DUAL_BOSS_SECONDS / 0.5);
expect(Object.values(rotation).every((casts) => casts > 0)).toBe(true);
for (const [slotId, casts] of Object.entries(rotation)) {
const cooldown = abilities[slotId as AbilitySlotId].cooldown;
if (cooldown <= 0) continue;
const maximumCasts = Math.floor((LONG_DUAL_BOSS_SECONDS - Number.EPSILON) / cooldown) + 1;
expect(casts).toBeLessThanOrEqual(maximumCasts);
}
expect(remainingMana).toBeGreaterThanOrEqual(MINIMUM_ENDING_RESERVE);
expect(remainingMana).toBeLessThanOrEqual(MAXIMUM_ENDING_RESERVE);
});
it("keeps reference rotation costs close enough that no class gets a dominant mana advantage", () => {
const costs = HEALER_CLASS_ORDER.map(rotationManaCost);
expect(Math.max(...costs) - Math.min(...costs)).toBeLessThanOrEqual(25);
});
it("detects why the previous 100 mana pool was insufficient", () => {
const previousBudget = 100 + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
for (const classId of HEALER_CLASS_ORDER) {
expect(rotationManaCost(classId)).toBeGreaterThan(previousBudget);
}
});
});
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "./hunterStats"; import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch, roguelikePvpRecordAfterMatch } from "./hunterStats";
describe("roguelike hunter records", () => { describe("roguelike hunter records", () => {
it("records the reached defeat round without lowering a previous best", () => { it("records the reached defeat round without lowering a previous best", () => {
@@ -46,6 +46,13 @@ describe("Healing Hockey PVP records", () => {
}); });
}); });
describe("Roguelike PVP records", () => {
it("increments the result and preserves the highest reached round", () => {
expect(roguelikePvpRecordAfterMatch(2, 3, 7, true, 11)).toEqual({ wins: 3, losses: 3, highestRound: 11 });
expect(roguelikePvpRecordAfterMatch(3, 3, 11, false, 4)).toEqual({ wins: 3, losses: 4, highestRound: 11 });
});
});
describe("Blockbreaker records", () => { describe("Blockbreaker records", () => {
it("keeps bricks, duration, and score as independent lifetime highs", () => { it("keeps bricks, duration, and score as independent lifetime highs", () => {
expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({ expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({
+17
View File
@@ -40,6 +40,23 @@ export function hockeyPvpRecordAfterMatch(currentWins: number, currentLosses: nu
return won ? { wins: wins + 1, losses } : { wins, losses: losses + 1 }; return won ? { wins: wins + 1, losses } : { wins, losses: losses + 1 };
} }
export function roguelikePvpRecordAfterMatch(
currentWins: number,
currentLosses: number,
currentHighestRound: number,
won: boolean,
reachedRound: number,
) {
const record = hockeyPvpRecordAfterMatch(currentWins, currentLosses, won);
return {
...record,
highestRound: Math.max(
Math.max(0, Math.floor(Number(currentHighestRound) || 0)),
Math.max(1, Math.floor(Number(reachedRound) || 1)),
),
};
}
export interface BlockbreakerRecords { export interface BlockbreakerRecords {
bricks: number; bricks: number;
durationSeconds: number; durationSeconds: number;
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { compileRunModifiers } from "./roguelike";
import {
ROGUELIKE_PVP_CURSE_MAX_RANK,
ROGUELIKE_PVP_CURSE_ORDER,
ROGUELIKE_PVP_CURSES,
compileRoguelikePvpCurses,
createSeededRandom,
formatRoguelikePvpCurseEffect,
increaseRoguelikePvpCurseRank,
isCompatibleRoguelikePvpBossRound,
roguelikePvpAbilityCooldown,
roguelikePvpAbilityManaCost,
roguelikePvpBossCountForRound,
roguelikePvpBossesForRound,
roguelikePvpCurseRank,
selectCpuRoguelikePvpDraft,
selectRoguelikePvpCurseDraft,
type RoguelikePvpCurseRanks,
} from "./roguelikePvp";
describe("Roguelike PVP domain", () => {
it("defines two rank-three curses for every ability slot", () => {
expect(ROGUELIKE_PVP_CURSE_ORDER).toHaveLength(12);
expect(new Set(ROGUELIKE_PVP_CURSE_ORDER)).toHaveLength(12);
expect(ROGUELIKE_PVP_CURSE_ORDER.every((id) => ROGUELIKE_PVP_CURSES[id].maxRank === 3)).toBe(true);
});
it("clamps persisted curse ranks and never increments beyond cap", () => {
const overRanked: RoguelikePvpCurseRanks = {
"ability1-mana-cost": 99,
"ability2-cooldown": -4,
"ability3-mana-cost": Number.NaN,
};
expect(roguelikePvpCurseRank(overRanked, "ability1-mana-cost")).toBe(ROGUELIKE_PVP_CURSE_MAX_RANK);
expect(roguelikePvpCurseRank(overRanked, "ability2-cooldown")).toBe(0);
expect(roguelikePvpCurseRank(overRanked, "ability3-mana-cost")).toBe(0);
expect(increaseRoguelikePvpCurseRank(overRanked, "ability1-mana-cost")["ability1-mana-cost"]).toBe(99);
let ranks: RoguelikePvpCurseRanks = {};
for (let index = 0; index < 5; index += 1) {
ranks = increaseRoguelikePvpCurseRank(ranks, "ability4-cooldown");
}
expect(ranks["ability4-cooldown"]).toBe(3);
expect(formatRoguelikePvpCurseEffect("ability4-cooldown", 3, "Purify")).toBe("+95% Purify cooldown");
});
it("offers only non-maxed curses and supports deterministic injected randomness", () => {
const maxed = Object.fromEntries(
ROGUELIKE_PVP_CURSE_ORDER.map((id) => [id, ROGUELIKE_PVP_CURSE_MAX_RANK]),
) as RoguelikePvpCurseRanks;
maxed["ability6-cooldown"] = 2;
expect(selectRoguelikePvpCurseDraft(maxed, () => 0)).toEqual(["ability6-cooldown"]);
maxed["ability6-cooldown"] = 3;
expect(selectRoguelikePvpCurseDraft(maxed, () => 0)).toEqual([]);
const first = selectRoguelikePvpCurseDraft({}, createSeededRandom(8128));
const second = selectRoguelikePvpCurseDraft({}, createSeededRandom(8128));
expect(first).toEqual(second);
expect(first).toHaveLength(3);
expect(new Set(first)).toHaveLength(3);
});
it("composes positive run buffs with per-slot cost and cooldown curses", () => {
const runModifiers = compileRunModifiers({
"mend-efficiency": 1,
"radiance-cooldown": 1,
});
const curses = compileRoguelikePvpCurses({
"ability1-mana-cost": 2,
"ability5-cooldown": 1,
"ability6-mana-cost": 3,
});
expect(roguelikePvpAbilityManaCost("ability1", 100, runModifiers, curses)).toBe(118);
expect(roguelikePvpAbilityManaCost("ability2", 30, runModifiers, curses)).toBe(30);
expect(roguelikePvpAbilityManaCost("ability6", 20, runModifiers, curses)).toBe(40);
expect(roguelikePvpAbilityManaCost("ability6", 0, runModifiers, curses)).toBe(0);
expect(roguelikePvpAbilityCooldown("ability5", 20, runModifiers, curses)).toBeCloseTo(20);
expect(roguelikePvpAbilityCooldown("ability2", 10, runModifiers, curses)).toBe(10);
});
it("derives mirrored compatible boss rounds from the shared seed", () => {
for (let round = 1; round <= 20; round += 1) {
const host = roguelikePvpBossesForRound(4242, round);
const guest = roguelikePvpBossesForRound(4242, round);
expect(host).toEqual(guest);
expect(host).toHaveLength(roguelikePvpBossCountForRound(round));
expect(new Set(host)).toHaveLength(host.length);
expect(isCompatibleRoguelikePvpBossRound(host)).toBe(true);
}
});
it("uses a boss trio every fifth round and pairs on all other rounds", () => {
expect(Array.from({ length: 12 }, (_, index) => roguelikePvpBossCountForRound(index + 1))).toEqual([
2, 2, 2, 2, 3,
2, 2, 2, 2, 3,
2, 2,
]);
});
it("selects CPU buff and curse submissions deterministically", () => {
const buffs = ["mend-echo", "renew-duration", "barrier-regen"] as const;
const curses = ["ability1-mana-cost", "ability3-cooldown", "ability6-cooldown"] as const;
const first = selectCpuRoguelikePvpDraft(99, 7, buffs, curses);
const second = selectCpuRoguelikePvpDraft(99, 7, buffs, curses);
expect(first).toEqual(second);
expect(first.round).toBe(7);
expect(buffs).toContain(first.buffId);
expect(curses).toContain(first.curseId);
expect(selectCpuRoguelikePvpDraft(99, 7, [], [])).toEqual({ round: 7, buffId: null, curseId: null });
});
});
+322
View File
@@ -0,0 +1,322 @@
import { canAddBossToEncounter } from "./bossSelection";
import {
runAbilityCooldown,
runAbilityManaCost,
selectRunBuffDraft,
selectUnseenBosses,
type CompiledRunModifiers,
} from "./roguelike";
import type {
AbilitySlotId,
BossId,
HealerClassId,
RunBuffId,
RunBuffRanks,
} from "./types";
export const ROGUELIKE_PVP_ABILITY_SLOTS = [
"ability1",
"ability2",
"ability3",
"ability4",
"ability5",
"ability6",
] as const satisfies readonly AbilitySlotId[];
export const ROGUELIKE_PVP_CURSE_MAX_RANK = 3;
export const ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK = 1.25;
export const ROGUELIKE_PVP_TRIO_CADENCE = 5;
export type RoguelikePvpCurseEffectKind = "mana-cost" | "cooldown";
export type RoguelikePvpCurseId = `${AbilitySlotId}-${RoguelikePvpCurseEffectKind}`;
export type RoguelikePvpCurseRanks = Partial<Record<RoguelikePvpCurseId, number>>;
export type RoguelikePvpRole = "cpu" | "host" | "guest";
export type RoguelikePvpStatus = "inactive" | "countdown" | "combat" | "drafting" | "won" | "lost";
export interface RoguelikePvpCurseDefinition {
id: RoguelikePvpCurseId;
abilitySlotId: AbilitySlotId;
effectKind: RoguelikePvpCurseEffectKind;
name: string;
icon: string;
summary: string;
detail: string;
accent: string;
maxRank: typeof ROGUELIKE_PVP_CURSE_MAX_RANK;
}
export interface CompiledRoguelikePvpCurses {
manaCostMultipliers: Record<AbilitySlotId, number>;
cooldownMultipliers: Record<AbilitySlotId, number>;
}
export interface RoguelikePvpMatchConfig {
matchId: string | null;
seed: number;
generation?: number;
opponentName: string;
opponentHealerClassId?: HealerClassId;
role: RoguelikePvpRole;
countdownEndsAtMs?: number;
}
export interface RoguelikePvpBossProgress {
id: BossId;
hp: number;
maxHp: number;
}
export interface RoguelikePvpProgress {
round: number;
bossesDefeated: number;
livingPartyMembers: number;
partyHpPercent?: number;
bosses: readonly RoguelikePvpBossProgress[];
}
export interface RoguelikePvpDraftChoices {
round: number;
buffChoices: readonly RunBuffId[];
curseChoices: readonly RoguelikePvpCurseId[];
}
/** The curse in a submission always targets the opposing party. */
export interface RoguelikePvpDraftSubmission {
round: number;
buffId: RunBuffId | null;
curseId: RoguelikePvpCurseId | null;
}
export interface RoguelikePvpDraftReveal {
round: number;
local: RoguelikePvpDraftSubmission;
opponent: RoguelikePvpDraftSubmission;
}
export interface RoguelikePvpRemoteSnapshot {
sequence: number;
time: number;
status: RoguelikePvpStatus;
progress: RoguelikePvpProgress;
buffRanks: RunBuffRanks;
curseRanks: RoguelikePvpCurseRanks;
draftSubmission: RoguelikePvpDraftSubmission | null;
}
const DEFAULT_ABILITY_NAMES: Record<AbilitySlotId, string> = {
ability1: "Ability 1",
ability2: "Ability 2",
ability3: "Ability 3",
ability4: "Ability 4",
ability5: "Ability 5",
ability6: "Ability 6",
};
const curse = (
abilitySlotId: AbilitySlotId,
effectKind: RoguelikePvpCurseEffectKind,
): RoguelikePvpCurseDefinition => {
const id = `${abilitySlotId}-${effectKind}` as RoguelikePvpCurseId;
const abilityName = DEFAULT_ABILITY_NAMES[abilitySlotId];
const manaCost = effectKind === "mana-cost";
return {
id,
abilitySlotId,
effectKind,
name: `${abilityName} ${manaCost ? "Burden" : "Delay"}`,
icon: manaCost ? "△" : "◷",
summary: `+25% ${manaCost ? "mana cost" : "cooldown"} per rank`,
detail: `${abilityName} ${manaCost ? "mana cost" : "cooldown"} is multiplied by 1.25 per rank.`,
accent: manaCost ? "#ff8b70" : "#d88cff",
maxRank: ROGUELIKE_PVP_CURSE_MAX_RANK,
};
};
const ROGUELIKE_PVP_CURSE_DEFINITIONS = ROGUELIKE_PVP_ABILITY_SLOTS.flatMap((abilitySlotId) => [
curse(abilitySlotId, "mana-cost"),
curse(abilitySlotId, "cooldown"),
]);
export const ROGUELIKE_PVP_CURSE_ORDER = ROGUELIKE_PVP_CURSE_DEFINITIONS.map(({ id }) => id);
export const ROGUELIKE_PVP_CURSES = Object.fromEntries(
ROGUELIKE_PVP_CURSE_DEFINITIONS.map((definition) => [definition.id, definition]),
) as Record<RoguelikePvpCurseId, RoguelikePvpCurseDefinition>;
function safeRank(value: number | undefined) {
return Number.isFinite(value) ? Math.max(0, Math.floor(value ?? 0)) : 0;
}
function safeUnitSample(random: () => number) {
const sample = random();
return Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
}
function normalizedRound(round: number) {
return Number.isFinite(round) ? Math.max(1, Math.floor(round)) : 1;
}
function normalizedSeed(seed: number) {
return Number.isFinite(seed) ? Math.floor(Math.abs(seed)) >>> 0 : 0;
}
function mixSeed(seed: number, round: number, salt = 0) {
let value = (normalizedSeed(seed) ^ Math.imul(normalizedRound(round), 0x9e3779b9) ^ salt) >>> 0;
value ^= value >>> 16;
value = Math.imul(value, 0x21f0aaad) >>> 0;
value ^= value >>> 15;
value = Math.imul(value, 0x735a2d97) >>> 0;
value ^= value >>> 15;
return value >>> 0;
}
export function roguelikePvpCurseRank(ranks: RoguelikePvpCurseRanks, curseId: RoguelikePvpCurseId): number {
return Math.min(ROGUELIKE_PVP_CURSES[curseId].maxRank, safeRank(ranks[curseId]));
}
export function increaseRoguelikePvpCurseRank(
ranks: RoguelikePvpCurseRanks,
curseId: RoguelikePvpCurseId,
): RoguelikePvpCurseRanks {
const current = roguelikePvpCurseRank(ranks, curseId);
if (current >= ROGUELIKE_PVP_CURSES[curseId].maxRank) return { ...ranks };
return { ...ranks, [curseId]: current + 1 };
}
export function compileRoguelikePvpCurses(ranks: RoguelikePvpCurseRanks): CompiledRoguelikePvpCurses {
const manaCostMultipliers = {} as Record<AbilitySlotId, number>;
const cooldownMultipliers = {} as Record<AbilitySlotId, number>;
for (const abilitySlotId of ROGUELIKE_PVP_ABILITY_SLOTS) {
manaCostMultipliers[abilitySlotId] = ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK
** roguelikePvpCurseRank(ranks, `${abilitySlotId}-mana-cost`);
cooldownMultipliers[abilitySlotId] = ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK
** roguelikePvpCurseRank(ranks, `${abilitySlotId}-cooldown`);
}
return { manaCostMultipliers, cooldownMultipliers };
}
export function formatRoguelikePvpCurseEffect(
curseId: RoguelikePvpCurseId,
requestedRank: number,
abilityName?: string,
): string {
const rank = Math.max(1, Math.min(ROGUELIKE_PVP_CURSE_MAX_RANK, safeRank(requestedRank)));
const definition = ROGUELIKE_PVP_CURSES[curseId];
const increase = Math.round((ROGUELIKE_PVP_CURSE_MULTIPLIER_PER_RANK ** rank - 1) * 100);
const name = abilityName ?? DEFAULT_ABILITY_NAMES[definition.abilitySlotId];
return `+${increase}% ${name} ${definition.effectKind === "mana-cost" ? "mana cost" : "cooldown"}`;
}
export function selectRoguelikePvpCurseDraft(
ranks: RoguelikePvpCurseRanks,
random: () => number = Math.random,
count = 3,
): RoguelikePvpCurseId[] {
const pool = ROGUELIKE_PVP_CURSE_ORDER.filter(
(id) => roguelikePvpCurseRank(ranks, id) < ROGUELIKE_PVP_CURSES[id].maxRank,
);
const choices: RoguelikePvpCurseId[] = [];
const requestedCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
while (choices.length < requestedCount && pool.length > 0) {
const index = Math.floor(safeUnitSample(random) * pool.length);
choices.push(pool[index]);
pool.splice(index, 1);
}
return choices;
}
export function selectRoguelikePvpDraftChoices(
round: number,
buffRanks: RunBuffRanks,
curseRanks: RoguelikePvpCurseRanks,
passiveInfusionId: RunBuffId | null = null,
random: () => number = Math.random,
count = 3,
allowedBuffIds?: readonly RunBuffId[],
): RoguelikePvpDraftChoices {
const allowed = allowedBuffIds ? new Set(allowedBuffIds) : null;
const buffChoices = allowed
? selectRunBuffDraft(buffRanks, passiveInfusionId, random, Number.MAX_SAFE_INTEGER)
.filter((buffId) => allowed.has(buffId))
.slice(0, count)
: selectRunBuffDraft(buffRanks, passiveInfusionId, random, count);
return {
round: normalizedRound(round),
buffChoices,
curseChoices: selectRoguelikePvpCurseDraft(curseRanks, random, count),
};
}
/** Mulberry32 PRNG. Same numeric seed produces the same platform-independent sequence. */
export function createSeededRandom(seed: number): () => number {
let state = normalizedSeed(seed);
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 0x100000000;
};
}
export function roguelikePvpBossCountForRound(round: number) {
return normalizedRound(round) % ROGUELIKE_PVP_TRIO_CADENCE === 0 ? 3 : 2;
}
/** Both match peers can derive an identical, compatible encounter from seed + round. */
export function roguelikePvpBossesForRound(seed: number, round: number): BossId[] {
const normalized = normalizedRound(round);
return selectUnseenBosses(
roguelikePvpBossCountForRound(normalized),
[],
createSeededRandom(mixSeed(seed, normalized, 0xb055)),
);
}
export function roguelikePvpAbilityManaCost(
abilitySlotId: AbilitySlotId,
baseCost: number,
runModifiers: CompiledRunModifiers,
curses: CompiledRoguelikePvpCurses,
): number {
const buffedCost = runAbilityManaCost(abilitySlotId, baseCost, runModifiers);
if (buffedCost <= 0) return 0;
return Math.max(1, Math.ceil(buffedCost * curses.manaCostMultipliers[abilitySlotId]));
}
export function roguelikePvpAbilityCooldown(
abilitySlotId: AbilitySlotId,
baseCooldown: number,
runModifiers: CompiledRunModifiers,
curses: CompiledRoguelikePvpCurses,
): number {
return runAbilityCooldown(abilitySlotId, baseCooldown, runModifiers)
* curses.cooldownMultipliers[abilitySlotId];
}
export function selectCpuRoguelikePvpDraft(
seed: number,
round: number,
buffChoices: readonly RunBuffId[],
curseChoices: readonly RoguelikePvpCurseId[],
): RoguelikePvpDraftSubmission {
const normalized = normalizedRound(round);
const random = createSeededRandom(mixSeed(seed, normalized, 0xc0ffee));
const pick = <T>(choices: readonly T[]): T | null => choices.length
? choices[Math.floor(random() * choices.length)]
: null;
return {
round: normalized,
buffId: pick(buffChoices),
curseId: pick(curseChoices),
};
}
export function isCompatibleRoguelikePvpBossRound(bossIds: readonly BossId[]) {
const selected: BossId[] = [];
for (const bossId of bossIds) {
if (!canAddBossToEncounter(selected, bossId)) return false;
selected.push(bossId);
}
return true;
}
+283
View File
@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createClassInventory } from "./healers";
import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot } from "./roguelikePvp";
import { useGameStore } from "./store";
const CPU_MATCH = {
matchId: null,
seed: 73_421,
generation: 1,
opponentName: "CPU Rowan",
opponentHealerClassId: "paladin" as const,
role: "cpu" as const,
countdownEndsAtMs: 0,
};
function configureCpuMatch() {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
CPU_MATCH,
);
}
describe("Roguelike PVP store integration", () => {
beforeEach(() => {
vi.restoreAllMocks();
configureCpuMatch();
});
it("starts both racers on the same deterministic seeded boss pair", () => {
const state = useGameStore.getState();
expect([state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)])
.toEqual(roguelikePvpBossesForRound(CPU_MATCH.seed, 1));
expect(state.roguelikePvp.opponentBossHp).toBe(state.roguelikePvp.opponentBossMaxHp);
expect(state.roguelikePvp.opponentName).toBe("CPU Rowan");
expect(state.roguelikePvp.opponentHealerClassId).toBe("paladin");
expect(state.passiveRunBuffId).toBeNull();
});
it("honors the shared countdown before combat", () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, countdownEndsAtMs: 5_000 },
);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("briefing");
now.mockReturnValue(5_000);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("combat");
});
it("applies received mana-cost and cooldown burdens to the matching ability slot", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
roguelikePvp: {
...state.roguelikePvp,
receivedCurseRanks: {
"ability5-mana-cost": 1,
"ability5-cooldown": 1,
},
},
}));
const before = useGameStore.getState();
expect(before.castAbility("ability5")).toBe(true);
const after = useGameStore.getState();
expect(before.mana - after.mana).toBe(15);
expect(after.cooldowns.ability5 - before.time).toBe(17.5);
});
it("locks one blessing and one burden, reveals CPU choices, then starts next round", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
roguelikePvp: { ...state.roguelikePvp, opponentBossHp: 0 },
}));
useGameStore.getState().tick(0.01);
const draft = useGameStore.getState().roguelikePvp;
expect(useGameStore.getState().phase).toBe("intermission");
expect(draft.buffChoices).toHaveLength(3);
expect(draft.curseChoices).toHaveLength(3);
expect(useGameStore.getState().submitRoguelikePvpDraft()).toBe(true);
const next = useGameStore.getState();
expect(next.phase).toBe("combat");
expect(next.round).toBe(2);
expect(next.runBuffRanks[draft.selectedBuffId!]).toBe(1);
expect(next.roguelikePvp.sentCurseRanks[draft.selectedCurseId!]).toBe(1);
expect(Object.values(next.roguelikePvp.receivedCurseRanks)).toContain(1);
});
it("does not offer Paladin blessings whose underlying Rogue Trials effect is a no-op", () => {
useGameStore.getState().configureHealer(
"paladin",
"Aelia",
createClassInventory("paladin"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, seed: 2 },
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
useGameStore.getState().tick(0.01);
const unsupported = new Set([
"renew-spread", "renew-duration", "renew-potency",
"shield-echo", "shield-potency", "shield-guard",
"radiance-cooldown", "radiance-renew", "radiance-shield",
"barrier-regen",
]);
expect(useGameStore.getState().roguelikePvp.buffChoices.every((buffId) => !unsupported.has(buffId))).toBe(true);
});
it("requires the full five-person formation to fall", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
party: state.party.map((member) => ({ ...member, hp: member.id === "aelia" ? member.hp : 0 })),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().roguelikePvp.status).toBe("lost");
});
it("auto-locks default online draft choices when the reveal timer expires", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, matchId: "rift-timer", role: "host" },
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
useGameStore.getState().tick(0.01);
const deadline = useGameStore.getState().roguelikePvp.draftDeadlineAtMs;
vi.spyOn(Date, "now").mockReturnValue(deadline);
useGameStore.getState().tick(0.01);
const draft = useGameStore.getState().roguelikePvp;
expect(draft.localDraftLocked).toBe(true);
expect(draft.draftStep).toBe("review");
expect(draft.selectedBuffId).toBe(draft.buffChoices[0]);
expect(draft.selectedCurseId).toBe(draft.curseChoices[0]);
});
it("keeps an online wipe provisional until the server adjudicates simultaneous losses", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, matchId: "rift-wipe", role: "host" },
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().roguelikePvp.status).toBe("lost");
useGameStore.getState().resolveRoguelikePvpMatch(true);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().roguelikePvp.status).toBe("won");
});
it("ends an online match when remote status reports defeat", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, matchId: "rift-1", role: "host" },
);
useGameStore.getState().startEncounter();
const remote: RoguelikePvpRemoteSnapshot = {
sequence: 1,
time: 1,
status: "lost",
progress: {
round: 1,
bossesDefeated: 0,
livingPartyMembers: 0,
partyHpPercent: 0,
bosses: [{ id: useGameStore.getState().boss.id, hp: 100, maxHp: 100 }],
},
buffRanks: {},
curseRanks: {},
draftSubmission: null,
};
useGameStore.getState().applyRoguelikePvpRemoteSnapshot(remote);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().roguelikePvp.status).toBe("won");
useGameStore.getState().applyRoguelikePvpRemoteSnapshot({
...remote,
sequence: 2,
status: "won",
progress: {
...remote.progress,
livingPartyMembers: 5,
partyHpPercent: 100,
},
});
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().roguelikePvp.status).toBe("won");
});
it("does not trust an opponent victory claim or an inconsistent defeat claim", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
"bulldrome",
"roguelike-pvp",
undefined,
"initiate",
{ ...CPU_MATCH, matchId: "rift-2", role: "guest" },
);
useGameStore.getState().startEncounter();
const state = useGameStore.getState();
const base: RoguelikePvpRemoteSnapshot = {
sequence: 1,
time: 1,
status: "won",
progress: {
round: 1,
bossesDefeated: 0,
livingPartyMembers: 5,
partyHpPercent: 100,
bosses: [{ id: state.boss.id, hp: 100, maxHp: 100 }],
},
buffRanks: {},
curseRanks: {},
draftSubmission: null,
};
useGameStore.getState().applyRoguelikePvpRemoteSnapshot(base);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.getState().applyRoguelikePvpRemoteSnapshot({
...base,
sequence: 2,
status: "lost",
});
expect(useGameStore.getState().phase).toBe("combat");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run"; export * from "./run";
export * from "./playSpace"; export * from "./playSpace";
export * from "./uiModel"; export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"]; export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = { const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" }, weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" }, armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId]; return equipment[ownerId]?.[slotId];
} }
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem( export function createRunGearItem(
id: string, id: string,
ownerId: RunGearOwnerId, ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots, assignRosterToCombatSlots,
autoEquipRunGear, autoEquipRunGear,
challengeObjective, challengeObjective,
compareRunGear,
createRandomState, createRandomState,
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker); expect(third.bag).toContain(weaker);
}); });
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => { it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501); let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1); const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
moveRpgFocus, moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun, reduceRpgRoguelikeRun,
rpgFocusId, rpgFocusId,
rpgFocusItems, rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
} }
describe("RPG Roguelike semantic UI focus", () => { describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => { it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 }); let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers; const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types"; import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run"; import { canRemovePartyMember } from "./run";
import { import {
MAX_ACTIVE_ROSTER, MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down"; export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = { export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`, partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`, partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return []; if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action( return [action(
rpgFocusId.partyOffer(candidate.candidateId), rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`, `${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited recruited
? { type: "party-remove", memberId: candidate.candidateId } ? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId }, : { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
}); });
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action( const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId), rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`, `Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId }, { type: "party-remove", memberId: member.instanceId },
)) : []; )) : [];
return [ return [
+29 -1
View File
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest"; 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 { 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 } from "./rpgRoguelike"; import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
function finishRpgDrafts() { function finishRpgDrafts() {
@@ -111,6 +113,32 @@ 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", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
const challenge = useGameStore.getState();
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
expect(challenge.additionalBosses).toHaveLength(1);
expect(challenge.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
));
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
completeCurrentChallenge();
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
const bossRoom = useGameStore.getState();
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
expect(bossRoom.additionalBosses).toHaveLength(0);
expect(bossRoom.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
));
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
}); });
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => { it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
+1 -1
View File
@@ -6,7 +6,7 @@ export function isPvpRunMode(runMode: RunMode): boolean {
} }
export function defaultGameplayActivity(runMode: RunMode): GameplayActivity { export function defaultGameplayActivity(runMode: RunMode): GameplayActivity {
if (runMode === "hockey-healing" || runMode === "hockey-healing-pvp" || runMode === "blockbreaker" || runMode === "aether-assault") { if (runMode === "roguelike-pvp" || runMode === "hockey-healing" || runMode === "hockey-healing-pvp" || runMode === "blockbreaker" || runMode === "aether-assault") {
return runMode; return runMode;
} }
return "boss"; return "boss";
+23 -2
View File
@@ -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, 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";
@@ -61,6 +61,27 @@ describe("Disc Priest combat simulation", () => {
}); });
}); });
it("regenerates mana at one-third the previous global rate", () => {
useGameStore.setState((state) => ({
mana: 0,
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
useGameStore.getState().tick(1);
expect(MANA_REGEN_PER_SECOND).toBeCloseTo(3.2 / 3);
expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3);
});
it("starts every healer class with the shared 150 mana pool", () => {
for (const classId of Object.keys(HEALER_CLASSES) as (keyof typeof HEALER_CLASSES)[]) {
useGameStore.getState().configureHealer(classId, "Aelia", createClassInventory(classId));
const state = useGameStore.getState();
expect(state.maxMana).toBe(BASE_MANA_POOL);
expect(state.mana).toBe(BASE_MANA_POOL);
}
});
it("uses a three-second Purify cooldown for every healer class", () => { 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);
@@ -549,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);
+648 -36
View File
@@ -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,
@@ -70,6 +72,7 @@ import {
type HockeyHealingState, type HockeyHealingState,
} from "./hockeyHealing"; } from "./hockeyHealing";
import { import {
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_DAMAGE,
advanceHockeyPvpCpuGoalie, advanceHockeyPvpCpuGoalie,
advanceHockeyPvpPuck, advanceHockeyPvpPuck,
@@ -77,10 +80,32 @@ import {
hockeyPvpHealingEffectiveness, hockeyPvpHealingEffectiveness,
hockeyPvpBossAt, hockeyPvpBossAt,
mirrorHockeyPvpPuck, mirrorHockeyPvpPuck,
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,
@@ -120,7 +145,6 @@ import {
} from "./aetherAssault"; } from "./aetherAssault";
import { import {
assignRosterToCombatSlots, assignRosterToCombatSlots,
BOSSES_PER_ACT,
createRpgRoguelikeRun, createRpgRoguelikeRun,
reduceRpgRoguelikeRun, reduceRpgRoguelikeRun,
selectCurrentBossId, selectCurrentBossId,
@@ -136,6 +160,7 @@ import {
spellRankPowerMultiplier, spellRankPowerMultiplier,
type RpgPartyDamageProfiles, type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter"; } from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace"; import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel"; import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -160,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;
@@ -195,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;
@@ -228,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;
@@ -239,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;
@@ -266,6 +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 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;
@@ -357,12 +429,58 @@ export function healMember(member: PartyMember, amount: number): PartyMember {
return { ...member, hp: Math.min(member.maxHp, member.hp + amount) }; return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
} }
function effectiveHealingMultiplier(state: Pick<GameState, "runMode" | "healingMultiplier" | "endlessBossKills" | "hockeyPvp">): number { function effectiveHealingMultiplier(state: Pick<GameState, "runMode" | "round" | "healingMultiplier" | "endlessBossKills" | "hockeyPvp">): number {
if (state.runMode !== "hockey-healing-pvp") return state.healingMultiplier; if (state.runMode === "hockey-healing-pvp") {
return state.healingMultiplier * hockeyPvpHealingEffectiveness( return state.healingMultiplier * hockeyPvpHealingEffectiveness(
state.endlessBossKills, state.endlessBossKills,
state.hockeyPvp.opponentBossKills, state.hockeyPvp.opponentBossKills,
); );
}
if (state.runMode === "roguelike-pvp") {
const dampening = Math.min(0.5, Math.max(0, state.round - 5) * 0.05);
return state.healingMultiplier * (1 - dampening);
}
return state.healingMultiplier;
}
function createRoguelikePvpState(
match: RoguelikePvpMatchConfig | undefined,
round: number,
opponentBossMaxHp: number,
healerClassId: HealerClassId,
): RoguelikePvpState {
const role = match?.role ?? "cpu";
const countdownEndsAtMs = match?.countdownEndsAtMs ?? 0;
return {
matchId: match?.matchId ?? null,
seed: match?.seed ?? 1,
generation: match?.generation ?? 1,
role,
opponentName: match?.opponentName ?? "CPU Willow",
opponentHealerClassId: match?.opponentHealerClassId ?? healerClassId,
status: countdownEndsAtMs > Date.now() ? "countdown" : "inactive",
round,
countdownEndsAtMs,
buffChoices: [],
curseChoices: [],
selectedBuffId: null,
selectedCurseId: null,
draftStep: "buff",
draftDeadlineAtMs: 0,
localDraftLocked: false,
opponentDraftLocked: false,
opponentRound: round,
opponentBossHp: opponentBossMaxHp,
opponentBossMaxHp,
opponentPartyHpPercent: 100,
connectionStatus: role === "cpu" ? "cpu" : "connecting",
receivedCurseRanks: {},
sentCurseRanks: {},
opponentBuffRanks: {},
opponentDraftSubmission: null,
networkSequence: 0,
nextCpuHealAt: 1.1,
};
} }
export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) { export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) {
@@ -539,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,
@@ -570,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(
@@ -581,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,
@@ -620,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,
@@ -628,7 +759,9 @@ function initialState(
runModifiers, runModifiers,
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 === "roguelike-pvp" ? 1 + Math.max(0, round - 1) * 0.06 : 1),
gearProgress, gearProgress,
gearModifiers, gearModifiers,
time: 0, time: 0,
@@ -656,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;
@@ -700,9 +992,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner] ? [currentBossId, challengePartner]
: [currentBossId]; : [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard"; const layout: EncounterLayout = challenge ? "hockey" : "standard";
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT); const modeDifficulty = rpgEncounterDifficulty(
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14) challenge ? "hallway-challenge" : "boss-room",
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier; run.bossIndex,
);
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId, bossId,
index, index,
@@ -721,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,
@@ -747,6 +1042,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party, party,
gearModifiers: projection.gearModifiers, gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower, healingMultiplier: projection.gearModifiers.aelia.healingPower,
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party), partyCombat: createPartyCombatState(party),
partyDamageEvents: [], partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout), partyPositions: freshPartyPositions(bossIds, layout),
@@ -786,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;
@@ -807,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(
@@ -828,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 }
: undefined, : runMode === "roguelike-pvp"
? { matchId: roguelikePvp.matchId, seed: roguelikePvp.seed, generation: roguelikePvp.generation, opponentName: roguelikePvp.opponentName, opponentHealerClassId: roguelikePvp.opponentHealerClassId, role: roguelikePvp.role, countdownEndsAtMs: roguelikePvp.countdownEndsAtMs }
: undefined,
abilityLoadout, 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,
@@ -868,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,
@@ -881,8 +1205,20 @@ export const useGameStore = create<GameState>((set, get) => ({
difficultySlug, difficultySlug,
[], [],
runMode === "hockey-healing-pvp" runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role } ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: undefined, : runMode === "roguelike-pvp"
? {
matchId: roguelikePvp.matchId,
seed: roguelikePvp.role === "cpu"
? Math.max(1, Math.floor(Math.random() * 0xffffffff))
: roguelikePvp.seed,
generation: roguelikePvp.role === "cpu" ? roguelikePvp.generation + 1 : roguelikePvp.generation,
opponentName: roguelikePvp.opponentName,
opponentHealerClassId: roguelikePvp.opponentHealerClassId,
role: roguelikePvp.role,
countdownEndsAtMs: Date.now() + 3_000,
}
: undefined,
abilityLoadout, abilityLoadout,
)); ));
}, },
@@ -1103,10 +1439,18 @@ 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
? mirrorHockeyPvpPuck(hostPuck) ? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
: undefined; : undefined;
const previousLocalGoals = state.hockeyPvp.localGoalsConceded; const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals; const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
@@ -1121,8 +1465,8 @@ export const useGameStore = create<GameState>((set, get) => ({
}) })
: state.party; : state.party;
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })); const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
const opponentWiped = isPartyWiped(opponentParty); const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
const localWiped = isPartyWiped(party); const localWiped = areAllNonHealerAlliesDefeated(party);
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase; const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
return { return {
party, party,
@@ -1159,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;
@@ -1173,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];
@@ -1512,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;
@@ -1543,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);
@@ -1586,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),
@@ -2104,7 +2616,9 @@ 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 opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party); const roguelikePvpMode = state.runMode === "roguelike-pvp";
const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active"; const rpgChallengeActive = rpgRun?.phase === "challenge-active";
const rpgBossActive = rpgRun?.phase === "boss-combat"; const rpgBossActive = rpgRun?.phase === "boss-combat";
if (rpgChallengeActive && rpgRun) { if (rpgChallengeActive && rpgRun) {
@@ -2185,8 +2699,56 @@ 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 (pvpMode) { } else if (roguelikePvpMode) {
if (partyWiped) { if (partyWiped) {
const firstOnlineWipe = roguelikePvp.status !== "lost";
roguelikePvp.status = "lost";
if (roguelikePvp.role === "cpu") {
phase = "defeat";
combatLog = addLog(combatLog, time, `${roguelikePvp.opponentName} wins the rift race.`, "danger");
} else {
// Online loss is provisional until the server freezes a winner. This
// lets the server resolve simultaneous wipes without both peers
// recording defeat or overwriting an already displayed result.
phase = "combat";
if (firstOnlineWipe) {
combatLog = addLog(combatLog, time, "Formation fell. Awaiting match adjudication.", "danger");
}
}
} else if (roguelikePvp.role === "cpu" && roguelikePvp.opponentPartyHpPercent <= 0) {
phase = "victory";
roguelikePvp.status = "won";
combatLog = addLog(combatLog, time, `${roguelikePvp.opponentName}'s formation falls. PVP victory.`, "good");
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
const choices = selectRoguelikePvpDraftChoices(
state.round,
state.runBuffRanks,
roguelikePvp.sentCurseRanks,
state.passiveRunBuffId,
createSeededRandom((roguelikePvp.seed ^ Math.imul(state.round, 0x7f4a7c15)) >>> 0),
3,
supportedRoguelikePvpBuffIds(state.healerClassId),
);
phase = "intermission";
runBuffInputUnlockAt = 0;
roguelikePvp = {
...roguelikePvp,
status: "drafting",
round: state.round,
buffChoices: [...choices.buffChoices],
curseChoices: [...choices.curseChoices],
selectedBuffId: choices.buffChoices[0] ?? null,
selectedCurseId: choices.curseChoices[0] ?? null,
draftStep: "buff",
draftDeadlineAtMs: Date.now() + ROGUELIKE_PVP_DRAFT_SECONDS * 1_000,
localDraftLocked: false,
};
combatLog = addLog(combatLog, time, "Rift cleared. Choose one blessing and one rival burden.", "good");
} else {
phase = "combat";
}
} else if (pvpMode) {
if (localPvpTeamDefeated) {
phase = "defeat"; phase = "defeat";
hockeyPvp.status = "lost"; hockeyPvp.status = "lost";
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger"); combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
@@ -2245,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,
@@ -2253,8 +2815,9 @@ export const useGameStore = create<GameState>((set, get) => ({
aetherAssault, aetherAssault,
hockeyPvp, hockeyPvp,
hockeyPvpOpponent, hockeyPvpOpponent,
roguelikePvp,
runBuffInputUnlockAt, runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast, activeCast,
combatLog, combatLog,
scenePulse: pulse, scenePulse: pulse,
@@ -2277,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"
@@ -2306,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,
@@ -2354,6 +2939,33 @@ export function getHockeyPvpNetworkSnapshot(): HockeyPvpRemoteSnapshot | null {
}; };
} }
export function getRoguelikePvpNetworkSnapshot(): RoguelikePvpRemoteSnapshot | null {
const state = useGameStore.getState();
if (state.runMode !== "roguelike-pvp" || state.roguelikePvp.role === "cpu") return null;
const bosses = [state.boss, ...state.additionalBosses.map((entry) => entry.boss)];
return {
sequence: Date.now(),
time: state.time,
status: state.roguelikePvp.status,
progress: {
round: state.round,
bossesDefeated: bosses.filter((boss) => boss.hp <= 0).length,
livingPartyMembers: state.party.filter((member) => member.hp > 0).length,
partyHpPercent: state.party.reduce((total, member) => total + member.hp / Math.max(1, member.maxHp), 0) / state.party.length * 100,
bosses: bosses.map((boss) => ({ id: boss.id, hp: boss.hp, maxHp: boss.maxHp })),
},
buffRanks: { ...state.runBuffRanks },
curseRanks: { ...state.roguelikePvp.receivedCurseRanks },
draftSubmission: state.roguelikePvp.localDraftLocked
? {
round: state.round,
buffId: state.roguelikePvp.selectedBuffId,
curseId: state.roguelikePvp.selectedCurseId,
}
: null,
};
}
export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record<AbilitySlotId, number>) { export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record<AbilitySlotId, number>) {
return Math.max(0, cooldowns[abilitySlotId] - time); return Math.max(0, cooldowns[abilitySlotId] - time);
} }
+2 -2
View File
@@ -94,8 +94,8 @@ export type BossMechanicId =
| "soul-siphon"; | "soul-siphon";
export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type BossAnimationCue = "idle" | "move" | "attack" | "special";
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
export type RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; export type RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "roguelike-pvp" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault";
export type GameplayActivity = "boss" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; export type GameplayActivity = "boss" | "roguelike-pvp" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault";
export type RunBuffId = export type RunBuffId =
| "mend-echo" | "mend-echo"
| "mend-efficiency" | "mend-efficiency"
+129 -4
View File
@@ -5,6 +5,19 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings"; import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
import { cycleBottomTab } from "./bottomTabs"; import { cycleBottomTab } from "./bottomTabs";
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel"; import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
import { getDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
import { isPvpRunMode } from "./runModes";
function tacticalOverlayOwnsInput() {
const store = useGameStore.getState();
if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
if (store.paused || store.phase === "intermission") return false;
if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
}
function cycleRunBuff(direction: 1 | -1) { function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -14,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");
@@ -29,15 +87,23 @@ 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;
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return; if (!enabled) return;
if (event.defaultPrevented) return;
if (event.repeat) return; if (event.repeat) return;
const store = useGameStore.getState(); const store = useGameStore.getState();
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (tacticalOverlayOwnsInput()) return;
if (store.paused) { if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault(); if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false); if (key === "escape") store.setPaused(false);
@@ -60,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);
@@ -82,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]);
@@ -98,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;
@@ -112,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;
} }
@@ -124,6 +219,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => subscribeControllerToken(({ token, repeat }) => { useEffect(() => subscribeControllerToken(({ token, repeat }) => {
if (!enabled) return; if (!enabled) return;
const store = useGameStore.getState(); const store = useGameStore.getState();
if (isSingleScreenLayout() && token === "Button8") return;
if (tacticalOverlayOwnsInput()) return;
if (store.paused) { if (store.paused) {
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume"); if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit"); if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
@@ -145,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);
@@ -165,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))];
@@ -176,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]);
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
const [seconds, setSeconds] = useState(() =>
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
useEffect(() => {
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
update();
if (!active) return;
const timer = window.setInterval(update, 100);
return () => window.clearInterval(timer);
}, [active, countdownEndsAtMs]);
return active ? seconds : 0;
}
+3
View File
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
import App from "./App"; import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp"; import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller"; import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import "./styles.css"; import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout"); const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display"); const displayMode = new URLSearchParams(window.location.search).get("display");
const displayLayout = currentDisplayLayout();
if (Capacitor.isNativePlatform() || nativeLayoutRequested) { if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform"); document.documentElement.classList.add("native-platform");
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
if (displayMode === "top" || displayMode === "bottom") { if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode; document.documentElement.dataset.displaySurface = displayMode;
} }
document.documentElement.dataset.displayLayout = displayLayout;
startControllerInput(); startControllerInput();
+12 -3
View File
@@ -11,6 +11,7 @@ import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp";
import type { RoguelikePvpMatchConfig } from "../game/roguelikePvp";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
@@ -70,8 +71,8 @@ export function BottomDisplayApp() {
channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
}, []); }, []);
const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => {
postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, hockeyPvpMatch }); postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, pvpMatch });
}, [postFrontendCommand]); }, [postFrontendCommand]);
useEffect(() => { useEffect(() => {
@@ -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} />}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveDisplayLayout } from "./displayLayout";
describe("resolveDisplayLayout", () => {
it("uses the top-screen-first single layout for ordinary browsers", () => {
expect(resolveDisplayLayout({})).toBe("single");
expect(resolveDisplayLayout({ layout: "single" })).toBe("single");
});
it("keeps the dual-screen hardware mockup behind an explicit preview", () => {
expect(resolveDisplayLayout({ layout: "thor-preview" })).toBe("thor-preview");
expect(resolveDisplayLayout({ layout: "dual" })).toBe("thor-preview");
});
it("never overrides a dedicated Android display surface", () => {
expect(resolveDisplayLayout({ display: "top", layout: "single" })).toBe("dedicated");
expect(resolveDisplayLayout({ display: "bottom", layout: "thor-preview" })).toBe("dedicated");
});
});
+29
View File
@@ -0,0 +1,29 @@
export type DisplayLayout = "single" | "thor-preview" | "dedicated";
export interface DisplayLayoutRequest {
display?: string | null;
layout?: string | null;
}
/**
* Physical Thor surfaces are selected explicitly by the Android host. Every
* ordinary browser viewport is a single-screen game unless a developer asks
* for the dual-screen hardware preview.
*/
export function resolveDisplayLayout({ display, layout }: DisplayLayoutRequest): DisplayLayout {
if (display === "top" || display === "bottom") return "dedicated";
if (layout === "thor-preview" || layout === "dual") return "thor-preview";
return "single";
}
export function currentDisplayLayout(search = window.location.search): DisplayLayout {
const params = new URLSearchParams(search);
return resolveDisplayLayout({
display: params.get("display"),
layout: params.get("layout"),
});
}
export function isSingleScreenLayout(search = window.location.search) {
return currentDisplayLayout(search) === "single";
}
+10 -1
View File
@@ -1,13 +1,22 @@
export type DisplaySurface = "top" | "bottom"; export type DisplaySurface = "top" | "bottom";
const DISPLAY_SURFACE_EVENT = "thor:display-surface"; const DISPLAY_SURFACE_EVENT = "thor:display-surface";
let currentSurface: DisplaySurface = "top";
export function requestDisplaySurface(surface: DisplaySurface) { export function requestDisplaySurface(surface: DisplaySurface) {
currentSurface = surface;
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface })); window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
} }
export function getDisplaySurface() {
return currentSurface;
}
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) { export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail); const onSurface = (event: Event) => {
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
listener(currentSurface);
};
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface); window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface); return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
} }
+32 -1
View File
@@ -30,6 +30,8 @@ function snapshot(): BottomGameSnapshot {
endlessMode: false, endlessMode: false,
endlessBossKills: 0, endlessBossKills: 0,
endlessChoiceSelection: "continue", endlessChoiceSelection: "continue",
runBuffRanks: {},
passiveRunBuffId: null,
runModifiers: { runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
@@ -92,6 +94,7 @@ function snapshot(): BottomGameSnapshot {
bossMotion: createBossMotionState("bulldrome"), bossMotion: createBossMotionState("bulldrome"),
partyCombat: createPartyCombatState(freshParty()), partyCombat: createPartyCombatState(freshParty()),
}, },
roguelikePvp: structuredClone(useGameStore.getState().roguelikePvp),
}; };
} }
@@ -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,
+41 -5
View File
@@ -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,
}; };
} }
+759
View File
@@ -118,6 +118,243 @@ button:focus-visible {
display: none; display: none;
} }
/* PC and handheld browsers use the Thor top surface as the canonical game
viewport. The lower surface becomes a disclosed tactical layer. */
html[data-display-layout="single"],
html[data-display-layout="single"] body,
html[data-display-layout="single"] #root,
html[data-display-layout="single"] .app-shell,
.single-display-frame,
.single-primary-surface {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
html[data-display-layout="single"] .app-shell {
padding: 0;
}
html[data-display-layout="single"] .app-header {
display: none;
}
.single-display-frame {
position: relative;
isolation: isolate;
background: #030706;
}
.single-primary-surface {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.single-primary-surface > .display,
.single-primary-surface > .top-display,
.single-primary-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-primary-surface > .top-display {
container-name: single-game-screen;
container-type: size;
}
.single-context-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
}
.single-context-backdrop {
position: absolute;
inset: 0;
border: 0;
background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
backdrop-filter: blur(5px);
cursor: pointer;
}
.single-context-surface {
position: relative;
z-index: 1;
width: min(100%, calc((100dvh - 28px) * 31 / 27));
max-width: 760px;
max-height: calc(100dvh - 28px);
aspect-ratio: 31 / 27;
overflow: hidden;
border: 1px solid rgba(232, 200, 114, 0.42);
border-radius: 8px;
background: #07110f;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
}
.single-context-surface > .bottom-display,
.single-context-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-context-toggle {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 100;
min-width: 94px;
display: grid;
gap: 1px;
padding: 7px 10px;
border: 1px solid rgba(232, 200, 114, 0.62);
border-radius: 4px;
color: var(--ink);
background: rgba(3, 9, 8, 0.92);
box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
text-align: left;
cursor: pointer;
}
.single-context-toggle b {
color: var(--gold-strong);
font-size: 10px;
line-height: 1;
letter-spacing: .08em;
text-transform: uppercase;
}
.single-context-toggle small {
color: var(--muted);
font-size: 7px;
line-height: 1.2;
}
.single-display-frame.context-open .single-context-toggle {
top: max(18px, env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
bottom: auto;
}
/* Keep Return in the lower header without covering its rightmost tab. At the
620 x 540 Thor fallback viewport the contextual surface nearly fills the
screen, so the header explicitly reserves the toggle's controller target. */
@media (max-width: 820px) {
.single-display-frame.context-open .single-context-toggle {
top: max(24px, env(safe-area-inset-top));
min-width: 82px;
padding: 6px 8px;
}
.single-display-frame.context-open .single-context-toggle small {
display: none;
}
.single-display-frame.context-open .single-context-surface .lower-header {
padding-right: 104px;
}
}
html[data-display-layout="single"] .top-party {
width: clamp(185px, 20cqw, 330px);
gap: clamp(3px, .55cqh, 7px);
}
html[data-display-layout="single"] .top-party-member {
min-height: clamp(38px, 7.8cqh, 72px);
grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
gap: clamp(5px, .75cqw, 11px);
padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
}
html[data-display-layout="single"] .portrait-dot {
width: clamp(26px, 3cqw, 48px);
height: clamp(26px, 3cqw, 48px);
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy strong {
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy small {
font-size: clamp(7px, .68cqw, 11px);
}
html[data-display-layout="single"] .microbar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(5px, .58cqh, 8px);
}
html[data-display-layout="single"] .microbar.player-health-bar {
bottom: clamp(9px, 1.05cqh, 15px);
}
html[data-display-layout="single"] .player-mana-bar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(3px, .38cqh, 6px);
}
html[data-display-layout="single"] .boss-bar-wrap {
width: min(39%, 660px);
}
html[data-display-layout="single"] .boss-name {
font-size: clamp(7px, .62cqw, 11px);
}
html[data-display-layout="single"] .boss-name strong {
font-size: clamp(12px, 1.2cqw, 20px);
}
html[data-display-layout="single"] .objective-chip span {
font-size: clamp(6px, .58cqw, 10px);
}
html[data-display-layout="single"] .objective-chip strong {
font-size: clamp(8px, .82cqw, 14px);
}
html[data-display-layout="single"] .casting-bar {
bottom: clamp(76px, 11cqh, 126px);
}
html[data-display-layout="single"] .control-hint {
display: none;
}
html[data-display-layout="single"] .encounter-callout {
bottom: clamp(74px, 10cqh, 118px);
}
@media (min-width: 1200px) and (min-height: 700px) {
.single-context-layer {
justify-items: end;
padding-right: max(24px, env(safe-area-inset-right));
}
.single-context-surface {
width: min(56vw, 720px);
}
}
.screen-label { .screen-label {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -683,6 +920,49 @@ button:focus-visible {
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)); }
@@ -1066,6 +1346,19 @@ button:focus-visible {
gap: 4%; gap: 4%;
} }
.single-ability-bar {
position: absolute;
z-index: 4;
bottom: max(12px, env(safe-area-inset-bottom));
left: 50%;
width: min(64vw, 760px);
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: clamp(3px, .45vw, 7px);
transform: translateX(-50%);
pointer-events: auto;
}
.ability { .ability {
--ability-color: #ddd; --ability-color: #ddd;
position: relative; position: relative;
@@ -1086,6 +1379,40 @@ button:focus-visible {
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease; transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
} }
.ability.is-compact {
height: clamp(48px, 7.2dvh, 68px);
grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
gap: clamp(3px, .45vw, 7px);
padding: 5px;
background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
}
.ability.is-compact .ability-icon {
width: clamp(27px, 3.1vw, 37px);
height: clamp(27px, 3.1vw, 37px);
font-size: clamp(14px, 1.45vw, 20px);
}
.ability.is-compact .ability-copy strong {
font-size: clamp(7px, .72vw, 11px);
}
.ability.is-compact .ability-copy small {
display: none;
}
.ability.is-compact .ability-key,
.ability.is-compact .ability-pad {
font-size: clamp(5px, .46vw, 7px);
}
.ability.is-compact .cooldown-mask b {
width: clamp(28px, 3vw, 38px);
height: clamp(28px, 3vw, 38px);
font-size: clamp(11px, 1.1vw, 16px);
}
.ability::after { .ability::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -1298,9 +1625,19 @@ button:focus-visible {
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; } .end-actions button { 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;
@@ -2596,6 +2933,7 @@ button:focus-visible {
} }
.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;
@@ -2860,6 +3198,427 @@ button:focus-visible {
.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; }