Compare commits

..
10 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
Warren H ea5d4550a5 Release v0.1.15 2026-07-18 2026-07-18 20:37:45 -04:00
Warren H 40aa23be46 new help section explaining classes and their mechanics 2026-07-18 12:05:34 -04:00
Warren H 638aef22b2 updated some performance stuff 2026-07-17 15:39:31 -04:00
Warren H 10ff124466 new rpg mode 2026-07-17 12:47:42 -04:00
214 changed files with 29744 additions and 1280 deletions
+1
View File
@@ -10,6 +10,7 @@ vite.config.d.ts
.env
.env.*
!.env.example
/git-token
__pycache__/
*.py[cod]
/public/basis/
+50 -10
View File
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
pnpm android:install
```
The first Android milestone uses the complete single-display fallback. Press
Select (or Tab with a keyboard) to switch between the main game surface and the
620 × 540 tactical surface. Native routing to both physical Thor displays is the
next milestone; it needs two Android display contexts backed by one shared game
state rather than two independent WebViews.
The Android host routes the main and tactical surfaces to separate physical
Thor displays while preserving one authoritative game state. If only one
display is available, Select (or Tab with a keyboard) opens the tactical surface
over the main game view.
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
@@ -97,8 +102,16 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
```
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
optionally override it for one run. Publish from `main`:
Create `git-token` in the repository root. Paste only the Gitea token into it:
```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
pnpm publish:gitea -- --message "Describe the update"
@@ -132,12 +145,39 @@ outside the repository.
- `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory.
## Character model rollout and rollback
Healer characters use the modular `Rig_Medium` renderer by default. Version 1 composes
head, upper body, lower body, headwear, back item, main hand, and offhand slots while
continuing to use Aelia's shared animation set.
Load a hunter, then open **Appearance Lab** from the main menu. The upper display shows
the real in-game healer renderer with idle, walk, and cast previews. The lower display
selects healer class and cycles every available part. **Save look** persists the current
class, **Reset** restores its authored default, and **Cancel** discards drafts. **Compare
legacy** shows the previous whole-character model without deleting the modular selection.
Some source assets currently fuse related pieces, so version 1 exposes honest combined
slots such as face + hair, shirt + arms, and pants + shoes. These can split into finer
customization slots when compatible rigged assets are added.
The previous whole-GLB renderer remains intact during rollout. Use either rollback:
```text
?characterModels=legacy
VITE_CHARACTER_MODEL_MODE=legacy
```
The query changes one browser/app launch. The environment variable produces a legacy
build. Remove the switch to return to modular rendering.
## Current game scope
- Five-member AI party with Disc Priest healer
@@ -151,6 +191,6 @@ Touch controls on lower display support party targeting, ability casting, map, a
- 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
Current Android build is an installable single-display test host. Shipping to both
physical Thor displays still needs distinct Android display contexts that project
one authoritative game state.
The Android build uses distinct display contexts that project one authoritative
game state across both physical Thor displays. PC and Steam Deck use the same top
surface with an adaptive tactical overlay.
@@ -20,7 +20,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
/** Routes every Thor controller event into one JavaScript input service without WebView focus. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
@@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
@@ -165,10 +146,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
+74
View File
@@ -66,3 +66,77 @@ CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_healing_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_returns INTEGER NOT NULL DEFAULT 0 CHECK (highest_returns >= 0),
duration_seconds REAL NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_healing_rank_idx
ON hockey_healing_records (highest_returns DESC, duration_seconds DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_pvp_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
wins INTEGER NOT NULL DEFAULT 0 CHECK (wins >= 0),
losses INTEGER NOT NULL DEFAULT 0 CHECK (losses >= 0),
boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_pvp_wins_rank_idx
ON hockey_pvp_records (wins DESC, losses ASC, updated_at ASC);
CREATE INDEX IF NOT EXISTS hockey_pvp_boss_kills_rank_idx
ON hockey_pvp_records (boss_kills DESC, wins DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS blockbreaker_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_bricks INTEGER NOT NULL DEFAULT 0 CHECK (highest_bricks >= 0),
bricks_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
longest_seconds REAL NOT NULL DEFAULT 0 CHECK (longest_seconds >= 0),
time_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS blockbreaker_bricks_rank_idx
ON blockbreaker_records (highest_bricks DESC, bricks_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_time_rank_idx
ON blockbreaker_records (longest_seconds DESC, time_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_score_rank_idx
ON blockbreaker_records (highest_score DESC, score_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE TABLE IF NOT EXISTS aether_assault_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
wave_at_best INTEGER NOT NULL DEFAULT 0 CHECK (wave_at_best >= 0),
duration_at_best REAL NOT NULL DEFAULT 0 CHECK (duration_at_best >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS aether_assault_rank_idx
ON aether_assault_records (
highest_score DESC,
wave_at_best DESC,
score_achieved_at ASC,
account_id ASC,
slot_id ASC
);
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.14",
"version": "0.1.21",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
@@ -23,6 +23,7 @@
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs",
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { createGameAssetIO } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb");
const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp");
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function luminance(red, green, blue) {
return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
}
function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) {
const samples = [];
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]));
}
}
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const range = Math.max(0.001, maximum - minimum);
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
const mix = clamp01((sourceLuminance - minimum) / range);
for (let channel = 0; channel < 3; channel += 1) {
pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix);
}
}
}
}
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture");
if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture.");
const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const { width, height, channels } = decoded.info;
if (width !== 512 || height !== 512 || channels !== 4) {
throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`);
}
const pixels = new Uint8Array(decoded.data);
paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]);
paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } })
.webp({ quality: 92, smartSubsample: true })
.toBuffer());
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
+42 -11
View File
@@ -11,6 +11,7 @@ import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -21,9 +22,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ANDROID_ROOT = REPO_ROOT / "android"
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_API = "https://git.whoagland.com/api/v1"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal-mmo"
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*)$")
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(
args: list[str],
*,
@@ -258,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> 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:
raise SystemExit(
"GITEA_TOKEN is required to create the release and upload the APK. "
"Use --skip-release to push source/tag only."
f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
"set GITEA_TOKEN, or use --skip-release."
)
return token
@@ -294,7 +306,7 @@ def gitea_request(
details = error.read().decode(errors="replace")
if allow_not_found and error.code == 404:
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
@@ -327,12 +339,31 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
"prerelease": True,
}
).encode()
result = gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
token=token,
method="POST",
body=payload,
)
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
result = None
for attempt in range(3):
try:
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):
raise SystemExit("Gitea returned an invalid release response")
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()
+1206 -10
View File
File diff suppressed because it is too large Load Diff
+708 -6
View File
@@ -7,7 +7,8 @@ import { after, before, test } from "node:test";
import { createGameApiHandler } from "./game-api.mjs";
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
const api = createGameApiHandler({ dataDirectory });
let roguelikePvpNowMs = 1_000_000;
const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs });
const server = createServer((request, response) => {
void api.handle(request, response, () => {
response.statusCode = 404;
@@ -34,12 +35,12 @@ async function json(path, init = {}) {
return { response, body };
}
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
return {
schemaVersion: 5,
schemaVersion: 7,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
};
}
@@ -63,13 +64,24 @@ test("accounts, server saves, and top-five plus current rankings work end to end
const kills = 60 - index * 10;
const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
const highestHockeyReturns = 30 - index * 4;
const hockeyDuration = 180 - index * 10;
const pvpWins = 30 - index * 4;
const pvpLosses = index + 1;
const pvpBossKills = 120 - index * 12;
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
const blockbreakerSeconds = 360 - index * 30;
const blockbreakerScore = 20_000 - index * 2_000;
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
const aetherWave = index < 2 ? 12 : 10 - index;
const aetherDuration = 300 - index * 20;
const upload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills });
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
}
const current = players[5];
@@ -97,12 +109,702 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(hockeyBoard.body.kind, "hockey-healing");
assert.equal(hockeyBoard.body.top.length, 5);
assert.equal(hockeyBoard.body.top[0].value, 30);
assert.equal(hockeyBoard.body.current.rank, 6);
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
assert.equal(pvpWinsBoard.body.top[0].value, 30);
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
assert.equal(pvpKillsBoard.body.top[0].value, 120);
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(aetherBoard.body.kind, "aether-assault");
assert.equal(aetherBoard.body.top.length, 5);
assert.equal(aetherBoard.body.top[0].value, 50_000);
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
assert.equal(aetherBoard.body.top[0].rank, 1);
assert.equal(aetherBoard.body.top[1].value, 50_000);
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
assert.equal(aetherBoard.body.top[1].rank, 2);
assert.equal(aetherBoard.body.current.rank, 6);
assert.equal(aetherBoard.body.current.value, current.aetherScore);
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
delete legacySnapshot.stats.highestBlockbreakerBricks;
delete legacySnapshot.stats.longestBlockbreakerSeconds;
delete legacySnapshot.stats.highestBlockbreakerScore;
delete legacySnapshot.stats.highestAetherAssaultScore;
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
legacySnapshot.schemaVersion = 5;
const legacyUpload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: legacySnapshot }),
});
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
assert.equal(legacyUpload.body.save.schemaVersion, 7);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(download.body.save.hunterName, "Hero 5");
});
test("Healing Hockey PVP queue pairs players and relays match snapshots", 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}` }),
});
return registration.body.token;
};
const alphaToken = await registerPlayer("pvp_alpha");
const betaToken = await registerPlayer("pvp_beta");
const alphaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
});
assert.equal(alphaQueue.body.status, "waiting");
const betaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
});
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.role, "guest");
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}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(alphaMatched.body.status, "matched");
assert.equal(alphaMatched.body.match.role, "host");
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.generation, betaQueue.body.match.generation);
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
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 }),
});
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: { sequence: 1, party: [] } }),
});
assert.deepEqual(guestExchange.body.opponentSnapshot, 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 () => {
const login = await json("/api/auth/login", {
method: "POST",
+446 -12
View File
@@ -3,15 +3,24 @@ import packageJson from "../package.json";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import { getHockeyPvpNetworkSnapshot, getRoguelikePvpNetworkSnapshot, useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop";
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 { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp";
import { onlineRepository, type RoguelikePvpWireSnapshot } from "./frontend/onlineRepository";
import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking";
import { roguelikePvpBossesForRound, type RoguelikePvpRemoteSnapshot, type RoguelikePvpStatus } from "./game/roguelikePvp";
import type { RoguelikePvpMatchConfig } from "./frontend/roguelikePvpMatchmaking";
import { createClassInventory } from "./game/healers";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery })));
function GameLoadingScreen() {
return (
@@ -22,10 +31,96 @@ function GameLoadingScreen() {
);
}
export default function App() {
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() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
const screen = useFrontendStore((state) => state.screen);
const accountId = useFrontendStore((state) => state.accountId);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
const navigate = useFrontendStore((state) => state.navigate);
@@ -34,36 +129,140 @@ export default function App() {
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const recordRoguelikePvpResult = useFrontendStore((state) => state.recordRoguelikePvpResult);
const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat);
const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult);
const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill);
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
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 hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null);
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
hockeyPvpPostMatchOperation.current?.cancel();
hockeyPvpPostMatchOperation.current = null;
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
updateActiveHealerInventory(useGameStore.getState().inventory);
const game = useGameStore.getState();
if (game.runMode === "roguelike-pvp"
&& (game.phase === "combat" || game.phase === "intermission")) {
game.resolveRoguelikePvpMatch(false);
}
// RPG Roguelike equipment belongs only to its current run. Never leak it
// into the hunter's permanent inventory when leaving the expedition.
if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory);
touchActiveSave();
navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
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;
const progress = hunter.healers[hunter.activeClassId];
const selectedMode = useFrontendStore.getState().selectedMode;
const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
const runMode = selectedMode === "roguelike-pve"
? "rpg-roguelike"
: selectedMode === "rogue-trials"
? "rogue-trials"
: selectedMode === "hockey-healing"
? "hockey-healing"
: selectedMode === "hockey-healing-pvp"
? "hockey-healing-pvp"
: selectedMode === "roguelike-pvp"
? "roguelike-pvp"
: selectedMode === "blockbreaker"
? "blockbreaker"
: selectedMode === "aether-assault"
? "aether-assault"
: "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
useGameStore.getState().configureHealer(
hunter.activeClassId,
hunter.hunterName,
progress.inventory,
bossIds,
runMode,
hunter.gearProgress,
launchDifficulty,
pvpMatch,
);
touchActiveSave();
navigate("game");
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.pvpMatch ?? detail.hockeyPvpMatch);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
@@ -75,6 +274,202 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, 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(() => {
if (!accountId) return;
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
}, [accountId]);
useEffect(() => {
if (screen !== "game") return;
let stopped = false;
let exchangeActive = false;
const exchange = async () => {
if (stopped || exchangeActive) return;
const state = useGameStore.getState();
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();
if (!snapshot) return;
exchangeActive = true;
try {
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, state.hockeyPvp.generation, snapshot);
if (!stopped && result.opponentSnapshot) {
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
result.opponentSnapshot,
result.hostSnapshot?.puck,
);
}
} catch {
// Last authoritative snapshot remains playable through short network gaps.
} finally {
exchangeActive = false;
}
};
void exchange();
const timer = window.setInterval(() => { void exchange(); }, 120);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [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);
useEffect(() => {
@@ -97,9 +492,36 @@ export default function App() {
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
if (state.runMode === "rpg-roguelike"
&& (state.phase === "defeat" || state.phase === "victory")
&& state.phase !== previousState.phase) {
recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0));
}
if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordHockeyHealingDefeat(state.hockey.returns, state.time);
}
if (state.runMode === "hockey-healing-pvp"
&& (state.phase === "victory" || state.phase === "defeat")
&& state.phase !== previousState.phase) {
recordHockeyPvpResult(state.phase === "victory");
}
if (state.runMode === "roguelike-pvp"
&& (state.phase === "victory" || state.phase === "defeat")
&& state.phase !== previousState.phase) {
recordRoguelikePvpResult(state.phase === "victory", state.round);
}
if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score);
}
if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time);
}
// RPG rewards are generated inside the run reducer. Permanent boss loot
// here would duplicate its chest and break run-only progression.
if (state.runMode === "rpg-roguelike" || state.runMode === "roguelike-pvp") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = state.bossInstanceId;
@@ -108,6 +530,7 @@ export default function App() {
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill();
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
@@ -121,7 +544,7 @@ export default function App() {
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRoguelikePvpResult, recordRogueTrialsEndlessDefeat]);
return (
<main className="app-shell">
@@ -130,8 +553,19 @@ export default function App() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} 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} />}
</main>
);
}
export default function App() {
const preview = import.meta.env.DEV ? new URLSearchParams(window.location.search).get("preview") : null;
if (preview === "healer-models") {
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
}
if (preview === "roguelike-pvp-draft") {
return <RoguelikePvpDraftPreview />;
}
return <MainApp />;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+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>
);
}
+431 -21
View File
@@ -3,9 +3,37 @@ import { useGLTF } from "@react-three/drei";
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import {
BLOCKBREAKER_BIOMES,
blockbreakerBiomeForSeed,
type BlockbreakerArenaBiome,
type BlockbreakerBiomeFixture,
} from "../game/blockbreakerBiomes";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_ARENA_LENGTH,
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
HOCKEY_ARENA_WIDTH,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_MIDLINE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import { useGameStore } from "../game/store";
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
import {
HOCKEY_PVP_ARENA_MAX_X,
HOCKEY_PVP_ARENA_MAX_Z,
HOCKEY_PVP_ARENA_MIN_X,
HOCKEY_PVP_ARENA_MIN_Z,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
} from "../game/hockeyHealingPvp";
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
const ROOM_CENTER_Z = ARENA_CENTER[1];
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
@@ -170,15 +198,15 @@ function LegacyDungeonAssetInstances({
return <DungeonMeshInstances mesh={mesh} {...props} />;
}
function arenaWalls(room: BossRoomDefinition) {
return ARENA_WALL_SEGMENTS.map((fixture) => ({
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
}));
}
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => arenaWalls(room), [room]);
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
return (
<group>
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
@@ -196,9 +224,9 @@ function namedMesh(scene: THREE.Object3D, name: string) {
throw new Error(`Dungeon kit is missing mesh ${name}.`);
}
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
const walls = useMemo(() => arenaWalls(room), [room]);
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
const meshes = useMemo(() => ({
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
@@ -232,9 +260,13 @@ class DungeonAssetErrorBoundary extends Component<{
}
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
const fixtures = useMemo(
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
[portalOpenings],
);
useFrame(({ camera }) => {
if (!walls.current) return;
@@ -252,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
{fixtures.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
@@ -267,28 +299,28 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<LegacyArenaArchitecture room={room} />
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
);
}
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
<Suspense fallback={<RoomWallFallback room={room} />}>
<DungeonKitArchitecture room={room} />
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} />
: <OptimizedRoomWalls room={room} />;
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
@@ -384,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
@@ -401,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
<RoomWalls room={room} portalOpenings={portalOpenings} />
</group>
);
}
function HockeyGoal({ z, color }: { z: number; color: string }) {
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
return (
<group position={[0, 0, z]}>
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 2.03, 0]} castShadow>
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
<planeGeometry args={[goalWidth, 2]} />
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
</mesh>
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
</group>
);
}
function HockeyHealingRoom() {
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
return (
<group>
<color attach="background" args={["#020b11"]} />
<fog attach="fog" args={["#061a24", 18, 46]} />
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
<meshStandardMaterial color="#061117" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
</mesh>
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
</mesh>
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.45, 2.55, 48]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
</mesh>
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
</group>
);
}
const BLOCKBREAKER_BIOME_FIXTURES = [
[-12.75, -12.5, 0.94],
[-12.75, -1, 1.08],
[-12.75, 10.5, 0.9],
[12.75, -12.5, 1.02],
[12.75, -1, 0.88],
[12.75, 10.5, 1.12],
] as const;
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
}
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
const mesh = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!mesh.current) return;
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
transform.rotation.set(
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
index * 0.73,
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
);
transform.scale.setScalar(scale);
transform.updateMatrix();
mesh.current!.setMatrixAt(index, transform.matrix);
});
mesh.current.instanceMatrix.needsUpdate = true;
mesh.current.computeBoundingSphere();
}, [biome.fixture, transform]);
return (
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
<meshStandardMaterial
color={biome.fixtureColor}
emissive={biome.fixtureEmissive}
emissiveIntensity={0.72}
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
/>
</instancedMesh>
);
}
function BrightArcadeRoom({
variant,
biome = BLOCKBREAKER_BIOMES[0],
}: {
variant: "blockbreaker" | "aether-assault";
biome?: BlockbreakerArenaBiome;
}) {
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
const roomLength = HOCKEY_ARENA_LENGTH + 12;
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
const wallHeight = 4.8;
const wallMaterial = (
<meshStandardMaterial
color={biome.wall}
emissive={biome.wallEmissive}
emissiveIntensity={biome.wallEmissiveIntensity}
roughness={0.62}
metalness={0.12}
/>
);
return (
<group key={variant === "blockbreaker" ? biome.id : variant}>
<color attach="background" args={[biome.background]} />
<fog attach="fog" args={[biome.fog, 34, 72]} />
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
<directionalLight
castShadow
position={[8, 15, 10]}
intensity={biome.keyLightIntensity}
color={biome.keyLight}
shadow-mapSize={[512, 512]}
/>
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
</mesh>
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[roomWidth, roomLength]} />
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
</mesh>
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
</mesh>
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" ? (
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</mesh>
) : (
<group>
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
</mesh>
))}
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
</mesh>
))}
</group>
)}
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
{wallMaterial}
</mesh>
))}
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
{wallMaterial}
</mesh>
))}
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
<meshBasicMaterial color={biome.railA} toneMapped={false} />
</mesh>
))}
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
<meshBasicMaterial color={biome.railB} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
</group>
);
}
function HealingHockeyPvpRoom() {
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
const sideCenters = [
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
];
return (
<group>
<color attach="background" args={["#040912"]} />
<fog attach="fog" args={["#071522", 28, 68]} />
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
<mesh position={[0, -0.42, 0]} receiveShadow>
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
<meshStandardMaterial color="#070d17" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, length]} />
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.5, 0.11]} />
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
</mesh>
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.8, 0.07]} />
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
</mesh>)}
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, length + 0.6]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
))}
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
<HockeyPvpScoreboards />
</group>
);
}
function HockeyPvpScoreboards() {
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const texture = useMemo(() => {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 192;
const next = new THREE.CanvasTexture(canvas);
next.colorSpace = THREE.SRGBColorSpace;
next.minFilter = THREE.LinearMipmapLinearFilter;
next.magFilter = THREE.LinearFilter;
return next;
}, []);
useEffect(() => {
const canvas = texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) return;
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, "#061d28");
gradient.addColorStop(0.5, "#05080d");
gradient.addColorStop(1, "#2b0a17");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#89efff";
context.lineWidth = 5;
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
context.fillStyle = "#a8c4c9";
context.font = "700 20px Inter, sans-serif";
context.textAlign = "center";
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
context.fillStyle = "#74ecff";
context.font = "700 18px Inter, sans-serif";
context.fillText("YOU", 132, 58);
context.fillStyle = "#ff819f";
context.fillText("RIVAL", 380, 58);
context.font = "700 94px Impact, Inter, sans-serif";
context.fillStyle = "#eaffff";
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
context.fillStyle = "#ffedf4";
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
context.fillStyle = "#f3d87c";
context.font = "700 56px Inter, sans-serif";
context.fillText("", 256, 137);
texture.needsUpdate = true;
}, [localGoals, opponentGoals, texture]);
useEffect(() => () => texture.dispose(), [texture]);
return <>{([-1, 1] as const).map((side) => (
<group
key={side}
position={[side * 9.78, 4.05, 5.5]}
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
>
<mesh castShadow>
<boxGeometry args={[7.6, 3.25, 0.24]} />
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
</mesh>
<mesh position={[0, 0, 0.126]}>
<planeGeometry args={[7.28, 2.93]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
</group>
))}</>;
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
if (hockeyMode) return <HockeyHealingRoom />;
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
@@ -418,7 +820,15 @@ export function BossRoom() {
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
<RoomFloor room={room} />
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
{rpgBossRoom && rpgPhase && (
<RpgRoomPortals
entryOpen
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
accent={room.accent}
wallColor={room.wallColor}
/>
)}
</group>
);
}
+563 -92
View File
@@ -1,12 +1,153 @@
import { useEffect, useRef } from "react";
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_NPC_PADDLE_HALF_WIDTH,
HOCKEY_NPC_PADDLE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
cycleHockeyPvpPostMatchSelection,
type HockeyPvpPostMatchSelection,
} from "../game/hockeyHealingPvp";
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
BLOCKBREAKER_DANGER_Z,
blockbreakerColumnX,
blockbreakerRowZ,
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
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() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -30,23 +171,27 @@ function PartyFrame({ member }: { member: PartyMember }) {
const selected = useGameStore((state) => state.selectedMemberId === member.id);
const selectMember = useGameStore((state) => state.selectMember);
const time = useGameStore((state) => state.time);
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
const knockedRemaining = Math.max(0, member.knockedUntil - time);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const tankPosition = useGameStore((state) => state.partyPositions.brann);
const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]);
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
const position = useGameStore((state) => state.partyPositions[member.id]);
const protectedByBarrier = barrierProtects(position, barrier, time);
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
: null;
return (
<button
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
onClick={() => selectMember(member.id)}
aria-pressed={selected}
aria-label={`${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
@@ -55,10 +200,16 @@ function PartyFrame({ member }: { member: PartyMember }) {
<small>{currentAction ?? member.className}</small>
</span>
<span className="effect-stack">
{beaconed && <i className="effect beacon-effect" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></i>}
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}></i>}
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
{activeHealingEffects.map((effect) => {
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
return <i key={effect.id} className="effect renew-effect" title={`${effect.id}: ${Math.max(0, effect.expiresAt - time).toFixed(1)} seconds`}>{label}</i>;
})}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <i className="effect earth-shield-effect" title={`Earth Shield: ${member.reactiveHeal.charges} charges`}>{member.reactiveHeal.charges}</i>}
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
{linkedBySpirit && <i className="effect spirit-link-effect" title="Spirit Link: health equalized each second">S</i>}
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
</span>
@@ -76,72 +227,42 @@ function PartyList() {
);
}
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = HEALER_CLASSES[healerClassId].abilities[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 remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && 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 resourceCopy = `${manaCost ? `${manaCost} mana` : "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">{ability.key}</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.gamepad}</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() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
const activityMode = useGameStore((state) => state.activityMode);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const bossKills = useGameStore((state) => state.endlessBossKills);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
const nextRowSeconds = Math.max(0, blockbreaker.nextRowAt - time);
return (
<div className="ability-column">
<div className="ability-meta">
<div className={`ability-meta ${hockeyMode || pvpMode || blockbreakerMode || aetherMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""}`}>
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
<div className="resource-stack">
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
{healer.secondaryResourceName && <div className="class-resource"><span>{healer.secondaryResourceName}</span><b>{healerMechanic.resource} / {healerMechanic.maxResource}</b></div>}
</div>
{hockeyMode && <div className="hockey-run-meta"><span>Returns</span><strong>{hockeyReturns}</strong><small>{duration} · {bossKills} KOs</small></div>}
{pvpMode && <div className="hockey-run-meta pvp-run-meta"><span>Goals · Bosses</span><strong>{hockeyPvp.opponentGoalsConceded}{hockeyPvp.localGoalsConceded}</strong><small>{bossKills}{hockeyPvp.opponentBossKills} · {hockeyPvp.opponentName}</small></div>}
{blockbreakerMode && <div className="hockey-run-meta blockbreaker-run-meta"><span>Score · Bricks</span><strong>{blockbreaker.score} · {blockbreaker.bricksBroken}</strong><small>{blockbreakerTimeMultiplier(time).toFixed(1)}× · next row {nextRowSeconds.toFixed(1)}s</small></div>}
{aetherMode && <div className="hockey-run-meta aether-run-meta"><span>Score · Wave</span><strong>{aetherAssault.score} · {aetherAssault.wave}</strong><small>{aetherAssault.multiplier.toFixed(2)}× · {aetherAssault.ships.length} ships</small></div>}
</div>
<div className="ability-grid">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
@@ -165,14 +286,28 @@ function BriefingPanel() {
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
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 (
<div className="briefing-panel">
<div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : roguelikePvpMode ? `Face ${roguelikePvp.opponentName} through matching seeded encounters. After every clear, choose one blessing for yourself and secretly inflict one ability curse on your rival. Last five-person formation standing wins.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
<button className="start-button" onClick={startEncounter} disabled={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 className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -191,10 +326,17 @@ function BriefingPanel() {
);
}
function EndPanel({ onExit }: { onExit?: () => void }) {
function EndPanel({
onExit,
onHockeyPvpAction,
}: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
}) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
@@ -207,38 +349,81 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
const requeueSeconds = useHockeyPvpCountdownSeconds(
hockeyPvp.postMatchStatus === "requeueing",
hockeyPvp.postMatchQueueEndsAtMs,
);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing";
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
const pvpMatch = activityMode === "hockey-healing-pvp";
const roguelikePvpMatch = runMode === "roguelike-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch && !roguelikePvpMatch;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return (
<div className={`end-panel end-${phase}`}>
<div className={`end-panel end-${phase} ${pvpMatch || roguelikePvpMatch ? "is-pvp" : ""}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<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` : 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">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</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" : 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>
{phase === "victory" && <RewardSummary />}
{!roguelikePvpMatch && (phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
</div> : roguelikePvpMatch ? <div className="end-actions pvp-end-actions">
{roguelikePvp.role === "cpu" && <button className="is-controller-selected" onClick={restart}><span>Run again</span><small>Same CPU rival</small></button>}
<button className={roguelikePvp.role === "cpu" ? "secondary" : "is-controller-selected"} onClick={onExit}>Main menu</button>
</div> : pvpMatch ? <>
<div 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 ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</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>
);
@@ -258,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 runMode = useGameStore((state) => state.runMode);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
if (phase === "intermission") return runMode === "roguelike-pvp"
? <RoguelikePvpDraftPanel />
: <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -275,10 +466,182 @@ function MapPanel() {
const time = useGameStore((state) => state.time);
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.bossId);
const activityMode = useGameStore((state) => state.activityMode);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent);
const bossDefinition = BOSS_DEFINITIONS[bossId];
const playerX = 120 + playerPosition[0] * 7;
const playerY = 143 + playerPosition[1] * 5.3;
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
if (activityMode === "aether-assault") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
return (
<div className="map-panel hockey-map-panel aether-map-panel">
<div className="map-copy">
<span>Aether Assault</span>
<h2>Arcane Formation Runway</h2>
<p>Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-ship" /> Ships <i className="legend-shot" /> Shots</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Aether Assault tactical map">
<defs><linearGradient id="aether-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#18394a" /><stop offset="0.55" stopColor="#d9f3ef" /><stop offset="1" stopColor="#dff8f4" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#aether-room)" />
<path className="map-ring aether-map-lanes" d="M63 20 V260 M91 20 V260 M120 20 V260 M149 20 V260 M177 20 V260" />
{aetherAssault.ships.map((ship, index) => {
const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind);
return <g key={ship.id}>
{ship.phase === "diving" && <circle className="aether-map-warning" cx={mapX(ship.position[0])} cy={mapY(ship.position[1])} r="8" />}
<path className={`aether-map-ship is-${ship.kind}`} style={{ fill: color, filter: `drop-shadow(0 0 4px ${color})` }} d={`M${mapX(ship.position[0])} ${mapY(ship.position[1]) - 5} l6 9 h-12 z`} />
</g>;
})}
{aetherAssault.playerShots.map((shot) => <circle key={`player-shot-${shot.id}`} className="aether-map-player-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2" />)}
{aetherAssault.enemyShots.map((shot) => <circle key={`enemy-shot-${shot.id}`} className="aether-map-enemy-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2.5" />)}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "blockbreaker") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = {
cyan: "#36d9ef",
amber: "#f1b74f",
magenta: "#e85aa9",
lime: "#93db54",
};
return (
<div className="map-panel hockey-map-panel blockbreaker-map-panel">
<div className="map-copy">
<span>Blockbreaker</span>
<h2>Advancing Color Wall</h2>
<p>Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-brick" /> Bricks <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Blockbreaker tactical map">
<defs><linearGradient id="blockbreaker-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#blockbreaker-room)" />
<path className="map-ring" d="M35 140 H205" />
<path className="blockbreaker-map-danger" d={`M35 ${mapY(BLOCKBREAKER_DANGER_Z)} H205`} />
{blockbreaker.bricks.map((brick) => (
<rect
className="blockbreaker-map-brick"
key={brick.id}
x={mapX(blockbreakerColumnX(brick.column)) - 12.5}
y={mapY(blockbreakerRowZ(brick.row)) - 5}
width="25"
height="10"
rx="2"
fill={colors[brick.color]}
/>
))}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing-pvp") {
const mapX = (x: number) => 120 + x * 8.2;
const mapY = (z: number) => 140 + z * 4.85;
const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z;
const opponentWorldX = (x: number) => -x;
const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z;
return (
<div className="map-panel hockey-map-panel pvp-map-panel">
<div className="map-copy">
<span>Healing Hockey PVP</span>
<h2>You vs {hockeyPvp.opponentName}</h2>
<p>Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.</p>
<div className="map-legend"><i className="legend-party" /> Your party <i className="legend-boss" /> Bosses <i className="legend-opponent" /> Rival <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Healing Hockey PVP tactical map">
<defs><linearGradient id="pvp-hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#361819" /><stop offset="0.5" stopColor="#15262b" /><stop offset="1" stopColor="#0a3040" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="14" width="170" height="252" rx="3" fill="url(#pvp-hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(-HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<path className="hockey-map-goal is-healer" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<circle className="map-boss" cx={mapX(bossMotion.position[0])} cy={mapY(localWorldZ(bossMotion.position[1]))} r="7" />
<circle className="map-boss is-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.bossMotion.position[0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.bossMotion.position[1]))} r="7" />
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="10" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="5" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(localWorldZ(partyPositions[memberId][1]))} r="3.5" />)}
{(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={`opponent-${memberId}`} className="map-ally map-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.partyPositions[memberId][0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.partyPositions[memberId][1]))} r="3.5" />)}
<circle className="hockey-map-puck" cx={mapX(hockeyPvp.puckPosition[0])} cy={mapY(hockeyPvp.puckPosition[1])} r="5" />
</svg>
<span className="map-state">{phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH);
const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH);
return (
<div className="map-panel hockey-map-panel">
<div className="map-copy">
<span>Hockey Healing</span>
<h2>Rectangular Boss Rink</h2>
<p>Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-paddle" /> Paddle <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Hockey Healing tactical map">
<defs>
<linearGradient id="hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient>
</defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${npcGoalLeft} ${mapY(HOCKEY_NPC_GOAL_Z)} H${npcGoalRight}`} />
<path className="hockey-map-goal is-healer" d={`M${npcGoalLeft} ${mapY(HOCKEY_HEALER_GOAL_Z)} H${npcGoalRight}`} />
<rect
className="hockey-map-paddle"
x={mapX(hockey.paddleX - HOCKEY_NPC_PADDLE_HALF_WIDTH)}
y={mapY(HOCKEY_NPC_PADDLE_Z) - 4}
width={HOCKEY_NPC_PADDLE_HALF_WIDTH * 2 * 8.5}
height="8"
rx="3"
/>
{bossMotions.map((motion, index) => (
<g key={`${motion.bossId}-${index}`}>
<circle className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="8" />
<path className="map-boss-arrow" d={`M${mapX(motion.position[0])} ${mapY(motion.position[1]) - 14} l6 9 h-12 z`} />
</g>
))}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
))}
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
</div>
</div>
);
}
return (
<div className="map-panel">
<div className="map-copy">
@@ -314,8 +677,8 @@ function MapPanel() {
className="map-barrier"
cx={120 + barrier.center[0] * 7}
cy={143 + barrier.center[1] * 5.3}
rx="21"
ry="15.9"
rx={BARRIER_RADIUS * 7}
ry={BARRIER_RADIUS * 5.3}
/>
)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
@@ -362,33 +725,141 @@ function PackPanel() {
);
}
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "combat", label: "Heal", icon: "✦", key: "" },
{ id: "map", label: "Map", icon: "⌁", key: "M" },
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
function PvpPanel() {
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
const living = opponentParty.filter((member) => member.hp > 0).length;
const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0);
const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0);
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
return (
<div className="pvp-panel">
<header className="pvp-roster-header">
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
<div><small>Goals</small><strong>{opponentGoalsConceded}{localGoalsConceded}</strong></div>
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
</header>
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
{opponentParty.map((member) => (
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
<HealthBar member={member} />
<small>{member.className}</small>
</span>
<b>{member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`}</b>
</article>
))}
</div>
</div>
);
}
const tabPresentation: Record<BottomTab, { label: string; icon: string; key: string }> = {
combat: { label: "Heal", icon: "✦", key: "" },
map: { label: "Map", icon: "⌁", key: "M" },
pack: { label: "Pack", icon: "▧", key: "I" },
pvp: { label: "PVP", icon: "⚔", key: "P" },
};
function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
readonly run: NonNullable<ReturnType<typeof useGameStore.getState>["rpgRun"]>;
readonly focusedId: string | null;
readonly paused: boolean;
readonly onExit?: () => void;
}) {
const party = useGameStore((state) => state.party);
const selectedMemberId = useGameStore((state) => state.selectedMemberId);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const time = useGameStore((state) => state.time);
const activeCast = useGameStore((state) => state.activeCast);
const spellResources = useGameStore((state) => state.rpgSpellResources);
const selectMember = useGameStore((state) => state.selectMember);
const castAbility = useGameStore((state) => state.castAbility);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
return (
<section className="display bottom-display rpg-bottom-display" aria-label="RPG Roguelike tactical display">
<RpgRunTacticalPanel
run={run}
focusedId={focusedId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
liveCombat={{
party,
selectedMemberId,
mana,
maxMana,
cooldowns,
globalCooldownUntil,
time,
activeCast,
spellResources,
onSelectMember: selectMember,
onCastAbility: castAbility,
}}
/>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
);
}
export function BottomScreen({ onExit, onHockeyPvpAction }: {
onExit?: () => void;
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
} = {}) {
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const tabs = bottomTabsFor(runMode);
if (runMode === "rpg-roguelike" && rpgRun) {
return <RpgBottomDisplay run={rpgRun} focusedId={rpgFocusId} paused={paused} onExit={onExit} />;
}
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections">
{tabs.map((tab) => (
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
<nav aria-label="Lower display sections" role="tablist">
{tabs.map((tabId) => {
const tab = tabPresentation[tabId];
return <button key={tabId} role="tab" aria-selected={activeTab === tabId} className={activeTab === tabId ? "is-active" : ""} onClick={() => setActiveTab(tabId)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
))}
})}
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
{phase === "intermission" && runMode === "roguelike-pvp"
? <RoguelikePvpDraftPanel />
: <>
{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>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
+4 -5
View File
@@ -41,13 +41,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
const buff = RUN_BUFFS[buffId];
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
const ability = abilities[buff.abilitySlotId];
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
className={selected === buffId ? "is-controller-selected" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
@@ -55,12 +54,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
>
<i>{buff.icon}</i>
<span><small>{rank ? `Rank ${rank}${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
<button className="buff-mastery-continue is-controller-selected" onClick={continueRun} disabled={inputLocked}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { CLAUDECRAFT_WEAPON_CATALOG } from "../game/weaponCatalog";
import { characterEquipmentAssetUrl, characterEquipmentAssetUrls } from "./CharacterEquipmentAssets";
describe("Claudecraft runtime weapon URLs", () => {
it("resolves every catalog entry to one emitted GLB URL", () => {
const urls = characterEquipmentAssetUrls();
expect(Object.keys(urls)).toHaveLength(55);
for (const definition of CLAUDECRAFT_WEAPON_CATALOG) {
expect(characterEquipmentAssetUrl(definition.id)).toMatch(/\.glb(?:\?|$)/);
}
});
});
@@ -0,0 +1,35 @@
import {
CLAUDECRAFT_WEAPON_CATALOG,
weaponDefinition,
type WeaponCatalogId,
} from "../game/weaponCatalog";
import { selectedGameAssetUrl } from "./GameAssetProvider";
const WEAPON_ASSET_PREFIX = "../assets/game/models/claudecraft/weapons/";
const WEAPON_ASSET_URLS = import.meta.glob<string>(
"../assets/game/models/claudecraft/weapons/*.glb",
{ eager: true, import: "default", query: "?url" },
);
function importedWeaponUrl(fileName: string) {
const url = WEAPON_ASSET_URLS[`${WEAPON_ASSET_PREFIX}${fileName}`];
if (!url) throw new Error(`Missing imported Claudecraft weapon asset: ${fileName}`);
return url;
}
/** Resolves one selected asset. Creating the URL table does not fetch or decode every GLB. */
export function characterEquipmentAssetUrl(modelId: WeaponCatalogId) {
const definition = weaponDefinition(modelId);
const sourceUrl = importedWeaponUrl(definition.sourceFile);
const optimizedUrl = definition.optimizedFile
? importedWeaponUrl(definition.optimizedFile)
: sourceUrl;
return selectedGameAssetUrl(sourceUrl, optimizedUrl);
}
export function characterEquipmentAssetUrls() {
return Object.fromEntries(CLAUDECRAFT_WEAPON_CATALOG.map((definition) => [
definition.id,
characterEquipmentAssetUrl(definition.id),
])) as Record<WeaponCatalogId, string>;
}
+53 -12
View File
@@ -1,39 +1,80 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
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 activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface;
const showSurface = useCallback((surface: DisplaySurface) => {
activeSurfaceRef.current = surface;
setActiveSurface(surface);
}, []);
const toggleSurface = useCallback(() => {
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
}, []);
useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
if (layout === "thor-preview") return;
requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (token === "Button8" && !repeat) toggle();
if (token === "Button8" && !repeat) toggleSurface();
});
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();
toggle();
toggleSurface();
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface();
unsubscribeController();
requestDisplaySurface("top");
};
}, [dedicatedSurface]);
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
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 (
<div className={`device-frame active-${activeSurface}`}>
<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>
<button
className="native-display-switch"
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
File diff suppressed because it is too large Load Diff
+1879 -292
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import { useFrame } from "@react-three/fiber";
import { useLayoutEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { HealerVisualProfile } from "../game/healerVisuals";
function RelicMaterial({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.45}
metalness={0.35}
roughness={0.42}
/>
);
}
function DawnNimbus({ accentColor, secondaryColor }: { accentColor: string; secondaryColor: string }) {
const rays = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!rays.current) return;
for (let index = 0; index < 8; index += 1) {
const angle = index * Math.PI / 4;
transform.position.set(Math.sin(angle) * 0.54, Math.cos(angle) * 0.54, 0);
transform.rotation.set(0, 0, -angle);
transform.updateMatrix();
rays.current.setMatrixAt(index, transform.matrix);
}
rays.current.instanceMatrix.needsUpdate = true;
}, [transform]);
return (
<group position={[0, 0.68, -0.04]}>
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
<boxGeometry args={[0.045, 0.16, 0.035]} />
<RelicMaterial color={accentColor} />
</instancedMesh>
<mesh>
<torusGeometry args={[0.42, 0.04, 6, 20]} />
<RelicMaterial color={accentColor} />
</mesh>
<mesh position={[0, 0.08, 0.015]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={secondaryColor} />
</mesh>
</group>
);
}
export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) {
const animated = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (!animated.current || profile.accessory !== "clockwork-rings") return;
animated.current.rotation.z += delta * 0.42;
animated.current.rotation.y -= delta * 0.18;
});
if (profile.accessory === "sun-halo") {
return <DawnNimbus accentColor={profile.accentColor} secondaryColor={profile.secondaryColor} />;
}
if (profile.accessory === "grove-antlers") {
return (
<group position={[0, 0.34, 0]}>
<mesh position={[-0.2, 0.18, 0]} rotation={[0, 0, -0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[0.2, 0.18, 0]} rotation={[0, 0, 0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.31, 0.28, 0]} rotation={[0, 0, -0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.31, 0.28, 0]} rotation={[0, 0, 0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "storm-totem") {
return (
<group position={[0, 0.78, 0]}>
<mesh>
<cylinderGeometry args={[0.09, 0.12, 0.42, 6]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.22, 0.1, 0]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.22, -0.08, 0]} scale={0.75}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "sun-crest") {
return (
<group position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.055, 6, 14]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.23, 0]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
</group>
);
}
return (
<group ref={animated} position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.04, 6, 18]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[Math.PI / 3, 0, 0]}>
<torusGeometry args={[0.25, 0.035, 6, 14]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { createPortal } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import {
CHARACTER_PART_CATALOG,
characterAppearancePartIds,
type CharacterAppearanceV1,
type CharacterMaterialVariant,
type CharacterPartId,
} from "../game/characterAppearance";
import type { MemberId } from "../game/types";
import { useGameGLTF } from "./GameAssetProvider";
interface BoundCharacterPart {
group: THREE.Group;
materials: THREE.Material[];
skeletons: THREE.Skeleton[];
}
const CHARACTER_MATERIAL_VARIANT_URLS: Record<CharacterMaterialVariant, string> = {
"priest-vestments": new URL("../assets/game/textures/claudecraft/chars/players/priest-vestments.webp", import.meta.url).href,
};
function rigBonesByName(rigScene: THREE.Object3D) {
const bones = new Map<string, THREE.Bone>();
rigScene.traverse((object) => {
if (object instanceof THREE.Bone) bones.set(object.name, object);
});
return bones;
}
function createBoundCharacterPart(
sourceScene: THREE.Object3D,
rigScene: THREE.Object3D,
partId: CharacterPartId,
materialTexture?: THREE.Texture,
): BoundCharacterPart {
const definition = CHARACTER_PART_CATALOG[partId];
const rigBones = rigBonesByName(rigScene);
const group = new THREE.Group();
group.name = `character-part:${partId}`;
const materialClones = new Map<THREE.Material, THREE.Material>();
const skeletons: THREE.Skeleton[] = [];
sourceScene.updateMatrixWorld(true);
for (const nodeName of definition.nodeNames) {
const sourceNode = sourceScene.getObjectByName(nodeName);
if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`);
const partNode = sourceNode.clone(true);
partNode.matrix.copy(sourceNode.matrixWorld);
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
partNode.traverse((object) => {
if (!(object instanceof THREE.SkinnedMesh)) return;
if (materialTexture) {
const cloneMaterial = (source: THREE.Material) => {
const existing = materialClones.get(source);
if (existing) return existing;
const clone = source.clone();
if ("map" in clone) clone.map = materialTexture;
clone.needsUpdate = true;
materialClones.set(source, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
}
const mappedBones = object.skeleton.bones.map((sourceBone) => {
const rigBone = rigBones.get(sourceBone.name);
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
return rigBone;
});
const skeleton = new THREE.Skeleton(
mappedBones,
object.skeleton.boneInverses.map((inverse) => inverse.clone()),
);
const bindMatrix = object.bindMatrix.clone();
object.bind(skeleton, bindMatrix);
object.castShadow = true;
object.receiveShadow = true;
object.frustumCulled = false;
skeletons.push(skeleton);
});
group.add(partNode);
}
return { group, materials: [...materialClones.values()], skeletons };
}
function BoundCharacterPart({
actorScene,
materialTexture,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialTexture?: THREE.Texture;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
const boundPart = useMemo(
() => createBoundCharacterPart(gltf.scene, actorScene, partId, materialTexture),
[actorScene, gltf.scene, materialTexture, partId],
);
useEffect(() => () => {
for (const skeleton of boundPart.skeletons) skeleton.dispose();
for (const material of boundPart.materials) material.dispose();
}, [boundPart]);
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
return createPortal(<primitive object={boundPart.group} />, rigRoot);
}
function MaterialVariantCharacterPart({
actorScene,
materialVariant,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialVariant: CharacterMaterialVariant;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const sourceTexture = useTexture(CHARACTER_MATERIAL_VARIANT_URLS[materialVariant]);
const materialTexture = useMemo(() => {
sourceTexture.flipY = false;
sourceTexture.colorSpace = THREE.SRGBColorSpace;
sourceTexture.magFilter = THREE.NearestFilter;
sourceTexture.needsUpdate = true;
return sourceTexture;
}, [sourceTexture]);
return <BoundCharacterPart actorScene={actorScene} materialTexture={materialTexture} modelUrls={modelUrls} partId={partId} />;
}
function ModularCharacterPart({
actorScene,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined;
return materialVariant
? <MaterialVariantCharacterPart actorScene={actorScene} materialVariant={materialVariant} modelUrls={modelUrls} partId={partId} />
: <BoundCharacterPart actorScene={actorScene} modelUrls={modelUrls} partId={partId} />;
}
export function ModularCharacterBody({
actorScene,
appearance,
modelUrls,
}: {
actorScene: THREE.Object3D;
appearance: CharacterAppearanceV1;
modelUrls: Record<MemberId, string>;
}) {
return (
<>
{characterAppearancePartIds(appearance).map((partId) => (
<ModularCharacterPart
key={`${partId}:${appearance.version}`}
actorScene={actorScene}
modelUrls={modelUrls}
partId={partId}
/>
))}
</>
);
}
+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>
);
}
+349 -37
View File
@@ -1,13 +1,57 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { lazy, memo, Suspense, useEffect, useRef, useState } from "react";
import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_ABILITIES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
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 = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
const CONVICTION_PIPS = [0, 1, 2] as const;
const MAX_CONVICTION = healerMaxResource("paladin");
function ConvictionMeter() {
const healerClassId = useGameStore((state) => state.healerClassId);
const runMode = useGameStore((state) => state.runMode);
const encounterConviction = useGameStore((state) => state.healerMechanic.resource);
const rpgConviction = useGameStore((state) => state.rpgSpellResources.conviction);
if (healerClassId !== "paladin") return null;
const conviction = Math.max(0, Math.min(MAX_CONVICTION, runMode === "rpg-roguelike" ? rpgConviction : encounterConviction));
return (
<div
className={`top-conviction-meter ${conviction === MAX_CONVICTION ? "is-full" : ""}`}
role="meter"
aria-label="Conviction"
aria-valuemin={0}
aria-valuemax={MAX_CONVICTION}
aria-valuenow={conviction}
aria-valuetext={`${conviction} of ${MAX_CONVICTION} Conviction`}
title={`${conviction} of ${MAX_CONVICTION} Conviction`}
>
<span><b>Conviction</b><small>{conviction} / {MAX_CONVICTION}</small></span>
<span className="top-conviction-pips" aria-hidden="true">
{CONVICTION_PIPS.map((pip) => <i className={pip < conviction ? "is-filled" : ""} key={pip} />)}
</span>
</div>
);
}
function CompactParty() {
const party = useGameStore((state) => state.party);
@@ -19,17 +63,19 @@ function CompactParty() {
const selectMember = useGameStore((state) => state.selectMember);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
return (
<div className="top-party" aria-label="Party health">
<div className="top-party" aria-label="Party health and player resources">
{party.map((member) => {
const health = (member.hp / member.maxHp) * 100;
const shield = (member.absorb / member.maxHp) * 100;
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
return (
<button
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
className={`top-party-member ${selected === member.id ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""}`}
key={member.id}
onClick={() => selectMember(member.id)}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
@@ -43,26 +89,29 @@ function CompactParty() {
</span>
)}
<span className="top-effects">
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
{beaconed && <em className="beacon-pip" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></em>}
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
{barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && <em className="barrier-pip">S</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && <em className="tank-aura-pip">T</em>}
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
</span>
</button>
);
})}
<ConvictionMeter />
</div>
);
}
function CastingBar() {
const healerClassId = useGameStore((state) => state.healerClassId);
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
const activeCast = useGameStore((state) => state.activeCast);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
if (!activeCast) return null;
const abilityName = HEALER_ABILITIES[activeCast.abilityId].name;
const duration = activeCast.completesAt - activeCast.startedAt;
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
const target = party.find((member) => member.id === activeCast.targetId);
@@ -78,13 +127,19 @@ function CastingBar() {
function BossBar() {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss);
const phase = useGameStore((state) => state.phase);
const activityMode = useGameStore((state) => state.activityMode);
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}>
{bosses.map((entry, index) => <div className={`boss-bar-entry ${pvpMode && index === 1 ? "is-opponent" : ""}`} key={`${entry.id}-${index}`}>
<div className="boss-name"><span>{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "Vault Beast"}</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
</div>)}
</div>
@@ -110,48 +165,200 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const hockey = useGameStore((state) => state.hockey);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
activityMode === "hockey-healing-pvp" && phase === "briefing",
hockeyPvp.countdownEndsAtMs,
);
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 (phase === "intermission") return runMode === "roguelike-pvp"
? <RoguelikePvpDraftWaitingOverlay />
: <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const roguelikePvpMode = runMode === "roguelike-pvp";
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
const briefingMode = hockeyMode
? "Endless Goal Defense"
: blockbreakerMode
? "Endless Color Break"
: aetherAssaultMode
? "Endless Arcade Assault"
: pvpMode
? `Versus ${hockeyPvp.opponentName}`
: roguelikePvpMode
? `Versus ${roguelikePvp.opponentName}`
: runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
if (phase === "combat") return null;
const title = phase === "briefing"
? room.name
? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : roguelikePvpMode ? "Roguelike PVP" : room.name
: phase === "victory"
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode || roguelikePvpMode ? "Match Won" : `${bossNames} Broken`
: hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode || roguelikePvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : roguelikePvpMode ? `${briefingMode} · Mirrored Rift` : `${briefingMode} · ${room.biome}`
: phase === "victory"
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : roguelikePvpMode ? `Round ${round} · Rift Race Victory` : "Encounter Complete"
: hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : roguelikePvpMode ? `Round ${round} · Rift Race Defeat` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
? 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."
: blockbreakerMode
? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve."
: aetherAssaultMode
? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses."
: pvpMode
? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly."
: roguelikePvpMode
? "Two five-person parties face the same seeded encounters. After each clear, claim one blessing and secretly send one ability curse to your rival. Last formation standing wins."
: definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : roguelikePvpMode ? `${roguelikePvp.opponentName}'s formation fell first.` : "Five entered. Five endured."
: hockeyMode
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
: blockbreakerMode
? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.`
: aetherAssaultMode
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: roguelikePvpMode ? `${roguelikePvp.opponentName} kept their formation alive through round ${round}.`
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
const 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 (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on 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>
);
}
function BlockbreakerScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt);
const count = useGameStore((state) => state.blockbreaker.lastBreakCount);
const award = useGameStore((state) => state.blockbreaker.lastScoreAward);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastBreakAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850);
return () => window.clearTimeout(timeout);
}, [activityMode, lastBreakAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{count} {count === 1 ? "brick" : "brick combo"}</small></div>;
}
function AetherScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt);
const award = useGameStore((state) => state.aetherAssault.lastKillScore);
const multiplier = useGameStore((state) => state.aetherAssault.multiplier);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastKillAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700);
return () => window.clearTimeout(timeout);
}, [activityMode, lastKillAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup aether-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{multiplier.toFixed(2)}× streak</small></div>;
}
function PauseOverlay({ onExit }: { onExit?: () => void }) {
const paused = useGameStore((state) => state.paused);
const selection = useGameStore((state) => state.pauseSelection);
@@ -170,14 +377,12 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
<p>Simulation, damage, and movement are stopped.</p>
<div className="pause-actions">
<button
className={selection === "resume" ? "is-controller-focused" : ""}
onFocus={() => setPauseSelection("resume")}
className={selection === "resume" ? "is-controller-selected" : ""}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
className={`secondary ${selection === "exit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
@@ -188,30 +393,137 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
);
}
export function TopScreen({ onExit }: { onExit?: () => void }) {
function GoalPopup() {
const activityMode = useGameStore((state) => state.activityMode);
const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence);
const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide);
const lastSeenSequence = useRef(goalSequence);
const [visibleSequence, setVisibleSequence] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) {
lastSeenSequence.current = goalSequence;
setVisibleSequence(null);
return;
}
if (goalSequence === lastSeenSequence.current) return;
lastSeenSequence.current = goalSequence;
setVisibleSequence(goalSequence);
const timeout = window.setTimeout(() => {
setVisibleSequence((current) => current === goalSequence ? null : current);
}, 1_250);
return () => window.clearTimeout(timeout);
}, [activityMode, goalSequence]);
if (visibleSequence === null) return null;
return (
<div
key={visibleSequence}
className={`goal-popup ${lastGoalSide === "local" ? "is-conceded" : "is-scored"}`}
role="status"
aria-live="assertive"
aria-atomic="true"
>
<strong>GOAL</strong>
</div>
);
}
function DampeningIndicator() {
const activityMode = useGameStore((state) => state.activityMode);
const localBossKills = useGameStore((state) => state.endlessBossKills);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
if (activityMode !== "hockey-healing-pvp") return null;
const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills);
return (
<div className="dampening-indicator" role="status" aria-label={`${percent}% healing dampening`}>
<span>Dampening</span><strong>{percent}%</strong>
<i><em style={{ width: `${percent}%` }} /></i>
</div>
);
}
export function TopScreen({
onExit,
playerAppearance,
}: {
onExit?: () => void;
playerAppearance?: CharacterAppearanceV1;
}) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const roguelikePvp = useGameStore((state) => state.roguelikePvp);
const time = useGameStore((state) => state.time);
const setPaused = useGameStore((state) => state.setPaused);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const roguelikePvpMode = runMode === "roguelike-pvp";
const onlineRoguelikePvp = roguelikePvpMode && roguelikePvp.role !== "cpu";
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
const previousPhaseRef = useRef(phase);
useEffect(() => {
const previousPhase = previousPhaseRef.current;
previousPhaseRef.current = phase;
if (!roguelikePvpMode || !isSingleScreenLayout()) return;
if (phase === "intermission" && previousPhase !== "intermission") requestDisplaySurface("bottom");
if (previousPhase === "intermission" && phase === "combat") requestDisplaySurface("top");
}, [phase, roguelikePvpMode]);
return (
<section className="display top-display" aria-label="Main game viewport">
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
<GameScene playerAppearance={playerAppearance} />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{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 />
<DampeningIndicator />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <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>}
<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 ${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>
<SingleScreenAbilityBar />
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
<PhaseOverlay />
{runMode === "rpg-roguelike" && rpgRun && (
<RpgRunOverlay
run={rpgRun}
focusedId={rpgFocusId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
/>
)}
<PauseOverlay onExit={onExit} />
</section>
);
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
AETHER_ARMORED_SHIP_COLORS,
AETHER_STANDARD_SHIP_COLORS,
aetherShipColor,
aetherShipColorIndex,
} from "./aetherAssaultVisuals";
describe("Aether Assault ship colors", () => {
it("assigns a deterministic mixed palette across a formation", () => {
const first = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
const replay = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
expect(replay).toEqual(first);
expect(new Set(first).size).toBe(AETHER_STANDARD_SHIP_COLORS.length);
});
it("changes palette ordering between waves and keeps armored variants distinct", () => {
const waveOneIndex = aetherShipColorIndex(42, 1, 0);
const waveTwoIndex = aetherShipColorIndex(42, 2, 0);
expect(waveTwoIndex).not.toBe(waveOneIndex);
expect(aetherShipColor(42, 1, 0, "standard")).toBe(AETHER_STANDARD_SHIP_COLORS[waveOneIndex]);
expect(aetherShipColor(42, 1, 0, "armored")).toBe(AETHER_ARMORED_SHIP_COLORS[waveOneIndex]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AetherShipKind } from "../game/aetherAssault";
export const AETHER_STANDARD_SHIP_COLORS = [
"#20c8e8",
"#8a5cff",
"#f044b5",
"#31cf74",
"#f06b3c",
"#d6b91c",
] as const;
export const AETHER_ARMORED_SHIP_COLORS = [
"#9ef5ff",
"#d0b8ff",
"#ff9bdc",
"#9af0b8",
"#ffb088",
"#ffe976",
] as const;
export function aetherShipColorIndex(seed: number, wave: number, shipIndex: number) {
const normalizedSeed = Math.floor(Number.isFinite(seed) ? seed : 0) >>> 0;
const mixedSeed = (normalizedSeed ^ (normalizedSeed >>> 16)) >>> 0;
const normalizedWave = Math.max(1, Math.floor(Number.isFinite(wave) ? wave : 1));
const normalizedIndex = Math.max(0, Math.floor(Number.isFinite(shipIndex) ? shipIndex : 0));
return (mixedSeed + (normalizedWave - 1) * 3 + normalizedIndex * 5) % AETHER_STANDARD_SHIP_COLORS.length;
}
export function aetherShipColor(seed: number, wave: number, shipIndex: number, kind: AetherShipKind) {
const palette = kind === "armored" ? AETHER_ARMORED_SHIP_COLORS : AETHER_STANDARD_SHIP_COLORS;
return palette[aetherShipColorIndex(seed, wave, shipIndex)];
}
+16 -2
View File
@@ -8,7 +8,13 @@ import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
advanceBossIndicatorOpacity,
consumeBossIndicatorPatch,
createBossIndicatorPatchState,
updateBossIndicatorPatchState,
} from "./bossDeathVisuals";
import { CircleHazardVfx } from "./CircleHazardVfx";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
@@ -821,6 +827,7 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
const patchState = useRef(createBossIndicatorPatchState(defeated));
const [retainIndicators, setRetainIndicators] = useState(!defeated);
const patchMeshOpacity = useCallback((child: THREE.Object3D) => {
@@ -852,11 +859,18 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
return () => window.clearTimeout(timeout);
}, [defeated]);
useLayoutEffect(() => {
patchState.current = updateBossIndicatorPatchState(patchState.current, defeated);
}, [defeated]);
useFrame((_, delta) => {
if (!group.current) return;
if (patchState.current.pending) {
group.current.traverse(patchMeshOpacity);
patchState.current = consumeBossIndicatorPatch(patchState.current);
}
opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta);
group.current.visible = opacity.current > 0;
group.current.traverse(patchMeshOpacity);
});
return (
@@ -6,7 +6,12 @@ import {
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
consumeBossIndicatorPatch,
createBossIndicatorPatchState,
updateBossIndicatorPatchState,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
@@ -23,9 +28,32 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
it("patches indicator meshes once per defeat, including a replacement boss", () => {
let state = createBossIndicatorPatchState(false);
expect(state.pending).toBe(false);
state = updateBossIndicatorPatchState(state, true);
expect(state.pending).toBe(true);
state = consumeBossIndicatorPatch(state);
expect(state.pending).toBe(false);
expect(updateBossIndicatorPatchState(state, true)).toBe(state);
state = updateBossIndicatorPatchState(state, false);
expect(state.pending).toBe(false);
state = updateBossIndicatorPatchState(state, true);
expect(state.pending).toBe(true);
});
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
it("keeps Gravehorn visible through its full authored fall", () => {
const hold = bossDeathHoldSeconds("gravehorn-triceratops");
expect(hold).toBeGreaterThan(5.73);
expect(bossDeathOpacity(5.73, "gravehorn-triceratops")).toBe(1);
expect(bossDeathOpacity(bossDeathDespawnSeconds("gravehorn-triceratops"), "gravehorn-triceratops")).toBe(0);
});
});
+23
View File
@@ -2,11 +2,34 @@ export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export interface BossIndicatorPatchState {
defeated: boolean;
pending: boolean;
}
export function createBossIndicatorPatchState(defeated: boolean): BossIndicatorPatchState {
return { defeated, pending: defeated };
}
export function updateBossIndicatorPatchState(
state: BossIndicatorPatchState,
defeated: boolean,
): BossIndicatorPatchState {
if (state.defeated === defeated) return state;
return { defeated, pending: defeated };
}
export function consumeBossIndicatorPatch(state: BossIndicatorPatchState): BossIndicatorPatchState {
return state.pending ? { ...state, pending: false } : state;
}
export function bossCanTrackTarget(hp: number) {
return hp > 0;
}
@@ -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>
);
}
@@ -0,0 +1,171 @@
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "../../game/arena";
import { BOSS_ARENA_PORTAL_HALF_WIDTH } from "../../game/rpgRoguelike/playSpace";
export interface RpgRoomPortalsProps {
entryOpen: boolean;
exitOpen: boolean;
accent: string;
wallColor: string;
}
interface PortalDoorwayProps {
open: boolean;
accent: string;
wallColor: string;
position: readonly [number, number, number];
rotationY: number;
hideNearCamera?: boolean;
}
const DOOR_HEIGHT = 3.25;
const DOOR_DEPTH = 0.24;
const FRAME_DEPTH = 0.72;
const FRAME_POST_WIDTH = 0.48;
const OPEN_ANGLE = Math.PI * 0.56;
const OPEN_DAMPING = 8;
const CAMERA_HIDE_RADIUS = 3.6;
function DoorLeaf({ side, accent, wallColor }: {
side: "left" | "right";
accent: string;
wallColor: string;
}) {
const leafWidth = BOSS_ARENA_PORTAL_HALF_WIDTH;
const centerX = side === "left" ? leafWidth * 0.5 : -leafWidth * 0.5;
return (
<group position={[centerX, DOOR_HEIGHT * 0.5, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[leafWidth - 0.035, DOOR_HEIGHT, DOOR_DEPTH]} />
<meshStandardMaterial
color={wallColor}
emissive={accent}
emissiveIntensity={0.08}
metalness={0.36}
roughness={0.62}
/>
</mesh>
{[-0.9, 0, 0.9].map((y) => (
<mesh key={y} position={[0, y, DOOR_DEPTH * 0.56]}>
<boxGeometry args={[leafWidth - 0.12, 0.09, 0.055]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.4} metalness={0.7} roughness={0.3} />
</mesh>
))}
</group>
);
}
function PortalDoorway({
open,
accent,
wallColor,
position,
rotationY,
hideNearCamera = false,
}: PortalDoorwayProps) {
const root = useRef<THREE.Group>(null);
const leftHinge = useRef<THREE.Group>(null);
const rightHinge = useRef<THREE.Group>(null);
useFrame(({ camera }, delta) => {
if (root.current) {
const distanceToCamera = Math.hypot(
camera.position.x - position[0],
camera.position.z - position[2],
);
root.current.visible = !hideNearCamera || distanceToCamera >= CAMERA_HIDE_RADIUS;
}
if (!leftHinge.current || !rightHinge.current) return;
const reducedMotion = typeof document !== "undefined"
&& document.documentElement.classList.contains("force-reduced-motion");
const leftTarget = open ? OPEN_ANGLE : 0;
const rightTarget = -leftTarget;
if (reducedMotion) {
leftHinge.current.rotation.y = leftTarget;
rightHinge.current.rotation.y = rightTarget;
return;
}
leftHinge.current.rotation.y = THREE.MathUtils.damp(
leftHinge.current.rotation.y,
leftTarget,
OPEN_DAMPING,
delta,
);
rightHinge.current.rotation.y = THREE.MathUtils.damp(
rightHinge.current.rotation.y,
rightTarget,
OPEN_DAMPING,
delta,
);
});
const postX = BOSS_ARENA_PORTAL_HALF_WIDTH + FRAME_POST_WIDTH * 0.5;
return (
<group ref={root} position={position} rotation={[0, rotationY, 0]}>
{([-1, 1] as const).map((side) => (
<mesh key={side} position={[side * postX, DOOR_HEIGHT * 0.55, 0]} castShadow receiveShadow>
<boxGeometry args={[FRAME_POST_WIDTH, DOOR_HEIGHT * 1.1, FRAME_DEPTH]} />
<meshStandardMaterial color={wallColor} roughness={0.82} metalness={0.08} />
</mesh>
))}
<mesh position={[0, DOOR_HEIGHT, 0]} castShadow receiveShadow>
<torusGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH + 0.04, 0.24, 6, 20, Math.PI]} />
<meshStandardMaterial color={wallColor} roughness={0.78} metalness={0.1} />
</mesh>
<mesh position={[0, DOOR_HEIGHT + BOSS_ARENA_PORTAL_HALF_WIDTH + 0.06, 0.03]} rotation={[0, 0, Math.PI / 4]} castShadow>
<octahedronGeometry args={[0.34, 0]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.65} metalness={0.42} roughness={0.28} />
</mesh>
<mesh position={[0, 0.055, 0]} receiveShadow>
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 2 + FRAME_POST_WIDTH, 0.11, FRAME_DEPTH]} />
<meshStandardMaterial color={wallColor} roughness={0.88} />
</mesh>
<group ref={leftHinge} position={[-BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
<DoorLeaf side="left" accent={accent} wallColor={wallColor} />
</group>
<group ref={rightHinge} position={[BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
<DoorLeaf side="right" accent={accent} wallColor={wallColor} />
</group>
<group visible={!open} position={[0, DOOR_HEIGHT * 0.52, DOOR_DEPTH * 0.76]}>
<mesh castShadow>
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 1.3, 0.16, 0.12]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.48} metalness={0.72} roughness={0.24} />
</mesh>
<mesh position={[0, 0, 0.1]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.28, 0]} />
<meshBasicMaterial color={accent} toneMapped={false} />
</mesh>
</group>
</group>
);
}
/**
* Projects the linear RPG route through the boss arena: entry is the south
* portal (+Z), while the next-room exit is the north portal (-Z).
*/
export function RpgRoomPortals({ entryOpen, exitOpen, accent, wallColor }: RpgRoomPortalsProps) {
return (
<group>
<PortalDoorway
open={exitOpen}
accent={accent}
wallColor={wallColor}
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] - ARENA_WALL_RADIUS]}
rotationY={0}
/>
<PortalDoorway
open={entryOpen}
accent={accent}
wallColor={wallColor}
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] + ARENA_WALL_RADIUS]}
rotationY={Math.PI}
hideNearCamera
/>
</group>
);
}
@@ -0,0 +1,444 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { HEALER_ABILITIES } from "../../game/healers";
import {
ABILITY_LOADOUT_SLOTS,
MAX_ACTIVE_ROSTER,
MAX_EQUIPPED_SPELLS,
PARTY_DRAFT_WAVE_COUNT,
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
import {
ChallengeObjective,
createUiContext,
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
RunHeader,
runAccentStyle,
SpellCard,
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
readonly disabled: boolean;
readonly label: string;
readonly hint: string;
}) {
return (
<footer className="rpg-draft-footer">
<span>{hint}</span>
<FocusButton
context={context}
focusId={focusId}
command={{ type: "run-action", action }}
className="rpg-primary-action"
disabled={disabled}
>
{label}<b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</footer>
);
}
function PartyDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.partyDraft!;
const canRemove = draft.waveIndex > 0;
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER
&& draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
const canContinue = finalWave
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
return (
<section className="rpg-phase-panel rpg-party-draft" aria-labelledby="rpg-party-draft-title">
<div className="rpg-phase-title">
<div><small>Draft {draft.waveIndex + 1} / {PARTY_DRAFT_WAVE_COUNT}</small><h2 id="rpg-party-draft-title">Assemble your party</h2></div>
<p>Recruit up to {PARTY_RECRUITS_PER_WAVE} this wave. Four companions enter each room.</p>
<b>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</b>
</div>
<div className="rpg-party-offer-grid">
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = !recruited && !canRecruit;
const action = recruited
? { type: "party-remove", memberId: candidate.candidateId } as const
: { type: "party-recruit", candidateId: candidate.candidateId } as const;
return (
<PartyCard
key={candidate.candidateId}
candidate={candidate}
selected={recruited}
action={disabled ? <span className="rpg-card-status">Party full</span> : (
<FocusButton
context={context}
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
command={{ type: "run-action", action }}
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => (
<FocusButton
key={member.instanceId}
context={context}
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
>
<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>)}
</div>
<DraftFooter
context={context}
focusId={rpgFocusId.partyContinue}
action={{ type: "party-next-wave" }}
disabled={!canContinue}
label={finalWave ? "Lock Party" : "Reroll Offers"}
hint={finalWave && !canContinue ? `Recruit exactly ${MAX_ACTIVE_ROSTER} companions, including a tank.` : "Previous recruits stay; remove any companion before locking the run."}
/>
</section>
);
}
function SpellDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.spellDraft!;
const canRemove = draft.waveIndex > 0;
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS
&& draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
const canContinue = !finalWave || run.selectedSpellIds.length > 0;
return (
<section className="rpg-phase-panel rpg-spell-draft" aria-labelledby="rpg-spell-draft-title">
<div className="rpg-phase-title">
<div><small>Draft {draft.waveIndex + 1} / {SPELL_DRAFT_WAVE_COUNT}</small><h2 id="rpg-spell-draft-title">Build your spellbook</h2></div>
<p>Learn up to {SPELL_PICKS_PER_WAVE} spells. Abilities from every enabled healer class can mix.</p>
<b>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</b>
</div>
<div className="rpg-spell-offer-grid">
{draft.offers.map((spellId) => {
const selected = run.selectedSpellIds.includes(spellId);
const disabled = !selected && !canPick;
const action = selected
? { type: "spell-remove", spellId } as const
: { type: "spell-pick", spellId } as const;
return (
<SpellCard
key={spellId}
spellId={spellId}
selected={selected}
action={disabled ? <span className="rpg-card-status">Wave cap reached</span> : (
<FocusButton
context={context}
focusId={rpgFocusId.spellOffer(spellId)}
command={{ type: "run-action", action }}
className="rpg-card-action"
disabled={selected && !canRemove}
pressed={selected}
label={`${selected ? "Remove" : "Learn"} ${HEALER_ABILITIES[spellId].name}`}
><span>{selected ? canRemove ? "Remove" : "Locked" : "Learn"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-spellbook-strip" aria-label={`Spellbook, ${run.selectedSpellIds.length} of ${MAX_EQUIPPED_SPELLS}`}>
<strong>Spellbook {run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS}</strong>
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
.map(({ spellId, index }) => {
const spell = HEALER_ABILITIES[spellId];
return (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellSelected(spellId)}
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
className="rpg-spellbook-chip"
disabled={!canRemove}
style={runAccentStyle(spell.color)}
label={`Remove ${spell.name}`}
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}</span><small>×</small></FocusButton>
);
})}
</div>
<DraftFooter
context={context}
focusId={rpgFocusId.spellContinue}
action={{ type: "spell-next-wave" }}
disabled={!canContinue}
label={finalWave ? "Lock Spellbook" : "Reroll Spells"}
hint={draft.waveIndex > 0 ? "Remove drafted spells to make room for new magic." : "Each learned spell fills the next ability slot."}
/>
</section>
);
}
function Briefing({ context, kind }: { context: RpgRunUiContext; kind: "challenge" | "boss" }) {
const { run } = context;
const boss = currentBoss(run);
if (kind === "challenge") {
return (
<section className="rpg-phase-panel rpg-centered-panel" aria-labelledby="rpg-challenge-title">
<span className="rpg-hero-sigil"></span>
<small>Hallway challenge</small>
<h2 id="rpg-challenge-title">Prove the party before the next door</h2>
<ChallengeObjective run={run} />
<p className="rpg-soft-fail-copy">Failure does not end the run. Success adds gold and improves the next chest.</p>
<FocusButton context={context} focusId={rpgFocusId.challengeStart} command={{ type: "run-action", action: { type: "challenge-start" } }} className="rpg-primary-action rpg-large-action">
Start Challenge <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
return (
<section className="rpg-phase-panel rpg-centered-panel rpg-boss-briefing" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-boss-title">
<span className="rpg-hero-sigil">{boss?.icon ?? "♛"}</span>
<small>{run.bossIndex === run.bossRoute.length - 1 ? "Final encounter" : "Boss chamber"}</small>
<h2 id="rpg-boss-title">{boss?.name ?? "Unknown Guardian"}</h2>
<h3>{boss?.title}</h3>
<p>{boss?.briefing}</p>
{run.lastChallengeResult && (
<div className={`rpg-challenge-result ${run.lastChallengeResult.succeeded ? "is-success" : "is-failure"}`}>
{run.lastChallengeResult.succeeded ? `Challenge cleared · +${run.lastChallengeResult.objective.rewardCurrency} gold · upgraded chest` : "Challenge missed · standard chest remains"}
</div>
)}
<FocusButton context={context} focusId={rpgFocusId.bossStart} command={{ type: "run-action", action: { type: "boss-start" } }} className="rpg-primary-action rpg-large-action">
Enter Chamber <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
function BossCleared({ context }: { context: RpgRunUiContext }) {
const boss = currentBoss(context.run);
return (
<section className="rpg-phase-panel rpg-cleared-panel" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-cleared-title">
<div className="rpg-exit-arrow"><i /><span>North gate open</span></div>
<small>Boss defeated</small>
<h2 id="rpg-cleared-title">{boss?.name} has fallen</h2>
<p>Walk through the far door to claim the chest. Party health carries forward.</p>
<FocusButton context={context} focusId={rpgFocusId.rewardOpen} command={{ type: "run-action", action: { type: "reward-open" } }} className="rpg-primary-action rpg-large-action">
Open Chest <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
function Rewards({ context }: { context: RpgRunUiContext }) {
const chest = context.run.pendingReward;
if (!chest) return null;
return (
<section className="rpg-phase-panel rpg-reward-panel" aria-labelledby="rpg-reward-title">
<div className="rpg-phase-title">
<div><small>Chest quality +{chest.quality}</small><h2 id="rpg-reward-title">Choose one reward</h2></div>
<p>Rewards improve this run only. Gear auto-equips when stronger.</p>
</div>
<div className="rpg-reward-grid">
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
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-reward-card"
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>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton>
);
})}
</div>
<footer className="rpg-controller-hint"><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Claim</footer>
</section>
);
}
function Shop({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const shop = run.shop;
if (!shop) return null;
const needsRest = run.playerHp > 0 && run.playerHp < 100
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
const dead = run.roster.filter((member) => member.hp <= 0);
return (
<section className="rpg-phase-panel rpg-shop-panel" aria-labelledby="rpg-shop-title">
<div className="rpg-phase-title">
<div><small>Safe intermission</small><h2 id="rpg-shop-title">Wayfarer's Exchange</h2></div>
<p>Run gear auto-equips when stronger. Sell displaced gear from the bag.</p>
</div>
<div className="rpg-shop-layout">
<section><h3>Buy gear</h3><div className="rpg-shop-grid">
{shop.offers.map((offer) => {
const disabled = offer.sold || run.currency < offer.price;
return (
<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={disabled}
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>
} />
);
})}
</div></section>
<section><h3>Bag & services</h3><div className="rpg-shop-side-list">
{run.bag.length ? run.bag.map((item) => (
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-shop-row">
<span><small>{item.slotId} · +{item.enhancement}</small>{item.name}</span><b>Sell {item.sellPrice}</b>
</FocusButton>
)) : <p className="rpg-empty-copy">Bag empty. Replaced gear appears here.</p>}
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-shop-row" disabled={!needsRest || run.currency < shop.restCost}>
<span><small>Living members</small>Rest party</span><b> {shop.restCost}</b>
</FocusButton>
{dead.map((member) => (
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-shop-row" disabled={run.currency < shop.reviveCost}>
<span><small>Return at 50% HP</small>Revive {member.name}</span><b> {shop.reviveCost}</b>
</FocusButton>
))}
</div></section>
</div>
<DraftFooter context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} disabled={false} label="Leave Shop" hint="Next act begins after leaving." />
</section>
);
}
function Terminal({ context }: { context: RpgRunUiContext }) {
const victory = context.run.phase === "victory";
return (
<section className={`rpg-phase-panel rpg-centered-panel rpg-terminal ${victory ? "is-victory" : "is-defeat"}`} aria-labelledby="rpg-terminal-title">
<span className="rpg-hero-sigil">{victory ? "♛" : "◇"}</span>
<small>{victory ? "Expedition complete" : "Run ended"}</small>
<h2 id="rpg-terminal-title">{victory ? "The gauntlet is conquered" : "The party has fallen"}</h2>
<p>{context.run.bossesDefeated} bosses defeated · {context.run.selectedSpellIds.length} spells drafted · {context.run.currency} remaining</p>
<div className="rpg-terminal-actions">
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-primary-action rpg-large-action" disabled={!context.onRestartRun}>New Run</FocusButton>
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action rpg-large-action" disabled={!context.onExitRun}>Mode Select</FocusButton>
</div>
</section>
);
}
function CompactCombatHud({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const boss = currentBoss(run);
const cleared = run.phase === "boss-cleared";
return (
<section className="rpg-compact-combat-hud" aria-live="polite">
<div>
<small>{run.phase === "challenge-active" ? "Hallway challenge" : cleared ? "Room cleared" : "RPG Roguelike"}</small>
<strong>{run.phase === "challenge-active" ? run.currentChallenge?.objective.name : cleared ? "North gate open · cross it or confirm" : boss?.name}</strong>
</div>
{run.phase === "challenge-active" ? <ChallengeObjective run={run} compact /> : <RoutePips run={run} />}
</section>
);
}
/** Main-display overlay. Draft/reward/shop phases gate play; live phases render compact HUD only. */
export function RpgRunOverlay(props: RpgRunUiProps) {
const context = createUiContext(props);
const live = props.run.phase === "challenge-active" || props.run.phase === "boss-combat" || props.run.phase === "boss-cleared";
const overlayRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (live) return;
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
return () => {
const previous = previousFocusRef.current;
if (previous?.isConnected) previous.focus({ preventScroll: true });
previousFocusRef.current = null;
};
}, [live]);
useEffect(() => {
if (live) return;
const frame = requestAnimationFrame(() => {
const selected = overlayRef.current?.querySelector<HTMLButtonElement>("[data-rpg-focus].is-controller-selected:not(:disabled)");
selected?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [context.activeFocusId, live, props.run.phase]);
const trapModalTab = (event: KeyboardEvent<HTMLDivElement>) => {
if (live || event.key !== "Tab") return;
const buttons = [...(overlayRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? [])];
if (!buttons.length) {
event.preventDefault();
overlayRef.current?.focus({ preventScroll: true });
return;
}
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIndex = event.shiftKey
? currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1
: currentIndex < 0 || currentIndex === buttons.length - 1 ? 0 : currentIndex + 1;
event.preventDefault();
buttons[nextIndex].focus({ preventScroll: true });
};
return (
<div
ref={overlayRef}
className={`rpg-run-overlay ${live ? "is-live" : "is-gated"} ${props.className ?? ""}`.trim()}
role={live ? "status" : "dialog"}
aria-modal={live ? undefined : true}
aria-label="RPG roguelike run"
tabIndex={live ? undefined : -1}
onKeyDown={trapModalTab}
>
{live ? <CompactCombatHud context={context} /> : (
<>
<RunHeader run={props.run} />
<RoutePips run={props.run} />
{props.run.phase === "party-draft" && <PartyDraft context={context} />}
{props.run.phase === "spell-draft" && <SpellDraft context={context} />}
{props.run.phase === "challenge-briefing" && <Briefing context={context} kind="challenge" />}
{props.run.phase === "boss-briefing" && <Briefing context={context} kind="boss" />}
{props.run.phase === "reward" && <Rewards context={context} />}
{props.run.phase === "shop" && <Shop context={context} />}
{(props.run.phase === "victory" || props.run.phase === "defeat") && <Terminal context={context} />}
</>
)}
</div>
);
}
@@ -0,0 +1,419 @@
import type { CSSProperties } from "react";
import { HEALER_ABILITIES } from "../../game/healers";
import { ABILITY_CONTROLLER_BINDINGS } from "../../game/controllerBindings";
import {
ABILITY_LOADOUT_SLOTS,
MAX_ACTIVE_ROSTER,
MAX_EQUIPPED_SPELLS,
PARTY_DRAFT_WAVE_COUNT,
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
import {
ChallengeObjective,
createUiContext,
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
RunHeader,
runAccentStyle,
SpellCard,
type RpgRunUiContext,
type RpgLiveCombatUi,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<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">
{run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
<FocusButton
context={context}
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton>
) : undefined} />
))}
{!run.roster.length && <p className="rpg-empty-copy">Drafted companions appear here.</p>}
</div>
</section>
);
}
function TacticalSpellbook({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Spellbook</h3><span>{run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS} slots</span></header>
<div className="rpg-tactical-spell-grid">
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
.map(({ spellId, index }) => {
const spell = HEALER_ABILITIES[spellId];
return interactive ? (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellSelected(spellId)}
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
className="rpg-tactical-spell"
style={runAccentStyle(spell.color)}
label={`Remove ${spell.name}`}
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span><em>×</em></FocusButton>
) : (
<div key={spellId} className="rpg-tactical-spell" style={runAccentStyle(spell.color)}>
<b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span>
</div>
);
})}
{!run.selectedSpellIds.length && <p className="rpg-empty-copy">Drafted spells appear here.</p>}
</div>
</section>
);
}
function TacticalContinue({ context, focusId, action, label, disabled = false }: {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
readonly label: string;
readonly disabled?: boolean;
}) {
return (
<FocusButton context={context} focusId={focusId} command={{ type: "run-action", action }} className="rpg-tactical-continue" disabled={disabled}>
<span>{label}</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
);
}
function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.partyDraft!;
const canRemove = draft.waveIndex > 0;
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
const canContinue = finalWave
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
return (
<>
<section className="rpg-tactical-section rpg-tactical-offers">
<header><h3>Offers · wave {draft.waveIndex + 1}/{PARTY_DRAFT_WAVE_COUNT}</h3><span>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</span></header>
<div className="rpg-tactical-list">
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return (
<FocusButton
key={candidate.candidateId}
context={context}
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
command={{ type: "run-action", action: recruited ? { type: "party-remove", memberId: candidate.candidateId } : { type: "party-recruit", candidateId: candidate.candidateId } }}
className={`rpg-tactical-offer rarity-${candidate.rarity}`}
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
>
<i>{role.icon}</i>
<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>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton>
);
})}
</div>
</section>
<TacticalParty context={context} interactive={canRemove} />
<TacticalContinue context={context} focusId={rpgFocusId.partyContinue} action={{ type: "party-next-wave" }} label={finalWave ? "Lock party" : "Reroll offers"} disabled={!canContinue} />
</>
);
}
function TacticalSpellDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.spellDraft!;
const canRemove = draft.waveIndex > 0;
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
return (
<>
<section className="rpg-tactical-section rpg-tactical-offers">
<header><h3>Spells · wave {draft.waveIndex + 1}/{SPELL_DRAFT_WAVE_COUNT}</h3><span>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</span></header>
<div className="rpg-tactical-list">
{draft.offers.map((spellId) => {
const spell = HEALER_ABILITIES[spellId];
const selected = run.selectedSpellIds.includes(spellId);
const disabled = (selected && !canRemove) || (!selected && !canPick);
return (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellOffer(spellId)}
command={{ type: "run-action", action: selected ? { type: "spell-remove", spellId } : { type: "spell-pick", spellId } }}
className="rpg-tactical-offer is-spell"
style={runAccentStyle(spell.color)}
disabled={disabled}
pressed={selected}
>
<i>{spell.icon}</i><span><small>{spell.targeting} · {spell.mana} mana</small><strong>{spell.name}</strong><em>{spell.description}</em></span><u>{selected ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Learn"}</u>
</FocusButton>
);
})}
</div>
</section>
<TacticalSpellbook context={context} interactive={canRemove} />
<TacticalContinue context={context} focusId={rpgFocusId.spellContinue} action={{ type: "spell-next-wave" }} label={finalWave ? "Lock spellbook" : "Reroll spells"} disabled={finalWave && !run.selectedSpellIds.length} />
</>
);
}
function TacticalBriefing({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const challenge = run.phase === "challenge-briefing";
const boss = currentBoss(run);
return (
<>
<section className="rpg-tactical-hero" style={!challenge && boss ? runAccentStyle(boss.accent) : undefined}>
<i>{challenge ? "◇" : boss?.icon ?? "♛"}</i>
<small>{challenge ? "Hallway challenge" : "Boss chamber"}</small>
<h2>{challenge ? run.currentChallenge?.objective.name : boss?.name}</h2>
<p>{challenge ? "Success improves the next chest. Failure still opens the boss door." : boss?.briefing}</p>
</section>
{challenge && <ChallengeObjective run={run} />}
<TacticalParty context={context} />
<TacticalContinue
context={context}
focusId={challenge ? rpgFocusId.challengeStart : rpgFocusId.bossStart}
action={challenge ? { type: "challenge-start" } : { type: "boss-start" }}
label={challenge ? "Start challenge" : "Enter chamber"}
/>
</>
);
}
function TacticalLiveParty({ live }: { live: RpgLiveCombatUi }) {
return (
<section className="rpg-tactical-section rpg-live-party">
<header><h3>Live party</h3><span>{live.party.filter((member) => member.hp > 0).length}/{live.party.length} standing</span></header>
<div className="rpg-live-party-grid">
{live.party.map((member) => {
const health = Math.max(0, Math.min(100, member.hp / Math.max(1, member.maxHp) * 100));
const selected = member.id === live.selectedMemberId;
return (
<button
key={member.id}
type="button"
className={`${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`.trim()}
onClick={() => live.onSelectMember?.(member.id)}
disabled={member.hp <= 0}
aria-pressed={selected}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} of ${member.maxHp} health`}
>
<i style={{ "--rpg-member-color": member.color } as CSSProperties}>{member.name[0]}</i>
<span><strong>{member.name}</strong><small>{member.className}</small><em><b style={{ width: `${health}%` }} /></em></span>
<u>{member.hp <= 0 ? "DOWN" : `${Math.ceil(member.hp)} / ${member.maxHp}`}</u>
</button>
);
})}
</div>
</section>
);
}
function TacticalLiveSpellbook({ context, live }: { context: RpgRunUiContext; live: RpgLiveCombatUi }) {
const castProgress = live.activeCast
? Math.max(0, Math.min(1, (live.time - live.activeCast.startedAt) / Math.max(0.001, live.activeCast.completesAt - live.activeCast.startedAt)))
: 0;
return (
<section className="rpg-tactical-section rpg-live-spells">
<header><h3>Spell arsenal</h3><span>{Math.ceil(live.mana)} / {live.maxMana} mana</span></header>
<div className="rpg-live-resource-bar"><i style={{ width: `${Math.max(0, Math.min(100, live.mana / Math.max(1, live.maxMana) * 100))}%` }} /></div>
<div className="rpg-live-resources">
<span><b>Verdancy</b>{live.spellResources.verdancy}/5</span>
<span><b>Tidal Surge</b>{live.spellResources.tidalSurge}/2</span>
<span><b>Conviction</b>{live.spellResources.conviction}/3</span>
<span><b>Chronoshards</b>{live.spellResources.chronoshards}/3</span>
</div>
{live.activeCast && (
<div className="rpg-live-cast" style={{ "--rpg-cast-progress": `${castProgress * 100}%` } as CSSProperties}>
<span>Casting <b>{HEALER_ABILITIES[live.activeCast.abilityId].name}</b></span><i><em /></i>
</div>
)}
<div className="rpg-live-spell-grid">
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => {
const spellId = context.run.abilityLoadout[slotId];
if (!spellId) return <div key={slotId} className="rpg-live-spell is-empty"><b>{index + 1}</b><span>Empty</span></div>;
const spell = HEALER_ABILITIES[spellId];
const cooldown = Math.max(0, live.cooldowns[slotId] - live.time, live.globalCooldownUntil - live.time);
const selected = live.party.find((member) => member.id === live.selectedMemberId);
const disabled = !live.onCastAbility || live.activeCast !== null || cooldown > 0 || live.mana < spell.mana
|| (spell.targeting === "ally" && (!selected || selected.hp <= 0))
|| (spell.pulseKind === "cleanse" && !selected?.debuffs.length);
return (
<button
key={slotId}
type="button"
className={`rpg-live-spell ${cooldown > 0 ? "is-cooling" : ""}`}
style={{ "--rpg-accent": spell.color } as CSSProperties}
onClick={() => live.onCastAbility?.(slotId)}
disabled={disabled}
aria-label={`${spell.name}, ${cooldown > 0 ? `${cooldown.toFixed(1)} seconds remaining` : "ready"}`}
>
<b>{ABILITY_CONTROLLER_BINDINGS[slotId].glyph}</b><i>{spell.icon}</i><span><strong>{spell.shortName}</strong><small>{spell.mana} mana · Rank {context.run.spellRanks[spellId] ?? 0}</small></span>
<u>{cooldown > 0 ? cooldown.toFixed(1) : "Ready"}</u>
</button>
);
})}
</div>
</section>
);
}
function TacticalLive({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const boss = currentBoss(run);
const live = context.liveCombat;
return (
<>
{run.phase === "challenge-active" ? <ChallengeObjective run={run} /> : (
<section className="rpg-tactical-hero is-live" style={boss ? runAccentStyle(boss.accent) : undefined}>
<i>{boss?.icon ?? "♛"}</i><small>Boss battle</small><h2>{boss?.name}</h2><p>{boss?.summary}</p>
</section>
)}
{live ? <TacticalLiveParty live={live} /> : <TacticalParty context={context} />}
{live ? <TacticalLiveSpellbook context={context} live={live} /> : <TacticalSpellbook context={context} />}
<p className="rpg-controller-hint"><b>D-pad</b> Target <i /> <b>Face buttons</b> Cast</p>
</>
);
}
function TacticalCleared({ context }: { context: RpgRunUiContext }) {
return (
<>
<section className="rpg-tactical-hero rpg-tactical-cleared">
<i></i><small>North gate open</small><h2>Room cleared</h2><p>Cross the far doorway. Health and fallen companions carry forward.</p>
</section>
<TacticalParty context={context} />
<TacticalContinue context={context} focusId={rpgFocusId.rewardOpen} action={{ type: "reward-open" }} label="Open chest" />
</>
);
}
function TacticalRewards({ context }: { context: RpgRunUiContext }) {
const chest = context.run.pendingReward;
if (!chest) return null;
return (
<section className="rpg-tactical-section">
<header><h3>Choose one reward</h3><span>Chest +{chest.quality}</span></header>
<div className="rpg-tactical-rewards">
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
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)} 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>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton>
);
})}
</div>
</section>
);
}
function TacticalShop({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const shop = run.shop;
if (!shop) return null;
const needsRest = run.playerHp > 0 && run.playerHp < 100
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
return (
<>
<section className="rpg-tactical-section">
<header><h3>Buy gear</h3><span> {run.currency}</span></header>
<div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => (
<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={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton>
} />
))}
</div>
</section>
<section className="rpg-tactical-section">
<header><h3>Bag & services</h3><span>{run.bag.length} stored</span></header>
<div className="rpg-tactical-list">
{run.bag.map((item) => (
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-service-row">
<span><small>Sell gear</small>{item.name}</span><b> {item.sellPrice}</b>
</FocusButton>
))}
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-service-row" disabled={!needsRest || run.currency < shop.restCost}>
<span><small>Restore living party</small>Rest</span><b> {shop.restCost}</b>
</FocusButton>
{run.roster.filter((member) => member.hp <= 0).map((member) => (
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-service-row" disabled={run.currency < shop.reviveCost}>
<span><small>Return at 50% HP</small>Revive {member.name}</span><b> {shop.reviveCost}</b>
</FocusButton>
))}
</div>
</section>
<TacticalContinue context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} label="Leave shop" />
</>
);
}
function TacticalTerminal({ context }: { context: RpgRunUiContext }) {
const victory = context.run.phase === "victory";
return (
<section className={`rpg-tactical-terminal ${victory ? "is-victory" : "is-defeat"}`}>
<i>{victory ? "♛" : "◇"}</i><small>{victory ? "Run complete" : "Run ended"}</small><h2>{victory ? "Victory" : "Party fallen"}</h2>
<p>{context.run.bossesDefeated} bosses · {context.run.selectedSpellIds.length} spells · {context.run.currency}</p>
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-tactical-continue" disabled={!context.onRestartRun}><span>New run</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b></FocusButton>
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action" disabled={!context.onExitRun}>Mode select</FocusButton>
</section>
);
}
/** Secondary-display tactical projection. Same semantic focus IDs as top overlay. */
export function RpgRunTacticalPanel(props: RpgRunUiProps) {
const context = createUiContext(props);
const run = props.run;
return (
<aside className={`rpg-run-tactical ${props.className ?? ""}`.trim()} aria-label="RPG roguelike tactics">
<RunHeader run={run} compact />
<RoutePips run={run} />
<div className="rpg-tactical-body">
{run.phase === "party-draft" && <TacticalPartyDraft context={context} />}
{run.phase === "spell-draft" && <TacticalSpellDraft context={context} />}
{(run.phase === "challenge-briefing" || run.phase === "boss-briefing") && <TacticalBriefing context={context} />}
{(run.phase === "challenge-active" || run.phase === "boss-combat") && <TacticalLive context={context} />}
{run.phase === "boss-cleared" && <TacticalCleared context={context} />}
{run.phase === "reward" && <TacticalRewards context={context} />}
{run.phase === "shop" && <TacticalShop context={context} />}
{(run.phase === "victory" || run.phase === "defeat") && <TacticalTerminal context={context} />}
</div>
</aside>
);
}
@@ -0,0 +1,363 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
import { BOSS_DEFINITIONS } from "../../game/bossCatalog";
import { HEALER_ABILITIES } from "../../game/healers";
import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types";
import type {
PartyDraftCandidate,
PartyRosterMember,
RewardChoice,
RpgRoguelikeAction,
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
readonly focusedId?: string | null;
readonly onFocusChange?: (focusId: string) => void;
readonly onAction: (action: RpgRoguelikeAction) => void;
readonly onRestartRun?: () => void;
readonly onExitRun?: () => void;
readonly className?: string;
readonly liveCombat?: RpgLiveCombatUi;
}
export interface RpgLiveCombatUi {
readonly party: readonly PartyMember[];
readonly selectedMemberId: MemberId;
readonly mana: number;
readonly maxMana: number;
readonly cooldowns: Readonly<Record<AbilitySlotId, number>>;
readonly globalCooldownUntil: number;
readonly time: number;
readonly activeCast: ActiveCast | null;
readonly spellResources: {
readonly verdancy: number;
readonly tidalSurge: number;
readonly conviction: number;
readonly chronoshards: number;
};
readonly onSelectMember?: (memberId: MemberId) => void;
readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void;
}
export interface RpgRunUiContext extends RpgRunUiProps {
readonly activeFocusId: string | null;
}
export function createUiContext(props: RpgRunUiProps): RpgRunUiContext {
return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) };
}
export function runAccentStyle(accent: string): CSSProperties {
return { "--rpg-accent": accent } as CSSProperties;
}
export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void {
if (command.type === "run-action") context.onAction(command.action);
else if (command.type === "restart-run") context.onRestartRun?.();
else context.onExitRun?.();
}
interface FocusButtonProps {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly command: RpgUiCommand;
readonly className?: string;
readonly disabled?: boolean;
readonly pressed?: boolean;
readonly style?: CSSProperties;
readonly children: ReactNode;
readonly label?: string;
}
export function FocusButton({
context,
focusId,
command,
className = "",
disabled = false,
pressed,
style,
children,
label,
}: FocusButtonProps) {
const selected = context.activeFocusId === focusId;
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const button = buttonRef.current;
if (!selected || !button) return;
let parent = button.parentElement;
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
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;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
parent = parent.parentElement;
}
}, [selected]);
return (
<button
ref={buttonRef}
type="button"
className={`${className} ${selected ? "is-controller-selected" : ""}`.trim()}
data-rpg-focus={focusId}
aria-current={selected ? "true" : undefined}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
style={style}
onFocus={() => context.onFocusChange?.(focusId)}
onPointerEnter={() => !disabled && context.onFocusChange?.(focusId)}
onClick={() => executeUiCommand(context, command)}
>
{children}
</button>
);
}
export function phaseLabel(run: RpgRoguelikeRunState): string {
switch (run.phase) {
case "party-draft": return "Party Draft";
case "spell-draft": return "Spell Draft";
case "challenge-briefing": return "Hallway Challenge";
case "challenge-active": return "Challenge Active";
case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door";
case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle";
case "boss-cleared": return "Room Cleared";
case "reward": return "Reward Chest";
case "shop": return `Act ${run.shop?.act ?? 1} Intermission`;
case "victory": return "Run Complete";
case "defeat": return "Party Fallen";
}
}
export function routeLabel(run: RpgRoguelikeRunState): string {
if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale";
return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`;
}
export function currentBoss(run: RpgRoguelikeRunState) {
const bossId = run.bossRoute[run.bossIndex];
return bossId ? BOSS_DEFINITIONS[bossId] : null;
}
export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
return (
<header className={`rpg-run-header ${compact ? "is-compact" : ""}`.trim()}>
<div>
<small>{routeLabel(run)}</small>
<strong>{phaseLabel(run)}</strong>
</div>
<div className="rpg-run-resources" aria-label={`${run.currency} gold, ${Math.ceil(run.playerHp)} healer health, ${run.bossesDefeated} bosses defeated`}>
<span><i></i>{run.currency}</span>
<span><i></i>{Math.ceil(run.playerHp)}</span>
<span><i></i>{run.bossesDefeated}/{TOTAL_BOSS_COUNT}</span>
</div>
</header>
);
}
export function RoutePips({ run }: { run: RpgRoguelikeRunState }) {
return (
<div className="rpg-route-pips" aria-label={`${run.bossesDefeated} of ${TOTAL_BOSS_COUNT} bosses defeated`}>
{run.bossRoute.map((bossId, index) => (
<i
key={`${bossId}-${index}`}
className={`${index < run.bossesDefeated ? "is-cleared" : ""} ${index === run.bossIndex ? "is-current" : ""} ${index === TOTAL_BOSS_COUNT - 1 ? "is-finale" : ""}`.trim()}
title={BOSS_DEFINITIONS[bossId].name}
/>
))}
</div>
);
}
function titleCase(value: string): string {
return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
}
export function PartyCard({
member,
candidate,
selected = false,
compact = false,
action,
}: {
readonly member?: PartyRosterMember;
readonly candidate?: PartyDraftCandidate;
readonly selected?: boolean;
readonly compact?: boolean;
readonly action?: ReactNode;
}) {
const entry = member ?? candidate;
if (!entry) return null;
const hp = member?.hp ?? entry.stats.maxHp;
const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100));
return (
<article
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3>
<p>{entry.className}</p>
{!compact && (
<div className="rpg-stat-row" aria-label="Combat stats">
<span><small>HP</small>{entry.stats.maxHp}</span>
<span><small>ST</small>{entry.stats.singleTarget.toFixed(2)}</span>
<span><small>AOE</small>{entry.stats.areaDamage.toFixed(2)}</span>
<span><small>DEF</small>{entry.stats.defense.toFixed(2)}</span>
</div>
)}
{member && (
<div className="rpg-vital-bar" aria-label={`${Math.round(hp)} of ${entry.stats.maxHp} health`}>
<i style={{ width: `${hpPercent}%` }} />
<small>{hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`}</small>
</div>
)}
{!compact && entry.traitIds.length > 0 && <footer>{entry.traitIds.map(titleCase).join(" · ")}</footer>}
{action}
</article>
);
}
export function SpellCard({
spellId,
rank = 0,
selected = false,
compact = false,
action,
}: {
readonly spellId: HealerAbilityId;
readonly rank?: number;
readonly selected?: boolean;
readonly compact?: boolean;
readonly action?: ReactNode;
}) {
const spell = HEALER_ABILITIES[spellId];
return (
<article
className={`rpg-spell-card ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(spell.color)}
>
<i className="rpg-spell-icon">{spell.icon}</i>
<div>
<small>{spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""}</small>
<h3>{spell.name}</h3>
{!compact && <p>{spell.description}</p>}
</div>
{action}
</article>
);
}
export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string {
if (item.ownerId === "player") return "Healer";
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 }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
readonly price?: number;
readonly action?: ReactNode;
}) {
return (
<article className="rpg-gear-card">
<i>{item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"}</i>
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3>
{price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div>
{action}
</article>
);
}
export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } {
if (choice.kind === "spell-rank") {
const spell = HEALER_ABILITIES[choice.spellId];
return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color };
}
if (choice.kind === "member-rarity") {
return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" };
}
if (choice.kind === "run-gear") {
return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" };
}
return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" };
}
export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
const challenge = run.currentChallenge;
const previous = run.lastChallengeResult;
if (!challenge && !previous) return null;
const objective = challenge?.objective ?? previous!.objective;
const metrics = challenge?.metrics ?? previous!.metrics;
const current = metrics[objective.metric];
const progress = Math.max(0, Math.min(100, (current / objective.target) * 100));
const copy = objective.challengeId === "blockbreaker"
? `Break ${objective.target} bricks before the board falls.`
: objective.challengeId === "hockey"
? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.`
: `Defeat ${objective.target} enemies before the assault ends.`;
return (
<section className={`rpg-challenge-objective ${compact ? "is-compact" : ""}`.trim()}>
<div><small>Repeat tier {objective.repeatIndex + 1}</small><strong>{objective.name}</strong></div>
{!compact && <p>{copy}</p>}
<div className="rpg-objective-progress">
<i style={{ width: `${progress}%` }} />
<span>{current} / {objective.target}</span>
</div>
{!compact && <footer>Success: {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}</footer>}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { RpgRoomPortals, type RpgRoomPortalsProps } from "./RpgRoomPortals";
export { RpgRunOverlay } from "./RpgRunOverlay";
export { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
export type { RpgRunUiProps } from "./RpgRunUiShared";
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import { bossDeathDespawnSeconds } from "../game/bossDeath";
import {
OUTCOME_RENDER_TAIL_SECONDS,
activePlayfieldKind,
consumeSimulationSteps,
outcomeElapsedAfterPhaseChange,
resetSceneClockForMode,
sceneCanvasFrameloop,
sceneFrameIntervalMs,
selectSceneRenderMode,
startSceneFrameLoop,
summarizeFramePerformance,
type SceneFrameSample,
} from "./sceneFramePolicy";
function createFakeFrames() {
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
requestFrame(callback: FrameRequestCallback) {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame(handle: number) {
callbacks.delete(handle);
},
runNext(now: number) {
const entry = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined;
if (!entry) return false;
callbacks.delete(entry[0]);
entry[1](now);
return true;
},
pending() {
return callbacks.size;
},
};
}
describe("scene render policy", () => {
it("selects active, outcome, static, and suspended modes", () => {
expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: true, outcomeTailComplete: false })).toBe("active");
expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: false })).toBe("outcome");
expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: true })).toBe("static");
expect(selectSceneRenderMode({ phase: "briefing", paused: false, visible: true, outcomeTailComplete: false })).toBe("static");
expect(selectSceneRenderMode({ phase: "combat", paused: true, visible: true, outcomeTailComplete: false })).toBe("static");
expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: false, outcomeTailComplete: false })).toBe("suspended");
});
it("keeps simulation fixed at 10 Hz and caps delayed catch-up at three steps", () => {
expect(consumeSimulationSteps(0, 0.09)).toEqual({ steps: 0, remainderSeconds: 0.09 });
const oneStep = consumeSimulationSteps(0.09, 0.02);
expect(oneStep.steps).toBe(1);
expect(oneStep.remainderSeconds).toBeCloseTo(0.01);
const delayed = consumeSimulationSteps(0.09, 1);
expect(delayed.steps).toBe(3);
expect(delayed.remainderSeconds).toBeCloseTo(0.04);
});
it("has no continuous interval for static or suspended scenes", () => {
expect(sceneCanvasFrameloop("active")).toBe("demand");
expect(sceneCanvasFrameloop("outcome")).toBe("demand");
expect(sceneCanvasFrameloop("static")).toBe("demand");
expect(sceneCanvasFrameloop("suspended")).toBe("never");
expect(sceneFrameIntervalMs("static")).toBeNull();
expect(sceneFrameIntervalMs("suspended")).toBeNull();
});
it("resets the demand clock before active and outcome frame requests", () => {
const start = vi.fn();
for (const mode of ["static", "suspended", "active", "outcome"] as const) {
resetSceneClockForMode({ start }, mode);
}
expect(start).toHaveBeenCalledTimes(2);
});
it("mounts only the selected special playfield", () => {
expect(activePlayfieldKind("boss")).toBeNull();
expect(activePlayfieldKind("hockey-healing")).toBe("hockey-healing");
expect(activePlayfieldKind("blockbreaker")).toBe("blockbreaker");
expect(activePlayfieldKind("aether-assault")).toBe("aether-assault");
expect(activePlayfieldKind("hockey-healing-pvp")).toBe("hockey-healing-pvp");
});
it("keeps outcome rendering longer than the longest authored boss death", () => {
expect(OUTCOME_RENDER_TAIL_SECONDS).toBeGreaterThan(bossDeathDespawnSeconds("gravehorn-triceratops"));
});
it("restarts the outcome tail when the outcome phase identity changes", () => {
expect(outcomeElapsedAfterPhaseChange("victory", "victory", 3.5)).toBe(3.5);
expect(outcomeElapsedAfterPhaseChange("victory", "intermission", 3.5)).toBe(0);
expect(outcomeElapsedAfterPhaseChange("defeat", null, 3.5)).toBe(0);
});
});
describe("scene frame request loop", () => {
it("caps active rendering at 60 FPS", () => {
const fake = createFakeFrames();
const samples: SceneFrameSample[] = [];
const stop = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
});
expect(fake.runNext(0)).toBe(true);
expect(fake.runNext(8)).toBe(true);
expect(fake.runNext(16.7)).toBe(true);
expect(fake.runNext(25)).toBe(true);
expect(fake.runNext(33.4)).toBe(true);
expect(samples).toHaveLength(3);
expect(samples.map((sample) => sample.elapsedSeconds)).toEqual([0, 0.0167, 0.0167]);
stop();
expect(fake.pending()).toBe(0);
});
it("renders an outcome for seven animation seconds without simulation ownership", () => {
const fake = createFakeFrames();
const completed = vi.fn();
const samples: SceneFrameSample[] = [];
startSceneFrameLoop({
mode: "outcome",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
onOutcomeComplete: completed,
});
fake.runNext(0);
let now = 0;
while (fake.pending() && now < 8_000) {
now += 1000 / 30;
fake.runNext(now);
}
expect(completed).toHaveBeenCalledTimes(1);
expect(samples.at(-1)?.outcomeElapsedSeconds).toBeGreaterThanOrEqual(OUTCOME_RENDER_TAIL_SECONDS);
expect(fake.pending()).toBe(0);
});
it("finishes an outcome after seven visible wall seconds when RAF is throttled", () => {
const fake = createFakeFrames();
const completed = vi.fn();
const samples: SceneFrameSample[] = [];
startSceneFrameLoop({
mode: "outcome",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
onOutcomeComplete: completed,
});
fake.runNext(0);
for (let now = 1_000; now <= 7_000; now += 1_000) fake.runNext(now);
expect(completed).toHaveBeenCalledTimes(1);
expect(samples.at(-1)?.outcomeElapsedSeconds).toBe(7);
expect(fake.pending()).toBe(0);
});
it("cancels immediately and resumes without hidden-time catch-up", () => {
const fake = createFakeFrames();
const beforeHide: SceneFrameSample[] = [];
const stop = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => beforeHide.push(sample),
});
fake.runNext(100);
fake.runNext(117);
stop();
expect(fake.pending()).toBe(0);
const afterResume: SceneFrameSample[] = [];
const stopResumed = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => afterResume.push(sample),
});
fake.runNext(60_000);
expect(beforeHide.at(-1)?.elapsedSeconds).toBeCloseTo(0.017);
expect(afterResume[0]).toMatchObject({
elapsedMs: 0,
elapsedSeconds: 0,
});
stopResumed();
});
});
describe("performance summaries", () => {
it("uses mode-specific targets and only counts active missed frames", () => {
const active = summarizeFramePerformance("active", [16, 18, 20]);
expect(active.targetFps).toBe(60);
expect(active.overBudget).toBe(1);
const outcome = summarizeFramePerformance("outcome", [32, 35]);
expect(outcome.targetFps).toBe(30);
expect(outcome.budgetMs).toBeCloseTo(1000 / 30);
expect(outcome.overBudget).toBe(0);
expect(summarizeFramePerformance("static", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 });
expect(summarizeFramePerformance("suspended", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 });
});
});
+208
View File
@@ -0,0 +1,208 @@
import type * as THREE from "three";
import type { GamePhase, GameplayActivity } from "../game/types";
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
// Visible modes stay in R3F's demand clock domain. Suspended mode blocks loader
// and host-commit invalidations as well as the explicit gameplay request loop.
export function sceneCanvasFrameloop(mode: SceneRenderMode): "demand" | "never" {
return mode === "suspended" ? "never" : "demand";
}
export function resetSceneClockForMode(clock: Pick<THREE.Clock, "start">, mode: SceneRenderMode) {
if (mode === "active" || mode === "outcome") clock.start();
}
export const GAMEPLAY_FRAME_INTERVAL_MS = 1_000 / 60;
export const OUTCOME_FRAME_INTERVAL_MS = 1_000 / 30;
export const FRAME_INTERVAL_JITTER_MS = 1.5;
export const SIMULATION_STEP_SECONDS = 0.1;
export const MAX_SIMULATION_STEPS_PER_FRAME = 3;
export const MAX_FRAME_DELTA_SECONDS = 0.25;
export const OUTCOME_RENDER_TAIL_SECONDS = 7;
export interface SceneRenderState {
phase: GamePhase;
paused: boolean;
visible: boolean;
outcomeTailComplete: boolean;
}
export function isOutcomePhase(phase: GamePhase): phase is OutcomePhase {
return phase === "victory" || phase === "defeat" || phase === "intermission";
}
export function outcomeElapsedAfterPhaseChange(
previousPhase: OutcomePhase | null,
nextPhase: OutcomePhase | null,
elapsedSeconds: number,
) {
return previousPhase === nextPhase ? Math.max(0, elapsedSeconds) : 0;
}
export function selectSceneRenderMode({
phase,
paused,
visible,
outcomeTailComplete,
}: SceneRenderState): SceneRenderMode {
if (!visible) return "suspended";
if (phase === "combat" && !paused) return "active";
if (isOutcomePhase(phase) && !outcomeTailComplete) return "outcome";
return "static";
}
export function sceneFrameIntervalMs(mode: SceneRenderMode) {
if (mode === "active") return GAMEPLAY_FRAME_INTERVAL_MS;
if (mode === "outcome") return OUTCOME_FRAME_INTERVAL_MS;
return null;
}
export function sceneTargetFps(mode: SceneRenderMode) {
if (mode === "active") return 60;
if (mode === "outcome") return 30;
return 0;
}
export interface SimulationStepResult {
steps: number;
remainderSeconds: number;
}
export function consumeSimulationSteps(
accumulatorSeconds: number,
elapsedSeconds: number,
): SimulationStepResult {
const boundedElapsed = Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, elapsedSeconds));
let remainderSeconds = Math.max(0, accumulatorSeconds) + boundedElapsed;
const steps = Math.min(
MAX_SIMULATION_STEPS_PER_FRAME,
Math.floor((remainderSeconds + Number.EPSILON) / SIMULATION_STEP_SECONDS),
);
remainderSeconds -= steps * SIMULATION_STEP_SECONDS;
return { steps, remainderSeconds };
}
export interface SceneFrameSample {
elapsedMs: number;
elapsedSeconds: number;
outcomeElapsedSeconds: number;
}
export interface SceneFrameLoopOptions {
mode: "active" | "outcome";
initialOutcomeElapsedSeconds?: number;
requestFrame: (callback: FrameRequestCallback) => number;
cancelFrame: (handle: number) => void;
onFrame: (sample: SceneFrameSample) => void;
onOutcomeComplete?: () => void;
}
/**
* Starts the bounded request loop used for active gameplay and finite outcome
* animation tails. Static and suspended modes intentionally have no loop.
*/
export function startSceneFrameLoop({
mode,
initialOutcomeElapsedSeconds = 0,
requestFrame,
cancelFrame,
onFrame,
onOutcomeComplete,
}: SceneFrameLoopOptions) {
const intervalMs = sceneFrameIntervalMs(mode)!;
let frameHandle: number | null = null;
let stopped = false;
let lastRenderedAt: number | null = null;
let outcomeElapsedSeconds = mode === "outcome" ? Math.max(0, initialOutcomeElapsedSeconds) : 0;
const scheduleNext = () => {
frameHandle = requestFrame(runFrame);
};
const runFrame: FrameRequestCallback = (now) => {
if (stopped) return;
const previous = lastRenderedAt;
if (previous === null) {
lastRenderedAt = now;
onFrame({ elapsedMs: 0, elapsedSeconds: 0, outcomeElapsedSeconds });
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= intervalMs) {
lastRenderedAt = now;
const wallElapsedSeconds = Math.max(0, elapsedMs / 1_000);
const elapsedSeconds = mode === "active"
? Math.min(MAX_FRAME_DELTA_SECONDS, wallElapsedSeconds)
: wallElapsedSeconds;
if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds;
onFrame({ elapsedMs, elapsedSeconds, outcomeElapsedSeconds });
if (mode === "outcome" && outcomeElapsedSeconds >= OUTCOME_RENDER_TAIL_SECONDS) {
stopped = true;
frameHandle = null;
onOutcomeComplete?.();
return;
}
}
}
scheduleNext();
};
scheduleNext();
return () => {
stopped = true;
if (frameHandle !== null) cancelFrame(frameHandle);
frameHandle = null;
};
}
export type ActivePlayfieldKind = Exclude<GameplayActivity, "boss">;
export function activePlayfieldKind(activity: GameplayActivity): ActivePlayfieldKind | null {
return activity === "boss" ? null : activity;
}
export interface FramePerformanceSummary {
mode: SceneRenderMode;
targetFps: number;
budgetMs: number | null;
averageMs: number;
p95Ms: number;
p99Ms: number;
overBudget: number;
samples: number;
}
function percentile(sorted: readonly number[], ratio: number) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
export function summarizeFramePerformance(
mode: SceneRenderMode,
samples: readonly number[],
): FramePerformanceSummary {
const targetFps = sceneTargetFps(mode);
const budgetMs = targetFps > 0 ? 1_000 / targetFps : null;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (mode === "active" && budgetMs !== null && duration > budgetMs + FRAME_INTERVAL_JITTER_MS) overBudget += 1;
}
return {
mode,
targetFps,
budgetMs,
averageMs: samples.length ? total / samples.length : 0,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
};
}
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from "vitest";
import { createDefaultHealerAppearance } from "../game/healerVisuals";
import { hasPendingSaveSync } from "./saveSync";
import { getFrontendSnapshot, useFrontendStore } from "./store";
const originalState = useFrontendStore.getState();
afterEach(() => {
useFrontendStore.getState().deleteSlot(3);
useFrontendStore.setState({
activeSlotId: originalState.activeSlotId,
selectedSlotId: originalState.selectedSlotId,
screen: originalState.screen,
appearanceClassId: originalState.appearanceClassId,
appearanceDrafts: structuredClone(originalState.appearanceDrafts),
previewMode: originalState.previewMode,
previewAnimation: originalState.previewAnimation,
notice: originalState.notice,
});
});
describe("Appearance Lab frontend state", () => {
it("opens from saved looks, previews drafts, saves explicitly, and cancels unsaved edits", () => {
const frontend = useFrontendStore.getState();
frontend.deleteSlot(3);
expect(frontend.createSlot(3, "Wardrobe Tester")).toBe(true);
frontend.playSlot(3);
useFrontendStore.getState().openAppearanceLab();
let state = useFrontendStore.getState();
const savedPriest = state.slots[2].local!.healers.priest.appearance;
expect(state.screen).toBe("appearance");
expect(state.appearanceClassId).toBe("priest");
expect(state.appearanceDrafts.priest).toEqual(savedPriest);
expect(state.appearanceDrafts.priest).not.toBe(savedPriest);
expect(state.previewMode).toBe("modular");
expect(state.previewAnimation).toBe("idle");
const mixedPriest = {
...state.appearanceDrafts.priest,
headPartId: "rogue-head" as const,
};
state.updateAppearanceDraft(mixedPriest);
state.setAppearancePreviewMode("legacy");
state.setAppearancePreviewAnimation("cast");
state = useFrontendStore.getState();
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe(
createDefaultHealerAppearance("priest").headPartId,
);
expect(state.previewMode).toBe("legacy");
expect(state.previewAnimation).toBe("cast");
expect(state.saveAppearanceDraft()).toBe(true);
state = useFrontendStore.getState();
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
expect(hasPendingSaveSync(3)).toBe(true);
state.updateAppearanceDraft({
...state.appearanceDrafts.priest,
headPartId: "ranger-head",
});
useFrontendStore.getState().closeAppearanceLab();
state = useFrontendStore.getState();
expect(state.screen).toBe("home");
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
});
it("resets only the selected class and includes state but no actions in snapshots", () => {
useFrontendStore.setState((state) => ({
appearanceClassId: "paladin",
appearanceDrafts: {
...state.appearanceDrafts,
paladin: { ...state.appearanceDrafts.paladin, headPartId: "rogue-head" },
},
previewMode: "legacy",
previewAnimation: "walk",
}));
useFrontendStore.getState().resetAppearanceDraft();
const snapshot = getFrontendSnapshot();
expect(snapshot.appearanceClassId).toBe("paladin");
expect(snapshot.appearanceDrafts.paladin).toEqual(createDefaultHealerAppearance("paladin"));
expect(snapshot.previewMode).toBe("legacy");
expect(snapshot.previewAnimation).toBe("walk");
expect("openAppearanceLab" in snapshot).toBe(false);
expect("updateAppearanceDraft" in snapshot).toBe(false);
expect("saveAppearanceDraft" in snapshot).toBe(false);
expect("setAppearancePreviewAnimation" in snapshot).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { getFrontendSnapshot, useFrontendStore } from "./store";
const originalState = useFrontendStore.getState();
afterEach(() => {
useFrontendStore.setState({
activeSlotId: originalState.activeSlotId,
screen: originalState.screen,
guideClassId: originalState.guideClassId,
guideAbilityId: originalState.guideAbilityId,
notice: originalState.notice,
});
});
describe("Class Help frontend state", () => {
it("opens on ability one, resets selection on class change, and syncs guide state", () => {
useFrontendStore.setState({
activeSlotId: null,
screen: "home",
guideClassId: "shaman",
guideAbilityId: "ability6",
notice: "Old notice",
});
useFrontendStore.getState().openClassHelp();
let state = useFrontendStore.getState();
expect(state.screen).toBe("class-help");
expect(state.guideClassId).toBe("shaman");
expect(state.guideAbilityId).toBe("ability1");
expect(state.notice).toBe("");
state.selectGuideClass("paladin");
state.selectGuideAbility("ability5");
const snapshot = getFrontendSnapshot();
expect(snapshot.guideClassId).toBe("paladin");
expect(snapshot.guideAbilityId).toBe("ability5");
expect("openClassHelp" in snapshot).toBe(false);
expect("selectGuideClass" in snapshot).toBe(false);
expect("selectGuideAbility" in snapshot).toBe(false);
});
});
+17 -3
View File
@@ -1,12 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
import { buildCollections, createHunterSave, MODE_COPY, selectRandomBoss } from "./data";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals";
import { selectRandomBossPair } from "../game/roguelike";
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
it("initializes progression for every playable healer", () => {
const save = createHunterSave(1, "2026-07-16T00:00:00.000Z", "Aelia");
expect(Object.keys(save.healers)).toEqual(HEALER_CLASS_ORDER);
expect(save.healers.paladin.inventory.length).toBeGreaterThan(0);
expect(save.healers.chronomancer.inventory.length).toBeGreaterThan(0);
for (const classId of HEALER_CLASS_ORDER) {
expect(save.healers[classId].appearance).toEqual(HEALER_VISUAL_PROFILES[classId].appearance);
expect(save.healers[classId].appearance).not.toBe(HEALER_VISUAL_PROFILES[classId].appearance);
}
});
it("separates RPG Roguelike from Rogue Trials and selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("RPG Roguelike");
expect(MODE_COPY["roguelike-pve"].description).toContain("Draft");
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
});
+59 -15
View File
@@ -1,9 +1,11 @@
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
import type { BossId } from "../game/types";
import { createDefaultGearProgress } from "../game/progression/gear";
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
import { createDefaultHealerAppearance } from "../game/healerVisuals";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
@@ -66,10 +68,10 @@ export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAU
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
eyebrow: "14 hunters · randomized PVE",
title: "PVE",
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
detail: "Two bosses selected when the run begins",
eyebrow: "Solo healer · drafted RPG expedition",
title: "RPG Roguelike",
description: "Draft a random four-companion party and mixed healing spellbook, clear escalating arcade hallways, defeat ten bosses, and build a run-only loadout.",
detail: "Three acts, three shops, and a final guardian",
status: "Playable now",
},
"rogue-trials": {
@@ -86,12 +88,40 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
status: "Playable now",
},
"hockey-healing": {
eyebrow: "14 hunters · endless healer pressure",
title: "Hockey Healing",
description: "Defend a wide goal while healing through two active bosses. Aim returns past a moving Pong paddle that strikes back.",
detail: "Every fallen boss drops loot, rolls its pet chance, then receives a replacement",
status: "Playable now",
},
"hockey-healing-pvp": {
eyebrow: "1v1 healer duel · online queue",
title: "Healing Hockey PVP",
description: "Defend your net, keep your party alive, and race a rival through the same endless boss order in a mirrored hockey arena.",
detail: "Goals deal 45 partywide damage · 5% Dampening per boss · normalized base gear",
status: "Playable now",
},
blockbreaker: {
eyebrow: "14 hunters · endless color-break PVE",
title: "Blockbreaker",
description: "Heal through two endless bosses while aiming a puck into advancing rows of linked color bricks.",
detail: `Breaches deal ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member · survive while four allies stand`,
status: "Playable now",
},
"aether-assault": {
eyebrow: "14 hunters · endless movement-only arcade PVE",
title: "Aether Assault",
description: "Move freely and heal through endless bosses while automatic spellfire cuts through arcane ship formations.",
detail: "No extra buttons · fixed-forward auto-fire · ship strikes damage only the healer",
status: "Playable now",
},
"roguelike-pvp": {
eyebrow: "3v3 · mirrored expeditions",
eyebrow: "1v1 healer duel · five-person mirrored survival",
title: "Roguelike PvP",
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
detail: "Draft order, rival pressure, and sudden-death rules",
status: "Mode shell ready",
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: "Seeded encounters · secret buff-and-curse drafts · normalized base gear · CPU fallback",
status: "Playable now",
},
"stadium-pvp": {
eyebrow: "5v5 · objective arena",
@@ -120,15 +150,15 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 5,
schemaVersion: 7,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
healers: {
priest: { level: 1, inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
},
healers: Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [classId, {
level: 1,
inventory: createClassInventory(classId),
appearance: createDefaultHealerAppearance(classId),
}])) as HunterSave["healers"],
location: "Ember Vault Approach",
playSeconds: 0,
updatedAt: now,
@@ -140,6 +170,20 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
highestHockeyHealingReturns: 0,
longestHockeyHealingSecondsAtBest: 0,
hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0,
roguelikePvpWins: 0,
roguelikePvpLosses: 0,
highestRoguelikePvpRound: 0,
highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0,
highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0,
longestAetherAssaultSecondsAtBest: 0,
},
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
+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);
},
};
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { LeaderboardCache } from "./leaderboardCache";
import type { LeaderboardResult } from "./onlineRepository";
function memoryStorage() {
const values = new Map<string, string>();
return {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
};
}
const result: LeaderboardResult = {
kind: "roguelike",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 },
};
describe("leaderboard cache", () => {
it("persists rankings for signed-in and offline reads", () => {
const storage = memoryStorage();
const cache = new LeaderboardCache(storage, () => "2026-07-14T00:00:00.000Z");
cache.write("hunter", "Aelia", 1, "roguelike", result);
expect(cache.read(1, "roguelike", "Aelia", "hunter")?.result).toEqual(result);
expect(cache.read(1, "roguelike", "Aelia", null)?.accountId).toBe("hunter");
expect(cache.read(1, "roguelike", "Other", null)).toBeNull();
expect(cache.read(1, "roguelike", "Aelia", "different-account")).toBeNull();
});
it("ignores corrupt persisted responses", () => {
const storage = memoryStorage();
storage.setItem("i-want-to-heal:leaderboards:cache:v1", JSON.stringify({
"1:roguelike": { accountId: "hunter", hunterName: "Aelia", slotId: 1, statId: "roguelike", updatedAt: "today", result: {} },
}));
expect(new LeaderboardCache(storage).read(1, "roguelike", "Aelia", "hunter")).toBeNull();
});
it("preserves Hockey Healing duration tiebreakers", () => {
const cache = new LeaderboardCache(memoryStorage());
const hockeyResult: LeaderboardResult = {
kind: "hockey-healing",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 },
};
cache.write("hunter", "Aelia", 1, "hockey-healing", hockeyResult);
expect(cache.read(1, "hockey-healing", "Aelia", "hunter")?.result).toEqual(hockeyResult);
});
it("validates Blockbreaker metric boards for offline reads", () => {
const cache = new LeaderboardCache(memoryStorage());
const blockbreakerResult: LeaderboardResult = {
kind: "blockbreaker-score",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 },
};
cache.write("hunter", "Aelia", 1, "blockbreaker-score", blockbreakerResult);
expect(cache.read(1, "blockbreaker-score", "Aelia", "hunter")?.result).toEqual(blockbreakerResult);
});
it("preserves Aether Assault wave tiebreakers for offline reads", () => {
const cache = new LeaderboardCache(memoryStorage());
const aetherResult: LeaderboardResult = {
kind: "aether-assault",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 },
};
cache.write("hunter", "Aelia", 1, "aether-assault", aetherResult);
expect(cache.read(1, "aether-assault", "Aelia", "hunter")?.result).toEqual(aetherResult);
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { LeaderboardEntry, LeaderboardResult } from "./onlineRepository";
import type { ProfileStatId, SaveSlotId } from "./types";
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface CachedLeaderboard {
accountId: string;
hunterName: string;
slotId: SaveSlotId;
statId: ProfileStatId;
updatedAt: string;
result: LeaderboardResult;
}
const CACHE_KEY = "i-want-to-heal:leaderboards:cache:v1";
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
function cacheId(slotId: SaveSlotId, statId: ProfileStatId) {
return `${slotId}:${statId}`;
}
function leaderboardEntry(value: unknown): LeaderboardEntry | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<LeaderboardEntry>;
if (!Number.isInteger(candidate.rank) || Number(candidate.rank) < 1) return null;
if (typeof candidate.username !== "string" || typeof candidate.hunterName !== "string") return null;
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
if (!Number.isFinite(candidate.value) || Number(candidate.value) < 0) return null;
if (candidate.secondaryValue !== undefined && (!Number.isFinite(candidate.secondaryValue) || Number(candidate.secondaryValue) < 0)) return null;
return {
rank: Number(candidate.rank),
username: candidate.username,
hunterName: candidate.hunterName,
slotId: candidate.slotId,
value: Number(candidate.value),
...(candidate.secondaryValue === undefined ? {} : { secondaryValue: Number(candidate.secondaryValue) }),
};
}
function leaderboardResult(value: unknown): LeaderboardResult | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<LeaderboardResult>;
if (candidate.kind !== "boss"
&& candidate.kind !== "roguelike"
&& candidate.kind !== "rogue-trials-endless"
&& candidate.kind !== "hockey-healing"
&& candidate.kind !== "hockey-pvp-wins"
&& candidate.kind !== "hockey-pvp-boss-kills"
&& candidate.kind !== "blockbreaker-bricks"
&& candidate.kind !== "blockbreaker-time"
&& candidate.kind !== "blockbreaker-score"
&& candidate.kind !== "aether-assault") return null;
if (!Array.isArray(candidate.top)) return null;
const top = candidate.top.map(leaderboardEntry);
if (top.some((entry) => !entry)) return null;
const current = candidate.current === null ? null : leaderboardEntry(candidate.current);
if (candidate.current !== null && !current) return null;
return {
kind: candidate.kind,
...(candidate.kind === "boss" && typeof candidate.bossId === "string" ? { bossId: candidate.bossId } : {}),
top: top as LeaderboardEntry[],
current,
};
}
function cachedLeaderboard(value: unknown): CachedLeaderboard | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<CachedLeaderboard>;
if (typeof candidate.accountId !== "string" || typeof candidate.hunterName !== "string") return null;
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
if (typeof candidate.statId !== "string" || typeof candidate.updatedAt !== "string") return null;
if (Number.isNaN(Date.parse(candidate.updatedAt))) return null;
const result = leaderboardResult(candidate.result);
if (!result) return null;
return { ...candidate, result } as CachedLeaderboard;
}
export class LeaderboardCache {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly now: () => string = () => new Date().toISOString(),
) {}
read(slotId: SaveSlotId, statId: ProfileStatId, hunterName: string, accountId: string | null): CachedLeaderboard | null {
const entry = this.readAll()[cacheId(slotId, statId)];
if (!entry || entry.slotId !== slotId || entry.statId !== statId || entry.hunterName !== hunterName) return null;
if (accountId && entry.accountId !== accountId) return null;
return structuredClone(entry);
}
write(accountId: string, hunterName: string, slotId: SaveSlotId, statId: ProfileStatId, result: LeaderboardResult): CachedLeaderboard {
const entries = this.readAll();
const entry: CachedLeaderboard = {
accountId,
hunterName,
slotId,
statId,
updatedAt: this.now(),
result: structuredClone(result),
};
entries[cacheId(slotId, statId)] = entry;
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
return structuredClone(entry);
}
clearSlot(slotId: SaveSlotId) {
const entries = this.readAll();
for (const key of Object.keys(entries)) {
if (entries[key].slotId === slotId) delete entries[key];
}
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
}
private readAll(): Record<string, CachedLeaderboard> {
try {
const raw = this.storage.getItem(CACHE_KEY);
const parsed = raw ? JSON.parse(raw) as Record<string, unknown> : {};
if (!parsed || typeof parsed !== "object") return {};
return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => {
const entry = cachedLeaderboard(value);
return entry ? [[key, entry]] : [];
}));
} catch {
return {};
}
}
}
export const leaderboardCache = new LeaderboardCache();
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { onlineRepository } from "./onlineRepository";
import { hasPendingSaveSync } from "./saveSync";
import { useFrontendStore } from "./store";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("offline leaderboard publishing", () => {
it("keeps earned stats pending without attempting an offline upload", async () => {
const original = useFrontendStore.getState();
original.deleteSlot(3);
expect(original.createSlot(3, "Offline Hunter")).toBe(true);
original.playSlot(3);
useFrontendStore.setState({ accountId: "offline-account" });
useFrontendStore.getState().recordBossVictory("bulldrome", "initiate");
useFrontendStore.getState().recordAetherAssaultDefeat(18_750, 6, 214.5);
expect(hasPendingSaveSync(3)).toBe(true);
vi.stubGlobal("navigator", { onLine: false });
const write = vi.spyOn(onlineRepository, "writeSave");
expect(await useFrontendStore.getState().uploadSlot(3)).toBe(false);
expect(write).not.toHaveBeenCalled();
expect(useFrontendStore.getState().slots[2].local?.stats.bossKills.bulldrome).toBe(1);
expect(useFrontendStore.getState().slots[2].local?.stats).toMatchObject({
highestAetherAssaultScore: 18_750,
highestAetherAssaultWaveAtBest: 6,
longestAetherAssaultSecondsAtBest: 214.5,
});
useFrontendStore.getState().deleteSlot(3);
useFrontendStore.setState({
accountId: original.accountId,
activeSlotId: original.activeSlotId,
selectedSlotId: original.selectedSlotId,
screen: original.screen,
notice: original.notice,
});
});
});
+235 -2
View File
@@ -1,6 +1,8 @@
import { Capacitor } from "@capacitor/core";
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 { RoguelikePvpCurseId } from "../game/roguelikePvp";
export interface OnlineAccount {
id: number;
@@ -19,15 +21,105 @@ export interface LeaderboardEntry {
hunterName: string;
slotId: SaveSlotId;
value: number;
secondaryValue?: number;
}
export interface LeaderboardResult {
kind: "boss" | "roguelike" | "rogue-trials-endless";
kind: "boss" | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
bossId?: BossId;
top: LeaderboardEntry[];
current: LeaderboardEntry | null;
}
export interface HockeyPvpOnlineMatch {
id: string;
seed: number;
generation: number;
countdownEndsAtMs: number;
opponentName: string;
role: Exclude<HockeyPvpRole, "cpu">;
}
export interface HockeyPvpQueueResult {
ticketId: string;
status: "waiting" | "matched";
match?: HockeyPvpOnlineMatch;
}
export interface HockeyPvpRematchResult {
status: "waiting" | "matched";
match?: HockeyPvpOnlineMatch;
}
export interface HockeyPvpExchangeResult {
opponentSnapshot: 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 {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
@@ -151,6 +243,147 @@ export class OnlineRepository {
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
hockeyHealingLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-healing?slot=${slotId}`);
}
hockeyPvpWinsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-pvp-wins?slot=${slotId}`);
}
hockeyPvpBossKillsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-pvp-boss-kills?slot=${slotId}`);
}
blockbreakerBricksLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-bricks?slot=${slotId}`);
}
blockbreakerTimeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-time?slot=${slotId}`);
}
blockbreakerScoreLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-score?slot=${slotId}`);
}
aetherAssaultLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/aether-assault?slot=${slotId}`);
}
joinHockeyPvpQueue(slotId: SaveSlotId, hunterName: string): Promise<HockeyPvpQueueResult> {
return this.request("/api/hockey-pvp/queue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slotId, hunterName }),
});
}
pollHockeyPvpQueue(ticketId: string): Promise<HockeyPvpQueueResult> {
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`);
}
cancelHockeyPvpQueue(ticketId: string): Promise<void> {
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
}
exchangeHockeyPvpState(matchId: string, generation: number, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
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 }),
});
}
}
export const onlineRepository = new OnlineRepository();
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildCollections } from "./data";
import { alphabeticalBosses, defaultStatForSection, profileSectionForStat } from "./profileSections";
describe("hunter profile sections", () => {
const bosses = alphabeticalBosses(buildCollections({ dropsFound: {}, petsFound: {} }, {}));
it("groups related records under one section", () => {
expect(profileSectionForStat("blockbreaker-bricks")).toBe("blockbreaker");
expect(profileSectionForStat("blockbreaker-time")).toBe("blockbreaker");
expect(profileSectionForStat("hockey-pvp-boss-kills")).toBe("hockey-pvp");
expect(profileSectionForStat("aether-assault")).toBe("aether-assault");
expect(profileSectionForStat("bulldrome")).toBe("bosses");
});
it("builds one alphabetical boss index across mechanic groups", () => {
const names = bosses.map((boss) => boss.bossName);
expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right)));
expect(new Set(bosses.map((boss) => boss.bossId)).size).toBe(bosses.length);
});
it("opens each section on its primary record", () => {
expect(defaultStatForSection("hockey-pvp", bosses)).toBe("hockey-pvp-wins");
expect(defaultStatForSection("blockbreaker", bosses)).toBe("blockbreaker-score");
expect(defaultStatForSection("aether-assault", bosses)).toBe("aether-assault");
expect(defaultStatForSection("bosses", bosses)).toBe(bosses[0].bossId);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { AVAILABLE_BOSS_IDS } from "../game/bossCatalog";
import type { BossId } from "../game/types";
import type { GroupBossCollection, ProfileStatId } from "./types";
export type ProfileSectionId = "roguelike" | "rogue-trials" | "hockey" | "hockey-pvp" | "blockbreaker" | "aether-assault" | "bosses";
export interface ProfileSectionDefinition {
id: ProfileSectionId;
label: string;
copy: string;
icon: string;
statIds: readonly ProfileStatId[];
}
export const PROFILE_SECTIONS: readonly ProfileSectionDefinition[] = [
{ id: "roguelike", label: "Roguelike", copy: "Highest completed round", icon: "∞", statIds: ["roguelike"] },
{ id: "rogue-trials", label: "Trials Endless", copy: "Best endless boss run", icon: "Ⅲ", statIds: ["rogue-trials-endless"] },
{ id: "hockey", label: "Hockey", copy: "Returns and survival", icon: "◌", statIds: ["hockey-healing"] },
{ id: "hockey-pvp", label: "Hockey PVP", copy: "Record and race kills", icon: "◇", statIds: ["hockey-pvp-wins", "hockey-pvp-boss-kills"] },
{ id: "blockbreaker", label: "Blockbreaker", copy: "Score, bricks, survival", icon: "▦", statIds: ["blockbreaker-score", "blockbreaker-bricks", "blockbreaker-time"] },
{ id: "aether-assault", label: "Aether Assault", copy: "Score, wave, survival", icon: "⌁", statIds: ["aether-assault"] },
{ id: "bosses", label: "Bosses", copy: "Kills and boss pets", icon: "♛", statIds: [] },
] as const;
const BOSS_IDS = new Set<string>(AVAILABLE_BOSS_IDS);
export function isBossProfileStat(statId: ProfileStatId): statId is BossId {
return BOSS_IDS.has(statId);
}
export function profileSectionForStat(statId: ProfileStatId): ProfileSectionId {
if (isBossProfileStat(statId)) return "bosses";
return PROFILE_SECTIONS.find((section) => section.statIds.includes(statId))?.id ?? "roguelike";
}
export function alphabeticalBosses(groups: readonly { bosses: readonly GroupBossCollection[] }[]): GroupBossCollection[] {
return groups
.flatMap((group) => group.bosses)
.sort((left, right) => left.bossName.localeCompare(right.bossName));
}
export function defaultStatForSection(sectionId: ProfileSectionId, bosses: readonly GroupBossCollection[]): ProfileStatId {
if (sectionId === "bosses") return bosses[0]?.bossId ?? "roguelike";
return PROFILE_SECTIONS.find((section) => section.id === sectionId)?.statIds[0] ?? "roguelike";
}

Some files were not shown because too many files have changed in this diff Show More