Android build v1.0.55
This commit is contained in:
Binary file not shown.
@@ -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.
|
||||
|
||||
+57
-17
@@ -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: {
|
||||
const match = createPvpMatch(contentType, startStage, {
|
||||
a: { side: 'a', ...opponent.player },
|
||||
b: { side: 'b', ...player },
|
||||
},
|
||||
states: {},
|
||||
statuses: {},
|
||||
progress: {},
|
||||
upgradeChoices: {},
|
||||
updatedAt: now,
|
||||
}
|
||||
pvpMatches.set(matchId, match)
|
||||
opponent.matchId = matchId
|
||||
}, 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))
|
||||
|
||||
@@ -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<CpuDifficulty | null>(null)
|
||||
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
||||
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
||||
const [rematchRequested, setRematchRequested] = useState(false)
|
||||
const [rematchMessage, setRematchMessage] = useState('')
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
@@ -635,6 +639,80 @@ export function PvPRoguelikeScreen({
|
||||
: null)
|
||||
}, [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 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<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(() => {
|
||||
if (status !== 'upgrade-choice') return
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
@@ -1898,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 className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||
elapsedTicks: number
|
||||
}>>
|
||||
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -45,6 +46,12 @@ export type PvpQueueResponse<TSideState = unknown> = {
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type PvpRematchResponse<TSideState = unknown> = {
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type CpuPvpLeaderboardEntry = {
|
||||
characterName: string
|
||||
className: string
|
||||
@@ -166,3 +173,9 @@ export function submitPvpUpgradeChoice(
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user