185 lines
5.7 KiB
TypeScript
185 lines
5.7 KiB
TypeScript
import { requestGameApiJson } from './gameRepository'
|
|
|
|
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
|
|
accountId: number
|
|
characterId: number
|
|
characterName: string
|
|
className: string
|
|
}
|
|
|
|
export type PvpUpgradeChoicePayload = {
|
|
encounterIndex: number
|
|
buffId: string
|
|
debuffId: string
|
|
purchases?: string[]
|
|
shopReady?: boolean
|
|
}
|
|
|
|
export type PvpMatchSnapshot<TSideState = unknown> = {
|
|
id: string
|
|
contentType: PvpContentType
|
|
startStage: number
|
|
createdAt: number
|
|
players: Record<PvpMatchSide, PvpPlayerInfo>
|
|
states: Partial<Record<PvpMatchSide, TSideState>>
|
|
statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
|
|
progress: Partial<Record<PvpMatchSide, {
|
|
stage: number
|
|
encounterIndex: number
|
|
encountersCleared: number
|
|
enemyHealth: number
|
|
alive: boolean
|
|
elapsedTicks: number
|
|
}>>
|
|
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
|
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
|
updatedAt: number
|
|
}
|
|
|
|
export type PvpQueueResponse<TSideState = unknown> = {
|
|
ticketId: string
|
|
status: 'waiting' | 'matched'
|
|
match?: PvpMatchSnapshot<TSideState>
|
|
side?: PvpMatchSide
|
|
}
|
|
|
|
export type PvpRematchResponse<TSideState = unknown> = {
|
|
status: 'waiting' | 'matched'
|
|
match?: PvpMatchSnapshot<TSideState>
|
|
side?: PvpMatchSide
|
|
}
|
|
|
|
export type CpuPvpLeaderboardEntry = {
|
|
characterName: string
|
|
className: string
|
|
contentType: PvpContentType
|
|
encountersCleared: number
|
|
cpuDifficulty: CpuDifficulty
|
|
result: 'victory' | 'defeat'
|
|
completedAt: string
|
|
}
|
|
|
|
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
|
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
|
|
|
export function randomCpuDifficulty(): CpuDifficulty {
|
|
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
|
}
|
|
|
|
export function loadCpuPvpLeaderboard(contentType?: PvpContentType): CpuPvpLeaderboardEntry[] {
|
|
try {
|
|
const raw = JSON.parse(localStorage.getItem(cpuLeaderboardKey) ?? '[]') as CpuPvpLeaderboardEntry[]
|
|
return raw
|
|
.filter((entry) => !contentType || entry.contentType === contentType)
|
|
.sort((left, right) =>
|
|
right.encountersCleared - left.encountersCleared
|
|
|| right.cpuDifficulty - left.cpuDifficulty
|
|
|| right.completedAt.localeCompare(left.completedAt),
|
|
)
|
|
.slice(0, 12)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
|
const current = loadCpuPvpLeaderboard()
|
|
const next = [...current, entry]
|
|
.sort((left, right) =>
|
|
right.encountersCleared - left.encountersCleared
|
|
|| right.cpuDifficulty - left.cpuDifficulty
|
|
|| right.completedAt.localeCompare(left.completedAt),
|
|
)
|
|
.slice(0, 30)
|
|
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
|
}
|
|
|
|
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
|
|
return `${checkpointKey}:${characterId}:${contentType}`
|
|
}
|
|
|
|
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
|
|
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
|
|
return Number.isInteger(value) && value >= 5 ? value : 1
|
|
}
|
|
|
|
export function recordPvpRoguelikeCheckpoint(
|
|
characterId: number,
|
|
contentType: PvpContentType,
|
|
stage: number,
|
|
) {
|
|
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
|
|
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
|
|
const next = Math.max(current, stage)
|
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
|
return next
|
|
}
|
|
|
|
export function joinPvpQueue<TSideState>(
|
|
contentType: PvpContentType,
|
|
startStage: number,
|
|
): Promise<PvpQueueResponse<TSideState>> {
|
|
return requestGameApiJson('/api/pvp/queue', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ contentType, startStage }),
|
|
})
|
|
}
|
|
|
|
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
|
}
|
|
|
|
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
}
|
|
|
|
export function publishPvpMatchState<TSideState>(
|
|
matchId: string,
|
|
payload: {
|
|
state: TSideState
|
|
status: PvpMatchStatus
|
|
stage: number
|
|
encounterIndex: number
|
|
encountersCleared: number
|
|
enemyHealth: number
|
|
alive: boolean
|
|
elapsedTicks: number
|
|
},
|
|
): Promise<PvpMatchSnapshot<TSideState>> {
|
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
}
|
|
|
|
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
|
}
|
|
|
|
export function submitPvpUpgradeChoice(
|
|
matchId: string,
|
|
payload: PvpUpgradeChoicePayload,
|
|
): Promise<PvpMatchSnapshot> {
|
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
}
|
|
|
|
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
|
method: 'POST',
|
|
})
|
|
}
|