Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abdf4cc654 | |||
| 5449276521 |
Binary file not shown.
Binary file not shown.
@@ -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 73
|
versionCode 75
|
||||||
versionName "1.0.54"
|
versionName "1.0.56"
|
||||||
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.
|
||||||
|
|||||||
+69
-20
@@ -2307,7 +2307,10 @@ 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'
|
||||||
: '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 +2325,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 +2380,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 +2402,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
|
||||||
@@ -2589,10 +2598,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 +2651,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 +2665,7 @@ function joinPvpQueue(session, payload) {
|
|||||||
contentType,
|
contentType,
|
||||||
startStage,
|
startStage,
|
||||||
player,
|
player,
|
||||||
matchId,
|
matchId: match.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -2751,6 +2767,33 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
|
|||||||
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 +3014,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))
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
joinPvpQueue,
|
joinPvpQueue,
|
||||||
loadPvpMatch,
|
loadPvpMatch,
|
||||||
publishPvpMatchState,
|
publishPvpMatchState,
|
||||||
|
requestPvpRematch,
|
||||||
randomCpuDifficulty,
|
randomCpuDifficulty,
|
||||||
recordCpuPvpLeaderboard,
|
recordCpuPvpLeaderboard,
|
||||||
recordPvpRoguelikeCheckpoint,
|
recordPvpRoguelikeCheckpoint,
|
||||||
@@ -35,6 +36,7 @@ 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'
|
||||||
@@ -55,24 +57,17 @@ 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`
|
| `slot${DraftSlotKey}-extra-target`
|
||||||
| `slot${SlotKey}-cost-down`
|
| `slot${DraftSlotKey}-cost-down`
|
||||||
| `slot${SlotKey}-cooldown-down`
|
| `slot${DraftSlotKey}-cooldown-down`
|
||||||
| 'fifth-cast-free'
|
|
||||||
| '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
|
||||||
@@ -182,7 +177,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 +197,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 +215,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 +242,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 +256,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 +264,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[] {
|
||||||
@@ -349,9 +323,6 @@ function starterSide(partyTemplate: PartyMember[], maxResource: number): SideSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 === '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 +336,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 +353,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 }
|
||||||
}
|
}
|
||||||
@@ -469,6 +429,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)
|
||||||
@@ -567,10 +529,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 +541,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 +554,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 +565,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.`,
|
||||||
@@ -635,6 +602,80 @@ export function PvPRoguelikeScreen({
|
|||||||
: 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)
|
||||||
|
setStatus('playing')
|
||||||
|
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' }])
|
||||||
|
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
|
||||||
|
|
||||||
const startMatch = useCallback((nextStartStage?: number) => {
|
const startMatch = useCallback((nextStartStage?: number) => {
|
||||||
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)
|
||||||
@@ -680,6 +721,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
|
||||||
@@ -709,29 +752,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 +802,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])
|
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
|
||||||
|
|
||||||
useEffect(() => startMatch(), [startMatch])
|
useEffect(() => startMatch(), [startMatch])
|
||||||
|
|
||||||
@@ -807,10 +828,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 +912,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 +929,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 +963,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 +975,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 +1087,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 +1102,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 +1140,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,
|
||||||
),
|
),
|
||||||
@@ -1160,8 +1172,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 +1200,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 +1217,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 +1233,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())
|
||||||
@@ -1244,9 +1299,7 @@ export function PvPRoguelikeScreen({
|
|||||||
...playerRef.current,
|
...playerRef.current,
|
||||||
buffs: [...playerRef.current.buffs, submittedBuff.id],
|
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] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1353,17 +1406,9 @@ export function PvPRoguelikeScreen({
|
|||||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
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] }
|
||||||
}
|
|
||||||
|
|
||||||
const clearedBoss = encounter.isBoss
|
const clearedBoss = encounter.isBoss
|
||||||
if (clearedBoss && cpuDefeatedRef.current) {
|
if (clearedBoss && cpuDefeatedRef.current) {
|
||||||
@@ -1898,6 +1943,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>
|
||||||
|
|||||||
+35
-1
@@ -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'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
@@ -503,6 +504,31 @@ 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 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 +1168,14 @@ 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),
|
||||||
|
)
|
||||||
: 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
|
||||||
|
|||||||
+2
-1
@@ -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'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,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 +46,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
|
||||||
@@ -166,3 +173,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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user