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.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.rarity}

{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),