Android build v1.1.9

This commit is contained in:
Warren H committed 2026-07-02 21:43:04 -04:00
1 parent 89bc1b6ab8
commit 6560a049f2
20 files changed
+1032 -45

No files matched your search

+297
View File
@@ -9669,3 +9669,300 @@ h2 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.hunter-profile-screen {
gap: 14px;
}
.hunter-profile-heading {
align-items: center;
}
.hunter-profile-tabs {
align-self: auto;
}
.hunter-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.hunter-stat-tile {
display: flex;
min-height: 118px;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 16px;
border: 1px solid rgba(229, 185, 95, 0.24);
border-radius: 8px;
background: rgba(19, 24, 31, 0.84);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
}
.hunter-stat-tile span,
.hunter-stat-tile small {
color: rgba(238, 232, 210, 0.68);
font-size: 0.74rem;
text-transform: uppercase;
}
.hunter-stat-tile strong {
color: #f4df9b;
font-size: 1.45rem;
line-height: 1.08;
}
.collection-log-layout {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
gap: 14px;
min-height: 0;
}
.collection-boss-list {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
}
.collection-drop-list {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
min-height: 0;
}
.collection-boss-list {
max-height: min(58vh, 420px);
overflow: auto;
padding-right: 4px;
}
.collection-boss-button {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 8px 10px;
align-items: center;
min-height: 64px;
padding: 10px;
border: 1px solid rgba(238, 232, 210, 0.12);
border-radius: 8px;
background: rgba(14, 18, 24, 0.78);
color: #eee8d2;
text-align: left;
}
.collection-boss-button:hover,
.collection-boss-button:focus-visible,
.collection-boss-button.selected {
border-color: rgba(229, 185, 95, 0.62);
background: rgba(46, 40, 28, 0.92);
}
.collection-boss-button > span,
.collection-boss-header > span,
.collection-drop-row > span {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 8px;
background: rgba(229, 185, 95, 0.14);
color: #f4df9b;
font-weight: 800;
}
.collection-boss-button strong,
.collection-boss-button small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collection-boss-button small {
grid-column: 2;
color: rgba(238, 232, 210, 0.66);
font-size: 0.76rem;
}
.collection-boss-detail {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid rgba(229, 185, 95, 0.2);
border-radius: 8px;
background: rgba(19, 24, 31, 0.84);
}
.collection-boss-header {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.collection-boss-header > span {
width: 54px;
height: 54px;
}
.collection-boss-header h2 {
margin: 0;
color: #f4df9b;
font-size: 1.35rem;
}
.collection-boss-header small {
color: rgba(238, 232, 210, 0.68);
}
.collection-drop-row {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 24px minmax(28px, 1fr) auto auto;
gap: 4px;
align-items: center;
justify-items: start;
min-height: 84px;
padding: 7px;
border: 1px solid rgba(238, 232, 210, 0.1);
border-radius: 8px;
background: rgba(9, 12, 17, 0.52);
color: #eee8d2;
text-align: left;
}
.collection-drop-row strong {
align-self: center;
min-width: 0;
font-size: 0.7rem;
line-height: 1.08;
}
.collection-drop-row small {
color: rgba(238, 232, 210, 0.68);
font-size: 0.64rem;
font-weight: 700;
}
.collection-drop-row b {
align-self: end;
color: #f4df9b;
font-size: 0.86rem;
line-height: 1;
}
.collection-drop-row > span {
width: 24px;
height: 24px;
border-radius: 6px;
font-size: 0.72rem;
}
.collection-drop-row.missing {
border-color: rgba(238, 232, 210, 0.08);
background: rgba(10, 12, 16, 0.38);
opacity: 0.46;
}
.collection-drop-row.missing > span {
background: rgba(238, 232, 210, 0.08);
color: rgba(238, 232, 210, 0.54);
}
.collection-drop-row.missing strong,
.collection-drop-row.missing small,
.collection-drop-row.missing b {
color: rgba(238, 232, 210, 0.58);
}
.collection-drop-row.owned {
border-color: rgba(229, 185, 95, 0.32);
}
.collection-drop-row:focus-visible {
outline: 2px solid #f4df9b;
outline-offset: 2px;
}
.collection-drop-row.collection-rarity-common {
border-color: rgba(238, 232, 210, 0.28);
}
.collection-drop-row.collection-rarity-stat {
border-color: rgba(229, 185, 95, 0.46);
}
.collection-drop-row.collection-rarity-stat > span {
background: rgba(229, 185, 95, 0.16);
color: #f4df9b;
}
.collection-drop-row.collection-rarity-common > span {
background: rgba(238, 232, 210, 0.1);
}
.collection-drop-row.collection-rarity-uncommon {
border-color: rgba(79, 204, 126, 0.56);
}
.collection-drop-row.collection-rarity-uncommon > span {
background: rgba(79, 204, 126, 0.14);
color: #7feb9f;
}
.collection-drop-row.collection-rarity-rare {
border-color: rgba(113, 208, 255, 0.6);
}
.collection-drop-row.collection-rarity-rare > span {
background: rgba(113, 208, 255, 0.14);
color: #9bdcff;
}
.collection-drop-row.collection-rarity-epic {
border-color: rgba(193, 134, 255, 0.62);
}
.collection-drop-row.collection-rarity-epic > span {
background: rgba(193, 134, 255, 0.16);
color: #d9b6ff;
}
.collection-drop-row.collection-rarity-legendary {
border-color: rgba(245, 159, 75, 0.64);
}
.collection-drop-row.collection-rarity-legendary > span {
background: rgba(245, 159, 75, 0.16);
color: #ffc078;
}
@media (max-width: 760px) {
.collection-log-layout {
grid-template-columns: 1fr;
}
.hunter-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hunter-stat-grid {
gap: 8px;
}
.hunter-stat-tile {
min-height: 78px;
padding: 12px;
}
.collection-boss-list {
max-height: 190px;
}
}
+31 -19
View File
@@ -25,6 +25,7 @@ import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen })))
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
@@ -39,6 +40,7 @@ type Screen =
| 'pvp'
| 'customize'
| 'equipment'
| 'hunter-profile'
| 'talents'
| 'settings'
@@ -52,6 +54,7 @@ const MENU_ITEMS: Array<{
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
]
@@ -457,26 +460,28 @@ function App() {
return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={barFillStyle(experiencePercent)} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
{screen !== 'hunter-profile' && (
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
</div>
</header>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={barFillStyle(experiencePercent)} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
</button>
</div>
</header>
)}
{screen === 'menu' && (
<section className="menu-screen">
@@ -1091,6 +1096,13 @@ function App() {
/>
)}
{screen === 'hunter-profile' && (
<HunterProfileScreen
profile={profile}
onBack={() => setScreen('menu')}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
+34
View File
@@ -3,6 +3,7 @@ import {
completeDungeon,
completeRoguelike,
loadProfile,
recordBossKill,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
@@ -371,6 +372,7 @@ export function CombatScreen({
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
@@ -516,6 +518,12 @@ export function CombatScreen({
: `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system',
)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
@@ -527,6 +535,29 @@ export function CombatScreen({
[addLog, difficulty.id],
)
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
if (!isRoguelike || !encounter.isBoss) return
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
if (recordedBossKillIdsRef.current.has(key)) return
recordedBossKillIdsRef.current.add(key)
recordBossKill(encounter.id, { petVariant: 'purple' })
.then((result) => {
onProfileUpdated(result.profile)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
'danger',
)
})
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
const resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
@@ -558,6 +589,7 @@ export function CombatScreen({
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent
@@ -991,6 +1023,7 @@ export function CombatScreen({
requestLootRoll(encounter.id, rollIndex)
}
}
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({
@@ -1100,6 +1133,7 @@ export function CombatScreen({
maxResource,
gameClass.resourceName,
requestLootRoll,
recordRoguelikeBossKill,
profile.character.name,
setCombat,
startPart,
+240
View File
@@ -0,0 +1,240 @@
import { useMemo, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
return (
<section className="content-screen hunter-profile-screen">
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
</article>
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
key={boss.encounter.id}
onClick={() => setSelectedBossId(boss.encounter.id)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</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'}`}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(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>
)
}
+24 -1
View File
@@ -9,7 +9,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -362,6 +362,7 @@ export function PvPRoguelikeScreen({
const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
@@ -538,6 +539,12 @@ export function PvPRoguelikeScreen({
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
setRewardError(
@@ -654,6 +661,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -711,6 +719,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -1096,6 +1105,20 @@ export function PvPRoguelikeScreen({
})
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
useEffect(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
+18 -1
View File
@@ -7,7 +7,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike } from '../profile'
import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -391,6 +391,7 @@ export function PvpStadiumScreen({
const nextLogId = useRef(2)
const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>())
const matchStatsRecordedRef = useRef(false)
const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('')
@@ -505,6 +506,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -541,6 +543,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -882,6 +885,20 @@ export function PvpStadiumScreen({
return () => window.clearInterval(timer)
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
const startNextRound = useCallback(() => {
const nextRound = roundIndex + 1
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
+7 -6
View File
@@ -100,14 +100,15 @@ export function LootRollList({
return (
<div className="run-loot-rolls">
{rolls.map((roll) => (
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
<div className={roll.dropped || roll.petAwarded ? 'dropped' : 'empty'} key={roll.encounterId}>
<strong>{roll.encounterName}</strong>
<span>
{roll.items.length > 0
? roll.items
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
.join(', ')
: 'No components dropped'}
{[
...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
...(roll.petAwarded
? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
: []),
].join(', ') || 'No components dropped'}
</span>
</div>
))}
+1 -1
View File
@@ -84,7 +84,7 @@ export type DualScreenCombatState = {
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
title: string
subtitle: string
summary?: string
+151 -10
View File
@@ -17,6 +17,7 @@ import type {
LootRoll,
Item,
EquipmentSlot,
PetAward,
} from './profile'
export type GameMode = 'online' | 'offline'
@@ -64,6 +65,14 @@ export interface GameRepository {
difficultyId: number,
runToken: string,
): Promise<LootRoll>
recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}>
recordPvpMatch(won: boolean): Promise<CharacterProfile>
}
type CharacterData = {
@@ -76,7 +85,7 @@ type CharacterData = {
}
type OfflineSave = {
version: 3
version: 4
characterName: string
activeClassId: number
completedDungeonParts: number
@@ -84,6 +93,10 @@ type OfflineSave = {
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
type OnlineCache = {
@@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null
function clone<T>(value: T): T {
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
}
}
return {
version: 3,
version: 4,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
@@ -185,14 +199,33 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
),
characters,
lootRolls: v1.lootRolls ?? {},
bossKills: p.hunterStats?.bossKills ?? {},
bossPets: p.hunterStats?.bossPets ?? {},
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
}
}
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
return normalizeSaveAbilitySlots({
...v2,
version: 3,
version: 4,
completedRaidPhases: 0,
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
})
}
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
return normalizeSaveAbilitySlots({
...v3,
version: 4,
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
})
}
@@ -224,9 +257,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile
lootRolls?: Record<string, LootRoll>
}
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 3) {
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
}
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
@@ -241,7 +277,7 @@ function readSaveKey(key: string): OfflineSave | null {
const raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw)
if (!save) return null
if (raw.version !== 3) {
if (raw.version !== 4) {
localStorage.setItem(key, JSON.stringify(save))
}
return save
@@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null {
save,
dirty: Boolean(raw.dirty),
}
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
writeOnlineCache(cache)
}
return cache
@@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
}))
static_.hunterStats = {
bossKills: clone(save.bossKills ?? {}),
bossPets: clone(save.bossPets ?? {}),
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
pvpMatchesWon: save.pvpMatchesWon ?? 0,
}
return static_
}
@@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
inventory: clone(profile.inventory),
}
return {
version: 3,
version: 4,
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
@@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
),
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
}
}
@@ -570,6 +616,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1]
}
function bossPetName(encounterName: string) {
return `${encounterName} Pet`
}
function purpleBossPetName(encounterName: string) {
return `Purple ${encounterName} Pet`
}
function maybeAwardBossPet(
save: OfflineSave,
encounter: { id: number; enemyName: string },
variant: 'normal' | 'purple' = 'normal',
): PetAward | null {
if (Math.random() >= BOSS_PET_DROP_RATE) return null
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
const previousQuantity = save.bossPets?.[key] ?? 0
save.bossPets = {
...(save.bossPets ?? {}),
[key]: previousQuantity + 1,
}
return {
encounterId: encounter.id,
petName: variant === 'purple'
? purpleBossPetName(encounter.enemyName)
: bossPetName(encounter.enemyName),
quantity: 1,
duplicate: previousQuantity > 0,
quantityAfter: previousQuantity + 1,
}
}
function recordBossKillInSave(
save: OfflineSave,
encounter: { id: number; enemyName: string },
options?: { petVariant?: 'normal' | 'purple' },
): PetAward | null {
const key = String(encounter.id)
save.bossKills = {
...(save.bossKills ?? {}),
[key]: (save.bossKills?.[key] ?? 0) + 1,
}
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
}
function awardRoguelikeCoin(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
@@ -885,6 +975,10 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
}
function emptyCharacterData(classId: number): CharacterData {
@@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
let petAwarded: PetAward | null = null
if (options?.lootSourceEncounterId) {
const sourceEncounter = profile.dungeons
.flatMap((candidate) => candidate.encounters)
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
if (sourceEncounter) {
petAwarded = recordBossKillInSave(save, sourceEncounter, {
petVariant: 'purple',
})
}
}
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
petAwarded,
profile: updatedProfile,
}
},
@@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const petAwarded = encounter.isBoss
? recordBossKillInSave(save, encounter)
: null
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
@@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
petAwarded,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
return clone(result)
},
async recordBossKill(encounterId, options) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
if (!encounter) throw new Error('Boss encounter not found.')
const petAwarded = recordBossKillInSave(save, encounter, {
petVariant: options?.petVariant ?? 'normal',
})
store.writeSave(save)
return {
profile: buildProfile(save),
petAwarded,
}
},
async recordPvpMatch(won) {
const save = requireStoredSave(store)
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
store.writeSave(save)
return buildProfile(save)
},
}
}
@@ -1581,6 +1714,10 @@ const cachedOnlineRepository: GameRepository = {
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
}
export function getGameMode(): GameMode {
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
version: 4,
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
}
writeOfflineSave(save)
writeMode('offline-local')
+1 -1
View File
@@ -1 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
+7 -1
View File
@@ -6905,5 +6905,11 @@
"full_run": []
}
}
]
],
"hunterStats": {
"bossKills": {},
"bossPets": {},
"pvpMatchesPlayed": 0,
"pvpMatchesWon": 0
}
}
+30
View File
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
quantityAfter: number
}
export type PetAward = {
encounterId: number
petName: string
quantity: number
duplicate: boolean
quantityAfter: number
}
export type LootRoll = {
encounterId: number
encounterName: string
@@ -157,6 +165,7 @@ export type LootRoll = {
awarded: boolean
duplicate: boolean
quantityAfter: number
petAwarded?: PetAward | null
}
export type Dungeon = {
@@ -232,6 +241,12 @@ export type CharacterProfile = {
craftingRecipes: CraftingRecipe[]
gearUpgradePaths: GearUpgradePath[]
dungeons: Dungeon[]
hunterStats: {
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
}
export type Account = {
@@ -280,6 +295,7 @@ export type DungeonReward = {
glyph: string
}>
bonusItem: BonusItem | null
petAwarded?: PetAward | null
profile: CharacterProfile
}
@@ -365,6 +381,20 @@ export async function completeRoguelike(
)
}
export async function recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}> {
return activeGameRepository().recordBossKill(encounterId, options)
}
export async function recordPvpMatch(won: boolean): Promise<CharacterProfile> {
return activeGameRepository().recordPvpMatch(won)
}
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
return activeGameRepository().allocateTalent(talentId)
}