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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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)}
{floatingTexts
@@ -1597,6 +1596,7 @@ export function CombatScreen({
{dualScreenEnabled && (
)}
diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx
index 2801656..281dadf 100644
--- a/src/components/PvpRoguelikeScreen.tsx
+++ b/src/components/PvpRoguelikeScreen.tsx
@@ -23,14 +23,24 @@ import {
} from '../dualScreen'
import {
loadPvpRoguelikeCheckpoint,
+ cancelPvpQueue,
+ checkPvpQueue,
+ joinPvpQueue,
+ loadPvpMatch,
+ publishPvpMatchState,
randomCpuDifficulty,
recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
+ submitPvpUpgradeChoice,
type CpuDifficulty,
+ type PvpMatchSnapshot,
+ type PvpMatchSide,
type PvpContentType,
+ type PvpUpgradeChoicePayload,
} from '../pvpRoguelike'
const TICK_MS = 700
+const UPGRADE_CHOICE_SECONDS = 10
type BossMechanic =
| 'party-pulse'
@@ -99,6 +109,14 @@ type PvpRunSummary = {
loot: Array
>
}
+type LivePvpMatch = {
+ id: string
+ side: PvpMatchSide
+ opponentSide: PvpMatchSide
+ opponentName: string
+ opponentClassName: string
+}
+
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
@@ -449,6 +467,8 @@ export function PvPRoguelikeScreen({
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState(null)
+ const [liveMatch, setLiveMatch] = useState(null)
+ const [liveUpgradePending, setLiveUpgradePending] = useState(false)
const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState(null)
@@ -460,6 +480,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState>>([])
const [selectedBuff, setSelectedBuff] = useState | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState | null>(null)
+ const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -471,6 +492,15 @@ export function PvPRoguelikeScreen({
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
+ const upgradeChoiceEndsAtRef = useRef(0)
+ const autoSubmittedUpgradeRef = useRef(false)
+ const liveMatchRef = useRef(null)
+ const loggedOpponentDoneRef = useRef(false)
+ const pendingLiveUpgradeRef = useRef<{
+ encounterIndex: number
+ buff: Choice
+ debuff: Choice
+ } | null>(null)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
@@ -484,6 +514,7 @@ export function PvPRoguelikeScreen({
? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
+ const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const activeSpellEffects = useMemo(
() => new Set(
gameClass.talents
@@ -632,6 +663,9 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices([])
setSelectedBuff(null)
setSelectedDebuff(null)
+ setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
+ upgradeChoiceEndsAtRef.current = 0
+ autoSubmittedUpgradeRef.current = false
setEncountersCleared(0)
setPaused(false)
setTargetGroup(0)
@@ -641,34 +675,159 @@ export function PvPRoguelikeScreen({
setShowEndLog(false)
setFloatingTexts([])
setCpuDifficulty(null)
+ setLiveMatch(null)
+ liveMatchRef.current = null
+ setLiveUpgradePending(false)
+ pendingLiveUpgradeRef.current = null
+ loggedOpponentDoneRef.current = false
recordedRunRef.current = false
rewardClaimedRef.current = false
cpuDefeatedRef.current = false
+ const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
+ liveMatchRef.current = null
+ setLiveMatch(null)
+ setCpuDifficulty(randomCpu)
+ setQueueMessage(message)
+ setLog([{ id: 1, text: message, tone: 'system' }])
+ setStatus('playing')
+ addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
+ }
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
- setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
- setCpuDifficulty(randomCpu)
- setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => {
- setStatus('playing')
- addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
+ beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
}, 500)
return () => window.clearTimeout(timer)
}
- setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
- setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
- const timer = window.setTimeout(() => {
- const randomCpu = randomCpuDifficulty()
- setCpuDifficulty(randomCpu)
- setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
+
+ let cancelled = false
+ let ticketId = ''
+ let pollTimer: number | undefined
+ setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
+ setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
+ const beginLiveMatch = (match: PvpMatchSnapshot, side: PvpMatchSide) => {
+ 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')
- addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
- }, 1400)
- return () => window.clearTimeout(timer)
+ }
+ const fallbackTimer = window.setTimeout(() => {
+ if (cancelled || liveMatchRef.current) return
+ cancelled = true
+ if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
+ const randomCpu = randomCpuDifficulty()
+ beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
+ }, 5000)
+ const pollQueue = () => {
+ if (!ticketId || cancelled) return
+ checkPvpQueue(ticketId)
+ .then((result) => {
+ if (cancelled) return
+ if (result.status === 'matched' && result.match && result.side) {
+ window.clearTimeout(fallbackTimer)
+ if (pollTimer) window.clearTimeout(pollTimer)
+ beginLiveMatch(result.match, result.side)
+ return
+ }
+ pollTimer = window.setTimeout(pollQueue, 500)
+ })
+ .catch(() => {
+ if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
+ })
+ }
+ joinPvpQueue(contentType, matchStartStage)
+ .then((result) => {
+ if (cancelled) return
+ ticketId = result.ticketId
+ if (result.status === 'matched' && result.match && result.side) {
+ window.clearTimeout(fallbackTimer)
+ beginLiveMatch(result.match, result.side)
+ return
+ }
+ pollTimer = window.setTimeout(pollQueue, 500)
+ })
+ .catch(() => {
+ if (cancelled) return
+ window.clearTimeout(fallbackTimer)
+ cancelled = true
+ const randomCpu = randomCpuDifficulty()
+ beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
+ })
+ return () => {
+ cancelled = true
+ window.clearTimeout(fallbackTimer)
+ if (pollTimer) window.clearTimeout(pollTimer)
+ if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
+ }
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
+ useEffect(() => {
+ if (!liveMatch || status === 'queueing') return
+ let stopped = false
+ const syncMatch = () => {
+ publishPvpMatchState(liveMatch.id, {
+ state: playerRef.current,
+ status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
+ stage,
+ encounterIndex,
+ encountersCleared,
+ enemyHealth: playerRef.current.enemyHealth,
+ alive: playerRef.current.party.some((member) => member.health > 0),
+ elapsedTicks,
+ })
+ .then((snapshot) => {
+ if (stopped) return
+ const opponentState = snapshot.states[liveMatch.opponentSide]
+ if (opponentState) {
+ cpuRef.current = opponentState
+ setCpuSide(opponentState)
+ }
+ const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
+ if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
+ loggedOpponentDoneRef.current = true
+ cpuDefeatedRef.current = true
+ addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
+ }
+ if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
+ finishRoguelikeRun()
+ setStatus('lost')
+ addLog(`${liveMatch.opponentName} finished first.`, 'danger')
+ }
+ })
+ .catch(() => undefined)
+ }
+ syncMatch()
+ const timer = window.setInterval(syncMatch, 700)
+ return () => {
+ stopped = true
+ window.clearInterval(timer)
+ }
+ }, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])
+
const applySpell = useCallback((
current: SideState,
setCurrent: React.Dispatch>,
@@ -981,6 +1140,9 @@ export function PvPRoguelikeScreen({
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
const beginUpgradePhase = useCallback(() => {
+ upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
+ autoSubmittedUpgradeRef.current = false
+ setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
@@ -992,9 +1154,9 @@ export function PvPRoguelikeScreen({
if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1)
- cpuTakeTurn()
+ if (!liveMatch) cpuTakeTurn()
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
- const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter)
+ const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
@@ -1024,7 +1186,7 @@ export function PvPRoguelikeScreen({
addLog('Your party fell first.', 'danger')
return
}
- if (!nextCpuAlive && !cpuDefeatedRef.current) {
+ if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
}
@@ -1032,7 +1194,7 @@ export function PvPRoguelikeScreen({
if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
- addLog('CPU defeated. Match complete.', 'loot')
+ addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
return
}
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
@@ -1040,7 +1202,7 @@ export function PvPRoguelikeScreen({
}
}, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer)
- }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
+ }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -1066,8 +1228,117 @@ export function PvPRoguelikeScreen({
window.requestAnimationFrame(() => focusFirstControl())
}, [paused])
- const confirmUpgradeChoices = useCallback(() => {
- if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
+ const confirmUpgradeChoices = useCallback((
+ forcedBuff?: Choice,
+ forcedDebuff?: Choice,
+ ) => {
+ const chosenBuff = forcedBuff ?? selectedBuff
+ const chosenDebuff = forcedDebuff ?? selectedDebuff
+ if (!chosenBuff || !chosenDebuff) return
+ if (liveMatch) {
+ const submittedBuff = chosenBuff
+ const submittedDebuff = chosenDebuff
+ const clearedEncounterIndex = encounterIndex
+ const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
+ let nextPlayer = {
+ ...playerRef.current,
+ buffs: [...playerRef.current.buffs, submittedBuff.id],
+ }
+ if (opponentChoice.debuffId === 'opp-purge-random-buff') {
+ nextPlayer = removeRandomBuff(nextPlayer)
+ } else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
+ nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
+ }
+
+ const clearedBoss = encounter.isBoss
+ if (clearedBoss && cpuDefeatedRef.current) {
+ finishRoguelikeRun()
+ setStatus('won')
+ setLiveUpgradePending(false)
+ addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
+ return
+ }
+ const nextStage = clearedBoss ? stage + 1 : stage
+ const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
+ const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
+ if (!nextEncounter) {
+ finishRoguelikeRun()
+ setStatus('won')
+ setLiveUpgradePending(false)
+ addLog('No further encounters remain.', 'loot')
+ return
+ }
+ nextPlayer = {
+ ...nextPlayer,
+ party: nextPlayer.party.map((member) => ({
+ ...member,
+ health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
+ debuff: undefined,
+ debuffTicks: undefined,
+ poisonStacks: undefined,
+ maxHealthPenaltyTicks: undefined,
+ healingReductionTicks: undefined,
+ })),
+ resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
+ cooldowns: {},
+ enemyHealth: nextEncounter.maxHealth,
+ }
+ if (clearedBoss) {
+ setStage(nextStage)
+ setEncounters((current) => [...current, ...nextSegment])
+ }
+ setEncounterIndex((value) => value + 1)
+ setPlayerSide(nextPlayer)
+ playerRef.current = nextPlayer
+ setElapsedTicks(0)
+ setLiveUpgradePending(false)
+ pendingLiveUpgradeRef.current = null
+ setStatus('playing')
+ const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
+ addLog(
+ `You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
+ 'system',
+ )
+ }
+ setLiveUpgradePending(true)
+ pendingLiveUpgradeRef.current = {
+ encounterIndex: clearedEncounterIndex,
+ buff: submittedBuff,
+ debuff: submittedDebuff,
+ }
+ addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
+ submitPvpUpgradeChoice(liveMatch.id, {
+ encounterIndex: clearedEncounterIndex,
+ buffId: submittedBuff.id,
+ debuffId: submittedDebuff.id,
+ }).catch((reason: unknown) => {
+ setLiveUpgradePending(false)
+ addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
+ })
+ let attempts = 0
+ const waitForOpponent = () => {
+ attempts += 1
+ loadPvpMatch(liveMatch.id)
+ .then((snapshot) => {
+ const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
+ if (opponentChoice) {
+ applyLiveUpgrade(opponentChoice)
+ return
+ }
+ if (attempts < 120 && pendingLiveUpgradeRef.current) {
+ window.setTimeout(waitForOpponent, 500)
+ }
+ })
+ .catch(() => {
+ if (attempts < 120 && pendingLiveUpgradeRef.current) {
+ window.setTimeout(waitForOpponent, 700)
+ }
+ })
+ }
+ window.setTimeout(waitForOpponent, 250)
+ return
+ }
+ if (!cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
@@ -1075,17 +1346,17 @@ export function PvPRoguelikeScreen({
let nextPlayer = {
...playerRef.current,
- buffs: [...playerRef.current.buffs, selectedBuff.id],
+ buffs: [...playerRef.current.buffs, chosenBuff.id],
}
let nextCpu = {
...cpuRef.current,
buffs: [...cpuRef.current.buffs, cpuBuff.id],
}
- if (selectedDebuff.id === 'opp-purge-random-buff') {
+ if (chosenDebuff.id === 'opp-purge-random-buff') {
nextCpu = removeRandomBuff(nextCpu)
} else {
- nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
+ nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
}
if (cpuDebuff.id === 'opp-purge-random-buff') {
@@ -1153,8 +1424,41 @@ export function PvPRoguelikeScreen({
cpuRef.current = nextCpu
setElapsedTicks(0)
setStatus('playing')
- addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
- }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
+ addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
+ }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
+
+ useEffect(() => {
+ if (status !== 'upgrade-choice' || liveUpgradePending) return
+ if (upgradeChoiceEndsAtRef.current <= 0) {
+ upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
+ }
+ const updateTimer = () => {
+ const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
+ setUpgradeTimeLeft(remaining)
+ if (remaining > 0 || autoSubmittedUpgradeRef.current) return
+ autoSubmittedUpgradeRef.current = true
+ const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
+ const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
+ if (autoBuff) setSelectedBuff(autoBuff)
+ if (autoDebuff) setSelectedDebuff(autoDebuff)
+ if (autoBuff && autoDebuff) {
+ addLog('Upgrade timer expired. Random choices selected.', 'system')
+ confirmUpgradeChoices(autoBuff, autoDebuff)
+ }
+ }
+ updateTimer()
+ const timer = window.setInterval(updateTimer, 100)
+ return () => window.clearInterval(timer)
+ }, [
+ addLog,
+ confirmUpgradeChoices,
+ liveUpgradePending,
+ playerBuffChoices,
+ playerDebuffChoices,
+ selectedBuff,
+ selectedDebuff,
+ status,
+ ])
useGameAction((action) => {
if (action === 'toggleSpeed') {
@@ -1212,6 +1516,13 @@ export function PvPRoguelikeScreen({
encounterIndex,
encounterCount: encounters.length,
party: playerSide.party,
+ opponentName: opponentLabel,
+ opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
+ opponentParty: cpuSide.party,
+ opponentResource: cpuSide.resource,
+ opponentEnemyHealth: cpuSide.enemyHealth,
+ opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
+ opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
floatingTexts: floatingTexts
.filter((entry) => entry.side === 'player')
.map(({ id, memberId, value }) => ({ id, memberId, value })),
@@ -1239,6 +1550,12 @@ export function PvPRoguelikeScreen({
}), [
bindings,
controllerIconStyle,
+ cpuDifficulty,
+ cpuSide.buffs,
+ cpuSide.debuffs,
+ cpuSide.enemyHealth,
+ cpuSide.party,
+ cpuSide.resource,
directPartyTargeting,
encounter.description,
encounter.enemyName,
@@ -1249,8 +1566,12 @@ export function PvPRoguelikeScreen({
floatingTexts,
gameClass.resourceName,
lastDevice,
+ liveMatch?.opponentClassName,
log,
maxResource,
+ opponentDebuffChoicesCatalog,
+ opponentLabel,
+ selfBuffChoicesCatalog,
paused,
playerAlive,
playerSide.buffs,
@@ -1285,6 +1606,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && (
)}
@@ -1321,7 +1643,6 @@ export function PvPRoguelikeScreen({
{member.shield > 0 && }
- {Math.floor(member.health)} / {effectiveMaxHealth(member)}
{floatingTexts
@@ -1361,7 +1682,7 @@ export function PvPRoguelikeScreen({
{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}
-
CPU clear
+
{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}
@@ -1395,14 +1716,16 @@ export function PvPRoguelikeScreen({
)
})}
- CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}
+
+ {liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
+
Opponent
-
CPU {cpuDifficulty}
+
{opponentLabel}
@@ -1422,7 +1745,6 @@ export function PvPRoguelikeScreen({
{member.shield > 0 && }
- {Math.floor(member.health)} / {effectiveMaxHealth(member)}
{floatingTexts
@@ -1450,6 +1772,13 @@ export function PvPRoguelikeScreen({
{status === 'upgrade-choice' && (
+
+
+
Choose Edge
+
{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}
+
+
{upgradeTimeLeft.toFixed(1)}s
+
Self Buff
@@ -1484,8 +1813,9 @@ export function PvPRoguelikeScreen({
-
@@ -1506,7 +1836,7 @@ export function PvPRoguelikeScreen({
{status === 'won' ? 'Victory' : 'Defeat'}
-
{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}
+
{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}
{finalEncountersCleared} encounters cleared.
{runSummary.bossesKilled} bosses killed.
diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx
index cac1c4d..d5bab3f 100644
--- a/src/dualScreen.tsx
+++ b/src/dualScreen.tsx
@@ -39,6 +39,13 @@ export type DualScreenCombatState = {
encounterIndex: number
encounterCount: number
party: PartyMember[]
+ opponentName?: string
+ opponentClassName?: string
+ opponentParty?: PartyMember[]
+ opponentResource?: number
+ opponentEnemyHealth?: number
+ opponentBuffSummary?: string
+ opponentDebuffSummary?: string
floatingTexts: Array<{
id: number
memberId: string
@@ -431,17 +438,62 @@ export function DualScreenBottomDisplay() {
}
return (
-
+
-
{state.difficultyName} {state.contentName}
-
{state.dungeonName}
+
{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}
+
{state.opponentParty ? state.opponentName : state.dungeonName}
+ {state.opponentParty &&
{state.opponentClassName}}
- Encounter {state.encounterIndex + 1}/{state.encounterCount}
+ {state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}
+ {state.opponentParty ? (
+ <>
+
+
+
Opponent Clear
+
{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}
+
+
+
+
+
+
+ 6 ? 'raid' : ''}`}>
+ {state.opponentParty.map((member) => (
+
+
+ {member.role[0]}
+ {member.name}
+ {Math.ceil(member.health)} / {member.maxHealth}
+
+
+
+ {member.shield > 0 && (
+
+ )}
+
+
+ {memberHotEffects(member).map((effect) => (
+ {effect.label}
+ ))}
+ {member.debuff && {member.debuff}}
+
+
+ ))}
+
+
+
+ Buffs: {state.opponentBuffSummary || 'none'}
+ Debuffs: {state.opponentDebuffSummary || 'none'}
+
+ >
+ ) : (
+ <>
+
Active Target
@@ -542,6 +594,8 @@ export function DualScreenBottomDisplay() {
)
})}
+ >
+ )}
)
}
@@ -549,9 +603,11 @@ export function DualScreenBottomDisplay() {
export function DualScreenTopCombat({
state,
onSelectTarget,
+ onCastSpell,
}: {
state: DualScreenCombatState
onSelectTarget: (id: string) => void
+ onCastSpell?: (spell: Spell) => void
}) {
const enemyPercent = Math.max(
0,
@@ -602,7 +658,6 @@ export function DualScreenTopCombat({
{member.shield > 0 && (
)}
-
{Math.ceil(member.health)} / {member.maxHealth}
{state.floatingTexts
@@ -629,6 +684,40 @@ export function DualScreenTopCombat({
+
+ {state.spells.map((spell, slotIndex) => {
+ if (!spell) return
+ const percent = spell.remaining > 0
+ ? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
+ : 0
+ return (
+ 0
+ || state.status !== 'playing'
+ || state.paused
+ }
+ key={spell.id}
+ onClick={() => onCastSpell?.(spell)}
+ type="button"
+ >
+ {spell.glyph}
+ {spell.remaining > 0 && }
+ {spell.remaining > 0 && {spell.remaining.toFixed(0)}}
+
+ )
+ })}
+
+
{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}
+
+
+
+
+
+
)
}
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),
+ })
+}