Compare commits

...
1 Commits
Author SHA1 Message Date
Warren H 016a012c78 Release v0.1.17 2026-07-19 2026-07-19 13:02:46 -04:00
17 changed files with 588 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.16", "version": "0.1.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs", "predev": "node scripts/sync_basis_transcoder.mjs",
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext, type RpgRunUiContext,
type RpgRunUiProps, type RpgRunUiProps,
} from "./RpgRunUiShared"; } from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css"; import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: { function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action" className="rpg-card-action"
disabled={recruited && !canRemove} disabled={recruited && !canRemove}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`} label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton> ><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)} )}
/> />
); );
})} })}
</div> </div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}> <div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong> <strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => ( {run.roster.map((member) => (
<FocusButton <FocusButton
key={member.instanceId} key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`} className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove} disabled={!canRemove}
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton> >
<i style={{ background: member.color }} />
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
<PartyRoleBadge role={member.role} />
<b className="rpg-picked-remove" aria-hidden="true">×</b>
</FocusButton>
))} ))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)} {Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div> </div>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card" className="rpg-reward-card"
style={runAccentStyle(summary.accent)} style={runAccentStyle(summary.accent)}
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
> >
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b> <i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action" className="rpg-card-action"
disabled={disabled} disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton> ><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} /> } />
); );
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE, PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT, SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE, SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId, rpgFocusId,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss, currentBoss,
FocusButton, FocusButton,
GearCard, GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard, PartyCard,
rewardSummary, rewardSummary,
RoutePips, RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) { function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context; const { run } = context;
return ( return (
<section className="rpg-tactical-section"> <section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header> <header>
<h3>Party</h3>
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
</header>
<div className="rpg-tactical-party-grid"> <div className="rpg-tactical-party-grid">
{run.roster.map((member) => ( {run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? ( <PartyCard key={member.instanceId} member={member} compact action={interactive ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)} focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }} command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action" className="rpg-card-action"
label={`Remove ${member.name}`} label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton> ><span>Remove</span></FocusButton>
) : undefined} /> ) : undefined} />
))} ))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => { {draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId); const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit); const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return ( return (
<FocusButton <FocusButton
key={candidate.candidateId} key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)} style={runAccentStyle(candidate.color)}
disabled={disabled} disabled={disabled}
pressed={recruited} pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
> >
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i> <i>{role.icon}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span> <span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b> <b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u> <u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton> </FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => { {chest.choices.map((choice) => {
const summary = rewardSummary(choice); const summary = rewardSummary(choice);
return ( return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}> <FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b> <i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton> </FocusButton>
); );
})} })}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid"> <div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => ( {shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={ <GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}> <FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span> <span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton> </FocusButton>
} /> } />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState, RpgRoguelikeRunState,
RunGearItem, RunGearItem,
} from "../../game/rpgRoguelike"; } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike"; import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike"; import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike"; import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps { export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) { while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect(); const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) { const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top; if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom; else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
} }
if (parent.scrollWidth > parent.clientWidth) { if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left; if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right; else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
} }
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()} className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)} style={runAccentStyle(entry.color)}
> >
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div> <div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3> <h3>{entry.name}</h3>
<p>{entry.className}</p> <p>{entry.className}</p>
{!compact && ( {!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion"; return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
} }
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
const comparison = compareRunGear(run.equipment, item);
const ownerName = gearOwnerName(run, item);
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
const change = comparison.delta >= 0
? `Gain ${comparison.delta} percentage points`
: `Lose ${Math.abs(comparison.delta)} percentage points`;
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
}
export function GearStatComparison({ run, item }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
}) {
const ownerName = gearOwnerName(run, item);
const comparison = compareRunGear(run.equipment, item);
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
return (
<span
className="rpg-gear-comparison"
aria-label={gearComparisonLabel(run, item)}
>
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
<span>
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
<i aria-hidden="true"></i>
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
</span>
</span>
);
}
export function GearCard({ run, item, price, action }: { export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState; readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem; readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div> <div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small> <small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3> <h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p> {price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div> </div>
{action} {action}
</article> </article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker { .rpg-card-kicker {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 5px; gap: 5px;
color: var(--rarity-color, #e7e9ec); color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600; font-weight: 600;
} }
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3, .rpg-party-card h3,
.rpg-spell-card h3, .rpg-spell-card h3,
.rpg-gear-card h3 { .rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip, .rpg-picked-chip,
.rpg-empty-chip, .rpg-empty-chip,
.rpg-spellbook-chip { .rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip { .rpg-picked-chip {
position: relative; position: relative;
padding: 4px 18px 4px 10px; padding: 4px 6px 4px 10px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center; align-content: center;
gap: 5px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
@@ -414,18 +465,24 @@
width: 3px; width: 3px;
} }
.rpg-picked-chip > small { .rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden; overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.rpg-picked-chip > b { .rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
position: absolute; .rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
right: 6px;
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e; color: #82958e;
font-size: 14px;
} }
.rpg-empty-chip { .rpg-empty-chip {
@@ -870,7 +927,7 @@
} }
.rpg-gear-card { .rpg-gear-card {
min-height: 72px; min-height: 112px;
padding: 8px 8px 22px 43px; padding: 8px 8px 22px 43px;
} }
@@ -904,6 +961,12 @@
font-size: 9px; font-size: 9px;
} }
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list { .rpg-shop-side-list {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px; font-size: 9px;
} }
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list { .rpg-tactical-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
} }
.rpg-tactical-reward { .rpg-tactical-reward {
min-height: 94px; min-height: 130px;
padding: 9px 64px 9px 49px; padding: 9px 64px 9px 49px;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase; text-transform: uppercase;
} }
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid { .rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.rpg-run-tactical .rpg-gear-card { .rpg-run-tactical .rpg-gear-card {
min-height: 80px; min-height: 112px;
} }
.rpg-service-row { .rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px; min-height: 92px;
} }
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip, .rpg-picked-strip,
.rpg-spellbook-strip { .rpg-spellbook-strip {
overflow-x: auto; overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px; min-width: 100px;
} }
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout { .rpg-shop-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
overflow-y: auto; overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b { .rpg-tactical-offer > b {
display: none; display: none;
} }
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
} }
/* Single-display browser fallback gets a usable full-height draft surface. */ /* Single-display browser fallback gets a usable full-height draft surface. */
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run"; export * from "./run";
export * from "./playSpace"; export * from "./playSpace";
export * from "./uiModel"; export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"]; export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = { const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" }, weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" }, armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId]; return equipment[ownerId]?.[slotId];
} }
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem( export function createRunGearItem(
id: string, id: string,
ownerId: RunGearOwnerId, ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots, assignRosterToCombatSlots,
autoEquipRunGear, autoEquipRunGear,
challengeObjective, challengeObjective,
compareRunGear,
createRandomState, createRandomState,
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker); expect(third.bag).toContain(weaker);
}); });
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => { it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501); let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1); const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun, createRpgRoguelikeRun,
createRunGearItem, createRunGearItem,
moveRpgFocus, moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun, reduceRpgRoguelikeRun,
rpgFocusId, rpgFocusId,
rpgFocusItems, rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
} }
describe("RPG Roguelike semantic UI focus", () => { describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => { it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 }); let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers; const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types"; import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run"; import { canRemovePartyMember } from "./run";
import { import {
MAX_ACTIVE_ROSTER, MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down"; export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = { export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`, partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`, partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return []; if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action( return [action(
rpgFocusId.partyOffer(candidate.candidateId), rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`, `${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited recruited
? { type: "party-remove", memberId: candidate.candidateId } ? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId }, : { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
}); });
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action( const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId), rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`, `Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId }, { type: "party-remove", memberId: member.instanceId },
)) : []; )) : [];
return [ return [
+26 -1
View File
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena"; import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { createClassInventory } from "./healers"; import { createClassInventory } from "./healers";
import { createDefaultGearProgress } from "./progression/gear"; import { createDefaultGearProgress } from "./progression/gear";
import { equipPassiveInfusion } from "./progression/infusions"; import { equipPassiveInfusion } from "./progression/infusions";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike"; import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
function finishRpgDrafts() { function finishRpgDrafts() {
@@ -113,6 +114,30 @@ describe("RPG Roguelike store integration", () => {
expect(state.endlessMode).toBe(true); expect(state.endlessMode).toBe(true);
}); });
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
const challenge = useGameStore.getState();
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
expect(challenge.additionalBosses).toHaveLength(1);
expect(challenge.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
));
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
completeCurrentChallenge();
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
const bossRoom = useGameStore.getState();
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
expect(bossRoom.additionalBosses).toHaveLength(0);
expect(bossRoom.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
));
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
});
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => { it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
finishRpgDrafts(); finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
+8 -4
View File
@@ -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),