Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+53 -34
View File
@@ -5,7 +5,7 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
@@ -20,7 +20,7 @@ import {
gearBonusText,
gearUpgradeCosts,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
@@ -388,7 +388,7 @@ function HomeScreen() {
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</div>
<ControllerLegend back />
@@ -424,10 +424,10 @@ function ProfileScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const actions = useMemo<MenuAction[]>(() => [
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
@@ -441,22 +441,22 @@ function ProfileScreen() {
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
</FrontSurface>
}
bottom={
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and mechanic group list">
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
<div className="profile-stats">
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
@@ -464,9 +464,9 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
</div>
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
</FocusButton>
))}</div>
</FrontSurface>
@@ -558,7 +558,7 @@ function GearScreen() {
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
@@ -576,7 +576,7 @@ function GearScreen() {
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
@@ -593,7 +593,7 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
@@ -601,7 +601,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -664,6 +664,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -671,36 +673,46 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
};
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
? `boss-${BOSS_ORDER[index + 1]}`
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1),
right: neighborInColumn(column + 1),
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -712,8 +724,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
@@ -741,10 +753,17 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<span>Choose encounter</span>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
{BOSS_ORDER.map((bossId) => {
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
</div>
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -756,7 +775,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
@@ -778,7 +797,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
</FrontSurface>
}
/>