Compare commits

..

2 Commits

Author SHA1 Message Date
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
11 changed files with 1944 additions and 25 deletions
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 76
versionName "1.0.57"
versionCode 78
versionName "1.0.59"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+40 -3
View File
@@ -2309,7 +2309,13 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
? 'pvp-boss-quarter-level'
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
? 'pvp-fight-twelfth-level'
: 'default'
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? 'pvp-stadium-round-win-quarter-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 'pvp-stadium-round-loss-tenth-level'
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
? 'pvp-stadium-match-half-level'
: 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds)
@@ -2412,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE experience_required <= ?
`).get(newExperience).level
}
} else if (
experienceMode === 'pvp-stadium-round-win-quarter-level'
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|| experienceMode === 'pvp-stadium-match-half-level'
) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel).experienceRequired
const nextLevelExperience = newLevel >= maxLevel
? maxExperience
: database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
? 0.25
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 0.1
: 0.5
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
WHERE experience_required <= ?
`).get(newExperience).level
} else {
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
@@ -2564,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) {
}
function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid') {
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
throw new Error('The PvP content type is invalid.')
}
return value
@@ -2736,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) {
function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
? payload.status
: 'playing'
const progress = {
@@ -2762,6 +2797,8 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
encounterIndex,
buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''),
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
shopReady: Boolean(payload.shopReady),
}
match.updatedAt = Date.now()
return pvpSnapshot(match)
+169
View File
@@ -18,6 +18,14 @@
box-sizing: border-box;
}
.sr-only {
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
button {
font: inherit;
}
@@ -778,6 +786,10 @@ textarea:focus-visible,
min-height: 0;
}
.dual-top-main.stadium-dual-top {
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.dual-top-main .dual-top-enemy,
.dual-top-main .dual-top-party,
.dual-top-main .dual-top-log {
@@ -6399,6 +6411,150 @@ h2 {
box-shadow: inset 0 0 0 2px #6e5727;
}
.stadium-screen {
min-height: 100dvh;
}
.stadium-board {
display: grid;
gap: 14px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: calc(100dvh - 72px);
}
.stadium-header,
.stadium-pressure-panel {
align-items: center;
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 4px 4px 0 #08090c;
display: flex;
grid-column: 1 / -1;
justify-content: space-between;
outline: 2px solid var(--edge);
padding: 12px 16px;
}
.stadium-header h2 {
font-size: 20px;
}
.stadium-header > strong {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 26px;
}
.stadium-header p,
.stadium-header small,
.stadium-pressure-panel span {
color: var(--muted);
font-size: 13px;
}
.stadium-pressure-panel strong {
color: var(--ink);
font-family: 'Press Start 2P', monospace;
font-size: 16px;
}
.stadium-side {
min-height: 0;
}
.stadium-shop-dialog {
max-width: 1180px !important;
padding: 16px !important;
text-align: left !important;
width: min(1180px, calc(100vw - 32px));
}
.stadium-shop-summary {
color: var(--muted);
display: flex;
flex-wrap: wrap;
font-family: 'Press Start 2P', monospace;
font-size: 12px;
gap: 16px;
margin-bottom: 12px;
}
.stadium-shop-layout {
display: grid;
gap: 14px;
grid-template-columns: 240px minmax(0, 1fr);
}
.stadium-shop-tabs {
display: grid;
gap: 8px;
}
.stadium-shop-tabs button,
.stadium-shop-grid button {
background: #303545;
border: 2px solid #0b0c0f;
color: var(--ink);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
outline: 2px solid #4d4c58;
}
.stadium-shop-tabs button {
font-size: 9px;
margin: 0;
padding: 10px;
text-align: left;
}
.stadium-shop-tabs button.active {
background: #303427;
outline-color: var(--gold);
}
.stadium-shop-layout h3 {
font-size: 15px;
margin-bottom: 10px;
}
.stadium-shop-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stadium-shop-grid button {
display: grid;
gap: 10px;
margin: 0;
min-height: 128px;
padding: 14px;
text-align: left;
}
.stadium-shop-grid button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.stadium-shop-grid strong {
color: #fff0b8;
font-size: 11px;
line-height: 1.35;
}
.stadium-shop-grid small,
.stadium-shop-layout p {
color: #f0e8d2;
font-family: 'VT323', monospace;
font-size: 22px;
line-height: 1.05;
}
.dual-opponent-progress.stadium {
grid-template-columns: 1fr 1fr;
}
.result-screen button,
.pause-screen button {
background: var(--gold);
@@ -6911,11 +7067,24 @@ h2 {
}
.pvp-board,
.stadium-board,
.stadium-shop-layout,
.pvp-choice-columns,
.pvp-choice-columns .upgrade-choice-grid {
grid-template-columns: 1fr;
}
.stadium-header,
.stadium-pressure-panel,
.stadium-shop-summary {
align-items: stretch;
flex-direction: column;
}
.stadium-shop-grid {
grid-template-columns: 1fr;
}
.active-target-card,
.mana-wrap {
width: 100%;
+27 -4
View File
@@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen'
import { CustomizeScreen } from './components/CustomizeScreen'
import { EquipmentScreen } from './components/EquipmentScreen'
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
import { PvpStadiumScreen } from './components/PvpStadiumScreen'
import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen'
import {
@@ -253,6 +254,19 @@ function App() {
}
if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
const pvpPool = profile.dungeons
.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters)
@@ -547,6 +561,13 @@ function App() {
>
Raid
</button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
onClick={() => setPvpContentType('stadium')}
type="button"
>
Stadium
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
@@ -554,9 +575,11 @@ function App() {
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
{pvpContentType === 'stadium'
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
: gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small>
</div>
<button
@@ -572,7 +595,7 @@ function App() {
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
File diff suppressed because it is too large Load Diff
+36 -10
View File
@@ -67,6 +67,14 @@ export type DualScreenCombatState = {
paused: boolean
targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2
stadium?: {
dampeningPercent: number
roundIndex: number
playerWins: number
opponentWins: number
survivalSeconds: number
opponentSurvivalSeconds: number
}
}
export type DualScreenWorkshopState = {
@@ -138,6 +146,11 @@ function memberHotEffects(member: PartyMember) {
: []
}
function formatDualTime(seconds: number) {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -446,21 +459,34 @@ export function DualScreenBottomDisplay() {
{state.opponentParty && <small>{state.opponentClassName}</small>}
</div>
<div className="dual-controls-progress">
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
</div>
</header>
{state.opponentParty ? (
<>
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
{state.stadium ? (
<section className="dual-opponent-progress stadium">
<div>
<p className="eyebrow">Dampening</p>
<strong>{state.stadium.dampeningPercent}%</strong>
</div>
<div>
<p className="eyebrow">Survival</p>
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
</div>
</section>
) : (
<section className="dual-opponent-progress">
<div>
<p className="eyebrow">Opponent Clear</p>
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => (
+29 -2
View File
@@ -37,7 +37,7 @@ export interface GameRepository {
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
@@ -529,6 +529,27 @@ function scaledPvpFightExperience(
return { experience, level }
}
function scaledCurrentLevelExperience(
startingExperience: number,
startingLevel: number,
maxLevel: number,
rate: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * rate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
return { experience, level }
}
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
@@ -1176,7 +1197,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxLevel,
highestOtherClassLevel(save),
)
: null
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
: options?.experienceMode === 'pvp-stadium-match-half-level'
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
+1 -1
View File
@@ -349,7 +349,7 @@ export async function completeRoguelike(
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
+6 -3
View File
@@ -1,8 +1,9 @@
import { requestGameApiJson } from './gameRepository'
export type PvpContentType = 'dungeon' | 'raid'
export type PvpContentType = 'dungeon' | 'raid' | 'stadium'
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
export type PvpMatchSide = 'a' | 'b'
export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost'
export type PvpPlayerInfo = {
side: PvpMatchSide
@@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = {
encounterIndex: number
buffId: string
debuffId: string
purchases?: string[]
shopReady?: boolean
}
export type PvpMatchSnapshot<TSideState = unknown> = {
@@ -25,7 +28,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
createdAt: number
players: Record<PvpMatchSide, PvpPlayerInfo>
states: Partial<Record<PvpMatchSide, TSideState>>
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
progress: Partial<Record<PvpMatchSide, {
stage: number
encounterIndex: number
@@ -143,7 +146,7 @@ export function publishPvpMatchState<TSideState>(
matchId: string,
payload: {
state: TSideState
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
status: PvpMatchStatus
stage: number
encounterIndex: number
encountersCleared: number