diff --git a/package.json b/package.json
index 6d38d8f..aa6e950 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
- "version": "0.1.16",
+ "version": "0.1.17",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
diff --git a/src/components/rpgRoguelike/PartyRoleBadge.test.ts b/src/components/rpgRoguelike/PartyRoleBadge.test.ts
new file mode 100644
index 0000000..d12befa
--- /dev/null
+++ b/src/components/rpgRoguelike/PartyRoleBadge.test.ts
@@ -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"');
+ });
+});
diff --git a/src/components/rpgRoguelike/PartyRoleBadge.tsx b/src/components/rpgRoguelike/PartyRoleBadge.tsx
new file mode 100644
index 0000000..7292f24
--- /dev/null
+++ b/src/components/rpgRoguelike/PartyRoleBadge.tsx
@@ -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 (
+
+ {presentation.icon}
+ {presentation.label}
+
+ );
+}
diff --git a/src/components/rpgRoguelike/RpgRunOverlay.tsx b/src/components/rpgRoguelike/RpgRunOverlay.tsx
index 2c506b0..80e25ec 100644
--- a/src/components/rpgRoguelike/RpgRunOverlay.tsx
+++ b/src/components/rpgRoguelike/RpgRunOverlay.tsx
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
+ partyCompositionLabel,
+ partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss,
FocusButton,
GearCard,
+ GearStatComparison,
+ gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
+import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
- label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
+ label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}
)}
/>
);
})}
-
-
Party {run.roster.length}/{MAX_ACTIVE_ROSTER}
+
+
Party {run.roster.length}/{MAX_ACTIVE_ROSTER}{partyCompositionLabel(run.roster)}
{run.roster.map((member) => (
{member.name}{member.className}×
+ label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
+ >
+
+
{member.name}{member.className}
+
+
×
+
))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) =>
Open)}
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card"
style={runAccentStyle(summary.accent)}
+ label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
>
-
{summary.icon}{summary.eyebrow}{choice.label}
{summary.detail}
Claim
+
{summary.icon}{summary.eyebrow}{choice.label}
{summary.detail}
+ {choice.kind === "run-gear" &&
}
+
Claim
);
})}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action"
disabled={disabled}
- label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
+ label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
>
{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}
} />
);
diff --git a/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx b/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx
index b5bbaec..2ffe337 100644
--- a/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx
+++ b/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
+ partyCompositionLabel,
+ partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss,
FocusButton,
GearCard,
+ GearStatComparison,
+ gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
-
- Party
{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing
+
+
+ Party
+ {partyCompositionLabel(run.roster)}{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing
+
{run.roster.map((member) => (
Remove
) : undefined} />
))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
+ const role = partyRolePresentation(candidate.role);
return (
- {candidate.role === "Tank" ? "⬡" : "⚔"}
- {candidate.rarity} · {candidate.role}{candidate.name}{candidate.className}
+ {role.icon}
+ {candidate.rarity} · {role.label}{candidate.name}{candidate.className}
HP {candidate.stats.maxHp}ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}
{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
return (
-
- {summary.icon}{summary.eyebrow}{choice.label}{summary.detail}
Claim
+
+ {summary.icon}{summary.eyebrow}{choice.label}{summary.detail}
{choice.kind === "run-gear" && }Claim
);
})}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
{shop.offers.map((offer) => (
+
{offer.sold ? "Sold" : "Buy"}
} />
diff --git a/src/components/rpgRoguelike/RpgRunUiShared.tsx b/src/components/rpgRoguelike/RpgRunUiShared.tsx
index 11ed8d8..957d177 100644
--- a/src/components/rpgRoguelike/RpgRunUiShared.tsx
+++ b/src/components/rpgRoguelike/RpgRunUiShared.tsx
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
-import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
+import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
+import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
- if (parent.scrollHeight > parent.clientHeight) {
+ const overflow = getComputedStyle(parent);
+ const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
+ const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
+ if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
- if (parent.scrollWidth > parent.clientWidth) {
+ if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
- {entry.rarity}{entry.role}
+
{entry.name}
{entry.className}
{!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
}
+export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
+ const comparison = compareRunGear(run.equipment, item);
+ const ownerName = gearOwnerName(run, item);
+ const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
+ const change = comparison.delta >= 0
+ ? `Gain ${comparison.delta} percentage points`
+ : `Lose ${Math.abs(comparison.delta)} percentage points`;
+ return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
+}
+
+export function GearStatComparison({ run, item }: {
+ readonly run: RpgRoguelikeRunState;
+ readonly item: RunGearItem;
+}) {
+ const ownerName = gearOwnerName(run, item);
+ const comparison = compareRunGear(run.equipment, item);
+ const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
+ const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
+ return (
+
+ {ownerName} · {comparison.effectLabel}{delta}
+
+ Current · {currentRank}+{comparison.currentValue}%
+ →
+ Replacement · +{item.enhancement}+{comparison.replacementValue}%
+
+
+ );
+}
+
export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
{gearOwnerName(run, item)} · {item.slotId}
{item.name}
-
+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}
+ {price !== undefined &&
◆ {price}
}
+
{action}
diff --git a/src/components/rpgRoguelike/rpgRoguelike.css b/src/components/rpgRoguelike/rpgRoguelike.css
index d48e3b5..718dfbc 100644
--- a/src/components/rpgRoguelike/rpgRoguelike.css
+++ b/src/components/rpgRoguelike/rpgRoguelike.css
@@ -235,6 +235,7 @@
.rpg-card-kicker {
display: flex;
+ align-items: center;
justify-content: space-between;
gap: 5px;
color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600;
}
+.rpg-role-badge {
+ min-width: 44px;
+ padding: 2px 5px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: 8px;
+ letter-spacing: 0.08em;
+ line-height: 1;
+ text-transform: uppercase;
+}
+
+.rpg-role-badge.is-tank {
+ color: #9bd9ff;
+ background: rgba(65, 145, 199, 0.2);
+}
+
+.rpg-role-badge.is-damage {
+ color: #ffc27c;
+ background: rgba(204, 111, 55, 0.19);
+}
+
+.rpg-role-badge > i {
+ font-size: 9px;
+ font-style: normal;
+}
+
+.rpg-role-badge > b {
+ color: inherit;
+ font-weight: 800;
+}
+
.rpg-party-card h3,
.rpg-spell-card h3,
.rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase;
}
+.rpg-picked-strip > strong {
+ width: 108px;
+ flex: 0 0 108px;
+}
+
+.rpg-picked-strip > strong > small {
+ color: var(--rpg-gold);
+ font-size: 8px;
+ letter-spacing: 0;
+ white-space: nowrap;
+}
+
.rpg-picked-chip,
.rpg-empty-chip,
.rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip {
position: relative;
- padding: 4px 18px 4px 10px;
+ padding: 4px 6px 4px 10px;
display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
align-content: center;
+ gap: 5px;
cursor: pointer;
text-align: left;
}
@@ -414,18 +465,24 @@
width: 3px;
}
-.rpg-picked-chip > small {
+.rpg-picked-copy {
+ min-width: 0;
+ display: grid;
+}
+
+.rpg-picked-copy > strong,
+.rpg-picked-copy > small {
overflow: hidden;
- color: var(--rpg-muted);
- font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
-.rpg-picked-chip > b {
- position: absolute;
- right: 6px;
+.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
+.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
+
+.rpg-picked-chip > .rpg-picked-remove {
color: #82958e;
+ font-size: 14px;
}
.rpg-empty-chip {
@@ -870,7 +927,7 @@
}
.rpg-gear-card {
- min-height: 72px;
+ min-height: 112px;
padding: 8px 8px 22px 43px;
}
@@ -904,6 +961,12 @@
font-size: 9px;
}
+.rpg-gear-card .rpg-gear-price {
+ margin: 2px 0 0;
+ color: var(--rpg-gold);
+ font-weight: 700;
+}
+
.rpg-shop-side-list {
min-height: 0;
overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px;
}
+.rpg-party-composition {
+ display: flex;
+ align-items: baseline;
+ gap: 7px;
+}
+
+.rpg-party-composition > b {
+ color: var(--rpg-gold);
+ font-size: 9px;
+}
+
+.rpg-party-composition > small {
+ color: var(--rpg-muted);
+ font-size: 8px;
+}
+
.rpg-tactical-list {
display: grid;
gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
}
.rpg-tactical-reward {
- min-height: 94px;
+ min-height: 130px;
padding: 9px 64px 9px 49px;
position: relative;
cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase;
}
+.rpg-gear-comparison {
+ min-width: 0;
+ margin-top: 7px;
+ padding-top: 6px;
+ display: grid;
+ gap: 5px;
+ color: #dce8e2;
+ border-top: 1px solid var(--rpg-line);
+ font-style: normal;
+}
+
+.rpg-gear-comparison > strong {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 5px;
+ overflow: hidden;
+ color: #dce8e2;
+ font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
+ font-size: 9px;
+ letter-spacing: 0.04em;
+ line-height: 1.15;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rpg-gear-comparison > strong > em {
+ flex: 0 0 auto;
+ padding: 2px 4px;
+ color: #9de1b8;
+ border-radius: 2px;
+ background: rgba(70, 167, 108, 0.15);
+ font-size: 8px;
+ font-style: normal;
+}
+
+.rpg-gear-comparison > span {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ align-items: center;
+ gap: 5px;
+}
+
+.rpg-gear-comparison > span > span {
+ min-width: 0;
+ display: grid;
+ gap: 1px;
+}
+
+.rpg-gear-comparison small,
+.rpg-tactical-reward .rpg-gear-comparison small {
+ overflow: hidden;
+ color: #82958e;
+ font-size: 8px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ line-height: 1.1;
+ text-overflow: ellipsis;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.rpg-gear-comparison b {
+ color: #f1f7f4;
+ font-size: 12px;
+ line-height: 1.1;
+}
+
+.rpg-gear-comparison > span > i {
+ color: #75bceb;
+ font-size: 11px;
+ font-style: normal;
+}
+
.rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rpg-run-tactical .rpg-gear-card {
- min-height: 80px;
+ min-height: 112px;
}
.rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px;
}
+ .rpg-reward-grid {
+ grid-template-columns: 1fr;
+ overflow-y: auto;
+ }
+
+ .rpg-reward-card {
+ min-height: 220px;
+ }
+
.rpg-picked-strip,
.rpg-spellbook-strip {
overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px;
}
+ .rpg-picked-chip {
+ min-width: 142px;
+ }
+
.rpg-shop-layout {
grid-template-columns: 1fr;
overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b {
display: none;
}
+
+ .rpg-party-composition {
+ display: grid;
+ gap: 0;
+ text-align: right;
+ }
}
/* Single-display browser fallback gets a usable full-height draft surface. */
diff --git a/src/game/rpgRoguelike/difficulty.test.ts b/src/game/rpgRoguelike/difficulty.test.ts
new file mode 100644
index 0000000..0a7deb1
--- /dev/null
+++ b/src/game/rpgRoguelike/difficulty.test.ts
@@ -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));
+ });
+});
diff --git a/src/game/rpgRoguelike/difficulty.ts b/src/game/rpgRoguelike/difficulty.ts
new file mode 100644
index 0000000..afbf9a5
--- /dev/null
+++ b/src/game/rpgRoguelike/difficulty.ts
@@ -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),
+ };
+}
diff --git a/src/game/rpgRoguelike/difficultySimulation.test.ts b/src/game/rpgRoguelike/difficultySimulation.test.ts
new file mode 100644
index 0000000..c5fcd35
--- /dev/null
+++ b/src/game/rpgRoguelike/difficultySimulation.test.ts
@@ -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);
+ });
+});
diff --git a/src/game/rpgRoguelike/index.ts b/src/game/rpgRoguelike/index.ts
index 0651958..582d1ce 100644
--- a/src/game/rpgRoguelike/index.ts
+++ b/src/game/rpgRoguelike/index.ts
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run";
export * from "./playSpace";
export * from "./uiModel";
+export * from "./difficulty";
diff --git a/src/game/rpgRoguelike/rewards.ts b/src/game/rpgRoguelike/rewards.ts
index 92bbc05..d06f4df 100644
--- a/src/game/rpgRoguelike/rewards.ts
+++ b/src/game/rpgRoguelike/rewards.ts
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
+export interface RunGearComparison {
+ readonly currentItem: RunGearItem | undefined;
+ readonly effectLabel: string;
+ readonly currentValue: number;
+ readonly replacementValue: number;
+ readonly delta: number;
+}
+
const GEAR_SLOT_DATA: Record = {
weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId];
}
+/** Player weapons convert their damage budget into healing power in combat. */
+export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
+ if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
+ if (statId === "maxHealth") return "Max health";
+ return "Haste";
+}
+
+/** Comparison data shared by chest and shop projections on either display. */
+export function compareRunGear(
+ equipment: RunEquipment,
+ item: RunGearItem,
+): RunGearComparison {
+ const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
+ const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
+ return {
+ currentItem,
+ effectLabel: runGearEffectLabel(item.ownerId, item.statId),
+ currentValue,
+ replacementValue: item.statValue,
+ delta: item.statValue - currentValue,
+ };
+}
+
export function createRunGearItem(
id: string,
ownerId: RunGearOwnerId,
diff --git a/src/game/rpgRoguelike/rpgRoguelike.test.ts b/src/game/rpgRoguelike/rpgRoguelike.test.ts
index b84fd78..753beba 100644
--- a/src/game/rpgRoguelike/rpgRoguelike.test.ts
+++ b/src/game/rpgRoguelike/rpgRoguelike.test.ts
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots,
autoEquipRunGear,
challengeObjective,
+ compareRunGear,
createRandomState,
createRpgRoguelikeRun,
createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker);
});
+ it("describes current and replacement gear buffs using combat-facing stat names", () => {
+ const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
+ const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
+ const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
+ const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
+ const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
+ const equipment = { player: { weapon: playerWeapon } };
+
+ expect(compareRunGear(equipment, playerUpgrade)).toEqual({
+ currentItem: playerWeapon,
+ effectLabel: "Healing power",
+ currentValue: 12,
+ replacementValue: 24,
+ delta: 12,
+ });
+ expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
+ currentItem: undefined,
+ effectLabel: "Damage",
+ currentValue: 0,
+ replacementValue: 16,
+ delta: 16,
+ });
+ expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
+ expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
+ });
+
it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1);
diff --git a/src/game/rpgRoguelike/uiModel.test.ts b/src/game/rpgRoguelike/uiModel.test.ts
index 39e3977..cd32586 100644
--- a/src/game/rpgRoguelike/uiModel.test.ts
+++ b/src/game/rpgRoguelike/uiModel.test.ts
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun,
createRunGearItem,
moveRpgFocus,
+ partyCompositionLabel,
+ partyRolePresentation,
reduceRpgRoguelikeRun,
rpgFocusId,
rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
}
describe("RPG Roguelike semantic UI focus", () => {
+ it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
+ expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
+ expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
+ expect(partyCompositionLabel([
+ { role: "Tank" },
+ { role: "Tank" },
+ { role: "Damage" },
+ { role: "Damage" },
+ ])).toBe("2 Tanks · 2 DPS");
+ });
+
+ it("includes each party role in controller action labels", () => {
+ const state = createRpgRoguelikeRun({ seed: 19 });
+ const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
+ for (const candidate of state.partyDraft!.offers) {
+ expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
+ `Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
+ );
+ }
+ });
+
it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers;
diff --git a/src/game/rpgRoguelike/uiModel.ts b/src/game/rpgRoguelike/uiModel.ts
index de63686..77fe5c2 100644
--- a/src/game/rpgRoguelike/uiModel.ts
+++ b/src/game/rpgRoguelike/uiModel.ts
@@ -1,4 +1,4 @@
-import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
+import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run";
import {
MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down";
+export interface PartyRolePresentation {
+ readonly label: "Tank" | "DPS";
+ readonly icon: "⬡" | "⚔";
+ readonly className: "tank" | "damage";
+}
+
+const PARTY_ROLE_PRESENTATIONS: Record = {
+ Tank: { label: "Tank", icon: "⬡", className: "tank" },
+ Damage: { label: "DPS", icon: "⚔", className: "damage" },
+};
+
+/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
+export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
+ return PARTY_ROLE_PRESENTATIONS[role];
+}
+
+/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
+export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
+ const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
+ const damageCount = members.length - tankCount;
+ return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
+}
+
export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action(
rpgFocusId.partyOffer(candidate.candidateId),
- `${recruited ? "Remove" : "Recruit"} ${candidate.name}`,
+ `${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited
? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
});
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId),
- `Remove ${member.name}`,
+ `Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId },
)) : [];
return [
diff --git a/src/game/rpgRoguelikeStore.test.ts b/src/game/rpgRoguelikeStore.test.ts
index 292808b..92181b2 100644
--- a/src/game/rpgRoguelikeStore.test.ts
+++ b/src/game/rpgRoguelikeStore.test.ts
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
+import { BOSS_DEFINITIONS } from "./bossCatalog";
import { createClassInventory } from "./healers";
import { createDefaultGearProgress } from "./progression/gear";
import { equipPassiveInfusion } from "./progression/infusions";
-import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike";
+import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store";
function finishRpgDrafts() {
@@ -113,6 +114,30 @@ describe("RPG Roguelike store integration", () => {
expect(state.endlessMode).toBe(true);
});
+ it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
+ finishRpgDrafts();
+ useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
+
+ const challenge = useGameStore.getState();
+ const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
+ expect(challenge.additionalBosses).toHaveLength(1);
+ expect(challenge.boss.maxHp).toBe(Math.round(
+ BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
+ ));
+ expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
+
+ completeCurrentChallenge();
+ useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
+
+ const bossRoom = useGameStore.getState();
+ const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
+ expect(bossRoom.additionalBosses).toHaveLength(0);
+ expect(bossRoom.boss.maxHp).toBe(Math.round(
+ BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
+ ));
+ expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
+ });
+
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
diff --git a/src/game/store.ts b/src/game/store.ts
index aaff98b..95385e1 100644
--- a/src/game/store.ts
+++ b/src/game/store.ts
@@ -120,7 +120,6 @@ import {
} from "./aetherAssault";
import {
assignRosterToCombatSlots,
- BOSSES_PER_ACT,
createRpgRoguelikeRun,
reduceRpgRoguelikeRun,
selectCurrentBossId,
@@ -136,6 +135,7 @@ import {
spellRankPowerMultiplier,
type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter";
+import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -700,9 +700,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner]
: [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard";
- const act = Math.floor(run.bossIndex / BOSSES_PER_ACT);
- const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14)
- * DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier;
+ const modeDifficulty = rpgEncounterDifficulty(
+ challenge ? "hallway-challenge" : "boss-room",
+ run.bossIndex,
+ );
+ const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
+ const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId,
index,
@@ -747,6 +750,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party,
gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower,
+ difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout),