Compare commits

..

7 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
Warren H 787e2bbae9 Android build v1.0.54 2026-06-21 20:22:12 -04:00
19 changed files with 2689 additions and 313 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.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 72 versionCode 79
versionName "1.0.53" versionName "1.0.60"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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.Bundle;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File; import java.io.File;
@@ -37,6 +38,18 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (hasFocus) enableImmersiveMode(); 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() { private void loadIntentUrl() {
if (bridge == null || getIntent() == null) return; if (bridge == null || getIntent() == null) return;
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL); 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'," "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post( bridge.getWebView().post(
() -> bridge.getWebView().evaluateJavascript(script, null) () -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
); );
} }
return true; return true;
+121 -22
View File
@@ -2307,7 +2307,16 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3)) const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level' const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
? 'pvp-boss-quarter-level' ? 'pvp-boss-quarter-level'
: 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' : 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent) const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds) const durationSeconds = Number(runMetrics?.durationSeconds)
if (!Number.isInteger(dungeonId) || dungeonId < 1) { if (!Number.isInteger(dungeonId) || dungeonId < 1) {
@@ -2322,6 +2331,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) { if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
throw new Error('The roguelike boss total is invalid.') 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) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
throw new Error('The run resource total is invalid.') throw new Error('The run resource total is invalid.')
} }
@@ -2374,14 +2386,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(maxLevel).experienceRequired `).get(maxLevel).experienceRequired
let newExperience = character.experience let newExperience = character.experience
let newLevel = character.level 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(` const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level SELECT COALESCE(MAX(level), 0) AS level
FROM characters FROM characters
WHERE account_id = ? WHERE account_id = ?
AND id != ? AND id != ?
`).get(accountId, characterId).level `).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(` const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired SELECT experience_required AS experienceRequired
FROM level_progression FROM level_progression
@@ -2395,7 +2408,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ? WHERE level = ?
`).get(newLevel + 1).experienceRequired `).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) 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)) newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
@@ -2403,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE experience_required <= ? WHERE experience_required <= ?
`).get(newExperience).level `).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 { } else {
const baseExperienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
@@ -2555,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) {
} }
function validatePvpContentType(value) { function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid') { if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
throw new Error('The PvP content type is invalid.') throw new Error('The PvP content type is invalid.')
} }
return value return value
@@ -2589,10 +2633,30 @@ function pvpSnapshot(match) {
statuses: match.statuses, statuses: match.statuses,
progress: match.progress, progress: match.progress,
upgradeChoices: match.upgradeChoices, upgradeChoices: match.upgradeChoices,
rematchRequests: match.rematchRequests,
updatedAt: match.updatedAt, 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) { function joinPvpQueue(session, payload) {
const now = Date.now() const now = Date.now()
cleanupPvpMemory(now) cleanupPvpMemory(now)
@@ -2622,24 +2686,11 @@ function joinPvpQueue(session, payload) {
.sort((left, right) => left.createdAt - right.createdAt)[0] .sort((left, right) => left.createdAt - right.createdAt)[0]
const player = pvpPlayerInfo(session) const player = pvpPlayerInfo(session)
if (opponent) { if (opponent) {
const matchId = randomBytes(12).toString('base64url') const match = createPvpMatch(contentType, startStage, {
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players: {
a: { side: 'a', ...opponent.player }, a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player }, b: { side: 'b', ...player },
}, }, now)
states: {}, opponent.matchId = match.id
statuses: {},
progress: {},
upgradeChoices: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
opponent.matchId = matchId
opponent.updatedAt = now opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url') const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, { pvpQueue.set(ticketId, {
@@ -2649,7 +2700,7 @@ function joinPvpQueue(session, payload) {
contentType, contentType,
startStage, startStage,
player, player,
matchId, matchId: match.id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@@ -2720,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) {
function updatePvpMatchState(session, matchId, payload) { function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId) 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 ? payload.status
: 'playing' : 'playing'
const progress = { const progress = {
@@ -2731,6 +2782,19 @@ function updatePvpMatchState(session, matchId, payload) {
alive: Boolean(payload.alive), alive: Boolean(payload.alive),
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)), 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.states[side] = payload.state ?? null
match.statuses[side] = status match.statuses[side] = status
match.progress[side] = progress match.progress[side] = progress
@@ -2746,11 +2810,40 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
encounterIndex, encounterIndex,
buffId: String(payload.buffId ?? ''), buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''), debuffId: String(payload.debuffId ?? ''),
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
shopReady: Boolean(payload.shopReady),
} }
match.updatedAt = Date.now() match.updatedAt = Date.now()
return pvpSnapshot(match) 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() { export function gameApiPlugin() {
return { return {
name: 'ashen-halls-game-api', name: 'ashen-halls-game-api',
@@ -2971,6 +3064,12 @@ export async function handleApiRequest(request, response, next) {
return 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_-]+)$/) const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
if (pvpMatch && request.method === 'GET') { if (pvpMatch && request.method === 'GET') {
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match)) sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
+292 -25
View File
@@ -18,6 +18,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
.sr-only {
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
button { button {
font: inherit; font: inherit;
} }
@@ -778,6 +786,10 @@ textarea:focus-visible,
min-height: 0; 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-enemy,
.dual-top-main .dual-top-party, .dual-top-main .dual-top-party,
.dual-top-main .dual-top-log { .dual-top-main .dual-top-log {
@@ -5888,6 +5900,33 @@ h2 {
z-index: 10; 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, .result-screen > div,
.pause-screen > div { .pause-screen > div {
background: var(--panel); background: var(--panel);
@@ -6087,27 +6126,17 @@ h2 {
.pvp-board { .pvp-board {
display: grid; display: grid;
gap: 8px; gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0; min-height: 0;
} }
.pvp-side, .pvp-side {
.pvp-middle-panel {
gap: 8px; gap: 8px;
min-height: 0; min-height: 0;
padding: 8px; padding: 8px;
} }
.pvp-vertical-spell-bar,
.pvp-vertical-spell-bar.six-slots {
grid-template-columns: 1fr;
}
.pvp-vertical-spell-bar .spell {
min-height: 58px;
padding: 6px;
}
.pvp-screen-tools { .pvp-screen-tools {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -6118,18 +6147,41 @@ h2 {
justify-content: flex-end; justify-content: flex-end;
} }
.pvp-resource-wrap { .pvp-side-bars {
color: #82bfff; display: grid;
min-width: 150px; gap: 8px;
text-align: right; min-width: min(320px, 45%);
width: min(170px, 100%); width: min(360px, 48%);
} }
.pvp-clear-wrap,
.pvp-resource-wrap {
color: var(--muted);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
text-align: right;
width: 100%;
}
.pvp-clear-wrap > span,
.pvp-resource-wrap > span { .pvp-resource-wrap > span {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
} }
.pvp-clear-wrap .bar,
.pvp-resource-wrap .bar {
height: 13px;
}
.pvp-clear-wrap {
color: #ff8d9a;
}
.pvp-resource-wrap {
color: #82bfff;
}
.pvp-side .party-member, .pvp-side .party-member,
.pvp-side .party-member > div, .pvp-side .party-member > div,
.pvp-side .party-member > small { .pvp-side .party-member > small {
@@ -6147,7 +6199,7 @@ h2 {
} }
.pvp-side .pvp-party-grid.raid .party-member { .pvp-side .pvp-party-grid.raid .party-member {
min-height: 62px; min-height: 96px;
padding: 6px; padding: 6px;
} }
@@ -6184,6 +6236,29 @@ h2 {
height: 14px; height: 14px;
} }
.pvp-side .member-health {
position: relative;
}
.pvp-side .member-health .health-text {
align-items: center;
color: #fff3c7;
display: flex;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
inset: 0;
justify-content: center;
pointer-events: none;
position: absolute;
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
z-index: 2;
}
.pvp-side .party-member .member-header small {
display: none;
}
.pvp-side .member-effects { .pvp-side .member-effects {
margin-top: 4px; margin-top: 4px;
} }
@@ -6202,22 +6277,57 @@ h2 {
gap: 8px; gap: 8px;
} }
.pvp-middle-panel .encounter-header h2 {
font-size: 20px;
}
.pvp-middle-panel .encounter-header small,
.pvp-enemy-race small { .pvp-enemy-race small {
font-size: 14px; font-size: 14px;
} }
.pvp-middle-panel .roguelike-upgrade-list,
.pvp-side .roguelike-upgrade-list { .pvp-side .roguelike-upgrade-list {
font-size: 12px; font-size: 12px;
line-height: 1.1; line-height: 1.1;
margin-top: 4px; margin-top: 4px;
} }
.pvp-bottom-spell-bar {
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 4px 4px 0 #08090c;
display: grid;
gap: 8px;
grid-column: 1 / -1;
grid-template-columns: repeat(6, minmax(0, 1fr));
outline: 2px solid var(--edge);
padding: 8px;
}
.pvp-bottom-spell-bar .spell {
align-items: center;
display: grid;
gap: 6px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 58px;
padding: 7px;
text-align: left;
}
.pvp-bottom-spell-bar .spell-icon {
height: 34px;
margin: 0;
width: 34px;
}
.pvp-bottom-spell-bar .spell strong {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pvp-bottom-spell-bar .spell small {
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.pvp-choice-columns { .pvp-choice-columns {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -6301,6 +6411,150 @@ h2 {
box-shadow: inset 0 0 0 2px #6e5727; 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, .result-screen button,
.pause-screen button { .pause-screen button {
background: var(--gold); background: var(--gold);
@@ -6813,11 +7067,24 @@ h2 {
} }
.pvp-board, .pvp-board,
.stadium-board,
.stadium-shop-layout,
.pvp-choice-columns, .pvp-choice-columns,
.pvp-choice-columns .upgrade-choice-grid { .pvp-choice-columns .upgrade-choice-grid {
grid-template-columns: 1fr; 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, .active-target-card,
.mana-wrap { .mana-wrap {
width: 100%; width: 100%;
+25 -2
View File
@@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen'
import { CustomizeScreen } from './components/CustomizeScreen' import { CustomizeScreen } from './components/CustomizeScreen'
import { EquipmentScreen } from './components/EquipmentScreen' import { EquipmentScreen } from './components/EquipmentScreen'
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen' import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
import { PvpStadiumScreen } from './components/PvpStadiumScreen'
import { TalentScreen } from './components/TalentScreen' import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen' import { SettingsScreen } from './components/SettingsScreen'
import { import {
@@ -253,6 +254,19 @@ function App() {
} }
if (screen === 'pvp') { if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
const pvpPool = profile.dungeons const pvpPool = profile.dungeons
.filter((candidate) => candidate.contentType === pvpContentType) .filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters) .flatMap((candidate) => candidate.encounters)
@@ -547,6 +561,13 @@ function App() {
> >
Raid Raid
</button> </button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
onClick={() => setPvpContentType('stadium')}
type="button"
>
Stadium
</button>
</div> </div>
</div> </div>
<div className="menu-card pvp-queue-panel"> <div className="menu-card pvp-queue-panel">
@@ -554,7 +575,9 @@ function App() {
<div> <div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong> <strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small> <small>
{gameMode === 'offline' {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.' ? '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.'} : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small> </small>
@@ -572,7 +595,7 @@ function App() {
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
<p className="eyebrow">CPU Leaderboard</p> <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> </div>
<div className="leaderboard-table"> <div className="leaderboard-table">
+325 -221
View File
@@ -28,6 +28,7 @@ import {
joinPvpQueue, joinPvpQueue,
loadPvpMatch, loadPvpMatch,
publishPvpMatchState, publishPvpMatchState,
requestPvpRematch,
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint, recordPvpRoguelikeCheckpoint,
@@ -35,12 +36,14 @@ import {
type CpuDifficulty, type CpuDifficulty,
type PvpMatchSnapshot, type PvpMatchSnapshot,
type PvpMatchSide, type PvpMatchSide,
type PvpRematchResponse,
type PvpContentType, type PvpContentType,
type PvpUpgradeChoicePayload, type PvpUpgradeChoicePayload,
} from '../pvpRoguelike' } from '../pvpRoguelike'
const TICK_MS = 700 const TICK_MS = 700
const UPGRADE_CHOICE_SECONDS = 10 const ROUND_START_SECONDS = 3
const UPGRADE_CHOICE_SECONDS = 15
type BossMechanic = type BossMechanic =
| 'party-pulse' | 'party-pulse'
@@ -55,24 +58,18 @@ type PvpEncounter = DungeonEncounter & {
} }
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type DraftSlotKey = Exclude<SlotKey, '6'>
type AbilityLabelMode = 'ability' | 'slot' type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId = type SelfBuffId =
| `slot${SlotKey}-extra-target` | 'revive-party-members'
| `slot${SlotKey}-cost-down` | `slot${DraftSlotKey}-extra-target`
| `slot${SlotKey}-cooldown-down` | `slot${DraftSlotKey}-cost-down`
| 'fifth-cast-free' | `slot${DraftSlotKey}-cooldown-down`
| 'group-heal-boost'
| 'shield-boost'
type OpponentDebuffId = type OpponentDebuffId =
| `opp-slot${SlotKey}-cost-up` | `opp-slot${DraftSlotKey}-cost-up`
| `opp-slot${SlotKey}-cooldown-up` | `opp-slot${DraftSlotKey}-cooldown-up`
| 'opp-takes-more-damage'
| 'opp-healing-reduced'
| 'opp-resource-regen-down'
| 'opp-cleanse-cooldown-up'
| 'opp-purge-random-buff'
type Choice<T extends string> = { type Choice<T extends string> = {
id: T id: T
@@ -117,6 +114,12 @@ type LivePvpMatch = {
opponentClassName: string 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[] = [ const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -182,7 +185,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
} }
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> { 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) const label = slotLabel(slot, spells, labelMode)
return [ 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>> { 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) const label = slotLabel(slot, spells, labelMode)
return [ 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 { 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))) 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) { function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 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) { 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 slot = spell.key as SlotKey
const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId) const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId) 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)
return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty))
} }
function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) { 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 downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId) const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks)) 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[] { 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[]) { function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'fifth-cast-free') return 8 if (buff.id === 'revive-party-members') return 10
if (buff.id === 'group-heal-boost') return 8
if (buff.id === 'shield-boost') return 6
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
const spell = spells.find((candidate) => candidate.key === slot) const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5 if (!spell) return 5
@@ -365,11 +369,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
} }
function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) { function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
if (debuff.id === 'opp-takes-more-damage') return 9 void opponentBuffCount
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
if (debuff.id.endsWith('cost-up')) return 7 if (debuff.id.endsWith('cost-up')) return 7
return 6 return 6
} }
@@ -386,13 +386,6 @@ function selectCpuChoice<T extends string>(
return ranked[0] 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']) { function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
return { id: nextLogId.current++, text, tone } return { id: nextLogId.current++, text, tone }
} }
@@ -456,7 +449,7 @@ export function PvPRoguelikeScreen({
})), })),
[contentType], [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 [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType)) const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0) const [encounterIndex, setEncounterIndex] = useState(0)
@@ -469,6 +462,8 @@ export function PvPRoguelikeScreen({
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null) const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null) const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
const [liveUpgradePending, setLiveUpgradePending] = useState(false) const [liveUpgradePending, setLiveUpgradePending] = useState(false)
const [rematchRequested, setRematchRequested] = useState(false)
const [rematchMessage, setRematchMessage] = useState('')
const [queueMessage, setQueueMessage] = useState('') const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null) const [reward, setReward] = useState<DungeonReward | null>(null)
@@ -480,6 +475,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([]) const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null) const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | 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 [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
const [encountersCleared, setEncountersCleared] = useState(0) const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
@@ -494,6 +490,7 @@ export function PvPRoguelikeScreen({
const queuedMatchRef = useRef(false) const queuedMatchRef = useRef(false)
const upgradeChoiceEndsAtRef = useRef(0) const upgradeChoiceEndsAtRef = useRef(0)
const autoSubmittedUpgradeRef = useRef(false) const autoSubmittedUpgradeRef = useRef(false)
const roundCountdownTimerRef = useRef<number | null>(null)
const liveMatchRef = useRef<LivePvpMatch | null>(null) const liveMatchRef = useRef<LivePvpMatch | null>(null)
const loggedOpponentDoneRef = useRef(false) const loggedOpponentDoneRef = useRef(false)
const pendingLiveUpgradeRef = useRef<{ const pendingLiveUpgradeRef = useRef<{
@@ -556,6 +553,29 @@ export function PvPRoguelikeScreen({
}, 900) }, 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(() => { useEffect(() => {
if (queuedMatchRef.current) return if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
@@ -567,10 +587,11 @@ export function PvPRoguelikeScreen({
encounterPoolRef.current = encounterPool encounterPoolRef.current = encounterPool
}, [encounterPool]) }, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => { const awardEncounterReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue) bossRewardClaimedRef.current.add(encounterIndexValue)
const rewardEncounter = encounters[encounterIndexValue] const rewardEncounter = encounters[encounterIndexValue]
const isBossReward = Boolean(rewardEncounter?.isBoss)
completeRoguelike( completeRoguelike(
rewardDungeon.id, rewardDungeon.id,
rewardDifficulty.id, rewardDifficulty.id,
@@ -578,10 +599,11 @@ export function PvPRoguelikeScreen({
0, 0,
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)), Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
{ {
bossesCleared: 1, bossesCleared: isBossReward ? 1 : 0,
experienceMode: 'pvp-boss-quarter-level', fightsCleared: 1,
lootSourceEncounterId: rewardEncounter?.sourceEncounterId, experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
roguelikeStage: stage, lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
roguelikeStage: isBossReward ? stage : undefined,
}, },
) )
.then((result) => { .then((result) => {
@@ -590,7 +612,7 @@ export function PvPRoguelikeScreen({
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return { return {
bossesKilled: current.bossesKilled + 1, bossesKilled: current.bossesKilled + (isBossReward ? 1 : 0),
experienceGained: current.experienceGained + result.experienceGained, experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel, previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel, newLevel: result.newLevel,
@@ -601,6 +623,9 @@ export function PvPRoguelikeScreen({
} }
}) })
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (!isBossReward && result.experienceGained > 0) {
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
}
if (result.bonusItem) { if (result.bonusItem) {
addLog( addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, `${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
@@ -622,20 +647,97 @@ export function PvPRoguelikeScreen({
useEffect(() => { useEffect(() => {
setPlayerBuffChoices((current) => current 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))) .filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
setPlayerDebuffChoices((current) => current setPlayerDebuffChoices((current) => current
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice))) .filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
setSelectedBuff((current) => current 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) : null)
setSelectedDebuff((current) => current setSelectedDebuff((current) => current
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current ? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [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) => { const startMatch = useCallback((nextStartStage?: number) => {
clearRoundCountdown()
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
@@ -680,6 +782,8 @@ export function PvPRoguelikeScreen({
setLiveUpgradePending(false) setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
setRematchRequested(false)
setRematchMessage('')
recordedRunRef.current = false recordedRunRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -689,8 +793,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(message) setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }]) setLog([{ id: 1, text: message, tone: 'system' }])
setStatus('playing') beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
} }
if (gameMode === 'offline') { if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
@@ -709,29 +812,7 @@ export function PvPRoguelikeScreen({
if (cancelled) return if (cancelled) return
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a' const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSide] const opponent = match.players[opponentSide]
const nextLiveMatch = { startLiveMatch(match, side, `${opponent.characterName} found. Stage ${match.startStage} begins.`)
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')
} }
const fallbackTimer = window.setTimeout(() => { const fallbackTimer = window.setTimeout(() => {
if (cancelled || liveMatchRef.current) return if (cancelled || liveMatchRef.current) return
@@ -781,7 +862,7 @@ export function PvPRoguelikeScreen({
if (pollTimer) window.clearTimeout(pollTimer) if (pollTimer) window.clearTimeout(pollTimer)
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined) 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]) useEffect(() => startMatch(), [startMatch])
@@ -791,7 +872,7 @@ export function PvPRoguelikeScreen({
const syncMatch = () => { const syncMatch = () => {
publishPvpMatchState<SideState>(liveMatch.id, { publishPvpMatchState<SideState>(liveMatch.id, {
state: playerRef.current, state: playerRef.current,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status, status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'round-countdown' ? 'playing' : status,
stage, stage,
encounterIndex, encounterIndex,
encountersCleared, encountersCleared,
@@ -807,10 +888,13 @@ export function PvPRoguelikeScreen({
setCpuSide(opponentState) setCpuSide(opponentState)
} }
const opponentStatus = snapshot.statuses[liveMatch.opponentSide] 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 loggedOpponentDoneRef.current = true
cpuDefeatedRef.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') { if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
finishRoguelikeRun() finishRoguelikeRun()
@@ -888,7 +972,7 @@ export function PvPRoguelikeScreen({
if (member.health <= 0) return member if (member.health <= 0) return member
if (spell.kind === 'group') { if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member 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)) const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
const nextShield = hasSpellEffect('radiance_applies_shield') 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 (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
if (spell.kind === 'shield') { if (spell.kind === 'shield') {
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost'))) const shieldPower = spell.power
return { return {
...member, ...member,
shield: Math.max(member.shield, shieldPower), shield: Math.max(member.shield, shieldPower),
@@ -939,16 +1023,6 @@ export function PvPRoguelikeScreen({
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, 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 = { const nextCooldowns = {
...current.cooldowns, ...current.cooldowns,
} }
@@ -961,8 +1035,8 @@ export function PvPRoguelikeScreen({
party: nextParty, party: nextParty,
resource: current.resource - effectiveCost, resource: current.resource - effectiveCost,
cooldowns: nextCooldowns, cooldowns: nextCooldowns,
castsTowardFree: nextCastsTowardFree, castsTowardFree: current.castsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady, freeCastReady: false,
} }
setCurrent(nextState) setCurrent(nextState)
return true return true
@@ -1073,7 +1147,6 @@ export function PvPRoguelikeScreen({
const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut') 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 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 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 hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
const tankPressure = tankPressureTargets(side.party) const tankPressure = tankPressureTargets(side.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
@@ -1089,7 +1162,6 @@ export function PvPRoguelikeScreen({
? Math.max(1, (member.poisonStacks ?? 0) + 1) ? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0 : member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3 if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
damage = Math.round(damage * damageMultiplier)
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) { if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
damage = Math.round(damage * 0.8) damage = Math.round(damage * 0.8)
} }
@@ -1128,7 +1200,7 @@ export function PvPRoguelikeScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: clamp( resource: clamp(
side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')), side.resource + 2.4,
0, 0,
maxResource, maxResource,
), ),
@@ -1142,10 +1214,11 @@ export function PvPRoguelikeScreen({
const beginUpgradePhase = useCallback(() => { const beginUpgradePhase = useCallback(() => {
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000 upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
autoSubmittedUpgradeRef.current = false autoSubmittedUpgradeRef.current = false
const playerNeedsRevive = hasDeadPartyMembers(playerRef.current)
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS) setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerBuffChoices(playerNeedsRevive ? [REVIVE_PARTY_CHOICE] : chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3)) setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null) setSelectedBuff(playerNeedsRevive ? REVIVE_PARTY_CHOICE : null)
setSelectedDebuff(null) setSelectedDebuff(null)
setStatus('upgrade-choice') setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
@@ -1160,8 +1233,8 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1) setEncountersCleared((value) => value + 1)
awardEncounterReward(encounterIndex)
if (encounter.isBoss) { if (encounter.isBoss) {
awardBossReward(encounterIndex)
const nextCheckpoint = recordPvpRoguelikeCheckpoint( const nextCheckpoint = recordPvpRoguelikeCheckpoint(
profile.character.id, profile.character.id,
contentType, contentType,
@@ -1188,7 +1261,10 @@ export function PvPRoguelikeScreen({
} }
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) { if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true 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 (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) { if (encounter.isBoss && cpuDefeatedRef.current) {
@@ -1202,7 +1278,7 @@ export function PvPRoguelikeScreen({
} }
}, TICK_MS / speedMultiplier) }, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer) 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(() => { useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return 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]) }, [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(() => { useEffect(() => {
if (status !== 'upgrade-choice') return if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
@@ -1242,13 +1358,16 @@ export function PvPRoguelikeScreen({
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => { const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
let nextPlayer = { let nextPlayer = {
...playerRef.current, ...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') { if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
nextPlayer = removeRandomBuff(nextPlayer)
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] } 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 const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) { if (clearedBoss && cpuDefeatedRef.current) {
@@ -1270,15 +1389,7 @@ export function PvPRoguelikeScreen({
} }
nextPlayer = { nextPlayer = {
...nextPlayer, ...nextPlayer,
party: nextPlayer.party.map((member) => ({ party: recoverPartyForNextEncounter(nextPlayer.party, submittedBuff.id === REVIVE_PARTY_CHOICE.id),
...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), resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {}, cooldowns: {},
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth,
@@ -1293,7 +1404,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0) setElapsedTicks(0)
setLiveUpgradePending(false) setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
setStatus('playing') beginRoundCountdown()
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId) const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
addLog( addLog(
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`, `You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
@@ -1339,30 +1450,34 @@ export function PvPRoguelikeScreen({
return return
} }
if (!cpuDifficulty) 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 cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells)) const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length)) const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
let nextPlayer = { let nextPlayer = {
...playerRef.current, ...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 = { let nextCpu = {
...cpuRef.current, ...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 const clearedBoss = encounter.isBoss
@@ -1384,30 +1499,14 @@ export function PvPRoguelikeScreen({
nextPlayer = { nextPlayer = {
...nextPlayer, ...nextPlayer,
party: nextPlayer.party.map((member) => ({ party: recoverPartyForNextEncounter(nextPlayer.party, chosenBuff.id === REVIVE_PARTY_CHOICE.id),
...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), resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {}, cooldowns: {},
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth,
} }
nextCpu = { nextCpu = {
...nextCpu, ...nextCpu,
party: nextCpu.party.map((member) => ({ party: recoverPartyForNextEncounter(nextCpu.party, cpuBuff.id === REVIVE_PARTY_CHOICE.id),
...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(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource), resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {}, cooldowns: {},
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth,
@@ -1423,9 +1522,9 @@ export function PvPRoguelikeScreen({
playerRef.current = nextPlayer playerRef.current = nextPlayer
cpuRef.current = nextCpu cpuRef.current = nextCpu
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') beginRoundCountdown()
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') 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(() => { useEffect(() => {
if (status !== 'upgrade-choice' || liveUpgradePending) return if (status !== 'upgrade-choice' || liveUpgradePending) return
@@ -1529,7 +1628,7 @@ export function PvPRoguelikeScreen({
partySize: playerSide.party.length, partySize: playerSide.party.length,
selectedId, selectedId,
log, log,
status: status === 'queueing' ? 'playing' : status, status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
resource: playerSide.resource, resource: playerSide.resource,
maxResource, maxResource,
resourceName: gameClass.resourceName, resourceName: gameClass.resourceName,
@@ -1618,8 +1717,15 @@ export function PvPRoguelikeScreen({
<div> <div>
<p className="eyebrow">You</p> <p className="eyebrow">You</p>
<h2>{profile.character.name}</h2> <h2>{profile.character.name}</h2>
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
</div>
<div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
</div> </div>
<div className="resource-row pvp-resource-row">
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
@@ -1643,6 +1749,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />} {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1665,69 +1772,20 @@ export function PvPRoguelikeScreen({
</p> </p>
</section> </section>
<section className="combat-panel pvp-middle-panel">
<div className="encounter-header">
<div>
<p className="eyebrow">Encounter {encounterIndex + 1}</p>
<h2>{encounter.enemyName}</h2>
<small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
</div>
</div>
<div className="pvp-enemy-race">
<div>
<strong>Your clear</strong>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
</div>
<div>
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
<small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
</div>
</div>
<div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
{starterSpells.map((spell) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
return (
<button
className="spell"
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
key={`middle-${spell.id}`}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{cost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</div>
<p className="roguelike-upgrade-list">
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
</p>
</section>
<section className="combat-panel pvp-side"> <section className="combat-panel pvp-side">
<div className="encounter-header"> <div className="encounter-header">
<div> <div>
<p className="eyebrow">Opponent</p> <p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2> <h2>{opponentLabel}</h2>
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
</div>
<div className="pvp-side-bars">
<div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div>
</div> </div>
<div className="resource-row pvp-resource-row">
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
@@ -1745,6 +1803,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />} {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1766,6 +1825,43 @@ export function PvPRoguelikeScreen({
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'} Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
</p> </p>
</section> </section>
<section className="pvp-bottom-spell-bar" aria-label="Player abilities">
{starterSpells.map((spell) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
return (
<button
className="spell"
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
key={`bottom-${spell.id}`}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
compact
iconStyle={controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{cost} {gameClass.resourceName}</small>
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</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> </div>
)} )}
@@ -1781,7 +1877,7 @@ export function PvPRoguelikeScreen({
</div> </div>
<div className="pvp-choice-columns"> <div className="pvp-choice-columns">
<div> <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"> <div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => ( {playerBuffChoices.map((choice) => (
<button <button
@@ -1910,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 onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button> <button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div> </div>
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -67,6 +67,14 @@ export type DualScreenCombatState = {
paused: boolean paused: boolean
targetGroup: 0 | 1 | 2 targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2 speedMultiplier: 1 | 2
stadium?: {
dampeningPercent: number
roundIndex: number
playerWins: number
opponentWins: number
survivalSeconds: number
opponentSurvivalSeconds: number
}
} }
export type DualScreenWorkshopState = { 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 }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -446,12 +459,24 @@ export function DualScreenBottomDisplay() {
{state.opponentParty && <small>{state.opponentClassName}</small>} {state.opponentParty && <small>{state.opponentClassName}</small>}
</div> </div>
<div className="dual-controls-progress"> <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> </div>
</header> </header>
{state.opponentParty ? ( {state.opponentParty ? (
<> <>
{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"> <section className="dual-opponent-progress">
<div> <div>
<p className="eyebrow">Opponent Clear</p> <p className="eyebrow">Opponent Clear</p>
@@ -461,6 +486,7 @@ export function DualScreenBottomDisplay() {
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} /> <span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div> </div>
</section> </section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}> <section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => ( {state.opponentParty.map((member) => (
+62 -1
View File
@@ -37,7 +37,8 @@ export interface GameRepository {
durationSeconds: number, durationSeconds: number,
options?: { options?: {
bossesCleared?: number 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 lootSourceEncounterId?: number
roguelikeStage?: number roguelikeStage?: number
}, },
@@ -503,6 +504,52 @@ function scaledPvpBossExperience(
return { experience, level } 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) { function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5))) return Math.min(4, Math.max(0, Math.floor(level / 5)))
} }
@@ -1142,6 +1189,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save)) ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: 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 : null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)) const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward const newExperience = scaledReward
+59 -16
View File
@@ -125,6 +125,9 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' 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 = { type CaptureState = {
device: InputDevice device: InputDevice
@@ -234,25 +237,41 @@ function focusableElements() {
).find(isVisible) ).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from( return Array.from(
scope.querySelectorAll<HTMLElement>( scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
).filter(isVisible) ).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() { export function focusFirstControl() {
const first = focusableElements()[0] const first = focusableElements()[0]
first?.focus({ preventScroll: true }) if (first) focusControl(first)
return first return first
} }
function moveFocus(action: InputAction) { function moveFocus(action: InputAction) {
const candidates = focusableElements() const candidates = focusableElements()
if (candidates.length === 0) return if (candidates.length === 0) return
const current = document.activeElement instanceof HTMLElement const current = currentFocusableControl(candidates)
&& candidates.includes(document.activeElement)
? document.activeElement
: null
if (!current) { if (!current) {
focusFirstControl() focusFirstControl()
return return
@@ -279,7 +298,7 @@ function moveFocus(action: InputAction) {
const next = ranked[0]?.candidate const next = ranked[0]?.candidate
if (!next) return if (!next) return
next.focus({ preventScroll: true }) focusControl(next)
} }
function hasUiOverlay() { function hasUiOverlay() {
@@ -456,12 +475,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
if (action.startsWith('navigate')) { if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) moveFocus(action) if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') { } else if (action === 'confirm') {
const active = document.activeElement const active = currentFocusableControl()
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
} else if ( } else if (
active instanceof HTMLElement active
&& active.matches('button:not(:disabled), [role="button"]') && active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active) && isVisible(active)
) { ) {
@@ -581,6 +600,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, [assignBinding, dispatchAction]) }, [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(() => { useEffect(() => {
const listener = (event: Event) => { const listener = (event: Event) => {
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
@@ -595,18 +637,19 @@ export function InputProvider({ children }: { children: ReactNode }) {
const combatActive = document.querySelector('[data-combat-active="true"]') const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return if (combatActive) return
const candidates = focusableElements() const candidates = focusableElements()
const active = document.activeElement const activeControl = currentFocusableControl(candidates)
const activeIsUsable = active instanceof HTMLElement
&& candidates.includes(active)
&& isVisible(active)
if ( if (
(!activeIsUsable || document.activeElement === document.body) (!activeControl || document.activeElement === document.body)
&& !keyboardInputRef.current && !keyboardInputRef.current
&& !captureRef.current && !captureRef.current
) { ) {
if (activeControl) {
focusControl(activeControl)
} else {
focusFirstControl() focusFirstControl()
} }
} }
}
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus) window.requestAnimationFrame(ensureFocus)
}) })
+2 -1
View File
@@ -349,7 +349,8 @@ export async function completeRoguelike(
durationSeconds: number, durationSeconds: number,
options?: { options?: {
bossesCleared?: number 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 lootSourceEncounterId?: number
roguelikeStage?: number roguelikeStage?: number
}, },
+19 -3
View File
@@ -1,8 +1,9 @@
import { requestGameApiJson } from './gameRepository' 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 CpuDifficulty = 1 | 2 | 3 | 4 | 5
export type PvpMatchSide = 'a' | 'b' export type PvpMatchSide = 'a' | 'b'
export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost'
export type PvpPlayerInfo = { export type PvpPlayerInfo = {
side: PvpMatchSide side: PvpMatchSide
@@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = {
encounterIndex: number encounterIndex: number
buffId: string buffId: string
debuffId: string debuffId: string
purchases?: string[]
shopReady?: boolean
} }
export type PvpMatchSnapshot<TSideState = unknown> = { export type PvpMatchSnapshot<TSideState = unknown> = {
@@ -25,7 +28,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
createdAt: number createdAt: number
players: Record<PvpMatchSide, PvpPlayerInfo> players: Record<PvpMatchSide, PvpPlayerInfo>
states: Partial<Record<PvpMatchSide, TSideState>> states: Partial<Record<PvpMatchSide, TSideState>>
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>> statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
progress: Partial<Record<PvpMatchSide, { progress: Partial<Record<PvpMatchSide, {
stage: number stage: number
encounterIndex: number encounterIndex: number
@@ -35,6 +38,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
elapsedTicks: number elapsedTicks: number
}>> }>>
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>> upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
updatedAt: number updatedAt: number
} }
@@ -45,6 +49,12 @@ export type PvpQueueResponse<TSideState = unknown> = {
side?: PvpMatchSide side?: PvpMatchSide
} }
export type PvpRematchResponse<TSideState = unknown> = {
status: 'waiting' | 'matched'
match?: PvpMatchSnapshot<TSideState>
side?: PvpMatchSide
}
export type CpuPvpLeaderboardEntry = { export type CpuPvpLeaderboardEntry = {
characterName: string characterName: string
className: string className: string
@@ -136,7 +146,7 @@ export function publishPvpMatchState<TSideState>(
matchId: string, matchId: string,
payload: { payload: {
state: TSideState state: TSideState
status: 'playing' | 'upgrade-choice' | 'won' | 'lost' status: PvpMatchStatus
stage: number stage: number
encounterIndex: number encounterIndex: number
encountersCleared: number encountersCleared: number
@@ -166,3 +176,9 @@ export function submitPvpUpgradeChoice(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
} }
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
method: 'POST',
})
}