Release v0.1.17 2026-07-19

This commit is contained in:
Warren H
2026-07-19 13:02:46 -04:00
parent 5045f17b94
commit 016a012c78
17 changed files with 588 additions and 39 deletions
@@ -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,
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}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => (
<FocusButton
key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
>
<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>)}
</div>
@@ -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}`}
>
<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>
);
})}
@@ -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)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} />
);
@@ -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 (
<section className="rpg-tactical-section">
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<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">
{run.roster.map((member) => (
<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)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}`}
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton>
) : 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 (
<FocusButton
key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
>
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<i>{role.icon}</i>
<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>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
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)}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
<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>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton>
);
})}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => (
<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>
</FocusButton>
} />
+43 -5
View File
@@ -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)}
>
<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>
<p>{entry.className}</p>
{!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 (
<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 }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<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>
{action}
</article>
+184 -10
View File
@@ -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. */