diff --git a/IWantToHeal-Thor-v1.0.55.apk b/IWantToHeal-Thor-v1.0.55.apk new file mode 100644 index 0000000..c44ecfb Binary files /dev/null and b/IWantToHeal-Thor-v1.0.55.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index f291f0a..691acaa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 73 - versionName "1.0.54" + versionCode 74 + versionName "1.0.55" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/server/game-api.mjs b/server/game-api.mjs index 6e5cb18..6adb49b 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -2589,10 +2589,30 @@ function pvpSnapshot(match) { statuses: match.statuses, progress: match.progress, upgradeChoices: match.upgradeChoices, + rematchRequests: match.rematchRequests, 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) { const now = Date.now() cleanupPvpMemory(now) @@ -2622,24 +2642,11 @@ function joinPvpQueue(session, payload) { .sort((left, right) => left.createdAt - right.createdAt)[0] const player = pvpPlayerInfo(session) if (opponent) { - const matchId = randomBytes(12).toString('base64url') - const match = { - id: matchId, - contentType, - startStage, - 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 + const match = createPvpMatch(contentType, startStage, { + a: { side: 'a', ...opponent.player }, + b: { side: 'b', ...player }, + }, now) + opponent.matchId = match.id opponent.updatedAt = now const ticketId = randomBytes(12).toString('base64url') pvpQueue.set(ticketId, { @@ -2649,7 +2656,7 @@ function joinPvpQueue(session, payload) { contentType, startStage, player, - matchId, + matchId: match.id, createdAt: now, updatedAt: now, }) @@ -2751,6 +2758,33 @@ function submitPvpUpgradeChoice(session, matchId, payload) { 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() { return { name: 'ashen-halls-game-api', @@ -2971,6 +3005,12 @@ export async function handleApiRequest(request, response, next) { 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_-]+)$/) if (pvpMatch && request.method === 'GET') { sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match)) diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index c44778a..6a644ea 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -28,6 +28,7 @@ import { joinPvpQueue, loadPvpMatch, publishPvpMatchState, + requestPvpRematch, randomCpuDifficulty, recordCpuPvpLeaderboard, recordPvpRoguelikeCheckpoint, @@ -35,6 +36,7 @@ import { type CpuDifficulty, type PvpMatchSnapshot, type PvpMatchSide, + type PvpRematchResponse, type PvpContentType, type PvpUpgradeChoicePayload, } from '../pvpRoguelike' @@ -469,6 +471,8 @@ export function PvPRoguelikeScreen({ const [cpuDifficulty, setCpuDifficulty] = useState(null) const [liveMatch, setLiveMatch] = useState(null) const [liveUpgradePending, setLiveUpgradePending] = useState(false) + const [rematchRequested, setRematchRequested] = useState(false) + const [rematchMessage, setRematchMessage] = useState('') const [queueMessage, setQueueMessage] = useState('') const [log, setLog] = useState([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [reward, setReward] = useState(null) @@ -635,6 +639,80 @@ export function PvPRoguelikeScreen({ : null) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) + const startLiveMatch = useCallback(( + match: PvpMatchSnapshot, + 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 matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) @@ -680,6 +758,8 @@ export function PvPRoguelikeScreen({ setLiveUpgradePending(false) pendingLiveUpgradeRef.current = null loggedOpponentDoneRef.current = false + setRematchRequested(false) + setRematchMessage('') recordedRunRef.current = false rewardClaimedRef.current = false cpuDefeatedRef.current = false @@ -709,29 +789,7 @@ export function PvPRoguelikeScreen({ if (cancelled) return const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' const opponent = match.players[opponentSide] - const nextLiveMatch = { - 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') + startLiveMatch(match, side, `${opponent.characterName} found. Stage ${match.startStage} begins.`) } const fallbackTimer = window.setTimeout(() => { if (cancelled || liveMatchRef.current) return @@ -781,7 +839,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]) + }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch]) useEffect(() => startMatch(), [startMatch]) @@ -807,10 +865,13 @@ export function PvPRoguelikeScreen({ setCpuSide(opponentState) } 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 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') { finishRoguelikeRun() @@ -1188,7 +1249,10 @@ export function PvPRoguelikeScreen({ } if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) { 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 (encounter.isBoss && cpuDefeatedRef.current) { @@ -1218,6 +1282,46 @@ export function PvPRoguelikeScreen({ }) }, [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) => { + 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(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(() => { if (status !== 'upgrade-choice') return window.requestAnimationFrame(() => focusFirstControl()) @@ -1898,6 +2002,14 @@ export function PvPRoguelikeScreen({ )} )} + {liveMatch && ( + <> + + {rematchMessage &&

{rematchMessage}

} + + )} diff --git a/src/pvpRoguelike.ts b/src/pvpRoguelike.ts index d2d3e70..e18f505 100644 --- a/src/pvpRoguelike.ts +++ b/src/pvpRoguelike.ts @@ -35,6 +35,7 @@ export type PvpMatchSnapshot = { elapsedTicks: number }>> upgradeChoices: Partial>> + rematchRequests?: Partial> updatedAt: number } @@ -45,6 +46,12 @@ export type PvpQueueResponse = { side?: PvpMatchSide } +export type PvpRematchResponse = { + status: 'waiting' | 'matched' + match?: PvpMatchSnapshot + side?: PvpMatchSide +} + export type CpuPvpLeaderboardEntry = { characterName: string className: string @@ -166,3 +173,9 @@ export function submitPvpUpgradeChoice( body: JSON.stringify(payload), }) } + +export function requestPvpRematch(matchId: string): Promise> { + return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, { + method: 'POST', + }) +}