Compare commits

...

2 Commits

Author SHA1 Message Date
Warren H 4703017832 Android build v1.0.58 stadium added 2026-06-22 23:15:50 -04:00
Warren H c0f2daccb1 Android build v1.0.57 2026-06-21 21:09:51 -04:00
12 changed files with 1803 additions and 73 deletions
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 75
versionName "1.0.56"
versionCode 77
versionName "1.0.58"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+40 -3
View File
@@ -2309,7 +2309,13 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
? 'pvp-boss-quarter-level'
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
? 'pvp-fight-twelfth-level'
: 'default'
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? 'pvp-stadium-round-win-quarter-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 'pvp-stadium-round-loss-tenth-level'
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
? 'pvp-stadium-match-half-level'
: 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds)
@@ -2412,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE experience_required <= ?
`).get(newExperience).level
}
} else if (
experienceMode === 'pvp-stadium-round-win-quarter-level'
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|| experienceMode === 'pvp-stadium-match-half-level'
) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel).experienceRequired
const nextLevelExperience = newLevel >= maxLevel
? maxExperience
: database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
? 0.25
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 0.1
: 0.5
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
WHERE experience_required <= ?
`).get(newExperience).level
} else {
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
@@ -2564,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) {
}
function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid') {
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
throw new Error('The PvP content type is invalid.')
}
return value
@@ -2736,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) {
function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
? payload.status
: 'playing'
const progress = {
@@ -2762,6 +2797,8 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
encounterIndex,
buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''),
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
shopReady: Boolean(payload.shopReady),
}
match.updatedAt = Date.now()
return pvpSnapshot(match)
+183
View File
@@ -5888,6 +5888,33 @@ h2 {
z-index: 10;
}
.pvp-round-countdown {
align-items: center;
background: rgba(5, 5, 8, 0.55);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 9;
}
.pvp-round-countdown > div {
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
min-width: 220px;
outline: 2px solid var(--gold);
padding: 28px;
text-align: center;
}
.pvp-round-countdown h2 {
color: var(--gold);
font-size: clamp(48px, 8vw, 92px);
line-height: 1;
margin-top: 8px;
}
.result-screen > div,
.pause-screen > div {
background: var(--panel);
@@ -6372,6 +6399,149 @@ h2 {
box-shadow: inset 0 0 0 2px #6e5727;
}
.stadium-screen {
min-height: 100dvh;
}
.stadium-board {
display: grid;
gap: 14px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: calc(100dvh - 72px);
}
.stadium-header,
.stadium-pressure-panel {
align-items: center;
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 4px 4px 0 #08090c;
display: flex;
grid-column: 1 / -1;
justify-content: space-between;
outline: 2px solid var(--edge);
padding: 12px 16px;
}
.stadium-header h2 {
font-size: 20px;
}
.stadium-header > strong {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 26px;
}
.stadium-header p,
.stadium-header small,
.stadium-pressure-panel span {
color: var(--muted);
font-size: 13px;
}
.stadium-pressure-panel strong {
color: var(--ink);
font-family: 'Press Start 2P', monospace;
font-size: 16px;
}
.stadium-side {
min-height: 0;
}
.stadium-shop-dialog {
max-width: 1180px !important;
padding: 16px !important;
text-align: left !important;
width: min(1180px, calc(100vw - 32px));
}
.stadium-shop-summary {
color: var(--muted);
display: flex;
flex-wrap: wrap;
font-family: 'Press Start 2P', monospace;
font-size: 12px;
gap: 16px;
margin-bottom: 12px;
}
.stadium-shop-layout {
display: grid;
gap: 14px;
grid-template-columns: 240px minmax(0, 1fr);
}
.stadium-shop-tabs {
display: grid;
gap: 8px;
}
.stadium-shop-tabs button,
.stadium-shop-grid button {
background: #252833;
border: 2px solid #0b0c0f;
color: var(--ink);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
outline: 2px solid #4d4c58;
}
.stadium-shop-tabs button {
font-size: 9px;
margin: 0;
padding: 10px;
text-align: left;
}
.stadium-shop-tabs button.active {
background: #303427;
outline-color: var(--gold);
}
.stadium-shop-layout h3 {
font-size: 15px;
margin-bottom: 10px;
}
.stadium-shop-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stadium-shop-grid button {
display: grid;
gap: 8px;
margin: 0;
min-height: 110px;
padding: 12px;
text-align: left;
}
.stadium-shop-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.stadium-shop-grid strong {
color: #ffe8a5;
font-size: 10px;
line-height: 1.25;
}
.stadium-shop-grid small,
.stadium-shop-layout p {
color: #d3d9e6;
font-size: 13px;
line-height: 1.2;
}
.dual-opponent-progress.stadium {
grid-template-columns: 1fr 1fr;
}
.result-screen button,
.pause-screen button {
background: var(--gold);
@@ -6884,11 +7054,24 @@ h2 {
}
.pvp-board,
.stadium-board,
.stadium-shop-layout,
.pvp-choice-columns,
.pvp-choice-columns .upgrade-choice-grid {
grid-template-columns: 1fr;
}
.stadium-header,
.stadium-pressure-panel,
.stadium-shop-summary {
align-items: stretch;
flex-direction: column;
}
.stadium-shop-grid {
grid-template-columns: 1fr;
}
.active-target-card,
.mana-wrap {
width: 100%;
+27 -4
View File
@@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen'
import { CustomizeScreen } from './components/CustomizeScreen'
import { EquipmentScreen } from './components/EquipmentScreen'
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
import { PvpStadiumScreen } from './components/PvpStadiumScreen'
import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen'
import {
@@ -253,6 +254,19 @@ function App() {
}
if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
const pvpPool = profile.dungeons
.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters)
@@ -547,6 +561,13 @@ function App() {
>
Raid
</button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
onClick={() => setPvpContentType('stadium')}
type="button"
>
Stadium
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
@@ -554,9 +575,11 @@ function App() {
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
{pvpContentType === 'stadium'
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
: gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small>
</div>
<button
@@ -572,7 +595,7 @@ function App() {
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
+111 -48
View File
@@ -42,7 +42,8 @@ import {
} from '../pvpRoguelike'
const TICK_MS = 700
const UPGRADE_CHOICE_SECONDS = 10
const ROUND_START_SECONDS = 3
const UPGRADE_CHOICE_SECONDS = 15
type BossMechanic =
| 'party-pulse'
@@ -61,6 +62,7 @@ type DraftSlotKey = Exclude<SlotKey, '6'>
type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId =
| 'revive-party-members'
| `slot${DraftSlotKey}-extra-target`
| `slot${DraftSlotKey}-cost-down`
| `slot${DraftSlotKey}-cooldown-down`
@@ -112,6 +114,12 @@ type LivePvpMatch = {
opponentClassName: string
}
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
description: 'Revive fallen party members before the next fight.',
}
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
@@ -322,7 +330,32 @@ function starterSide(partyTemplate: PartyMember[], maxResource: number): SideSta
}
}
function hasDeadPartyMembers(side: SideState) {
return side.party.some((member) => member.health <= 0)
}
function recoverPartyForNextEncounter(party: PartyMember[], reviveDead: boolean) {
return party.map((member) => ({
...member,
health: member.health <= 0
? (reviveDead ? Math.max(1, Math.round(member.maxHealth * 0.3)) : 0)
: clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
}))
}
function removeRandomDebuff(debuffs: OpponentDebuffId[]) {
if (debuffs.length === 0) return debuffs
const removedIndex = Math.floor(Math.random() * debuffs.length)
return debuffs.filter((_, index) => index !== removedIndex)
}
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'revive-party-members') return 10
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5
@@ -416,7 +449,7 @@ export function PvPRoguelikeScreen({
})),
[contentType],
)
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0)
@@ -442,6 +475,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS)
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
@@ -456,6 +490,7 @@ export function PvPRoguelikeScreen({
const queuedMatchRef = useRef(false)
const upgradeChoiceEndsAtRef = useRef(0)
const autoSubmittedUpgradeRef = useRef(false)
const roundCountdownTimerRef = useRef<number | null>(null)
const liveMatchRef = useRef<LivePvpMatch | null>(null)
const loggedOpponentDoneRef = useRef(false)
const pendingLiveUpgradeRef = useRef<{
@@ -518,6 +553,29 @@ export function PvPRoguelikeScreen({
}, 900)
}, [])
const clearRoundCountdown = useCallback(() => {
if (roundCountdownTimerRef.current === null) return
window.clearInterval(roundCountdownTimerRef.current)
roundCountdownTimerRef.current = null
}, [])
const beginRoundCountdown = useCallback((message?: string) => {
clearRoundCountdown()
setRoundCountdown(ROUND_START_SECONDS)
setStatus('round-countdown')
if (message) addLog(message, 'system')
const startedAt = Date.now()
roundCountdownTimerRef.current = window.setInterval(() => {
const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000)
setRoundCountdown(remaining)
if (remaining > 0) return
clearRoundCountdown()
setStatus((current) => current === 'round-countdown' ? 'playing' : current)
}, 100)
}, [addLog, clearRoundCountdown])
useEffect(() => () => clearRoundCountdown(), [clearRoundCountdown])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
@@ -589,13 +647,15 @@ export function PvPRoguelikeScreen({
useEffect(() => {
setPlayerBuffChoices((current) => current
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.map((choice) => choice.id === REVIVE_PARTY_CHOICE.id
? REVIVE_PARTY_CHOICE
: selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
setPlayerDebuffChoices((current) => current
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
setSelectedBuff((current) => current
? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current)
: null)
setSelectedDebuff((current) => current
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
@@ -642,7 +702,6 @@ export function PvPRoguelikeScreen({
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0)
setStatus('playing')
setPlayerSide(basePlayer)
setCpuSide(baseOpponent)
setSelectedTargetId(partyTemplate[0].id)
@@ -674,9 +733,11 @@ export function PvPRoguelikeScreen({
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
setQueueMessage(logText)
setLog([{ id: 1, text: logText, tone: 'system' }])
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
beginRoundCountdown()
}, [beginRoundCountdown, contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
const startMatch = useCallback((nextStartStage?: number) => {
clearRoundCountdown()
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0]
@@ -732,8 +793,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(randomCpu)
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
setStatus('playing')
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
}
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
@@ -802,7 +862,7 @@ export function PvPRoguelikeScreen({
if (pollTimer) window.clearTimeout(pollTimer)
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
}
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
}, [beginRoundCountdown, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
useEffect(() => startMatch(), [startMatch])
@@ -812,7 +872,7 @@ export function PvPRoguelikeScreen({
const syncMatch = () => {
publishPvpMatchState<SideState>(liveMatch.id, {
state: playerRef.current,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'round-countdown' ? 'playing' : status,
stage,
encounterIndex,
encountersCleared,
@@ -1154,10 +1214,11 @@ export function PvPRoguelikeScreen({
const beginUpgradePhase = useCallback(() => {
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
autoSubmittedUpgradeRef.current = false
const playerNeedsRevive = hasDeadPartyMembers(playerRef.current)
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerBuffChoices(playerNeedsRevive ? [REVIVE_PARTY_CHOICE] : chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedBuff(playerNeedsRevive ? REVIVE_PARTY_CHOICE : null)
setSelectedDebuff(null)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
@@ -1297,11 +1358,16 @@ export function PvPRoguelikeScreen({
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
let nextPlayer = {
...playerRef.current,
buffs: [...playerRef.current.buffs, submittedBuff.id],
buffs: submittedBuff.id === REVIVE_PARTY_CHOICE.id
? playerRef.current.buffs
: [...playerRef.current.buffs, submittedBuff.id],
}
if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
}
if (submittedBuff.id === REVIVE_PARTY_CHOICE.id) {
nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) }
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
@@ -1323,15 +1389,7 @@ export function PvPRoguelikeScreen({
}
nextPlayer = {
...nextPlayer,
party: nextPlayer.party.map((member) => ({
...member,
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
})),
party: recoverPartyForNextEncounter(nextPlayer.party, submittedBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
@@ -1346,7 +1404,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0)
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
setStatus('playing')
beginRoundCountdown()
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
addLog(
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
@@ -1392,23 +1450,35 @@ export function PvPRoguelikeScreen({
return
}
if (!cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
const cpuBuffChoices = hasDeadPartyMembers(cpuRef.current)
? [REVIVE_PARTY_CHOICE]
: chooseRandom(selfBuffChoicesCatalog, 3)
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
let nextPlayer = {
...playerRef.current,
buffs: [...playerRef.current.buffs, chosenBuff.id],
buffs: chosenBuff.id === REVIVE_PARTY_CHOICE.id
? playerRef.current.buffs
: [...playerRef.current.buffs, chosenBuff.id],
}
let nextCpu = {
...cpuRef.current,
buffs: [...cpuRef.current.buffs, cpuBuff.id],
buffs: cpuBuff.id === REVIVE_PARTY_CHOICE.id
? cpuRef.current.buffs
: [...cpuRef.current.buffs, cpuBuff.id],
}
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] }
if (chosenBuff.id === REVIVE_PARTY_CHOICE.id) {
nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) }
}
if (cpuBuff.id === REVIVE_PARTY_CHOICE.id) {
nextCpu = { ...nextCpu, debuffs: removeRandomDebuff(nextCpu.debuffs) }
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
@@ -1429,30 +1499,14 @@ export function PvPRoguelikeScreen({
nextPlayer = {
...nextPlayer,
party: nextPlayer.party.map((member) => ({
...member,
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
})),
party: recoverPartyForNextEncounter(nextPlayer.party, chosenBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
}
nextCpu = {
...nextCpu,
party: nextCpu.party.map((member) => ({
...member,
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
})),
party: recoverPartyForNextEncounter(nextCpu.party, cpuBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
@@ -1468,9 +1522,9 @@ export function PvPRoguelikeScreen({
playerRef.current = nextPlayer
cpuRef.current = nextCpu
setElapsedTicks(0)
setStatus('playing')
beginRoundCountdown()
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useEffect(() => {
if (status !== 'upgrade-choice' || liveUpgradePending) return
@@ -1574,7 +1628,7 @@ export function PvPRoguelikeScreen({
partySize: playerSide.party.length,
selectedId,
log,
status: status === 'queueing' ? 'playing' : status,
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
resource: playerSide.resource,
maxResource,
resourceName: gameClass.resourceName,
@@ -1802,6 +1856,15 @@ export function PvPRoguelikeScreen({
</div>
)}
{status === 'round-countdown' && (
<div className="pvp-round-countdown">
<div>
<p className="eyebrow">Round Starts</p>
<h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
</div>
</div>
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="pvp-upgrade-dialog">
@@ -1814,7 +1877,7 @@ export function PvPRoguelikeScreen({
</div>
<div className="pvp-choice-columns">
<div>
<strong>Self Buff</strong>
<strong>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
File diff suppressed because it is too large Load Diff
+36 -10
View File
@@ -67,6 +67,14 @@ export type DualScreenCombatState = {
paused: boolean
targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2
stadium?: {
dampeningPercent: number
roundIndex: number
playerWins: number
opponentWins: number
survivalSeconds: number
opponentSurvivalSeconds: number
}
}
export type DualScreenWorkshopState = {
@@ -138,6 +146,11 @@ function memberHotEffects(member: PartyMember) {
: []
}
function formatDualTime(seconds: number) {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -446,21 +459,34 @@ export function DualScreenBottomDisplay() {
{state.opponentParty && <small>{state.opponentClassName}</small>}
</div>
<div className="dual-controls-progress">
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
</div>
</header>
{state.opponentParty ? (
<>
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
{state.stadium ? (
<section className="dual-opponent-progress stadium">
<div>
<p className="eyebrow">Dampening</p>
<strong>{state.stadium.dampeningPercent}%</strong>
</div>
<div>
<p className="eyebrow">Survival</p>
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
</div>
</section>
) : (
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => (
+29 -2
View File
@@ -37,7 +37,7 @@ export interface GameRepository {
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
@@ -529,6 +529,27 @@ function scaledPvpFightExperience(
return { experience, level }
}
function scaledCurrentLevelExperience(
startingExperience: number,
startingLevel: number,
maxLevel: number,
rate: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * rate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
return { experience, level }
}
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
@@ -1176,7 +1197,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxLevel,
highestOtherClassLevel(save),
)
: null
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
: options?.experienceMode === 'pvp-stadium-match-half-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
+1 -1
View File
@@ -349,7 +349,7 @@ export async function completeRoguelike(
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
+6 -3
View File
@@ -1,8 +1,9 @@
import { requestGameApiJson } from './gameRepository'
export type PvpContentType = 'dungeon' | 'raid'
export type PvpContentType = 'dungeon' | 'raid' | 'stadium'
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
export type PvpMatchSide = 'a' | 'b'
export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost'
export type PvpPlayerInfo = {
side: PvpMatchSide
@@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = {
encounterIndex: number
buffId: string
debuffId: string
purchases?: string[]
shopReady?: boolean
}
export type PvpMatchSnapshot<TSideState = unknown> = {
@@ -25,7 +28,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
createdAt: number
players: Record<PvpMatchSide, PvpPlayerInfo>
states: Partial<Record<PvpMatchSide, TSideState>>
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
progress: Partial<Record<PvpMatchSide, {
stage: number
encounterIndex: number
@@ -143,7 +146,7 @@ export function publishPvpMatchState<TSideState>(
matchId: string,
payload: {
state: TSideState
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
status: PvpMatchStatus
stage: number
encounterIndex: number
encountersCleared: number