Compare commits

...

6 Commits

Author SHA1 Message Date
Warren H cbe42b6164 Android build v1.0.60 stadium updated 2026-06-23 13:13:57 -04:00
Warren H c2888c287b Android build v1.0.59 stadium updated 2026-06-22 23:38:12 -04:00
Warren H 4703017832 Android build v1.0.58 stadium added 2026-06-22 23:15:50 -04:00
Warren H c0f2daccb1 Android build v1.0.57 2026-06-21 21:09:51 -04:00
Warren H abdf4cc654 Android build v1.0.56 2026-06-21 21:00:17 -04:00
Warren H 5449276521 Android build v1.0.55 2026-06-21 20:38:55 -04:00
18 changed files with 2547 additions and 230 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 73
versionName "1.0.54"
versionCode 79
versionName "1.0.60"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -4,6 +4,7 @@ import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import com.getcapacitor.BridgeActivity;
import java.io.File;
@@ -37,6 +38,18 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (hasFocus) enableImmersiveMode();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (
event.getActionMasked() == MotionEvent.ACTION_DOWN
&& bridge != null
&& bridge.getWebView() != null
) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
private void loadIntentUrl() {
if (bridge == null || getIntent() == null) return;
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL);
@@ -86,7 +99,10 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> bridge.getWebView().evaluateJavascript(script, null)
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
return true;
+124 -25
View File
@@ -2307,7 +2307,16 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
? 'pvp-boss-quarter-level'
: 'default'
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
? 'pvp-fight-twelfth-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? 'pvp-stadium-round-win-quarter-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 'pvp-stadium-round-loss-tenth-level'
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
? 'pvp-stadium-match-half-level'
: 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds)
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
@@ -2322,6 +2331,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
throw new Error('The roguelike boss total is invalid.')
}
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
throw new Error('The roguelike fight total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
throw new Error('The run resource total is invalid.')
}
@@ -2374,14 +2386,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(maxLevel).experienceRequired
let newExperience = character.experience
let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') {
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
@@ -2395,7 +2408,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
@@ -2403,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE experience_required <= ?
`).get(newExperience).level
}
} else if (
experienceMode === 'pvp-stadium-round-win-quarter-level'
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|| experienceMode === 'pvp-stadium-match-half-level'
) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel).experienceRequired
const nextLevelExperience = newLevel >= maxLevel
? maxExperience
: database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
? 0.25
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 0.1
: 0.5
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
WHERE experience_required <= ?
`).get(newExperience).level
} else {
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
@@ -2555,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) {
}
function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid') {
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
throw new Error('The PvP content type is invalid.')
}
return value
@@ -2589,10 +2633,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 +2686,11 @@ function joinPvpQueue(session, payload) {
.sort((left, right) => left.createdAt - right.createdAt)[0]
const player = pvpPlayerInfo(session)
if (opponent) {
const matchId = randomBytes(12).toString('base64url')
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players: {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
},
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
opponent.matchId = matchId
const match = createPvpMatch(contentType, startStage, {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
}, now)
opponent.matchId = match.id
opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
@@ -2649,7 +2700,7 @@ function joinPvpQueue(session, payload) {
contentType,
startStage,
player,
matchId,
matchId: match.id,
createdAt: now,
updatedAt: now,
})
@@ -2720,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) {
function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
? payload.status
: 'playing'
const progress = {
@@ -2731,6 +2782,19 @@ function updatePvpMatchState(session, matchId, payload) {
alive: Boolean(payload.alive),
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
}
const currentProgress = match.progress[side]
if (
currentProgress
&& (
progress.stage < currentProgress.stage
|| (
progress.stage === currentProgress.stage
&& progress.encounterIndex < currentProgress.encounterIndex
)
)
) {
return pvpSnapshot(match)
}
match.states[side] = payload.state ?? null
match.statuses[side] = status
match.progress[side] = progress
@@ -2746,11 +2810,40 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
encounterIndex,
buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''),
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
shopReady: Boolean(payload.shopReady),
}
match.updatedAt = Date.now()
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 +3064,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))
+196
View File
@@ -18,6 +18,14 @@
box-sizing: border-box;
}
.sr-only {
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
button {
font: inherit;
}
@@ -778,6 +786,10 @@ textarea:focus-visible,
min-height: 0;
}
.dual-top-main.stadium-dual-top {
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.dual-top-main .dual-top-enemy,
.dual-top-main .dual-top-party,
.dual-top-main .dual-top-log {
@@ -5888,6 +5900,33 @@ h2 {
z-index: 10;
}
.pvp-round-countdown {
align-items: center;
background: rgba(5, 5, 8, 0.55);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 9;
}
.pvp-round-countdown > div {
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
min-width: 220px;
outline: 2px solid var(--gold);
padding: 28px;
text-align: center;
}
.pvp-round-countdown h2 {
color: var(--gold);
font-size: clamp(48px, 8vw, 92px);
line-height: 1;
margin-top: 8px;
}
.result-screen > div,
.pause-screen > div {
background: var(--panel);
@@ -6372,6 +6411,150 @@ h2 {
box-shadow: inset 0 0 0 2px #6e5727;
}
.stadium-screen {
min-height: 100dvh;
}
.stadium-board {
display: grid;
gap: 14px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: calc(100dvh - 72px);
}
.stadium-header,
.stadium-pressure-panel {
align-items: center;
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 4px 4px 0 #08090c;
display: flex;
grid-column: 1 / -1;
justify-content: space-between;
outline: 2px solid var(--edge);
padding: 12px 16px;
}
.stadium-header h2 {
font-size: 20px;
}
.stadium-header > strong {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 26px;
}
.stadium-header p,
.stadium-header small,
.stadium-pressure-panel span {
color: var(--muted);
font-size: 13px;
}
.stadium-pressure-panel strong {
color: var(--ink);
font-family: 'Press Start 2P', monospace;
font-size: 16px;
}
.stadium-side {
min-height: 0;
}
.stadium-shop-dialog {
max-width: 1180px !important;
padding: 16px !important;
text-align: left !important;
width: min(1180px, calc(100vw - 32px));
}
.stadium-shop-summary {
color: var(--muted);
display: flex;
flex-wrap: wrap;
font-family: 'Press Start 2P', monospace;
font-size: 12px;
gap: 16px;
margin-bottom: 12px;
}
.stadium-shop-layout {
display: grid;
gap: 14px;
grid-template-columns: 240px minmax(0, 1fr);
}
.stadium-shop-tabs {
display: grid;
gap: 8px;
}
.stadium-shop-tabs button,
.stadium-shop-grid button {
background: #303545;
border: 2px solid #0b0c0f;
color: var(--ink);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
outline: 2px solid #4d4c58;
}
.stadium-shop-tabs button {
font-size: 9px;
margin: 0;
padding: 10px;
text-align: left;
}
.stadium-shop-tabs button.active {
background: #303427;
outline-color: var(--gold);
}
.stadium-shop-layout h3 {
font-size: 15px;
margin-bottom: 10px;
}
.stadium-shop-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stadium-shop-grid button {
display: grid;
gap: 10px;
margin: 0;
min-height: 128px;
padding: 14px;
text-align: left;
}
.stadium-shop-grid button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.stadium-shop-grid strong {
color: #fff0b8;
font-size: 11px;
line-height: 1.35;
}
.stadium-shop-grid small,
.stadium-shop-layout p {
color: #f0e8d2;
font-family: 'VT323', monospace;
font-size: 22px;
line-height: 1.05;
}
.dual-opponent-progress.stadium {
grid-template-columns: 1fr 1fr;
}
.result-screen button,
.pause-screen button {
background: var(--gold);
@@ -6884,11 +7067,24 @@ h2 {
}
.pvp-board,
.stadium-board,
.stadium-shop-layout,
.pvp-choice-columns,
.pvp-choice-columns .upgrade-choice-grid {
grid-template-columns: 1fr;
}
.stadium-header,
.stadium-pressure-panel,
.stadium-shop-summary {
align-items: stretch;
flex-direction: column;
}
.stadium-shop-grid {
grid-template-columns: 1fr;
}
.active-target-card,
.mana-wrap {
width: 100%;
+27 -4
View File
@@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen'
import { CustomizeScreen } from './components/CustomizeScreen'
import { EquipmentScreen } from './components/EquipmentScreen'
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
import { PvpStadiumScreen } from './components/PvpStadiumScreen'
import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen'
import {
@@ -253,6 +254,19 @@ function App() {
}
if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
const pvpPool = profile.dungeons
.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters)
@@ -547,6 +561,13 @@ function App() {
>
Raid
</button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
onClick={() => setPvpContentType('stadium')}
type="button"
>
Stadium
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
@@ -554,9 +575,11 @@ function App() {
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
{pvpContentType === 'stadium'
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
: gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small>
</div>
<button
@@ -572,7 +595,7 @@ function App() {
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
+281 -165
View File
@@ -28,6 +28,7 @@ import {
joinPvpQueue,
loadPvpMatch,
publishPvpMatchState,
requestPvpRematch,
randomCpuDifficulty,
recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
@@ -35,12 +36,14 @@ import {
type CpuDifficulty,
type PvpMatchSnapshot,
type PvpMatchSide,
type PvpRematchResponse,
type PvpContentType,
type PvpUpgradeChoicePayload,
} from '../pvpRoguelike'
const TICK_MS = 700
const UPGRADE_CHOICE_SECONDS = 10
const ROUND_START_SECONDS = 3
const UPGRADE_CHOICE_SECONDS = 15
type BossMechanic =
| 'party-pulse'
@@ -55,24 +58,18 @@ type PvpEncounter = DungeonEncounter & {
}
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type DraftSlotKey = Exclude<SlotKey, '6'>
type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId =
| `slot${SlotKey}-extra-target`
| `slot${SlotKey}-cost-down`
| `slot${SlotKey}-cooldown-down`
| 'fifth-cast-free'
| 'group-heal-boost'
| 'shield-boost'
| 'revive-party-members'
| `slot${DraftSlotKey}-extra-target`
| `slot${DraftSlotKey}-cost-down`
| `slot${DraftSlotKey}-cooldown-down`
type OpponentDebuffId =
| `opp-slot${SlotKey}-cost-up`
| `opp-slot${SlotKey}-cooldown-up`
| 'opp-takes-more-damage'
| 'opp-healing-reduced'
| 'opp-resource-regen-down'
| 'opp-cleanse-cooldown-up'
| 'opp-purge-random-buff'
| `opp-slot${DraftSlotKey}-cost-up`
| `opp-slot${DraftSlotKey}-cooldown-up`
type Choice<T extends string> = {
id: T
@@ -117,6 +114,12 @@ type LivePvpMatch = {
opponentClassName: string
}
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
description: 'Revive fallen party members before the next fight.',
}
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
@@ -182,7 +185,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
}
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode)
return [
{
@@ -202,16 +205,10 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
},
]
})
return [
...slotChoices,
{ id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the next cast is free.' },
{ id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.' },
{ id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.' },
]
}
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode)
return [
{
@@ -226,14 +223,6 @@ function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode
},
]
})
return [
...slotChoices,
{ id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' },
{ id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' },
{ id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' },
{ id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' },
{ id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' },
]
}
function toCombatSpell(ability: Ability, key: string): Spell {
@@ -261,17 +250,10 @@ function effectiveMaxHealth(member: PartyMember) {
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
}
function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) {
return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage')
}
function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
}
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
void debuffs
return Math.round(amount * healingReduction * multiplier)
}
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
@@ -282,8 +264,7 @@ function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: Opponent
const slot = spell.key as SlotKey
const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId)
const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0
return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty))
return (0.75 ** downStacks) * (1.25 ** upStacks)
}
function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) {
@@ -291,7 +272,8 @@ function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentD
const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks))
return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost
void freeCastReady
return adjustedCost
}
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
@@ -348,10 +330,32 @@ function starterSide(partyTemplate: PartyMember[], maxResource: number): SideSta
}
}
function hasDeadPartyMembers(side: SideState) {
return side.party.some((member) => member.health <= 0)
}
function recoverPartyForNextEncounter(party: PartyMember[], reviveDead: boolean) {
return party.map((member) => ({
...member,
health: member.health <= 0
? (reviveDead ? Math.max(1, Math.round(member.maxHealth * 0.3)) : 0)
: clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
}))
}
function removeRandomDebuff(debuffs: OpponentDebuffId[]) {
if (debuffs.length === 0) return debuffs
const removedIndex = Math.floor(Math.random() * debuffs.length)
return debuffs.filter((_, index) => index !== removedIndex)
}
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'fifth-cast-free') return 8
if (buff.id === 'group-heal-boost') return 8
if (buff.id === 'shield-boost') return 6
if (buff.id === 'revive-party-members') return 10
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5
@@ -365,11 +369,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
}
function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
if (debuff.id === 'opp-takes-more-damage') return 9
if (debuff.id === 'opp-healing-reduced') return 8
if (debuff.id === 'opp-resource-regen-down') return 7
if (debuff.id === 'opp-cleanse-cooldown-up') return 5
if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2
void opponentBuffCount
if (debuff.id.endsWith('cost-up')) return 7
return 6
}
@@ -386,13 +386,6 @@ function selectCpuChoice<T extends string>(
return ranked[0]
}
function removeRandomBuff(side: SideState) {
if (side.buffs.length === 0) return side
const nextBuffs = [...side.buffs]
nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1)
return { ...side, buffs: nextBuffs }
}
function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
return { id: nextLogId.current++, text, tone }
}
@@ -456,7 +449,7 @@ export function PvPRoguelikeScreen({
})),
[contentType],
)
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0)
@@ -469,6 +462,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)
@@ -480,6 +475,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS)
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
@@ -494,6 +490,7 @@ export function PvPRoguelikeScreen({
const queuedMatchRef = useRef(false)
const upgradeChoiceEndsAtRef = useRef(0)
const autoSubmittedUpgradeRef = useRef(false)
const roundCountdownTimerRef = useRef<number | null>(null)
const liveMatchRef = useRef<LivePvpMatch | null>(null)
const loggedOpponentDoneRef = useRef(false)
const pendingLiveUpgradeRef = useRef<{
@@ -556,6 +553,29 @@ export function PvPRoguelikeScreen({
}, 900)
}, [])
const clearRoundCountdown = useCallback(() => {
if (roundCountdownTimerRef.current === null) return
window.clearInterval(roundCountdownTimerRef.current)
roundCountdownTimerRef.current = null
}, [])
const beginRoundCountdown = useCallback((message?: string) => {
clearRoundCountdown()
setRoundCountdown(ROUND_START_SECONDS)
setStatus('round-countdown')
if (message) addLog(message, 'system')
const startedAt = Date.now()
roundCountdownTimerRef.current = window.setInterval(() => {
const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000)
setRoundCountdown(remaining)
if (remaining > 0) return
clearRoundCountdown()
setStatus((current) => current === 'round-countdown' ? 'playing' : current)
}, 100)
}, [addLog, clearRoundCountdown])
useEffect(() => () => clearRoundCountdown(), [clearRoundCountdown])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
@@ -567,10 +587,11 @@ export function PvPRoguelikeScreen({
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => {
const awardEncounterReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
const rewardEncounter = encounters[encounterIndexValue]
const isBossReward = Boolean(rewardEncounter?.isBoss)
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
@@ -578,10 +599,11 @@ export function PvPRoguelikeScreen({
0,
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
{
bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level',
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
roguelikeStage: stage,
bossesCleared: isBossReward ? 1 : 0,
fightsCleared: 1,
experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
roguelikeStage: isBossReward ? stage : undefined,
},
)
.then((result) => {
@@ -590,7 +612,7 @@ export function PvPRoguelikeScreen({
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
bossesKilled: current.bossesKilled + (isBossReward ? 1 : 0),
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
@@ -601,6 +623,9 @@ export function PvPRoguelikeScreen({
}
})
onProfileUpdated(result.profile)
if (!isBossReward && result.experienceGained > 0) {
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
}
if (result.bonusItem) {
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
@@ -622,20 +647,97 @@ export function PvPRoguelikeScreen({
useEffect(() => {
setPlayerBuffChoices((current) => current
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.map((choice) => choice.id === REVIVE_PARTY_CHOICE.id
? REVIVE_PARTY_CHOICE
: selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
setPlayerDebuffChoices((current) => current
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
setSelectedBuff((current) => current
? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current)
: null)
setSelectedDebuff((current) => current
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
: 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)
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' }])
beginRoundCountdown()
}, [beginRoundCountdown, contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
const startMatch = useCallback((nextStartStage?: number) => {
clearRoundCountdown()
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0]
@@ -680,6 +782,8 @@ export function PvPRoguelikeScreen({
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
setRematchRequested(false)
setRematchMessage('')
recordedRunRef.current = false
rewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -689,8 +793,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(randomCpu)
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
setStatus('playing')
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
}
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
@@ -709,29 +812,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 +862,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])
}, [beginRoundCountdown, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
useEffect(() => startMatch(), [startMatch])
@@ -791,7 +872,7 @@ export function PvPRoguelikeScreen({
const syncMatch = () => {
publishPvpMatchState<SideState>(liveMatch.id, {
state: playerRef.current,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'round-countdown' ? 'playing' : status,
stage,
encounterIndex,
encountersCleared,
@@ -807,10 +888,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()
@@ -888,7 +972,7 @@ export function PvPRoguelikeScreen({
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
const groupPower = spell.power
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
const nextShield = hasSpellEffect('radiance_applies_shield')
@@ -905,7 +989,7 @@ export function PvPRoguelikeScreen({
}
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
if (spell.kind === 'shield') {
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
const shieldPower = spell.power
return {
...member,
shield: Math.max(member.shield, shieldPower),
@@ -939,16 +1023,6 @@ export function PvPRoguelikeScreen({
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
}
})
const freeCastStacks = buffStacks(buffs, 'fifth-cast-free')
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady
const nextCastsTowardFree = freeCastStacks > 0
? current.freeCastReady
? 0
: current.castsTowardFree + 1 >= 5
? 0
: current.castsTowardFree + 1
: current.castsTowardFree
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
const nextCooldowns = {
...current.cooldowns,
}
@@ -961,8 +1035,8 @@ export function PvPRoguelikeScreen({
party: nextParty,
resource: current.resource - effectiveCost,
cooldowns: nextCooldowns,
castsTowardFree: nextCastsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady,
castsTowardFree: current.castsTowardFree,
freeCastReady: false,
}
setCurrent(nextState)
return true
@@ -1073,7 +1147,6 @@ export function PvPRoguelikeScreen({
const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut')
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
const tankPressure = tankPressureTargets(side.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
@@ -1089,7 +1162,6 @@ export function PvPRoguelikeScreen({
? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
damage = Math.round(damage * damageMultiplier)
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
damage = Math.round(damage * 0.8)
}
@@ -1128,7 +1200,7 @@ export function PvPRoguelikeScreen({
...side,
party: nextParty,
resource: clamp(
side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')),
side.resource + 2.4,
0,
maxResource,
),
@@ -1142,10 +1214,11 @@ export function PvPRoguelikeScreen({
const beginUpgradePhase = useCallback(() => {
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
autoSubmittedUpgradeRef.current = false
const playerNeedsRevive = hasDeadPartyMembers(playerRef.current)
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerBuffChoices(playerNeedsRevive ? [REVIVE_PARTY_CHOICE] : chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedBuff(playerNeedsRevive ? REVIVE_PARTY_CHOICE : null)
setSelectedDebuff(null)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
@@ -1160,8 +1233,8 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
awardEncounterReward(encounterIndex)
if (encounter.isBoss) {
awardBossReward(encounterIndex)
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
profile.character.id,
contentType,
@@ -1188,7 +1261,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) {
@@ -1202,7 +1278,7 @@ export function PvPRoguelikeScreen({
}
}, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer)
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
}, [addLog, advanceSide, awardEncounterReward, 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
@@ -1218,6 +1294,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())
@@ -1242,13 +1358,16 @@ export function PvPRoguelikeScreen({
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
let nextPlayer = {
...playerRef.current,
buffs: [...playerRef.current.buffs, submittedBuff.id],
buffs: submittedBuff.id === REVIVE_PARTY_CHOICE.id
? 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)) {
if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
}
if (submittedBuff.id === REVIVE_PARTY_CHOICE.id) {
nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) }
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
@@ -1270,15 +1389,7 @@ export function PvPRoguelikeScreen({
}
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,
})),
party: recoverPartyForNextEncounter(nextPlayer.party, submittedBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
@@ -1293,7 +1404,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0)
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
setStatus('playing')
beginRoundCountdown()
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'}.`,
@@ -1339,30 +1450,34 @@ export function PvPRoguelikeScreen({
return
}
if (!cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
const cpuBuffChoices = hasDeadPartyMembers(cpuRef.current)
? [REVIVE_PARTY_CHOICE]
: chooseRandom(selfBuffChoicesCatalog, 3)
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
let nextPlayer = {
...playerRef.current,
buffs: [...playerRef.current.buffs, chosenBuff.id],
buffs: chosenBuff.id === REVIVE_PARTY_CHOICE.id
? playerRef.current.buffs
: [...playerRef.current.buffs, chosenBuff.id],
}
let nextCpu = {
...cpuRef.current,
buffs: [...cpuRef.current.buffs, cpuBuff.id],
buffs: cpuBuff.id === REVIVE_PARTY_CHOICE.id
? cpuRef.current.buffs
: [...cpuRef.current.buffs, cpuBuff.id],
}
if (chosenDebuff.id === 'opp-purge-random-buff') {
nextCpu = removeRandomBuff(nextCpu)
} else {
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
}
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
if (cpuDebuff.id === 'opp-purge-random-buff') {
nextPlayer = removeRandomBuff(nextPlayer)
} else {
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] }
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] }
if (chosenBuff.id === REVIVE_PARTY_CHOICE.id) {
nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) }
}
if (cpuBuff.id === REVIVE_PARTY_CHOICE.id) {
nextCpu = { ...nextCpu, debuffs: removeRandomDebuff(nextCpu.debuffs) }
}
const clearedBoss = encounter.isBoss
@@ -1384,30 +1499,14 @@ export function PvPRoguelikeScreen({
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,
})),
party: recoverPartyForNextEncounter(nextPlayer.party, chosenBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
}
nextCpu = {
...nextCpu,
party: nextCpu.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,
})),
party: recoverPartyForNextEncounter(nextCpu.party, cpuBuff.id === REVIVE_PARTY_CHOICE.id),
resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
@@ -1423,9 +1522,9 @@ export function PvPRoguelikeScreen({
playerRef.current = nextPlayer
cpuRef.current = nextCpu
setElapsedTicks(0)
setStatus('playing')
beginRoundCountdown()
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])
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useEffect(() => {
if (status !== 'upgrade-choice' || liveUpgradePending) return
@@ -1529,7 +1628,7 @@ export function PvPRoguelikeScreen({
partySize: playerSide.party.length,
selectedId,
log,
status: status === 'queueing' ? 'playing' : status,
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
resource: playerSide.resource,
maxResource,
resourceName: gameClass.resourceName,
@@ -1757,6 +1856,15 @@ export function PvPRoguelikeScreen({
</div>
)}
{status === 'round-countdown' && (
<div className="pvp-round-countdown">
<div>
<p className="eyebrow">Round Starts</p>
<h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
</div>
</div>
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="pvp-upgrade-dialog">
@@ -1769,7 +1877,7 @@ export function PvPRoguelikeScreen({
</div>
<div className="pvp-choice-columns">
<div>
<strong>Self Buff</strong>
<strong>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
@@ -1898,6 +2006,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>
File diff suppressed because it is too large Load Diff
+36 -10
View File
@@ -67,6 +67,14 @@ export type DualScreenCombatState = {
paused: boolean
targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2
stadium?: {
dampeningPercent: number
roundIndex: number
playerWins: number
opponentWins: number
survivalSeconds: number
opponentSurvivalSeconds: number
}
}
export type DualScreenWorkshopState = {
@@ -138,6 +146,11 @@ function memberHotEffects(member: PartyMember) {
: []
}
function formatDualTime(seconds: number) {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -446,21 +459,34 @@ export function DualScreenBottomDisplay() {
{state.opponentParty && <small>{state.opponentClassName}</small>}
</div>
<div className="dual-controls-progress">
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
</div>
</header>
{state.opponentParty ? (
<>
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
{state.stadium ? (
<section className="dual-opponent-progress stadium">
<div>
<p className="eyebrow">Dampening</p>
<strong>{state.stadium.dampeningPercent}%</strong>
</div>
<div>
<p className="eyebrow">Survival</p>
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
</div>
</section>
) : (
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => (
+63 -2
View File
@@ -37,7 +37,8 @@ export interface GameRepository {
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
},
@@ -503,6 +504,52 @@ function scaledPvpBossExperience(
return { experience, level }
}
function scaledPvpFightExperience(
startingExperience: number,
startingLevel: number,
fightsCleared: number,
maxLevel: number,
targetLevel = startingLevel,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) {
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
}
return { experience, level }
}
function scaledCurrentLevelExperience(
startingExperience: number,
startingLevel: number,
maxLevel: number,
rate: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * rate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
return { experience, level }
}
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
@@ -1142,7 +1189,21 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null
: options?.experienceMode === 'pvp-fight-twelfth-level'
? scaledPvpFightExperience(
previousExperience,
previousLevel,
Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
profile.maxLevel,
highestOtherClassLevel(save),
)
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
: options?.experienceMode === 'pvp-stadium-match-half-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
+60 -17
View File
@@ -125,6 +125,9 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
let lastControllerFocus: HTMLElement | null = null
type CaptureState = {
device: InputDevice
@@ -234,25 +237,41 @@ function focusableElements() {
).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function rememberFocusableControl(element: HTMLElement) {
lastControllerFocus = element
}
function focusControl(element: HTMLElement) {
rememberFocusableControl(element)
element.focus({ preventScroll: true })
}
function currentFocusableControl(candidates = focusableElements()) {
const active = document.activeElement
if (active instanceof HTMLElement && candidates.includes(active)) {
rememberFocusableControl(active)
return active
}
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
return lastControllerFocus
}
return null
}
export function focusFirstControl() {
const first = focusableElements()[0]
first?.focus({ preventScroll: true })
if (first) focusControl(first)
return first
}
function moveFocus(action: InputAction) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = document.activeElement instanceof HTMLElement
&& candidates.includes(document.activeElement)
? document.activeElement
: null
const current = currentFocusableControl(candidates)
if (!current) {
focusFirstControl()
return
@@ -279,7 +298,7 @@ function moveFocus(action: InputAction) {
const next = ranked[0]?.candidate
if (!next) return
next.focus({ preventScroll: true })
focusControl(next)
}
function hasUiOverlay() {
@@ -456,12 +475,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') {
const active = document.activeElement
const active = currentFocusableControl()
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (
active instanceof HTMLElement
active
&& active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active)
) {
@@ -581,6 +600,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKeyDown)
}, [assignBinding, dispatchAction])
useEffect(() => {
const onFocusIn = (event: FocusEvent) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
document.documentElement.dataset.inputDevice = 'pc'
const target = event.target
if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
if (!control || !isVisible(control)) return
rememberFocusableControl(control)
}
document.addEventListener('focusin', onFocusIn)
document.addEventListener('pointerdown', onPointerDown, { capture: true })
return () => {
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('pointerdown', onPointerDown, { capture: true })
}
}, [])
useEffect(() => {
const listener = (event: Event) => {
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
@@ -595,16 +637,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
const candidates = focusableElements()
const active = document.activeElement
const activeIsUsable = active instanceof HTMLElement
&& candidates.includes(active)
&& isVisible(active)
const activeControl = currentFocusableControl(candidates)
if (
(!activeIsUsable || document.activeElement === document.body)
(!activeControl || document.activeElement === document.body)
&& !keyboardInputRef.current
&& !captureRef.current
) {
focusFirstControl()
if (activeControl) {
focusControl(activeControl)
} else {
focusFirstControl()
}
}
}
const observer = new MutationObserver(() => {
+2 -1
View File
@@ -349,7 +349,8 @@ export async function completeRoguelike(
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
},
+19 -3
View File
@@ -1,8 +1,9 @@
import { requestGameApiJson } from './gameRepository'
export type PvpContentType = 'dungeon' | 'raid'
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
@@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = {
encounterIndex: number
buffId: string
debuffId: string
purchases?: string[]
shopReady?: boolean
}
export type PvpMatchSnapshot<TSideState = unknown> = {
@@ -25,7 +28,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
createdAt: number
players: Record<PvpMatchSide, PvpPlayerInfo>
states: Partial<Record<PvpMatchSide, TSideState>>
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
progress: Partial<Record<PvpMatchSide, {
stage: number
encounterIndex: number
@@ -35,6 +38,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
elapsedTicks: number
}>>
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
updatedAt: number
}
@@ -45,6 +49,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
@@ -136,7 +146,7 @@ export function publishPvpMatchState<TSideState>(
matchId: string,
payload: {
state: TSideState
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
status: PvpMatchStatus
stage: number
encounterIndex: number
encountersCleared: number
@@ -166,3 +176,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',
})
}