Android build v1.1.22

This commit is contained in:
Warren H
2026-07-05 12:08:11 -04:00
parent 547044892d
commit 956c9e32f9
69 changed files with 9259 additions and 196 deletions
+592 -71
View File
@@ -1,6 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import {
useGameAction,
useInput,
type ControllerIconStyle,
type InputAction,
} from '../../../input'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
restoreIwt2CloudSlot,
@@ -9,6 +15,7 @@ import {
type Iwt2Save,
} from '../save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import {
abilitiesForHealer,
IWT2_HEALER_METADATA,
@@ -62,6 +69,39 @@ type Iwt2UpgradeNavEntry =
| { kind: 'upgradeContinue', row: number, column: number, disabled?: boolean }
| { kind: 'upgradeLeave', row: number, column: number }
type Iwt2HunterProfileTab = 'stats' | 'collection'
type Iwt2HunterProfileNavEntry =
| { kind: 'back', key: string, row: number, column: number }
| { kind: 'tab', key: string, row: number, column: number, tab: Iwt2HunterProfileTab }
| { kind: 'stat', key: string, row: number, column: number, index: number }
| { kind: 'boss', key: string, row: number, column: number, bossId: Iwt2BossId }
| { kind: 'item', key: string, row: number, column: number, itemKey: string }
type Iwt2HunterProfileBossEntry = {
bossId: Iwt2BossId
dropId: string
dropName: string
encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
kills: number
petId: string
petName: string
pets: number
drops: number
}
type Iwt2HunterCollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] {
return [
{
@@ -122,10 +162,83 @@ const MODE_ACTIONS: Record<Iwt2Mode, Array<{
detail: 'Shared arena entry with raid-role notes: watch lightning markers, rotate through safe bands, and recover stunned allies fast.',
value: 'Start',
},
{
bossId: 'rathian',
label: 'Rathian Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge tail arcs, keep poison puddles out of the party path, and recover after sweeps.',
value: 'Start',
},
{
bossId: 'barroth',
label: 'Barroth Raid Assignment',
detail: 'Shared arena entry with raid-role notes: break mud armor, sidestep mud cones, and reposition through slow zones.',
value: 'Start',
},
{
bossId: 'tobi-kadachi',
label: 'Tobi Kadachi Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track static charge, bait line pounces, and spread before chain shock.',
value: 'Start',
},
{
bossId: 'rimebastion',
label: 'Rimebastion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: read ice wall lanes, dodge shard cones, and keep the tank away from corners.',
value: 'Start',
},
{
bossId: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track sidesteps, cut through slash lanes, and spread before crossed blades.',
value: 'Start',
},
{
bossId: 'cinderback-ricochet',
label: 'Cinderback Ricochet Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait ricochet lanes, avoid lava trails, and recover after armor slam.',
value: 'Start',
},
{
bossId: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait charge lines, avoid quake fractures, and break armor safely.',
value: 'Start',
},
{
bossId: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Raid Assignment',
detail: 'Shared arena entry with raid-role notes: spread for chain lightning and rotate through charged ring gaps.',
value: 'Start',
},
{
bossId: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge staggered poison cones and keep root lanes off the party.',
value: 'Start',
},
{
bossId: 'sandglass-scorpion',
label: 'Sandglass Scorpion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track burrows, wait out stinger eruptions, and avoid sinking sand.',
value: 'Start',
},
{
bossId: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Raid Assignment',
detail: 'Shared arena entry with raid-role notes: space for sonic rings, watch mirror lanes, and dodge swoops.',
value: 'Start',
},
{
bossId: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Raid Assignment',
detail: 'Shared arena entry with raid-role notes: avoid cursed hoof zones and sidestep ghost-antler charges.',
value: 'Start',
},
],
}
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
const IWT2_DUNGEON_BOSS_PAGE_SIZE = 5
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
xbox: 'Xbox',
@@ -133,6 +246,121 @@ const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
nintendo: 'Nintendo',
}
const IWT2_DUNGEON_ACTIONS: Array<Omit<Iwt2NavAction, 'onConfirm'> & { bossId: Iwt2BossId }> = [
{
bossId: 'bulldrome',
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
},
{
bossId: 'yian-kut-ku',
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
},
{
bossId: 'great-jaggi',
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
},
{
bossId: 'khezu',
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
},
{
bossId: 'rathian',
key: 'rathian',
label: 'Rathian Arena',
detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.',
value: 'Ready',
},
{
bossId: 'barroth',
key: 'barroth',
label: 'Barroth Arena',
detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.',
value: 'Ready',
},
{
bossId: 'tobi-kadachi',
key: 'tobi-kadachi',
label: 'Tobi Kadachi Arena',
detail: 'Static builds into line pounces while chain shock punishes grouped allies.',
value: 'Ready',
},
{
bossId: 'rimebastion',
key: 'rimebastion',
label: 'Rimebastion Arena',
detail: 'Ice walls split the room, then shard lanes and cones punish trapped routes.',
value: 'Ready',
},
{
bossId: 'ember-mantis-duelist',
key: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Arena',
detail: 'Fast sidesteps lead into line slashes and crossed lane attacks.',
value: 'Ready',
},
{
bossId: 'cinderback-ricochet',
key: 'cinderback-ricochet',
label: 'Cinderback Ricochet Arena',
detail: 'Wall-bounce rolls leave lava trails before a heavy armor slam.',
value: 'Ready',
},
{
bossId: 'obsidian-ram-golem',
key: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Arena',
detail: 'Volcanic charge lines crack armor, then quake fractures split the floor.',
value: 'Ready',
},
{
bossId: 'stormcoil-wyrm',
key: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Arena',
detail: 'Charged rings and chain lightning punish clustered party movement.',
value: 'Ready',
},
{
bossId: 'venom-orchid-hydra',
key: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Arena',
detail: 'Three heads sweep poison cones while roots and pods pressure escape paths.',
value: 'Ready',
},
{
bossId: 'sandglass-scorpion',
key: 'sandglass-scorpion',
label: 'Sandglass Scorpion Arena',
detail: 'Burrows, delayed stinger eruptions, and shifting sand zones control space.',
value: 'Ready',
},
{
bossId: 'crystal-bat-matriarch',
key: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Arena',
detail: 'Sonic rings, mirror shard lanes, and swoop dives punish bad spacing.',
value: 'Ready',
},
{
bossId: 'hollowcrown-revenant',
key: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Arena',
detail: 'Cursed hoof zones and ghost-antler dash trails cut across the arena.',
value: 'Ready',
},
]
export function Iwt2ScreenShell({
children,
eyebrow = 'I Want To Heal 2',
@@ -222,40 +450,39 @@ export function Iwt2DungeonsScreen({
onBack: () => void
onOpenBoss: (bossId: Iwt2BossId) => void
}) {
const [page, setPage] = useState(0)
const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE))
const currentPage = Math.min(page, pageCount - 1)
const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE
const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE)
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
onConfirm: () => onOpenBoss('bulldrome'),
key: 'dungeons-page-prev',
label: 'Previous Page',
detail: `Bosses ${pageStart + 1}-${pageEnd} of ${IWT2_DUNGEON_ACTIONS.length}`,
disabled: currentPage === 0,
value: 'Prev',
onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)),
},
{
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
onConfirm: () => onOpenBoss('yian-kut-ku'),
key: 'dungeons-page-next',
label: 'Next Page',
detail: `Page ${currentPage + 1}/${pageCount}`,
disabled: currentPage >= pageCount - 1,
value: 'Next',
onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)),
},
{
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
onConfirm: () => onOpenBoss('great-jaggi'),
},
{
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
onConfirm: () => onOpenBoss('khezu'),
},
], onBack), [onBack, onOpenBoss])
...IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd).map((entry) => ({
...entry,
onConfirm: () => onOpenBoss(entry.bossId),
})),
], onBack), [currentPage, onBack, onOpenBoss, pageCount, pageEnd, pageStart])
return (
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
<Iwt2ActionList actions={actions} />
<div className="iwt2-dungeon-list">
<Iwt2ActionList actions={actions} />
</div>
</Iwt2ScreenShell>
)
}
@@ -778,63 +1005,352 @@ export function Iwt2HunterProfileScreen({
onBack: () => void
save: Iwt2Save
}) {
const bossRows = (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
bossId,
drops: save.collectionLog.dropsFound[bossDropIdForProfile(bossId)] ?? 0,
kills: save.collectionLog.bossKills[bossId] ?? 0,
name: IWT2_BOSS_METADATA[bossId].name,
}))
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
const [activeTab, setActiveTab] = useState<Iwt2HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<Iwt2HunterProfileBossEntry[]>(() => (
(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => {
const drop = iwt2BossMaterialRewardFor(bossId)
const pet = iwt2BossPetRewardFor(bossId)
return {
bossId,
dropId: drop.id,
dropName: drop.name,
drops: save.collectionLog.dropsFound[drop.id] ?? 0,
encounter: IWT2_BOSS_METADATA[bossId],
kills: save.collectionLog.bossKills[bossId] ?? 0,
petId: pet.id,
petName: pet.name,
pets: save.collectionLog.bossPets[pet.id] ?? 0,
}
})
), [save.collectionLog.bossKills, save.collectionLog.bossPets, save.collectionLog.dropsFound])
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>(() => bosses[0]?.bossId ?? 'bulldrome')
const selectedBoss = bosses.find((boss) => boss.bossId === selectedBossId) ?? bosses[0]
const totalBossKills = bosses.reduce((total, boss) => total + boss.kills, 0)
const totalDrops = bosses.reduce((total, boss) => total + boss.drops, 0)
const totalPets = bosses.reduce((total, boss) => total + boss.pets, 0)
const mostKilledBoss = bosses.reduce<Iwt2HunterProfileBossEntry | null>((best, boss) => {
if (!best) return boss
return boss.kills > best.kills ? boss : best
}, null)
const collectionItems = useMemo<Iwt2HunterCollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
chance: 'Defeats',
glyph: 'K',
key: `kills:${selectedBoss.bossId}`,
name: 'Boss Kills',
quantity: selectedBoss.kills,
rarity: 'stat',
source: selectedBoss.encounter.name,
},
{
chance: 'Guaranteed',
glyph: selectedBoss.encounter.icon,
key: `drop:${selectedBoss.dropId}`,
name: selectedBoss.dropName,
quantity: selectedBoss.drops,
rarity: 'common',
source: selectedBoss.encounter.name,
},
{
chance: '1 in 500',
glyph: '*',
key: `pet:${selectedBoss.petId}`,
name: selectedBoss.petName,
quantity: selectedBoss.pets,
rarity: 'legendary',
source: selectedBoss.encounter.name,
},
]
}, [selectedBoss])
const statTiles = useMemo(() => [
{
key: 'name',
label: 'Name',
value: save.character.name,
onConfirm: () => {},
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'level',
label: 'Level',
value: String(save.character.level),
onConfirm: () => {},
detail: totalBossKills > 0 && mostKilledBoss ? `${mostKilledBoss.kills} kills` : '0 kills',
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.name : 'None',
},
{
key: 'experience',
key: 'stat:hunter-level',
label: 'Hunter Level',
value: save.character.level,
},
{
key: 'stat:experience',
label: 'Experience',
value: `${save.character.experience} XP`,
onConfirm: () => {},
},
{
key: 'class',
label: 'Healing Class',
value: IWT2_HEALER_METADATA[save.character.healerStyle].name,
onConfirm: () => {},
key: 'stat:collection-drops',
label: 'Collection Drops',
value: totalDrops + totalPets,
detail: `${totalPets} pets, ${save.inventory.length} inventory slots`,
},
...bossRows.map((row) => ({
key: `boss-${row.bossId}`,
label: row.name,
value: `${row.kills} kills / ${row.drops} drops`,
onConfirm: () => {},
})),
{
key: 'inventory',
label: 'IWT2 Inventory',
value: `${save.inventory.length} slots`,
onConfirm: () => {},
},
], onBack), [bossRows, onBack, save.character.experience, save.character.healerStyle, save.character.level, save.character.name, save.inventory.length])
], [mostKilledBoss, save.character.experience, save.character.level, save.inventory.length, totalBossKills, totalDrops, totalPets])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<Iwt2HunterProfileNavEntry[]>(() => {
const entries: Iwt2HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
column: index,
index,
key: tile.key,
kind: 'stat' as const,
row: 1,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
bossId: boss.bossId,
column: 0,
key: `boss:${boss.bossId}`,
kind: 'boss' as const,
row: index + 1,
})),
...collectionItems.map((item, index) => ({
column: (index % IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
key: `item:${item.key}`,
kind: 'item' as const,
row: Math.floor(index / IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
items: [
{
detail: focusedItem.source,
glyph: focusedItem.glyph,
meta: focusedItem.chance,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
title: 'Drop Rate',
},
{
detail: 'I Want To Heal 2',
meta: `${selectedBoss.kills}`,
status: `${selectedBoss.drops} drops`,
title: 'Boss Kills',
},
],
mode: 'collection',
subtitle: selectedBoss.encounter.name,
summary: `Owned x${focusedItem.quantity}`,
title: focusedItem.name,
}
}, [activeTab, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: Iwt2HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: Iwt2BossId) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
aria-selected={activeTab === 'stats'}
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
role="tab"
type="button"
>
Stats
</button>
<button
aria-selected={activeTab === 'collection'}
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
role="tab"
type="button"
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => (
<button
className={`collection-boss-button ${selectedBoss?.bossId === boss.bossId ? 'selected' : ''} ${selected(`boss:${boss.bossId}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.bossId}`) ? 'true' : undefined}
key={boss.bossId}
onClick={() => selectBoss(boss.bossId)}
onPointerDown={() => selectKey(`boss:${boss.bossId}`)}
type="button"
>
<span>{bossInitialsForProfile(boss.encounter.name)}</span>
<strong>{boss.encounter.name}</strong>
</button>
))}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
function bossDropIdForProfile(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
function bossInitialsForProfile(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
export function Iwt2CustomizeCharacterScreen({
@@ -938,8 +1454,13 @@ function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1)
if (!actions[bounded]?.disabled) return bounded
const firstEnabled = actions.findIndex((action) => !action.disabled)
return firstEnabled >= 0 ? firstEnabled : 0
for (let offset = 1; offset < actions.length; offset += 1) {
const previous = bounded - offset
const next = bounded + offset
if (previous >= 0 && !actions[previous]?.disabled) return previous
if (next < actions.length && !actions[next]?.disabled) return next
}
return 0
}
function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) {