Compare commits

...

2 Commits

Author SHA1 Message Date
Warren H 5449276521 Android build v1.0.55 2026-06-21 20:38:55 -04:00
Warren H 787e2bbae9 Android build v1.0.54 2026-06-21 20:22:12 -04:00
7 changed files with 355 additions and 131 deletions
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 72 versionCode 74
versionName "1.0.53" versionName "1.0.55"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+59 -19
View File
@@ -2589,10 +2589,30 @@ function pvpSnapshot(match) {
statuses: match.statuses, statuses: match.statuses,
progress: match.progress, progress: match.progress,
upgradeChoices: match.upgradeChoices, upgradeChoices: match.upgradeChoices,
rematchRequests: match.rematchRequests,
updatedAt: match.updatedAt, updatedAt: match.updatedAt,
} }
} }
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
const matchId = randomBytes(12).toString('base64url')
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players,
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
rematchRequests: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
return match
}
function joinPvpQueue(session, payload) { function joinPvpQueue(session, payload) {
const now = Date.now() const now = Date.now()
cleanupPvpMemory(now) cleanupPvpMemory(now)
@@ -2622,24 +2642,11 @@ function joinPvpQueue(session, payload) {
.sort((left, right) => left.createdAt - right.createdAt)[0] .sort((left, right) => left.createdAt - right.createdAt)[0]
const player = pvpPlayerInfo(session) const player = pvpPlayerInfo(session)
if (opponent) { if (opponent) {
const matchId = randomBytes(12).toString('base64url') const match = createPvpMatch(contentType, startStage, {
const match = { a: { side: 'a', ...opponent.player },
id: matchId, b: { side: 'b', ...player },
contentType, }, now)
startStage, opponent.matchId = match.id
createdAt: now,
players: {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
},
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
opponent.matchId = matchId
opponent.updatedAt = now opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url') const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, { pvpQueue.set(ticketId, {
@@ -2649,7 +2656,7 @@ function joinPvpQueue(session, payload) {
contentType, contentType,
startStage, startStage,
player, player,
matchId, matchId: match.id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@@ -2751,6 +2758,33 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
return pvpSnapshot(match) return pvpSnapshot(match)
} }
function requestPvpRematch(session, matchId) {
const { match, side } = requirePvpMatchForSession(session, matchId)
if (match.nextMatchId) {
const nextMatch = pvpMatches.get(match.nextMatchId)
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
match.rematchRequests = match.rematchRequests ?? {}
match.rematchRequests[side] = true
match.updatedAt = Date.now()
const opponentSide = side === 'a' ? 'b' : 'a'
if (!match.rematchRequests[opponentSide]) {
return { status: 'waiting', match: pvpSnapshot(match), side }
}
const nextMatch = createPvpMatch(
match.contentType,
match.startStage,
{
a: match.players.a,
b: match.players.b,
},
Date.now(),
)
match.nextMatchId = nextMatch.id
match.updatedAt = Date.now()
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
export function gameApiPlugin() { export function gameApiPlugin() {
return { return {
name: 'ashen-halls-game-api', name: 'ashen-halls-game-api',
@@ -2971,6 +3005,12 @@ export async function handleApiRequest(request, response, next) {
return return
} }
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
if (pvpRematch && request.method === 'POST') {
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
return
}
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/) const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
if (pvpMatch && request.method === 'GET') { if (pvpMatch && request.method === 'GET') {
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match)) sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
+96 -25
View File
@@ -6087,27 +6087,17 @@ h2 {
.pvp-board { .pvp-board {
display: grid; display: grid;
gap: 8px; gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0; min-height: 0;
} }
.pvp-side, .pvp-side {
.pvp-middle-panel {
gap: 8px; gap: 8px;
min-height: 0; min-height: 0;
padding: 8px; padding: 8px;
} }
.pvp-vertical-spell-bar,
.pvp-vertical-spell-bar.six-slots {
grid-template-columns: 1fr;
}
.pvp-vertical-spell-bar .spell {
min-height: 58px;
padding: 6px;
}
.pvp-screen-tools { .pvp-screen-tools {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -6118,18 +6108,41 @@ h2 {
justify-content: flex-end; justify-content: flex-end;
} }
.pvp-resource-wrap { .pvp-side-bars {
color: #82bfff; display: grid;
min-width: 150px; gap: 8px;
text-align: right; min-width: min(320px, 45%);
width: min(170px, 100%); width: min(360px, 48%);
} }
.pvp-clear-wrap,
.pvp-resource-wrap {
color: var(--muted);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
text-align: right;
width: 100%;
}
.pvp-clear-wrap > span,
.pvp-resource-wrap > span { .pvp-resource-wrap > span {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
} }
.pvp-clear-wrap .bar,
.pvp-resource-wrap .bar {
height: 13px;
}
.pvp-clear-wrap {
color: #ff8d9a;
}
.pvp-resource-wrap {
color: #82bfff;
}
.pvp-side .party-member, .pvp-side .party-member,
.pvp-side .party-member > div, .pvp-side .party-member > div,
.pvp-side .party-member > small { .pvp-side .party-member > small {
@@ -6147,7 +6160,7 @@ h2 {
} }
.pvp-side .pvp-party-grid.raid .party-member { .pvp-side .pvp-party-grid.raid .party-member {
min-height: 62px; min-height: 96px;
padding: 6px; padding: 6px;
} }
@@ -6184,6 +6197,29 @@ h2 {
height: 14px; height: 14px;
} }
.pvp-side .member-health {
position: relative;
}
.pvp-side .member-health .health-text {
align-items: center;
color: #fff3c7;
display: flex;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
inset: 0;
justify-content: center;
pointer-events: none;
position: absolute;
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
z-index: 2;
}
.pvp-side .party-member .member-header small {
display: none;
}
.pvp-side .member-effects { .pvp-side .member-effects {
margin-top: 4px; margin-top: 4px;
} }
@@ -6202,22 +6238,57 @@ h2 {
gap: 8px; gap: 8px;
} }
.pvp-middle-panel .encounter-header h2 {
font-size: 20px;
}
.pvp-middle-panel .encounter-header small,
.pvp-enemy-race small { .pvp-enemy-race small {
font-size: 14px; font-size: 14px;
} }
.pvp-middle-panel .roguelike-upgrade-list,
.pvp-side .roguelike-upgrade-list { .pvp-side .roguelike-upgrade-list {
font-size: 12px; font-size: 12px;
line-height: 1.1; line-height: 1.1;
margin-top: 4px; margin-top: 4px;
} }
.pvp-bottom-spell-bar {
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 4px 4px 0 #08090c;
display: grid;
gap: 8px;
grid-column: 1 / -1;
grid-template-columns: repeat(6, minmax(0, 1fr));
outline: 2px solid var(--edge);
padding: 8px;
}
.pvp-bottom-spell-bar .spell {
align-items: center;
display: grid;
gap: 6px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 58px;
padding: 7px;
text-align: left;
}
.pvp-bottom-spell-bar .spell-icon {
height: 34px;
margin: 0;
width: 34px;
}
.pvp-bottom-spell-bar .spell strong {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pvp-bottom-spell-bar .spell small {
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.pvp-choice-columns { .pvp-choice-columns {
display: grid; display: grid;
gap: 10px; gap: 10px;
+185 -85
View File
@@ -28,6 +28,7 @@ import {
joinPvpQueue, joinPvpQueue,
loadPvpMatch, loadPvpMatch,
publishPvpMatchState, publishPvpMatchState,
requestPvpRematch,
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint, recordPvpRoguelikeCheckpoint,
@@ -35,6 +36,7 @@ import {
type CpuDifficulty, type CpuDifficulty,
type PvpMatchSnapshot, type PvpMatchSnapshot,
type PvpMatchSide, type PvpMatchSide,
type PvpRematchResponse,
type PvpContentType, type PvpContentType,
type PvpUpgradeChoicePayload, type PvpUpgradeChoicePayload,
} from '../pvpRoguelike' } from '../pvpRoguelike'
@@ -469,6 +471,8 @@ export function PvPRoguelikeScreen({
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null) const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null) const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
const [liveUpgradePending, setLiveUpgradePending] = useState(false) const [liveUpgradePending, setLiveUpgradePending] = useState(false)
const [rematchRequested, setRematchRequested] = useState(false)
const [rematchMessage, setRematchMessage] = useState('')
const [queueMessage, setQueueMessage] = useState('') const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null) const [reward, setReward] = useState<DungeonReward | null>(null)
@@ -635,6 +639,80 @@ export function PvPRoguelikeScreen({
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
const startLiveMatch = useCallback((
match: PvpMatchSnapshot<SideState>,
side: PvpMatchSide,
message?: string,
) => {
const matchStartStage = match.startStage
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource)
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSide]
const baseOpponent = starterSide(
cpuPartyTemplate.map((member) => ({
...member,
name: member.id === 'mira' ? opponent.characterName : member.name,
})),
maxResource,
)
basePlayer.enemyHealth = firstEncounter.maxHealth
baseOpponent.enemyHealth = firstEncounter.maxHealth
const nextLiveMatch: LivePvpMatch = {
id: match.id,
side,
opponentSide,
opponentName: opponent.characterName,
opponentClassName: opponent.className,
}
playerRef.current = basePlayer
cpuRef.current = baseOpponent
liveMatchRef.current = nextLiveMatch
nextLogId.current = 2
playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0)
setStatus('playing')
setPlayerSide(basePlayer)
setCpuSide(baseOpponent)
setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([])
setPlayerDebuffChoices([])
setSelectedBuff(null)
setSelectedDebuff(null)
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
upgradeChoiceEndsAtRef.current = 0
autoSubmittedUpgradeRef.current = false
setEncountersCleared(0)
setPaused(false)
setTargetGroup(0)
setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError('')
setShowEndLog(false)
setFloatingTexts([])
setCpuDifficulty(null)
setLiveMatch(nextLiveMatch)
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
rewardClaimedRef.current = false
cpuDefeatedRef.current = false
setRematchRequested(false)
setRematchMessage('')
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
setQueueMessage(logText)
setLog([{ id: 1, text: logText, tone: 'system' }])
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
const startMatch = useCallback((nextStartStage?: number) => { const startMatch = useCallback((nextStartStage?: number) => {
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
@@ -680,6 +758,8 @@ export function PvPRoguelikeScreen({
setLiveUpgradePending(false) setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
setRematchRequested(false)
setRematchMessage('')
recordedRunRef.current = false recordedRunRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -709,29 +789,7 @@ export function PvPRoguelikeScreen({
if (cancelled) return if (cancelled) return
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSide] const opponent = match.players[opponentSide]
const nextLiveMatch = { startLiveMatch(match, side, `${opponent.characterName} found. Stage ${match.startStage} begins.`)
id: match.id,
side,
opponentSide,
opponentName: opponent.characterName,
opponentClassName: opponent.className,
}
liveMatchRef.current = nextLiveMatch
setLiveMatch(nextLiveMatch)
setCpuDifficulty(null)
const opponentBase = starterSide(
cpuPartyTemplate.map((member) => ({
...member,
name: member.id === 'mira' ? opponent.characterName : member.name,
})),
maxResource,
)
opponentBase.enemyHealth = firstEncounter.maxHealth
cpuRef.current = opponentBase
setCpuSide(opponentBase)
setQueueMessage(`${opponent.characterName} found. Match begins.`)
setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
setStatus('playing')
} }
const fallbackTimer = window.setTimeout(() => { const fallbackTimer = window.setTimeout(() => {
if (cancelled || liveMatchRef.current) return if (cancelled || liveMatchRef.current) return
@@ -781,7 +839,7 @@ export function PvPRoguelikeScreen({
if (pollTimer) window.clearTimeout(pollTimer) if (pollTimer) window.clearTimeout(pollTimer)
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined) if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
} }
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
useEffect(() => startMatch(), [startMatch]) useEffect(() => startMatch(), [startMatch])
@@ -807,10 +865,13 @@ export function PvPRoguelikeScreen({
setCpuSide(opponentState) setCpuSide(opponentState)
} }
const opponentStatus = snapshot.statuses[liveMatch.opponentSide] const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) { const opponentAlive = snapshot.progress[liveMatch.opponentSide]?.alive
if ((opponentStatus === 'lost' || opponentAlive === false) && !loggedOpponentDoneRef.current && status !== 'won' && status !== 'lost') {
loggedOpponentDoneRef.current = true loggedOpponentDoneRef.current = true
cpuDefeatedRef.current = true cpuDefeatedRef.current = true
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot') finishRoguelikeRun()
setStatus('won')
addLog(`${liveMatch.opponentName} fell. Match complete.`, 'loot')
} }
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') { if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
finishRoguelikeRun() finishRoguelikeRun()
@@ -1188,7 +1249,10 @@ export function PvPRoguelikeScreen({
} }
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) { if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true cpuDefeatedRef.current = true
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') finishRoguelikeRun()
setStatus('won')
addLog(`CPU ${cpuDifficulty ?? 1} fell. Match complete.`, 'loot')
return
} }
if (nextPlayer.enemyHealth <= 0) { if (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) { if (encounter.isBoss && cpuDefeatedRef.current) {
@@ -1218,6 +1282,46 @@ export function PvPRoguelikeScreen({
}) })
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status]) }, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
const handleRematch = useCallback(() => {
if (!liveMatch || rematchRequested) return
let cancelled = false
let attempts = 0
setRematchRequested(true)
setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`)
const handleResponse = (result: PvpRematchResponse<SideState>) => {
if (cancelled) return
if (result.status === 'matched' && result.match && result.side) {
startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`)
return
}
attempts += 1
if (attempts >= 180) {
setRematchRequested(false)
setRematchMessage('Rematch expired.')
return
}
window.setTimeout(pollRematch, 700)
}
const pollRematch = () => {
requestPvpRematch<SideState>(liveMatch.id)
.then(handleResponse)
.catch((reason: unknown) => {
if (cancelled) return
attempts += 1
if (attempts >= 10) {
setRematchRequested(false)
setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.')
return
}
window.setTimeout(pollRematch, 900)
})
}
pollRematch()
return () => {
cancelled = true
}
}, [liveMatch, rematchRequested, startLiveMatch])
useEffect(() => { useEffect(() => {
if (status !== 'upgrade-choice') return if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
@@ -1618,8 +1722,15 @@ export function PvPRoguelikeScreen({
<div> <div>
<p className="eyebrow">You</p> <p className="eyebrow">You</p>
<h2>{profile.character.name}</h2> <h2>{profile.character.name}</h2>
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
</div> </div>
<div className="resource-row pvp-resource-row"> <div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
</div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
@@ -1643,6 +1754,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />} {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1665,69 +1777,20 @@ export function PvPRoguelikeScreen({
</p> </p>
</section> </section>
<section className="combat-panel pvp-middle-panel">
<div className="encounter-header">
<div>
<p className="eyebrow">Encounter {encounterIndex + 1}</p>
<h2>{encounter.enemyName}</h2>
<small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
</div>
</div>
<div className="pvp-enemy-race">
<div>
<strong>Your clear</strong>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
</div>
<div>
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
<small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
</div>
</div>
<div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
{starterSpells.map((spell) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
return (
<button
className="spell"
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
key={`middle-${spell.id}`}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{cost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</div>
<p className="roguelike-upgrade-list">
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
</p>
</section>
<section className="combat-panel pvp-side"> <section className="combat-panel pvp-side">
<div className="encounter-header"> <div className="encounter-header">
<div> <div>
<p className="eyebrow">Opponent</p> <p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2> <h2>{opponentLabel}</h2>
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
</div> </div>
<div className="resource-row pvp-resource-row"> <div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
</div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
@@ -1745,6 +1808,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />} {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1766,6 +1830,34 @@ export function PvPRoguelikeScreen({
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'} Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
</p> </p>
</section> </section>
<section className="pvp-bottom-spell-bar" aria-label="Player abilities">
{starterSpells.map((spell) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
return (
<button
className="spell"
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
key={`bottom-${spell.id}`}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{cost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</div> </div>
)} )}
@@ -1910,6 +2002,14 @@ export function PvPRoguelikeScreen({
)} )}
</> </>
)} )}
{liveMatch && (
<>
<button disabled={rematchRequested} onClick={handleRematch} type="button">
{rematchRequested ? 'Waiting for Rematch' : 'Rematch'}
</button>
{rematchMessage && <p>{rematchMessage}</p>}
</>
)}
<button onClick={() => startMatch()} type="button">Queue Next Match</button> <button onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button> <button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div> </div>
+13
View File
@@ -35,6 +35,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
elapsedTicks: number elapsedTicks: number
}>> }>>
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>> upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
updatedAt: number updatedAt: number
} }
@@ -45,6 +46,12 @@ export type PvpQueueResponse<TSideState = unknown> = {
side?: PvpMatchSide side?: PvpMatchSide
} }
export type PvpRematchResponse<TSideState = unknown> = {
status: 'waiting' | 'matched'
match?: PvpMatchSnapshot<TSideState>
side?: PvpMatchSide
}
export type CpuPvpLeaderboardEntry = { export type CpuPvpLeaderboardEntry = {
characterName: string characterName: string
className: string className: string
@@ -166,3 +173,9 @@ export function submitPvpUpgradeChoice(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
} }
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
method: 'POST',
})
}