Compare commits

..
3 Commits
Author SHA1 Message Date
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
36 changed files with 1499 additions and 152 deletions
+1
View File
@@ -10,6 +10,7 @@ vite.config.d.ts
.env .env
.env.* .env.*
!.env.example !.env.example
/git-token
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/public/basis/ /public/basis/
+24 -10
View File
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
pnpm android:install pnpm android:install
``` ```
The first Android milestone uses the complete single-display fallback. Press The Android host routes the main and tactical surfaces to separate physical
Select (or Tab with a keyboard) to switch between the main game surface and the Thor displays while preserving one authoritative game state. If only one
620 × 540 tactical surface. Native routing to both physical Thor displays is the display is available, Select (or Tab with a keyboard) opens the tactical surface
next milestone; it needs two Android display contexts backed by one shared game over the main game view.
state rather than two independent WebViews.
PC and handheld browsers use the Thor top screen as a responsive, full-viewport
game surface. The compact ability strip keeps combat controls visible; Select
or Tab opens party, map, inventory, and other tactical detail. Use
`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
browser QA.
## TrueNAS deployment ## TrueNAS deployment
@@ -97,8 +102,16 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
``` ```
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can Create `git-token` in the repository root. Paste only the Gitea token into it:
optionally override it for one run. Publish from `main`:
```text
gitea_token_value_goes_here
```
Do not add quotes or a `GITEA_TOKEN=` prefix. The exact `/git-token` path is
Git-ignored. Restrict local file access with `chmod 600 git-token`. The publisher
reads it automatically. `GITEA_TOKEN` remains available as an optional override.
Publish from `main` normally:
```bash ```bash
pnpm publish:gitea -- --message "Describe the update" pnpm publish:gitea -- --message "Describe the update"
@@ -132,6 +145,7 @@ outside the repository.
- `Q` and `E` / D-pad: cycle party target - `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal - `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities - Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
- `M`: tactical map - `M`: tactical map
- `I`: inventory and item tooltip - `I`: inventory and item tooltip
- `Enter` / `START`: begin or reset encounter - `Enter` / `START`: begin or reset encounter
@@ -177,6 +191,6 @@ build. Remove the switch to return to modular rendering.
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom - Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
- Lower-screen typography scales against its own container, never the main page viewport - Lower-screen typography scales against its own container, never the main page viewport
Current Android build is an installable single-display test host. Shipping to both The Android build uses distinct display contexts that project one authoritative
physical Thor displays still needs distinct Android display contexts that project game state across both physical Thor displays. PC and Steam Deck use the same top
one authoritative game state. surface with an adaptive tactical overlay.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.15", "version": "0.1.18",
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs", "predev": "node scripts/sync_basis_transcoder.mjs",
+42 -11
View File
@@ -11,6 +11,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -21,9 +22,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
ANDROID_ROOT = REPO_ROOT / "android" ANDROID_ROOT = REPO_ROOT / "android"
PACKAGE_JSON = REPO_ROOT / "package.json" PACKAGE_JSON = REPO_ROOT / "package.json"
GITEA_TOKEN_FILE = REPO_ROOT / "git-token"
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git" GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
GITEA_API = "https://git.whoagland.com/api/v1" GITEA_API = "https://git.whoagland.com/api/v1"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
GITEA_OWNER = "phenom" GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal-mmo" GITEA_REPO = "i-want-to-heal-mmo"
BRANCH = "main" BRANCH = "main"
@@ -34,6 +35,15 @@ TRUENAS_GITEA_REPO = Path(
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
class GiteaAPIError(SystemExit):
def __init__(self, method: str, path: str, status: int, details: str) -> None:
self.method = method
self.path = path
self.status = status
self.details = details
super().__init__(f"Gitea API {method} {path} failed ({status}): {details}")
def run( def run(
args: list[str], args: list[str],
*, *,
@@ -258,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
def gitea_token() -> str: def gitea_token() -> str:
token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip() token = os.environ.get("GITEA_TOKEN", "").strip()
if not token and GITEA_TOKEN_FILE.is_file():
token = GITEA_TOKEN_FILE.read_text().strip()
if not token: if not token:
raise SystemExit( raise SystemExit(
"GITEA_TOKEN is required to create the release and upload the APK. " f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
"Use --skip-release to push source/tag only." "set GITEA_TOKEN, or use --skip-release."
) )
return token return token
@@ -294,7 +306,7 @@ def gitea_request(
details = error.read().decode(errors="replace") details = error.read().decode(errors="replace")
if allow_not_found and error.code == 404: if allow_not_found and error.code == 404:
return None return None
raise SystemExit(f"Gitea API {method} {path} failed ({error.code}): {details}") from error raise GiteaAPIError(method, path, error.code, details) from error
return json.loads(payload) if payload else None return json.loads(payload) if payload else None
@@ -327,12 +339,31 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
"prerelease": True, "prerelease": True,
} }
).encode() ).encode()
result = gitea_request( path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases", result = None
token=token, for attempt in range(3):
method="POST", try:
body=payload, result = gitea_request(
) path,
token=token,
method="POST",
body=payload,
)
break
except GiteaAPIError as error:
tag_sync_race = (
error.status == 500
and "UQE_release_n" in error.details
and "23505" in error.details
)
if not tag_sync_race or attempt == 2:
raise
delay = 0.5 * (attempt + 1)
print(
f"Gitea tag sync still settling for {tag}; "
f"retrying release in {delay:g}s."
)
time.sleep(delay)
if not isinstance(result, dict): if not isinstance(result, dict):
raise SystemExit("Gitea returned an invalid release response") raise SystemExit("Gitea returned an invalid release response")
return result return result
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
from scripts import publish_gitea
class GiteaTokenTests(unittest.TestCase):
def test_reads_ignored_token_file(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = True
token_file.read_text.return_value = "local-token\n"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "local-token")
def test_environment_token_overrides_file(self) -> None:
token_file = MagicMock()
with (
patch.dict(os.environ, {"GITEA_TOKEN": "environment-token"}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "environment-token")
token_file.is_file.assert_not_called()
def test_requires_token(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = False
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
with self.assertRaisesRegex(SystemExit, "Gitea token is required"):
publish_gitea.gitea_token()
class CreateReleaseTests(unittest.TestCase):
def test_retries_postgres_tag_sync_collision(self) -> None:
collision = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
500,
'duplicate key violates "UQE_release_n" (23505)',
)
created = {"id": 15, "tag_name": "v0.1.15"}
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=[collision, created],
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
result = publish_gitea.create_release(
"v0.1.15",
"ea5d455",
"Release v0.1.15 2026-07-18",
"token",
)
self.assertEqual(result, created)
self.assertEqual(request.call_count, 2)
sleep.assert_called_once_with(0.5)
def test_does_not_retry_unrelated_api_error(self) -> None:
unauthorized = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
401,
"unauthorized",
)
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=unauthorized,
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
with self.assertRaises(publish_gitea.GiteaAPIError):
publish_gitea.create_release(
"v0.1.16",
"commit",
"Release v0.1.16",
"token",
)
request.assert_called_once()
sleep.assert_not_called()
if __name__ == "__main__":
unittest.main()
+5 -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() { function MainApp() {
useForcedThorDisplays(); useForcedThorDisplays();
useAuthoritativeDualScreenSync(); useAuthoritativeDualScreenSync();
@@ -225,7 +229,7 @@ function MainApp() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p> <p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header> </header>
{screen === "game" {screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense> ? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} /></Suspense>} /></Suspense>
: <FrontEnd onLaunch={launchGame} />} : <FrontEnd onLaunch={launchGame} />}
</main> </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>
);
}
+79 -61
View File
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { ABILITY_ORDER } from "../game/data"; import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers"; import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store"; import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types"; import type { BottomTab, PartyMember } from "../game/types";
import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -21,7 +21,7 @@ import {
HOCKEY_PVP_GOAL_Z, HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z, HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp"; } from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs"; import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import { import {
BLOCKBREAKER_BREACH_DAMAGE, BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS, BLOCKBREAKER_BRICK_COLORS,
@@ -31,9 +31,82 @@ import {
blockbreakerTimeMultiplier, blockbreakerTimeMultiplier,
} from "../game/blockbreaker"; } from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals"; import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel"; import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics"; import { isBeaconOfLightTarget } from "../game/healerMechanics";
import { AbilityButton } from "./AbilityButton";
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller";
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() { function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards); const rewards = useFrontendStore((state) => state.recentRewards);
@@ -113,62 +186,6 @@ function PartyList() {
); );
} }
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
style={{ "--ability-color": ability.color } as React.CSSProperties}
onClick={() => castAbility(abilityId)}
disabled={disabled}
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
function AbilityTray() { function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId); const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId]; const healer = HEALER_CLASSES[healerClassId];
@@ -706,6 +723,7 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
} }
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) { export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
useSingleScreenTacticalInput();
const activeTab = useGameStore((state) => state.activeTab); const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab); const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
+53 -12
View File
@@ -1,39 +1,80 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) { export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display"); const params = new URLSearchParams(window.location.search);
const dedicatedSurface = params.get("display");
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top"); const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
const activeSurfaceRef = useRef(activeSurface); const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface; activeSurfaceRef.current = activeSurface;
const showSurface = useCallback((surface: DisplaySurface) => {
activeSurfaceRef.current = surface;
setActiveSurface(surface);
}, []);
const toggleSurface = useCallback(() => {
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
}, []);
useEffect(() => { useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return; if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top"); if (layout === "thor-preview") return;
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface); requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => { const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (token === "Button8" && !repeat) toggle(); if (token === "Button8" && !repeat) toggleSurface();
}); });
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab" || event.repeat) return; if (event.repeat) return;
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
event.preventDefault();
requestDisplaySurface("top");
return;
}
if (event.key !== "Tab") return;
event.preventDefault(); event.preventDefault();
toggle(); toggleSurface();
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface(); unsubscribeSurface();
unsubscribeController(); unsubscribeController();
requestDisplaySurface("top");
}; };
}, [dedicatedSurface]); }, [dedicatedSurface, layout, showSurface, toggleSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") { if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>; return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
} }
if (layout === "single") {
const contextOpen = activeSurface === "bottom";
return (
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
<div className="single-primary-surface">{top}</div>
{contextOpen && (
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button
className="single-context-toggle"
onClick={toggleSurface}
aria-expanded={contextOpen}
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
>
<b>{contextOpen ? "Return" : contextLabel}</b>
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
}
return ( return (
<div className={`device-frame active-${activeSurface}`}> <div className={`device-frame active-${activeSurface}`}>
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div> <div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div> <div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
<button <button
className="native-display-switch" className="native-display-switch"
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")} onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"} aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
> >
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small> <b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
+19 -1
View File
@@ -11,6 +11,9 @@ import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance"; import type { CharacterAppearanceV1 } from "../game/characterAppearance";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics"; import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
import { isSingleScreenLayout } from "../platform/displayLayout";
import { ABILITY_ORDER } from "../game/data";
import { AbilityButton } from "./AbilityButton";
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })))); const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene"; GameScene.displayName = "MemoizedGameScene";
@@ -165,6 +168,7 @@ function PhaseOverlay() {
const blockbreaker = useGameStore((state) => state.blockbreaker); const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault); const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp); const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const singleScreen = isSingleScreenLayout();
if (runMode === "rpg-roguelike") return null; if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />; if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
@@ -225,7 +229,20 @@ function PhaseOverlay() {
<span>{eyebrow}</span> <span>{eyebrow}</span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{copy}</p> <p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small> <small>{singleScreen
? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
: 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>
</div>
);
}
function SingleScreenAbilityBar() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
return (
<div className="single-ability-bar" aria-label="Equipped abilities">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} compact />)}
</div> </div>
); );
} }
@@ -402,6 +419,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> <div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>} {onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div> </div>
<SingleScreenAbilityBar />
<GoalPopup /> <GoalPopup />
<BlockbreakerScorePopup /> <BlockbreakerScorePopup />
<AetherScorePopup /> <AetherScorePopup />
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext, type RpgRunUiContext,
type RpgRunUiProps, type RpgRunUiProps,
} from "./RpgRunUiShared"; } from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css"; import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: { function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action" className="rpg-card-action"
disabled={recruited && !canRemove} disabled={recruited && !canRemove}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`} label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton> ><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)} )}
/> />
); );
})} })}
</div> </div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}> <div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong> <strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => ( {run.roster.map((member) => (
<FocusButton <FocusButton
key={member.instanceId} key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`} className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove} disabled={!canRemove}
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton> >
<i style={{ background: member.color }} />
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
<PartyRoleBadge role={member.role} />
<b className="rpg-picked-remove" aria-hidden="true">×</b>
</FocusButton>
))} ))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)} {Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div> </div>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card" className="rpg-reward-card"
style={runAccentStyle(summary.accent)} style={runAccentStyle(summary.accent)}
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
> >
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b> <i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action" className="rpg-card-action"
disabled={disabled} disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton> ><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} /> } />
); );
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) { function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context; const { run } = context;
return ( return (
<section className="rpg-tactical-section"> <section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header> <header>
<h3>Party</h3>
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
</header>
<div className="rpg-tactical-party-grid"> <div className="rpg-tactical-party-grid">
{run.roster.map((member) => ( {run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? ( <PartyCard key={member.instanceId} member={member} compact action={interactive ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)} focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action" className="rpg-card-action"
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton> ><span>Remove</span></FocusButton>
) : undefined} /> ) : undefined} />
))} ))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => { {draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId); const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit); const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return ( return (
<FocusButton <FocusButton
key={candidate.candidateId} key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)} style={runAccentStyle(candidate.color)}
disabled={disabled} disabled={disabled}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
> >
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i> <i>{role.icon}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span> <span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b> <b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u> <u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton> </FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => { {chest.choices.map((choice) => {
const summary = rewardSummary(choice); const summary = rewardSummary(choice);
return ( return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}> <FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b> <i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid"> <div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => ( {shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={ <GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}> <FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span> <span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton> </FocusButton>
} /> } />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState, RpgRoguelikeRunState,
RunGearItem, RunGearItem,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike"; import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike"; import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike"; import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps { export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) { while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect(); const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) { const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top; if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom; else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
} }
if (parent.scrollWidth > parent.clientWidth) { if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left; if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right; else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
} }
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()} className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)} style={runAccentStyle(entry.color)}
> >
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div> <div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3> <h3>{entry.name}</h3>
<p>{entry.className}</p> <p>{entry.className}</p>
{!compact && ( {!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion"; return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
} }
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
const comparison = compareRunGear(run.equipment, item);
const ownerName = gearOwnerName(run, item);
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
const change = comparison.delta >= 0
? `Gain ${comparison.delta} percentage points`
: `Lose ${Math.abs(comparison.delta)} percentage points`;
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
}
export function GearStatComparison({ run, item }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
}) {
const ownerName = gearOwnerName(run, item);
const comparison = compareRunGear(run.equipment, item);
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
return (
<span
className="rpg-gear-comparison"
aria-label={gearComparisonLabel(run, item)}
>
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
<span>
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
<i aria-hidden="true"></i>
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
</span>
</span>
);
}
export function GearCard({ run, item, price, action }: { export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem; readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div> <div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small> <small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3> <h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p> {price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div> </div>
{action} {action}
</article> </article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker { .rpg-card-kicker {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 5px; gap: 5px;
color: var(--rarity-color, #e7e9ec); color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600; font-weight: 600;
} }
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3, .rpg-party-card h3,
.rpg-spell-card h3, .rpg-spell-card h3,
.rpg-gear-card h3 { .rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip, .rpg-picked-chip,
.rpg-empty-chip, .rpg-empty-chip,
.rpg-spellbook-chip { .rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip { .rpg-picked-chip {
position: relative; position: relative;
padding: 4px 18px 4px 10px; padding: 4px 6px 4px 10px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center; align-content: center;
gap: 5px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
@@ -414,18 +465,24 @@
width: 3px; width: 3px;
} }
.rpg-picked-chip > small { .rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden; overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.rpg-picked-chip > b { .rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
position: absolute; .rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
right: 6px;
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e; color: #82958e;
font-size: 14px;
} }
.rpg-empty-chip { .rpg-empty-chip {
@@ -870,7 +927,7 @@
} }
.rpg-gear-card { .rpg-gear-card {
min-height: 72px; min-height: 112px;
padding: 8px 8px 22px 43px; padding: 8px 8px 22px 43px;
} }
@@ -904,6 +961,12 @@
font-size: 9px; font-size: 9px;
} }
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list { .rpg-shop-side-list {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px; font-size: 9px;
} }
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list { .rpg-tactical-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
} }
.rpg-tactical-reward { .rpg-tactical-reward {
min-height: 94px; min-height: 130px;
padding: 9px 64px 9px 49px; padding: 9px 64px 9px 49px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid { .rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.rpg-run-tactical .rpg-gear-card { .rpg-run-tactical .rpg-gear-card {
min-height: 80px; min-height: 112px;
} }
.rpg-service-row { .rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px; min-height: 92px;
} }
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip, .rpg-picked-strip,
.rpg-spellbook-strip { .rpg-spellbook-strip {
overflow-x: auto; overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px; min-width: 100px;
} }
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout { .rpg-shop-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
overflow-y: auto; overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b { .rpg-tactical-offer > b {
display: none; display: none;
} }
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
} }
/* Single-display browser fallback gets a usable full-height draft surface. */ /* Single-display browser fallback gets a usable full-height draft surface. */
+27 -2
View File
@@ -9,14 +9,15 @@ import {
hockeyPvpHealingEffectiveness, hockeyPvpHealingEffectiveness,
hockeyPvpPuckSpeed, hockeyPvpPuckSpeed,
mirrorHockeyPvpPuck, mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
} from "./hockeyHealingPvp"; } from "./hockeyHealingPvp";
describe("Healing Hockey PVP", () => { describe("Healing Hockey PVP", () => {
it("starts rallies at the faster default puck speed", () => { it("starts rallies at the faster default puck speed", () => {
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" }); const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
expect(hockeyPvpPuckSpeed(0)).toBe(7.2); expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2); expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
}); });
it("adds five percent global dampening for every boss killed by either party", () => { it("adds five percent global dampening for every boss killed by either party", () => {
@@ -76,4 +77,28 @@ describe("Healing Hockey PVP", () => {
lastGoalSide: "local", lastGoalSide: "local",
}); });
}); });
it("predicts guest movement between snapshots and soft-corrects small drift", () => {
const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
guest.puckVelocity = [3, 6];
const predicted = advanceHockeyPvpPuck(guest, {
delta: 0.1,
localPlayerPosition: [0, 8.5],
localAimDirection: [0, -1],
opponentPlayerPosition: [0, 8.5],
opponentAimDirection: [0, -1],
});
expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
expect(predicted.localGoalsConceded).toBe(0);
const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
authoritative.goalSequence += 1;
authoritative.puckPosition = [0, 0];
expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
});
}); });
+62 -3
View File
@@ -59,13 +59,16 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
export const HOCKEY_PVP_PUCK_RADIUS = 0.42; export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05; export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
export const HOCKEY_PVP_GOAL_DAMAGE = 45; export const HOCKEY_PVP_GOAL_DAMAGE = 45;
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5; export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000; export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
const STARTING_SPEED = 7.2; const STARTING_SPEED = 7.8;
const MAX_SPEED = 11.5; const MAX_SPEED = 12.2;
const MAX_SUBSTEPS = 10; const MAX_SUBSTEPS = 10;
const MAX_SUBSTEP_DISTANCE = 0.32; const MAX_SUBSTEP_DISTANCE = 0.32;
const GUEST_RECONCILIATION_BLEND = 0.35;
const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const; const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number { export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
@@ -161,6 +164,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns); state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
} }
function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
puckVelocity: [...source.puckVelocity],
};
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
const subDelta = delta / substeps;
const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
for (let index = 0; index < substeps; index += 1) {
const end: WorldPosition = [
state.puckPosition[0] + state.puckVelocity[0] * subDelta,
state.puckPosition[1] + state.puckVelocity[1] * subDelta,
];
if (end[0] < minX || end[0] > maxX) {
end[0] = Math.max(minX, Math.min(maxX, end[0]));
state.puckVelocity[0] *= -1;
}
if (end[1] >= HOCKEY_PVP_GOAL_Z) {
end[1] = HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
} else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
end[1] = -HOCKEY_PVP_GOAL_Z;
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
}
state.puckPosition = end;
}
return state;
}
export function reconcileHockeyPvpPuck(
predicted: HockeyPvpPuckState,
authoritative: HockeyPvpPuckState,
): HockeyPvpPuckState {
const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
|| Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
return {
...authoritative,
puckPosition: shouldSnap
? [...authoritative.puckPosition]
: [
predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
],
puckVelocity: [...authoritative.puckVelocity],
};
}
export function advanceHockeyPvpPuck( export function advanceHockeyPvpPuck(
source: HockeyPvpState, source: HockeyPvpState,
step: { step: {
@@ -171,7 +227,10 @@ export function advanceHockeyPvpPuck(
opponentAimDirection: WorldPosition; opponentAimDirection: WorldPosition;
}, },
): HockeyPvpState { ): HockeyPvpState {
if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source; if (source.status !== "live" || step.delta <= 0) return source;
// Guest predicts travel only. Host snapshots remain authoritative for contacts,
// goals, damage, and rally counters.
if (source.role === "guest") return predictGuestPuck(source, step.delta);
const state: HockeyPvpState = { const state: HockeyPvpState = {
...source, ...source,
puckPosition: [...source.puckPosition], puckPosition: [...source.puckPosition],
+61 -3
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { createClassInventory } from "./healers"; import { createClassInventory } from "./healers";
import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp"; import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
import { upcomingEncounterMechanic, useGameStore } from "./store"; import { upcomingEncounterMechanic, useGameStore } from "./store";
import { freshParty } from "./data"; import { freshParty } from "./data";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear"; import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
@@ -42,6 +42,7 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id); expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
expect(state.hockeyPvp.opponentName).toBe("CPU Aster"); expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
}); });
it("normalizes both parties to default base gear without changing saved upgrades", () => { it("normalizes both parties to default base gear without changing saved upgrades", () => {
@@ -170,13 +171,16 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id); expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
}); });
it("wins when opponent party falls", () => { it("wins when opponent companions fall while rival healer remains alive", () => {
useGameStore.getState().startEncounter(); useGameStore.getState().startEncounter();
useGameStore.getState().setActiveTab("map"); useGameStore.getState().setActiveTab("map");
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
hockeyPvpOpponent: { hockeyPvpOpponent: {
...state.hockeyPvpOpponent, ...state.hockeyPvpOpponent,
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })), party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}, },
})); }));
useGameStore.getState().tick(0.01); useGameStore.getState().tick(0.01);
@@ -185,6 +189,60 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().activeTab).toBe("combat"); expect(useGameStore.getState().activeTab).toBe("combat");
}); });
it("loses when local companions fall while healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
});
it("wins an online match when remote companions fall while rival healer remains alive", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
[hockeyPvpBossAt(MATCH.seed, 0)],
"hockey-healing-pvp",
undefined,
"initiate",
{ ...MATCH, matchId: "online-match", role: "guest" },
);
useGameStore.getState().startEncounter();
const state = useGameStore.getState();
const snapshot: HockeyPvpRemoteSnapshot = {
sequence: 1,
time: state.time,
party: state.hockeyPvpOpponent.party.map((member) => ({
...member,
hp: member.id === "aelia" ? member.hp : 0,
})),
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
boss: {
id: state.hockeyPvpOpponent.boss.id,
name: state.hockeyPvpOpponent.boss.name,
hp: state.hockeyPvpOpponent.boss.hp,
maxHp: state.hockeyPvpOpponent.boss.maxHp,
},
bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
bossMode: state.hockeyPvpOpponent.bossMotion.mode,
bossKills: 0,
playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
aimDirection: [...state.hockeyPvp.opponentAimDirection],
};
useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
expect(useGameStore.getState().phase).toBe("victory");
expect(useGameStore.getState().hockeyPvp.status).toBe("won");
});
it("keeps HUD mechanic data defined during instant boss replacement", () => { it("keeps HUD mechanic data defined during instant boss replacement", () => {
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } })); useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({ expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run"; export * from "./run";
export * from "./playSpace"; export * from "./playSpace";
export * from "./uiModel"; export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"]; export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = { const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" }, weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" }, armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId]; return equipment[ownerId]?.[slotId];
} }
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem( export function createRunGearItem(
id: string, id: string,
ownerId: RunGearOwnerId, ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots, assignRosterToCombatSlots,
autoEquipRunGear, autoEquipRunGear,
challengeObjective, challengeObjective,
compareRunGear,
createRandomState, createRandomState,
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker); expect(third.bag).toContain(weaker);
}); });
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => { it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501); let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1); const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
moveRpgFocus, moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun, reduceRpgRoguelikeRun,
rpgFocusId, rpgFocusId,
rpgFocusItems, rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
} }
describe("RPG Roguelike semantic UI focus", () => { describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => { it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 }); let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers; const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types"; import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run"; import { canRemovePartyMember } from "./run";
import { import {
MAX_ACTIVE_ROSTER, MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down"; export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = { export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`, partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`, partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return []; if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action( return [action(
rpgFocusId.partyOffer(candidate.candidateId), rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`, `${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited recruited
? { type: "party-remove", memberId: candidate.candidateId } ? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId }, : { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
}); });
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action( const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId), rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`, `Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId }, { type: "party-remove", memberId: member.instanceId },
)) : []; )) : [];
return [ return [
+26 -1
View File
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena"; import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { createClassInventory } from "./healers"; import { createClassInventory } from "./healers";
import { createDefaultGearProgress } from "./progression/gear"; import { createDefaultGearProgress } from "./progression/gear";
import { equipPassiveInfusion } from "./progression/infusions"; import { equipPassiveInfusion } from "./progression/infusions";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike"; import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
function finishRpgDrafts() { function finishRpgDrafts() {
@@ -113,6 +114,30 @@ describe("RPG Roguelike store integration", () => {
expect(state.endlessMode).toBe(true); 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", () => { it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
finishRpgDrafts(); finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
+13 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { BULL_CHARGE } from "./bossMechanics"; import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry"; import { distance, pointToSegmentDistance } from "./geometry";
import { BARRIER_RADIUS, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store"; import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers"; import { createClassInventory, HEALER_CLASSES } from "./healers";
import { healingEffect } from "./healerEffects"; import { healingEffect } from "./healerEffects";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
@@ -61,6 +61,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", () => { it("uses a three-second Purify cooldown for every healer class", () => {
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3); expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3); expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
+20 -11
View File
@@ -70,6 +70,7 @@ import {
type HockeyHealingState, type HockeyHealingState,
} from "./hockeyHealing"; } from "./hockeyHealing";
import { import {
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_DAMAGE,
advanceHockeyPvpCpuGoalie, advanceHockeyPvpCpuGoalie,
advanceHockeyPvpPuck, advanceHockeyPvpPuck,
@@ -77,6 +78,7 @@ import {
hockeyPvpHealingEffectiveness, hockeyPvpHealingEffectiveness,
hockeyPvpBossAt, hockeyPvpBossAt,
mirrorHockeyPvpPuck, mirrorHockeyPvpPuck,
reconcileHockeyPvpPuck,
type HockeyPvpMatchConfig, type HockeyPvpMatchConfig,
type HockeyPvpRemoteSnapshot, type HockeyPvpRemoteSnapshot,
type HockeyPvpState, type HockeyPvpState,
@@ -120,7 +122,6 @@ import {
} from "./aetherAssault"; } from "./aetherAssault";
import { import {
assignRosterToCombatSlots, assignRosterToCombatSlots,
BOSSES_PER_ACT,
createRpgRoguelikeRun, createRpgRoguelikeRun,
reduceRpgRoguelikeRun, reduceRpgRoguelikeRun,
selectCurrentBossId, selectCurrentBossId,
@@ -136,6 +137,7 @@ import {
spellRankPowerMultiplier, spellRankPowerMultiplier,
type RpgPartyDamageProfiles, type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter"; } from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace"; import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel"; import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -266,6 +268,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5; export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500; export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
export const BARRIER_RADIUS = 4; export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3; export const BARRIER_DAMAGE_REDUCTION = 0.3;
@@ -628,7 +631,8 @@ function initialState(
runModifiers, runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower, healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug, difficultySlug,
difficultyDamageMultiplier: difficulty.damageMultiplier, difficultyDamageMultiplier: difficulty.damageMultiplier
* (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
gearProgress, gearProgress,
gearModifiers, gearModifiers,
time: 0, time: 0,
@@ -700,9 +704,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner] ? [currentBossId, challengePartner]
: [currentBossId]; : [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard"; const layout: EncounterLayout = challenge ? "hockey" : "standard";
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT); const modeDifficulty = rpgEncounterDifficulty(
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14) challenge ? "hallway-challenge" : "boss-room",
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier; run.bossIndex,
);
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId, bossId,
index, index,
@@ -747,6 +754,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party, party,
gearModifiers: projection.gearModifiers, gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower, healingMultiplier: projection.gearModifiers.aelia.healingPower,
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party), partyCombat: createPartyCombatState(party),
partyDamageEvents: [], partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout), partyPositions: freshPartyPositions(bossIds, layout),
@@ -1106,7 +1114,7 @@ export const useGameStore = create<GameState>((set, get) => ({
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => { applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state; if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
? mirrorHockeyPvpPuck(hostPuck) ? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
: undefined; : undefined;
const previousLocalGoals = state.hockeyPvp.localGoalsConceded; const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals; const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
@@ -1121,8 +1129,8 @@ export const useGameStore = create<GameState>((set, get) => ({
}) })
: state.party; : state.party;
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })); const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
const opponentWiped = isPartyWiped(opponentParty); const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
const localWiped = isPartyWiped(party); const localWiped = areAllNonHealerAlliesDefeated(party);
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase; const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
return { return {
party, party,
@@ -2104,7 +2112,8 @@ export const useGameStore = create<GameState>((set, get) => ({
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost"; const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost"; const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
const pvpMode = state.activityMode === "hockey-healing-pvp"; const pvpMode = state.activityMode === "hockey-healing-pvp";
const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party); const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active"; const rpgChallengeActive = rpgRun?.phase === "challenge-active";
const rpgBossActive = rpgRun?.phase === "boss-combat"; const rpgBossActive = rpgRun?.phase === "boss-combat";
if (rpgChallengeActive && rpgRun) { if (rpgChallengeActive && rpgRun) {
@@ -2186,7 +2195,7 @@ export const useGameStore = create<GameState>((set, get) => ({
phase = "combat"; phase = "combat";
endlessMode = false; endlessMode = false;
} else if (pvpMode) { } else if (pvpMode) {
if (partyWiped) { if (localPvpTeamDefeated) {
phase = "defeat"; phase = "defeat";
hockeyPvp.status = "lost"; hockeyPvp.status = "lost";
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger"); combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
@@ -2254,7 +2263,7 @@ export const useGameStore = create<GameState>((set, get) => ({
hockeyPvp, hockeyPvp,
hockeyPvpOpponent, hockeyPvpOpponent,
runBuffInputUnlockAt, runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast, activeCast,
combatLog, combatLog,
scenePulse: pulse, scenePulse: pulse,
+14
View File
@@ -5,6 +5,16 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings"; import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
import { cycleBottomTab } from "./bottomTabs"; import { cycleBottomTab } from "./bottomTabs";
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel"; import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
import { getDisplaySurface } from "../platform/displayRouting";
import { isSingleScreenLayout } from "../platform/displayLayout";
function tacticalOverlayOwnsInput() {
const store = useGameStore.getState();
if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
if (store.paused || store.phase === "intermission") return false;
if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
}
function cycleRunBuff(direction: 1 | -1) { function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -35,9 +45,11 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return; if (!enabled) return;
if (event.defaultPrevented) return;
if (event.repeat) return; if (event.repeat) return;
const store = useGameStore.getState(); const store = useGameStore.getState();
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (tacticalOverlayOwnsInput()) return;
if (store.paused) { if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault(); if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false); if (key === "escape") store.setPaused(false);
@@ -124,6 +136,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => subscribeControllerToken(({ token, repeat }) => { useEffect(() => subscribeControllerToken(({ token, repeat }) => {
if (!enabled) return; if (!enabled) return;
const store = useGameStore.getState(); const store = useGameStore.getState();
if (isSingleScreenLayout() && token === "Button8") return;
if (tacticalOverlayOwnsInput()) return;
if (store.paused) { if (store.paused) {
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume"); if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit"); if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
+3
View File
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
import App from "./App"; import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp"; import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller"; import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import "./styles.css"; import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout"); const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display"); const displayMode = new URLSearchParams(window.location.search).get("display");
const displayLayout = currentDisplayLayout();
if (Capacitor.isNativePlatform() || nativeLayoutRequested) { if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform"); document.documentElement.classList.add("native-platform");
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
if (displayMode === "top" || displayMode === "bottom") { if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode; document.documentElement.dataset.displaySurface = displayMode;
} }
document.documentElement.dataset.displayLayout = displayLayout;
startControllerInput(); startControllerInput();
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveDisplayLayout } from "./displayLayout";
describe("resolveDisplayLayout", () => {
it("uses the top-screen-first single layout for ordinary browsers", () => {
expect(resolveDisplayLayout({})).toBe("single");
expect(resolveDisplayLayout({ layout: "single" })).toBe("single");
});
it("keeps the dual-screen hardware mockup behind an explicit preview", () => {
expect(resolveDisplayLayout({ layout: "thor-preview" })).toBe("thor-preview");
expect(resolveDisplayLayout({ layout: "dual" })).toBe("thor-preview");
});
it("never overrides a dedicated Android display surface", () => {
expect(resolveDisplayLayout({ display: "top", layout: "single" })).toBe("dedicated");
expect(resolveDisplayLayout({ display: "bottom", layout: "thor-preview" })).toBe("dedicated");
});
});
+29
View File
@@ -0,0 +1,29 @@
export type DisplayLayout = "single" | "thor-preview" | "dedicated";
export interface DisplayLayoutRequest {
display?: string | null;
layout?: string | null;
}
/**
* Physical Thor surfaces are selected explicitly by the Android host. Every
* ordinary browser viewport is a single-screen game unless a developer asks
* for the dual-screen hardware preview.
*/
export function resolveDisplayLayout({ display, layout }: DisplayLayoutRequest): DisplayLayout {
if (display === "top" || display === "bottom") return "dedicated";
if (layout === "thor-preview" || layout === "dual") return "thor-preview";
return "single";
}
export function currentDisplayLayout(search = window.location.search): DisplayLayout {
const params = new URLSearchParams(search);
return resolveDisplayLayout({
display: params.get("display"),
layout: params.get("layout"),
});
}
export function isSingleScreenLayout(search = window.location.search) {
return currentDisplayLayout(search) === "single";
}
+10 -1
View File
@@ -1,13 +1,22 @@
export type DisplaySurface = "top" | "bottom"; export type DisplaySurface = "top" | "bottom";
const DISPLAY_SURFACE_EVENT = "thor:display-surface"; const DISPLAY_SURFACE_EVENT = "thor:display-surface";
let currentSurface: DisplaySurface = "top";
export function requestDisplaySurface(surface: DisplaySurface) { export function requestDisplaySurface(surface: DisplaySurface) {
currentSurface = surface;
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface })); window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
} }
export function getDisplaySurface() {
return currentSurface;
}
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) { export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail); const onSurface = (event: Event) => {
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
listener(currentSurface);
};
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface); window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface); return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
} }
+265
View File
@@ -118,6 +118,224 @@ button:focus-visible {
display: none; display: none;
} }
/* PC and handheld browsers use the Thor top surface as the canonical game
viewport. The lower surface becomes a disclosed tactical layer. */
html[data-display-layout="single"],
html[data-display-layout="single"] body,
html[data-display-layout="single"] #root,
html[data-display-layout="single"] .app-shell,
.single-display-frame,
.single-primary-surface {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
html[data-display-layout="single"] .app-shell {
padding: 0;
}
html[data-display-layout="single"] .app-header {
display: none;
}
.single-display-frame {
position: relative;
isolation: isolate;
background: #030706;
}
.single-primary-surface {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.single-primary-surface > .display,
.single-primary-surface > .top-display,
.single-primary-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-primary-surface > .top-display {
container-name: single-game-screen;
container-type: size;
}
.single-context-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
}
.single-context-backdrop {
position: absolute;
inset: 0;
border: 0;
background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
backdrop-filter: blur(5px);
cursor: pointer;
}
.single-context-surface {
position: relative;
z-index: 1;
width: min(100%, calc((100dvh - 28px) * 31 / 27));
max-width: 760px;
max-height: calc(100dvh - 28px);
aspect-ratio: 31 / 27;
overflow: hidden;
border: 1px solid rgba(232, 200, 114, 0.42);
border-radius: 8px;
background: #07110f;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
}
.single-context-surface > .bottom-display,
.single-context-surface > .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
border: 0;
border-radius: 0;
box-shadow: none;
}
.single-context-toggle {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 100;
min-width: 94px;
display: grid;
gap: 1px;
padding: 7px 10px;
border: 1px solid rgba(232, 200, 114, 0.62);
border-radius: 4px;
color: var(--ink);
background: rgba(3, 9, 8, 0.92);
box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
text-align: left;
cursor: pointer;
}
.single-context-toggle b {
color: var(--gold-strong);
font-size: 10px;
line-height: 1;
letter-spacing: .08em;
text-transform: uppercase;
}
.single-context-toggle small {
color: var(--muted);
font-size: 7px;
line-height: 1.2;
}
.single-display-frame.context-open .single-context-toggle {
top: max(18px, env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
bottom: auto;
}
html[data-display-layout="single"] .top-party {
width: clamp(185px, 20cqw, 330px);
gap: clamp(3px, .55cqh, 7px);
}
html[data-display-layout="single"] .top-party-member {
min-height: clamp(38px, 7.8cqh, 72px);
grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
gap: clamp(5px, .75cqw, 11px);
padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
}
html[data-display-layout="single"] .portrait-dot {
width: clamp(26px, 3cqw, 48px);
height: clamp(26px, 3cqw, 48px);
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy strong {
font-size: clamp(10px, 1.05cqw, 17px);
}
html[data-display-layout="single"] .top-party-copy small {
font-size: clamp(7px, .68cqw, 11px);
}
html[data-display-layout="single"] .microbar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(5px, .58cqh, 8px);
}
html[data-display-layout="single"] .microbar.player-health-bar {
bottom: clamp(9px, 1.05cqh, 15px);
}
html[data-display-layout="single"] .player-mana-bar {
right: clamp(5px, .7cqw, 11px);
bottom: clamp(3px, .4cqh, 6px);
left: clamp(37px, 4.25cqw, 68px);
height: clamp(3px, .38cqh, 6px);
}
html[data-display-layout="single"] .boss-bar-wrap {
width: min(39%, 660px);
}
html[data-display-layout="single"] .boss-name {
font-size: clamp(7px, .62cqw, 11px);
}
html[data-display-layout="single"] .boss-name strong {
font-size: clamp(12px, 1.2cqw, 20px);
}
html[data-display-layout="single"] .objective-chip span {
font-size: clamp(6px, .58cqw, 10px);
}
html[data-display-layout="single"] .objective-chip strong {
font-size: clamp(8px, .82cqw, 14px);
}
html[data-display-layout="single"] .casting-bar {
bottom: clamp(76px, 11cqh, 126px);
}
html[data-display-layout="single"] .control-hint {
display: none;
}
html[data-display-layout="single"] .encounter-callout {
bottom: clamp(74px, 10cqh, 118px);
}
@media (min-width: 1200px) and (min-height: 700px) {
.single-context-layer {
justify-items: end;
padding-right: max(24px, env(safe-area-inset-right));
}
.single-context-surface {
width: min(56vw, 720px);
}
}
.screen-label { .screen-label {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -1066,6 +1284,19 @@ button:focus-visible {
gap: 4%; gap: 4%;
} }
.single-ability-bar {
position: absolute;
z-index: 4;
bottom: max(12px, env(safe-area-inset-bottom));
left: 50%;
width: min(64vw, 760px);
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: clamp(3px, .45vw, 7px);
transform: translateX(-50%);
pointer-events: auto;
}
.ability { .ability {
--ability-color: #ddd; --ability-color: #ddd;
position: relative; position: relative;
@@ -1086,6 +1317,40 @@ button:focus-visible {
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease; transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
} }
.ability.is-compact {
height: clamp(48px, 7.2dvh, 68px);
grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
gap: clamp(3px, .45vw, 7px);
padding: 5px;
background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
}
.ability.is-compact .ability-icon {
width: clamp(27px, 3.1vw, 37px);
height: clamp(27px, 3.1vw, 37px);
font-size: clamp(14px, 1.45vw, 20px);
}
.ability.is-compact .ability-copy strong {
font-size: clamp(7px, .72vw, 11px);
}
.ability.is-compact .ability-copy small {
display: none;
}
.ability.is-compact .ability-key,
.ability.is-compact .ability-pad {
font-size: clamp(5px, .46vw, 7px);
}
.ability.is-compact .cooldown-mask b {
width: clamp(28px, 3vw, 38px);
height: clamp(28px, 3vw, 38px);
font-size: clamp(11px, 1.1vw, 16px);
}
.ability::after { .ability::after {
content: ""; content: "";
position: absolute; position: absolute;