Android build v1.1.23

This commit is contained in:
Warren H
2026-07-05 22:48:56 -04:00
parent 956c9e32f9
commit 9708ba9e40
106 changed files with 4294 additions and 458 deletions
+85 -27
View File
@@ -8,8 +8,9 @@ import {
type Spell,
} from '../game'
import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
import { PartyMemberFrame } from './PartyFrames'
import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
@@ -64,9 +65,10 @@ import {
import {
createEmptyRewardSummary,
mergeDungeonRewardSummary,
type RunBossCoinAward,
type RewardSummaryBase,
} from '../combat/rewardSummaries'
import { RewardXpSummary } from './RewardPanels'
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
publishPvpMatchState,
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
}
function starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) {
return gameClass.spells
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1)))
}
function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) {
const pool = classes.length > 0 ? classes : []
return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId)
}
function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) {
const targetItemLevel = roguelikeCoinItemLevel(stage)
const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel))
const pool = eligible.length > 0 ? eligible : bosses
return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined
}
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
const directName = slotSpellName('1', spells, 'Mend')
const sustainName = slotSpellName('2', spells, 'Renew')
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
const starterSpells = useMemo(() => gameClass.spells
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
const partyTemplate = useMemo(
() => INITIAL_PARTY.map((member) => ({
...member,
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
)
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
const rewardDifficulty = rewardDungeon.difficulties[0]
const stadiumCoinBosses = useMemo(
() => profile.dungeons
.flatMap((candidate) => candidate.encounters)
.filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0),
[profile.dungeons],
)
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
partyTemplate,
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
clearFloatingTexts,
} = useSidedFloatingCombatText()
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false)
const selectedIdRef = useRef(partyTemplate[0].id)
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs],
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
)
const playerBuffSummary = useMemo(
() => summarizeStacks(playerSide.buffs, buffCatalog),
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
setStatus('round-countdown')
}, [clearRoundCountdown, startRoundCountdown])
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
const awardXp = useCallback((
key: string,
mode: StadiumExperienceMode,
coinSource?: { encounter: DungeonEncounter; stage: number; label: string },
) => {
if (awardedXpRef.current.has(key)) return
awardedXpRef.current.add(key)
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
bossesCleared: 0,
fightsCleared: 1,
experienceMode: mode,
lootSourceEncounterId: coinSource?.encounter.id,
roguelikeStage: coinSource?.stage,
})
.then((result) => {
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
if (result.bonusItem && coinSource) {
setStadiumBossCoins((current) => [...current, {
...result.bonusItem!,
sourceLabel: coinSource.label,
}])
}
onProfileUpdated(result.profile)
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
if (result.bonusItem) addLog(`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, 'loot')
})
.catch((reason: unknown) => {
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
setLiveMatch(setup.liveMatch)
setPaused(setup.defaults.paused)
setRewardSummary(createEmptyRewardSummary())
setStadiumBossCoins([])
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
setLiveMatch(null)
setPaused(setup.defaults.paused)
setRewardSummary(createEmptyRewardSummary())
setStadiumBossCoins([])
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
loggedOpponentRoundRef.current = ''
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
setCpuDifficulty(randomCpu)
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
@@ -591,7 +635,7 @@ export function PvpStadiumScreen({
},
},
})
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch])
useEffect(() => {
const frame = window.requestAnimationFrame(() => startMatch())
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
current: StadiumSideState,
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
sideName: 'player' | 'cpu',
spells: Spell[],
spell: Spell,
targetId: string,
) => {
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
})
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
const shieldEffect = spells.find((candidate) => candidate.kind === 'shield')
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
sourcePower
* strength
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
wasReady: effectiveCost === 0 && current.freeCastReady,
},
})
}, [addFloatingHeal, starterSpells])
}, [addFloatingHeal])
const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || !playerAlive) return
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next
setPlayerSide(next)
}, 'player', spell, targetId)
}, 'player', starterSpells, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, status])
}, [addLog, applySpell, playerAlive, starterSpells, status])
const getTargetParty = useCallback(() => playerRef.current.party, [])
const {
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
runCpuHealTurn({
side: cpuRef.current,
elapsedTicks,
spells: starterSpells,
spells: cpuStarterSpells,
behavior,
preferSlots: true,
applySpell: (side, spell, targetId) => {
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
const next = typeof value === 'function' ? value(cpuRef.current) : value
cpuRef.current = next
setCpuSide(next)
}, 'cpu', spell, targetId)
}, 'cpu', cpuStarterSpells, spell, targetId)
},
})
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
}, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status])
const advanceBoss = useCallback((side: StadiumSideState) => {
if (side.roundStatus !== 'playing') return side
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
})
if (!liveMatchRef.current) {
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
buffCatalog,
cpuBuffCatalog,
stadiumShopPointsForOutcome(outcome, 'opponent'),
)
setCpuSide((current) => {
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
return next
})
}
}, [buffCatalog, startShopTimer])
}, [cpuBuffCatalog, startShopTimer])
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
if (status !== 'playing') return
if (roundResolvedRef.current) return
roundResolvedRef.current = true
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
awardXp(result.roundExperience.key, result.roundExperience.mode)
const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex)
awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource
? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` }
: undefined)
addLog(result.log.text, result.log.tone)
if (result.status === 'won') {
setRoundWins(result.nextWins)
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
playerRef.current = next
return next
})
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
if (result.matchExperience) {
const matchCoinStage = roundIndex + 1
const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage)
awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource
? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` }
: undefined)
}
return
}
if (result.status === 'lost') {
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
return
}
beginShop(outcome, result.nextWins)
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
useEffect(() => {
if (status !== 'playing' || paused) return
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
encounterCount: 5,
party: playerSide.party,
opponentName: opponentLabel,
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
opponentParty: cpuSide.party,
opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
opponentBuffSummary,
opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts,
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
activeBindings,
controllerIconStyle,
cpuDifficulty,
cpuGameClass.name,
cpuGameClass.resourceName,
cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds,
directPartyTargeting,
dualScreenFloatingTexts,
gameClass.resourceName,
liveMatch?.opponentClassName,
liveMatch,
opponentBuffSummary,
opponentDebuffSummary,
opponentLabel,
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
<div>
<p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2>
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
</div>
</div>
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
>
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
<div className="reward-summary">
<p>Run totals</p>
<RewardXpSummary reward={rewardSummary} />
{rewardError && <p className="reward-error">{rewardError}</p>}
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
</div>
</ResultScreen>
)}