Android build v1.0.39

This commit is contained in:
Warren H
2026-06-20 20:09:57 -04:00
parent f8a1fbc5e2
commit 8f5a957963
11 changed files with 4311 additions and 2696 deletions
+53
View File
@@ -1801,6 +1801,35 @@ h2 {
text-align: right;
}
.activity-pager {
align-items: center;
display: flex;
gap: 8px;
}
.activity-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 11px;
min-height: 34px;
outline: 2px solid #41404a;
padding: 6px 8px;
}
.activity-pager button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.activity-pager span {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.tier-grid {
display: grid;
gap: 10px;
@@ -2069,6 +2098,16 @@ h2 {
font-size: 12px;
}
.activity-pager button {
font-size: 9px;
min-height: 28px;
padding: 4px 6px;
}
.activity-pager span {
font-size: 11px;
}
.dungeon-run-screen .eyebrow {
font-size: 7px;
margin-bottom: 5px;
@@ -2273,6 +2312,20 @@ h2 {
margin-top: 5px;
}
.activity-pager {
gap: 4px;
}
.activity-pager button {
font-size: 8px;
min-height: 24px;
padding: 3px 5px;
}
.activity-pager span {
font-size: 9px;
}
.dungeon-choice-grid {
grid-auto-rows: minmax(52px, max-content);
}
+74 -48
View File
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 6
function activityInitials(name: string) {
return name
@@ -81,14 +82,15 @@ function App() {
return Number.isFinite(saved) && saved > 0 ? saved : 1
})
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(2)
const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1)
const [selectedHardMode, setSelectedHardMode] = useState(false)
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false)
@@ -236,7 +238,8 @@ function App() {
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={selectedHardMode && combatContentId > 0}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
@@ -296,7 +299,7 @@ function App() {
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedPart(1)
setSelectedHardMode(false)
setSelectedMarathonMode(false)
setScreen('combat')
}
const tierOptions = activityOptions
@@ -318,6 +321,12 @@ function App() {
const tierActivityOptions = activityOptions.filter((option) =>
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
)
const activityPageCount = Math.max(1, Math.ceil(tierActivityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = tierActivityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
)
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
?? tierActivityOptions[0]
@@ -326,15 +335,9 @@ function App() {
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
const completedSections = activity.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
]
const activityCompletionCount = activity.completionCount ?? 0
const marathonUnlocked = activityCompletionCount >= 10
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters]
@@ -640,10 +643,30 @@ function App() {
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
{activityPageCount > 1 ? (
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<button
disabled={currentActivityPage === 0}
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
type="button"
>
Prev
</button>
<span>{currentActivityPage + 1}/{activityPageCount}</span>
<button
disabled={currentActivityPage >= activityPageCount - 1}
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
) : (
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div>
<div className="activity-card-grid dungeon-choice-grid">
{tierActivityOptions.map((candidate) => {
{pagedActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
@@ -695,6 +718,7 @@ function App() {
disabled={locked}
key={difficulty.id}
onClick={() => {
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
@@ -725,43 +749,45 @@ function App() {
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>{sectionName}</h2>
<h2>Run</h2>
</div>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: marathonUnlocked
? 'Marathon keeps health and mana between boss kills.'
: `Marathon unlocks after 10 clears (${activityCompletionCount}/10).`}
</small>
</div>
<div className="part-picker">
{parts.map((p) => (
<div className="part-start-row" key={p.part}>
<button
className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setSelectedHardMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
<button
className={`primary-button hard-mode-button ${selectedPart === p.part && selectedHardMode ? 'selected-part' : ''} ${!p.hardUnlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.hardUnlocked}
onClick={() => {
setSelectedPart(p.part)
setSelectedHardMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Hard
</button>
</div>
))}
<button
className="primary-button selected-part"
disabled={difficultyLocked}
onClick={() => {
setSelectedPart(1)
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Start Hunt
</button>
<button
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${!marathonUnlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !marathonUnlocked}
onClick={() => {
setSelectedPart(1)
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Marathon
</button>
</div>
</section>
+61 -5
View File
@@ -341,6 +341,7 @@ export function CombatScreen({
difficulty,
dungeon,
hardMode = false,
marathonMode = false,
profile,
startPart = 1,
roguelikeMode,
@@ -353,6 +354,7 @@ export function CombatScreen({
difficulty: Difficulty
dungeon: Dungeon
hardMode?: boolean
marathonMode?: boolean
profile: CharacterProfile
startPart?: number
roguelikeMode?: RoguelikeMode
@@ -416,7 +418,7 @@ export function CombatScreen({
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -430,6 +432,7 @@ export function CombatScreen({
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>())
@@ -439,6 +442,7 @@ export function CombatScreen({
const partStartTimesRef = useRef<Record<number, number>>({})
const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1)
const marathonBossesDefeatedRef = useRef(0)
const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id)
const runCombatTickRef = useRef<() => void>(() => {})
@@ -553,10 +557,10 @@ export function CombatScreen({
const requestLootRoll = useCallback(
(encounterId: number, rollIndex = 0) => {
const rollKey = `${encounterId}:${rollIndex}`
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
if (rolledEncounterIdsRef.current.has(rollKey)) return
rolledEncounterIdsRef.current.add(rollKey)
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => {
setLootRolls((current) => [...current, result])
@@ -609,10 +613,12 @@ export function CombatScreen({
setFloatingTexts([])
setRoguelikeUpgrades([])
setUpgradeChoices([])
setMarathonBossesDefeated(0)
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = 0
runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
@@ -1201,6 +1207,9 @@ export function CombatScreen({
}
if (isPartBoss && !isFinalBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
@@ -1209,12 +1218,28 @@ export function CombatScreen({
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('part-complete')
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
addLog(`${encounter.enemyName} is defeated.`, 'loot')
return
}
if (encounterIndex === encounters.length - 1) {
if (marathonMode && encounter.isBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('marathon-choice')
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
return
}
setCombat({
...current,
party: nextParty,
@@ -1267,6 +1292,7 @@ export function CombatScreen({
isPartBoss,
isFinalBoss,
isRoguelike,
marathonMode,
upgradesEveryEncounter,
roguelikeUpgradeCatalog,
roguelikeUpgrades,
@@ -1620,7 +1646,7 @@ export function CombatScreen({
</div>
)}
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
{status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
@@ -1735,6 +1761,36 @@ export function CombatScreen({
</div>
</div>
)}
{status === 'marathon-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">Marathon</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
Continue with current health and {gameClass.resourceName}, or end the hunt.
</p>
<button
onClick={() => {
const current = combatRef.current
setCombat({
...current,
enemyHealth: encounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
}}
type="button"
>
Continue Marathon
</button>
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End
</button>
</div>
</div>
)}
{status === 'part-complete' && (
<div className="result-screen">
<div>
+1 -1
View File
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
partySize: number
selectedId: string
log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
resource: number
maxResource: number
resourceName: string
+15
View File
@@ -70,6 +70,7 @@ type OfflineSave = {
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
@@ -159,6 +160,9 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0,
dungeonCompletions: Object.fromEntries(
p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters,
lootRolls: v1.lootRolls ?? {},
}
@@ -314,6 +318,10 @@ function buildProfile(save: OfflineSave): CharacterProfile {
updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases
static_.dungeons = static_.dungeons.map((dungeon) => ({
...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
}))
return static_
}
@@ -491,6 +499,9 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
completedRaidPhases: profile.completedRaidPhases,
dungeonCompletions: Object.fromEntries(
profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
}
@@ -958,6 +969,10 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
}
save.dungeonCompletions = {
...(save.dungeonCompletions ?? {}),
[String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
}
let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -168,6 +168,7 @@ export type Dungeon = {
difficulties: Difficulty[]
encounters: DungeonEncounter[]
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
completionCount?: number
leaderboard: LeaderboardEntry[]
leaderboards: {
part_1: LeaderboardEntry[]