diff --git a/IWantToHeal-Thor-v1.0.53.apk b/IWantToHeal-Thor-v1.0.53.apk new file mode 100644 index 0000000..0cee2f4 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.53.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index eefabf2..9d7ffca 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 71 - versionName "1.0.52" + versionCode 72 + versionName "1.0.53" 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/docs/ui-mockups/pvp-dungeon-pc-layout.svg b/docs/ui-mockups/pvp-dungeon-pc-layout.svg new file mode 100644 index 0000000..f7eed67 --- /dev/null +++ b/docs/ui-mockups/pvp-dungeon-pc-layout.svg @@ -0,0 +1,59 @@ + + + + + + + PC Dungeon PvP Roguelike + Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s. + + + YouMira - Priest + Party Health + + Tank82% + Mira74% + DPS55% + DPS92% + DPS66% + DPS41% + + Buffs: Wide Radiance x1Debuffs: Mana Squeeze x1 + + + Stage 15 BossBulldrome Guardian + Your Clear + Astra Clear + + + Choose Edge07.4s + Self BuffOpponent Debuff + +1 Target + -25% Cost + Cost Up + Mana Squeeze + + + + + R + S + G + C + B + + + + OpponentAstra - Druid + Opponent Party + + Tank59% + Astra88% + DPS73% + DPS42% + DPS91% + DPS66% + + Buffs: Dense Shields x1Debuffs: Cost Up x1 + diff --git a/docs/ui-mockups/pvp-dungeon-thor-main.svg b/docs/ui-mockups/pvp-dungeon-thor-main.svg new file mode 100644 index 0000000..5ef272c --- /dev/null +++ b/docs/ui-mockups/pvp-dungeon-thor-main.svg @@ -0,0 +1,30 @@ + + + + + + + Thor Main - Dungeon PvP + Dungeon party shows exactly 6 members. Opponent health stays on bottom screen. + + Mira PartyStage 15 Boss + + + Tank82 / 100 + Mira74 / 100 + DPS55 / 100 + DPS92 / 100 + DPS66 / 100 + DPS41 / 100 + + + + + R3 + S8 + G + C5 + B + Mana 73 / 100 + diff --git a/docs/ui-mockups/pvp-raid-pc-layout.svg b/docs/ui-mockups/pvp-raid-pc-layout.svg new file mode 100644 index 0000000..f4b83b2 --- /dev/null +++ b/docs/ui-mockups/pvp-raid-pc-layout.svg @@ -0,0 +1,62 @@ + + + + + + + PC Raid PvP Roguelike + Raid party = compact roster. Opponent panel mirrors raid health without crowding center. + + YouMira Raid Group + + Tank 82% + Mira 74% + DPS 55% + DPS 92% + DPS 66% + DPS 41% + DPS 88% + DPS 63% + DPS 77% + DPS 49% + DPS 96% + DPS 70% + + Direct target group: 1 / 2Buffs: Wide Radiance x1 + + + Raid Stage 20 BossAshen Warden + Your Clear + Astra Clear + + Choose Edge10.0s + Raid Heal + + Mana Squeeze + Shield Boost + Cooldown Up + + + + R + S + G + C + B + + + + Opponent RaidAstra Group + + Tank 61% + Astra 89% + DPS 73% + DPS 42% + DPS 91% + DPS 66% + DPS 79% + DPS 58% + DPS 84% + + Opponent buffs/debuffs visible hereRaid bottom screen can scroll second group if needed + diff --git a/docs/ui-mockups/pvp-raid-thor-main.svg b/docs/ui-mockups/pvp-raid-thor-main.svg new file mode 100644 index 0000000..c5b7866 --- /dev/null +++ b/docs/ui-mockups/pvp-raid-thor-main.svg @@ -0,0 +1,36 @@ + + + + + + + Thor Main - Raid PvP + Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid. + + Mira RaidTarget Group 1 / 2 + Raid Boss + + Tank82% + Mira74% + DPS55% + DPS92% + DPS66% + DPS41% + DPS88% + DPS63% + DPS77% + DPS49% + DPS96% + DPS70% + + + + + R3 + S8 + G + C5 + B + Mana 73 / 100 + diff --git a/docs/ui-mockups/pvp-thor-bottom.svg b/docs/ui-mockups/pvp-thor-bottom.svg new file mode 100644 index 0000000..58c95a4 --- /dev/null +++ b/docs/ui-mockups/pvp-thor-bottom.svg @@ -0,0 +1,41 @@ + + + + + + + + + + Thor Bottom Screen - Opponent View + Secondary display shows real opponent health/progress. + + Astra + Druid - live opponent + Opponent Clear + + Opponent Party Health + + Tank59% + Astra88% + DPS73% + DPS42% + DPS91% + DPS66% + + Mana 86 / 100 + + Buffs: Dense Shields x1 + Debuffs: Cost Up x1 + diff --git a/server/game-api.mjs b/server/game-api.mjs index a0fffe1..6e5cb18 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -27,6 +27,10 @@ const directCraftItemLevels = new Set([1, 10, 20, 25]) const sessionCookieName = 'chronicle_session' const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const rateLimitBuckets = new Map() +const pvpQueue = new Map() +const pvpMatches = new Map() +const pvpQueueTtlMs = 15 * 1000 +const pvpMatchTtlMs = 60 * 60 * 1000 function sendJson(response, status, body, headers = {}) { response.statusCode = status @@ -349,10 +353,13 @@ function currentSession(database, request) { accounts.id AS accountId, accounts.username, characters.id AS characterId, - characters.class_id AS classId + characters.class_id AS classId, + characters.name AS characterName, + classes.name AS className FROM sessions JOIN accounts ON accounts.id = sessions.account_id JOIN characters ON characters.id = sessions.active_character_id + JOIN classes ON classes.id = characters.class_id WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP `).get(tokenHash(token)) ?? null @@ -2538,6 +2545,212 @@ function saveProfile(database, characterId, accountId, payload) { } } +function cleanupPvpMemory(now = Date.now()) { + for (const [ticketId, ticket] of pvpQueue.entries()) { + if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId) + } + for (const [matchId, match] of pvpMatches.entries()) { + if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId) + } +} + +function validatePvpContentType(value) { + if (value !== 'dungeon' && value !== 'raid') { + throw new Error('The PvP content type is invalid.') + } + return value +} + +function validatePvpStartStage(value) { + const startStage = Number(value) + if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) { + throw new Error('The PvP start stage is invalid.') + } + return startStage +} + +function pvpPlayerInfo(session) { + return { + accountId: session.accountId, + characterId: session.characterId, + characterName: session.characterName, + className: session.className, + } +} + +function pvpSnapshot(match) { + return { + id: match.id, + contentType: match.contentType, + startStage: match.startStage, + createdAt: match.createdAt, + players: match.players, + states: match.states, + statuses: match.statuses, + progress: match.progress, + upgradeChoices: match.upgradeChoices, + updatedAt: match.updatedAt, + } +} + +function joinPvpQueue(session, payload) { + const now = Date.now() + cleanupPvpMemory(now) + const contentType = validatePvpContentType(payload.contentType) + const startStage = validatePvpStartStage(payload.startStage) + const existingTicket = [...pvpQueue.values()].find((ticket) => + ticket.accountId === session.accountId + && ticket.characterId === session.characterId + && ticket.contentType === contentType + && ticket.startStage === startStage + ) + if (existingTicket?.matchId) { + const match = pvpMatches.get(existingTicket.matchId) + if (match) { + const side = match.players.a.accountId === session.accountId ? 'a' : 'b' + return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) } + } + } + + const opponent = [...pvpQueue.values()] + .filter((ticket) => + !ticket.matchId + && ticket.contentType === contentType + && ticket.startStage === startStage + && ticket.accountId !== session.accountId + ) + .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 + opponent.updatedAt = now + const ticketId = randomBytes(12).toString('base64url') + pvpQueue.set(ticketId, { + id: ticketId, + accountId: session.accountId, + characterId: session.characterId, + contentType, + startStage, + player, + matchId, + createdAt: now, + updatedAt: now, + }) + return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) } + } + + if (existingTicket) { + existingTicket.updatedAt = now + return { ticketId: existingTicket.id, status: 'waiting' } + } + + const ticketId = randomBytes(12).toString('base64url') + pvpQueue.set(ticketId, { + id: ticketId, + accountId: session.accountId, + characterId: session.characterId, + contentType, + startStage, + player, + createdAt: now, + updatedAt: now, + }) + return { ticketId, status: 'waiting' } +} + +function checkPvpQueue(session, ticketId) { + cleanupPvpMemory() + const ticket = pvpQueue.get(ticketId) + if (!ticket || ticket.accountId !== session.accountId) { + const error = new Error('PvP queue ticket not found.') + error.status = 404 + throw error + } + ticket.updatedAt = Date.now() + if (!ticket.matchId) return { ticketId, status: 'waiting' } + const match = pvpMatches.get(ticket.matchId) + if (!match) return { ticketId, status: 'waiting' } + const side = match.players.a.accountId === session.accountId ? 'a' : 'b' + return { ticketId, status: 'matched', side, match: pvpSnapshot(match) } +} + +function cancelPvpQueue(session, ticketId) { + const ticket = pvpQueue.get(ticketId) + if (ticket && ticket.accountId === session.accountId && !ticket.matchId) { + pvpQueue.delete(ticketId) + } + return { ok: true } +} + +function requirePvpMatchForSession(session, matchId) { + cleanupPvpMemory() + const match = pvpMatches.get(matchId) + if (!match) { + const error = new Error('PvP match not found.') + error.status = 404 + throw error + } + const side = match.players.a.accountId === session.accountId ? 'a' + : match.players.b.accountId === session.accountId ? 'b' + : null + if (!side) { + const error = new Error('That PvP match belongs to another account.') + error.status = 403 + throw error + } + return { match, side } +} + +function updatePvpMatchState(session, matchId, payload) { + const { match, side } = requirePvpMatchForSession(session, matchId) + const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status) + ? payload.status + : 'playing' + const progress = { + stage: validatePvpStartStage(payload.stage), + encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)), + encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)), + enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0), + alive: Boolean(payload.alive), + elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)), + } + match.states[side] = payload.state ?? null + match.statuses[side] = status + match.progress[side] = progress + match.updatedAt = Date.now() + return pvpSnapshot(match) +} + +function submitPvpUpgradeChoice(session, matchId, payload) { + const { match, side } = requirePvpMatchForSession(session, matchId) + const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)) + if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {} + match.upgradeChoices[side][String(encounterIndex)] = { + encounterIndex, + buffId: String(payload.buffId ?? ''), + debuffId: String(payload.debuffId ?? ''), + } + match.updatedAt = Date.now() + return pvpSnapshot(match) +} + export function gameApiPlugin() { return { name: 'ashen-halls-game-api', @@ -2688,7 +2901,7 @@ export async function handleApiRequest(request, response, next) { try { const ip = requestIp(request) - consumeRateLimit(`api:${ip}`, 240, 60 * 1000) + consumeRateLimit(`api:${ip}`, 900, 60 * 1000) database.prepare(` DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP `).run() @@ -2727,6 +2940,43 @@ export async function handleApiRequest(request, response, next) { return } + if (request.url === '/api/pvp/queue' && request.method === 'POST') { + const payload = await readJson(request) + sendJson(response, 200, joinPvpQueue(session, payload)) + return + } + + const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/) + if (pvpQueueTicket && request.method === 'GET') { + sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1])) + return + } + + if (pvpQueueTicket && request.method === 'DELETE') { + sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1])) + return + } + + const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/) + if (pvpMatchState && request.method === 'POST') { + const payload = await readJson(request, 128 * 1024) + sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload)) + return + } + + const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/) + if (pvpUpgradeChoice && request.method === 'POST') { + const payload = await readJson(request) + sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload)) + 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)) + return + } + const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) if (dungeonCompletion && request.method === 'POST') { const payload = await readJson(request) diff --git a/src/App.css b/src/App.css index 0d95640..bfff360 100644 --- a/src/App.css +++ b/src/App.css @@ -774,7 +774,7 @@ textarea:focus-visible, display: grid; gap: 10px; height: calc(100dvh - 20px); - grid-template-rows: auto 1fr; + grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; } @@ -804,6 +804,84 @@ textarea:focus-visible, outline-color: var(--gold); } +.dual-top-spell-strip { + align-items: center; + background: var(--panel); + border: 3px solid #0c0d11; + display: grid; + gap: 8px; + grid-template-columns: repeat(6, 54px) minmax(180px, 1fr); + min-height: 64px; + outline: 2px solid var(--edge); + padding: 8px 10px; +} + +.dual-top-spell { + align-items: center; + background: #20232c; + border: 2px solid #08090c; + color: var(--ink); + display: flex; + height: 48px; + justify-content: center; + min-width: 0; + outline: 2px solid #4d4c58; + padding: 0; + position: relative; +} + +.dual-top-spell:not(:disabled) { + cursor: pointer; +} + +.dual-top-spell:disabled { + opacity: 0.62; +} + +.dual-top-spell .spell-icon { + font-size: 20px; + height: 32px; + margin: 0; + width: 32px; +} + +.dual-top-spell > i { + background: rgba(0, 0, 0, 0.58); + bottom: 0; + display: block; + left: 0; + pointer-events: none; + position: absolute; + right: 0; +} + +.dual-top-spell > small { + color: #fff4a8; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + position: absolute; +} + +.dual-top-resource { + align-self: center; + color: #82bfff; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + justify-self: end; + min-width: 220px; + width: min(280px, 100%); +} + +.dual-top-resource strong { + display: block; + margin-bottom: 6px; + text-align: right; +} + +.dual-top-resource .bar { + height: 14px; +} + .dual-bottom-display { background: linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)), @@ -816,6 +894,10 @@ textarea:focus-visible, padding: 10px; } +.pvp-opponent-bottom-display { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + .dual-controls-header, .dual-controls-resource, .dual-controls-targets, @@ -838,6 +920,109 @@ textarea:focus-visible, font-size: clamp(14px, 2.2vw, 23px); } +.dual-controls-header small { + color: var(--muted); + display: block; + font-size: 14px; + margin-top: 4px; +} + +.dual-opponent-progress, +.dual-opponent-effects { + background: var(--panel); + border: 3px solid #0c0d11; + outline: 2px solid var(--edge); +} + +.dual-opponent-progress { + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: minmax(130px, 0.45fr) minmax(0, 1fr); + padding: 10px 14px; +} + +.dual-opponent-progress strong { + color: var(--ink); + font-size: 19px; +} + +.dual-opponent-progress .bar { + height: 22px; +} + +.dual-opponent-party-grid { + background: var(--panel); + border: 3px solid #0c0d11; + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + min-height: 0; + outline: 2px solid var(--edge); + overflow: hidden; + padding: 10px; +} + +.dual-opponent-party-grid.raid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow-y: auto; +} + +.dual-opponent-member { + background: var(--panel-light); + border: 2px solid #0a0b0e; + min-width: 0; + outline: 2px solid #3a3944; + padding: 9px; +} + +.dual-opponent-member.dead { + filter: grayscale(1); + opacity: 0.5; +} + +.dual-opponent-member .member-header { + gap: 5px; +} + +.dual-opponent-member .member-header strong { + font-size: 17px; +} + +.dual-opponent-member .member-header small { + font-size: 13px; +} + +.dual-opponent-member .bar { + height: 14px; + margin-top: 7px; +} + +.dual-opponent-party-grid.raid .dual-opponent-member { + padding: 7px; +} + +.dual-opponent-party-grid.raid .member-header strong { + font-size: 15px; +} + +.dual-opponent-party-grid.raid .member-header small { + display: none; +} + +.dual-opponent-party-grid.raid .dual-opponent-member .bar { + height: 12px; +} + +.dual-opponent-effects { + color: var(--muted); + display: grid; + font-size: 14px; + gap: 8px; + grid-template-columns: 1fr 1fr; + padding: 8px 12px; +} + .dual-controls-progress { color: var(--muted); font-family: 'Press Start 2P', monospace; @@ -950,6 +1135,10 @@ textarea:focus-visible, line-height: 1.2; } + .dual-controls-header small { + font-size: 11px; + } + .dual-controls-progress { font-size: 6px; } @@ -1023,6 +1212,67 @@ textarea:focus-visible, .dual-controls-spells .spell small { font-size: 12px; } + + .pvp-opponent-bottom-display { + grid-template-rows: auto auto minmax(0, 1fr) auto; + } + + .dual-opponent-progress { + border-width: 2px; + gap: 8px; + grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr); + padding: 6px 8px; + } + + .dual-opponent-progress .eyebrow { + font-size: 6px; + margin-bottom: 3px; + } + + .dual-opponent-progress strong { + font-size: 14px; + } + + .dual-opponent-progress .bar { + height: 16px; + } + + .dual-opponent-party-grid { + border-width: 2px; + gap: 6px; + padding: 6px; + } + + .dual-opponent-party-grid.raid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .dual-opponent-member { + padding: 6px; + } + + .dual-opponent-member .member-header strong { + font-size: 13px; + } + + .dual-opponent-member .member-header small { + font-size: 11px; + } + + .dual-opponent-member .bar { + height: 10px; + margin-top: 5px; + } + + .dual-opponent-member .member-effects { + display: none; + } + + .dual-opponent-effects { + border-width: 2px; + font-size: 11px; + padding: 6px 8px; + } } .dual-bottom-waiting { @@ -1686,7 +1936,7 @@ h2 { .equipment-screen .crafting-panel, .talent-screen .talent-tree, .talent-screen .spell-effect-layout { - flex: 1; + flex: 0 0 auto; min-height: 0; } @@ -1713,7 +1963,8 @@ h2 { .customize-screen > .embedded-screen { flex: 1; min-height: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .customize-screen .loadout-editor { @@ -3520,6 +3771,7 @@ h2 { .spell-effect-layout { display: grid; + flex: 0 0 auto; gap: 14px; grid-template-columns: 220px minmax(0, 1fr); margin-top: 17px; @@ -3538,6 +3790,7 @@ h2 { } .effect-slots-panel { + align-content: start; display: grid; gap: 10px; grid-auto-rows: minmax(76px, auto); @@ -3641,12 +3894,12 @@ h2 { .effect-pool { align-content: start; display: grid; - flex: 1; + flex: 0 0 auto; gap: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, 62px); margin-top: 12px; - min-height: 0; + min-height: 134px; overflow: hidden; } @@ -4704,7 +4957,7 @@ h2 { .customize-tabs { display: grid; gap: 8px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 16px; } @@ -5253,23 +5506,6 @@ h2 { top: 0; } -.member-health .health-text { - color: var(--ink); - display: none; - font-family: 'Press Start 2P', monospace; - font-size: 7px; - font-style: normal; - left: 50%; - line-height: 1; - pointer-events: none; - position: absolute; - text-shadow: 1px 1px #08090c; - top: 50%; - transform: translate(-50%, -50%); - white-space: nowrap; - z-index: 2; -} - .raid-party-grid .party-member { min-height: 66px; padding: 7px; @@ -5985,7 +6221,7 @@ h2 { .pvp-choice-columns { display: grid; gap: 10px; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 0; } @@ -6020,6 +6256,29 @@ h2 { margin-top: 8px !important; } +.pvp-upgrade-header { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 10px; +} + +.pvp-upgrade-header h2 { + font-size: 18px; +} + +.pvp-upgrade-header > strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 16px; + white-space: nowrap; +} + +.pvp-upgrade-header > strong.danger { + color: #ff8190; +} + .pvp-upgrade-dialog .pvp-choice-columns { gap: 10px; margin-top: 0; diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 4ccf468..7734001 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -1523,7 +1523,6 @@ export function CombatScreen({
{member.shield > 0 && } - {Math.ceil(member.health)} / {effectiveMaxHealth(member)}
) } diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 01f3429..1368be0 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -671,7 +671,7 @@ function getApiBaseUrl(path: string): string { return '' } -async function requestJson(path: string, init?: RequestInit): Promise { +export async function requestGameApiJson(path: string, init?: RequestInit): Promise { const baseUrl = getApiBaseUrl(path) const url = baseUrl ? `${baseUrl}${path}` : path const headers = new Headers(init?.headers) @@ -696,6 +696,10 @@ async function requestJson(path: string, init?: RequestInit): Promise { return body } +async function requestJson(path: string, init?: RequestInit): Promise { + return requestGameApiJson(path, init) +} + function isNetworkError(reason: unknown): reason is NetworkError { return reason instanceof Error && Boolean((reason as NetworkError).network) } diff --git a/src/pvpRoguelike.ts b/src/pvpRoguelike.ts index 497dcd5..d2d3e70 100644 --- a/src/pvpRoguelike.ts +++ b/src/pvpRoguelike.ts @@ -1,5 +1,49 @@ +import { requestGameApiJson } from './gameRepository' + export type PvpContentType = 'dungeon' | 'raid' export type CpuDifficulty = 1 | 2 | 3 | 4 | 5 +export type PvpMatchSide = 'a' | 'b' + +export type PvpPlayerInfo = { + side: PvpMatchSide + accountId: number + characterId: number + characterName: string + className: string +} + +export type PvpUpgradeChoicePayload = { + encounterIndex: number + buffId: string + debuffId: string +} + +export type PvpMatchSnapshot = { + id: string + contentType: PvpContentType + startStage: number + createdAt: number + players: Record + states: Partial> + statuses: Partial> + progress: Partial> + upgradeChoices: Partial>> + updatedAt: number +} + +export type PvpQueueResponse = { + ticketId: string + status: 'waiting' | 'matched' + match?: PvpMatchSnapshot + side?: PvpMatchSide +} export type CpuPvpLeaderboardEntry = { characterName: string @@ -66,3 +110,59 @@ export function recordPvpRoguelikeCheckpoint( localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next)) return next } + +export function joinPvpQueue( + contentType: PvpContentType, + startStage: number, +): Promise> { + return requestGameApiJson('/api/pvp/queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentType, startStage }), + }) +} + +export function checkPvpQueue(ticketId: string): Promise> { + 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( + matchId: string, + payload: { + state: TSideState + status: 'playing' | 'upgrade-choice' | 'won' | 'lost' + stage: number + encounterIndex: number + encountersCleared: number + enemyHealth: number + alive: boolean + elapsedTicks: number + }, +): Promise> { + return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export function loadPvpMatch(matchId: string): Promise> { + return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`) +} + +export function submitPvpUpgradeChoice( + matchId: string, + payload: PvpUpgradeChoicePayload, +): Promise { + return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +}