Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2888c287b | |||
| 4703017832 |
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
+39
-2
@@ -2309,6 +2309,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
? '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'
|
||||
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
|
||||
const resourceSpent = Number(runMetrics?.resourceSpent)
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
+25
-2
@@ -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,7 +575,9 @@ function App() {
|
||||
<div>
|
||||
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
||||
<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.'
|
||||
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
||||
</small>
|
||||
@@ -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
+27
-1
@@ -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,12 +459,24 @@ 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 ? (
|
||||
<>
|
||||
{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>
|
||||
@@ -461,6 +486,7 @@ export function DualScreenBottomDisplay() {
|
||||
<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) => (
|
||||
|
||||
+28
-1
@@ -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,6 +1197,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
profile.maxLevel,
|
||||
highestOtherClassLevel(save),
|
||||
)
|
||||
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
|
||||
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
|
||||
: options?.experienceMode === 'pvp-stadium-match-half-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
|
||||
: null
|
||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||
const newExperience = scaledReward
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user