Compare commits

...
4 Commits
Author SHA1 Message Date
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
41 changed files with 1608 additions and 156 deletions
+1
View File
@@ -10,6 +10,7 @@ vite.config.d.ts
.env
.env.*
!.env.example
/git-token
__pycache__/
*.py[cod]
/public/basis/
+24 -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,6 +145,7 @@ 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
@@ -177,6 +191,6 @@ build. Remove the switch to return to modular rendering.
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
- 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.15",
"version": "0.1.19",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
+37 -6
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()
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
result = None
for attempt in range(3):
try:
result = gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
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()
+3
View File
@@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20;
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
const authAttempts = new Map();
function apiError(message, status = 400) {
@@ -650,6 +651,7 @@ export function createGameApiHandler(options = {}) {
match: {
id: match.id,
seed: match.seed,
countdownEndsAtMs: match.countdownEndsAtMs,
opponentName: opponent.hunterName,
role: ticket.side,
},
@@ -693,6 +695,7 @@ export function createGameApiHandler(options = {}) {
id: matchId,
seed: randomBytes(4).readUInt32BE(0) || 1,
createdAt: now,
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
players: { host: opponent, guest: ticket },
snapshots: { host: null, guest: null },
};
+2
View File
@@ -228,6 +228,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.role, "guest");
assert.equal(betaQueue.body.match.opponentName, "Alpha");
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
@@ -236,6 +237,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
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.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
+24 -1
View File
@@ -26,6 +26,10 @@ function GameLoadingScreen() {
);
}
function TacticalLoadingScreen() {
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
}
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -147,6 +151,25 @@ function MainApp() {
};
}, [screen]);
useEffect(() => {
if (screen !== "game") return;
let timer: number | undefined;
const autoStart = () => {
const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || state.phase !== "briefing") return;
const remaining = state.hockeyPvp.countdownEndsAtMs - Date.now();
if (remaining <= 0) {
state.startEncounter();
return;
}
timer = window.setTimeout(autoStart, remaining + 16);
};
autoStart();
return () => {
if (timer !== undefined) window.clearTimeout(timer);
};
}, [screen]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -225,7 +248,7 @@ function MainApp() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} /></Suspense>} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
+72
View File
@@ -0,0 +1,72 @@
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 { 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 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 manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`${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>
);
}
+83 -62
View File
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, 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";
@@ -21,7 +21,8 @@ import {
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs";
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
@@ -31,9 +32,82 @@ import {
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
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";
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() {
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.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 (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 (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);
@@ -113,62 +187,6 @@ function PartyList() {
);
}
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
@@ -234,6 +252,8 @@ function BriefingPanel() {
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);
return (
<div className="briefing-panel">
<div className="briefing-class">
@@ -241,7 +261,7 @@ function BriefingPanel() {
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
<button className="start-button" onClick={startEncounter} disabled={pvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? pvpCountdownSeconds > 0 ? `Match starts in ${pvpCountdownSeconds}` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{pvpMode ? "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>
@@ -706,6 +726,7 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
useSingleScreenTacticalInput();
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
+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();
toggle();
requestDisplaySurface("top");
return;
}
if (event.key !== "Tab") return;
event.preventDefault();
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>
+4
View File
@@ -51,6 +51,7 @@ import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_COUNTDOWN_MS,
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
hockeyPvpBossAt,
randomHockeyPvpCpuName,
@@ -1593,6 +1594,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
opponentName: randomHockeyPvpCpuName(),
role: "cpu",
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
});
};
const cancelPvpQueue = () => {
@@ -1624,6 +1626,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
completePvpQueue({
matchId: joined.match.id,
seed: joined.match.seed,
countdownEndsAtMs: joined.match.countdownEndsAtMs,
opponentName: joined.match.opponentName,
role: joined.match.role,
});
@@ -1638,6 +1641,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
completePvpQueue({
matchId: result.match.id,
seed: result.match.seed,
countdownEndsAtMs: result.match.countdownEndsAtMs,
opponentName: result.match.opponentName,
role: result.match.role,
});
+33 -1
View File
@@ -7,10 +7,14 @@ 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 { 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";
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
@@ -165,6 +169,11 @@ function PhaseOverlay() {
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
activityMode === "hockey-healing-pvp" && phase === "briefing",
hockeyPvp.countdownEndsAtMs,
);
const singleScreen = isSingleScreenLayout();
if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
@@ -219,13 +228,35 @@ function PhaseOverlay() {
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
const briefingPrompt = pvpMode
? pvpCountdownSeconds > 0
? `Match starts automatically in ${pvpCountdownSeconds}`
: "Match starting now"
: singleScreen
? "Press Start / Enter to begin"
: "Begin from lower display";
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" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing"
? briefingPrompt
: singleScreen
? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
: showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
</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>
);
}
@@ -402,6 +433,7 @@ export function TopScreen({
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<SingleScreenAbilityBar />
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
<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}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
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>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
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><b>Claim</b>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton>
);
})}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
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}`}
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>
} />
);
@@ -9,6 +9,8 @@ import {
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";
@@ -18,6 +20,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
<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 ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}`}
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton>
) : undefined} />
))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{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}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
>
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<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>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{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)}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
<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>
);
})}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<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={`Buy ${offer.item.name}`}>
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton>
} />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) {
const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
if (parent.scrollWidth > parent.clientWidth) {
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3>
<p>{entry.className}</p>
{!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
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;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
{price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div>
{action}
</article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600;
}
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3,
.rpg-spell-card h3,
.rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase;
}
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip,
.rpg-empty-chip,
.rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip {
position: relative;
padding: 4px 18px 4px 10px;
padding: 4px 6px 4px 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center;
gap: 5px;
cursor: pointer;
text-align: left;
}
@@ -414,18 +465,24 @@
width: 3px;
}
.rpg-picked-chip > small {
.rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-picked-chip > b {
position: absolute;
right: 6px;
.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e;
font-size: 14px;
}
.rpg-empty-chip {
@@ -870,7 +927,7 @@
}
.rpg-gear-card {
min-height: 72px;
min-height: 112px;
padding: 8px 8px 22px 43px;
}
@@ -904,6 +961,12 @@
font-size: 9px;
}
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list {
min-height: 0;
overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px;
}
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list {
display: grid;
gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
}
.rpg-tactical-reward {
min-height: 94px;
min-height: 130px;
padding: 9px 64px 9px 49px;
position: relative;
cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase;
}
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rpg-run-tactical .rpg-gear-card {
min-height: 80px;
min-height: 112px;
}
.rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px;
}
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip,
.rpg-spellbook-strip {
overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px;
}
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout {
grid-template-columns: 1fr;
overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b {
display: none;
}
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
}
/* Single-display browser fallback gets a usable full-height draft surface. */
+1
View File
@@ -36,6 +36,7 @@ export interface HockeyPvpQueueResult {
match?: {
id: string;
seed: number;
countdownEndsAtMs: number;
opponentName: string;
role: Exclude<HockeyPvpRole, "cpu">;
};
+35 -2
View File
@@ -5,18 +5,27 @@ import {
advanceHockeyPvpPuck,
createHockeyPvpState,
hockeyPvpBossAt,
hockeyPvpCountdownSeconds,
hockeyPvpDampeningPercent,
hockeyPvpHealingEffectiveness,
hockeyPvpPuckSpeed,
mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
} from "./hockeyHealingPvp";
describe("Healing Hockey PVP", () => {
it("starts rallies at the faster default puck speed", () => {
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
expect(hockeyPvpPuckSpeed(0)).toBe(7.2);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2);
expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
});
it("counts down five whole seconds and never returns a negative value", () => {
expect(hockeyPvpCountdownSeconds(10_000, 5_000)).toBe(5);
expect(hockeyPvpCountdownSeconds(10_000, 9_001)).toBe(1);
expect(hockeyPvpCountdownSeconds(10_000, 10_000)).toBe(0);
expect(hockeyPvpCountdownSeconds(10_000, 12_000)).toBe(0);
});
it("adds five percent global dampening for every boss killed by either party", () => {
@@ -76,4 +85,28 @@ describe("Healing Hockey PVP", () => {
lastGoalSide: "local",
});
});
it("predicts guest movement between snapshots and soft-corrects small drift", () => {
const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
guest.puckVelocity = [3, 6];
const predicted = advanceHockeyPvpPuck(guest, {
delta: 0.1,
localPlayerPosition: [0, 8.5],
localAimDirection: [0, -1],
opponentPlayerPosition: [0, 8.5],
opponentAimDirection: [0, -1],
});
expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
expect(predicted.localGoalsConceded).toBe(0);
const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
authoritative.goalSequence += 1;
authoritative.puckPosition = [0, 0];
expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
});
});
+70 -3
View File
@@ -10,6 +10,7 @@ export interface HockeyPvpMatchConfig {
seed: number;
opponentName: string;
role: HockeyPvpRole;
countdownEndsAtMs?: number;
}
export interface HockeyPvpPuckState {
@@ -25,6 +26,7 @@ export interface HockeyPvpPuckState {
}
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
countdownEndsAtMs: number;
status: "inactive" | "live" | "won" | "lost";
aimDirection: WorldPosition;
opponentBossKills: number;
@@ -59,13 +61,17 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
export const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
const STARTING_SPEED = 7.2;
const MAX_SPEED = 11.5;
const STARTING_SPEED = 7.8;
const MAX_SPEED = 12.2;
const MAX_SUBSTEPS = 10;
const MAX_SUBSTEP_DISTANCE = 0.32;
const GUEST_RECONCILIATION_BLEND = 0.35;
const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
@@ -95,6 +101,10 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
}
export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Date.now()) {
return Math.max(0, Math.ceil((countdownEndsAtMs - nowMs) / 1_000));
}
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
@@ -107,6 +117,7 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
return {
...match,
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
status: config ? "live" : "inactive",
puckPosition: [0, 0],
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
@@ -161,6 +172,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
}
function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
puckVelocity: [...source.puckVelocity],
};
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
const subDelta = delta / substeps;
const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
for (let index = 0; index < substeps; index += 1) {
const end: WorldPosition = [
state.puckPosition[0] + state.puckVelocity[0] * subDelta,
state.puckPosition[1] + state.puckVelocity[1] * subDelta,
];
if (end[0] < minX || end[0] > maxX) {
end[0] = Math.max(minX, Math.min(maxX, end[0]));
state.puckVelocity[0] *= -1;
}
if (end[1] >= HOCKEY_PVP_GOAL_Z) {
end[1] = HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
} else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
end[1] = -HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
}
state.puckPosition = end;
}
return state;
}
export function reconcileHockeyPvpPuck(
predicted: HockeyPvpPuckState,
authoritative: HockeyPvpPuckState,
): HockeyPvpPuckState {
const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
|| Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
return {
...authoritative,
puckPosition: shouldSnap
? [...authoritative.puckPosition]
: [
predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
],
puckVelocity: [...authoritative.puckVelocity],
};
}
export function advanceHockeyPvpPuck(
source: HockeyPvpState,
step: {
@@ -171,7 +235,10 @@ export function advanceHockeyPvpPuck(
opponentAimDirection: WorldPosition;
},
): HockeyPvpState {
if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source;
if (source.status !== "live" || step.delta <= 0) return source;
// Guest predicts travel only. Host snapshots remain authoritative for contacts,
// goals, damage, and rally counters.
if (source.role === "guest") return predictGuestPuck(source, step.delta);
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
+85 -4
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createClassInventory } from "./healers";
import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp";
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
import { upcomingEncounterMechanic, useGameStore } from "./store";
import { freshParty } from "./data";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
@@ -23,6 +23,8 @@ function createMaxedGear() {
}
describe("Healing Hockey PVP encounter integration", () => {
afterEach(() => vi.restoreAllMocks());
beforeEach(() => {
useGameStore.getState().configureHealer(
"priest",
@@ -42,6 +44,28 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
});
it("ignores start input until shared five-second countdown ends", () => {
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, countdownEndsAtMs: 10_000 },
);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("briefing");
now.mockReturnValue(10_000);
useGameStore.getState().startEncounter();
expect(useGameStore.getState().phase).toBe("combat");
});
it("normalizes both parties to default base gear without changing saved upgrades", () => {
@@ -170,13 +194,16 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
});
it("wins when opponent party falls", () => {
it("wins when opponent companions fall while rival healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.getState().setActiveTab("map");
useGameStore.setState((state) => ({
hockeyPvpOpponent: {
...state.hockeyPvpOpponent,
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })),
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
},
}));
useGameStore.getState().tick(0.01);
@@ -185,6 +212,60 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().activeTab).toBe("combat");
});
it("loses when local companions fall while healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
});
it("wins an online match when remote companions fall while rival healer remains alive", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, matchId: "online-match", role: "guest" },
);
useGameStore.getState().startEncounter();
const state = useGameStore.getState();
const snapshot: HockeyPvpRemoteSnapshot = {
sequence: 1,
time: state.time,
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
boss: {
id: state.hockeyPvpOpponent.boss.id,
name: state.hockeyPvpOpponent.boss.name,
hp: state.hockeyPvpOpponent.boss.hp,
maxHp: state.hockeyPvpOpponent.boss.maxHp,
},
bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
bossMode: state.hockeyPvpOpponent.bossMotion.mode,
bossKills: 0,
playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
aimDirection: [...state.hockeyPvp.opponentAimDirection],
};
useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().hockeyPvp.status).toBe("won");
});
it("keeps HUD mechanic data defined during instant boss replacement", () => {
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run";
export * from "./playSpace";
export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId];
}
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem(
id: string,
ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots,
autoEquipRunGear,
challengeObjective,
compareRunGear,
createRandomState,
createRpgRoguelikeRun,
createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker);
});
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun,
createRunGearItem,
moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun,
rpgFocusId,
rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
}
describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run";
import {
MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action(
rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`,
`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited
? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
});
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`,
`Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId },
)) : [];
return [
+26 -1
View File
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { createClassInventory } from "./healers";
import { createDefaultGearProgress } from "./progression/gear";
import { equipPassiveInfusion } from "./progression/infusions";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store";
function finishRpgDrafts() {
@@ -113,6 +114,30 @@ describe("RPG Roguelike store integration", () => {
expect(state.endlessMode).toBe(true);
});
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
const challenge = useGameStore.getState();
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
expect(challenge.additionalBosses).toHaveLength(1);
expect(challenge.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
));
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
completeCurrentChallenge();
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
const bossRoom = useGameStore.getState();
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
expect(bossRoom.additionalBosses).toHaveLength(0);
expect(bossRoom.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
));
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
});
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
+13 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { BARRIER_RADIUS, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { healingEffect } from "./healerEffects";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
@@ -61,6 +61,18 @@ describe("Disc Priest combat simulation", () => {
});
});
it("regenerates mana at one-third the previous global rate", () => {
useGameStore.setState((state) => ({
mana: 0,
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
useGameStore.getState().tick(1);
expect(MANA_REGEN_PER_SECOND).toBeCloseTo(3.2 / 3);
expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3);
});
it("uses a three-second Purify cooldown for every healer class", () => {
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
+25 -13
View File
@@ -70,6 +70,7 @@ import {
type HockeyHealingState,
} from "./hockeyHealing";
import {
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
HOCKEY_PVP_GOAL_DAMAGE,
advanceHockeyPvpCpuGoalie,
advanceHockeyPvpPuck,
@@ -77,6 +78,7 @@ import {
hockeyPvpHealingEffectiveness,
hockeyPvpBossAt,
mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
type HockeyPvpMatchConfig,
type HockeyPvpRemoteSnapshot,
type HockeyPvpState,
@@ -120,7 +122,6 @@ import {
} from "./aetherAssault";
import {
assignRosterToCombatSlots,
BOSSES_PER_ACT,
createRpgRoguelikeRun,
reduceRpgRoguelikeRun,
selectCurrentBossId,
@@ -136,6 +137,7 @@ import {
spellRankPowerMultiplier,
type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -266,6 +268,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
@@ -628,7 +631,8 @@ function initialState(
runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier,
difficultyDamageMultiplier: difficulty.damageMultiplier
* (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
gearProgress,
gearModifiers,
time: 0,
@@ -700,9 +704,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner]
: [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard";
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT);
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14)
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier;
const modeDifficulty = rpgEncounterDifficulty(
challenge ? "hallway-challenge" : "boss-room",
run.bossIndex,
);
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId,
index,
@@ -747,6 +754,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party,
gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower,
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout),
@@ -807,6 +815,9 @@ export const useGameStore = create<GameState>((set, get) => ({
startEncounter: () => {
const current = get();
if (current.runMode === "hockey-healing-pvp"
&& current.phase === "briefing"
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return;
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
@@ -828,7 +839,7 @@ export const useGameStore = create<GameState>((set, get) => ({
difficultySlug,
seenBossIds,
runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: undefined,
abilityLoadout,
),
@@ -881,7 +892,7 @@ export const useGameStore = create<GameState>((set, get) => ({
difficultySlug,
[],
runMode === "hockey-healing-pvp"
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
: undefined,
abilityLoadout,
));
@@ -1106,7 +1117,7 @@ export const useGameStore = create<GameState>((set, get) => ({
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
? mirrorHockeyPvpPuck(hostPuck)
? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
: undefined;
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
@@ -1121,8 +1132,8 @@ export const useGameStore = create<GameState>((set, get) => ({
})
: state.party;
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
const opponentWiped = isPartyWiped(opponentParty);
const localWiped = isPartyWiped(party);
const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
const localWiped = areAllNonHealerAlliesDefeated(party);
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
return {
party,
@@ -2104,7 +2115,8 @@ export const useGameStore = create<GameState>((set, get) => ({
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
const pvpMode = state.activityMode === "hockey-healing-pvp";
const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party);
const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active";
const rpgBossActive = rpgRun?.phase === "boss-combat";
if (rpgChallengeActive && rpgRun) {
@@ -2186,7 +2198,7 @@ export const useGameStore = create<GameState>((set, get) => ({
phase = "combat";
endlessMode = false;
} else if (pvpMode) {
if (partyWiped) {
if (localPvpTeamDefeated) {
phase = "defeat";
hockeyPvp.status = "lost";
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
@@ -2254,7 +2266,7 @@ export const useGameStore = create<GameState>((set, get) => ({
hockeyPvp,
hockeyPvpOpponent,
runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast,
combatLog,
scenePulse: pulse,
+14
View File
@@ -5,6 +5,16 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
import { cycleBottomTab } from "./bottomTabs";
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
import { getDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
function tacticalOverlayOwnsInput() {
const store = useGameStore.getState();
if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
if (store.paused || store.phase === "intermission") return false;
if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
}
function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState();
@@ -35,9 +45,11 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return;
if (event.defaultPrevented) return;
if (event.repeat) return;
const store = useGameStore.getState();
const key = event.key.toLowerCase();
if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false);
@@ -124,6 +136,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
if (!enabled) return;
const store = useGameStore.getState();
if (isSingleScreenLayout() && token === "Button8") return;
if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
const [seconds, setSeconds] = useState(() =>
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
useEffect(() => {
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
update();
if (!active) return;
const timer = window.setInterval(update, 100);
return () => window.clearInterval(timer);
}, [active, countdownEndsAtMs]);
return active ? seconds : 0;
}
+3
View File
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display");
const displayLayout = currentDisplayLayout();
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform");
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode;
}
document.documentElement.dataset.displayLayout = displayLayout;
startControllerInput();
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveDisplayLayout } from "./displayLayout";
describe("resolveDisplayLayout", () => {
it("uses the top-screen-first single layout for ordinary browsers", () => {
expect(resolveDisplayLayout({})).toBe("single");
expect(resolveDisplayLayout({ layout: "single" })).toBe("single");
});
it("keeps the dual-screen hardware mockup behind an explicit preview", () => {
expect(resolveDisplayLayout({ layout: "thor-preview" })).toBe("thor-preview");
expect(resolveDisplayLayout({ layout: "dual" })).toBe("thor-preview");
});
it("never overrides a dedicated Android display surface", () => {
expect(resolveDisplayLayout({ display: "top", layout: "single" })).toBe("dedicated");
expect(resolveDisplayLayout({ display: "bottom", layout: "thor-preview" })).toBe("dedicated");
});
});
+29
View File
@@ -0,0 +1,29 @@
export type DisplayLayout = "single" | "thor-preview" | "dedicated";
export interface DisplayLayoutRequest {
display?: string | null;
layout?: string | null;
}
/**
* Physical Thor surfaces are selected explicitly by the Android host. Every
* ordinary browser viewport is a single-screen game unless a developer asks
* for the dual-screen hardware preview.
*/
export function resolveDisplayLayout({ display, layout }: DisplayLayoutRequest): DisplayLayout {
if (display === "top" || display === "bottom") return "dedicated";
if (layout === "thor-preview" || layout === "dual") return "thor-preview";
return "single";
}
export function currentDisplayLayout(search = window.location.search): DisplayLayout {
const params = new URLSearchParams(search);
return resolveDisplayLayout({
display: params.get("display"),
layout: params.get("layout"),
});
}
export function isSingleScreenLayout(search = window.location.search) {
return currentDisplayLayout(search) === "single";
}
+10 -1
View File
@@ -1,13 +1,22 @@
export type DisplaySurface = "top" | "bottom";
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
let currentSurface: DisplaySurface = "top";
export function requestDisplaySurface(surface: DisplaySurface) {
currentSurface = surface;
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
}
export function getDisplaySurface() {
return currentSurface;
}
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail);
const onSurface = (event: Event) => {
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
listener(currentSurface);
};
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
}
+265
View File
@@ -118,6 +118,224 @@ button:focus-visible {
display: none;
}
/* PC and handheld browsers use the Thor top surface as the canonical game
viewport. The lower surface becomes a disclosed tactical layer. */
html[data-display-layout="single"],
html[data-display-layout="single"] body,
html[data-display-layout="single"] #root,
html[data-display-layout="single"] .app-shell,
.single-display-frame,
.single-primary-surface {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
html[data-display-layout="single"] .app-shell {
padding: 0;
}
html[data-display-layout="single"] .app-header {
display: none;
}
.single-display-frame {
position: relative;
isolation: isolate;
background: #030706;
}
.single-primary-surface {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.single-primary-surface > .display,
.single-primary-surface > .top-display,
.single-primary-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-primary-surface > .top-display {
container-name: single-game-screen;
container-type: size;
}
.single-context-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
}
.single-context-backdrop {
position: absolute;
inset: 0;
border: 0;
background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
backdrop-filter: blur(5px);
cursor: pointer;
}
.single-context-surface {
position: relative;
z-index: 1;
width: min(100%, calc((100dvh - 28px) * 31 / 27));
max-width: 760px;
max-height: calc(100dvh - 28px);
aspect-ratio: 31 / 27;
overflow: hidden;
border: 1px solid rgba(232, 200, 114, 0.42);
border-radius: 8px;
background: #07110f;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
}
.single-context-surface > .bottom-display,
.single-context-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-context-toggle {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 100;
min-width: 94px;
display: grid;
gap: 1px;
padding: 7px 10px;
border: 1px solid rgba(232, 200, 114, 0.62);
border-radius: 4px;
color: var(--ink);
background: rgba(3, 9, 8, 0.92);
box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
text-align: left;
cursor: pointer;
}
.single-context-toggle b {
color: var(--gold-strong);
font-size: 10px;
line-height: 1;
letter-spacing: .08em;
text-transform: uppercase;
}
.single-context-toggle small {
color: var(--muted);
font-size: 7px;
line-height: 1.2;
}
.single-display-frame.context-open .single-context-toggle {
top: max(18px, env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
bottom: auto;
}
html[data-display-layout="single"] .top-party {
width: clamp(185px, 20cqw, 330px);
gap: clamp(3px, .55cqh, 7px);
}
html[data-display-layout="single"] .top-party-member {
min-height: clamp(38px, 7.8cqh, 72px);
grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
gap: clamp(5px, .75cqw, 11px);
padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
}
html[data-display-layout="single"] .portrait-dot {
width: clamp(26px, 3cqw, 48px);
height: clamp(26px, 3cqw, 48px);
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy strong {
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy small {
font-size: clamp(7px, .68cqw, 11px);
}
html[data-display-layout="single"] .microbar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(5px, .58cqh, 8px);
}
html[data-display-layout="single"] .microbar.player-health-bar {
bottom: clamp(9px, 1.05cqh, 15px);
}
html[data-display-layout="single"] .player-mana-bar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(3px, .38cqh, 6px);
}
html[data-display-layout="single"] .boss-bar-wrap {
width: min(39%, 660px);
}
html[data-display-layout="single"] .boss-name {
font-size: clamp(7px, .62cqw, 11px);
}
html[data-display-layout="single"] .boss-name strong {
font-size: clamp(12px, 1.2cqw, 20px);
}
html[data-display-layout="single"] .objective-chip span {
font-size: clamp(6px, .58cqw, 10px);
}
html[data-display-layout="single"] .objective-chip strong {
font-size: clamp(8px, .82cqw, 14px);
}
html[data-display-layout="single"] .casting-bar {
bottom: clamp(76px, 11cqh, 126px);
}
html[data-display-layout="single"] .control-hint {
display: none;
}
html[data-display-layout="single"] .encounter-callout {
bottom: clamp(74px, 10cqh, 118px);
}
@media (min-width: 1200px) and (min-height: 700px) {
.single-context-layer {
justify-items: end;
padding-right: max(24px, env(safe-area-inset-right));
}
.single-context-surface {
width: min(56vw, 720px);
}
}
.screen-label {
width: 100%;
display: flex;
@@ -1066,6 +1284,19 @@ button:focus-visible {
gap: 4%;
}
.single-ability-bar {
position: absolute;
z-index: 4;
bottom: max(12px, env(safe-area-inset-bottom));
left: 50%;
width: min(64vw, 760px);
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: clamp(3px, .45vw, 7px);
transform: translateX(-50%);
pointer-events: auto;
}
.ability {
--ability-color: #ddd;
position: relative;
@@ -1086,6 +1317,40 @@ button:focus-visible {
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
}
.ability.is-compact {
height: clamp(48px, 7.2dvh, 68px);
grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
gap: clamp(3px, .45vw, 7px);
padding: 5px;
background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
}
.ability.is-compact .ability-icon {
width: clamp(27px, 3.1vw, 37px);
height: clamp(27px, 3.1vw, 37px);
font-size: clamp(14px, 1.45vw, 20px);
}
.ability.is-compact .ability-copy strong {
font-size: clamp(7px, .72vw, 11px);
}
.ability.is-compact .ability-copy small {
display: none;
}
.ability.is-compact .ability-key,
.ability.is-compact .ability-pad {
font-size: clamp(5px, .46vw, 7px);
}
.ability.is-compact .cooldown-mask b {
width: clamp(28px, 3vw, 38px);
height: clamp(28px, 3vw, 38px);
font-size: clamp(11px, 1.1vw, 16px);
}
.ability::after {
content: "";
position: absolute;