Android build v1.1.2
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# Combat Modules
|
||||
|
||||
Shared combat code is split by responsibility:
|
||||
|
||||
- `spellEffects.ts` owns spell target plans and the shared spell-effect applier.
|
||||
- `combatEngine.ts` owns repeated per-member tick math: damage reduction, shields, HoTs, poison, debuff timers, and bounce-heal jumps.
|
||||
- `combatPresentation.ts` owns UI-facing caps and grouping for combat logs and floating combat text.
|
||||
- Screen files provide `SpellEffectProfile` objects for mode-specific numbers.
|
||||
- `spellCasting.ts` owns cast readiness and cooldown writes.
|
||||
- `pvpSpellCasting.ts` owns the shared PvP cast pipeline: readiness validation, live target validation, effect application, floating-heal emission, and side-state writes.
|
||||
- `combatTick.ts` owns small tick helpers such as resource regeneration and cooldown decay.
|
||||
- `cpuAi.ts` owns CPU healer timing, mistake rolls, spell choice, and turn execution.
|
||||
- `stackCounts.ts` owns stacked buff/upgrade count maps and summaries.
|
||||
- `spellModifiers.ts` owns stack-based spell modifiers such as cost, cooldown, extra targets, free casts, and power multipliers.
|
||||
- `combatStateTransitions.ts` owns shared combat state writes such as applying a completed cast to party, resource, cooldown, and free-cast fields.
|
||||
- `stadiumLifecycle.ts` owns pure Stadium round/shop decisions: round outcome resolution, shop point awards, and CPU purchase selection.
|
||||
- `pvpRoguelikeLifecycle.ts` owns pure PvP roguelike lifecycle decisions: live snapshot outcomes, combat tick outcomes, upgrade-complete checks, and next-stage progression.
|
||||
- `pvpRoguelikeUpgrades.ts` owns PvP roguelike upgrade application, revive cleanup, party recovery, resource restore, cooldown reset, and next enemy health setup.
|
||||
- `pvpRoguelikeMatchSetup.ts` owns PvP roguelike match-start side creation, first segment setup, live opponent naming, and shared reset defaults.
|
||||
- `stadiumMatchSetup.ts` owns Stadium starter side creation, local/live match setup, live opponent naming, and shared reset defaults.
|
||||
- `pveRoguelikeRunSetup.ts` owns PvE combat-state creation and roguelike run reset defaults.
|
||||
- `rewardSummaries.ts` owns reward summary initialization, unlocked ability merging, XP/level/talent accumulation, and PvP roguelike boss loot accumulation.
|
||||
- `dualScreenPayloads.ts` owns dual-screen combat payload construction and mode-specific status normalization.
|
||||
- `pvpLiveLifecycle.ts` owns live PvP polling, local progress publication, and rematch request state.
|
||||
|
||||
## SpellEffectProfile
|
||||
|
||||
Use a profile when spell behavior is the same shape but different by mode.
|
||||
|
||||
Examples:
|
||||
|
||||
- PvE stores Renew-style effects in `hotEffects`; PvP modes mostly use `hotTicks`.
|
||||
- Stadium applies dampening through profile power callbacks.
|
||||
- PvE cleanse intentionally does not emit floating heal text; PvP modes do.
|
||||
- Shield ratios such as Radiance `0.3`, Mend `0.5`, and Stadium group shield `0.5` belong in profiles.
|
||||
|
||||
When adding a spell rule, prefer adding a named profile field or callback instead of branching in screen components.
|
||||
|
||||
## StackCounts
|
||||
|
||||
Use `createStackCounts` once per party state, upgrade list, or cast snapshot when repeated math needs stack totals. Then pass the map to spell cost, cooldown, target, and effect helpers. This keeps stacked upgrade behavior consistent and avoids repeated `filter`/scan work in combat render and cast paths.
|
||||
|
||||
## Spell Modifiers
|
||||
|
||||
Use `spellModifiers.ts` for stacked upgrade math instead of open-coded exponent formulas in screen components. Screens still own mode-specific IDs, but shared helpers own the math:
|
||||
|
||||
- slot cost/cooldown reducers default to `0.75 ** stacks`.
|
||||
- opponent slot penalties default to `1.25 ** stacks`.
|
||||
- power boosts default to `1.25 ** stacks`.
|
||||
- free-cast checks use a named stack modifier and the mode's existing `freeCastReady` rules.
|
||||
|
||||
## State Transitions
|
||||
|
||||
Use `applyCastStateUpdate` after a spell effect profile returns the next party. Screens can still prepare mode-specific cooldown adjustments before the spell cooldown lands, but the final resource spend, cooldown write, party replacement, and free-cast progress should go through the transition helper.
|
||||
|
||||
## PvP Spell Casting
|
||||
|
||||
Use `applyPvpSpellCast` after a PvP screen builds its mode-specific target plan and `SpellEffectProfile`. Keep balance inputs such as roguelike effects, Stadium dampening, shop buffs, cooldown tweaks, and free-cast flags in the screen; let the helper own the repeated validate/apply/emit/write pipeline.
|
||||
|
||||
## CPU AI
|
||||
|
||||
Use `runCpuHealTurn` for PvP CPU turns. Screens still own mode-specific behavior tables and apply-spell adapters, while `cpuAi.ts` owns timing checks, mistake rolls, action selection, and invoking the selected spell.
|
||||
|
||||
## Stadium Lifecycle
|
||||
|
||||
Use `resolveStadiumRound` for win/loss/tie decisions instead of open-coding score checks in `PvpStadiumScreen`. Use `stadiumShopPointsForOutcome` and `chooseStadiumCpuPurchases` for shop setup so player and CPU point rules stay in one place.
|
||||
|
||||
## PvP Roguelike Lifecycle
|
||||
|
||||
Use `resolvePvpRoguelikeCombatOutcome` after each tick advances both sides. Use `resolvePvpRoguelikeLiveSnapshot` when a live match snapshot arrives. Use `nextPvpRoguelikeStage` after upgrade choices to decide whether to continue, advance stage, or complete the match. Components should execute the returned intent with React setters instead of duplicating the decision tree.
|
||||
|
||||
## PvP Roguelike Upgrades
|
||||
|
||||
Use `applyPvpRoguelikeUpgradeChoice` to apply selected buffs, incoming debuffs, and revive-buff debuff cleanup. Use `preparePvpRoguelikeNextEncounter` after progression chooses the next encounter so recovery, 25% resource restore, cooldown reset, and enemy health setup stay consistent between live and CPU matches.
|
||||
|
||||
## PvP Roguelike Match Setup
|
||||
|
||||
Use `createPvpRoguelikeMatchStart` for local/CPU starts and `createPvpRoguelikeLiveMatchStart` for live matches. These helpers build starter sides, set first encounter health, name live opponents, and provide reset defaults so rematch, checkpoint start, and queue fallback do not duplicate initial state construction.
|
||||
|
||||
## Stadium Match Setup
|
||||
|
||||
Use `createStadiumMatchStart` for queue/CPU starts, `createStadiumLiveMatchStart` for live matches, and `createStadiumStarterSide` for advancing to the next round. These helpers reset party state, carry purchased buffs and round wins where needed, and provide shared reset defaults for queue, rematch, and live match starts.
|
||||
|
||||
## PvE Run Setup
|
||||
|
||||
Use `createPveCombatState` for initial single-player combat state and `createPveRoguelikeRunStart` when resetting a run. This keeps party cloning, resource setup, enemy health, cooldown/free-cast reset, and UI reset defaults in one place.
|
||||
|
||||
## Reward Summaries
|
||||
|
||||
Use `createEmptyRewardSummary`, `createEmptyPvpRunSummary`, `mergeDungeonRewardSummary`, and `mergePvpRunRewardSummary` instead of open-coding reward accumulation in screens. This keeps XP, level, talent points, unlocked abilities, and boss loot merge behavior consistent.
|
||||
|
||||
Presentation components for these summaries live in `components/RewardPanels.tsx`: use `RewardXpSummary`, `BonusItemReward`, `LootRollList`, and `PvpRunLootList` instead of duplicating reward JSX in result screens.
|
||||
|
||||
## Dual-Screen Payloads
|
||||
|
||||
Use the builders in `dualScreenPayloads.ts` when publishing combat state to the secondary display. Keep render-derived arrays such as stripped floating text and opponent summaries memoized before calling the builder, then let the builder normalize mode status values like queueing, countdown, and Stadium shop into the bottom-screen status model.
|
||||
|
||||
## Live PvP Sync
|
||||
|
||||
Use `usePvpLiveMatchSync` for live match polling and rematch controls. Screens should provide only mode-specific progress payloads and opponent snapshot rules; the hook owns publish/poll intervals, rematch pending state, expiry, and error messages.
|
||||
|
||||
## Timers
|
||||
|
||||
Use `useRoundCountdown` for PvP round starts and `useDeadlineTimer` for mode deadlines such as PvP roguelike upgrade choice and Stadium shop. Keep countdown expiry behavior mode-specific, but avoid open-coded interval refs and `Date.now()` loops in screens.
|
||||
|
||||
## Floating Combat Text
|
||||
|
||||
Use `useFloatingCombatText` for single-sided combat screens and `useSidedFloatingCombatText` for PvP screens. The hooks own IDs, expiry cleanup, member grouping, side filtering, and dual-screen text stripping so screens only emit heal events.
|
||||
|
||||
## Party Targeting
|
||||
|
||||
Use `usePartyTargeting` for combat-screen navigation wrappers. Keep raw grid/direct selection rules in `combat/targeting.ts`; the hook owns relative, directional, and direct target callbacks around the current party ref, selected-id ref, grid columns, living-target flags, and target group.
|
||||
|
||||
## Render Allocation
|
||||
|
||||
Keep repeated combat-render derivations memoized near their source state: active input bindings, stack summaries, visible shop choices, enemy health segments, and stripped floating text payloads. Avoid calling summary helpers or `bindings[lastDevice]` repeatedly inside JSX loops.
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import {
|
||||
clamp,
|
||||
healAmount,
|
||||
memberHotEffects,
|
||||
tickHotEffects,
|
||||
} from './rules'
|
||||
|
||||
export type MemberTickInput = {
|
||||
member: PartyMember
|
||||
party: PartyMember[]
|
||||
damage: number
|
||||
hotHealing: number
|
||||
hotTicks: 'effects' | 'ticks'
|
||||
healingMultiplier?: number
|
||||
damageReductionMultiplier?: number
|
||||
damageReductionRounding?: 'round' | 'ceil'
|
||||
shieldedDamageMultiplier?: number
|
||||
applyDebuff?: {
|
||||
label: string
|
||||
ticks: number
|
||||
}
|
||||
applyPoisonStacks?: boolean
|
||||
poisonDamage?: (stacks: number) => number
|
||||
applyMaxHealthPenaltyTicks?: number
|
||||
applyHealingReductionTicks?: number
|
||||
decrementDamageReduction?: boolean
|
||||
useBounceHeals?: boolean
|
||||
jumpTarget?: () => PartyMember | undefined
|
||||
}
|
||||
|
||||
export type MemberTickResult = {
|
||||
member: PartyMember
|
||||
floatingHeal: number
|
||||
jumpedBounceHeals: Array<{
|
||||
targetId: string
|
||||
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one combat tick to a party member. Screen-level engines still decide
|
||||
* who is targeted and when fights end; this helper owns repeated health math:
|
||||
* damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals.
|
||||
*/
|
||||
export function advanceMemberTick({
|
||||
member,
|
||||
party,
|
||||
damage,
|
||||
hotHealing,
|
||||
hotTicks,
|
||||
healingMultiplier = 1,
|
||||
damageReductionMultiplier = 0.5,
|
||||
damageReductionRounding = 'round',
|
||||
shieldedDamageMultiplier,
|
||||
applyDebuff,
|
||||
applyPoisonStacks = false,
|
||||
poisonDamage = () => 0,
|
||||
applyMaxHealthPenaltyTicks,
|
||||
applyHealingReductionTicks,
|
||||
decrementDamageReduction = false,
|
||||
useBounceHeals = false,
|
||||
jumpTarget,
|
||||
}: MemberTickInput): MemberTickResult {
|
||||
if (member.health <= 0) {
|
||||
return { member, floatingHeal: 0, jumpedBounceHeals: [] }
|
||||
}
|
||||
|
||||
let nextDamage = damage
|
||||
const nextPoisonStacks = applyPoisonStacks
|
||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks)
|
||||
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
const reducedDamage = nextDamage * damageReductionMultiplier
|
||||
nextDamage = damageReductionRounding === 'ceil'
|
||||
? Math.ceil(reducedDamage)
|
||||
: Math.round(reducedDamage)
|
||||
}
|
||||
if (member.shield > 0 && shieldedDamageMultiplier !== undefined) {
|
||||
nextDamage = Math.round(nextDamage * shieldedDamageMultiplier)
|
||||
}
|
||||
|
||||
const absorbed = Math.min(member.shield, nextDamage)
|
||||
const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : []
|
||||
let healing = hotTicks === 'effects'
|
||||
? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||
: member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0
|
||||
|
||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||
const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = []
|
||||
if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power, healingMultiplier)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member
|
||||
jumpedBounceHeals.push({
|
||||
targetId: target.id,
|
||||
heal: { ...effect, charges: nextCharges },
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined
|
||||
? applyMaxHealthPenaltyTicks
|
||||
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
|
||||
const nextHealingReductionTicks = applyHealingReductionTicks !== undefined
|
||||
? applyHealingReductionTicks
|
||||
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
|
||||
const nextDebuffTicks = applyDebuff
|
||||
? applyDebuff.ticks
|
||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||
const nextEffectiveMaxHealth = Math.max(1, Math.round(
|
||||
member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1),
|
||||
))
|
||||
|
||||
return {
|
||||
member: {
|
||||
...member,
|
||||
health: clamp(
|
||||
clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed,
|
||||
0,
|
||||
nextEffectiveMaxHealth,
|
||||
),
|
||||
shield: Math.max(0, member.shield - nextDamage),
|
||||
hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1),
|
||||
hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects,
|
||||
bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals,
|
||||
damageReductionTicks: decrementDamageReduction
|
||||
? Math.max(0, (member.damageReductionTicks ?? 0) - 1)
|
||||
: member.damageReductionTicks,
|
||||
debuff: nextDebuffTicks > 0
|
||||
? applyDebuff?.label ?? member.debuff
|
||||
: undefined,
|
||||
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
|
||||
poisonStacks: nextPoisonStacks,
|
||||
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
|
||||
healingReductionTicks: nextHealingReductionTicks,
|
||||
},
|
||||
floatingHeal: healing,
|
||||
jumpedBounceHeals,
|
||||
}
|
||||
}
|
||||
|
||||
export function attachJumpedBounceHeals(
|
||||
party: PartyMember[],
|
||||
jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'],
|
||||
) {
|
||||
if (jumpedBounceHeals.length === 0) return party
|
||||
const jumpedByTarget = new Map<string, NonNullable<PartyMember['bounceHeals']>>()
|
||||
for (const jump of jumpedBounceHeals) {
|
||||
const current = jumpedByTarget.get(jump.targetId)
|
||||
if (current) current.push(jump.heal)
|
||||
else jumpedByTarget.set(jump.targetId, [jump.heal])
|
||||
}
|
||||
return party.map((member) => {
|
||||
const jumped = jumpedByTarget.get(member.id)
|
||||
if (!jumped || jumped.length === 0) return member
|
||||
return {
|
||||
...member,
|
||||
bounceHeals: [
|
||||
...(member.bounceHeals ?? []),
|
||||
...jumped,
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CombatLogEntry } from '../game'
|
||||
|
||||
export type BasicFloatingCombatText = {
|
||||
id: number
|
||||
memberId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export const DEFAULT_COMBAT_LOG_LIMIT = 60
|
||||
export const STADIUM_COMBAT_LOG_LIMIT = 70
|
||||
export const DEFAULT_FLOATING_TEXT_LIMIT = 48
|
||||
|
||||
export function appendCombatLog(
|
||||
current: CombatLogEntry[],
|
||||
entry: CombatLogEntry,
|
||||
limit = DEFAULT_COMBAT_LOG_LIMIT,
|
||||
) {
|
||||
return [entry, ...current].slice(0, limit)
|
||||
}
|
||||
|
||||
export function appendFloatingText<T extends BasicFloatingCombatText>(
|
||||
current: T[],
|
||||
entry: T,
|
||||
limit = DEFAULT_FLOATING_TEXT_LIMIT,
|
||||
) {
|
||||
return [...current, entry].slice(-limit)
|
||||
}
|
||||
|
||||
export function groupFloatingTextsByMember<T extends BasicFloatingCombatText>(texts: T[]) {
|
||||
const groups = new Map<string, T[]>()
|
||||
texts.forEach((entry) => {
|
||||
const current = groups.get(entry.memberId)
|
||||
if (current) current.push(entry)
|
||||
else groups.set(entry.memberId, [entry])
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
export function stripFloatingTextSide<T extends BasicFloatingCombatText>(texts: T[]): BasicFloatingCombatText[] {
|
||||
return texts.map(({ id, memberId, value }) => ({ id, memberId, value }))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { putSpellOnCooldown } from './spellCasting'
|
||||
import { advanceFreeCastProgress } from './spellEffects'
|
||||
|
||||
export type CastStateFields<TParty extends readonly PartyMember[] = PartyMember[]> = {
|
||||
party: TParty
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export function applyCastStateUpdate<
|
||||
TParty extends readonly PartyMember[],
|
||||
TState extends CastStateFields<TParty>,
|
||||
>({
|
||||
current,
|
||||
party,
|
||||
spell,
|
||||
resourceCost,
|
||||
cooldownMultiplier = 1,
|
||||
cooldowns = current.cooldowns,
|
||||
freeCast = { enabled: false, wasReady: false },
|
||||
}: {
|
||||
current: TState
|
||||
party: TParty
|
||||
spell: Spell
|
||||
resourceCost: number
|
||||
cooldownMultiplier?: number
|
||||
cooldowns?: Record<string, number>
|
||||
freeCast?: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
}
|
||||
}) {
|
||||
const freeCastProgress = advanceFreeCastProgress({
|
||||
enabled: freeCast.enabled,
|
||||
wasReady: freeCast.wasReady,
|
||||
castsTowardFree: current.castsTowardFree,
|
||||
})
|
||||
|
||||
return {
|
||||
...current,
|
||||
party,
|
||||
resource: current.resource - resourceCost,
|
||||
cooldowns: putSpellOnCooldown(cooldowns, spell, cooldownMultiplier),
|
||||
castsTowardFree: freeCastProgress.castsTowardFree,
|
||||
freeCastReady: freeCastProgress.freeCastReady,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { clamp } from './rules'
|
||||
|
||||
export function tickSeconds(tickMs: number) {
|
||||
return tickMs / 1000
|
||||
}
|
||||
|
||||
export function advanceCooldowns(cooldowns: Record<string, number>, elapsedSeconds: number) {
|
||||
const nextCooldowns: Record<string, number> = {}
|
||||
for (const id in cooldowns) {
|
||||
nextCooldowns[id] = Math.max(0, cooldowns[id] - elapsedSeconds)
|
||||
}
|
||||
return nextCooldowns
|
||||
}
|
||||
|
||||
export function regenerateResource(resource: number, amount: number, maxResource: number) {
|
||||
return clamp(resource + amount, 0, maxResource)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { effectiveMaxHealth } from './rules'
|
||||
|
||||
export type CpuHealBehavior = {
|
||||
directHealThreshold: number
|
||||
groupHealThreshold: number
|
||||
hotThreshold: number
|
||||
shieldThreshold: number
|
||||
}
|
||||
|
||||
export type CpuTurnBehavior = CpuHealBehavior & {
|
||||
actionEveryTicks: number
|
||||
mistakeChance: number
|
||||
}
|
||||
|
||||
export function shouldCpuAct({
|
||||
elapsedTicks,
|
||||
behavior,
|
||||
}: {
|
||||
elapsedTicks: number
|
||||
behavior: CpuTurnBehavior
|
||||
}) {
|
||||
return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance
|
||||
}
|
||||
|
||||
export function chooseCpuHealActions({
|
||||
party,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots = false,
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spells: Spell[]
|
||||
behavior: CpuHealBehavior
|
||||
preferSlots?: boolean
|
||||
}) {
|
||||
let livingCount = 0
|
||||
let healthRatioTotal = 0
|
||||
let lowest: PartyMember | null = null
|
||||
let tank: PartyMember | null = null
|
||||
let cleanseTarget: PartyMember | null = null
|
||||
let renewTarget: PartyMember | null = null
|
||||
let shieldTarget: PartyMember | null = null
|
||||
let woundedCount = 0
|
||||
|
||||
for (const member of party) {
|
||||
if (member.health <= 0) continue
|
||||
livingCount += 1
|
||||
const ratio = member.health / effectiveMaxHealth(member)
|
||||
healthRatioTotal += ratio
|
||||
if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member
|
||||
if (!tank && member.role === 'Tank') tank = member
|
||||
if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member
|
||||
if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member
|
||||
if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) {
|
||||
shieldTarget = member
|
||||
}
|
||||
if (ratio < behavior.directHealThreshold) woundedCount += 1
|
||||
}
|
||||
|
||||
if (!lowest || livingCount === 0) return []
|
||||
const averageHealth = healthRatioTotal / livingCount
|
||||
const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind)
|
||||
const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot)
|
||||
const direct = preferSlots ? spellBySlot('1') : spellByKind('direct')
|
||||
const hot = preferSlots ? spellBySlot('2') : spellByKind('hot')
|
||||
const group = preferSlots ? spellBySlot('3') : spellByKind('group')
|
||||
const shield = preferSlots ? spellBySlot('4') : spellByKind('shield')
|
||||
const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse')
|
||||
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
|
||||
{ spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null },
|
||||
{ spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id },
|
||||
{ spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null },
|
||||
{ spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id },
|
||||
{ spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null },
|
||||
{ spell: tank ? direct : undefined, targetId: tank?.id ?? null },
|
||||
]
|
||||
|
||||
return ordered
|
||||
.filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId))
|
||||
}
|
||||
|
||||
export function chooseCpuHealAction(options: Parameters<typeof chooseCpuHealActions>[0]) {
|
||||
return chooseCpuHealActions(options)[0] ?? null
|
||||
}
|
||||
|
||||
export function runCpuHealTurn<TSide>({
|
||||
side,
|
||||
elapsedTicks,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots = false,
|
||||
applySpell,
|
||||
}: {
|
||||
side: TSide & { party: PartyMember[] }
|
||||
elapsedTicks: number
|
||||
spells: Spell[]
|
||||
behavior: CpuTurnBehavior
|
||||
preferSlots?: boolean
|
||||
applySpell: (side: TSide, spell: Spell, targetId: string) => void
|
||||
}) {
|
||||
if (!shouldCpuAct({ elapsedTicks, behavior })) return false
|
||||
const action = chooseCpuHealAction({
|
||||
party: side.party,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots,
|
||||
})
|
||||
if (!action) return false
|
||||
applySpell(side, action.spell, action.targetId)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DualScreenCombatState } from '../dualScreen'
|
||||
|
||||
export function buildCombatDualScreenState(state: DualScreenCombatState): DualScreenCombatState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function buildPvpRoguelikeDualScreenState({
|
||||
status,
|
||||
...state
|
||||
}: Omit<DualScreenCombatState, 'status'> & {
|
||||
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown'
|
||||
}): DualScreenCombatState {
|
||||
return {
|
||||
...state,
|
||||
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStadiumDualScreenState({
|
||||
status,
|
||||
...state
|
||||
}: Omit<DualScreenCombatState, 'status' | 'targetGroup' | 'speedMultiplier'> & {
|
||||
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' | 'shop'
|
||||
}): DualScreenCombatState {
|
||||
return {
|
||||
...state,
|
||||
status: status === 'queueing' || status === 'round-countdown'
|
||||
? 'playing'
|
||||
: status === 'shop'
|
||||
? 'upgrade-choice'
|
||||
: status,
|
||||
targetGroup: 0,
|
||||
speedMultiplier: 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DungeonEncounter } from '../profile'
|
||||
import { chooseRandom } from './rules'
|
||||
|
||||
export function encounterThreat(encounter: DungeonEncounter) {
|
||||
return (
|
||||
encounter.maxHealth
|
||||
+ encounter.damage * 18
|
||||
+ encounter.tankDamage * 10
|
||||
+ encounter.partyDamage * 18
|
||||
)
|
||||
}
|
||||
|
||||
export function buildRoguelikeSegment<TMechanic extends string, TExtra extends object = object>({
|
||||
pool,
|
||||
stage,
|
||||
mechanics,
|
||||
trashCandidateCount,
|
||||
bossCandidateCount,
|
||||
healthScale,
|
||||
damageScale,
|
||||
partyDamageScale,
|
||||
idBase,
|
||||
bossDescription,
|
||||
bossName,
|
||||
fallbackBossFirst = false,
|
||||
extraFields,
|
||||
}: {
|
||||
pool: DungeonEncounter[]
|
||||
stage: number
|
||||
mechanics: TMechanic[]
|
||||
trashCandidateCount: (trashCount: number) => number
|
||||
bossCandidateCount: (bossCount: number) => number
|
||||
healthScale: number
|
||||
damageScale: number
|
||||
partyDamageScale: number
|
||||
idBase: number
|
||||
bossDescription: (mechanics: TMechanic[]) => string
|
||||
bossName?: (encounter: DungeonEncounter) => string
|
||||
fallbackBossFirst?: boolean
|
||||
extraFields?: (encounter: DungeonEncounter, isBoss: boolean, mechanics: TMechanic[]) => TExtra
|
||||
}) {
|
||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount(trashPool.length)), 2)
|
||||
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount(bossPool.length)), 1)[0]
|
||||
?? (fallbackBossFirst ? bossPool[0] : trashPool[0])
|
||||
?? trashPool[0]
|
||||
?? pool[0]
|
||||
const selectedMechanics = mechanics
|
||||
|
||||
return [...selectedTrash, selectedBoss].map((encounter, index) => {
|
||||
const isBoss = index === 2
|
||||
return {
|
||||
...encounter,
|
||||
id: idBase + stage * 10 + index,
|
||||
sequence: (stage - 1) * 3 + index + 1,
|
||||
isBoss,
|
||||
encounterType: isBoss ? 'boss' : 'trash',
|
||||
enemyName: isBoss ? (bossName?.(encounter) ?? `${encounter.enemyName} ${stage}`) : encounter.enemyName,
|
||||
description: isBoss ? bossDescription(selectedMechanics) : encounter.description,
|
||||
maxHealth: Math.round(encounter.maxHealth * healthScale),
|
||||
damage: Math.round(encounter.damage * damageScale),
|
||||
tankDamage: Math.round(encounter.tankDamage * damageScale),
|
||||
partyDamage: Math.round(encounter.partyDamage * partyDamageScale),
|
||||
lootTables: [],
|
||||
...(extraFields?.(encounter, isBoss, selectedMechanics) ?? {} as TExtra),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type PveCombatState = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
enemyHealth: number
|
||||
cooldowns: Record<string, number>
|
||||
elapsedTicks: number
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export function createPveCombatState<TEncounter extends { maxHealth: number }>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounter,
|
||||
enemyCount,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
encounter: TEncounter
|
||||
enemyCount: number
|
||||
}): PveCombatState {
|
||||
return {
|
||||
party: partyTemplate.map((member) => ({ ...member })),
|
||||
resource: maxResource,
|
||||
enemyHealth: encounter.maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPveRoguelikeRunStart<TEncounter extends { maxHealth: number }>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounters,
|
||||
initialEncounterIndex,
|
||||
enemyCount,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
encounters: readonly TEncounter[]
|
||||
initialEncounterIndex: number
|
||||
enemyCount: number
|
||||
}) {
|
||||
return {
|
||||
combatState: createPveCombatState({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounter: encounters[initialEncounterIndex],
|
||||
enemyCount,
|
||||
}),
|
||||
defaults: {
|
||||
roguelikeStage: 1,
|
||||
selectedId: partyTemplate[0].id,
|
||||
encounterIndex: initialEncounterIndex,
|
||||
status: 'playing' as const,
|
||||
paused: false,
|
||||
targetGroup: 0 as const,
|
||||
reward: null,
|
||||
rewardError: '',
|
||||
lootRolls: [],
|
||||
showEndLog: false,
|
||||
floatingTexts: [],
|
||||
roguelikeUpgrades: [],
|
||||
upgradeChoices: [],
|
||||
marathonBossesDefeated: 0,
|
||||
resourceSpent: 0,
|
||||
log: { text: 'A new run begins.', tone: 'system' as const },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type PvpRoguelikeStatus = 'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||
export type PvpRoguelikeLogTone = 'system' | 'heal' | 'danger' | 'loot'
|
||||
|
||||
export type PvpRoguelikeCombatOutcome =
|
||||
| {
|
||||
type: 'none'
|
||||
}
|
||||
| {
|
||||
type: 'lost'
|
||||
status: 'lost'
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
| {
|
||||
type: 'won'
|
||||
status: 'won'
|
||||
markOpponentDefeated: boolean
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
| {
|
||||
type: 'upgrade-choice'
|
||||
status: 'upgrade-choice'
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeCombatOutcome({
|
||||
playerAlive,
|
||||
opponentAlive,
|
||||
playerCleared,
|
||||
encounterIsBoss,
|
||||
encounterName,
|
||||
opponentDefeated,
|
||||
liveMatchActive,
|
||||
opponentLabel,
|
||||
}: {
|
||||
playerAlive: boolean
|
||||
opponentAlive: boolean
|
||||
playerCleared: boolean
|
||||
encounterIsBoss: boolean
|
||||
encounterName: string
|
||||
opponentDefeated: boolean
|
||||
liveMatchActive: boolean
|
||||
opponentLabel: string
|
||||
}): PvpRoguelikeCombatOutcome {
|
||||
if (!playerAlive) {
|
||||
return {
|
||||
type: 'lost',
|
||||
status: 'lost',
|
||||
log: { text: 'Your party fell first.', tone: 'danger' },
|
||||
}
|
||||
}
|
||||
if (!liveMatchActive && !opponentAlive && !opponentDefeated) {
|
||||
return {
|
||||
type: 'won',
|
||||
status: 'won',
|
||||
markOpponentDefeated: true,
|
||||
log: { text: `${opponentLabel} fell. Match complete.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
if (!playerCleared) return { type: 'none' }
|
||||
if (encounterIsBoss && opponentDefeated) {
|
||||
return {
|
||||
type: 'won',
|
||||
status: 'won',
|
||||
markOpponentDefeated: false,
|
||||
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'upgrade-choice',
|
||||
status: 'upgrade-choice',
|
||||
log: { text: `${encounterName} cleared. Choose your next edge.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeLiveSnapshot({
|
||||
currentStatus,
|
||||
opponentStatus,
|
||||
opponentAlive,
|
||||
alreadyLoggedOpponentDone,
|
||||
opponentName,
|
||||
}: {
|
||||
currentStatus: PvpRoguelikeStatus
|
||||
opponentStatus?: string
|
||||
opponentAlive?: boolean
|
||||
alreadyLoggedOpponentDone: boolean
|
||||
opponentName: string
|
||||
}) {
|
||||
if (currentStatus === 'won' || currentStatus === 'lost') return { type: 'none' as const }
|
||||
if ((opponentStatus === 'lost' || opponentAlive === false) && !alreadyLoggedOpponentDone) {
|
||||
return {
|
||||
type: 'won' as const,
|
||||
status: 'won' as const,
|
||||
markOpponentDefeated: true,
|
||||
log: { text: `${opponentName} fell. Match complete.`, tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
if (opponentStatus === 'won') {
|
||||
return {
|
||||
type: 'lost' as const,
|
||||
status: 'lost' as const,
|
||||
log: { text: `${opponentName} finished first.`, tone: 'danger' as const },
|
||||
}
|
||||
}
|
||||
return { type: 'none' as const }
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeUpgradeCompletion({
|
||||
clearedBoss,
|
||||
opponentDefeated,
|
||||
opponentLabel,
|
||||
}: {
|
||||
clearedBoss: boolean
|
||||
opponentDefeated: boolean
|
||||
opponentLabel: string
|
||||
}) {
|
||||
if (!clearedBoss || !opponentDefeated) return { type: 'none' as const }
|
||||
return {
|
||||
type: 'won' as const,
|
||||
status: 'won' as const,
|
||||
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
|
||||
export function nextPvpRoguelikeStage<TEncounter>({
|
||||
clearedBoss,
|
||||
stage,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
nextSegment,
|
||||
}: {
|
||||
clearedBoss: boolean
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encounters: readonly TEncounter[]
|
||||
nextSegment: readonly TEncounter[]
|
||||
}) {
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
return {
|
||||
type: 'complete' as const,
|
||||
status: 'won' as const,
|
||||
log: { text: 'No further encounters remain.', tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'next' as const,
|
||||
nextStage,
|
||||
nextEncounter,
|
||||
nextEncounterIndex: encounterIndex + 1,
|
||||
appendSegment: clearedBoss,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||
|
||||
export type PvpRoguelikeSetupSide<TBuff extends string, TDebuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
enemyHealth: number
|
||||
buffs: TBuff[]
|
||||
debuffs: TDebuff[]
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export type PvpRoguelikeLiveMatchSetup = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeStarterSide<TBuff extends string, TDebuff extends string>(
|
||||
partyTemplate: readonly PartyMember[],
|
||||
maxResource: number,
|
||||
): PvpRoguelikeSetupSide<TBuff, TDebuff> {
|
||||
return {
|
||||
party: partyTemplate.map((member) => ({ ...member })),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
enemyHealth: 0,
|
||||
buffs: [],
|
||||
debuffs: [],
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeMatchStart<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TEncounter extends { maxHealth: number },
|
||||
>({
|
||||
startStage,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
}: {
|
||||
startStage: number
|
||||
encounterPool: readonly TEncounter[]
|
||||
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
upgradeChoiceSeconds: number
|
||||
}) {
|
||||
const firstSegment = buildSegment(encounterPool, startStage)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const playerSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(partyTemplate, maxResource)
|
||||
const opponentSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(opponentPartyTemplate, maxResource)
|
||||
playerSide.enemyHealth = firstEncounter.maxHealth
|
||||
opponentSide.enemyHealth = firstEncounter.maxHealth
|
||||
|
||||
return {
|
||||
startStage,
|
||||
firstSegment,
|
||||
firstEncounter,
|
||||
playerSide,
|
||||
opponentSide,
|
||||
defaults: {
|
||||
encounterIndex: 0,
|
||||
elapsedTicks: 0,
|
||||
encountersCleared: 0,
|
||||
paused: false,
|
||||
targetGroup: 0 as const,
|
||||
upgradeTimeLeft: upgradeChoiceSeconds,
|
||||
liveUpgradePending: false,
|
||||
rewardError: '',
|
||||
showEndLog: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeLiveMatchStart<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TEncounter extends { maxHealth: number },
|
||||
>({
|
||||
match,
|
||||
side,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
message,
|
||||
}: {
|
||||
match: PvpMatchSnapshot<PvpRoguelikeSetupSide<TBuff, TDebuff>>
|
||||
side: PvpMatchSide
|
||||
encounterPool: readonly TEncounter[]
|
||||
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
upgradeChoiceSeconds: number
|
||||
message?: string
|
||||
}) {
|
||||
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSideId]
|
||||
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
}))
|
||||
const setup = createPvpRoguelikeMatchStart<TBuff, TDebuff, TEncounter>({
|
||||
startStage: match.startStage,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate: opponentTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
})
|
||||
const liveMatch: PvpRoguelikeLiveMatchSetup = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide: opponentSideId,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
|
||||
return {
|
||||
...setup,
|
||||
liveMatch,
|
||||
logText: message ?? `${opponent.characterName} found. Stage ${match.startStage} begins.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type PvpRoguelikeUpgradeSide<TBuff extends string, TDebuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
enemyHealth: number
|
||||
buffs: TBuff[]
|
||||
debuffs: TDebuff[]
|
||||
}
|
||||
|
||||
function removeRandomItem<T>(items: readonly T[], random = Math.random) {
|
||||
if (items.length === 0) return [...items]
|
||||
const removedIndex = Math.floor(random() * items.length)
|
||||
return items.filter((_, index) => index !== removedIndex)
|
||||
}
|
||||
|
||||
export function recoverPartyForNextPvpRoguelikeEncounter(party: readonly PartyMember[]) {
|
||||
return party.map((member) => ({
|
||||
...member,
|
||||
health: member.maxHealth,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function applyPvpRoguelikeUpgradeChoice<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||
>({
|
||||
side,
|
||||
buffId,
|
||||
incomingDebuffId,
|
||||
reviveBuffId,
|
||||
random,
|
||||
}: {
|
||||
side: TSide
|
||||
buffId: TBuff
|
||||
incomingDebuffId?: TDebuff
|
||||
reviveBuffId: TBuff
|
||||
random?: () => number
|
||||
}) {
|
||||
const reviveSelected = buffId === reviveBuffId
|
||||
const nextBuffs = reviveSelected ? side.buffs : [...side.buffs, buffId]
|
||||
const nextDebuffs = incomingDebuffId ? [...side.debuffs, incomingDebuffId] : side.debuffs
|
||||
return {
|
||||
...side,
|
||||
buffs: nextBuffs,
|
||||
debuffs: reviveSelected ? removeRandomItem(nextDebuffs, random) : nextDebuffs,
|
||||
}
|
||||
}
|
||||
|
||||
export function preparePvpRoguelikeNextEncounter<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||
>({
|
||||
side,
|
||||
maxResource,
|
||||
enemyMaxHealth,
|
||||
}: {
|
||||
side: TSide
|
||||
maxResource: number
|
||||
enemyMaxHealth: number
|
||||
}) {
|
||||
return {
|
||||
...side,
|
||||
party: recoverPartyForNextPvpRoguelikeEncounter(side.party),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
enemyHealth: enemyMaxHealth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { applyCastStateUpdate, type CastStateFields } from './combatStateTransitions'
|
||||
import { canCastSpell } from './spellCasting'
|
||||
import { applySpellEffectProfile, type SpellEffectProfile, type SpellTargetPlan } from './spellEffects'
|
||||
|
||||
export function applyPvpSpellCast<TState extends CastStateFields<PartyMember[]>>({
|
||||
current,
|
||||
spell,
|
||||
targetId,
|
||||
resourceCost,
|
||||
targetPlan,
|
||||
profile,
|
||||
setCurrent,
|
||||
emitFloatingHeal,
|
||||
cooldownMultiplier = 1,
|
||||
cooldowns,
|
||||
freeCast,
|
||||
}: {
|
||||
current: TState
|
||||
spell: Spell
|
||||
targetId: string
|
||||
resourceCost: number
|
||||
targetPlan: SpellTargetPlan
|
||||
profile: SpellEffectProfile
|
||||
setCurrent: (next: TState) => void
|
||||
emitFloatingHeal: (memberId: string, value: number) => void
|
||||
cooldownMultiplier?: number
|
||||
cooldowns?: Record<string, number>
|
||||
freeCast?: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
}
|
||||
}) {
|
||||
if (!canCastSpell(spell, current.resource, current.cooldowns, resourceCost)) return false
|
||||
const target = current.party.find((member) => member.id === targetId && member.health > 0)
|
||||
if (!target) return false
|
||||
|
||||
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
|
||||
party: current.party,
|
||||
spell,
|
||||
targetId,
|
||||
plan: targetPlan,
|
||||
profile,
|
||||
})
|
||||
floatingHeals.forEach((event) => emitFloatingHeal(event.memberId, event.value))
|
||||
|
||||
const nextState = applyCastStateUpdate({
|
||||
current,
|
||||
party: nextParty,
|
||||
cooldowns,
|
||||
spell,
|
||||
resourceCost,
|
||||
cooldownMultiplier,
|
||||
freeCast,
|
||||
})
|
||||
setCurrent(nextState)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DungeonReward } from '../profile'
|
||||
|
||||
export type RewardSummaryBase = {
|
||||
experienceGained: number
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
levelsGained: number
|
||||
talentPointsGained: number
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
}
|
||||
|
||||
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||
bossesKilled: number
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||
return {
|
||||
experienceGained: 0,
|
||||
previousLevel: null,
|
||||
newLevel: null,
|
||||
levelsGained: 0,
|
||||
talentPointsGained: 0,
|
||||
unlockedAbilities: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyPvpRunSummary(): PvpRunRewardSummary {
|
||||
return {
|
||||
bossesKilled: 0,
|
||||
...createEmptyRewardSummary(),
|
||||
loot: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeUnlockedAbilities(
|
||||
current: DungeonReward['unlockedAbilities'],
|
||||
next: DungeonReward['unlockedAbilities'],
|
||||
) {
|
||||
const unlockedById = new Map(current.map((ability) => [ability.id, ability]))
|
||||
next.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||
return Array.from(unlockedById.values())
|
||||
}
|
||||
|
||||
export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
||||
current: TSummary,
|
||||
reward: DungeonReward,
|
||||
) {
|
||||
return {
|
||||
...current,
|
||||
experienceGained: current.experienceGained + reward.experienceGained,
|
||||
previousLevel: current.previousLevel ?? reward.previousLevel,
|
||||
newLevel: reward.newLevel,
|
||||
levelsGained: current.levelsGained + reward.levelsGained,
|
||||
talentPointsGained: current.talentPointsGained + reward.talentPointsGained,
|
||||
unlockedAbilities: mergeUnlockedAbilities(current.unlockedAbilities, reward.unlockedAbilities),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePvpRunRewardSummary(
|
||||
current: PvpRunRewardSummary,
|
||||
reward: DungeonReward,
|
||||
{ bossKilled }: { bossKilled: boolean },
|
||||
) {
|
||||
return {
|
||||
...mergeDungeonRewardSummary(current, reward),
|
||||
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
||||
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spell } from '../game'
|
||||
import { createStackCounts, summarizeStackCounts } from './stackCounts'
|
||||
|
||||
export type UpgradeChoice<T extends string> = {
|
||||
id: T
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function slotLabel(slot: string, spells: Spell[], labelMode: 'ability' | 'slot') {
|
||||
const spell = spells.find((candidate) => candidate.key === slot)
|
||||
if (labelMode === 'ability' && spell) return spell.name
|
||||
return `Slot ${slot}`
|
||||
}
|
||||
|
||||
export function buildSelfSlotUpgradeChoices<T extends string>({
|
||||
slots,
|
||||
spells,
|
||||
labelMode,
|
||||
idPrefix = 'slot',
|
||||
}: {
|
||||
slots: readonly string[]
|
||||
spells: Spell[]
|
||||
labelMode: 'ability' | 'slot'
|
||||
idPrefix?: string
|
||||
}) {
|
||||
return slots.flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
id: `${idPrefix}${slot}-extra-target` as T,
|
||||
name: `${label}: +1 target`,
|
||||
description: `${label} affects 1 additional ally when possible.`,
|
||||
},
|
||||
{
|
||||
id: `${idPrefix}${slot}-cost-down` as T,
|
||||
name: `${label}: -25% cost`,
|
||||
description: `${label} costs 25% less resource.`,
|
||||
},
|
||||
{
|
||||
id: `${idPrefix}${slot}-cooldown-down` as T,
|
||||
name: `${label}: -25% cooldown`,
|
||||
description: `${label} recharges 25% faster.`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildOpponentSlotDebuffChoices<T extends string>({
|
||||
slots,
|
||||
spells,
|
||||
labelMode,
|
||||
}: {
|
||||
slots: readonly string[]
|
||||
spells: Spell[]
|
||||
labelMode: 'ability' | 'slot'
|
||||
}) {
|
||||
return slots.flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
id: `opp-slot${slot}-cost-up` as T,
|
||||
name: `${label}: +25% cost`,
|
||||
description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
|
||||
},
|
||||
{
|
||||
id: `opp-slot${slot}-cooldown-up` as T,
|
||||
name: `${label}: +25% cooldown`,
|
||||
description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeChoiceStacks<T extends string>(
|
||||
items: T[],
|
||||
catalog: Array<UpgradeChoice<T>>,
|
||||
emptyLabel = '',
|
||||
) {
|
||||
return summarizeStackCounts(createStackCounts(items), catalog, emptyLabel)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import type { Ability } from '../profile'
|
||||
import { createStackCounts, stackCount } from './stackCounts'
|
||||
|
||||
export const DEFAULT_TICK_MS = 700
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function chooseRandom<T>(items: T[], count: number) {
|
||||
const pool = [...items]
|
||||
const result: T[] = []
|
||||
while (pool.length > 0 && result.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
result.push(pool.splice(index, 1)[0])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function effectiveMaxHealth(member: PartyMember) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)),
|
||||
)
|
||||
}
|
||||
|
||||
export function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||
}
|
||||
|
||||
export function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
export function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function effectId(prefix: string) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||
}
|
||||
|
||||
export function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
const nextEffect = {
|
||||
id: effectId(spell.id),
|
||||
spellId: spell.id,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
power: Math.max(1, Math.round(spell.power / 2)),
|
||||
}
|
||||
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||
return [...currentEffects, nextEffect]
|
||||
}
|
||||
|
||||
export function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: effectId(spell.id),
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||
return (effects ?? [])
|
||||
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||
.filter((effect) => effect.ticks > 0)
|
||||
}
|
||||
|
||||
export function formatEffectTime(ticks: number, tickMs = DEFAULT_TICK_MS) {
|
||||
const seconds = (ticks * tickMs) / 1000
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function buffStacks<T extends string>(items: readonly T[], id: T) {
|
||||
return stackCount(createStackCounts(items), id)
|
||||
}
|
||||
|
||||
export function createLogEntry(nextLogId: { current: number }, text: string, tone: 'system' | 'heal' | 'danger' | 'loot') {
|
||||
return { id: nextLogId.current++, text, tone }
|
||||
}
|
||||
|
||||
export function toCombatSpell(ability: Ability, key: string, healingPower = 0): Spell {
|
||||
const kinds: Record<string, Spell['kind']> = {
|
||||
direct_heal: 'direct',
|
||||
direct_hot: 'direct',
|
||||
heal_over_time: 'hot',
|
||||
party_heal: 'group',
|
||||
party_hot: 'group',
|
||||
party_absorb: 'group',
|
||||
absorb: 'shield',
|
||||
damage_reduction: 'damage_reduction',
|
||||
bounce_heal: 'bounce_heal',
|
||||
cleanse: 'cleanse',
|
||||
}
|
||||
return {
|
||||
id: String(ability.id),
|
||||
key,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
cost: ability.cost,
|
||||
cooldown: ability.cooldown,
|
||||
power: ability.power + healingPower,
|
||||
glyph: ability.glyph,
|
||||
kind: kinds[ability.spellType] ?? 'direct',
|
||||
effectType: ability.spellType,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetParty(partyTemplate: PartyMember[]) {
|
||||
return partyTemplate.map((member) => ({
|
||||
...member,
|
||||
health: member.maxHealth,
|
||||
shield: 0,
|
||||
hotTicks: 0,
|
||||
hotEffects: undefined,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
damageReductionTicks: undefined,
|
||||
bounceHeals: undefined,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Spell } from '../game'
|
||||
|
||||
export function canCastSpell(
|
||||
spell: Spell,
|
||||
resource: number,
|
||||
cooldowns: Record<string, number>,
|
||||
resourceCost: number,
|
||||
) {
|
||||
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0
|
||||
}
|
||||
|
||||
export function putSpellOnCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spell: Spell,
|
||||
cooldownMultiplier = 1,
|
||||
) {
|
||||
return {
|
||||
...cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game'
|
||||
import { addBounceHeal, addHotEffect } from './rules'
|
||||
|
||||
export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction'
|
||||
|
||||
export type SpellTargetPlan = {
|
||||
directTargets: Set<string>
|
||||
hotTargets: Set<string>
|
||||
shieldTargets: Set<string>
|
||||
damageReductionTargets: Set<string>
|
||||
groupTargets: Set<string>
|
||||
}
|
||||
|
||||
export type ExtraTargetMode = Partial<Record<Spell['kind'], SpellTargetBucket>>
|
||||
|
||||
function hasBlockedId(blockedIds: Iterable<string>, id: string) {
|
||||
if (blockedIds instanceof Set) return blockedIds.has(id)
|
||||
for (const blockedId of blockedIds) {
|
||||
if (blockedId === id) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable<string>) {
|
||||
let target: PartyMember | undefined
|
||||
let targetRatio = Number.POSITIVE_INFINITY
|
||||
for (const member of party) {
|
||||
if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue
|
||||
const healthRatio = member.health / member.maxHealth
|
||||
if (healthRatio < targetRatio) {
|
||||
target = member
|
||||
targetRatio = healthRatio
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export function addLowestHealthExtraTarget(
|
||||
plan: SpellTargetPlan,
|
||||
party: PartyMember[],
|
||||
bucket: SpellTargetBucket,
|
||||
) {
|
||||
const targetSet = bucket === 'direct'
|
||||
? plan.directTargets
|
||||
: bucket === 'hot'
|
||||
? plan.hotTargets
|
||||
: bucket === 'shield'
|
||||
? plan.shieldTargets
|
||||
: plan.damageReductionTargets
|
||||
const extra = lowestHealthExtraTarget(party, targetSet)
|
||||
if (extra) targetSet.add(extra.id)
|
||||
}
|
||||
|
||||
export function buildSpellTargetPlan({
|
||||
party,
|
||||
spell,
|
||||
targetId,
|
||||
extraTargets,
|
||||
directTarget,
|
||||
hotTarget,
|
||||
shieldTarget,
|
||||
damageReductionTarget,
|
||||
groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS,
|
||||
extraTargetMode,
|
||||
beforeExtraTargetBuckets = [],
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spell: Spell
|
||||
targetId: string
|
||||
extraTargets: number
|
||||
directTarget: boolean
|
||||
hotTarget: boolean
|
||||
shieldTarget: boolean
|
||||
damageReductionTarget?: boolean
|
||||
groupTargetCount?: number
|
||||
extraTargetMode: ExtraTargetMode
|
||||
beforeExtraTargetBuckets?: SpellTargetBucket[]
|
||||
}): SpellTargetPlan {
|
||||
const plan: SpellTargetPlan = {
|
||||
directTargets: new Set(directTarget ? [targetId] : []),
|
||||
hotTargets: new Set(hotTarget ? [targetId] : []),
|
||||
shieldTargets: new Set(shieldTarget ? [targetId] : []),
|
||||
damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []),
|
||||
groupTargets: new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(party, groupTargetCount).map((member) => member.id)
|
||||
: [],
|
||||
),
|
||||
}
|
||||
|
||||
beforeExtraTargetBuckets.forEach((bucket) => {
|
||||
addLowestHealthExtraTarget(plan, party, bucket)
|
||||
})
|
||||
|
||||
const bucket = extraTargetMode[spell.kind] ?? 'direct'
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
addLowestHealthExtraTarget(plan, party, bucket)
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
export function clearNegativeEffects(member: PartyMember) {
|
||||
return {
|
||||
...member,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export type FloatingHealEvent = {
|
||||
memberId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export type SpellEffectProfile = {
|
||||
/**
|
||||
* Profiles keep mode-specific spell math out of screen components. PvE uses
|
||||
* stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium
|
||||
* also applies dampening through the power callbacks below.
|
||||
*/
|
||||
modeName: string
|
||||
heal: (member: PartyMember, power: number, multiplier: number) => number
|
||||
healingMultiplier: (member: PartyMember) => number
|
||||
power: {
|
||||
direct: (spell: Spell) => number
|
||||
cleanse: (spell: Spell) => number
|
||||
groupHeal: (spell: Spell) => number
|
||||
groupAbsorb: (spell: Spell) => number
|
||||
shield: (sourcePower: number, strength?: number) => number
|
||||
}
|
||||
hot: {
|
||||
mode: 'effects' | 'ticks'
|
||||
defaultTicks: number
|
||||
groupTicks: number
|
||||
radianceTicks: number
|
||||
merge: 'replace' | 'max'
|
||||
groupMerge?: 'replace' | 'max'
|
||||
}
|
||||
effects: {
|
||||
renewSpell?: Spell | null
|
||||
shieldSpell?: Spell | null
|
||||
groupAbsorbOnly: (spell: Spell) => boolean
|
||||
groupHotOnly: (spell: Spell) => boolean
|
||||
groupAppliesShield: (spell: Spell) => boolean
|
||||
groupAppliesHot: (spell: Spell) => boolean
|
||||
shieldAppliesHot: (spell: Spell) => boolean
|
||||
hotSpellForDirect: (spell: Spell) => Spell
|
||||
}
|
||||
ratios: {
|
||||
groupShield: number
|
||||
directShield: number
|
||||
}
|
||||
damageReductionTicks: number
|
||||
floatingHeals: {
|
||||
group: boolean
|
||||
direct: boolean
|
||||
cleanse: boolean
|
||||
}
|
||||
bounceHeals: boolean
|
||||
}
|
||||
|
||||
function applyHot(
|
||||
member: PartyMember,
|
||||
spell: Spell,
|
||||
profile: SpellEffectProfile,
|
||||
ticks = profile.hot.defaultTicks,
|
||||
merge = profile.hot.merge,
|
||||
) {
|
||||
if (profile.hot.mode === 'effects') {
|
||||
return {
|
||||
...member,
|
||||
hotTicks: 0,
|
||||
hotEffects: addHotEffect(member, spell, ticks),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...member,
|
||||
hotTicks: merge === 'max'
|
||||
? Math.max(member.hotTicks, ticks)
|
||||
: ticks,
|
||||
}
|
||||
}
|
||||
|
||||
function recordFloatingHeal(
|
||||
events: FloatingHealEvent[],
|
||||
enabled: boolean,
|
||||
member: PartyMember,
|
||||
nextHealth: number,
|
||||
) {
|
||||
if (enabled && nextHealth > member.health) {
|
||||
events.push({ memberId: member.id, value: nextHealth - member.health })
|
||||
}
|
||||
}
|
||||
|
||||
export function applySpellEffectProfile({
|
||||
party,
|
||||
spell,
|
||||
targetId,
|
||||
plan,
|
||||
profile,
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spell: Spell
|
||||
targetId: string
|
||||
plan: SpellTargetPlan
|
||||
profile: SpellEffectProfile
|
||||
}) {
|
||||
const floatingHeals: FloatingHealEvent[] = []
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
|
||||
if (spell.kind === 'group') {
|
||||
if (!plan.groupTargets.has(member.id)) return member
|
||||
const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell)
|
||||
if (profile.effects.groupHotOnly(spell)) {
|
||||
return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||
}
|
||||
|
||||
const power = isGroupAbsorb
|
||||
? profile.power.groupAbsorb(spell)
|
||||
: profile.power.groupHeal(spell)
|
||||
const nextHealth = isGroupAbsorb
|
||||
? member.health
|
||||
: profile.heal(member, power, profile.healingMultiplier(member))
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth)
|
||||
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: profile.effects.groupAppliesShield(spell)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
isGroupAbsorb
|
||||
? power
|
||||
: profile.power.shield(
|
||||
profile.effects.shieldSpell?.power ?? spell.power,
|
||||
profile.ratios.groupShield,
|
||||
),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge)
|
||||
} else if (profile.effects.groupAppliesHot(spell)) {
|
||||
nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
if (
|
||||
!plan.directTargets.has(member.id)
|
||||
&& !plan.hotTargets.has(member.id)
|
||||
&& !plan.shieldTargets.has(member.id)
|
||||
&& !plan.damageReductionTargets.has(member.id)
|
||||
&& !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals)
|
||||
) return member
|
||||
|
||||
if (spell.kind === 'shield') {
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
shield: Math.max(member.shield, profile.power.shield(spell.power)),
|
||||
}
|
||||
if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
if (spell.kind === 'damage_reduction') {
|
||||
return {
|
||||
...member,
|
||||
damageReductionTicks: profile.hot.merge === 'max'
|
||||
? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks)
|
||||
: profile.damageReductionTicks,
|
||||
hotTicks: plan.hotTargets.has(member.id)
|
||||
? (profile.hot.merge === 'max'
|
||||
? Math.max(member.hotTicks, profile.hot.defaultTicks)
|
||||
: profile.hot.defaultTicks)
|
||||
: member.hotTicks,
|
||||
}
|
||||
}
|
||||
|
||||
if (spell.kind === 'bounce_heal' && profile.bounceHeals) {
|
||||
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||
}
|
||||
|
||||
if (spell.kind === 'cleanse') {
|
||||
const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member))
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth)
|
||||
let nextMember: PartyMember = {
|
||||
...clearNegativeEffects(member),
|
||||
health: nextHealth,
|
||||
shield: plan.shieldTargets.has(member.id)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (plan.hotTargets.has(member.id)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
const nextHealth = plan.directTargets.has(member.id)
|
||||
? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member))
|
||||
: member.health
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth)
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: plan.shieldTargets.has(member.id)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
profile.power.shield(
|
||||
profile.effects.shieldSpell?.power ?? spell.power,
|
||||
profile.ratios.directShield,
|
||||
),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (plan.hotTargets.has(member.id)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile)
|
||||
}
|
||||
return nextMember
|
||||
})
|
||||
|
||||
return { party: nextParty, floatingHeals }
|
||||
}
|
||||
|
||||
export function advanceFreeCastProgress({
|
||||
enabled,
|
||||
wasReady,
|
||||
castsTowardFree,
|
||||
}: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
castsTowardFree: number
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return {
|
||||
castsTowardFree,
|
||||
freeCastReady: wasReady,
|
||||
}
|
||||
}
|
||||
if (wasReady) {
|
||||
return {
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
const nextCasts = castsTowardFree + 1
|
||||
return {
|
||||
castsTowardFree: nextCasts >= 5 ? 0 : nextCasts,
|
||||
freeCastReady: nextCasts >= 5,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spell } from '../game'
|
||||
import { stackCount, type StackCounts } from './stackCounts'
|
||||
|
||||
export type SlotModifier<T extends string> = {
|
||||
stacks: StackCounts<T>
|
||||
id: (slot: string) => T
|
||||
multiplier?: number
|
||||
}
|
||||
|
||||
export type StackModifier<T extends string> = {
|
||||
stacks: StackCounts<T>
|
||||
id: T
|
||||
multiplier?: number
|
||||
}
|
||||
|
||||
function slotKey(spell: Spell) {
|
||||
return String(spell.key)
|
||||
}
|
||||
|
||||
function slotModifierStacks<T extends string>(spell: Spell, modifier?: SlotModifier<T>) {
|
||||
return modifier ? stackCount(modifier.stacks, modifier.id(slotKey(spell))) : 0
|
||||
}
|
||||
|
||||
function stackModifierStacks<T extends string>(modifier?: StackModifier<T>) {
|
||||
return modifier ? stackCount(modifier.stacks, modifier.id) : 0
|
||||
}
|
||||
|
||||
function stackMultiplier<T extends string>(modifier?: StackModifier<T>) {
|
||||
if (!modifier) return 1
|
||||
return (modifier.multiplier ?? 1.25) ** stackModifierStacks(modifier)
|
||||
}
|
||||
|
||||
export function spellSlotMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||
spell: Spell,
|
||||
positive?: SlotModifier<TPositive>,
|
||||
negative?: SlotModifier<TNegative>,
|
||||
) {
|
||||
const positiveMultiplier = (positive?.multiplier ?? 0.75) ** slotModifierStacks(spell, positive)
|
||||
const negativeMultiplier = (negative?.multiplier ?? 1.25) ** slotModifierStacks(spell, negative)
|
||||
return positiveMultiplier * negativeMultiplier
|
||||
}
|
||||
|
||||
export function spellCooldownMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||
spell: Spell,
|
||||
cooldownDown?: SlotModifier<TPositive>,
|
||||
cooldownUp?: SlotModifier<TNegative>,
|
||||
) {
|
||||
return spellSlotMultiplier(spell, cooldownDown, cooldownUp)
|
||||
}
|
||||
|
||||
export function spellResourceCost<TPositive extends string, TNegative extends string = never>({
|
||||
spell,
|
||||
costDown,
|
||||
costUp,
|
||||
freeCastReady = false,
|
||||
freeCast,
|
||||
}: {
|
||||
spell: Spell
|
||||
costDown?: SlotModifier<TPositive>
|
||||
costUp?: SlotModifier<TNegative>
|
||||
freeCastReady?: boolean
|
||||
freeCast?: StackModifier<TPositive>
|
||||
}) {
|
||||
if (freeCastReady && stackModifierStacks(freeCast) > 0) return 0
|
||||
return Math.ceil(spell.cost * spellSlotMultiplier(spell, costDown, costUp))
|
||||
}
|
||||
|
||||
export function spellExtraTargets<T extends string>(
|
||||
spell: Spell,
|
||||
extraTarget: SlotModifier<T>,
|
||||
) {
|
||||
return slotModifierStacks(spell, extraTarget)
|
||||
}
|
||||
|
||||
export function spellPowerMultiplier<T extends string>(modifier: StackModifier<T>) {
|
||||
return stackMultiplier(modifier)
|
||||
}
|
||||
|
||||
export function hasSpellStack<T extends string>(modifier: StackModifier<T>) {
|
||||
return stackModifierStacks(modifier) > 0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type StackCounts<T extends string> = ReadonlyMap<T, number>
|
||||
|
||||
export function createStackCounts<T extends string>(items: readonly T[]): StackCounts<T> {
|
||||
const counts = new Map<T, number>()
|
||||
items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
|
||||
return counts
|
||||
}
|
||||
|
||||
export function stackCount<T extends string>(counts: StackCounts<T>, id: T) {
|
||||
return counts.get(id) ?? 0
|
||||
}
|
||||
|
||||
export function summarizeStackCounts<T extends string, TChoice extends { id: T; name: string }>(
|
||||
counts: StackCounts<T>,
|
||||
catalog: readonly TChoice[],
|
||||
emptyLabel = '',
|
||||
) {
|
||||
const summary = Array.from(counts.entries())
|
||||
.map(([id, count]) => {
|
||||
const label = catalog.find((choice) => choice.id === id)?.name ?? id
|
||||
return count > 1 ? `${label} x${count}` : label
|
||||
})
|
||||
.join(', ')
|
||||
return summary || emptyLabel
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type StadiumRoundOutcome = 'win' | 'loss' | 'tie'
|
||||
export type StadiumWins = {
|
||||
player: number
|
||||
opponent: number
|
||||
}
|
||||
export type StadiumRoundStatus = 'playing' | 'shop' | 'won' | 'lost'
|
||||
export type StadiumExperienceMode =
|
||||
| 'pvp-stadium-round-win-quarter-level'
|
||||
| 'pvp-stadium-round-loss-tenth-level'
|
||||
| 'pvp-stadium-match-half-level'
|
||||
|
||||
export const DEFAULT_STADIUM_WIN_ROUNDS = 3
|
||||
|
||||
export function stadiumShopPointsForOutcome(outcome: StadiumRoundOutcome, side: 'player' | 'opponent') {
|
||||
if (side === 'player') return outcome === 'loss' ? 4 : 3
|
||||
return outcome === 'win' ? 4 : 3
|
||||
}
|
||||
|
||||
export function resolveStadiumRound({
|
||||
outcome,
|
||||
roundIndex,
|
||||
wins,
|
||||
winRounds = DEFAULT_STADIUM_WIN_ROUNDS,
|
||||
}: {
|
||||
outcome: StadiumRoundOutcome
|
||||
roundIndex: number
|
||||
wins: StadiumWins
|
||||
winRounds?: number
|
||||
}) {
|
||||
const roundExperienceMode: StadiumExperienceMode = outcome === 'loss'
|
||||
? 'pvp-stadium-round-loss-tenth-level'
|
||||
: 'pvp-stadium-round-win-quarter-level'
|
||||
const logTone = outcome === 'loss' ? 'danger' as const : 'loot' as const
|
||||
const nextWins = {
|
||||
player: wins.player + (outcome === 'win' ? 1 : 0),
|
||||
opponent: wins.opponent + (outcome === 'loss' ? 1 : 0),
|
||||
}
|
||||
const status: StadiumRoundStatus = nextWins.player >= winRounds
|
||||
? 'won'
|
||||
: nextWins.opponent >= winRounds
|
||||
? 'lost'
|
||||
: 'shop'
|
||||
|
||||
return {
|
||||
nextWins,
|
||||
status,
|
||||
playerRoundStatus: status,
|
||||
roundExperience: {
|
||||
key: `round-${roundIndex}-${outcome}`,
|
||||
mode: roundExperienceMode,
|
||||
},
|
||||
matchExperience: status === 'won'
|
||||
? { key: 'match-win', mode: 'pvp-stadium-match-half-level' as StadiumExperienceMode }
|
||||
: null,
|
||||
log: {
|
||||
text: outcome === 'win'
|
||||
? `Round ${roundIndex} won.`
|
||||
: outcome === 'loss'
|
||||
? `Round ${roundIndex} lost.`
|
||||
: `Round ${roundIndex} tied.`,
|
||||
tone: logTone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseStadiumCpuPurchases<TId extends string, TBuff extends { id: TId; cost: number }>(
|
||||
catalog: readonly TBuff[],
|
||||
points: number,
|
||||
random = Math.random,
|
||||
) {
|
||||
let remaining = points
|
||||
const purchases: TId[] = []
|
||||
while (remaining > 0) {
|
||||
const affordable = catalog.filter((buff) => buff.cost <= remaining)
|
||||
if (affordable.length === 0) break
|
||||
const selected = affordable[Math.floor(random() * affordable.length)]
|
||||
purchases.push(selected.id)
|
||||
remaining -= selected.cost
|
||||
}
|
||||
return purchases
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||
import { resetParty } from './rules'
|
||||
import type { StadiumRoundOutcome, StadiumWins } from './stadiumLifecycle'
|
||||
|
||||
export type StadiumSetupSide<TBuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
buffs: TBuff[]
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
survivalSeconds: number
|
||||
dampeningPercent: number
|
||||
roundIndex: number
|
||||
roundWins: number
|
||||
roundStatus: 'playing' | 'shop' | 'won' | 'lost'
|
||||
lastRoundOutcome?: StadiumRoundOutcome
|
||||
shopReady: boolean
|
||||
}
|
||||
|
||||
export type StadiumLiveMatchSetup = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
export function createStadiumStarterSide<TBuff extends string>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
roundIndex,
|
||||
buffs = [],
|
||||
roundWins = 0,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
roundIndex: number
|
||||
buffs?: TBuff[]
|
||||
roundWins?: number
|
||||
}): StadiumSetupSide<TBuff> {
|
||||
return {
|
||||
party: resetParty([...partyTemplate]),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
buffs,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
survivalSeconds: 0,
|
||||
dampeningPercent: 0,
|
||||
roundIndex,
|
||||
roundWins,
|
||||
roundStatus: 'playing',
|
||||
shopReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createStadiumMatchStart<TBuff extends string>({
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
}) {
|
||||
const roundIndex = 1
|
||||
return {
|
||||
playerSide: createStadiumStarterSide<TBuff>({ partyTemplate, maxResource, roundIndex }),
|
||||
opponentSide: createStadiumStarterSide<TBuff>({ partyTemplate: opponentPartyTemplate, maxResource, roundIndex }),
|
||||
defaults: {
|
||||
roundIndex,
|
||||
roundWins: { player: 0, opponent: 0 } satisfies StadiumWins,
|
||||
elapsedTicks: 0,
|
||||
shopPoints: 0,
|
||||
shopReady: false,
|
||||
paused: false,
|
||||
rewardError: '',
|
||||
showEndLog: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createStadiumLiveMatchStart<TBuff extends string>({
|
||||
match,
|
||||
side,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
message,
|
||||
}: {
|
||||
match: PvpMatchSnapshot<StadiumSetupSide<TBuff>>
|
||||
side: PvpMatchSide
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
message?: string
|
||||
}) {
|
||||
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSideId]
|
||||
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
}))
|
||||
const setup = createStadiumMatchStart<TBuff>({
|
||||
partyTemplate,
|
||||
opponentPartyTemplate: opponentTemplate,
|
||||
maxResource,
|
||||
})
|
||||
const liveMatch: StadiumLiveMatchSetup = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide: opponentSideId,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
|
||||
return {
|
||||
...setup,
|
||||
liveMatch,
|
||||
logText: message ?? `${opponent.characterName} found. Stadium begins.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type NavigateAction = 'navigateLeft' | 'navigateRight' | 'navigateUp' | 'navigateDown'
|
||||
|
||||
type TargetOptions = {
|
||||
livingOnly?: boolean
|
||||
}
|
||||
|
||||
function canTarget(member: PartyMember, options: TargetOptions) {
|
||||
return !options.livingOnly || member.health > 0
|
||||
}
|
||||
|
||||
export function selectRelativePartyTarget(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
direction: -1 | 1,
|
||||
options: TargetOptions = {},
|
||||
) {
|
||||
const candidates = party.filter((member) => canTarget(member, options))
|
||||
if (candidates.length === 0) return null
|
||||
const currentIndex = candidates.findIndex((member) => member.id === selectedId)
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + direction + candidates.length) % candidates.length
|
||||
return candidates[nextIndex]?.id ?? null
|
||||
}
|
||||
|
||||
export function selectDirectionalPartyTarget(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
action: NavigateAction,
|
||||
columns: number,
|
||||
options: TargetOptions = {},
|
||||
) {
|
||||
const currentIndex = party.findIndex((member) => member.id === selectedId)
|
||||
if (currentIndex < 0) {
|
||||
return party.find((member) => canTarget(member, options))?.id ?? null
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / columns)
|
||||
const currentColumn = currentIndex % columns
|
||||
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||
const candidate = party
|
||||
.map((member, index) => ({
|
||||
member,
|
||||
index,
|
||||
row: Math.floor(index / columns),
|
||||
column: index % columns,
|
||||
}))
|
||||
.filter(({ member, index, row, column }) => {
|
||||
if (!canTarget(member, options) || index === currentIndex) return false
|
||||
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||
if (action === 'navigateUp') return row < currentRow
|
||||
return row > currentRow
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftPrimary = horizontal
|
||||
? Math.abs(left.column - currentColumn)
|
||||
: Math.abs(left.row - currentRow)
|
||||
const rightPrimary = horizontal
|
||||
? Math.abs(right.column - currentColumn)
|
||||
: Math.abs(right.row - currentRow)
|
||||
const leftSecondary = horizontal ? 0 : Math.abs(left.column - currentColumn)
|
||||
const rightSecondary = horizontal ? 0 : Math.abs(right.column - currentColumn)
|
||||
return leftPrimary - rightPrimary || leftSecondary - rightSecondary
|
||||
})[0]
|
||||
return candidate?.member.id ?? null
|
||||
}
|
||||
|
||||
export function selectDirectPartyTarget(
|
||||
party: PartyMember[],
|
||||
slot: number,
|
||||
options: TargetOptions & {
|
||||
targetGroup?: number
|
||||
groupSize?: number
|
||||
} = {},
|
||||
) {
|
||||
const groupSize = options.groupSize ?? 6
|
||||
const index = slot + (options.targetGroup ?? 0) * groupSize
|
||||
const member = party[index]
|
||||
return member && canTarget(member, options) ? member.id : null
|
||||
}
|
||||
|
||||
export function nextTargetGroupSelection(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
currentGroup: number,
|
||||
groupSize = 6,
|
||||
) {
|
||||
const groupCount = Math.max(1, Math.ceil(party.length / groupSize))
|
||||
const nextGroup = (currentGroup + 1) % groupCount
|
||||
const selectedIndex = party.findIndex((member) => member.id === selectedId)
|
||||
const slot = selectedIndex < 0 ? 0 : selectedIndex % groupSize
|
||||
return {
|
||||
group: nextGroup,
|
||||
selectedId: party[slot + nextGroup * groupSize]?.id ?? null,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user