/** * Combat sandbox: monsters, melee, resources and experience, at the game's tick * rate. * * The module is deliberately free of rendering and input concerns. It advances * one 25 Hz tick at a time from a small input record and a collision predicate, * which is what allows the whole system to be simulated headlessly in a test — * combat that can only be verified by looking at the screen is combat that will * be broken most of the time. * * Numbers come from data tables (see `tables.ts`), never from constants baked * into the AI: a monster's health, damage, speed and aggro range are its table * row, so swapping in the real `MonStats.txt` changes behaviour without touching * code. * * What this is *not*: it is not Diablo II's combat model. Real damage involves * attack rating versus defence, hit recovery, block, resistances, elemental * damage and per-skill formulas. This is the skeleton those formulas plug into — * the states, cooldowns, resource pools and event stream are the parts that need * to exist first, and they are the parts whose shape is independent of the exact * arithmetic. */ import { numberCell, textCell } from './tables.ts' import type { DataTable } from './tables.ts' import { allocatePacksToRooms, partitionMapIntoRooms, spawnMonsterPacksByRooms, spawnPacksInRoom, PACK_RADIUS_PX, } from './monster-rooms.ts' import type { MapRoom } from './monster-rooms.ts' import { calculateFrozenArmorFreezeLength, calculateShiverArmorDamage, calculateShiverArmorChillLength, calculateChillingArmorDamage, calculateChillingArmorChillLength, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateEnchantAttackDamage, } from './engine/missile-engine.ts' import { computeEffectiveResistance } from './engine/combat-pipeline.ts' export { allocatePacksToRooms, partitionMapIntoRooms, spawnMonsterPacksByRooms, spawnPacksInRoom, type MapRoom, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateEnchantAttackDamage, } /** Movement request and intent for one tick. */ export interface CombatInput { /** Normalized movement, screen axes (y grows downward). */ readonly movement: { readonly x: number; readonly y: number } /** Whether the attack control is held this tick. */ readonly attack: boolean } /** * What the simulation needs from the world it moves in. * * The predicate reports *how many* solid sub-tiles a position overlaps rather * than a yes/no, because movement needs the count: a body that is already * overlapping something (a bad spawn, a tile that turned solid under it) must * still be able to walk out, and the rule that allows it is "never make the * overlap worse". A boolean cannot express that, and a body stuck forever is a * far worse failure than one that briefly clips a corner. */ export interface CombatTerrain { /** * Count solid sub-tiles overlapping a body position. * * @param x - body centre x. * @param y - body centre y. * @returns the overlap count (0 = clear ground). */ readonly overlap: (x: number, y: number) => number } /** Tuning that is not per-monster. */ export interface CombatOptions { /** Player walk speed in pixels per second. */ readonly playerSpeed: number /** Player melee reach in pixels. */ readonly playerReach: number /** Ticks between player attacks. */ readonly playerCooldownTicks: number /** Player damage per hit. */ readonly playerDamage: number /** Mana spent per attack (0 disables the cost). */ readonly playerManaPerAttack: number /** Ticks before a dead player is restored, at full resources. */ readonly respawnTicks: number /** When true, monsters ignore aggro distance and remain standing still (idle). */ readonly disableMonsterAggro?: boolean /** When true, world.events is preserved across ticks instead of cleared at tick start. */ readonly preserveEvents?: boolean | undefined } /** One monster's immutable numbers, read from a table row. */ export interface MonsterStats { /** Identifier, as written in the table. */ readonly id: string /** Display name. */ readonly name: string /** Maximum health. */ readonly hp: number /** Damage per hit. */ readonly damage: number /** Ticks between attacks. */ readonly cooldownTicks: number /** Reach in pixels. */ readonly reach: number /** Distance at which the monster notices the player, in pixels. */ readonly aggroRadius: number /** Speed in pixels per second. */ readonly speed: number /** Experience awarded on death. */ readonly xp: number /** Elite rank: normal, champion, unique, or minion. */ readonly rank?: 'normal' | 'champion' | 'unique' | 'minion' /** Rolled elite/superunique modifiers from MonUMod.txt. */ readonly modifiers?: readonly string[] /** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */ readonly superUniqueId?: string /** Monster level for drops and scaling. */ readonly level?: number /** Resistances per damage type (from MonStats.txt). */ readonly resistances?: { readonly physical?: number readonly magic?: number readonly fire?: number readonly lightning?: number readonly cold?: number readonly poison?: number } /** Explicit lightning resistance percentage. */ readonly lightningResist?: number /** Generic resists bag for compatibility. */ readonly resists?: { readonly physical?: number readonly magic?: number readonly lightning?: number readonly fire?: number readonly cold?: number readonly poison?: number readonly [k: string]: number | undefined } } /** One monster's mutable state. */ export interface Monster { /** Stable index, for events and debugging. */ readonly index: number /** Its table numbers. */ readonly stats: MonsterStats /** World x. */ x: number /** World y. */ y: number /** Current health. */ hp: number /** Ticks until it may attack again. */ cooldown: number /** What it is doing. */ state: 'idle' | 'chase' | 'attack' | 'dead' /** Facing (0 = south, turning west), for the renderer. */ facing: number /** Ticks left of the hit flash, for the renderer. */ hitFlash: number /** Ticks left of the death animation. */ corpseTicks: number /** Whether the monster is sleeping (inactive off-screen room). */ sleeping?: boolean | undefined /** Ticks remaining frozen (cannot move or attack). */ frozenTicks?: number | undefined /** Ticks remaining chilled (slowed movement). */ chillTicks?: number | undefined /** Ticks remaining poisoned (damage over time). */ poisonTicks?: number | undefined /** Active Necromancer curse debuff state. */ curse?: MonsterCurseState | undefined /** Whether drops have already been rolled for this monster instance. */ dropRolled?: boolean | undefined } export interface MonsterCurseState { name: string slvl: number curseType: number ticksRemaining: number overlayName?: string | undefined casterX?: number | undefined casterY?: number | undefined fleeTarget?: { x: number; y: number } | undefined returnPct?: number | undefined } /** The player's combat-relevant state. */ export interface CombatPlayer { /** World x. */ x: number /** World y. */ y: number /** Current health. */ hp: number /** Maximum health. */ maxHp: number /** Current mana. */ mana: number /** Maximum mana. */ maxMana: number /** Character level. */ level: number /** Experience accumulated. */ xp: number /** Ticks until the next attack. */ cooldown: number /** Facing (0 = south, turning west). */ facing: number /** Whether the player is alive. */ alive: boolean /** Ticks left until respawn while dead. */ respawnIn: number /** Ticks left of the swing animation, for the renderer. */ swingTicks: number /** Thunder Storm active remaining ticks. */ thunderstormTicks?: number thunderstormInterval?: number thunderstormNextStrike?: number thunderstormDamage?: number thunderstormDamageRange?: { readonly min: number; readonly max: number } | undefined thunderstormSlvl?: number /** Energy Shield active remaining ticks. */ energyshieldTicks?: number energyshieldPct?: number energyshieldSlvl?: number /** Cold Armor active remaining ticks and type ('frozenarmor' | 'shiverarmor' | 'chillingarmor'). */ coldArmorType?: 'frozenarmor' | 'shiverarmor' | 'chillingarmor' | undefined coldArmorTicks?: number | undefined coldArmorSlvl?: number | undefined /** Blaze active remaining ticks and patch config. */ blazeTicks?: number | undefined blazeSlvl?: number | undefined blazeDamage?: number | undefined blazePatchDuration?: number | undefined lastBlazeX?: number | undefined lastBlazeY?: number | undefined /** Inferno continuous channeling state and sub-mana fractional accumulator (256ths). */ infernoChanneling?: boolean | undefined infernoManaRemainder256?: number | undefined /** Enchant active remaining ticks, added fire damage, and attack rating % bonus. */ enchantTicks?: number | undefined enchantDamage?: { min: number; max: number } | undefined enchantAttackRatingPercent?: number | undefined fireMasterySlvl?: number | undefined isSorceress?: boolean | undefined isRanged?: boolean | undefined } /** Something worth telling the player (and the renderer) about. */ export interface CombatEvent { /** Event kind. */ readonly kind: 'playerHit' | 'monsterHit' | 'kill' | 'levelUp' | 'playerDeath' | 'respawn' | 'noMana' | 'missileHit' | 'missileExplode' /** World x the event happened at. */ readonly x: number /** World y the event happened at. */ readonly y: number /** Magnitude (damage), when meaningful. */ readonly amount?: number /** Text to float above the point, when meaningful. */ readonly text?: string /** Subject identity (a monster's table id on a kill, for quest counters). */ readonly subjectId?: string /** Stable monster index for kill and hit events. */ readonly monsterIndex?: number | undefined /** Monster level when event is 'kill'. */ readonly monsterLevel?: number | undefined /** Monster rank when event is 'kill'. */ readonly monsterRank?: ('normal' | 'champion' | 'unique' | 'minion') | undefined /** Monster type code (1=normal, 2=champion, 3=unique, 4=boss). */ readonly monsterType?: number | undefined /** SuperUnique ID if applicable. */ readonly superUniqueId?: string | undefined /** Missile type identifier if applicable (e.g. 'firebolt', 'fireexplode'). */ readonly missileType?: string | undefined /** Source skill ID if applicable. */ readonly skillId?: string | undefined } /** The whole simulation state. */ export interface CombatWorld { /** * The local player, for the single-player scene and every existing caller. * * In a networked game this is one of {@link CombatWorld.players}, and which one * is a *view* decision: the simulation must not depend on it, which is why the * state digest covers `players` in peer order and never this field. * * **Invariant:** this object is the same object as one entry of `players`. A * world assembled by copying fields — a save being loaded, a snapshot being * restored — breaks that unless {@link rebindPlayer} is called, and the symptom * is a world that simulates one body while the screen draws another. */ player: CombatPlayer /** * Every player in the world, in peer order, index 0 first. * * Monsters pick the nearest living entry, so two peers whose worlds hold the * same players in the same order see the same monsters make the same choices. */ players: CombatPlayer[] /** Every monster, dead ones included until their corpse timer expires. */ monsters: Monster[] /** Ticks simulated. */ tick: number /** Events produced during the last tick (cleared each tick). */ events: CombatEvent[] /** Kill events pending drop resolution. */ pendingKills?: CombatEvent[] | undefined /** Monsters killed. */ kills: number } /** Defaults that keep the sandbox playable when a table is thin. */ const DEFAULTS = { hp: 20, damage: 3, cooldownTicks: 25, reach: 40, aggroRadius: 220, speed: 90, xp: 10, } as const /** * Read one monster's numbers from a table row. * * @param row - the record. * @param rowIndex - row position, used to build a fallback id. * @returns the stats. */ export function monsterStatsFromRow(row: Readonly>, rowIndex: number): MonsterStats { const ltngRaw = row['ResLi'] ?? row['ResistLightning'] ?? row['ResLi(N)'] ?? row['ResLi(H)'] const ltngRes = ltngRaw !== undefined && ltngRaw.trim() !== '' ? Number(ltngRaw) : undefined return { id: textCell(row, 'Id', `monster${String(rowIndex)}`), name: textCell(row, 'Name', textCell(row, 'Id', `Monster ${String(rowIndex)}`)), hp: numberCell(row, 'HP', DEFAULTS.hp), damage: numberCell(row, 'Damage', DEFAULTS.damage), cooldownTicks: numberCell(row, 'CooldownTicks', DEFAULTS.cooldownTicks), reach: numberCell(row, 'Reach', DEFAULTS.reach), aggroRadius: numberCell(row, 'AggroRadius', DEFAULTS.aggroRadius), speed: numberCell(row, 'Speed', DEFAULTS.speed), xp: numberCell(row, 'XP', DEFAULTS.xp), ...(ltngRes !== undefined ? { lightningResist: ltngRes, resists: { lightning: ltngRes }, resistances: { lightning: ltngRes } } : {}), } } /** * Read every monster definition in a table. * * @param table - a `MonStats`-shaped table. * @returns the definitions. */ export function monsterStatsFromTable(table: DataTable): MonsterStats[] { return table.rows.map((row, index) => monsterStatsFromRow(row, index)) } /** * Read the experience required per level. * * @param table - an `Experience`-shaped table with a `Level` and an `XP` column. * @returns required cumulative experience by level, index 1 = level 1. */ export function experienceTable(table: DataTable): number[] { const levels: number[] = [0, 0] for (const row of table.rows) { const level = numberCell(row, 'Level', 0) if (level < 1) continue levels[level] = numberCell(row, 'XP', 0) } for (let level = 2; level < levels.length; level += 1) { levels[level] = Math.max(levels[level] ?? 0, levels[level - 1] ?? 0) } return levels } /** * Create a world with a player at a position. * * @param x - player x. * @param y - player y. * @param maxHp - starting maximum health. * @param maxMana - starting maximum mana. * @returns the world. */ export function createWorld(x: number, y: number, maxHp = 60, maxMana = 30): CombatWorld { const player = createPlayer(x, y, maxHp, maxMana) return { player, players: [player], monsters: [], tick: 0, events: [], pendingKills: [], kills: 0, } } /** Alias for createWorld conforming to combat world nomenclature. */ export const createCombatWorld = createWorld /** * Create one player body. * * @param x - x. * @param y - y. * @param maxHp - starting maximum health. * @param maxMana - starting maximum mana. * @returns the player. */ export function createPlayer(x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer { return { x, y, hp: maxHp, maxHp, mana: maxMana, maxMana, level: 1, xp: 0, cooldown: 0, facing: 0, alive: true, respawnIn: 0, swingTicks: 0, } } /** * Re-establish the invariant that the local player is in the player list. * * The combat tick drives `players` while the scene and the renderer read * `player`; a world built by spreading one object over another ends up with a * fresh `player` and the *previous* `players` array, so those two would drift * apart on the next tick. A loaded save is a single-player world by definition, * so the list becomes exactly this one player. * * @param world - the world to repair (mutated in place). * @returns the same world. */ export function rebindPlayer(world: CombatWorld): CombatWorld { world.players = [world.player] return world } /** * Add another player to the world, as a joining peer would. * * The new body is appended, so its index is its peer index and every peer agrees * on the order. Nothing about the joining player is derived from the local view. * * @param world - the world. * @param x - spawn x. * @param y - spawn y. * @param maxHp - starting maximum health. * @param maxMana - starting maximum mana. * @returns the player that was added. */ export function addPlayer(world: CombatWorld, x: number, y: number, maxHp = 60, maxMana = 30): CombatPlayer { const player = createPlayer(x, y, maxHp, maxMana) world.players.push(player) return player } /** * The player a monster should go for: the nearest of this tick's targets. * * Nearest rather than "player 0" because a monster that ignores the peer standing * on top of it reads as broken, and because the choice has to be a pure function * of the world for two peers to agree. * * @param monster - the monster choosing. * @param targets - the players it may attack. * @returns the target, or null when there is none. */ function nearestTarget(monster: Monster, targets: readonly CombatPlayer[]): CombatPlayer | null { let best: CombatPlayer | null = null let bestDistance = Number.POSITIVE_INFINITY for (const candidate of targets) { const distance = Math.hypot(candidate.x - monster.x, candidate.y - monster.y) if (distance < bestDistance) { bestDistance = distance best = candidate } } return best } /** * Place monsters around a point, skipping unwalkable spots. * * Spawns are rejected rather than nudged: dropping a monster into a wall because * nothing better was found is how units end up permanently stuck, and the caller * can see the shortfall in the return value. * * @param world - the world to populate. * @param stats - the definitions to spawn from. * @param count - how many to spawn. * @param around - spawn centre. * @param spread - spawn radius in pixels. * @param terrain - collision predicate. * @returns how many were actually placed. */ export function spawnMonsters( world: CombatWorld, stats: readonly MonsterStats[], count: number, around: { readonly x: number; readonly y: number }, spread: number, terrain: CombatTerrain, ): number { if (stats.length === 0) return 0 let placed = 0 let attempt = 0 while (placed < count && attempt < count * 24) { attempt += 1 // A deterministic spiral keeps spawns reproducible, which is what makes a // failing test repeatable. const angle = attempt * 2.399963 const radius = spread * Math.sqrt(attempt / (count * 24)) const x = around.x + Math.cos(angle) * radius const y = around.y + Math.sin(angle) * radius if (terrain.overlap(x, y) > 0) continue const definition = stats[placed % stats.length]! world.monsters.push({ index: world.monsters.length, stats: definition, x, y, hp: definition.hp, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) placed += 1 } return placed } /** A circular exclusion zone where ordinary monster camps must not spawn. */ export interface SafeZone { readonly x: number readonly y: number readonly radius: number } /** A group of monsters that belongs together on the ground. */ export interface MonsterPack { /** * The members, already rolled — one entry per monster. * * A pack is a list rather than a definition plus a count because members are * not interchangeable: health is rolled per monster, and a unique's pack has * a stronger leader followed by its minions. */ readonly members: readonly MonsterStats[] /** Fixed world pixel coordinates for landmark-bound SuperUnique packs. */ readonly fixedPosition?: { readonly x: number; readonly y: number } /** Landmark tile coordinates (tileX, tileY) if pixel coordinates are resolved at scene load. */ readonly landmarkTile?: { readonly tileX: number; readonly tileY: number } /** SuperUnique boss identifier if this pack belongs to a fixed SuperUnique. */ readonly superUniqueId?: string } function isInsideSafeZone(x: number, y: number, safeZones: readonly SafeZone[] | undefined): boolean { if (safeZones === undefined || safeZones.length === 0) return false for (const sz of safeZones) { if (Math.hypot(x - sz.x, y - sz.y) < sz.radius) return true } return false } /** * Place packs, keeping each pack's members together. * * {@link spawnMonsters} scatters its monsters on a golden-angle spiral, which * is the right shape for spreading *unrelated* things evenly and exactly the * wrong one for a Fallen camp: consecutive spiral points are deliberately far * apart, so a pack placed that way arrives as a field of loners. Here the * spiral places the *camps*, and members are clustered inside their own camp. * * Both loops reject blocked ground rather than nudging, for the same reason * {@link spawnMonsters} does: a monster shoved into a wall is stuck forever. * A camp that cannot fit its whole pack keeps whichever members found room, so * the caller can see the shortfall in the return value. * * @param world - the world to populate. * @param packs - the packs to place, in order. * @param around - the centre to spread camps around. * @param spread - how far camps may be from that centre, in pixels. * @param terrain - collision predicate. * @param safeZones - optional safe zones (spawn point, town bridge, waypoints) to exclude ordinary packs from. * @returns how many monsters were actually placed. */ export function spawnMonsterPacks( world: CombatWorld, packs: readonly MonsterPack[], around: { readonly x: number; readonly y: number }, spread: number, terrain: CombatTerrain, safeZones?: readonly SafeZone[], rooms?: readonly MapRoom[], ): number { if (rooms !== undefined && rooms.length > 0) { return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined) } if (packs.length === 0) return 0 let placed = 0 let campIndex = 0 let attempt = 0 const attemptLimit = packs.length * 32 while (campIndex < packs.length && attempt < attemptLimit) { const pack = packs[campIndex]! let campX: number let campY: number let angle: number if (pack.fixedPosition !== undefined) { // Fixed landmark SuperUnique pack: anchor camp center to the landmark position. campX = pack.fixedPosition.x campY = pack.fixedPosition.y angle = campIndex * 2.399963 if (terrain.overlap(campX, campY) > 0) { // Search outward from landmark if exact center tile is slightly blocked let foundClear = false for (let searchStep = 1; searchStep <= 16; searchStep += 1) { const sAngle = searchStep * 2.399963 const sx = campX + Math.cos(sAngle) * (searchStep * 12) const sy = campY + Math.sin(sAngle) * (searchStep * 12) if (terrain.overlap(sx, sy) === 0) { campX = sx campY = sy foundClear = true break } } if (!foundClear) { campIndex += 1 continue } } } else { attempt += 1 angle = attempt * 2.399963 const radius = spread * Math.sqrt(attempt / (packs.length * 24)) campX = around.x + Math.cos(angle) * radius campY = around.y + Math.sin(angle) * radius if (isInsideSafeZone(campX, campY, safeZones)) continue if (terrain.overlap(campX, campY) > 0) continue } campIndex += 1 let memberAttempt = 0 let memberPlaced = 0 while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 16) { // The leader stands on the camp centre; the rest ring it. Offsetting the // ring by the camp's own angle stops every camp in the level from having // an identically oriented formation. const memberAngle = angle + memberAttempt * 2.399963 const memberRadius = memberAttempt === 0 ? 0 : PACK_RADIUS_PX * Math.sqrt(memberAttempt / (pack.members.length * 12)) memberAttempt += 1 const x = campX + Math.cos(memberAngle) * memberRadius const y = campY + Math.sin(memberAngle) * memberRadius if (pack.fixedPosition === undefined && isInsideSafeZone(x, y, safeZones)) continue if (terrain.overlap(x, y) > 0) continue const definition = pack.members[memberPlaced]! world.monsters.push({ index: world.monsters.length, stats: definition, x, y, hp: definition.hp, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) memberPlaced += 1 placed += 1 } } return placed } /** * Direction index (0 = south, turning west) for a vector. * * @param dx - horizontal component. * @param dy - vertical component. * @returns the facing. */ export function facingOf(dx: number, dy: number): number { if (dx === 0 && dy === 0) return 0 const angle = Math.atan2(-dx, dy) return Math.round((angle / (Math.PI / 4)) + 8) % 8 } /** * Move a body one tick, sliding along blocked axes. * * @param from - current position. * @param dx - desired delta x. * @param dy - desired delta y. * @param terrain - collision predicate. * @returns the new position. */ function moveWithCollision( from: { readonly x: number; readonly y: number }, dx: number, dy: number, terrain: CombatTerrain, ): { x: number; y: number } { let { x, y } = from const current = terrain.overlap(x, y) // Axis-separated: a blocked axis stops while the other slides along the wall. if (dx !== 0 && terrain.overlap(x + dx, y) <= current) x += dx if (dy !== 0 && terrain.overlap(x, y + dy) <= current) y += dy return { x, y } } /** * Apply damage to one monster from any source (a melee swing, a projectile). * * Kill handling lives here rather than at each call site so experience, the kill * counter, the corpse timer and the event stream behave identically however the * damage arrived — which is the difference between "the skill works" and "the * skill works but kills do not count". * * @param world - the world. * @param monsterIndex - which monster. * @param amount - damage to apply. * @param attacker - who dealt it; the killer is credited with the experience. * Defaults to the local player, which is what a single-player caller means. * @returns true when this damage killed it. */ function buildKillEvent(monster: Monster): CombatEvent { const rank = monster.stats.rank ?? 'normal' const monsterType = rank === 'unique' ? 3 : rank === 'champion' ? 2 : 1 return { kind: 'kill', x: monster.x, y: monster.y, text: monster.stats.name, subjectId: monster.stats.id, monsterIndex: monster.index, monsterLevel: monster.stats.level, monsterRank: monster.stats.rank, monsterType, superUniqueId: monster.stats.superUniqueId, } } export function damageMonster(world: CombatWorld, monsterIndex: number, amount: number, attacker: CombatPlayer = world.player): boolean { const monster = world.monsters[monsterIndex] if (monster === undefined || monster.state === 'dead') return false monster.hp -= amount monster.hitFlash = 4 world.events.push({ kind: 'monsterHit', x: monster.x, y: monster.y, amount }) if (monster.curse?.name === 'lifetap' && amount > 0 && attacker) { const heal = Math.floor(amount * 0.50) attacker.hp = Math.min(attacker.maxHp ?? 1000, attacker.hp + heal) } if (monster.hp > 0) return false monster.state = 'dead' monster.corpseTicks = 100 monster.dropRolled = monster.dropRolled ?? false world.kills += 1 const killEvent = buildKillEvent(monster) world.events.push(killEvent) if (world.pendingKills !== undefined) { world.pendingKills.push(killEvent) } attacker.xp += monster.stats.xp return true } /** * Advance the simulation one tick for one player. * * @param world - the world to advance (mutated in place). * @param input - this tick's movement and attack intent. * @param options - non-per-monster tuning. * @param terrain - collision predicate. * @param xpTable - cumulative experience required per level, index 1 = level 1. */ export function tickCombat( world: CombatWorld, input: CombatInput, options: CombatOptions, terrain: CombatTerrain, xpTable: readonly number[], ): void { tickCombatMulti(world, [input], options, terrain, xpTable) } /** * Advance the simulation one tick for several players at once. * * One tick, not one per player: the tick counter, the event stream and the * monster turn all happen once, while every player's own input is applied before * the monsters act. Calling {@link tickCombat} once per player instead would run * the monsters twice in a tick and desync two peers against each other. * * `inputs[i]` drives `players[i]`. A peer with no input this tick simply does not * act — it does not fall back to someone else's controls. * * @param world - the world to advance (mutated in place). * @param inputs - one input per player, in peer order. * @param options - non-per-monster tuning. * @param terrain - collision predicate. * @param xpTable - cumulative experience required per level, index 1 = level 1. */ export function tickCombatMulti( world: CombatWorld, inputs: readonly CombatInput[], options: CombatOptions, terrain: CombatTerrain, xpTable: readonly number[], ): void { world.tick += 1 if (!options.preserveEvents) { world.events = [] } // Who the monsters may attack is decided at the *start* of the tick. A player // who comes back to life during this tick is therefore safe in it, and a world // with nobody alive gives the monsters nothing to do: their cooldowns and the // corpses hold still rather than running down against a player who cannot // answer. Both rules exist to keep death and respawn reading the way they do in // single player — you die, the world waits, you come back with a moment to move. const targets = world.players.filter(player => player.alive) for (let index = 0; index < inputs.length; index += 1) { const player = world.players[index] if (player === undefined) continue tickPlayer(world, player, inputs[index]!, options, terrain, xpTable) } if (targets.length > 0) tickMonsters(world, options, terrain, targets) } /** * Apply one player's turn: respawn, movement, attack. * * @param world - the world. * @param player - the player acting. * @param input - its input. * @param options - non-per-monster tuning. * @param terrain - collision predicate. * @param xpTable - cumulative experience required per level. */ function tickPlayer( world: CombatWorld, player: CombatPlayer, input: CombatInput, options: CombatOptions, terrain: CombatTerrain, xpTable: readonly number[], ): void { if (!player.alive) { player.respawnIn -= 1 if (player.respawnIn <= 0) { player.alive = true player.hp = player.maxHp player.mana = player.maxMana world.events.push({ kind: 'respawn', x: player.x, y: player.y }) } return } if (player.cooldown > 0) player.cooldown -= 1 if (player.swingTicks > 0) player.swingTicks -= 1 if (player.enchantTicks && player.enchantTicks > 0) { player.enchantTicks -= 1 if (player.enchantTicks <= 0) { player.enchantTicks = 0 player.enchantDamage = undefined player.enchantAttackRatingPercent = undefined } } // Movement first, so an attack this tick happens from where the player ended up. const frwMultiplier = player.blazeTicks && player.blazeTicks > 0 ? 1.3 : 1.0 const speed = (options.playerSpeed / 25) * frwMultiplier const moved = moveWithCollision(player, input.movement.x * speed, input.movement.y * speed, terrain) player.x = moved.x player.y = moved.y if (input.movement.x !== 0 || input.movement.y !== 0) { player.facing = facingOf(input.movement.x, input.movement.y) } // Player melee: nearest living monster in reach, if the attack is ready. if (input.attack && player.cooldown === 0) { const cost = options.playerManaPerAttack if (cost > 0 && player.mana < cost) { world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' }) } else { player.mana = Math.max(0, player.mana - cost) player.cooldown = options.playerCooldownTicks player.swingTicks = Math.max(1, Math.floor(options.playerCooldownTicks / 2)) let target: Monster | null = null let bestDistance = Number.POSITIVE_INFINITY for (const monster of world.monsters) { if (monster.state === 'dead') continue const distance = Math.hypot(monster.x - player.x, monster.y - player.y) if (distance <= options.playerReach && distance < bestDistance) { bestDistance = distance target = monster } } if (target === null) { world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) } else { player.facing = facingOf(target.x - player.x, target.y - player.y) const basePhysRes = (target.stats as any).resistances?.physical ?? (target.stats as any).resists?.physical ?? 0 let physPierce = 0 if (target.curse?.name === 'amplifydamage') physPierce = 100 else if (target.curse?.name === 'decrepify') physPierce = 50 const physRes = computeEffectiveResistance({ baseRes: basePhysRes, convictionPierce: physPierce }) let totalDamage = physRes.isImmune ? 0 : Math.max(0, Math.round((options.playerDamage * (100 - physRes.effectiveRes)) / 100)) if (player.enchantTicks && player.enchantTicks > 0 && player.enchantDamage) { const enchantRes = calculateEnchantAttackDamage({ enchantDamage: player.enchantDamage, isRanged: player.isRanged, isSorceress: player.isSorceress, fireMasterySlvl: player.fireMasterySlvl, targetFireResist: (target as any).fireResist ?? (target.stats as any).fireResist, }) totalDamage += Math.round(enchantRes.average) } target.hp -= totalDamage target.hitFlash = 4 world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: totalDamage }) if (target.curse?.name === 'lifetap' && totalDamage > 0) { const heal = Math.floor(totalDamage * 0.50) player.hp = Math.min(player.maxHp, player.hp + heal) } if (target.hp <= 0) { target.state = 'dead' target.corpseTicks = 100 target.dropRolled = target.dropRolled ?? false world.kills += 1 const killEvent = buildKillEvent(target) world.events.push(killEvent) if (world.pendingKills !== undefined) { world.pendingKills.push(killEvent) } player.xp += target.stats.xp // Level up while the threshold is crossed; the table is cumulative. while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) { player.level += 1 player.maxHp += 10 player.maxMana += 5 player.hp = player.maxHp player.mana = player.maxMana world.events.push({ kind: 'levelUp', x: player.x, y: player.y, text: `level ${String(player.level)}` }) } } } } } } /** * The monsters' turn: notice, close in, strike. Corpses decay. * * @param world - the world. * @param options - non-per-monster tuning. * @param terrain - collision predicate. * @param targets - the players that were alive when the tick began. */ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void { for (const monster of world.monsters) { if (monster.sleeping) continue if (monster.state === 'dead') { if (monster.corpseTicks > 0) monster.corpseTicks -= 1 continue } if (monster.hitFlash > 0) monster.hitFlash -= 1 if (monster.cooldown > 0) monster.cooldown -= 1 if (monster.frozenTicks !== undefined && monster.frozenTicks > 0) { monster.frozenTicks -= 1 monster.state = 'idle' continue } if (monster.chillTicks !== undefined && monster.chillTicks > 0) { monster.chillTicks -= 1 } if (monster.poisonTicks !== undefined && monster.poisonTicks > 0) { monster.poisonTicks -= 1 if (world.tick % 5 === 0 && monster.hp > 1) { monster.hp -= 1 } } if (monster.curse !== undefined && monster.curse.ticksRemaining > 0) { monster.curse.ticksRemaining -= 1 if (monster.curse.ticksRemaining <= 0) { monster.curse = undefined } } if (options.disableMonsterAggro) { monster.state = 'idle' continue } const player = nearestTarget(monster, targets) if (player === null) { monster.state = 'idle' continue } // Terror flee behavior: flee directly opposite caster/player if (monster.curse?.name === 'terror') { const fleeFromX = monster.curse.casterX ?? player.x const fleeFromY = monster.curse.casterY ?? player.y const fdx = monster.x - fleeFromX const fdy = monster.y - fleeFromY const flen = Math.hypot(fdx, fdy) || 1 monster.facing = facingOf(fdx, fdy) const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1 const step = (monster.stats.speed / 25) * chillFactor const moved = moveWithCollision(monster, (fdx / flen) * step, (fdy / flen) * step, terrain) monster.x = moved.x monster.y = moved.y monster.state = 'chase' continue } const sightRadius = monster.curse?.name === 'dimvision' ? 40 : monster.stats.aggroRadius // Attract & Confuse target redirection: let targetMonster: Monster | null = null let bestTargetDist = sightRadius for (const other of world.monsters) { if (other.index === monster.index || other.state === 'dead' || other.hp <= 0) continue if (other.curse?.name === 'attract') { const d = Math.hypot(other.x - monster.x, other.y - monster.y) if (d <= bestTargetDist) { bestTargetDist = d targetMonster = other } } } if (monster.curse?.name === 'confuse' && targetMonster === null) { for (const other of world.monsters) { if (other.index === monster.index || other.state === 'dead' || other.hp <= 0) continue const d = Math.hypot(other.x - monster.x, other.y - monster.y) if (d <= bestTargetDist) { bestTargetDist = d targetMonster = other } } } if (targetMonster !== null) { const mdx = targetMonster.x - monster.x const mdy = targetMonster.y - monster.y const mdist = Math.hypot(mdx, mdy) monster.facing = facingOf(mdx, mdy) if (mdist <= monster.stats.reach) { monster.state = 'attack' if (monster.cooldown === 0) { monster.cooldown = monster.curse?.name === 'decrepify' ? monster.stats.cooldownTicks * 2 : monster.stats.cooldownTicks let dmg = monster.stats.damage if (monster.curse?.name === 'weaken') dmg = Math.floor(dmg * 0.67) if (monster.curse?.name === 'decrepify') dmg = Math.floor(dmg * 0.50) damageMonster(world, targetMonster.index, dmg, player) } continue } monster.state = 'chase' const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1 const decrepFactor = monster.curse?.name === 'decrepify' ? 0.5 : 1 const step = (monster.stats.speed / 25) * chillFactor * decrepFactor const moved = moveWithCollision(monster, (mdx / mdist) * step, (mdy / mdist) * step, terrain) monster.x = moved.x monster.y = moved.y continue } const dx = player.x - monster.x const dy = player.y - monster.y const distance = Math.hypot(dx, dy) if (distance > sightRadius) { monster.state = 'idle' continue } if (monster.curse?.name === 'dimvision' && monster.stats.reach > 40) { monster.state = 'idle' continue } monster.facing = facingOf(dx, dy) if (distance <= monster.stats.reach) { monster.state = 'attack' if (monster.cooldown === 0) { monster.cooldown = monster.curse?.name === 'decrepify' ? monster.stats.cooldownTicks * 2 : monster.stats.cooldownTicks let dmg = monster.stats.damage if (monster.curse?.name === 'weaken') dmg = Math.floor(dmg * 0.67) if (monster.curse?.name === 'decrepify') dmg = Math.floor(dmg * 0.50) const isRanged = monster.stats.reach > 40 // Iron Maiden reflection on melee attack if (monster.curse?.name === 'ironmaiden' && !isRanged && dmg > 0) { const returnPct = monster.curse.returnPct ?? (200 + (monster.curse.slvl - 1) * 25) const physRes = monster.stats.resistances?.physical ?? monster.stats.resists?.physical ?? 0 const rawReflect = Math.floor((dmg * returnPct) / 100) const effReflect = physRes >= 100 ? 0 : Math.max(0, Math.floor((rawReflect * (100 - physRes)) / 100)) if (effReflect > 0) { damageMonster(world, monster.index, effReflect, player) } } // Cold Armor: Shiver Armor (Skill 50) proactive melee defense if (!isRanged && player.coldArmorType === 'shiverarmor') { const slvl = player.coldArmorSlvl ?? 1 const dmgRange = calculateShiverArmorDamage(slvl) const coldDmg = Math.round((dmgRange.min + dmgRange.max) / 2) damageMonster(world, monster.index, coldDmg, player) const chillFrames = calculateShiverArmorChillLength(slvl) monster.chillTicks = Math.max(monster.chillTicks ?? 0, chillFrames) } // Cold Armor: Chilling Armor (Skill 60) ranged retaliatory reflection if (isRanged && player.coldArmorType === 'chillingarmor') { const slvl = player.coldArmorSlvl ?? 1 const dmgRange = calculateChillingArmorDamage(slvl) const boltDmg = Math.round((dmgRange.min + dmgRange.max) / 2) world.events.push({ kind: 'missileHit', x: player.x, y: player.y, missileType: 'chillingarmorbolt', skillId: '60', amount: boltDmg, }) damageMonster(world, monster.index, boltDmg, player) const chillFrames = calculateChillingArmorChillLength(slvl) monster.chillTicks = Math.max(monster.chillTicks ?? 0, chillFrames) } if (monster.hp <= 0) { continue } if (player.energyshieldTicks && player.energyshieldTicks > 0 && player.mana > 0) { const absorbPct = player.energyshieldPct ?? 20 const absorbed = Math.floor((dmg * absorbPct) / 100) const ratio = 2.0 const manaNeeded = Math.ceil(absorbed * ratio) if (player.mana >= manaNeeded) { player.mana -= manaNeeded dmg -= absorbed } else { const actualAbsorbed = Math.floor(player.mana / ratio) player.mana = 0 dmg -= actualAbsorbed player.energyshieldTicks = 0 } } player.hp -= dmg world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: dmg }) // Cold Armor: Frozen Armor (Skill 40) reactive melee freeze on physical damage if (!isRanged && player.coldArmorType === 'frozenarmor' && dmg > 0) { const slvl = player.coldArmorSlvl ?? 1 const freezeFrames = calculateFrozenArmorFreezeLength(slvl) monster.frozenTicks = Math.max(monster.frozenTicks ?? 0, freezeFrames) } if (player.hp <= 0) { player.hp = 0 player.alive = false player.respawnIn = options.respawnTicks world.events.push({ kind: 'playerDeath', x: player.x, y: player.y, text: 'you died' }) } } continue } monster.state = 'chase' const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1 const decrepFactor = monster.curse?.name === 'decrepify' ? 0.5 : 1 const step = (monster.stats.speed / 25) * chillFactor * decrepFactor const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain) monster.x = movedMonster.x monster.y = movedMonster.y } } export const updateMonsters = tickMonsters export interface PlayerMissileHitOptions { readonly damage?: number readonly missileType?: string readonly attackerIndex?: number readonly attacker?: Monster | CombatPlayer readonly returnFire?: boolean readonly sourceX?: number readonly sourceY?: number } /** * Handle a ranged missile hit on the player. * When player has Chilling Armor active and returnFire !== false, * reflects a retaliatory ice bolt missile ('chillingarmorbolt') toward the attacker with returnFire: false. */ export function hitPlayerWithMissile( world: CombatWorld, options: PlayerMissileHitOptions, player: CombatPlayer = world.player, ): { reflected: boolean; retaliatoryMissile?: string; damageDealt: number } { let dmg = options.damage ?? 10 const returnFire = options.returnFire !== false let reflected = false let retaliatoryMissile: string | undefined if (player.coldArmorType === 'chillingarmor' && returnFire) { reflected = true retaliatoryMissile = 'chillingarmorbolt' const slvl = player.coldArmorSlvl ?? 1 const dmgRange = calculateChillingArmorDamage(slvl) const boltDmg = Math.round((dmgRange.min + dmgRange.max) / 2) const chillFrames = calculateChillingArmorChillLength(slvl) world.events.push({ kind: 'missileHit', x: player.x, y: player.y, missileType: 'chillingarmorbolt', skillId: '60', amount: boltDmg, }) if (options.attackerIndex !== undefined) { damageMonster(world, options.attackerIndex, boltDmg, player) const m = world.monsters[options.attackerIndex] if (m) { m.chillTicks = Math.max(m.chillTicks ?? 0, chillFrames) } } else if (options.attacker && 'stats' in options.attacker) { const m = options.attacker as Monster damageMonster(world, m.index, boltDmg, player) m.chillTicks = Math.max(m.chillTicks ?? 0, chillFrames) } } // Energy shield absorption if (player.energyshieldTicks && player.energyshieldTicks > 0 && player.mana > 0) { const absorbPct = player.energyshieldPct ?? 20 const absorbed = Math.floor((dmg * absorbPct) / 100) const ratio = 2.0 const manaNeeded = Math.ceil(absorbed * ratio) if (player.mana >= manaNeeded) { player.mana -= manaNeeded dmg -= absorbed } else { const actualAbsorbed = Math.floor(player.mana / ratio) player.mana = 0 dmg -= actualAbsorbed player.energyshieldTicks = 0 } } player.hp -= dmg world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: dmg }) if (player.hp <= 0) { player.hp = 0 player.alive = false player.respawnIn = 100 world.events.push({ kind: 'playerDeath', x: player.x, y: player.y, text: 'you died' }) } return { reflected, ...(retaliatoryMissile !== undefined ? { retaliatoryMissile } : {}), damageDealt: dmg, } } /** * Apply Enchant buff to a combat player or ally/pet unit. * Authentic 1.13c: duration = 3600 + (slvl - 1) * 600 frames, AR bonus = 20 + (slvl - 1) * 9 %, * damage = calculateEnchantDamage(slvl, synergies, fmSlvl). */ export function applyEnchantBuff( target: CombatPlayer | { enchantTicks?: number | undefined enchantDamage?: { min: number; max: number } | undefined enchantAttackRatingPercent?: number | undefined }, slvl: number, options?: { casterWarmth?: number | undefined casterFireMasterySlvl?: number | undefined isCasterSorceress?: boolean | undefined } | undefined, ): void { const duration = calculateEnchantDuration(slvl) const arBonus = calculateEnchantAttackRating(slvl) const dmg = calculateEnchantDamage( slvl, options?.casterWarmth !== undefined ? { warmth: options.casterWarmth } : undefined, options?.casterFireMasterySlvl, ) target.enchantTicks = duration target.enchantAttackRatingPercent = arBonus target.enchantDamage = { min: dmg.min, max: dmg.max } } /** * Determine whether a unit can be targeted for Enchant (Skill 52). * 1.13c Skills.txt: InTown: 1, TargetAlly: 1, TargetPet: 1. * Enemies cannot be enchanted. Self, party allies, and pets are allowed, even in town. */ export function canCastEnchantOn( targetType: 'self' | 'ally' | 'pet' | 'enemy' | string, _inTown = true, ): boolean { if (targetType === 'enemy') return false return true }