Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016a012c78 | ||
|
|
5045f17b94 |
@@ -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/
|
||||||
|
|||||||
@@ -97,8 +97,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"
|
||||||
|
|||||||
+1
-1
@@ -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.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
|
||||||
|
result = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
result = gitea_request(
|
result = gitea_request(
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
|
path,
|
||||||
token=token,
|
token=token,
|
||||||
method="POST",
|
method="POST",
|
||||||
body=payload,
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
} />
|
} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 50–90 seconds, with 100 seconds as the hard cap.
|
||||||
|
expect(duration).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
+8
-4
@@ -120,7 +120,6 @@ import {
|
|||||||
} from "./aetherAssault";
|
} from "./aetherAssault";
|
||||||
import {
|
import {
|
||||||
assignRosterToCombatSlots,
|
assignRosterToCombatSlots,
|
||||||
BOSSES_PER_ACT,
|
|
||||||
createRpgRoguelikeRun,
|
createRpgRoguelikeRun,
|
||||||
reduceRpgRoguelikeRun,
|
reduceRpgRoguelikeRun,
|
||||||
selectCurrentBossId,
|
selectCurrentBossId,
|
||||||
@@ -136,6 +135,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";
|
||||||
|
|
||||||
@@ -700,9 +700,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 +750,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),
|
||||||
|
|||||||
Reference in New Issue
Block a user