@idosgames/mcp 0.1.38 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,12 +14,12 @@
14
14
  "store",
15
15
  "inventory"
16
16
  ],
17
- "summary": "Roguelike runs in the style of Obsidian Knight with Warcraft III's damage: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.",
18
- "description": "A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage is Warcraft III's: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by the Frozen Throne table, and armor points by the WC3 formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.",
17
+ "summary": "Roguelike runs in the style of Obsidian Knight with attack and armor types: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.",
18
+ "description": "A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage goes by type: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by a damage table, and armor points by the formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.",
19
19
  "provides": [
20
20
  "roguelike runs of levels: waves, camp, fountains, chest, boss",
21
21
  "rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)",
22
- "Warcraft III attack types × armor types and the armor formula",
22
+ "attack types × armor types and the armor formula",
23
23
  "types from the gear: troops and the hero read off their look",
24
24
  "scouting of a level and type tips introduced island by island",
25
25
  "one ultimate a run (cards may bring it back) and potions",
@@ -32,7 +32,6 @@
32
32
  "rpg",
33
33
  "auto-battler",
34
34
  "obsidian-knight",
35
- "warcraft",
36
35
  "cards",
37
36
  "phaser",
38
37
  "2d"
@@ -41,7 +40,7 @@
41
40
  "name": "iDos Games",
42
41
  "url": "https://idosgames.com"
43
42
  },
44
- "version": "0.1.0",
43
+ "version": "0.1.1",
45
44
  "events": {
46
45
  "emits": [
47
46
  {
@@ -76,7 +75,7 @@
76
75
  "phaser": "4.2.1",
77
76
  "react": "19.2.7"
78
77
  },
79
- "contentHash": "2f0ebb048c12b5d9b6970cde73504c96385f5fea653fce5860622d1b2cde8762",
78
+ "contentHash": "366e0e5848d4204b3e15e63a64a5fb1b9ee4245578f458131771a3dc18ec2e3c",
80
79
  "files": [
81
80
  {
82
81
  "path": "character/art/svg.ts",
@@ -328,19 +327,19 @@
328
327
  },
329
328
  {
330
329
  "path": "game/battle.ts",
331
- "content": "// One RUN of a level — a deterministic simulation with no Phaser in it, so it is tested on its own\n// (game.test.ts) and drawn by the scene from the events each tick returns.\n//\n// The road (Obsidian Knight's march), in lane widths: the hero runs right along it and the world\n// scrolls past — on screen the hero stays at HERO_X. The level's segments come one after another: a\n// wave of troops stands on the road ahead and waits (brawlers one at a time, face to face; shooters\n// fire once the hero is in their range), a camp, a fountain or a chest stands there to be reached,\n// and the boss closes the road. Kills fill the RAGE; each threshold is a run level and a choice of\n// one of three cards (cards.ts) — the run stops until the player takes one. Death ends the run.\n//\n// The cards are triggers: the battle FIRES them — on a hit, a crit, a kill, a dodge, a counter, a blow\n// taken, a heal, the ultimate, the camp, every N-th attack, a new run level — and their effects throw\n// Lightning, Axes, Spears and Arrows, stun, heal, repair the shield, give charges.\n//\n// Damage is Warcraft's (damage.ts): the hero's weapon has an attack type, every troop an armor type\n// and an armor value, and so does the hero. A shield is a pool on top of the health that takes the\n// blows first (the blue bar) — the hero's when it carries one, a troop's when it does.\n\nimport {\n armorFactor,\n armorReduction,\n dealt,\n effectOf,\n typeFactor,\n} from \"./damage\";\nimport type { ArmorType, AttackType, Effect as TypeEffect } from \"./damage\";\nimport {\n BOLT_TYPE,\n drawCards,\n modsOf,\n noMods,\n procsOf,\n type Bolt,\n type Effect,\n type Proc,\n type RunMods,\n type Trigger,\n} from \"./cards\";\nimport type { FighterStats } from \"./heroStats\";\nimport type { LevelDef, Segment } from \"./campaign\";\nimport { seededRandom } from \"./random\";\nimport {\n TROOPS,\n TROOP_RANGE,\n troopArmor,\n troopAttack,\n troopRanged,\n troopShielded,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\n/** Where the hero stands on screen (share of the lane); the road scrolls under it. */\nexport const HERO_X = 0.2;\n/** The hero's run, lane widths per second — the most; the scene lowers it to what the hero's legs\n * carry at its size (`setWalkSpeed`), so the feet never skate. */\nexport const WALK_SPEED = 0.2;\n/** A melee blow lands from this far: the hero stops here before a troop, face to face. */\nexport const MELEE_REACH = 0.13;\n/** The boss is drawn this much bigger (the scene) — face to face with it is that much farther. */\nexport const BOSS_SIZE = 1.4;\n/** A ranged weapon (a bow, a gun, a staff) shoots from this far. */\nexport const RANGED_REACH = 0.55;\n/** The first troop of a new wave stands this far ahead (just past the screen edge). */\nexport const WAVE_AHEAD = 0.85;\n/** The gap between troops waiting in line. */\nexport const MONSTER_GAP = 0.14;\nexport const PROJECTILE_SPEED = 1.9;\nexport const ENEMY_SHOT_SPEED = 1.1;\n\n/** A plain troop's health and attack at level 1 (× its troop's share × the level's power). */\nexport const BASE_HP = 26;\nexport const BASE_ATTACK = 3;\n/** A shield's pool: this share of its bearer's max health. */\nexport const SHIELD_SHARE = 0.5;\n\n/** Total rage for each run level (Obsidian Knight's table); past the last it grows on. */\nexport const RAGE_THRESHOLDS = [\n 3, 7, 14, 22, 32, 44, 58, 74, 92, 112, 134, 158,\n];\nexport const RAGE_PER_KILL = 1;\nexport const RAGE_PER_BOSS = 5;\nexport const FOUNTAIN_RAGE = 5;\nexport const FOUNTAIN_HEAL = 0.3;\nexport const CAMP_RAGE = 5;\nexport const CAMP_HEAL = 0.4;\n\nexport const POTION_HEAL = 0.35;\n/** With auto on, a potion goes down below this share of health. */\nexport const AUTO_POTION_BELOW = 0.35;\n\n/** New three cards for the same choice, a run (\"Refresh\"). */\nexport const REROLLS_PER_RUN = 1;\n\nexport type UltId = \"meteor\" | \"frenzy\";\n/**\n * The ultimate: one slot, picked before the run, ready from its start and used ONCE a run (owner's\n * rule 23.10.2026) — only a card brings it back, by a chance at a kill or a new run level.\n */\nexport const ULTS: Readonly<Record<UltId, { duration: number }>> = {\n meteor: { duration: 0 },\n frenzy: { duration: 6 },\n};\n/** With auto on, the one ultimate waits for a moment worth it: the boss, a crowd on screen, or trouble. */\nexport const AUTO_ULT_CROWD = 4;\nexport const AUTO_ULT_BELOW = 0.4;\n/** The meteor hits every troop on screen for this many hero blows (spells — through any armor). */\nexport const METEOR_POWER = 4;\nexport const FRENZY_SPEED = 2;\n\n/** A lifesteal heals this share of the blow that triggered it. */\nexport const LIFESTEAL_SHARE = 0.3;\n/** A \"Shield break\" stack: + this share of damage on its target. */\nexport const BREAK_BONUS = 0.08;\n/** At most this many \"Shield break\" stacks on one troop. */\nexport const BREAK_MAX = 4;\n/** Below this share of health the low-health cards hold. */\nexport const LOW_HP = 0.3;\n/** How deep effects may set off effects (a kill's arrow killing, its heal striking…). */\nconst MAX_PROC_DEPTH = 4;\n\nexport type Reach = \"melee\" | \"ranged\";\n\n/**\n * A line of the hero's sheet in a run (Obsidian Knight's eight): its own number (gear, rank) and the\n * number now, with the cards. Defense is the share the armor value takes off a blow; the counter and\n * the lifesteal are chances. There is no health regen in the game — health comes back from potions,\n * the camp, fountains and the cards.\n */\nexport type HeroNumber =\n | \"damage\"\n | \"attackSpeed\"\n | \"critChance\"\n | \"critMultiplier\"\n | \"defense\"\n | \"counter\"\n | \"dodge\"\n | \"lifesteal\";\n\n/** In the sheet's order: the left column, then the right. */\nexport const HERO_NUMBERS: readonly HeroNumber[] = [\n \"damage\",\n \"attackSpeed\",\n \"critChance\",\n \"critMultiplier\",\n \"defense\",\n \"counter\",\n \"dodge\",\n \"lifesteal\",\n];\n\nexport interface Monster {\n id: number;\n troop: TroopDef;\n boss: boolean;\n hp: number;\n maxHp: number;\n /** Its shield's pool (0 — none): blows take it first. */\n shield: number;\n maxShield: number;\n attack: number;\n attackType: AttackType;\n armorType: ArmorType;\n /** Its armor points; `broken` of them are knocked off (past zero with the shred cards). */\n armor: number;\n broken: number;\n /** \"Shield break\" stacks on it: + BREAK_BONUS damage each. */\n breaks: number;\n /** Seconds between its blows; how fast a brawler closes the last step (× the hero's run). */\n interval: number;\n speed: number;\n /** Lane widths it shoots from; 0 — it fights face to face. */\n range: number;\n stunned: number;\n /** Where it stands on the road (lane widths, the same axis as `Battle.heroX`). */\n x: number;\n cooldown: number;\n}\n\n/** The hero's shot (a ranged weapon only — a melee blow lands at once). */\nexport interface Projectile {\n id: number;\n x: number;\n targetId: number;\n damage: number;\n crit: boolean;\n extra: boolean;\n}\n\n/** A troop's shot flying at the hero. */\nexport interface EnemyShot {\n id: number;\n x: number;\n damage: number;\n attackType: AttackType;\n}\n\n/** A camp, a fountain or a chest standing on the road. */\nexport interface RoadFeature {\n id: number;\n kind: \"camp\" | \"fountain\" | \"chest\";\n gives?: \"heal\" | \"rage\";\n x: number;\n}\n\nexport type Pending =\n /** Choice `index` of `total` in a row of run levels (\"Upgrade: 1/5\"). */\n | { kind: \"cards\"; options: string[]; index: number; total: number }\n | { kind: \"camp\" };\n\nexport type CampChoice = \"heal\" | \"rage\" | \"both\";\n\nexport type Charge = \"focus\" | \"divine\" | \"lightning\";\n\nexport interface RunResult {\n cleared: boolean;\n /** The level played (1-based across islands). */\n level: number;\n kills: number;\n runLevel: number;\n chests: number;\n seconds: number;\n cards: string[];\n /** The player's own potions drunk — they leave the wallet. A card's potions go first and cost nothing. */\n potionsUsed: number;\n}\n\nexport type BattleEvent =\n | { type: \"spawn\"; monster: Monster }\n | { type: \"feature\"; feature: RoadFeature }\n | { type: \"reached\"; feature: RoadFeature }\n | { type: \"shoot\"; projectile: Projectile }\n | { type: \"strike\"; monsterId: number }\n | { type: \"enemyShoot\"; shot: EnemyShot; monsterId: number }\n | { type: \"enemyStrike\"; monsterId: number }\n | {\n type: \"hit\";\n monsterId: number;\n x: number;\n damage: number;\n crit: boolean;\n melee: boolean;\n /** How the hero's attack type fares against its armor (the number's colour). */\n effect: TypeEffect;\n }\n | { type: \"kill\"; monsterId: number; x: number; boss: boolean }\n /** A card's missile: from the hero to these troops (the scene draws it). */\n | { type: \"bolt\"; bolt: Bolt; targets: number[] }\n | { type: \"blast\"; monsterId: number; x: number }\n | { type: \"stun\"; monsterId: number }\n | { type: \"heroHit\"; damage: number; shield: number; healed: boolean }\n /** A blow that did not land: the divine shield took it, or the armor shrugged it off. */\n | { type: \"guard\" }\n | { type: \"dodge\" }\n | { type: \"counter\"; monsterId: number }\n | { type: \"charge\"; charge: Charge; count: number }\n | { type: \"heal\"; amount: number }\n | { type: \"heroDown\" }\n | { type: \"rage\"; amount: number }\n | { type: \"levelUp\"; level: number }\n | { type: \"card\"; id: string }\n | { type: \"potion\" }\n | { type: \"ult\"; ult: UltId }\n | { type: \"ultBack\" }\n | { type: \"bossStart\" }\n | { type: \"end\"; result: RunResult };\n\nexport interface BattleSnapshot {\n level: number;\n island: number;\n /** Segment now (0-based; === segments → the boss), of how many. */\n segment: number;\n segments: number;\n heroHp: number;\n heroMaxHp: number;\n heroShield: number;\n heroMaxShield: number;\n heroDown: boolean;\n walking: boolean;\n rage: number;\n /** Rage the current run level started at, and the next one needs. */\n rageFrom: number;\n rageTo: number;\n runLevel: number;\n pending: Pending | null;\n rerollsLeft: number;\n potions: number;\n ult: UltId;\n /** The ultimate is there to use (once a run; a card may bring it back). */\n ultReady: boolean;\n ultActiveLeft: number;\n charges: Record<Charge, number>;\n autoPlay: boolean;\n kills: number;\n chests: number;\n cards: string[];\n result: RunResult | null;\n monsters: number;\n bossHp: number;\n bossMaxHp: number;\n}\n\n/** Total rage a run level needs (level 0 → the first threshold). */\nexport function rageFor(level: number): number {\n const n = Math.max(0, Math.floor(level));\n if (n < RAGE_THRESHOLDS.length) return RAGE_THRESHOLDS[n]!;\n const last = RAGE_THRESHOLDS.length - 1;\n let total = RAGE_THRESHOLDS[last]!;\n let step = RAGE_THRESHOLDS[last]! - RAGE_THRESHOLDS[last - 1]!;\n for (let i = last; i < n; i++) {\n step += 2;\n total += step;\n }\n return total;\n}\n\nconst IDLE_STATS: FighterStats = {\n maxHp: 100,\n damage: 10,\n armor: 0,\n attackSpeed: 1,\n critChance: 0,\n critMultiplier: 1.5,\n dodge: 0,\n regen: 0,\n multiShot: 0,\n};\n\nexport interface RunOptions {\n level: LevelDef;\n stats?: FighterStats;\n /** What the hero's weapon deals and what its body wears (the session reads them off the gear). */\n heroAttack?: AttackType;\n heroArmor?: ArmorType;\n /** Does the hero carry a shield (its blue pool). */\n shield?: boolean;\n ult?: UltId;\n potions?: number;\n seed?: number;\n}\n\n/** What a fired trigger knows: who it is about, what was over. */\ninterface FireContext {\n target?: Monster;\n /** The killing blow's damage past the health; the heal past the max. */\n overflow?: number;\n /** A \"turn the blow into healing\" fired — the blow does not land. */\n converted?: boolean;\n}\n\nexport class Battle {\n readonly level: LevelDef;\n readonly heroAttack: AttackType;\n readonly heroArmorType: ArmorType;\n readonly shielded: boolean;\n readonly ult: UltId;\n\n stats: FighterStats = IDLE_STATS;\n mods: RunMods = noMods();\n procs: Proc[] = [];\n cards: string[] = [];\n heroHp = IDLE_STATS.maxHp;\n /** The hero's shield pool — takes the blows before its health. */\n heroShield = 0;\n /** How far the hero has run (lane widths). */\n heroX = 0;\n /** The hero is on the move this step (nobody and nothing within its reach). */\n walking = false;\n walkSpeed = WALK_SPEED;\n waveAhead = WAVE_AHEAD;\n monsterGap = MONSTER_GAP;\n reach: Reach = \"melee\";\n /** Seconds from the start of the hero's attack to its hit (the scene sets it from the animation). */\n windup = 0;\n /** Seconds the attack plays on after its hit — the hero does not set off before it is over. */\n recover = 0;\n /** Standing in line, nearest first. */\n monsters: Monster[] = [];\n projectiles: Projectile[] = [];\n enemyShots: EnemyShot[] = [];\n feature: RoadFeature | null = null;\n /** The segment now (-1 — before the first; === segments.length — the boss). */\n segment = -1;\n\n rage = 0;\n runLevel = 0;\n /** What the run waits for (a card, the camp); the clock stands still meanwhile. */\n pending: Pending | null = null;\n rerollsLeft = REROLLS_PER_RUN;\n readonly rerollsTotal = REROLLS_PER_RUN;\n potions: number;\n /** Of `potions`, those a card brought for this run (not the player's): drunk first. */\n cardPotions = 0;\n potionsUsed = 0;\n ultReady = true;\n ultActiveLeft = 0;\n /** Charges the cards gave: focus, divine shield, lightning strikes. */\n charges: Record<Charge, number> = { focus: 0, divine: 0, lightning: 0 };\n /** Crit chance the kills have added this run. */\n runCrit = 0;\n /** Potions and the ultimate go by themselves. */\n autoPlay = true;\n kills = 0;\n chests = 0;\n time = 0;\n result: RunResult | null = null;\n\n private queued = 0;\n private choice = { index: 0, total: 0 };\n private fired: Partial<Record<Trigger, number>> = {};\n private depth = 0;\n private heroCooldown = 0;\n private heroRecoverLeft = 0;\n private nextId = 1;\n private readonly random: () => number;\n private events: BattleEvent[] = [];\n\n constructor(opts: RunOptions) {\n this.level = opts.level;\n this.heroAttack = opts.heroAttack ?? \"normal\";\n this.heroArmorType = opts.heroArmor ?? \"unarmored\";\n this.shielded = !!opts.shield;\n this.ult = opts.ult ?? \"meteor\";\n this.potions = Math.max(0, Math.floor(opts.potions ?? 0));\n this.random = seededRandom(opts.seed ?? Date.now());\n if (opts.stats) this.stats = opts.stats;\n this.heroHp = this.heroMaxHp();\n this.heroShield = this.heroMaxShield();\n }\n\n // ── The hero's numbers with the cards ──────────────────────────────────────────────────────\n\n heroMaxHp(): number {\n return this.stats.maxHp * (1 + this.mods.maxHp);\n }\n\n heroMaxShield(): number {\n return this.shielded ? this.heroMaxHp() * SHIELD_SHARE : 0;\n }\n\n heroArmorValue(): number {\n return this.stats.armor + this.mods.armor;\n }\n\n private critChance(): number {\n return this.stats.critChance + this.mods.critChance + this.runCrit;\n }\n\n /**\n * The hero's sheet: each number as the hero came (gear, rank) and as it fights now with the cards —\n * the screen shows the difference (\"+15\"). The conditions that hold right now count (full health,\n * low health).\n */\n heroNumbers(): Record<HeroNumber, { base: number; now: number }> {\n const s = this.stats;\n const m = this.mods;\n return {\n damage: { base: s.damage, now: this.heroDamage() },\n attackSpeed: {\n base: s.attackSpeed,\n now: this.heroSpeed() / (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1),\n },\n critChance: {\n base: s.critChance,\n now: Math.min(1, this.critChance()),\n },\n critMultiplier: {\n base: s.critMultiplier,\n now: s.critMultiplier + m.critDamage,\n },\n defense: {\n base: armorReduction(s.armor),\n now: armorReduction(this.heroArmorValue()),\n },\n counter: { base: 0, now: Math.min(1, m.counter) },\n dodge: { base: s.dodge, now: Math.min(1, s.dodge + m.dodge) },\n lifesteal: { base: 0, now: Math.min(1, m.lifesteal) },\n };\n }\n\n /** New numbers of the hero (gear, another hero): health and shield keep their share. */\n setStats(stats: FighterStats): void {\n const hpShare = this.heroHp / Math.max(1, this.heroMaxHp());\n const shieldMax = this.heroMaxShield();\n const shieldShare = shieldMax > 0 ? this.heroShield / shieldMax : 1;\n this.stats = stats;\n this.heroHp = Math.max(1, hpShare * this.heroMaxHp());\n this.heroShield = shieldShare * this.heroMaxShield();\n }\n\n setWalkSpeed(speed: number): void {\n this.walkSpeed = Math.max(0.01, Math.min(WALK_SPEED, speed));\n }\n\n setMonsterGap(gap: number): void {\n this.monsterGap = Math.max(0.08, Math.min(0.3, gap));\n }\n\n setWaveAhead(ahead: number): void {\n this.waveAhead = Math.max(0.6, Math.min(2, ahead));\n }\n\n setReach(reach: Reach): void {\n this.reach = reach;\n }\n\n /** How the hero's attack is timed: the swing before its hit, the follow-through after it (s, 0…1). */\n setSwing(windup: number, recover: number): void {\n this.windup = Math.max(0, Math.min(1, windup));\n this.recover = Math.max(0, Math.min(1, recover));\n }\n\n setAutoPlay(on: boolean): void {\n this.autoPlay = on;\n }\n\n // ── The player's moves ─────────────────────────────────────────────────────────────────────\n\n /** Take one of the three cards offered. `false` — no choice is open, or it is not one of them. */\n takeCard(id: string): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || !p.options.includes(id)) return false;\n const maxBefore = this.heroMaxHp();\n const shieldBefore = this.heroMaxShield();\n const potionsBefore = this.mods.extraPotions;\n this.cards.push(id);\n this.setCards(this.cards);\n // a card that brings potions brings them into this run\n const brought = Math.max(0, this.mods.extraPotions - potionsBefore);\n this.potions += brought;\n this.cardPotions += brought;\n // a card that raises the max raises the health by as much\n this.heroHp += Math.max(0, this.heroMaxHp() - maxBefore);\n this.heroShield += Math.max(0, this.heroMaxShield() - shieldBefore);\n this.pending = null;\n this.emit({ type: \"card\", id });\n this.offerCards();\n return true;\n }\n\n /** Put these cards on the run (what they add and what they fire) — a test sets a build this way. */\n setCards(cards: readonly string[]): void {\n this.cards = [...cards];\n this.mods = modsOf(this.cards);\n this.procs = procsOf(this.cards);\n }\n\n /** \"Refresh\": three other cards for the same choice (REROLLS_PER_RUN a run). */\n reroll(): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || this.rerollsLeft <= 0) return false;\n this.rerollsLeft--;\n const options = this.draw();\n if (options.length > 0) this.pending = { ...p, options };\n return true;\n }\n\n /** The camp: health or rage — or both (the session takes the gems for it first). */\n chooseCamp(choice: CampChoice): boolean {\n if (this.pending?.kind !== \"camp\") return false;\n this.pending = null;\n this.feature = null;\n if (choice !== \"rage\") this.healHero(CAMP_HEAL * this.heroMaxHp());\n if (choice !== \"heal\") this.addRage(CAMP_RAGE, false);\n this.fire(\"camp\");\n return true;\n }\n\n drinkPotion(): boolean {\n if (this.result || this.pending || this.potions <= 0) return false;\n if (this.heroHp >= this.heroMaxHp()) return false;\n this.potions--;\n if (this.cardPotions > 0) this.cardPotions--;\n else this.potionsUsed++;\n this.emit({ type: \"potion\" });\n this.healHero(POTION_HEAL * (1 + this.mods.potionPower) * this.heroMaxHp());\n return true;\n }\n\n /** The ultimate — once a run, unless a card brought it back. */\n castUlt(): boolean {\n if (this.result || this.pending || !this.ultReady) return false;\n this.ultReady = false;\n this.emit({ type: \"ult\", ult: this.ult });\n if (this.ult === \"frenzy\") this.ultActiveLeft = ULTS.frenzy.duration;\n else {\n const damage = this.heroDamage() * METEOR_POWER;\n for (const m of [...this.monsters])\n if (this.onScreen(m)) this.strikeTyped(m, damage, [[\"spells\", 1]]);\n }\n this.fire(\"ult\");\n return true;\n }\n\n /** Leave the run (it ends as lost). */\n abandon(): void {\n if (!this.result) this.finish(false);\n }\n\n // ── Reading the road ───────────────────────────────────────────────────────────────────────\n\n screenX(x: number): number {\n return HERO_X + (x - this.heroX);\n }\n\n onScreen(m: Monster): boolean {\n return this.screenX(m.x) < 1;\n }\n\n reachDistance(): number {\n if (this.reach === \"ranged\") return RANGED_REACH;\n const m = this.front();\n return m ? this.contact(m) : MELEE_REACH;\n }\n\n contact(m: Monster): number {\n return m.boss ? MELEE_REACH * BOSS_SIZE : MELEE_REACH;\n }\n\n front(): Monster | undefined {\n return this.monsters[0];\n }\n\n /** Is this troop fighting the hero now — a shooter in range, a brawler first in line face to face. */\n engaged(m: Monster): boolean {\n if (this.result) return false;\n const distance = m.x - this.heroX;\n if (m.range > 0) return distance <= m.range + 1e-6;\n return m === this.front() && distance <= this.contact(m) + 1e-6;\n }\n\n /** A brawler first in line that steps up to the hero (within the hero's reach, not yet face to face). */\n closing(m: Monster): boolean {\n return (\n m.range === 0 &&\n m === this.front() &&\n this.hasTarget() &&\n m.x - this.heroX > this.contact(m) + 1e-6\n );\n }\n\n heroNextShotIn(): number {\n return Math.max(0, this.heroCooldown) / Math.max(0.01, this.heroSpeed());\n }\n\n hasTarget(): boolean {\n const m = this.front();\n return !!m && m.x - this.heroX <= this.reachDistance() + 1e-6;\n }\n\n get heroDown(): boolean {\n return this.heroHp <= 0;\n }\n\n /** The armor value a troop stands in now (its points less what is broken off). */\n armorOf(m: Monster): number {\n return m.armor - m.broken;\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** Advance by `dt` seconds (clamped — a hidden tab must not resolve a minute in one step). */\n tick(dt: number): BattleEvent[] {\n // what happened between the frames (a tapped ultimate, a potion, a card) comes first\n const out = this.events;\n let left = Math.min(Math.max(0, dt), 0.25);\n while (left > 0 && !this.result && !this.pending) {\n const step = Math.min(left, 1 / 30);\n this.step(step);\n left -= step;\n }\n this.events = [];\n return out;\n }\n\n private step(dt: number): void {\n this.time += dt;\n this.ultActiveLeft = Math.max(0, this.ultActiveLeft - dt);\n for (const m of this.monsters) m.stunned = Math.max(0, m.stunned - dt);\n\n if (this.autoPlay) {\n if (this.heroHp < this.heroMaxHp() * AUTO_POTION_BELOW)\n this.drinkPotion();\n if (this.ultReady && this.ultWorthIt()) this.castUlt();\n }\n\n this.heroMove(dt);\n this.heroSwing(dt);\n this.moveProjectiles(dt);\n this.monstersAct(dt);\n this.moveEnemyShots(dt);\n if (this.result || this.pending) return;\n\n if (this.monsters.length === 0 && !this.feature) this.nextSegment();\n }\n\n /** The hero runs until the first troop is within its reach, or up to what stands on the road. */\n private heroMove(dt: number): void {\n this.heroRecoverLeft = Math.max(0, this.heroRecoverLeft - dt);\n if (this.heroRecoverLeft > 0) {\n this.walking = false;\n return;\n }\n const m = this.front();\n const f = this.feature;\n const stopAt = m ? m.x - this.reachDistance() : f ? f.x - 0.04 : Infinity;\n if (this.heroX >= stopAt - 1e-6) {\n this.walking = false;\n if (!m && f) this.arrive(f);\n return;\n }\n this.walking = true;\n this.heroX = Math.min(stopAt, this.heroX + this.walkSpeed * dt);\n }\n\n private heroSpeed(): number {\n const low =\n this.heroHp < this.heroMaxHp() * LOW_HP ? this.mods.lowHpSpeed : 0;\n return (\n this.stats.attackSpeed *\n (1 + this.mods.attackSpeed + low) *\n (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1)\n );\n }\n\n /** The hero's blow before the target's armor (ATK): its damage with the cards that do not care who is hit. */\n private heroDamage(): number {\n const full =\n this.heroHp >= this.heroMaxHp() - 1e-9 ? this.mods.fullHpDamage : 0;\n return this.stats.damage * (1 + this.mods.damage + full);\n }\n\n private heroSwing(dt: number): void {\n this.heroCooldown -= dt * this.heroSpeed();\n if (!this.hasTarget()) {\n // nobody at hand: the next hit lands a swing after the hero reaches someone\n this.heroCooldown = Math.max(\n this.heroCooldown,\n this.windup * this.heroSpeed(),\n );\n return;\n }\n if (this.heroCooldown > 0) return;\n const target = this.front()!;\n this.heroCooldown += 1;\n this.heroRecoverLeft = this.recover;\n this.attackOnce(target, false);\n // \"attack again at once\"\n const twice = this.stats.multiShot + this.mods.doubleHit;\n if (twice > 0 && this.random() < twice)\n this.attackOnce(this.front() ?? target, true);\n }\n\n /** Rolls a crit, the focus charge first. */\n private rollCrit(bonus = 0): boolean {\n if (this.charges.focus > 0) {\n this.charges.focus--;\n return true;\n }\n return this.random() < this.critChance() + bonus;\n }\n\n private critDamage(): number {\n return this.stats.critMultiplier + this.mods.critDamage;\n }\n\n private attackOnce(target: Monster, extra: boolean): void {\n const crit = this.rollCrit();\n const damage = this.heroDamage() * (crit ? this.critDamage() : 1);\n if (this.reach === \"melee\") {\n const m = this.front();\n if (!m || !this.hasTarget()) return;\n this.emit({ type: \"strike\", monsterId: m.id });\n this.blow(m, damage, crit, true);\n } else {\n const p: Projectile = {\n id: this.nextId++,\n x: this.heroX + 0.04,\n targetId: target.id,\n damage,\n crit,\n extra,\n };\n this.projectiles.push(p);\n this.emit({ type: \"shoot\", projectile: p });\n }\n if (!extra) this.fire(\"attack\", { target: this.front() ?? target });\n }\n\n private moveProjectiles(dt: number): void {\n for (const p of [...this.projectiles]) {\n let target = this.monsters.find((m) => m.id === p.targetId);\n if (!target) {\n target = this.monsters.find((m) => m.x > p.x);\n if (!target) {\n this.drop(this.projectiles, p);\n continue;\n }\n p.targetId = target.id;\n }\n p.x += PROJECTILE_SPEED * dt;\n if (p.x < target.x) continue;\n this.drop(this.projectiles, p);\n this.blow(target, p.damage, p.crit, false);\n }\n }\n\n private drop<T>(list: T[], item: T): void {\n const i = list.indexOf(item);\n if (i >= 0) list.splice(i, 1);\n }\n\n /** The weapon's type plus the cards' other types (each its share of the blow). */\n private components(): Array<[AttackType, number]> {\n const out: Array<[AttackType, number]> = [[this.heroAttack, 1]];\n for (const [type, share] of Object.entries(this.mods.hybrid))\n if (share) out.push([type as AttackType, share]);\n return out;\n }\n\n /**\n * The hero's blow on a troop — its weapon's types through the table and the armor value — and what a\n * blow sets off: the lifesteal, the shred, a lightning charge, the \"on hit\" and \"on crit\" cards.\n */\n private blow(\n m: Monster,\n damage: number,\n crit: boolean,\n melee: boolean,\n ): void {\n if (!this.monsters.includes(m)) return;\n // the one standing behind the target (before the blow: the target may fall)\n const behind = this.monsters[this.monsters.indexOf(m) + 1];\n const dealtNow = this.strikeTyped(\n m,\n damage,\n this.components(),\n crit,\n melee,\n );\n if (behind && this.mods.pierceShare > 0)\n this.strikeTyped(\n behind,\n damage * this.mods.pierceShare,\n this.components(),\n crit,\n melee,\n );\n if (this.mods.lifesteal > 0 && this.random() < this.mods.lifesteal)\n this.healHero(dealtNow * LIFESTEAL_SHARE);\n const alive = this.monsters.includes(m);\n if (alive && this.mods.shred > 0)\n m.broken = Math.min(\n m.armor + this.mods.shredMax,\n m.broken + this.mods.shred,\n );\n if (alive && this.charges.lightning > 0) {\n this.charges.lightning--;\n this.boltAt(\"lightning\", 0.75, [m]);\n }\n this.fire(\"hit\", { target: m });\n if (crit) this.fire(\"crit\", { target: m });\n }\n\n /**\n * Damage of the given types on a troop (the share of each), through the table and its armor value,\n * with the cards that care who is hit. Its shield takes it first. Returns what it dealt.\n */\n private strikeTyped(\n m: Monster,\n damage: number,\n components: ReadonlyArray<[AttackType, number]>,\n crit = false,\n melee = false,\n ): number {\n if (!this.monsters.includes(m)) return 0;\n const md = this.mods;\n const armor = this.armorOf(m);\n let bonus = 1 + (md.vsArmor[m.armorType] ?? 0) + m.breaks * BREAK_BONUS;\n if (m !== this.front()) bonus += md.backRow;\n if (m.stunned > 0) bonus += md.vsStunned;\n if (md.missingHpDamage > 0)\n bonus += Math.floor((1 - m.hp / m.maxHp) * 10) * md.missingHpDamage;\n let total = 0;\n for (const [type, share] of components)\n total += dealt(damage * share, type, m.armorType, armor);\n total *= bonus;\n this.damage(m, total);\n this.emit({\n type: \"hit\",\n monsterId: m.id,\n x: m.x,\n damage: total,\n crit,\n melee,\n effect: effectOf(components[0]![0], m.armorType),\n });\n if (m.hp <= 0) this.kill(m, -m.hp);\n return total;\n }\n\n private damage(m: Monster, amount: number): void {\n const absorbed = Math.min(m.shield, amount);\n m.shield -= absorbed;\n m.hp -= amount - absorbed;\n }\n\n private kill(m: Monster, overflow: number): void {\n const i = this.monsters.indexOf(m);\n if (i < 0) return;\n this.monsters.splice(i, 1);\n this.kills++;\n this.emit({ type: \"kill\", monsterId: m.id, x: m.x, boss: m.boss });\n this.addRage(m.boss ? RAGE_PER_BOSS : RAGE_PER_KILL, true);\n if (m.boss) return this.finish(true);\n this.fire(\"kill\", { target: m, overflow });\n }\n\n // ── The cards' triggers ────────────────────────────────────────────────────────────────────\n\n /** Fire a trigger: every card on it — by its chance, or on its every N-th time. */\n private fire(trigger: Trigger, ctx: FireContext = {}): void {\n const n = (this.fired[trigger] = (this.fired[trigger] ?? 0) + 1);\n if (this.result || this.depth >= MAX_PROC_DEPTH) return;\n this.depth++;\n try {\n for (const p of this.procs) {\n if (p.trigger !== trigger) continue;\n if (p.every ? n % p.every !== 0 : this.random() >= p.chance) continue;\n this.apply(p.effect, ctx);\n if (this.result) return;\n }\n } finally {\n this.depth--;\n }\n }\n\n private apply(e: Effect, ctx: FireContext): void {\n switch (e.kind) {\n case \"bolt\":\n return this.bolt(e.bolt, e.power, e.count, e.at, ctx.target);\n case \"heal\":\n return this.healHero(e.share * this.heroMaxHp());\n case \"repair\":\n return this.repair(\n e.share > 0 ? e.share * this.heroMaxShield() : (ctx.overflow ?? 0),\n );\n case \"stun\": {\n const who = e.all\n ? this.monsters.filter((m) => this.onScreen(m))\n : ctx.target && this.monsters.includes(ctx.target)\n ? [ctx.target]\n : [];\n for (const m of who) {\n m.stunned = Math.max(m.stunned, e.seconds);\n this.emit({ type: \"stun\", monsterId: m.id });\n }\n return;\n }\n case \"charge\":\n this.charges[e.charge] += e.count;\n this.emit({ type: \"charge\", charge: e.charge, count: e.count });\n return;\n case \"break\":\n if (ctx.target && this.monsters.includes(ctx.target))\n ctx.target.breaks = Math.min(BREAK_MAX, ctx.target.breaks + 1);\n return;\n case \"again\": {\n const m = this.front();\n if (m && this.hasTarget()) this.attackOnce(m, true);\n return;\n }\n case \"cleave\": {\n const t = ctx.target;\n const i = t ? this.monsters.indexOf(t) : -1;\n const next = this.monsters[i + 1];\n if (next) this.strikeTyped(next, this.heroDamage(), this.components());\n return;\n }\n case \"gainCrit\":\n this.runCrit += e.value;\n return;\n case \"ultBack\":\n if (!this.ultReady) {\n this.ultReady = true;\n this.emit({ type: \"ultBack\" });\n }\n return;\n case \"overkillShield\":\n return this.repair(ctx.overflow ?? 0, false);\n case \"hurtToHeal\":\n ctx.converted = true;\n return;\n case \"flurry\": {\n const t = ctx.target;\n for (let k = 0; k < 30 && t && this.monsters.includes(t); k++) {\n const crit = this.rollCrit();\n this.blow(\n t,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n }\n return;\n }\n case \"explode\": {\n const dead = ctx.target;\n const next = this.front();\n if (!dead || !next) return;\n this.emit({ type: \"blast\", monsterId: dead.id, x: dead.x });\n this.strikeTyped(next, dead.maxHp * e.share, [[\"spells\", 1]]);\n return;\n }\n }\n }\n\n /** A card's missiles: who they are for, then the damage (no \"on hit\" cards — those are the hero's blows). */\n private bolt(\n kind: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n target: Monster | undefined,\n ): void {\n const seen = this.monsters.filter((m) => this.onScreen(m));\n if (seen.length === 0) return;\n if (at === \"line\") return this.boltAt(kind, power, seen);\n for (let k = 0; k < count; k++) {\n const live = this.monsters.filter((m) => this.onScreen(m));\n if (live.length === 0) return;\n const who =\n at === \"random\"\n ? live[Math.floor(this.random() * live.length)]!\n : at === \"farthest\"\n ? live[live.length - 1]!\n : at === \"target\" && target && this.monsters.includes(target)\n ? target\n : live[0]!;\n this.boltAt(kind, power, [who]);\n }\n }\n\n private boltAt(kind: Bolt, power: number, targets: Monster[]): void {\n this.emit({ type: \"bolt\", bolt: kind, targets: targets.map((m) => m.id) });\n const damage = this.heroDamage() * power;\n for (const m of targets)\n this.strikeTyped(m, damage, [[BOLT_TYPE[kind], 1]]);\n }\n\n /** Health back — and the \"on heal\" / \"on overheal\" cards with what it had over. */\n private healHero(amount: number): void {\n if (amount <= 0 || this.heroHp <= 0) return;\n const before = this.heroHp;\n this.heroHp = Math.min(this.heroMaxHp(), this.heroHp + amount);\n const healed = this.heroHp - before;\n const over = amount - healed;\n if (healed > 0) {\n this.emit({ type: \"heal\", amount: healed });\n this.fire(\"heal\");\n }\n if (over > 1e-9) this.fire(\"overheal\", { overflow: over });\n }\n\n /** The shield back by `amount` (the repair cards make it bigger), never past its max. */\n private repair(amount: number, boosted = true): void {\n const max = this.heroMaxShield();\n if (max <= 0 || amount <= 0) return;\n const got = amount * (boosted ? 1 + this.mods.repairPower : 1);\n this.heroShield = Math.min(max, this.heroShield + got);\n }\n\n // ── The troops ─────────────────────────────────────────────────────────────────────────────\n\n private monstersAct(dt: number): void {\n for (const m of [...this.monsters]) {\n if (this.closing(m)) {\n m.x = Math.max(\n this.heroX + this.contact(m),\n m.x - m.speed * this.walkSpeed * dt,\n );\n continue;\n }\n if (!this.engaged(m) || m.stunned > 0) continue;\n m.cooldown -= dt;\n if (m.cooldown > 0) continue;\n m.cooldown += m.interval;\n if (m.range > 0) {\n const shot: EnemyShot = {\n id: this.nextId++,\n x: m.x - 0.03,\n damage: m.attack,\n attackType: m.attackType,\n };\n this.enemyShots.push(shot);\n this.emit({ type: \"enemyShoot\", shot, monsterId: m.id });\n continue;\n }\n this.emit({ type: \"enemyStrike\", monsterId: m.id });\n this.hurtHero(m.attack, m.attackType, m);\n if (this.result) return;\n }\n }\n\n private moveEnemyShots(dt: number): void {\n for (const s of [...this.enemyShots]) {\n s.x -= ENEMY_SHOT_SPEED * dt;\n if (s.x > this.heroX + 0.02) continue;\n this.drop(this.enemyShots, s);\n this.hurtHero(s.damage, s.attackType, null);\n if (this.result) return;\n }\n }\n\n /**\n * A troop's blow on the hero: dodged, taken by the divine shield, shrugged off while the armor holds,\n * turned into healing — or through the table against the hero's armor type and its armor value, the\n * shield first. A blow face to face may be countered.\n */\n private hurtHero(raw: number, type: AttackType, from: Monster | null): void {\n if (this.random() < this.stats.dodge + this.mods.dodge) {\n this.emit({ type: \"dodge\" });\n this.fire(\"dodge\");\n return;\n }\n if (this.charges.divine > 0) {\n this.charges.divine--;\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n if (\n this.heroShield > 0 &&\n this.mods.shieldIgnore > 0 &&\n this.random() < this.mods.shieldIgnore\n ) {\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n let damage =\n raw *\n typeFactor(type, this.heroArmorType) *\n armorFactor(this.heroArmorValue());\n if (type === \"pierce\") damage *= 1 - Math.min(0.8, this.mods.vsPierce);\n const ctx: FireContext = {};\n this.fire(\"hurt\", ctx);\n if (ctx.converted) {\n this.emit({ type: \"heroHit\", damage, shield: 0, healed: true });\n this.healHero(damage);\n return this.counter(from);\n }\n const absorbed = Math.min(this.heroShield, damage);\n this.heroShield -= absorbed;\n this.heroHp -= damage - absorbed;\n this.emit({ type: \"heroHit\", damage, shield: absorbed, healed: false });\n if (this.heroHp > 0 || this.result) return this.counter(from);\n this.heroHp = 0;\n this.emit({ type: \"heroDown\" });\n this.finish(false);\n }\n\n /** The counter: a blow taken face to face may be answered at once with the hero's own. */\n private counter(from: Monster | null): void {\n if (!from || this.mods.counter <= 0 || !this.monsters.includes(from))\n return;\n if (this.random() >= this.mods.counter) return;\n this.emit({ type: \"counter\", monsterId: from.id });\n const crit = this.rollCrit(this.mods.counterCrit);\n this.blow(\n from,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n this.fire(\"counter\", { target: from });\n }\n\n /** Auto's moment for the one ultimate: the boss in reach, a crowd on screen, or the hero in trouble. */\n private ultWorthIt(): boolean {\n if (!this.hasTarget()) return false;\n if (this.monsters.some((m) => m.boss)) return true;\n if (this.monsters.filter((m) => this.onScreen(m)).length >= AUTO_ULT_CROWD)\n return true;\n return this.heroHp < this.heroMaxHp() * AUTO_ULT_BELOW;\n }\n\n // ── Rage and run levels ────────────────────────────────────────────────────────────────────\n\n private addRage(amount: number, fromKill: boolean): void {\n const got = amount * (fromKill ? 1 + this.mods.rage : 1);\n this.rage += got;\n this.emit({ type: \"rage\", amount: got });\n while (this.rage >= rageFor(this.runLevel)) {\n this.runLevel++;\n this.queued++;\n this.emit({ type: \"levelUp\", level: this.runLevel });\n this.fire(\"level\");\n }\n if (!this.result) this.offerCards();\n }\n\n private draw(): string[] {\n return drawCards(this.random, this.level.island.index, this.cards, {\n counter: this.mods.counter > 0,\n dodge: this.stats.dodge + this.mods.dodge > 0,\n shield: this.shielded,\n });\n }\n\n /** The next choice of three, if one is owed and none is open (\"Upgrade: 2/5\" counts a row of them). */\n private offerCards(): void {\n while (this.queued > 0 && !this.pending) {\n // a new row of choices starts when the last one is done\n if (this.choice.index >= this.choice.total)\n this.choice = { index: 0, total: this.queued };\n this.queued--;\n this.choice.index++;\n const options = this.draw();\n if (options.length > 0)\n this.pending = { kind: \"cards\", options, ...this.choice };\n }\n }\n\n // ── The road ───────────────────────────────────────────────────────────────────────────────\n\n private nextSegment(): void {\n this.segment++;\n const segments = this.level.segments;\n if (this.segment < segments.length)\n return this.startSegment(segments[this.segment]!);\n if (this.segment === segments.length) {\n this.lineUp([this.level.boss], Math.min(this.waveAhead, 0.72));\n this.emit({ type: \"bossStart\" });\n }\n }\n\n private startSegment(s: Segment): void {\n if (s.kind === \"wave\") return this.lineUp(s.troops, this.waveAhead);\n this.feature = {\n id: this.nextId++,\n kind: s.kind,\n gives: s.kind === \"fountain\" ? s.gives : undefined,\n x: this.heroX + this.waveAhead,\n };\n this.emit({ type: \"feature\", feature: this.feature });\n }\n\n /** The hero is at what stands on the road. */\n private arrive(f: RoadFeature): void {\n this.emit({ type: \"reached\", feature: f });\n if (f.kind === \"camp\") {\n this.pending = { kind: \"camp\" };\n return;\n }\n this.feature = null;\n if (f.kind === \"chest\") this.chests++;\n else if (f.gives === \"rage\") this.addRage(FOUNTAIN_RAGE, false);\n else this.healHero(FOUNTAIN_HEAL * this.heroMaxHp());\n }\n\n private lineUp(ids: readonly TroopId[], ahead: number): void {\n const power = this.level.power;\n const grow = Math.floor((this.level.index - 1) / 4);\n this.monsters = ids.map((tid, i) => {\n const troop = TROOPS[tid];\n const hp = BASE_HP * power * troop.hp;\n const shield = troopShielded(troop) ? hp * SHIELD_SHARE : 0;\n const armor = troop.armor + grow;\n return {\n id: this.nextId++,\n troop,\n boss: !!troop.boss,\n hp,\n maxHp: hp,\n shield,\n maxShield: shield,\n attack: BASE_ATTACK * power * troop.attack,\n attackType: troopAttack(troop),\n armorType: troopArmor(troop),\n armor,\n broken: Math.min(armor + this.mods.shredMax, this.mods.warCry),\n breaks: 0,\n interval: troop.interval,\n speed: troop.speed,\n range: troopRanged(troop) ? TROOP_RANGE : 0,\n stunned: 0,\n x: this.heroX + ahead + i * this.monsterGap,\n cooldown: troop.interval * 0.5,\n };\n });\n for (const m of this.monsters) this.emit({ type: \"spawn\", monster: m });\n }\n\n private finish(cleared: boolean): void {\n this.walking = false;\n this.pending = null;\n this.projectiles = [];\n this.enemyShots = [];\n this.result = {\n cleared,\n level: this.level.index,\n kills: this.kills,\n runLevel: this.runLevel,\n chests: this.chests,\n seconds: Math.round(this.time),\n cards: [...this.cards],\n potionsUsed: this.potionsUsed,\n };\n this.emit({ type: \"end\", result: this.result });\n }\n\n private emit(e: BattleEvent): void {\n this.events.push(e);\n }\n\n // ── Reading ────────────────────────────────────────────────────────────────────────────────\n\n snapshot(): BattleSnapshot {\n const boss = this.monsters.find((m) => m.boss);\n return {\n level: this.level.index,\n island: this.level.island.index,\n segment: Math.max(0, this.segment),\n segments: this.level.segments.length,\n heroHp: Math.max(0, this.heroHp),\n heroMaxHp: this.heroMaxHp(),\n heroShield: Math.max(0, this.heroShield),\n heroMaxShield: this.heroMaxShield(),\n heroDown: this.heroDown,\n walking: this.walking,\n rage: this.rage,\n rageFrom: this.runLevel > 0 ? rageFor(this.runLevel - 1) : 0,\n rageTo: rageFor(this.runLevel),\n runLevel: this.runLevel,\n pending: this.pending,\n rerollsLeft: this.rerollsLeft,\n potions: this.potions,\n ult: this.ult,\n ultReady: this.ultReady,\n ultActiveLeft: this.ultActiveLeft,\n charges: { ...this.charges },\n autoPlay: this.autoPlay,\n kills: this.kills,\n chests: this.chests,\n cards: [...this.cards],\n result: this.result,\n monsters: this.monsters.length,\n bossHp: boss ? Math.max(0, boss.hp) : 0,\n bossMaxHp: boss?.maxHp ?? 0,\n };\n }\n}\n"
330
+ "content": "// One RUN of a level — a deterministic simulation with no Phaser in it, so it is tested on its own\n// (game.test.ts) and drawn by the scene from the events each tick returns.\n//\n// The road (Obsidian Knight's march), in lane widths: the hero runs right along it and the world\n// scrolls past — on screen the hero stays at HERO_X. The level's segments come one after another: a\n// wave of troops stands on the road ahead and waits (brawlers one at a time, face to face; shooters\n// fire once the hero is in their range), a camp, a fountain or a chest stands there to be reached,\n// and the boss closes the road. Kills fill the RAGE; each threshold is a run level and a choice of\n// one of three cards (cards.ts) — the run stops until the player takes one. Death ends the run.\n//\n// The cards are triggers: the battle FIRES them — on a hit, a crit, a kill, a dodge, a counter, a blow\n// taken, a heal, the ultimate, the camp, every N-th attack, a new run level — and their effects throw\n// Lightning, Axes, Spears and Arrows, stun, heal, repair the shield, give charges.\n//\n// Damage goes by type (damage.ts): the hero's weapon has an attack type, every troop an armor type\n// and an armor value, and so does the hero. A shield is a pool on top of the health that takes the\n// blows first (the blue bar) — the hero's when it carries one, a troop's when it does.\n\nimport {\n armorFactor,\n armorReduction,\n dealt,\n effectOf,\n typeFactor,\n} from \"./damage\";\nimport type { ArmorType, AttackType, Effect as TypeEffect } from \"./damage\";\nimport {\n BOLT_TYPE,\n drawCards,\n modsOf,\n noMods,\n procsOf,\n type Bolt,\n type Effect,\n type Proc,\n type RunMods,\n type Trigger,\n} from \"./cards\";\nimport type { FighterStats } from \"./heroStats\";\nimport type { LevelDef, Segment } from \"./campaign\";\nimport { seededRandom } from \"./random\";\nimport {\n TROOPS,\n TROOP_RANGE,\n troopArmor,\n troopAttack,\n troopRanged,\n troopShielded,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\n/** Where the hero stands on screen (share of the lane); the road scrolls under it. */\nexport const HERO_X = 0.2;\n/** The hero's run, lane widths per second — the most; the scene lowers it to what the hero's legs\n * carry at its size (`setWalkSpeed`), so the feet never skate. */\nexport const WALK_SPEED = 0.2;\n/** A melee blow lands from this far: the hero stops here before a troop, face to face. */\nexport const MELEE_REACH = 0.13;\n/** The boss is drawn this much bigger (the scene) — face to face with it is that much farther. */\nexport const BOSS_SIZE = 1.4;\n/** A ranged weapon (a bow, a gun, a staff) shoots from this far. */\nexport const RANGED_REACH = 0.55;\n/** The first troop of a new wave stands this far ahead (just past the screen edge). */\nexport const WAVE_AHEAD = 0.85;\n/** The gap between troops waiting in line. */\nexport const MONSTER_GAP = 0.14;\nexport const PROJECTILE_SPEED = 1.9;\nexport const ENEMY_SHOT_SPEED = 1.1;\n\n/** A plain troop's health and attack at level 1 (× its troop's share × the level's power). */\nexport const BASE_HP = 26;\nexport const BASE_ATTACK = 3;\n/** A shield's pool: this share of its bearer's max health. */\nexport const SHIELD_SHARE = 0.5;\n\n/** Total rage for each run level (Obsidian Knight's table); past the last it grows on. */\nexport const RAGE_THRESHOLDS = [\n 3, 7, 14, 22, 32, 44, 58, 74, 92, 112, 134, 158,\n];\nexport const RAGE_PER_KILL = 1;\nexport const RAGE_PER_BOSS = 5;\nexport const FOUNTAIN_RAGE = 5;\nexport const FOUNTAIN_HEAL = 0.3;\nexport const CAMP_RAGE = 5;\nexport const CAMP_HEAL = 0.4;\n\nexport const POTION_HEAL = 0.35;\n/** With auto on, a potion goes down below this share of health. */\nexport const AUTO_POTION_BELOW = 0.35;\n\n/** New three cards for the same choice, a run (\"Refresh\"). */\nexport const REROLLS_PER_RUN = 1;\n\nexport type UltId = \"meteor\" | \"frenzy\";\n/**\n * The ultimate: one slot, picked before the run, ready from its start and used ONCE a run (owner's\n * rule 23.10.2026) — only a card brings it back, by a chance at a kill or a new run level.\n */\nexport const ULTS: Readonly<Record<UltId, { duration: number }>> = {\n meteor: { duration: 0 },\n frenzy: { duration: 6 },\n};\n/** With auto on, the one ultimate waits for a moment worth it: the boss, a crowd on screen, or trouble. */\nexport const AUTO_ULT_CROWD = 4;\nexport const AUTO_ULT_BELOW = 0.4;\n/** The meteor hits every troop on screen for this many hero blows (spells — through any armor). */\nexport const METEOR_POWER = 4;\nexport const FRENZY_SPEED = 2;\n\n/** A lifesteal heals this share of the blow that triggered it. */\nexport const LIFESTEAL_SHARE = 0.3;\n/** A \"Shield break\" stack: + this share of damage on its target. */\nexport const BREAK_BONUS = 0.08;\n/** At most this many \"Shield break\" stacks on one troop. */\nexport const BREAK_MAX = 4;\n/** Below this share of health the low-health cards hold. */\nexport const LOW_HP = 0.3;\n/** How deep effects may set off effects (a kill's arrow killing, its heal striking…). */\nconst MAX_PROC_DEPTH = 4;\n\nexport type Reach = \"melee\" | \"ranged\";\n\n/**\n * A line of the hero's sheet in a run (Obsidian Knight's eight): its own number (gear, rank) and the\n * number now, with the cards. Defense is the share the armor value takes off a blow; the counter and\n * the lifesteal are chances. There is no health regen in the game — health comes back from potions,\n * the camp, fountains and the cards.\n */\nexport type HeroNumber =\n | \"damage\"\n | \"attackSpeed\"\n | \"critChance\"\n | \"critMultiplier\"\n | \"defense\"\n | \"counter\"\n | \"dodge\"\n | \"lifesteal\";\n\n/** In the sheet's order: the left column, then the right. */\nexport const HERO_NUMBERS: readonly HeroNumber[] = [\n \"damage\",\n \"attackSpeed\",\n \"critChance\",\n \"critMultiplier\",\n \"defense\",\n \"counter\",\n \"dodge\",\n \"lifesteal\",\n];\n\nexport interface Monster {\n id: number;\n troop: TroopDef;\n boss: boolean;\n hp: number;\n maxHp: number;\n /** Its shield's pool (0 — none): blows take it first. */\n shield: number;\n maxShield: number;\n attack: number;\n attackType: AttackType;\n armorType: ArmorType;\n /** Its armor points; `broken` of them are knocked off (past zero with the shred cards). */\n armor: number;\n broken: number;\n /** \"Shield break\" stacks on it: + BREAK_BONUS damage each. */\n breaks: number;\n /** Seconds between its blows; how fast a brawler closes the last step (× the hero's run). */\n interval: number;\n speed: number;\n /** Lane widths it shoots from; 0 — it fights face to face. */\n range: number;\n stunned: number;\n /** Where it stands on the road (lane widths, the same axis as `Battle.heroX`). */\n x: number;\n cooldown: number;\n}\n\n/** The hero's shot (a ranged weapon only — a melee blow lands at once). */\nexport interface Projectile {\n id: number;\n x: number;\n targetId: number;\n damage: number;\n crit: boolean;\n extra: boolean;\n}\n\n/** A troop's shot flying at the hero. */\nexport interface EnemyShot {\n id: number;\n x: number;\n damage: number;\n attackType: AttackType;\n}\n\n/** A camp, a fountain or a chest standing on the road. */\nexport interface RoadFeature {\n id: number;\n kind: \"camp\" | \"fountain\" | \"chest\";\n gives?: \"heal\" | \"rage\";\n x: number;\n}\n\nexport type Pending =\n /** Choice `index` of `total` in a row of run levels (\"Upgrade: 1/5\"). */\n | { kind: \"cards\"; options: string[]; index: number; total: number }\n | { kind: \"camp\" };\n\nexport type CampChoice = \"heal\" | \"rage\" | \"both\";\n\nexport type Charge = \"focus\" | \"divine\" | \"lightning\";\n\nexport interface RunResult {\n cleared: boolean;\n /** The level played (1-based across islands). */\n level: number;\n kills: number;\n runLevel: number;\n chests: number;\n seconds: number;\n cards: string[];\n /** The player's own potions drunk — they leave the wallet. A card's potions go first and cost nothing. */\n potionsUsed: number;\n}\n\nexport type BattleEvent =\n | { type: \"spawn\"; monster: Monster }\n | { type: \"feature\"; feature: RoadFeature }\n | { type: \"reached\"; feature: RoadFeature }\n | { type: \"shoot\"; projectile: Projectile }\n | { type: \"strike\"; monsterId: number }\n | { type: \"enemyShoot\"; shot: EnemyShot; monsterId: number }\n | { type: \"enemyStrike\"; monsterId: number }\n | {\n type: \"hit\";\n monsterId: number;\n x: number;\n damage: number;\n crit: boolean;\n melee: boolean;\n /** How the hero's attack type fares against its armor (the number's colour). */\n effect: TypeEffect;\n }\n | { type: \"kill\"; monsterId: number; x: number; boss: boolean }\n /** A card's missile: from the hero to these troops (the scene draws it). */\n | { type: \"bolt\"; bolt: Bolt; targets: number[] }\n | { type: \"blast\"; monsterId: number; x: number }\n | { type: \"stun\"; monsterId: number }\n | { type: \"heroHit\"; damage: number; shield: number; healed: boolean }\n /** A blow that did not land: the divine shield took it, or the armor shrugged it off. */\n | { type: \"guard\" }\n | { type: \"dodge\" }\n | { type: \"counter\"; monsterId: number }\n | { type: \"charge\"; charge: Charge; count: number }\n | { type: \"heal\"; amount: number }\n | { type: \"heroDown\" }\n | { type: \"rage\"; amount: number }\n | { type: \"levelUp\"; level: number }\n | { type: \"card\"; id: string }\n | { type: \"potion\" }\n | { type: \"ult\"; ult: UltId }\n | { type: \"ultBack\" }\n | { type: \"bossStart\" }\n | { type: \"end\"; result: RunResult };\n\nexport interface BattleSnapshot {\n level: number;\n island: number;\n /** Segment now (0-based; === segments → the boss), of how many. */\n segment: number;\n segments: number;\n heroHp: number;\n heroMaxHp: number;\n heroShield: number;\n heroMaxShield: number;\n heroDown: boolean;\n walking: boolean;\n rage: number;\n /** Rage the current run level started at, and the next one needs. */\n rageFrom: number;\n rageTo: number;\n runLevel: number;\n pending: Pending | null;\n rerollsLeft: number;\n potions: number;\n ult: UltId;\n /** The ultimate is there to use (once a run; a card may bring it back). */\n ultReady: boolean;\n ultActiveLeft: number;\n charges: Record<Charge, number>;\n autoPlay: boolean;\n kills: number;\n chests: number;\n cards: string[];\n result: RunResult | null;\n monsters: number;\n bossHp: number;\n bossMaxHp: number;\n}\n\n/** Total rage a run level needs (level 0 → the first threshold). */\nexport function rageFor(level: number): number {\n const n = Math.max(0, Math.floor(level));\n if (n < RAGE_THRESHOLDS.length) return RAGE_THRESHOLDS[n]!;\n const last = RAGE_THRESHOLDS.length - 1;\n let total = RAGE_THRESHOLDS[last]!;\n let step = RAGE_THRESHOLDS[last]! - RAGE_THRESHOLDS[last - 1]!;\n for (let i = last; i < n; i++) {\n step += 2;\n total += step;\n }\n return total;\n}\n\nconst IDLE_STATS: FighterStats = {\n maxHp: 100,\n damage: 10,\n armor: 0,\n attackSpeed: 1,\n critChance: 0,\n critMultiplier: 1.5,\n dodge: 0,\n regen: 0,\n multiShot: 0,\n};\n\nexport interface RunOptions {\n level: LevelDef;\n stats?: FighterStats;\n /** What the hero's weapon deals and what its body wears (the session reads them off the gear). */\n heroAttack?: AttackType;\n heroArmor?: ArmorType;\n /** Does the hero carry a shield (its blue pool). */\n shield?: boolean;\n ult?: UltId;\n potions?: number;\n seed?: number;\n}\n\n/** What a fired trigger knows: who it is about, what was over. */\ninterface FireContext {\n target?: Monster;\n /** The killing blow's damage past the health; the heal past the max. */\n overflow?: number;\n /** A \"turn the blow into healing\" fired — the blow does not land. */\n converted?: boolean;\n}\n\nexport class Battle {\n readonly level: LevelDef;\n readonly heroAttack: AttackType;\n readonly heroArmorType: ArmorType;\n readonly shielded: boolean;\n readonly ult: UltId;\n\n stats: FighterStats = IDLE_STATS;\n mods: RunMods = noMods();\n procs: Proc[] = [];\n cards: string[] = [];\n heroHp = IDLE_STATS.maxHp;\n /** The hero's shield pool — takes the blows before its health. */\n heroShield = 0;\n /** How far the hero has run (lane widths). */\n heroX = 0;\n /** The hero is on the move this step (nobody and nothing within its reach). */\n walking = false;\n walkSpeed = WALK_SPEED;\n waveAhead = WAVE_AHEAD;\n monsterGap = MONSTER_GAP;\n reach: Reach = \"melee\";\n /** Seconds from the start of the hero's attack to its hit (the scene sets it from the animation). */\n windup = 0;\n /** Seconds the attack plays on after its hit — the hero does not set off before it is over. */\n recover = 0;\n /** Standing in line, nearest first. */\n monsters: Monster[] = [];\n projectiles: Projectile[] = [];\n enemyShots: EnemyShot[] = [];\n feature: RoadFeature | null = null;\n /** The segment now (-1 — before the first; === segments.length — the boss). */\n segment = -1;\n\n rage = 0;\n runLevel = 0;\n /** What the run waits for (a card, the camp); the clock stands still meanwhile. */\n pending: Pending | null = null;\n rerollsLeft = REROLLS_PER_RUN;\n readonly rerollsTotal = REROLLS_PER_RUN;\n potions: number;\n /** Of `potions`, those a card brought for this run (not the player's): drunk first. */\n cardPotions = 0;\n potionsUsed = 0;\n ultReady = true;\n ultActiveLeft = 0;\n /** Charges the cards gave: focus, divine shield, lightning strikes. */\n charges: Record<Charge, number> = { focus: 0, divine: 0, lightning: 0 };\n /** Crit chance the kills have added this run. */\n runCrit = 0;\n /** Potions and the ultimate go by themselves. */\n autoPlay = true;\n kills = 0;\n chests = 0;\n time = 0;\n result: RunResult | null = null;\n\n private queued = 0;\n private choice = { index: 0, total: 0 };\n private fired: Partial<Record<Trigger, number>> = {};\n private depth = 0;\n private heroCooldown = 0;\n private heroRecoverLeft = 0;\n private nextId = 1;\n private readonly random: () => number;\n private events: BattleEvent[] = [];\n\n constructor(opts: RunOptions) {\n this.level = opts.level;\n this.heroAttack = opts.heroAttack ?? \"normal\";\n this.heroArmorType = opts.heroArmor ?? \"unarmored\";\n this.shielded = !!opts.shield;\n this.ult = opts.ult ?? \"meteor\";\n this.potions = Math.max(0, Math.floor(opts.potions ?? 0));\n this.random = seededRandom(opts.seed ?? Date.now());\n if (opts.stats) this.stats = opts.stats;\n this.heroHp = this.heroMaxHp();\n this.heroShield = this.heroMaxShield();\n }\n\n // ── The hero's numbers with the cards ──────────────────────────────────────────────────────\n\n heroMaxHp(): number {\n return this.stats.maxHp * (1 + this.mods.maxHp);\n }\n\n heroMaxShield(): number {\n return this.shielded ? this.heroMaxHp() * SHIELD_SHARE : 0;\n }\n\n heroArmorValue(): number {\n return this.stats.armor + this.mods.armor;\n }\n\n private critChance(): number {\n return this.stats.critChance + this.mods.critChance + this.runCrit;\n }\n\n /**\n * The hero's sheet: each number as the hero came (gear, rank) and as it fights now with the cards —\n * the screen shows the difference (\"+15\"). The conditions that hold right now count (full health,\n * low health).\n */\n heroNumbers(): Record<HeroNumber, { base: number; now: number }> {\n const s = this.stats;\n const m = this.mods;\n return {\n damage: { base: s.damage, now: this.heroDamage() },\n attackSpeed: {\n base: s.attackSpeed,\n now: this.heroSpeed() / (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1),\n },\n critChance: {\n base: s.critChance,\n now: Math.min(1, this.critChance()),\n },\n critMultiplier: {\n base: s.critMultiplier,\n now: s.critMultiplier + m.critDamage,\n },\n defense: {\n base: armorReduction(s.armor),\n now: armorReduction(this.heroArmorValue()),\n },\n counter: { base: 0, now: Math.min(1, m.counter) },\n dodge: { base: s.dodge, now: Math.min(1, s.dodge + m.dodge) },\n lifesteal: { base: 0, now: Math.min(1, m.lifesteal) },\n };\n }\n\n /** New numbers of the hero (gear, another hero): health and shield keep their share. */\n setStats(stats: FighterStats): void {\n const hpShare = this.heroHp / Math.max(1, this.heroMaxHp());\n const shieldMax = this.heroMaxShield();\n const shieldShare = shieldMax > 0 ? this.heroShield / shieldMax : 1;\n this.stats = stats;\n this.heroHp = Math.max(1, hpShare * this.heroMaxHp());\n this.heroShield = shieldShare * this.heroMaxShield();\n }\n\n setWalkSpeed(speed: number): void {\n this.walkSpeed = Math.max(0.01, Math.min(WALK_SPEED, speed));\n }\n\n setMonsterGap(gap: number): void {\n this.monsterGap = Math.max(0.08, Math.min(0.3, gap));\n }\n\n setWaveAhead(ahead: number): void {\n this.waveAhead = Math.max(0.6, Math.min(2, ahead));\n }\n\n setReach(reach: Reach): void {\n this.reach = reach;\n }\n\n /** How the hero's attack is timed: the swing before its hit, the follow-through after it (s, 0…1). */\n setSwing(windup: number, recover: number): void {\n this.windup = Math.max(0, Math.min(1, windup));\n this.recover = Math.max(0, Math.min(1, recover));\n }\n\n setAutoPlay(on: boolean): void {\n this.autoPlay = on;\n }\n\n // ── The player's moves ─────────────────────────────────────────────────────────────────────\n\n /** Take one of the three cards offered. `false` — no choice is open, or it is not one of them. */\n takeCard(id: string): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || !p.options.includes(id)) return false;\n const maxBefore = this.heroMaxHp();\n const shieldBefore = this.heroMaxShield();\n const potionsBefore = this.mods.extraPotions;\n this.cards.push(id);\n this.setCards(this.cards);\n // a card that brings potions brings them into this run\n const brought = Math.max(0, this.mods.extraPotions - potionsBefore);\n this.potions += brought;\n this.cardPotions += brought;\n // a card that raises the max raises the health by as much\n this.heroHp += Math.max(0, this.heroMaxHp() - maxBefore);\n this.heroShield += Math.max(0, this.heroMaxShield() - shieldBefore);\n this.pending = null;\n this.emit({ type: \"card\", id });\n this.offerCards();\n return true;\n }\n\n /** Put these cards on the run (what they add and what they fire) — a test sets a build this way. */\n setCards(cards: readonly string[]): void {\n this.cards = [...cards];\n this.mods = modsOf(this.cards);\n this.procs = procsOf(this.cards);\n }\n\n /** \"Refresh\": three other cards for the same choice (REROLLS_PER_RUN a run). */\n reroll(): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || this.rerollsLeft <= 0) return false;\n this.rerollsLeft--;\n const options = this.draw();\n if (options.length > 0) this.pending = { ...p, options };\n return true;\n }\n\n /** The camp: health or rage — or both (the session takes the gems for it first). */\n chooseCamp(choice: CampChoice): boolean {\n if (this.pending?.kind !== \"camp\") return false;\n this.pending = null;\n this.feature = null;\n if (choice !== \"rage\") this.healHero(CAMP_HEAL * this.heroMaxHp());\n if (choice !== \"heal\") this.addRage(CAMP_RAGE, false);\n this.fire(\"camp\");\n return true;\n }\n\n drinkPotion(): boolean {\n if (this.result || this.pending || this.potions <= 0) return false;\n if (this.heroHp >= this.heroMaxHp()) return false;\n this.potions--;\n if (this.cardPotions > 0) this.cardPotions--;\n else this.potionsUsed++;\n this.emit({ type: \"potion\" });\n this.healHero(POTION_HEAL * (1 + this.mods.potionPower) * this.heroMaxHp());\n return true;\n }\n\n /** The ultimate — once a run, unless a card brought it back. */\n castUlt(): boolean {\n if (this.result || this.pending || !this.ultReady) return false;\n this.ultReady = false;\n this.emit({ type: \"ult\", ult: this.ult });\n if (this.ult === \"frenzy\") this.ultActiveLeft = ULTS.frenzy.duration;\n else {\n const damage = this.heroDamage() * METEOR_POWER;\n for (const m of [...this.monsters])\n if (this.onScreen(m)) this.strikeTyped(m, damage, [[\"spells\", 1]]);\n }\n this.fire(\"ult\");\n return true;\n }\n\n /** Leave the run (it ends as lost). */\n abandon(): void {\n if (!this.result) this.finish(false);\n }\n\n // ── Reading the road ───────────────────────────────────────────────────────────────────────\n\n screenX(x: number): number {\n return HERO_X + (x - this.heroX);\n }\n\n onScreen(m: Monster): boolean {\n return this.screenX(m.x) < 1;\n }\n\n reachDistance(): number {\n if (this.reach === \"ranged\") return RANGED_REACH;\n const m = this.front();\n return m ? this.contact(m) : MELEE_REACH;\n }\n\n contact(m: Monster): number {\n return m.boss ? MELEE_REACH * BOSS_SIZE : MELEE_REACH;\n }\n\n front(): Monster | undefined {\n return this.monsters[0];\n }\n\n /** Is this troop fighting the hero now — a shooter in range, a brawler first in line face to face. */\n engaged(m: Monster): boolean {\n if (this.result) return false;\n const distance = m.x - this.heroX;\n if (m.range > 0) return distance <= m.range + 1e-6;\n return m === this.front() && distance <= this.contact(m) + 1e-6;\n }\n\n /** A brawler first in line that steps up to the hero (within the hero's reach, not yet face to face). */\n closing(m: Monster): boolean {\n return (\n m.range === 0 &&\n m === this.front() &&\n this.hasTarget() &&\n m.x - this.heroX > this.contact(m) + 1e-6\n );\n }\n\n heroNextShotIn(): number {\n return Math.max(0, this.heroCooldown) / Math.max(0.01, this.heroSpeed());\n }\n\n hasTarget(): boolean {\n const m = this.front();\n return !!m && m.x - this.heroX <= this.reachDistance() + 1e-6;\n }\n\n get heroDown(): boolean {\n return this.heroHp <= 0;\n }\n\n /** The armor value a troop stands in now (its points less what is broken off). */\n armorOf(m: Monster): number {\n return m.armor - m.broken;\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** Advance by `dt` seconds (clamped — a hidden tab must not resolve a minute in one step). */\n tick(dt: number): BattleEvent[] {\n // what happened between the frames (a tapped ultimate, a potion, a card) comes first\n const out = this.events;\n let left = Math.min(Math.max(0, dt), 0.25);\n while (left > 0 && !this.result && !this.pending) {\n const step = Math.min(left, 1 / 30);\n this.step(step);\n left -= step;\n }\n this.events = [];\n return out;\n }\n\n private step(dt: number): void {\n this.time += dt;\n this.ultActiveLeft = Math.max(0, this.ultActiveLeft - dt);\n for (const m of this.monsters) m.stunned = Math.max(0, m.stunned - dt);\n\n if (this.autoPlay) {\n if (this.heroHp < this.heroMaxHp() * AUTO_POTION_BELOW)\n this.drinkPotion();\n if (this.ultReady && this.ultWorthIt()) this.castUlt();\n }\n\n this.heroMove(dt);\n this.heroSwing(dt);\n this.moveProjectiles(dt);\n this.monstersAct(dt);\n this.moveEnemyShots(dt);\n if (this.result || this.pending) return;\n\n if (this.monsters.length === 0 && !this.feature) this.nextSegment();\n }\n\n /** The hero runs until the first troop is within its reach, or up to what stands on the road. */\n private heroMove(dt: number): void {\n this.heroRecoverLeft = Math.max(0, this.heroRecoverLeft - dt);\n if (this.heroRecoverLeft > 0) {\n this.walking = false;\n return;\n }\n const m = this.front();\n const f = this.feature;\n const stopAt = m ? m.x - this.reachDistance() : f ? f.x - 0.04 : Infinity;\n if (this.heroX >= stopAt - 1e-6) {\n this.walking = false;\n if (!m && f) this.arrive(f);\n return;\n }\n this.walking = true;\n this.heroX = Math.min(stopAt, this.heroX + this.walkSpeed * dt);\n }\n\n private heroSpeed(): number {\n const low =\n this.heroHp < this.heroMaxHp() * LOW_HP ? this.mods.lowHpSpeed : 0;\n return (\n this.stats.attackSpeed *\n (1 + this.mods.attackSpeed + low) *\n (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1)\n );\n }\n\n /** The hero's blow before the target's armor (ATK): its damage with the cards that do not care who is hit. */\n private heroDamage(): number {\n const full =\n this.heroHp >= this.heroMaxHp() - 1e-9 ? this.mods.fullHpDamage : 0;\n return this.stats.damage * (1 + this.mods.damage + full);\n }\n\n private heroSwing(dt: number): void {\n this.heroCooldown -= dt * this.heroSpeed();\n if (!this.hasTarget()) {\n // nobody at hand: the next hit lands a swing after the hero reaches someone\n this.heroCooldown = Math.max(\n this.heroCooldown,\n this.windup * this.heroSpeed(),\n );\n return;\n }\n if (this.heroCooldown > 0) return;\n const target = this.front()!;\n this.heroCooldown += 1;\n this.heroRecoverLeft = this.recover;\n this.attackOnce(target, false);\n // \"attack again at once\"\n const twice = this.stats.multiShot + this.mods.doubleHit;\n if (twice > 0 && this.random() < twice)\n this.attackOnce(this.front() ?? target, true);\n }\n\n /** Rolls a crit, the focus charge first. */\n private rollCrit(bonus = 0): boolean {\n if (this.charges.focus > 0) {\n this.charges.focus--;\n return true;\n }\n return this.random() < this.critChance() + bonus;\n }\n\n private critDamage(): number {\n return this.stats.critMultiplier + this.mods.critDamage;\n }\n\n private attackOnce(target: Monster, extra: boolean): void {\n const crit = this.rollCrit();\n const damage = this.heroDamage() * (crit ? this.critDamage() : 1);\n if (this.reach === \"melee\") {\n const m = this.front();\n if (!m || !this.hasTarget()) return;\n this.emit({ type: \"strike\", monsterId: m.id });\n this.blow(m, damage, crit, true);\n } else {\n const p: Projectile = {\n id: this.nextId++,\n x: this.heroX + 0.04,\n targetId: target.id,\n damage,\n crit,\n extra,\n };\n this.projectiles.push(p);\n this.emit({ type: \"shoot\", projectile: p });\n }\n if (!extra) this.fire(\"attack\", { target: this.front() ?? target });\n }\n\n private moveProjectiles(dt: number): void {\n for (const p of [...this.projectiles]) {\n let target = this.monsters.find((m) => m.id === p.targetId);\n if (!target) {\n target = this.monsters.find((m) => m.x > p.x);\n if (!target) {\n this.drop(this.projectiles, p);\n continue;\n }\n p.targetId = target.id;\n }\n p.x += PROJECTILE_SPEED * dt;\n if (p.x < target.x) continue;\n this.drop(this.projectiles, p);\n this.blow(target, p.damage, p.crit, false);\n }\n }\n\n private drop<T>(list: T[], item: T): void {\n const i = list.indexOf(item);\n if (i >= 0) list.splice(i, 1);\n }\n\n /** The weapon's type plus the cards' other types (each its share of the blow). */\n private components(): Array<[AttackType, number]> {\n const out: Array<[AttackType, number]> = [[this.heroAttack, 1]];\n for (const [type, share] of Object.entries(this.mods.hybrid))\n if (share) out.push([type as AttackType, share]);\n return out;\n }\n\n /**\n * The hero's blow on a troop — its weapon's types through the table and the armor value — and what a\n * blow sets off: the lifesteal, the shred, a lightning charge, the \"on hit\" and \"on crit\" cards.\n */\n private blow(\n m: Monster,\n damage: number,\n crit: boolean,\n melee: boolean,\n ): void {\n if (!this.monsters.includes(m)) return;\n // the one standing behind the target (before the blow: the target may fall)\n const behind = this.monsters[this.monsters.indexOf(m) + 1];\n const dealtNow = this.strikeTyped(\n m,\n damage,\n this.components(),\n crit,\n melee,\n );\n if (behind && this.mods.pierceShare > 0)\n this.strikeTyped(\n behind,\n damage * this.mods.pierceShare,\n this.components(),\n crit,\n melee,\n );\n if (this.mods.lifesteal > 0 && this.random() < this.mods.lifesteal)\n this.healHero(dealtNow * LIFESTEAL_SHARE);\n const alive = this.monsters.includes(m);\n if (alive && this.mods.shred > 0)\n m.broken = Math.min(\n m.armor + this.mods.shredMax,\n m.broken + this.mods.shred,\n );\n if (alive && this.charges.lightning > 0) {\n this.charges.lightning--;\n this.boltAt(\"lightning\", 0.75, [m]);\n }\n this.fire(\"hit\", { target: m });\n if (crit) this.fire(\"crit\", { target: m });\n }\n\n /**\n * Damage of the given types on a troop (the share of each), through the table and its armor value,\n * with the cards that care who is hit. Its shield takes it first. Returns what it dealt.\n */\n private strikeTyped(\n m: Monster,\n damage: number,\n components: ReadonlyArray<[AttackType, number]>,\n crit = false,\n melee = false,\n ): number {\n if (!this.monsters.includes(m)) return 0;\n const md = this.mods;\n const armor = this.armorOf(m);\n let bonus = 1 + (md.vsArmor[m.armorType] ?? 0) + m.breaks * BREAK_BONUS;\n if (m !== this.front()) bonus += md.backRow;\n if (m.stunned > 0) bonus += md.vsStunned;\n if (md.missingHpDamage > 0)\n bonus += Math.floor((1 - m.hp / m.maxHp) * 10) * md.missingHpDamage;\n let total = 0;\n for (const [type, share] of components)\n total += dealt(damage * share, type, m.armorType, armor);\n total *= bonus;\n this.damage(m, total);\n this.emit({\n type: \"hit\",\n monsterId: m.id,\n x: m.x,\n damage: total,\n crit,\n melee,\n effect: effectOf(components[0]![0], m.armorType),\n });\n if (m.hp <= 0) this.kill(m, -m.hp);\n return total;\n }\n\n private damage(m: Monster, amount: number): void {\n const absorbed = Math.min(m.shield, amount);\n m.shield -= absorbed;\n m.hp -= amount - absorbed;\n }\n\n private kill(m: Monster, overflow: number): void {\n const i = this.monsters.indexOf(m);\n if (i < 0) return;\n this.monsters.splice(i, 1);\n this.kills++;\n this.emit({ type: \"kill\", monsterId: m.id, x: m.x, boss: m.boss });\n this.addRage(m.boss ? RAGE_PER_BOSS : RAGE_PER_KILL, true);\n if (m.boss) return this.finish(true);\n this.fire(\"kill\", { target: m, overflow });\n }\n\n // ── The cards' triggers ────────────────────────────────────────────────────────────────────\n\n /** Fire a trigger: every card on it — by its chance, or on its every N-th time. */\n private fire(trigger: Trigger, ctx: FireContext = {}): void {\n const n = (this.fired[trigger] = (this.fired[trigger] ?? 0) + 1);\n if (this.result || this.depth >= MAX_PROC_DEPTH) return;\n this.depth++;\n try {\n for (const p of this.procs) {\n if (p.trigger !== trigger) continue;\n if (p.every ? n % p.every !== 0 : this.random() >= p.chance) continue;\n this.apply(p.effect, ctx);\n if (this.result) return;\n }\n } finally {\n this.depth--;\n }\n }\n\n private apply(e: Effect, ctx: FireContext): void {\n switch (e.kind) {\n case \"bolt\":\n return this.bolt(e.bolt, e.power, e.count, e.at, ctx.target);\n case \"heal\":\n return this.healHero(e.share * this.heroMaxHp());\n case \"repair\":\n return this.repair(\n e.share > 0 ? e.share * this.heroMaxShield() : (ctx.overflow ?? 0),\n );\n case \"stun\": {\n const who = e.all\n ? this.monsters.filter((m) => this.onScreen(m))\n : ctx.target && this.monsters.includes(ctx.target)\n ? [ctx.target]\n : [];\n for (const m of who) {\n m.stunned = Math.max(m.stunned, e.seconds);\n this.emit({ type: \"stun\", monsterId: m.id });\n }\n return;\n }\n case \"charge\":\n this.charges[e.charge] += e.count;\n this.emit({ type: \"charge\", charge: e.charge, count: e.count });\n return;\n case \"break\":\n if (ctx.target && this.monsters.includes(ctx.target))\n ctx.target.breaks = Math.min(BREAK_MAX, ctx.target.breaks + 1);\n return;\n case \"again\": {\n const m = this.front();\n if (m && this.hasTarget()) this.attackOnce(m, true);\n return;\n }\n case \"cleave\": {\n const t = ctx.target;\n const i = t ? this.monsters.indexOf(t) : -1;\n const next = this.monsters[i + 1];\n if (next) this.strikeTyped(next, this.heroDamage(), this.components());\n return;\n }\n case \"gainCrit\":\n this.runCrit += e.value;\n return;\n case \"ultBack\":\n if (!this.ultReady) {\n this.ultReady = true;\n this.emit({ type: \"ultBack\" });\n }\n return;\n case \"overkillShield\":\n return this.repair(ctx.overflow ?? 0, false);\n case \"hurtToHeal\":\n ctx.converted = true;\n return;\n case \"flurry\": {\n const t = ctx.target;\n for (let k = 0; k < 30 && t && this.monsters.includes(t); k++) {\n const crit = this.rollCrit();\n this.blow(\n t,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n }\n return;\n }\n case \"explode\": {\n const dead = ctx.target;\n const next = this.front();\n if (!dead || !next) return;\n this.emit({ type: \"blast\", monsterId: dead.id, x: dead.x });\n this.strikeTyped(next, dead.maxHp * e.share, [[\"spells\", 1]]);\n return;\n }\n }\n }\n\n /** A card's missiles: who they are for, then the damage (no \"on hit\" cards — those are the hero's blows). */\n private bolt(\n kind: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n target: Monster | undefined,\n ): void {\n const seen = this.monsters.filter((m) => this.onScreen(m));\n if (seen.length === 0) return;\n if (at === \"line\") return this.boltAt(kind, power, seen);\n for (let k = 0; k < count; k++) {\n const live = this.monsters.filter((m) => this.onScreen(m));\n if (live.length === 0) return;\n const who =\n at === \"random\"\n ? live[Math.floor(this.random() * live.length)]!\n : at === \"farthest\"\n ? live[live.length - 1]!\n : at === \"target\" && target && this.monsters.includes(target)\n ? target\n : live[0]!;\n this.boltAt(kind, power, [who]);\n }\n }\n\n private boltAt(kind: Bolt, power: number, targets: Monster[]): void {\n this.emit({ type: \"bolt\", bolt: kind, targets: targets.map((m) => m.id) });\n const damage = this.heroDamage() * power;\n for (const m of targets)\n this.strikeTyped(m, damage, [[BOLT_TYPE[kind], 1]]);\n }\n\n /** Health back — and the \"on heal\" / \"on overheal\" cards with what it had over. */\n private healHero(amount: number): void {\n if (amount <= 0 || this.heroHp <= 0) return;\n const before = this.heroHp;\n this.heroHp = Math.min(this.heroMaxHp(), this.heroHp + amount);\n const healed = this.heroHp - before;\n const over = amount - healed;\n if (healed > 0) {\n this.emit({ type: \"heal\", amount: healed });\n this.fire(\"heal\");\n }\n if (over > 1e-9) this.fire(\"overheal\", { overflow: over });\n }\n\n /** The shield back by `amount` (the repair cards make it bigger), never past its max. */\n private repair(amount: number, boosted = true): void {\n const max = this.heroMaxShield();\n if (max <= 0 || amount <= 0) return;\n const got = amount * (boosted ? 1 + this.mods.repairPower : 1);\n this.heroShield = Math.min(max, this.heroShield + got);\n }\n\n // ── The troops ─────────────────────────────────────────────────────────────────────────────\n\n private monstersAct(dt: number): void {\n for (const m of [...this.monsters]) {\n if (this.closing(m)) {\n m.x = Math.max(\n this.heroX + this.contact(m),\n m.x - m.speed * this.walkSpeed * dt,\n );\n continue;\n }\n if (!this.engaged(m) || m.stunned > 0) continue;\n m.cooldown -= dt;\n if (m.cooldown > 0) continue;\n m.cooldown += m.interval;\n if (m.range > 0) {\n const shot: EnemyShot = {\n id: this.nextId++,\n x: m.x - 0.03,\n damage: m.attack,\n attackType: m.attackType,\n };\n this.enemyShots.push(shot);\n this.emit({ type: \"enemyShoot\", shot, monsterId: m.id });\n continue;\n }\n this.emit({ type: \"enemyStrike\", monsterId: m.id });\n this.hurtHero(m.attack, m.attackType, m);\n if (this.result) return;\n }\n }\n\n private moveEnemyShots(dt: number): void {\n for (const s of [...this.enemyShots]) {\n s.x -= ENEMY_SHOT_SPEED * dt;\n if (s.x > this.heroX + 0.02) continue;\n this.drop(this.enemyShots, s);\n this.hurtHero(s.damage, s.attackType, null);\n if (this.result) return;\n }\n }\n\n /**\n * A troop's blow on the hero: dodged, taken by the divine shield, shrugged off while the armor holds,\n * turned into healing — or through the table against the hero's armor type and its armor value, the\n * shield first. A blow face to face may be countered.\n */\n private hurtHero(raw: number, type: AttackType, from: Monster | null): void {\n if (this.random() < this.stats.dodge + this.mods.dodge) {\n this.emit({ type: \"dodge\" });\n this.fire(\"dodge\");\n return;\n }\n if (this.charges.divine > 0) {\n this.charges.divine--;\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n if (\n this.heroShield > 0 &&\n this.mods.shieldIgnore > 0 &&\n this.random() < this.mods.shieldIgnore\n ) {\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n let damage =\n raw *\n typeFactor(type, this.heroArmorType) *\n armorFactor(this.heroArmorValue());\n if (type === \"pierce\") damage *= 1 - Math.min(0.8, this.mods.vsPierce);\n const ctx: FireContext = {};\n this.fire(\"hurt\", ctx);\n if (ctx.converted) {\n this.emit({ type: \"heroHit\", damage, shield: 0, healed: true });\n this.healHero(damage);\n return this.counter(from);\n }\n const absorbed = Math.min(this.heroShield, damage);\n this.heroShield -= absorbed;\n this.heroHp -= damage - absorbed;\n this.emit({ type: \"heroHit\", damage, shield: absorbed, healed: false });\n if (this.heroHp > 0 || this.result) return this.counter(from);\n this.heroHp = 0;\n this.emit({ type: \"heroDown\" });\n this.finish(false);\n }\n\n /** The counter: a blow taken face to face may be answered at once with the hero's own. */\n private counter(from: Monster | null): void {\n if (!from || this.mods.counter <= 0 || !this.monsters.includes(from))\n return;\n if (this.random() >= this.mods.counter) return;\n this.emit({ type: \"counter\", monsterId: from.id });\n const crit = this.rollCrit(this.mods.counterCrit);\n this.blow(\n from,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n this.fire(\"counter\", { target: from });\n }\n\n /** Auto's moment for the one ultimate: the boss in reach, a crowd on screen, or the hero in trouble. */\n private ultWorthIt(): boolean {\n if (!this.hasTarget()) return false;\n if (this.monsters.some((m) => m.boss)) return true;\n if (this.monsters.filter((m) => this.onScreen(m)).length >= AUTO_ULT_CROWD)\n return true;\n return this.heroHp < this.heroMaxHp() * AUTO_ULT_BELOW;\n }\n\n // ── Rage and run levels ────────────────────────────────────────────────────────────────────\n\n private addRage(amount: number, fromKill: boolean): void {\n const got = amount * (fromKill ? 1 + this.mods.rage : 1);\n this.rage += got;\n this.emit({ type: \"rage\", amount: got });\n while (this.rage >= rageFor(this.runLevel)) {\n this.runLevel++;\n this.queued++;\n this.emit({ type: \"levelUp\", level: this.runLevel });\n this.fire(\"level\");\n }\n if (!this.result) this.offerCards();\n }\n\n private draw(): string[] {\n return drawCards(this.random, this.level.island.index, this.cards, {\n counter: this.mods.counter > 0,\n dodge: this.stats.dodge + this.mods.dodge > 0,\n shield: this.shielded,\n });\n }\n\n /** The next choice of three, if one is owed and none is open (\"Upgrade: 2/5\" counts a row of them). */\n private offerCards(): void {\n while (this.queued > 0 && !this.pending) {\n // a new row of choices starts when the last one is done\n if (this.choice.index >= this.choice.total)\n this.choice = { index: 0, total: this.queued };\n this.queued--;\n this.choice.index++;\n const options = this.draw();\n if (options.length > 0)\n this.pending = { kind: \"cards\", options, ...this.choice };\n }\n }\n\n // ── The road ───────────────────────────────────────────────────────────────────────────────\n\n private nextSegment(): void {\n this.segment++;\n const segments = this.level.segments;\n if (this.segment < segments.length)\n return this.startSegment(segments[this.segment]!);\n if (this.segment === segments.length) {\n this.lineUp([this.level.boss], Math.min(this.waveAhead, 0.72));\n this.emit({ type: \"bossStart\" });\n }\n }\n\n private startSegment(s: Segment): void {\n if (s.kind === \"wave\") return this.lineUp(s.troops, this.waveAhead);\n this.feature = {\n id: this.nextId++,\n kind: s.kind,\n gives: s.kind === \"fountain\" ? s.gives : undefined,\n x: this.heroX + this.waveAhead,\n };\n this.emit({ type: \"feature\", feature: this.feature });\n }\n\n /** The hero is at what stands on the road. */\n private arrive(f: RoadFeature): void {\n this.emit({ type: \"reached\", feature: f });\n if (f.kind === \"camp\") {\n this.pending = { kind: \"camp\" };\n return;\n }\n this.feature = null;\n if (f.kind === \"chest\") this.chests++;\n else if (f.gives === \"rage\") this.addRage(FOUNTAIN_RAGE, false);\n else this.healHero(FOUNTAIN_HEAL * this.heroMaxHp());\n }\n\n private lineUp(ids: readonly TroopId[], ahead: number): void {\n const power = this.level.power;\n const grow = Math.floor((this.level.index - 1) / 4);\n this.monsters = ids.map((tid, i) => {\n const troop = TROOPS[tid];\n const hp = BASE_HP * power * troop.hp;\n const shield = troopShielded(troop) ? hp * SHIELD_SHARE : 0;\n const armor = troop.armor + grow;\n return {\n id: this.nextId++,\n troop,\n boss: !!troop.boss,\n hp,\n maxHp: hp,\n shield,\n maxShield: shield,\n attack: BASE_ATTACK * power * troop.attack,\n attackType: troopAttack(troop),\n armorType: troopArmor(troop),\n armor,\n broken: Math.min(armor + this.mods.shredMax, this.mods.warCry),\n breaks: 0,\n interval: troop.interval,\n speed: troop.speed,\n range: troopRanged(troop) ? TROOP_RANGE : 0,\n stunned: 0,\n x: this.heroX + ahead + i * this.monsterGap,\n cooldown: troop.interval * 0.5,\n };\n });\n for (const m of this.monsters) this.emit({ type: \"spawn\", monster: m });\n }\n\n private finish(cleared: boolean): void {\n this.walking = false;\n this.pending = null;\n this.projectiles = [];\n this.enemyShots = [];\n this.result = {\n cleared,\n level: this.level.index,\n kills: this.kills,\n runLevel: this.runLevel,\n chests: this.chests,\n seconds: Math.round(this.time),\n cards: [...this.cards],\n potionsUsed: this.potionsUsed,\n };\n this.emit({ type: \"end\", result: this.result });\n }\n\n private emit(e: BattleEvent): void {\n this.events.push(e);\n }\n\n // ── Reading ────────────────────────────────────────────────────────────────────────────────\n\n snapshot(): BattleSnapshot {\n const boss = this.monsters.find((m) => m.boss);\n return {\n level: this.level.index,\n island: this.level.island.index,\n segment: Math.max(0, this.segment),\n segments: this.level.segments.length,\n heroHp: Math.max(0, this.heroHp),\n heroMaxHp: this.heroMaxHp(),\n heroShield: Math.max(0, this.heroShield),\n heroMaxShield: this.heroMaxShield(),\n heroDown: this.heroDown,\n walking: this.walking,\n rage: this.rage,\n rageFrom: this.runLevel > 0 ? rageFor(this.runLevel - 1) : 0,\n rageTo: rageFor(this.runLevel),\n runLevel: this.runLevel,\n pending: this.pending,\n rerollsLeft: this.rerollsLeft,\n potions: this.potions,\n ult: this.ult,\n ultReady: this.ultReady,\n ultActiveLeft: this.ultActiveLeft,\n charges: { ...this.charges },\n autoPlay: this.autoPlay,\n kills: this.kills,\n chests: this.chests,\n cards: [...this.cards],\n result: this.result,\n monsters: this.monsters.length,\n bossHp: boss ? Math.max(0, boss.hp) : 0,\n bossMaxHp: boss?.maxHp ?? 0,\n };\n }\n}\n"
332
331
  },
333
332
  {
334
333
  "path": "game/campaign.ts",
335
- "content": "// The campaign: islands of levels, each level ONE run (Obsidian Knight). A level is a road of\n// segments — waves of troops, a camp, fountains, a chest — and a boss at its end. It is generated\n// from its number, so every player meets the same road on the same level (and a test the same one).\n//\n// The types of Warcraft come in one by one: the first island is casual — unarmored and light troops,\n// blades and bows, no scouting; every next island brings one new armor or attack type with a tip the\n// first time it is met, and from the second island on the scouting screen shows the road's armor.\n\nimport type { ArmorType, AttackType } from \"./damage\";\nimport { seededRandom } from \"./random\";\nimport { THEMES, type Theme } from \"./themes\";\nimport {\n TROOPS,\n troopArmor,\n troopAttack,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\nexport const LEVELS_PER_ISLAND = 6;\n\nexport type Segment =\n | { kind: \"wave\"; troops: TroopId[] }\n | { kind: \"camp\" }\n | { kind: \"fountain\"; gives: \"heal\" | \"rage\" }\n | { kind: \"chest\" };\n\nexport interface IslandDef {\n /** 1-based. */\n index: number;\n theme: Theme;\n /** Troops of its waves. */\n troops: TroopId[];\n /** Its bosses, by level (the last one closes the island). */\n bosses: TroopId[];\n /** The types this island brings (a tip the first time each is met). */\n introduces: Array<\n { kind: \"armor\"; type: ArmorType } | { kind: \"attack\"; type: AttackType }\n >;\n /** Does the scouting screen show the armor of the road (not on the casual first island). */\n scouting: boolean;\n}\n\nexport interface LevelDef {\n /** 1-based across islands. */\n index: number;\n island: IslandDef;\n /** 1..LEVELS_PER_ISLAND. */\n level: number;\n segments: Segment[];\n boss: TroopId;\n /** The strength multiplier of its troops (it grows level by level). */\n power: number;\n}\n\n/** The five islands of a loop; past the fifth the loop comes again, harder (power keeps growing). */\nconst ISLANDS: Array<Omit<IslandDef, \"index\" | \"theme\">> = [\n {\n troops: [\"thug\", \"archer\", \"rogue\"],\n bosses: [\"chief\"],\n introduces: [],\n scouting: false,\n },\n {\n troops: [\"rogue\", \"archer\", \"spearman\", \"crossbowman\"],\n bosses: [\"chief\", \"warlord\"],\n introduces: [{ kind: \"armor\", type: \"medium\" }],\n scouting: true,\n },\n {\n troops: [\"spearman\", \"footman\", \"acolyte\", \"mage\", \"crossbowman\"],\n bosses: [\"warlord\", \"archmage\"],\n introduces: [\n { kind: \"armor\", type: \"heavy\" },\n { kind: \"attack\", type: \"magic\" },\n { kind: \"armor\", type: \"hero\" },\n ],\n scouting: true,\n },\n {\n troops: [\"footman\", \"maceman\", \"gunner\", \"berserker\", \"warden\", \"mage\"],\n bosses: [\"warlord\", \"archmage\", \"dreadknight\"],\n introduces: [\n { kind: \"attack\", type: \"siege\" },\n { kind: \"armor\", type: \"fortified\" },\n ],\n scouting: true,\n },\n {\n troops: [\"warden\", \"hammerguard\", \"warlock\", \"gunner\", \"berserker\", \"mage\"],\n bosses: [\"archmage\", \"dreadknight\"],\n introduces: [{ kind: \"attack\", type: \"chaos\" }],\n scouting: true,\n },\n];\n\nconst POWER_GROWTH = 1.16;\n\nexport function islandOf(index: number): IslandDef {\n const i = Math.max(1, Math.floor(index));\n const def = ISLANDS[(i - 1) % ISLANDS.length]!;\n return {\n ...def,\n index: i,\n theme: THEMES[(i - 1) % THEMES.length]!,\n // past the first loop nothing is casual any more\n scouting: def.scouting || i > ISLANDS.length,\n };\n}\n\n/** Level `index` (1-based across islands): its island, its road and its boss. */\nexport function levelOf(index: number): LevelDef {\n const n = Math.max(1, Math.floor(index));\n const islandIndex = Math.ceil(n / LEVELS_PER_ISLAND);\n const level = n - (islandIndex - 1) * LEVELS_PER_ISLAND;\n const island = islandOf(islandIndex);\n const rng = seededRandom(n * 7919 + 17);\n // 6 waves, one more every 3 levels, at most 8 (owner's decision 23.10.2026: 10 made a cleared run\n // on islands 4–5 last 5.5–6 minutes)\n const waves = 6 + Math.min(2, Math.floor((n - 1) / 3));\n const segments: Segment[] = [];\n for (let w = 0; w < waves; w++) {\n const size = Math.min(6, 2 + Math.floor((n + w) / 4) + (w % 2));\n const troops: TroopId[] = [];\n for (let k = 0; k < size; k++)\n troops.push(island.troops[Math.floor(rng() * island.troops.length)]!);\n segments.push({ kind: \"wave\", troops });\n // the camp halfway, a fountain or a chest now and then\n if (w === Math.floor(waves / 2) - 1) segments.push({ kind: \"camp\" });\n else if (w > 0 && w < waves - 1 && rng() < 0.3)\n segments.push(\n rng() < 0.35\n ? { kind: \"chest\" }\n : { kind: \"fountain\", gives: rng() < 0.5 ? \"heal\" : \"rage\" },\n );\n }\n // the island's bosses take turns over its levels; the last level always ends with the last of them\n const turn = Math.floor(\n ((level - 1) * island.bosses.length) / LEVELS_PER_ISLAND,\n );\n return {\n index: n,\n island,\n level,\n segments,\n boss:\n level === LEVELS_PER_ISLAND\n ? island.bosses.at(-1)!\n : island.bosses[Math.min(island.bosses.length - 1, turn)]!,\n power: Math.pow(POWER_GROWTH, n - 1),\n };\n}\n\n/** What the scouting screen shows: the share of each armor type on the road, and the boss. */\nexport interface Scouting {\n armor: Array<{ type: ArmorType; share: number }>;\n attack: Array<{ type: AttackType; share: number }>;\n boss: { troop: TroopDef; armor: ArmorType; attack: AttackType };\n}\n\nexport function scout(level: LevelDef): Scouting {\n const armor = new Map<ArmorType, number>();\n const attack = new Map<AttackType, number>();\n let total = 0;\n for (const s of level.segments)\n if (s.kind === \"wave\")\n for (const id of s.troops) {\n const t = TROOPS[id];\n armor.set(troopArmor(t), (armor.get(troopArmor(t)) ?? 0) + 1);\n attack.set(troopAttack(t), (attack.get(troopAttack(t)) ?? 0) + 1);\n total++;\n }\n const shares = <T>(m: Map<T, number>) =>\n [...m.entries()]\n .map(([type, n]) => ({ type, share: total > 0 ? n / total : 0 }))\n .sort((a, b) => b.share - a.share);\n const boss = TROOPS[level.boss];\n return {\n armor: shares(armor),\n attack: shares(attack),\n boss: { troop: boss, armor: troopArmor(boss), attack: troopAttack(boss) },\n };\n}\n\n/** Every type the player has met by this island (what the tables and tips may talk about). */\nexport function typesKnownBy(islandIndex: number): {\n armor: Set<ArmorType>;\n attack: Set<AttackType>;\n} {\n const armor = new Set<ArmorType>();\n const attack = new Set<AttackType>();\n for (let i = 1; i <= Math.min(islandIndex, ISLANDS.length); i++) {\n const island = islandOf(i);\n for (const id of [...island.troops, ...island.bosses]) {\n armor.add(troopArmor(TROOPS[id]));\n attack.add(troopAttack(TROOPS[id]));\n }\n }\n return { armor, attack };\n}\n"
334
+ "content": "// The campaign: islands of levels, each level ONE run (Obsidian Knight). A level is a road of\n// segments — waves of troops, a camp, fountains, a chest — and a boss at its end. It is generated\n// from its number, so every player meets the same road on the same level (and a test the same one).\n//\n// The attack and armor types come in one by one: the first island is casual — unarmored and light troops,\n// blades and bows, no scouting; every next island brings one new armor or attack type with a tip the\n// first time it is met, and from the second island on the scouting screen shows the road's armor.\n\nimport type { ArmorType, AttackType } from \"./damage\";\nimport { seededRandom } from \"./random\";\nimport { THEMES, type Theme } from \"./themes\";\nimport {\n TROOPS,\n troopArmor,\n troopAttack,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\nexport const LEVELS_PER_ISLAND = 6;\n\nexport type Segment =\n | { kind: \"wave\"; troops: TroopId[] }\n | { kind: \"camp\" }\n | { kind: \"fountain\"; gives: \"heal\" | \"rage\" }\n | { kind: \"chest\" };\n\nexport interface IslandDef {\n /** 1-based. */\n index: number;\n theme: Theme;\n /** Troops of its waves. */\n troops: TroopId[];\n /** Its bosses, by level (the last one closes the island). */\n bosses: TroopId[];\n /** The types this island brings (a tip the first time each is met). */\n introduces: Array<\n { kind: \"armor\"; type: ArmorType } | { kind: \"attack\"; type: AttackType }\n >;\n /** Does the scouting screen show the armor of the road (not on the casual first island). */\n scouting: boolean;\n}\n\nexport interface LevelDef {\n /** 1-based across islands. */\n index: number;\n island: IslandDef;\n /** 1..LEVELS_PER_ISLAND. */\n level: number;\n segments: Segment[];\n boss: TroopId;\n /** The strength multiplier of its troops (it grows level by level). */\n power: number;\n}\n\n/** The five islands of a loop; past the fifth the loop comes again, harder (power keeps growing). */\nconst ISLANDS: Array<Omit<IslandDef, \"index\" | \"theme\">> = [\n {\n troops: [\"thug\", \"archer\", \"rogue\"],\n bosses: [\"chief\"],\n introduces: [],\n scouting: false,\n },\n {\n troops: [\"rogue\", \"archer\", \"spearman\", \"crossbowman\"],\n bosses: [\"chief\", \"warlord\"],\n introduces: [{ kind: \"armor\", type: \"medium\" }],\n scouting: true,\n },\n {\n troops: [\"spearman\", \"footman\", \"acolyte\", \"mage\", \"crossbowman\"],\n bosses: [\"warlord\", \"archmage\"],\n introduces: [\n { kind: \"armor\", type: \"heavy\" },\n { kind: \"attack\", type: \"magic\" },\n { kind: \"armor\", type: \"hero\" },\n ],\n scouting: true,\n },\n {\n troops: [\"footman\", \"maceman\", \"gunner\", \"berserker\", \"warden\", \"mage\"],\n bosses: [\"warlord\", \"archmage\", \"dreadknight\"],\n introduces: [\n { kind: \"attack\", type: \"siege\" },\n { kind: \"armor\", type: \"fortified\" },\n ],\n scouting: true,\n },\n {\n troops: [\"warden\", \"hammerguard\", \"warlock\", \"gunner\", \"berserker\", \"mage\"],\n bosses: [\"archmage\", \"dreadknight\"],\n introduces: [{ kind: \"attack\", type: \"chaos\" }],\n scouting: true,\n },\n];\n\nconst POWER_GROWTH = 1.16;\n\nexport function islandOf(index: number): IslandDef {\n const i = Math.max(1, Math.floor(index));\n const def = ISLANDS[(i - 1) % ISLANDS.length]!;\n return {\n ...def,\n index: i,\n theme: THEMES[(i - 1) % THEMES.length]!,\n // past the first loop nothing is casual any more\n scouting: def.scouting || i > ISLANDS.length,\n };\n}\n\n/** Level `index` (1-based across islands): its island, its road and its boss. */\nexport function levelOf(index: number): LevelDef {\n const n = Math.max(1, Math.floor(index));\n const islandIndex = Math.ceil(n / LEVELS_PER_ISLAND);\n const level = n - (islandIndex - 1) * LEVELS_PER_ISLAND;\n const island = islandOf(islandIndex);\n const rng = seededRandom(n * 7919 + 17);\n // 6 waves, one more every 3 levels, at most 8 (owner's decision 23.10.2026: 10 made a cleared run\n // on islands 4–5 last 5.5–6 minutes)\n const waves = 6 + Math.min(2, Math.floor((n - 1) / 3));\n const segments: Segment[] = [];\n for (let w = 0; w < waves; w++) {\n const size = Math.min(6, 2 + Math.floor((n + w) / 4) + (w % 2));\n const troops: TroopId[] = [];\n for (let k = 0; k < size; k++)\n troops.push(island.troops[Math.floor(rng() * island.troops.length)]!);\n segments.push({ kind: \"wave\", troops });\n // the camp halfway, a fountain or a chest now and then\n if (w === Math.floor(waves / 2) - 1) segments.push({ kind: \"camp\" });\n else if (w > 0 && w < waves - 1 && rng() < 0.3)\n segments.push(\n rng() < 0.35\n ? { kind: \"chest\" }\n : { kind: \"fountain\", gives: rng() < 0.5 ? \"heal\" : \"rage\" },\n );\n }\n // the island's bosses take turns over its levels; the last level always ends with the last of them\n const turn = Math.floor(\n ((level - 1) * island.bosses.length) / LEVELS_PER_ISLAND,\n );\n return {\n index: n,\n island,\n level,\n segments,\n boss:\n level === LEVELS_PER_ISLAND\n ? island.bosses.at(-1)!\n : island.bosses[Math.min(island.bosses.length - 1, turn)]!,\n power: Math.pow(POWER_GROWTH, n - 1),\n };\n}\n\n/** What the scouting screen shows: the share of each armor type on the road, and the boss. */\nexport interface Scouting {\n armor: Array<{ type: ArmorType; share: number }>;\n attack: Array<{ type: AttackType; share: number }>;\n boss: { troop: TroopDef; armor: ArmorType; attack: AttackType };\n}\n\nexport function scout(level: LevelDef): Scouting {\n const armor = new Map<ArmorType, number>();\n const attack = new Map<AttackType, number>();\n let total = 0;\n for (const s of level.segments)\n if (s.kind === \"wave\")\n for (const id of s.troops) {\n const t = TROOPS[id];\n armor.set(troopArmor(t), (armor.get(troopArmor(t)) ?? 0) + 1);\n attack.set(troopAttack(t), (attack.get(troopAttack(t)) ?? 0) + 1);\n total++;\n }\n const shares = <T>(m: Map<T, number>) =>\n [...m.entries()]\n .map(([type, n]) => ({ type, share: total > 0 ? n / total : 0 }))\n .sort((a, b) => b.share - a.share);\n const boss = TROOPS[level.boss];\n return {\n armor: shares(armor),\n attack: shares(attack),\n boss: { troop: boss, armor: troopArmor(boss), attack: troopAttack(boss) },\n };\n}\n\n/** Every type the player has met by this island (what the tables and tips may talk about). */\nexport function typesKnownBy(islandIndex: number): {\n armor: Set<ArmorType>;\n attack: Set<AttackType>;\n} {\n const armor = new Set<ArmorType>();\n const attack = new Set<AttackType>();\n for (let i = 1; i <= Math.min(islandIndex, ISLANDS.length); i++) {\n const island = islandOf(i);\n for (const id of [...island.troops, ...island.bosses]) {\n armor.add(troopArmor(TROOPS[id]));\n attack.add(troopAttack(TROOPS[id]));\n }\n }\n return { armor, attack };\n}\n"
336
335
  },
337
336
  {
338
337
  "path": "game/cards.ts",
339
- "content": "// Rage cards — Obsidian Knight's \"upgrades\": every time the rage fills, the run stops and the player\n// takes one of three. A card is a TRIGGER and an EFFECT (\"On kill: 10% chance to restore 10% HP\",\n// \"Every 3rd attack: a double Lightning strike\") or a passive number (\"Passive: +8% Counter\"). The\n// battle (battle.ts) fires the triggers; this file is data — the passives as `RunMods`, the triggered\n// ones as `Proc`s, the text with its colours.\n//\n// The Warcraft table still rules every blow: a Lightning strike is SPELLS (through any armor), a\n// thrown Axe NORMAL, a Spear and the Arrows PIERCE; the passives that add damage of another type\n// (\"+25% of every blow as Magic\") are how a sword gets through plate.\n//\n// Text markup (ui/types.tsx renders it): **When** — the trigger, white and bold; [g]10%[/g] — a\n// number, green; [o]Lightning[/o] — an effect's name, orange.\n\nimport type { ArmorType, AttackType } from \"./damage\";\n\nexport type Rarity = \"common\" | \"rare\" | \"legendary\";\n\n/** How often a rarity is drawn, against the others. */\nexport const RARITY_WEIGHT: Readonly<Record<Rarity, number>> = {\n common: 70,\n rare: 22,\n legendary: 8,\n};\n\n/** The passive numbers the cards add (all zero — no card taken); the battle reads them. */\nexport interface RunMods {\n /** + share of the hero's damage (ATK). */\n damage: number;\n attackSpeed: number;\n critChance: number;\n /** + to the crit multiplier. */\n critDamage: number;\n /** + share of the max health. */\n maxHp: number;\n dodge: number;\n /** + armor points of the hero. */\n armor: number;\n /** Chance a blow taken face to face is answered at once (the counter). */\n counter: number;\n /** Chance a blow heals the hero for a share of its damage. */\n lifesteal: number;\n /** Chance to attack again at once (after a blow). */\n doubleHit: number;\n /** Every blow also deals this share of itself as damage of another type. */\n hybrid: Partial<Record<AttackType, number>>;\n /** + share of damage against an armor type. */\n vsArmor: Partial<Record<ArmorType, number>>;\n /** Share taken off pierce blows (WC3's Defend). */\n vsPierce: number;\n /** + share of damage on enemies behind the first in line. */\n backRow: number;\n /** Share of every blow that goes on into the enemy behind its target. */\n pierceShare: number;\n /** + share of damage on a stunned enemy. */\n vsStunned: number;\n /** + share of what a repair gives the shield. */\n repairPower: number;\n /** Potions this run, on top of those taken along; + share of what a potion heals. */\n extraPotions: number;\n potionPower: number;\n /** + counter and + max health for every card taken. */\n counterPerCard: number;\n maxHpPerCard: number;\n /** + crit chance of a counter blow. */\n counterCrit: number;\n /** + attack speed below 30 % health; + damage at full health. */\n lowHpSpeed: number;\n fullHpDamage: number;\n /** Chance to ignore a blow while the shield holds. */\n shieldIgnore: number;\n /** + share of damage for every 10 % of the target's missing health. */\n missingHpDamage: number;\n /** Armor points a blow breaks off its target, and how deep it may go below zero. */\n shred: number;\n shredMax: number;\n /** Armor points broken off every troop as a wave comes. */\n warCry: number;\n /** + share of rage from kills. */\n rage: number;\n}\n\nexport function noMods(): RunMods {\n return {\n damage: 0,\n attackSpeed: 0,\n critChance: 0,\n critDamage: 0,\n maxHp: 0,\n dodge: 0,\n armor: 0,\n counter: 0,\n lifesteal: 0,\n doubleHit: 0,\n hybrid: {},\n vsArmor: {},\n vsPierce: 0,\n backRow: 0,\n pierceShare: 0,\n vsStunned: 0,\n repairPower: 0,\n extraPotions: 0,\n potionPower: 0,\n counterPerCard: 0,\n maxHpPerCard: 0,\n counterCrit: 0,\n lowHpSpeed: 0,\n fullHpDamage: 0,\n shieldIgnore: 0,\n missingHpDamage: 0,\n shred: 0,\n shredMax: 0,\n warCry: 0,\n rage: 0,\n };\n}\n\n/** When a triggered card fires. */\nexport type Trigger =\n | \"hit\"\n | \"crit\"\n | \"kill\"\n | \"dodge\"\n | \"counter\"\n | \"hurt\"\n | \"heal\"\n | \"overheal\"\n | \"ult\"\n | \"camp\"\n | \"attack\"\n | \"level\";\n\n/** A missile an effect sends: its look, its attack type, who it hits. */\nexport type Bolt = \"lightning\" | \"axe\" | \"spear\" | \"arrow\";\n\nexport const BOLT_TYPE: Readonly<Record<Bolt, AttackType>> = {\n lightning: \"spells\",\n axe: \"normal\",\n spear: \"pierce\",\n arrow: \"pierce\",\n};\n\n/** What a triggered card does. */\nexport type Effect =\n /** Damage: `power` × ATK, `count` times, at whom (`line` — the spear goes through all of them). */\n | {\n kind: \"bolt\";\n bolt: Bolt;\n power: number;\n count: number;\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\";\n }\n | { kind: \"heal\"; share: number }\n /** The shield repairs this share of its max (0 — by what the heal had over). */\n | { kind: \"repair\"; share: number }\n | { kind: \"stun\"; seconds: number; all: boolean }\n /** Charges: focus (+100% crit on the next attack), divine (blocks a blow), lightning (a strike on a hit). */\n | { kind: \"charge\"; charge: \"focus\" | \"divine\" | \"lightning\"; count: number }\n /** Shield break on the target: +10% damage on it a stack. */\n | { kind: \"break\" }\n | { kind: \"again\" }\n | { kind: \"cleave\" }\n /** + crit chance for the rest of the run. */\n | { kind: \"gainCrit\"; value: number }\n | { kind: \"ultBack\" }\n /** The killing blow's damage past the health goes into the shield. */\n | { kind: \"overkillShield\" }\n /** The blow taken heals instead. */\n | { kind: \"hurtToHeal\" }\n /** Attack with no cooldown until the target dies. */\n | { kind: \"flurry\" }\n /** The dead one blows up: this share of its max health on the next. */\n | { kind: \"explode\"; share: number };\n\nexport interface Proc {\n trigger: Trigger;\n /** The chance it fires (1 — always). */\n chance: number;\n /** Fires on every N-th time of its trigger (an attack, a counter) instead of by chance. */\n every?: number;\n effect: Effect;\n}\n\n/** What a card's octagon shows (ui/cardIcons.tsx draws them). */\nexport type CardIcon =\n | \"lightning\"\n | \"axe\"\n | \"spear\"\n | \"arrows\"\n | \"heal\"\n | \"repair\"\n | \"divine\"\n | \"stun\"\n | \"focus\"\n | \"skull\"\n | \"counter\"\n | \"daggers\"\n | \"speed\"\n | \"crit\"\n | \"sword\"\n | \"backrow\"\n | \"break\"\n | \"blood\"\n | \"potion\"\n | \"heart\"\n | \"dodge\"\n | \"rage\"\n | \"type\";\n\nexport interface CardDef {\n id: string;\n rarity: Rarity;\n /** The island it is first drawn on. */\n island: number;\n /** How many times it may be taken in one run. */\n stacks: number;\n icon: CardIcon;\n /** The text, English and Russian, with the markup (see the top). */\n text: [string, string];\n /** Passive numbers it adds. */\n apply?: (m: RunMods) => void;\n /** Its triggered effect. */\n proc?: Proc;\n /** Drawn only when the run has this (a counter card needs a counter). */\n needs?: \"counter\" | \"dodge\" | \"shield\";\n}\n\nconst add = (rec: Partial<Record<string, number>>, key: string, v: number) => {\n rec[key] = (rec[key] ?? 0) + v;\n};\n\n/** Russian first (the owner's sheet), English second; stored [en, ru]. */\nconst P = (ru: string, en: string): [string, string] => [en, ru];\n\nconst bolt = (\n b: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n): Effect => ({ kind: \"bolt\", bolt: b, power, count, at });\n\n/** The cards. Ids are stable: a saved run or a test names them. */\nexport const CARDS: readonly CardDef[] = [\n // ── Passive numbers ──\n {\n id: \"p_counter\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] Контрудар\",\n \"**Passive**: [g]+20%[/g] Counter\",\n ),\n apply: (m) => (m.counter += 0.2),\n },\n {\n id: \"p_speed\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"speed\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Скорость атаки\",\n \"**Passive**: [g]+15%[/g] Attack speed\",\n ),\n apply: (m) => (m.attackSpeed += 0.15),\n },\n {\n id: \"p_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Шанс крита\",\n \"**Passive**: [g]+12%[/g] Crit chance\",\n ),\n apply: (m) => (m.critChance += 0.12),\n },\n {\n id: \"p_critmult\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+60%[/g] Крит. множитель и [g]+4%[/g] Шанс крита\",\n \"**Passive**: [g]+60%[/g] Crit multiplier and [g]+4%[/g] Crit chance\",\n ),\n apply: (m) => {\n m.critDamage += 0.6;\n m.critChance += 0.04;\n },\n },\n {\n id: \"p_dodge\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"dodge\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Уклонение\",\n \"**Passive**: [g]+12%[/g] Dodge\",\n ),\n apply: (m) => (m.dodge += 0.12),\n },\n {\n id: \"p_lifesteal\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"blood\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Шанс вампиризма\",\n \"**Passive**: [g]+15%[/g] Lifesteal chance\",\n ),\n apply: (m) => (m.lifesteal += 0.15),\n },\n {\n id: \"p_hp\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Макс. HP\",\n \"**Passive**: [g]+15%[/g] Max HP\",\n ),\n apply: (m) => (m.maxHp += 0.15),\n },\n {\n id: \"p_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\"**Пассивно**: [g]+30%[/g] ATK\", \"**Passive**: [g]+30%[/g] ATK\"),\n apply: (m) => (m.damage += 0.3),\n },\n {\n id: \"p_again\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] шанс немедленно атаковать ещё раз\",\n \"**Passive**: [g]+20%[/g] chance to attack again at once\",\n ),\n apply: (m) => (m.doubleHit += 0.2),\n },\n {\n id: \"p_backrow\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"backrow\",\n text: P(\n \"**Пассивно**: [g]30%[/g] урона каждого удара проходит во врага позади; урон по заднему ряду [g]+100%[/g]\",\n \"**Passive**: [g]30%[/g] of every blow goes on into the enemy behind; [g]+100%[/g] damage to the back row\",\n ),\n apply: (m) => {\n m.backRow += 1;\n m.pierceShare += 0.3;\n },\n },\n {\n id: \"p_repair\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**Пассивно**: Броня чинится эффективнее на [g]100%[/g]\",\n \"**Passive**: the Armor repairs [g]100%[/g] better\",\n ),\n apply: (m) => (m.repairPower += 1),\n },\n {\n id: \"p_counter_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+2%[/g] Контрудар за каждое усиление\",\n \"**Passive**: [g]+2%[/g] Counter for every upgrade\",\n ),\n apply: (m) => (m.counterPerCard += 0.02),\n },\n {\n id: \"p_hp_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+8%[/g] Макс. HP за каждое усиление\",\n \"**Passive**: [g]+8%[/g] Max HP for every upgrade\",\n ),\n apply: (m) => (m.maxHpPerCard += 0.08),\n },\n {\n id: \"p_counter_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**Пассивно**: Контрудар имеет [g]+20%[/g] шанс крита\",\n \"**Passive**: a Counter has [g]+20%[/g] crit chance\",\n ),\n apply: (m) => (m.counterCrit += 0.2),\n },\n {\n id: \"p_flask\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: [g]+1[/g] зелье на этот забег\",\n \"**Passive**: [g]+1[/g] potion for this run\",\n ),\n apply: (m) => (m.extraPotions += 1),\n },\n {\n id: \"p_alchemist\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: зелья лечат на [g]50%[/g] больше\",\n \"**Passive**: potions heal [g]50%[/g] more\",\n ),\n apply: (m) => (m.potionPower += 0.5),\n },\n {\n id: \"p_low_hp_speed\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"speed\",\n text: P(\n \"**Когда HP ниже 30%**: [g]+100%[/g] Скорость атаки\",\n \"**Below 30% HP**: [g]+100%[/g] Attack speed\",\n ),\n apply: (m) => (m.lowHpSpeed += 1),\n },\n {\n id: \"p_full_hp_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**При полном HP**: [g]+100%[/g] ATK\",\n \"**At full HP**: [g]+100%[/g] ATK\",\n ),\n apply: (m) => (m.fullHpDamage += 1),\n },\n {\n id: \"p_shield_ignore\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n needs: \"shield\",\n text: P(\n \"**Пока есть Броня**: шанс [g]50%[/g] игнорировать урон\",\n \"**While the Armor holds**: [g]50%[/g] chance to ignore damage\",\n ),\n apply: (m) => (m.shieldIgnore = Math.max(m.shieldIgnore, 0.5)),\n },\n // ── Warcraft: damage of another type, armor broken ──\n {\n id: \"p_magic\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Магией[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Magic[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"magic\", 0.25),\n },\n {\n id: \"p_siege\",\n rarity: \"common\",\n island: 3,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Осадным[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Siege[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"siege\", 0.25),\n },\n {\n id: \"p_pierce\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Проникающим[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Pierce[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"pierce\", 0.25),\n },\n {\n id: \"p_chaos\",\n rarity: \"legendary\",\n island: 4,\n stacks: 1,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+50%[/g] урона каждого удара — [o]Хаосом[/o] (полный урон по любой броне)\",\n \"**Passive**: [g]+50%[/g] of every blow as [o]Chaos[/o] (full damage to any armor)\",\n ),\n apply: (m) => add(m.hybrid, \"chaos\", 0.5),\n },\n {\n id: \"p_vs_heavy\",\n rarity: \"rare\",\n island: 3,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+35%[/g] по [o]Тяжёлой[/o] броне\",\n \"**Passive**: [g]+35%[/g] damage to [o]Heavy[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"heavy\", 0.35),\n },\n {\n id: \"p_vs_fortified\",\n rarity: \"rare\",\n island: 4,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+50%[/g] по [o]Укреплённой[/o] броне\",\n \"**Passive**: [g]+50%[/g] damage to [o]Fortified[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"fortified\", 0.5),\n },\n {\n id: \"p_defend\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"repair\",\n text: P(\n \"**Пассивно**: [g]−30%[/g] урона от [o]Проникающих[/o] (стрелы, болты, пули)\",\n \"**Passive**: [g]−30%[/g] damage from [o]Pierce[/o] (arrows, bolts, bullets)\",\n ),\n apply: (m) => (m.vsPierce += 0.3),\n },\n {\n id: \"hit_shred\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**При попадании**: броня цели [g]−1[/g] (до [g]−5[/g])\",\n \"**On hit**: the target's armor [g]−1[/g] (down to [g]−5[/g])\",\n ),\n apply: (m) => {\n m.shred += 1;\n m.shredMax += 5;\n },\n },\n {\n id: \"wave_cry\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**Новая волна**: броня врагов [g]−2[/g]\",\n \"**A new wave**: the enemies' armor [g]−2[/g]\",\n ),\n apply: (m) => (m.warCry += 2),\n },\n // ── On hit ──\n {\n id: \"hit_spear\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"spear\",\n text: P(\n \"**При попадании**: шанс [g]10%[/g] метнуть [o]Пронзающее копьё[/o] ([g]60%[/g] ATK)\",\n \"**On hit**: [g]10%[/g] chance to throw a [o]Piercing spear[/o] ([g]60%[/g] ATK)\",\n ),\n proc: {\n trigger: \"hit\",\n chance: 0.1,\n effect: bolt(\"spear\", 0.6, 1, \"line\"),\n },\n },\n {\n id: \"hit_break\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"break\",\n text: P(\n \"**При попадании**: заряд «[o]Пробитие щита[/o]», увеличивающий урон по цели на [g]8%[/g] (до [g]4[/g] зарядов)\",\n \"**On hit**: a «[o]Shield break[/o]» stack, [g]+8%[/g] damage to the target (up to [g]4[/g] stacks)\",\n ),\n proc: { trigger: \"hit\", chance: 1, effect: { kind: \"break\" } },\n },\n {\n id: \"hit_missing\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\n \"**При попадании**: Урон [g]+10%[/g] за каждые [g]10%[/g] недостающего HP цели\",\n \"**On hit**: [g]+10%[/g] damage for every [g]10%[/g] of the target's missing HP\",\n ),\n apply: (m) => (m.missingHpDamage += 0.1),\n },\n // ── On crit ──\n {\n id: \"crit_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n text: P(\n \"**При крите**: метните [o]Топор[/o] ([g]150%[/g] ATK) в случайного врага\",\n \"**On crit**: throw an [o]Axe[/o] ([g]150%[/g] ATK) at a random enemy\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: bolt(\"axe\", 1.5, 1, \"random\") },\n },\n {\n id: \"crit_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При крите**: [o]Обездвиживание[/o] цели на [g]3[/g] сек.\",\n \"**On crit**: [o]Stun[/o] the target for [g]3[/g] s\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 3, all: false },\n },\n },\n {\n id: \"crit_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**При крите**: восстановите [g]6%[/g] HP\",\n \"**On crit**: restore [g]6%[/g] HP\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: { kind: \"heal\", share: 0.06 } },\n },\n {\n id: \"crit_arrows\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"arrows\",\n text: P(\n \"**При крите**: выпускаются [g]2[/g] [o]Стрелы[/o] ([g]33%[/g] ATK) по случайным врагам\",\n \"**On crit**: [g]2[/g] [o]Arrows[/o] ([g]33%[/g] ATK) fly at random enemies\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: bolt(\"arrow\", 0.33, 2, \"random\"),\n },\n },\n // ── On kill ──\n {\n id: \"kill_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]30%[/g] восстановить [g]8%[/g] HP\",\n \"**On kill**: [g]30%[/g] chance to restore [g]8%[/g] HP\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.3,\n effect: { kind: \"heal\", share: 0.08 },\n },\n },\n {\n id: \"kill_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]50%[/g] получить [g]+2%[/g] Шанс крита\",\n \"**On kill**: [g]50%[/g] chance to gain [g]+2%[/g] Crit chance\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.5,\n effect: { kind: \"gainCrit\", value: 0.02 },\n },\n },\n {\n id: \"kill_arrow\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"arrows\",\n text: P(\n \"**При убийстве**: [o]Стрела[/o] ([g]60%[/g] ATK) летит в следующего врага\",\n \"**On kill**: an [o]Arrow[/o] ([g]60%[/g] ATK) flies at the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 1,\n effect: bolt(\"arrow\", 0.6, 1, \"next\"),\n },\n },\n {\n id: \"kill_overkill\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При убийстве**: избыточный урон засчитывается как [o]Броня[/o]\",\n \"**On kill**: the overkill damage turns into [o]Armor[/o]\",\n ),\n proc: { trigger: \"kill\", chance: 1, effect: { kind: \"overkillShield\" } },\n },\n {\n id: \"kill_explode\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]25%[/g] — [o]Взрыв[/o] ([g]30%[/g] макс. HP погибшего) по следующему врагу\",\n \"**On kill**: [g]25%[/g] chance of a [o]Blast[/o] ([g]30%[/g] of the dead one's max HP) on the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.25,\n effect: { kind: \"explode\", share: 0.3 },\n },\n },\n {\n id: \"kill_ult\",\n rarity: \"rare\",\n island: 1,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**При убийстве**: шанс [g]15%[/g] вернуть ультимейт\",\n \"**On kill**: [g]15%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"kill\", chance: 0.15, effect: { kind: \"ultBack\" } },\n },\n // ── On dodge ──\n {\n id: \"dodge_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: метните [o]Топор[/o] ([g]50%[/g] ATK) в случайного врага\",\n \"**On dodge**: throw an [o]Axe[/o] ([g]50%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: bolt(\"axe\", 0.5, 1, \"random\"),\n },\n },\n {\n id: \"dodge_focus\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"focus\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: получите заряд «[o]Концентрация[/o]» (x1, [g]+100%[/g] шанс крита на одну атаку)\",\n \"**On dodge**: gain a «[o]Focus[/o]» charge (x1, [g]+100%[/g] crit chance for one attack)\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"focus\", count: 1 },\n },\n },\n {\n id: \"dodge_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: [o]Броня[/o] чинится на [g]12%[/g]\",\n \"**On dodge**: the [o]Armor[/o] repairs [g]12%[/g]\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"repair\", share: 0.12 },\n },\n },\n // ── On counter ──\n {\n id: \"counter_again\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]33%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]33%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.33, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_again_plus\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]66%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]66%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.66, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_cleave\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: [o]Рассечение[/o] (удар по соседнему врагу)\",\n \"**On counter**: [o]Cleave[/o] (a blow at the enemy next to it)\",\n ),\n proc: { trigger: \"counter\", chance: 1, effect: { kind: \"cleave\" } },\n },\n {\n id: \"counter_flurry\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"rage\",\n needs: \"counter\",\n text: P(\n \"**Каждый [g]4[/g]-й Контрудар**: атакуйте без перезарядки, пока враг не умрёт\",\n \"**Every [g]4[/g]th Counter**: attack with no cooldown until the enemy dies\",\n ),\n proc: {\n trigger: \"counter\",\n chance: 1,\n every: 4,\n effect: { kind: \"flurry\" },\n },\n },\n // ── On being hit ──\n {\n id: \"hurt_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"blood\",\n text: P(\n \"**При получении удара**: шанс [g]10%[/g] превратить урон в лечение\",\n \"**When hit**: [g]10%[/g] chance to turn the damage into healing\",\n ),\n proc: { trigger: \"hurt\", chance: 0.1, effect: { kind: \"hurtToHeal\" } },\n },\n // ── On healing ──\n {\n id: \"heal_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: двойной [o]Удар молнии[/o] ([g]75%[/g] ATK) по самому дальнему врагу\",\n \"**On heal**: a double [o]Lightning strike[/o] ([g]75%[/g] ATK) at the farthest enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 2, \"farthest\"),\n },\n },\n {\n id: \"overheal_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При избыточном лечении**: [o]Броня[/o] чинится\",\n \"**On overheal**: the [o]Armor[/o] repairs\",\n ),\n proc: {\n trigger: \"overheal\",\n chance: 1,\n effect: { kind: \"repair\", share: 0 },\n },\n },\n {\n id: \"overheal_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: [o]Удар молнии[/o] ([g]75%[/g] ATK) по случайному врагу\",\n \"**On heal**: a [o]Lightning strike[/o] ([g]75%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 1, \"random\"),\n },\n },\n // ── On the ultimate ──\n {\n id: \"ult_divine\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n text: P(\n \"**При использовании активного умения**: получите [o]Божественный щит[/o] (x8), блокирует урон один раз\",\n \"**On the active skill**: gain a [o]Divine shield[/o] (x8), each blocks one blow\",\n ),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"divine\", count: 8 },\n },\n },\n {\n id: \"ult_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При использовании активного умения**: [o]Обездвиживание[/o] всех врагов на [g]6[/g] сек. Обездвиженные враги получают [g]+50%[/g] урона\",\n \"**On the active skill**: [o]Stun[/o] every enemy for [g]6[/g] s. Stunned enemies take [g]+50%[/g] damage\",\n ),\n apply: (m) => (m.vsStunned += 0.5),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 6, all: true },\n },\n },\n // ── Every N-th attack ──\n {\n id: \"every3_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**Каждая [g]3[/g]-я атака**: двойной [o]Удар молнии[/o] ([g]30%[/g] ATK) по врагу\",\n \"**Every [g]3[/g]rd attack**: a double [o]Lightning strike[/o] ([g]30%[/g] ATK) at the enemy\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 3,\n effect: bolt(\"lightning\", 0.3, 2, \"target\"),\n },\n },\n {\n id: \"every4_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"heal\",\n text: P(\n \"**Каждая [g]4[/g]-я атака** восстанавливает [g]2%[/g] здоровья\",\n \"**Every [g]4[/g]th attack** restores [g]2%[/g] health\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 4,\n effect: { kind: \"heal\", share: 0.02 },\n },\n },\n // ── The camp, a new run level ──\n {\n id: \"camp_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При отдыхе**: получите заряды «[o]Удар молнии[/o]» (x3, [g]75%[/g] ATK), которые поражают врагов **при попадании**\",\n \"**On rest**: gain «[o]Lightning strike[/o]» charges (x3, [g]75%[/g] ATK) that strike enemies **on hit**\",\n ),\n proc: {\n trigger: \"camp\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"lightning\", count: 3 },\n },\n },\n {\n id: \"level_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**Новый уровень забега**: восстановите [g]25%[/g] HP\",\n \"**A new run level**: restore [g]25%[/g] HP\",\n ),\n proc: {\n trigger: \"level\",\n chance: 1,\n effect: { kind: \"heal\", share: 0.25 },\n },\n },\n {\n id: \"level_ult\",\n rarity: \"rare\",\n island: 2,\n stacks: 1,\n icon: \"rage\",\n text: P(\n \"**Новый уровень забега**: шанс [g]30%[/g] вернуть ультимейт\",\n \"**A new run level**: [g]30%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"level\", chance: 0.3, effect: { kind: \"ultBack\" } },\n },\n];\n\nexport const CARD_BY_ID: ReadonlyMap<string, CardDef> = new Map(\n CARDS.map((c) => [c.id, c]),\n);\n\n/** The run's passive numbers from the cards taken. */\nexport function modsOf(taken: readonly string[]): RunMods {\n const m = noMods();\n for (const id of taken) CARD_BY_ID.get(id)?.apply?.(m);\n // the \"for every upgrade\" cards count every card taken\n m.counter += m.counterPerCard * taken.length;\n m.maxHp += m.maxHpPerCard * taken.length;\n return m;\n}\n\n/** The run's triggered effects from the cards taken (a card taken twice fires twice). */\nexport function procsOf(taken: readonly string[]): Proc[] {\n const out: Proc[] = [];\n for (const id of taken) {\n const p = CARD_BY_ID.get(id)?.proc;\n if (p) out.push(p);\n }\n return out;\n}\n\n/** What the run has so far, for the cards that build on something. */\nexport interface DrawContext {\n counter: boolean;\n dodge: boolean;\n shield: boolean;\n}\n\n/**\n * Three different cards to choose from: the ones open on this island, not yet taken as many times as\n * they stack, with what they build on already there (no counter card without a counter), weighted by\n * rarity — drawn from the run's own random, so a seed replays the same picks.\n */\nexport function drawCards(\n random: () => number,\n island: number,\n taken: readonly string[],\n has: DrawContext = { counter: true, dodge: true, shield: true },\n count = 3,\n): string[] {\n const times = new Map<string, number>();\n for (const id of taken) times.set(id, (times.get(id) ?? 0) + 1);\n const pool = CARDS.filter(\n (c) =>\n c.island <= island &&\n (times.get(c.id) ?? 0) < c.stacks &&\n (!c.needs || has[c.needs]),\n );\n const out: string[] = [];\n while (out.length < count && pool.length > 0) {\n const total = pool.reduce((s, c) => s + RARITY_WEIGHT[c.rarity], 0);\n let roll = random() * total;\n let i = 0;\n for (; i < pool.length - 1; i++) {\n roll -= RARITY_WEIGHT[pool[i]!.rarity];\n if (roll < 0) break;\n }\n out.push(pool[i]!.id);\n pool.splice(i, 1);\n }\n return out;\n}\n"
338
+ "content": "// Rage cards — Obsidian Knight's \"upgrades\": every time the rage fills, the run stops and the player\n// takes one of three. A card is a TRIGGER and an EFFECT (\"On kill: 10% chance to restore 10% HP\",\n// \"Every 3rd attack: a double Lightning strike\") or a passive number (\"Passive: +8% Counter\"). The\n// battle (battle.ts) fires the triggers; this file is data — the passives as `RunMods`, the triggered\n// ones as `Proc`s, the text with its colours.\n//\n// The damage table still rules every blow: a Lightning strike is SPELLS (through any armor), a\n// thrown Axe NORMAL, a Spear and the Arrows PIERCE; the passives that add damage of another type\n// (\"+25% of every blow as Magic\") are how a sword gets through plate.\n//\n// Text markup (ui/types.tsx renders it): **When** — the trigger, white and bold; [g]10%[/g] — a\n// number, green; [o]Lightning[/o] — an effect's name, orange.\n\nimport type { ArmorType, AttackType } from \"./damage\";\n\nexport type Rarity = \"common\" | \"rare\" | \"legendary\";\n\n/** How often a rarity is drawn, against the others. */\nexport const RARITY_WEIGHT: Readonly<Record<Rarity, number>> = {\n common: 70,\n rare: 22,\n legendary: 8,\n};\n\n/** The passive numbers the cards add (all zero — no card taken); the battle reads them. */\nexport interface RunMods {\n /** + share of the hero's damage (ATK). */\n damage: number;\n attackSpeed: number;\n critChance: number;\n /** + to the crit multiplier. */\n critDamage: number;\n /** + share of the max health. */\n maxHp: number;\n dodge: number;\n /** + armor points of the hero. */\n armor: number;\n /** Chance a blow taken face to face is answered at once (the counter). */\n counter: number;\n /** Chance a blow heals the hero for a share of its damage. */\n lifesteal: number;\n /** Chance to attack again at once (after a blow). */\n doubleHit: number;\n /** Every blow also deals this share of itself as damage of another type. */\n hybrid: Partial<Record<AttackType, number>>;\n /** + share of damage against an armor type. */\n vsArmor: Partial<Record<ArmorType, number>>;\n /** Share taken off pierce blows (the Defend card). */\n vsPierce: number;\n /** + share of damage on enemies behind the first in line. */\n backRow: number;\n /** Share of every blow that goes on into the enemy behind its target. */\n pierceShare: number;\n /** + share of damage on a stunned enemy. */\n vsStunned: number;\n /** + share of what a repair gives the shield. */\n repairPower: number;\n /** Potions this run, on top of those taken along; + share of what a potion heals. */\n extraPotions: number;\n potionPower: number;\n /** + counter and + max health for every card taken. */\n counterPerCard: number;\n maxHpPerCard: number;\n /** + crit chance of a counter blow. */\n counterCrit: number;\n /** + attack speed below 30 % health; + damage at full health. */\n lowHpSpeed: number;\n fullHpDamage: number;\n /** Chance to ignore a blow while the shield holds. */\n shieldIgnore: number;\n /** + share of damage for every 10 % of the target's missing health. */\n missingHpDamage: number;\n /** Armor points a blow breaks off its target, and how deep it may go below zero. */\n shred: number;\n shredMax: number;\n /** Armor points broken off every troop as a wave comes. */\n warCry: number;\n /** + share of rage from kills. */\n rage: number;\n}\n\nexport function noMods(): RunMods {\n return {\n damage: 0,\n attackSpeed: 0,\n critChance: 0,\n critDamage: 0,\n maxHp: 0,\n dodge: 0,\n armor: 0,\n counter: 0,\n lifesteal: 0,\n doubleHit: 0,\n hybrid: {},\n vsArmor: {},\n vsPierce: 0,\n backRow: 0,\n pierceShare: 0,\n vsStunned: 0,\n repairPower: 0,\n extraPotions: 0,\n potionPower: 0,\n counterPerCard: 0,\n maxHpPerCard: 0,\n counterCrit: 0,\n lowHpSpeed: 0,\n fullHpDamage: 0,\n shieldIgnore: 0,\n missingHpDamage: 0,\n shred: 0,\n shredMax: 0,\n warCry: 0,\n rage: 0,\n };\n}\n\n/** When a triggered card fires. */\nexport type Trigger =\n | \"hit\"\n | \"crit\"\n | \"kill\"\n | \"dodge\"\n | \"counter\"\n | \"hurt\"\n | \"heal\"\n | \"overheal\"\n | \"ult\"\n | \"camp\"\n | \"attack\"\n | \"level\";\n\n/** A missile an effect sends: its look, its attack type, who it hits. */\nexport type Bolt = \"lightning\" | \"axe\" | \"spear\" | \"arrow\";\n\nexport const BOLT_TYPE: Readonly<Record<Bolt, AttackType>> = {\n lightning: \"spells\",\n axe: \"normal\",\n spear: \"pierce\",\n arrow: \"pierce\",\n};\n\n/** What a triggered card does. */\nexport type Effect =\n /** Damage: `power` × ATK, `count` times, at whom (`line` — the spear goes through all of them). */\n | {\n kind: \"bolt\";\n bolt: Bolt;\n power: number;\n count: number;\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\";\n }\n | { kind: \"heal\"; share: number }\n /** The shield repairs this share of its max (0 — by what the heal had over). */\n | { kind: \"repair\"; share: number }\n | { kind: \"stun\"; seconds: number; all: boolean }\n /** Charges: focus (+100% crit on the next attack), divine (blocks a blow), lightning (a strike on a hit). */\n | { kind: \"charge\"; charge: \"focus\" | \"divine\" | \"lightning\"; count: number }\n /** Shield break on the target: +10% damage on it a stack. */\n | { kind: \"break\" }\n | { kind: \"again\" }\n | { kind: \"cleave\" }\n /** + crit chance for the rest of the run. */\n | { kind: \"gainCrit\"; value: number }\n | { kind: \"ultBack\" }\n /** The killing blow's damage past the health goes into the shield. */\n | { kind: \"overkillShield\" }\n /** The blow taken heals instead. */\n | { kind: \"hurtToHeal\" }\n /** Attack with no cooldown until the target dies. */\n | { kind: \"flurry\" }\n /** The dead one blows up: this share of its max health on the next. */\n | { kind: \"explode\"; share: number };\n\nexport interface Proc {\n trigger: Trigger;\n /** The chance it fires (1 — always). */\n chance: number;\n /** Fires on every N-th time of its trigger (an attack, a counter) instead of by chance. */\n every?: number;\n effect: Effect;\n}\n\n/** What a card's octagon shows (ui/cardIcons.tsx draws them). */\nexport type CardIcon =\n | \"lightning\"\n | \"axe\"\n | \"spear\"\n | \"arrows\"\n | \"heal\"\n | \"repair\"\n | \"divine\"\n | \"stun\"\n | \"focus\"\n | \"skull\"\n | \"counter\"\n | \"daggers\"\n | \"speed\"\n | \"crit\"\n | \"sword\"\n | \"backrow\"\n | \"break\"\n | \"blood\"\n | \"potion\"\n | \"heart\"\n | \"dodge\"\n | \"rage\"\n | \"type\";\n\nexport interface CardDef {\n id: string;\n rarity: Rarity;\n /** The island it is first drawn on. */\n island: number;\n /** How many times it may be taken in one run. */\n stacks: number;\n icon: CardIcon;\n /** The text, English and Russian, with the markup (see the top). */\n text: [string, string];\n /** Passive numbers it adds. */\n apply?: (m: RunMods) => void;\n /** Its triggered effect. */\n proc?: Proc;\n /** Drawn only when the run has this (a counter card needs a counter). */\n needs?: \"counter\" | \"dodge\" | \"shield\";\n}\n\nconst add = (rec: Partial<Record<string, number>>, key: string, v: number) => {\n rec[key] = (rec[key] ?? 0) + v;\n};\n\n/** Russian first (the owner's sheet), English second; stored [en, ru]. */\nconst P = (ru: string, en: string): [string, string] => [en, ru];\n\nconst bolt = (\n b: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n): Effect => ({ kind: \"bolt\", bolt: b, power, count, at });\n\n/** The cards. Ids are stable: a saved run or a test names them. */\nexport const CARDS: readonly CardDef[] = [\n // ── Passive numbers ──\n {\n id: \"p_counter\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] Контрудар\",\n \"**Passive**: [g]+20%[/g] Counter\",\n ),\n apply: (m) => (m.counter += 0.2),\n },\n {\n id: \"p_speed\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"speed\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Скорость атаки\",\n \"**Passive**: [g]+15%[/g] Attack speed\",\n ),\n apply: (m) => (m.attackSpeed += 0.15),\n },\n {\n id: \"p_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Шанс крита\",\n \"**Passive**: [g]+12%[/g] Crit chance\",\n ),\n apply: (m) => (m.critChance += 0.12),\n },\n {\n id: \"p_critmult\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+60%[/g] Крит. множитель и [g]+4%[/g] Шанс крита\",\n \"**Passive**: [g]+60%[/g] Crit multiplier and [g]+4%[/g] Crit chance\",\n ),\n apply: (m) => {\n m.critDamage += 0.6;\n m.critChance += 0.04;\n },\n },\n {\n id: \"p_dodge\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"dodge\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Уклонение\",\n \"**Passive**: [g]+12%[/g] Dodge\",\n ),\n apply: (m) => (m.dodge += 0.12),\n },\n {\n id: \"p_lifesteal\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"blood\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Шанс вампиризма\",\n \"**Passive**: [g]+15%[/g] Lifesteal chance\",\n ),\n apply: (m) => (m.lifesteal += 0.15),\n },\n {\n id: \"p_hp\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Макс. HP\",\n \"**Passive**: [g]+15%[/g] Max HP\",\n ),\n apply: (m) => (m.maxHp += 0.15),\n },\n {\n id: \"p_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\"**Пассивно**: [g]+30%[/g] ATK\", \"**Passive**: [g]+30%[/g] ATK\"),\n apply: (m) => (m.damage += 0.3),\n },\n {\n id: \"p_again\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] шанс немедленно атаковать ещё раз\",\n \"**Passive**: [g]+20%[/g] chance to attack again at once\",\n ),\n apply: (m) => (m.doubleHit += 0.2),\n },\n {\n id: \"p_backrow\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"backrow\",\n text: P(\n \"**Пассивно**: [g]30%[/g] урона каждого удара проходит во врага позади; урон по заднему ряду [g]+100%[/g]\",\n \"**Passive**: [g]30%[/g] of every blow goes on into the enemy behind; [g]+100%[/g] damage to the back row\",\n ),\n apply: (m) => {\n m.backRow += 1;\n m.pierceShare += 0.3;\n },\n },\n {\n id: \"p_repair\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**Пассивно**: Броня чинится эффективнее на [g]100%[/g]\",\n \"**Passive**: the Armor repairs [g]100%[/g] better\",\n ),\n apply: (m) => (m.repairPower += 1),\n },\n {\n id: \"p_counter_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+2%[/g] Контрудар за каждое усиление\",\n \"**Passive**: [g]+2%[/g] Counter for every upgrade\",\n ),\n apply: (m) => (m.counterPerCard += 0.02),\n },\n {\n id: \"p_hp_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+8%[/g] Макс. HP за каждое усиление\",\n \"**Passive**: [g]+8%[/g] Max HP for every upgrade\",\n ),\n apply: (m) => (m.maxHpPerCard += 0.08),\n },\n {\n id: \"p_counter_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**Пассивно**: Контрудар имеет [g]+20%[/g] шанс крита\",\n \"**Passive**: a Counter has [g]+20%[/g] crit chance\",\n ),\n apply: (m) => (m.counterCrit += 0.2),\n },\n {\n id: \"p_flask\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: [g]+1[/g] зелье на этот забег\",\n \"**Passive**: [g]+1[/g] potion for this run\",\n ),\n apply: (m) => (m.extraPotions += 1),\n },\n {\n id: \"p_alchemist\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: зелья лечат на [g]50%[/g] больше\",\n \"**Passive**: potions heal [g]50%[/g] more\",\n ),\n apply: (m) => (m.potionPower += 0.5),\n },\n {\n id: \"p_low_hp_speed\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"speed\",\n text: P(\n \"**Когда HP ниже 30%**: [g]+100%[/g] Скорость атаки\",\n \"**Below 30% HP**: [g]+100%[/g] Attack speed\",\n ),\n apply: (m) => (m.lowHpSpeed += 1),\n },\n {\n id: \"p_full_hp_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**При полном HP**: [g]+100%[/g] ATK\",\n \"**At full HP**: [g]+100%[/g] ATK\",\n ),\n apply: (m) => (m.fullHpDamage += 1),\n },\n {\n id: \"p_shield_ignore\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n needs: \"shield\",\n text: P(\n \"**Пока есть Броня**: шанс [g]50%[/g] игнорировать урон\",\n \"**While the Armor holds**: [g]50%[/g] chance to ignore damage\",\n ),\n apply: (m) => (m.shieldIgnore = Math.max(m.shieldIgnore, 0.5)),\n },\n // ── Damage of another type, armor broken ──\n {\n id: \"p_magic\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Магией[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Magic[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"magic\", 0.25),\n },\n {\n id: \"p_siege\",\n rarity: \"common\",\n island: 3,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Осадным[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Siege[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"siege\", 0.25),\n },\n {\n id: \"p_pierce\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Проникающим[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Pierce[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"pierce\", 0.25),\n },\n {\n id: \"p_chaos\",\n rarity: \"legendary\",\n island: 4,\n stacks: 1,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+50%[/g] урона каждого удара — [o]Хаосом[/o] (полный урон по любой броне)\",\n \"**Passive**: [g]+50%[/g] of every blow as [o]Chaos[/o] (full damage to any armor)\",\n ),\n apply: (m) => add(m.hybrid, \"chaos\", 0.5),\n },\n {\n id: \"p_vs_heavy\",\n rarity: \"rare\",\n island: 3,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+35%[/g] по [o]Тяжёлой[/o] броне\",\n \"**Passive**: [g]+35%[/g] damage to [o]Heavy[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"heavy\", 0.35),\n },\n {\n id: \"p_vs_fortified\",\n rarity: \"rare\",\n island: 4,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+50%[/g] по [o]Укреплённой[/o] броне\",\n \"**Passive**: [g]+50%[/g] damage to [o]Fortified[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"fortified\", 0.5),\n },\n {\n id: \"p_defend\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"repair\",\n text: P(\n \"**Пассивно**: [g]−30%[/g] урона от [o]Проникающих[/o] (стрелы, болты, пули)\",\n \"**Passive**: [g]−30%[/g] damage from [o]Pierce[/o] (arrows, bolts, bullets)\",\n ),\n apply: (m) => (m.vsPierce += 0.3),\n },\n {\n id: \"hit_shred\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**При попадании**: броня цели [g]−1[/g] (до [g]−5[/g])\",\n \"**On hit**: the target's armor [g]−1[/g] (down to [g]−5[/g])\",\n ),\n apply: (m) => {\n m.shred += 1;\n m.shredMax += 5;\n },\n },\n {\n id: \"wave_cry\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**Новая волна**: броня врагов [g]−2[/g]\",\n \"**A new wave**: the enemies' armor [g]−2[/g]\",\n ),\n apply: (m) => (m.warCry += 2),\n },\n // ── On hit ──\n {\n id: \"hit_spear\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"spear\",\n text: P(\n \"**При попадании**: шанс [g]10%[/g] метнуть [o]Пронзающее копьё[/o] ([g]60%[/g] ATK)\",\n \"**On hit**: [g]10%[/g] chance to throw a [o]Piercing spear[/o] ([g]60%[/g] ATK)\",\n ),\n proc: {\n trigger: \"hit\",\n chance: 0.1,\n effect: bolt(\"spear\", 0.6, 1, \"line\"),\n },\n },\n {\n id: \"hit_break\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"break\",\n text: P(\n \"**При попадании**: заряд «[o]Пробитие щита[/o]», увеличивающий урон по цели на [g]8%[/g] (до [g]4[/g] зарядов)\",\n \"**On hit**: a «[o]Shield break[/o]» stack, [g]+8%[/g] damage to the target (up to [g]4[/g] stacks)\",\n ),\n proc: { trigger: \"hit\", chance: 1, effect: { kind: \"break\" } },\n },\n {\n id: \"hit_missing\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\n \"**При попадании**: Урон [g]+10%[/g] за каждые [g]10%[/g] недостающего HP цели\",\n \"**On hit**: [g]+10%[/g] damage for every [g]10%[/g] of the target's missing HP\",\n ),\n apply: (m) => (m.missingHpDamage += 0.1),\n },\n // ── On crit ──\n {\n id: \"crit_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n text: P(\n \"**При крите**: метните [o]Топор[/o] ([g]150%[/g] ATK) в случайного врага\",\n \"**On crit**: throw an [o]Axe[/o] ([g]150%[/g] ATK) at a random enemy\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: bolt(\"axe\", 1.5, 1, \"random\") },\n },\n {\n id: \"crit_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При крите**: [o]Обездвиживание[/o] цели на [g]3[/g] сек.\",\n \"**On crit**: [o]Stun[/o] the target for [g]3[/g] s\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 3, all: false },\n },\n },\n {\n id: \"crit_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**При крите**: восстановите [g]6%[/g] HP\",\n \"**On crit**: restore [g]6%[/g] HP\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: { kind: \"heal\", share: 0.06 } },\n },\n {\n id: \"crit_arrows\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"arrows\",\n text: P(\n \"**При крите**: выпускаются [g]2[/g] [o]Стрелы[/o] ([g]33%[/g] ATK) по случайным врагам\",\n \"**On crit**: [g]2[/g] [o]Arrows[/o] ([g]33%[/g] ATK) fly at random enemies\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: bolt(\"arrow\", 0.33, 2, \"random\"),\n },\n },\n // ── On kill ──\n {\n id: \"kill_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]30%[/g] восстановить [g]8%[/g] HP\",\n \"**On kill**: [g]30%[/g] chance to restore [g]8%[/g] HP\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.3,\n effect: { kind: \"heal\", share: 0.08 },\n },\n },\n {\n id: \"kill_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]50%[/g] получить [g]+2%[/g] Шанс крита\",\n \"**On kill**: [g]50%[/g] chance to gain [g]+2%[/g] Crit chance\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.5,\n effect: { kind: \"gainCrit\", value: 0.02 },\n },\n },\n {\n id: \"kill_arrow\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"arrows\",\n text: P(\n \"**При убийстве**: [o]Стрела[/o] ([g]60%[/g] ATK) летит в следующего врага\",\n \"**On kill**: an [o]Arrow[/o] ([g]60%[/g] ATK) flies at the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 1,\n effect: bolt(\"arrow\", 0.6, 1, \"next\"),\n },\n },\n {\n id: \"kill_overkill\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При убийстве**: избыточный урон засчитывается как [o]Броня[/o]\",\n \"**On kill**: the overkill damage turns into [o]Armor[/o]\",\n ),\n proc: { trigger: \"kill\", chance: 1, effect: { kind: \"overkillShield\" } },\n },\n {\n id: \"kill_explode\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]25%[/g] — [o]Взрыв[/o] ([g]30%[/g] макс. HP погибшего) по следующему врагу\",\n \"**On kill**: [g]25%[/g] chance of a [o]Blast[/o] ([g]30%[/g] of the dead one's max HP) on the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.25,\n effect: { kind: \"explode\", share: 0.3 },\n },\n },\n {\n id: \"kill_ult\",\n rarity: \"rare\",\n island: 1,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**При убийстве**: шанс [g]15%[/g] вернуть ультимейт\",\n \"**On kill**: [g]15%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"kill\", chance: 0.15, effect: { kind: \"ultBack\" } },\n },\n // ── On dodge ──\n {\n id: \"dodge_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: метните [o]Топор[/o] ([g]50%[/g] ATK) в случайного врага\",\n \"**On dodge**: throw an [o]Axe[/o] ([g]50%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: bolt(\"axe\", 0.5, 1, \"random\"),\n },\n },\n {\n id: \"dodge_focus\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"focus\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: получите заряд «[o]Концентрация[/o]» (x1, [g]+100%[/g] шанс крита на одну атаку)\",\n \"**On dodge**: gain a «[o]Focus[/o]» charge (x1, [g]+100%[/g] crit chance for one attack)\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"focus\", count: 1 },\n },\n },\n {\n id: \"dodge_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: [o]Броня[/o] чинится на [g]12%[/g]\",\n \"**On dodge**: the [o]Armor[/o] repairs [g]12%[/g]\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"repair\", share: 0.12 },\n },\n },\n // ── On counter ──\n {\n id: \"counter_again\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]33%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]33%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.33, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_again_plus\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]66%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]66%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.66, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_cleave\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: [o]Рассечение[/o] (удар по соседнему врагу)\",\n \"**On counter**: [o]Cleave[/o] (a blow at the enemy next to it)\",\n ),\n proc: { trigger: \"counter\", chance: 1, effect: { kind: \"cleave\" } },\n },\n {\n id: \"counter_flurry\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"rage\",\n needs: \"counter\",\n text: P(\n \"**Каждый [g]4[/g]-й Контрудар**: атакуйте без перезарядки, пока враг не умрёт\",\n \"**Every [g]4[/g]th Counter**: attack with no cooldown until the enemy dies\",\n ),\n proc: {\n trigger: \"counter\",\n chance: 1,\n every: 4,\n effect: { kind: \"flurry\" },\n },\n },\n // ── On being hit ──\n {\n id: \"hurt_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"blood\",\n text: P(\n \"**При получении удара**: шанс [g]10%[/g] превратить урон в лечение\",\n \"**When hit**: [g]10%[/g] chance to turn the damage into healing\",\n ),\n proc: { trigger: \"hurt\", chance: 0.1, effect: { kind: \"hurtToHeal\" } },\n },\n // ── On healing ──\n {\n id: \"heal_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: двойной [o]Удар молнии[/o] ([g]75%[/g] ATK) по самому дальнему врагу\",\n \"**On heal**: a double [o]Lightning strike[/o] ([g]75%[/g] ATK) at the farthest enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 2, \"farthest\"),\n },\n },\n {\n id: \"overheal_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При избыточном лечении**: [o]Броня[/o] чинится\",\n \"**On overheal**: the [o]Armor[/o] repairs\",\n ),\n proc: {\n trigger: \"overheal\",\n chance: 1,\n effect: { kind: \"repair\", share: 0 },\n },\n },\n {\n id: \"overheal_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: [o]Удар молнии[/o] ([g]75%[/g] ATK) по случайному врагу\",\n \"**On heal**: a [o]Lightning strike[/o] ([g]75%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 1, \"random\"),\n },\n },\n // ── On the ultimate ──\n {\n id: \"ult_divine\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n text: P(\n \"**При использовании активного умения**: получите [o]Божественный щит[/o] (x8), блокирует урон один раз\",\n \"**On the active skill**: gain a [o]Divine shield[/o] (x8), each blocks one blow\",\n ),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"divine\", count: 8 },\n },\n },\n {\n id: \"ult_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При использовании активного умения**: [o]Обездвиживание[/o] всех врагов на [g]6[/g] сек. Обездвиженные враги получают [g]+50%[/g] урона\",\n \"**On the active skill**: [o]Stun[/o] every enemy for [g]6[/g] s. Stunned enemies take [g]+50%[/g] damage\",\n ),\n apply: (m) => (m.vsStunned += 0.5),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 6, all: true },\n },\n },\n // ── Every N-th attack ──\n {\n id: \"every3_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**Каждая [g]3[/g]-я атака**: двойной [o]Удар молнии[/o] ([g]30%[/g] ATK) по врагу\",\n \"**Every [g]3[/g]rd attack**: a double [o]Lightning strike[/o] ([g]30%[/g] ATK) at the enemy\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 3,\n effect: bolt(\"lightning\", 0.3, 2, \"target\"),\n },\n },\n {\n id: \"every4_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"heal\",\n text: P(\n \"**Каждая [g]4[/g]-я атака** восстанавливает [g]2%[/g] здоровья\",\n \"**Every [g]4[/g]th attack** restores [g]2%[/g] health\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 4,\n effect: { kind: \"heal\", share: 0.02 },\n },\n },\n // ── The camp, a new run level ──\n {\n id: \"camp_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При отдыхе**: получите заряды «[o]Удар молнии[/o]» (x3, [g]75%[/g] ATK), которые поражают врагов **при попадании**\",\n \"**On rest**: gain «[o]Lightning strike[/o]» charges (x3, [g]75%[/g] ATK) that strike enemies **on hit**\",\n ),\n proc: {\n trigger: \"camp\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"lightning\", count: 3 },\n },\n },\n {\n id: \"level_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**Новый уровень забега**: восстановите [g]25%[/g] HP\",\n \"**A new run level**: restore [g]25%[/g] HP\",\n ),\n proc: {\n trigger: \"level\",\n chance: 1,\n effect: { kind: \"heal\", share: 0.25 },\n },\n },\n {\n id: \"level_ult\",\n rarity: \"rare\",\n island: 2,\n stacks: 1,\n icon: \"rage\",\n text: P(\n \"**Новый уровень забега**: шанс [g]30%[/g] вернуть ультимейт\",\n \"**A new run level**: [g]30%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"level\", chance: 0.3, effect: { kind: \"ultBack\" } },\n },\n];\n\nexport const CARD_BY_ID: ReadonlyMap<string, CardDef> = new Map(\n CARDS.map((c) => [c.id, c]),\n);\n\n/** The run's passive numbers from the cards taken. */\nexport function modsOf(taken: readonly string[]): RunMods {\n const m = noMods();\n for (const id of taken) CARD_BY_ID.get(id)?.apply?.(m);\n // the \"for every upgrade\" cards count every card taken\n m.counter += m.counterPerCard * taken.length;\n m.maxHp += m.maxHpPerCard * taken.length;\n return m;\n}\n\n/** The run's triggered effects from the cards taken (a card taken twice fires twice). */\nexport function procsOf(taken: readonly string[]): Proc[] {\n const out: Proc[] = [];\n for (const id of taken) {\n const p = CARD_BY_ID.get(id)?.proc;\n if (p) out.push(p);\n }\n return out;\n}\n\n/** What the run has so far, for the cards that build on something. */\nexport interface DrawContext {\n counter: boolean;\n dodge: boolean;\n shield: boolean;\n}\n\n/**\n * Three different cards to choose from: the ones open on this island, not yet taken as many times as\n * they stack, with what they build on already there (no counter card without a counter), weighted by\n * rarity — drawn from the run's own random, so a seed replays the same picks.\n */\nexport function drawCards(\n random: () => number,\n island: number,\n taken: readonly string[],\n has: DrawContext = { counter: true, dodge: true, shield: true },\n count = 3,\n): string[] {\n const times = new Map<string, number>();\n for (const id of taken) times.set(id, (times.get(id) ?? 0) + 1);\n const pool = CARDS.filter(\n (c) =>\n c.island <= island &&\n (times.get(c.id) ?? 0) < c.stacks &&\n (!c.needs || has[c.needs]),\n );\n const out: string[] = [];\n while (out.length < count && pool.length > 0) {\n const total = pool.reduce((s, c) => s + RARITY_WEIGHT[c.rarity], 0);\n let roll = random() * total;\n let i = 0;\n for (; i < pool.length - 1; i++) {\n roll -= RARITY_WEIGHT[pool[i]!.rarity];\n if (roll < 0) break;\n }\n out.push(pool[i]!.id);\n pool.splice(i, 1);\n }\n return out;\n}\n"
340
339
  },
341
340
  {
342
341
  "path": "game/damage.ts",
343
- "content": "// Damage the Warcraft III way: every blow has an ATTACK TYPE (what hits — a blade, an arrow, a hammer,\n// a spell) and every fighter an ARMOR TYPE (what it wears) plus an ARMOR VALUE. The table says how\n// well a type of attack goes through a type of armor; the value then takes its share off by the WC3\n// formula (never all of it). Seven attack types × six armor types, as in The Frozen Throne — the\n// numbers are the owner's sheet of it; tune them gently, the feel of \"an arrow tears a robe, bounces\n// off a wall\" is the point.\n//\n// Pure: no Phaser, no client — the battle, the scouting screen and the tests read the same numbers.\n\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type AttackType =\n \"normal\" | \"pierce\" | \"siege\" | \"magic\" | \"chaos\" | \"spells\" | \"hero\";\n\nexport type ArmorType =\n \"light\" | \"medium\" | \"heavy\" | \"fortified\" | \"hero\" | \"unarmored\";\n\nexport const ATTACK_TYPES: readonly AttackType[] = [\n \"normal\",\n \"pierce\",\n \"siege\",\n \"magic\",\n \"chaos\",\n \"spells\",\n \"hero\",\n];\n\nexport const ARMOR_TYPES: readonly ArmorType[] = [\n \"light\",\n \"medium\",\n \"heavy\",\n \"fortified\",\n \"hero\",\n \"unarmored\",\n];\n\n/** How much of a blow of this attack type goes through this armor type (1 — all of it). */\nexport const DAMAGE_TABLE: Readonly<\n Record<AttackType, Readonly<Record<ArmorType, number>>>\n> = {\n normal: {\n light: 1,\n medium: 1.5,\n heavy: 1,\n fortified: 0.7,\n hero: 1,\n unarmored: 1,\n },\n pierce: {\n light: 2,\n medium: 0.75,\n heavy: 1,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1.5,\n },\n siege: {\n light: 1,\n medium: 0.5,\n heavy: 1,\n fortified: 1.5,\n hero: 0.5,\n unarmored: 1.5,\n },\n magic: {\n light: 1.25,\n medium: 0.75,\n heavy: 2,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1,\n },\n chaos: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 1,\n unarmored: 1,\n },\n spells: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 0.7,\n unarmored: 1,\n },\n hero: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 0.5,\n hero: 1,\n unarmored: 1,\n },\n};\n\n/** WC3's armor constant: each point of armor is worth this much. */\nexport const ARMOR_PER_POINT = 0.06;\n\n/**\n * The share of a blow the armor VALUE takes off: `0.06·A / (1 + 0.06·A)`, the percent rounded up\n * (10 armor — 38 %, 20 — 55 %, never 100 %). Negative armor takes nothing off — see `armorFactor`.\n */\nexport function armorReduction(armor: number): number {\n if (!(armor > 0)) return 0;\n const share = (ARMOR_PER_POINT * armor) / (1 + ARMOR_PER_POINT * armor);\n return Math.ceil(share * 100 - 1e-9) / 100;\n}\n\n/**\n * What a blow is multiplied by for the armor value: below 1 for armor, above 1 for armor broken into\n * the negative (WC3: `2 − 0.94^−A`, so −10 armor takes ~46 % more, and never twice as much).\n */\nexport function armorFactor(armor: number): number {\n if (armor >= 0) return 1 - armorReduction(armor);\n return 2 - Math.pow(1 - ARMOR_PER_POINT, -armor);\n}\n\n/** The table's word on this attack against this armor. */\nexport function typeFactor(attack: AttackType, armor: ArmorType): number {\n return DAMAGE_TABLE[attack][armor];\n}\n\n/**\n * A blow of `damage` of `attack` type on a target in `armorType` with `armorValue` points: the table,\n * then the armor value. Spells go through armor of any value (WC3 — the armor value is physical);\n * only the table's type bites them.\n */\nexport function dealt(\n damage: number,\n attack: AttackType,\n armorType: ArmorType,\n armorValue: number,\n): number {\n const value = attack === \"spells\" ? 1 : armorFactor(armorValue);\n return Math.max(0, damage * typeFactor(attack, armorType) * value);\n}\n\n/** How a type of attack fares against a type of armor, as the screens colour it. */\nexport type Effect = \"strong\" | \"even\" | \"weak\";\n\nexport function effectOf(attack: AttackType, armor: ArmorType): Effect {\n const f = typeFactor(attack, armor);\n return f > 1.001 ? \"strong\" : f < 0.999 ? \"weak\" : \"even\";\n}\n\n// ── What a fighter's gear says ───────────────────────────────────────────────────────────────────\n\n/** Weapons that crush: maces and hammers — siege damage (they break walls and shields). */\nconst SIEGE_WEAPONS = new Set([\"weapon/mace\", \"weapon/warhammer\"]);\n/** Weapons that cast: staves — magic damage. */\nconst MAGIC_WEAPONS = new Set([\n \"weapon/wizard_staff\",\n \"weapon/druid_staff\",\n \"weapon/28\",\n]);\n/** Melee weapons that pierce: the spear. */\nconst PIERCING_MELEE = new Set([\"weapon/spear\"]);\n\n/**\n * The attack type of what the hand holds (when the item does not name one itself): a bow, a crossbow,\n * a gun, a spear — pierce; a mace, a hammer — siege; a staff — magic; a sword, an axe, a dagger, bare\n * hands — normal.\n */\nexport function attackTypeOf(weapon: string | null): AttackType {\n if (!weapon) return \"normal\";\n if (MAGIC_WEAPONS.has(weapon)) return \"magic\";\n if (SIEGE_WEAPONS.has(weapon)) return \"siege\";\n if (PIERCING_MELEE.has(weapon)) return \"pierce\";\n return heroIsMelee(weapon) ? \"normal\" : \"pierce\";\n}\n\n/**\n * The armor type of what the body wears (when the item does not name one itself): plate — heavy (and\n * plate with a tower shield — fortified, a walking wall), chainmail — medium, leather — light, a robe\n * or plain clothes — unarmored.\n */\nexport function armorTypeOf(\n top: string | null,\n offhand: string | null = null,\n): ArmorType {\n const t = top ?? \"\";\n if (/plate/.test(t))\n return offhand === \"offhand/tower_shield\" ? \"fortified\" : \"heavy\";\n if (/chain/.test(t)) return \"medium\";\n if (/leather/.test(t)) return \"light\";\n return \"unarmored\";\n}\n\n/** A type named in an item's CustomData (\"Pierce\", \"pierce\", \"PIERCE\") — or null when it is not one. */\nexport function parseAttackType(value: unknown): AttackType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ATTACK_TYPES as readonly string[]).includes(v)\n ? (v as AttackType)\n : null;\n}\n\nexport function parseArmorType(value: unknown): ArmorType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ARMOR_TYPES as readonly string[]).includes(v)\n ? (v as ArmorType)\n : null;\n}\n"
342
+ "content": "// Damage by type: every blow has an ATTACK TYPE (what hits — a blade, an arrow, a hammer,\n// a spell) and every fighter an ARMOR TYPE (what it wears) plus an ARMOR VALUE. The table says how\n// well a type of attack goes through a type of armor; the value then takes its share off by the\n// formula (never all of it). Seven attack types × six armor types — the\n// numbers are the owner's sheet of it; tune them gently, the feel of \"an arrow tears a robe, bounces\n// off a wall\" is the point.\n//\n// Pure: no Phaser, no client — the battle, the scouting screen and the tests read the same numbers.\n\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type AttackType =\n \"normal\" | \"pierce\" | \"siege\" | \"magic\" | \"chaos\" | \"spells\" | \"hero\";\n\nexport type ArmorType =\n \"light\" | \"medium\" | \"heavy\" | \"fortified\" | \"hero\" | \"unarmored\";\n\nexport const ATTACK_TYPES: readonly AttackType[] = [\n \"normal\",\n \"pierce\",\n \"siege\",\n \"magic\",\n \"chaos\",\n \"spells\",\n \"hero\",\n];\n\nexport const ARMOR_TYPES: readonly ArmorType[] = [\n \"light\",\n \"medium\",\n \"heavy\",\n \"fortified\",\n \"hero\",\n \"unarmored\",\n];\n\n/** How much of a blow of this attack type goes through this armor type (1 — all of it). */\nexport const DAMAGE_TABLE: Readonly<\n Record<AttackType, Readonly<Record<ArmorType, number>>>\n> = {\n normal: {\n light: 1,\n medium: 1.5,\n heavy: 1,\n fortified: 0.7,\n hero: 1,\n unarmored: 1,\n },\n pierce: {\n light: 2,\n medium: 0.75,\n heavy: 1,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1.5,\n },\n siege: {\n light: 1,\n medium: 0.5,\n heavy: 1,\n fortified: 1.5,\n hero: 0.5,\n unarmored: 1.5,\n },\n magic: {\n light: 1.25,\n medium: 0.75,\n heavy: 2,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1,\n },\n chaos: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 1,\n unarmored: 1,\n },\n spells: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 0.7,\n unarmored: 1,\n },\n hero: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 0.5,\n hero: 1,\n unarmored: 1,\n },\n};\n\n/** The armor constant: each point of armor is worth this much. */\nexport const ARMOR_PER_POINT = 0.06;\n\n/**\n * The share of a blow the armor VALUE takes off: `0.06·A / (1 + 0.06·A)`, the percent rounded up\n * (10 armor — 38 %, 20 — 55 %, never 100 %). Negative armor takes nothing off — see `armorFactor`.\n */\nexport function armorReduction(armor: number): number {\n if (!(armor > 0)) return 0;\n const share = (ARMOR_PER_POINT * armor) / (1 + ARMOR_PER_POINT * armor);\n return Math.ceil(share * 100 - 1e-9) / 100;\n}\n\n/**\n * What a blow is multiplied by for the armor value: below 1 for armor, above 1 for armor broken into\n * the negative (`2 − 0.94^−A`, so −10 armor takes ~46 % more, and never twice as much).\n */\nexport function armorFactor(armor: number): number {\n if (armor >= 0) return 1 - armorReduction(armor);\n return 2 - Math.pow(1 - ARMOR_PER_POINT, -armor);\n}\n\n/** The table's word on this attack against this armor. */\nexport function typeFactor(attack: AttackType, armor: ArmorType): number {\n return DAMAGE_TABLE[attack][armor];\n}\n\n/**\n * A blow of `damage` of `attack` type on a target in `armorType` with `armorValue` points: the table,\n * then the armor value. Spells go through armor of any value (the armor value is physical);\n * only the table's type bites them.\n */\nexport function dealt(\n damage: number,\n attack: AttackType,\n armorType: ArmorType,\n armorValue: number,\n): number {\n const value = attack === \"spells\" ? 1 : armorFactor(armorValue);\n return Math.max(0, damage * typeFactor(attack, armorType) * value);\n}\n\n/** How a type of attack fares against a type of armor, as the screens colour it. */\nexport type Effect = \"strong\" | \"even\" | \"weak\";\n\nexport function effectOf(attack: AttackType, armor: ArmorType): Effect {\n const f = typeFactor(attack, armor);\n return f > 1.001 ? \"strong\" : f < 0.999 ? \"weak\" : \"even\";\n}\n\n// ── What a fighter's gear says ───────────────────────────────────────────────────────────────────\n\n/** Weapons that crush: maces and hammers — siege damage (they break walls and shields). */\nconst SIEGE_WEAPONS = new Set([\"weapon/mace\", \"weapon/warhammer\"]);\n/** Weapons that cast: staves — magic damage. */\nconst MAGIC_WEAPONS = new Set([\n \"weapon/wizard_staff\",\n \"weapon/druid_staff\",\n \"weapon/28\",\n]);\n/** Melee weapons that pierce: the spear. */\nconst PIERCING_MELEE = new Set([\"weapon/spear\"]);\n\n/**\n * The attack type of what the hand holds (when the item does not name one itself): a bow, a crossbow,\n * a gun, a spear — pierce; a mace, a hammer — siege; a staff — magic; a sword, an axe, a dagger, bare\n * hands — normal.\n */\nexport function attackTypeOf(weapon: string | null): AttackType {\n if (!weapon) return \"normal\";\n if (MAGIC_WEAPONS.has(weapon)) return \"magic\";\n if (SIEGE_WEAPONS.has(weapon)) return \"siege\";\n if (PIERCING_MELEE.has(weapon)) return \"pierce\";\n return heroIsMelee(weapon) ? \"normal\" : \"pierce\";\n}\n\n/**\n * The armor type of what the body wears (when the item does not name one itself): plate — heavy (and\n * plate with a tower shield — fortified, a walking wall), chainmail — medium, leather — light, a robe\n * or plain clothes — unarmored.\n */\nexport function armorTypeOf(\n top: string | null,\n offhand: string | null = null,\n): ArmorType {\n const t = top ?? \"\";\n if (/plate/.test(t))\n return offhand === \"offhand/tower_shield\" ? \"fortified\" : \"heavy\";\n if (/chain/.test(t)) return \"medium\";\n if (/leather/.test(t)) return \"light\";\n return \"unarmored\";\n}\n\n/** A type named in an item's CustomData (\"Pierce\", \"pierce\", \"PIERCE\") — or null when it is not one. */\nexport function parseAttackType(value: unknown): AttackType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ATTACK_TYPES as readonly string[]).includes(v)\n ? (v as AttackType)\n : null;\n}\n\nexport function parseArmorType(value: unknown): ArmorType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ARMOR_TYPES as readonly string[]).includes(v)\n ? (v as ArmorType)\n : null;\n}\n"
344
343
  },
345
344
  {
346
345
  "path": "game/economy.ts",
@@ -348,7 +347,7 @@
348
347
  },
349
348
  {
350
349
  "path": "game/enemyLooks.ts",
351
- "content": "// Enemies are people like the hero, on the same rig: a troop (units.ts) is dressed in exactly the gear\n// that gives its types — plate for heavy armor, a robe for none, a bow for pierce, a hammer for siege —\n// so the player reads the Warcraft table off the picture. The troop's id on the road picks its face,\n// skin and hair, so a wave is a varied crowd and one enemy looks the same on every frame.\n\n// engine-free parts of the character system only (its index brings the Phaser renderer along)\nimport { type CharacterLook } from \"../character/core/look\";\nimport { seedOf, seededRandom } from \"../character/core/random\";\nimport { VARIETY } from \"../character/looks/classLooks\";\nimport type { TroopDef } from \"./units\";\n\nconst HAIR = [\"hair/2\", \"hair/3\", \"hair/5\", \"hair/10\", \"hair/13\"];\nconst BEARD = [null, null, \"beard/4\", \"beard/10\"];\nconst EYES = [\"eyes/1\", \"eyes/10\"];\n\nexport function enemyLook(troop: TroopDef, monsterId: number): CharacterLook {\n const rng = seededRandom(seedOf(`${troop.id}:${monsterId}`));\n const pick = <T>(list: readonly T[]): T =>\n list[Math.floor(rng() * list.length)]!;\n const hair = pick(VARIETY.hairColor);\n return {\n v: 1,\n parts: {\n Skin: \"skin/1\",\n Eyes: pick(EYES),\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Hair: pick(HAIR),\n Beard: troop.boss ? \"beard/4\" : pick(BEARD),\n Glasses: null,\n Pants: \"pants/26\",\n ...troop.gear,\n },\n colors: {\n Skin: pick(VARIETY.skin),\n Hair: hair,\n Beard: hair,\n Brows: hair,\n },\n };\n}\n"
350
+ "content": "// Enemies are people like the hero, on the same rig: a troop (units.ts) is dressed in exactly the gear\n// that gives its types — plate for heavy armor, a robe for none, a bow for pierce, a hammer for siege —\n// so the player reads the damage table off the picture. The troop's id on the road picks its face,\n// skin and hair, so a wave is a varied crowd and one enemy looks the same on every frame.\n\n// engine-free parts of the character system only (its index brings the Phaser renderer along)\nimport { type CharacterLook } from \"../character/core/look\";\nimport { seedOf, seededRandom } from \"../character/core/random\";\nimport { VARIETY } from \"../character/looks/classLooks\";\nimport type { TroopDef } from \"./units\";\n\nconst HAIR = [\"hair/2\", \"hair/3\", \"hair/5\", \"hair/10\", \"hair/13\"];\nconst BEARD = [null, null, \"beard/4\", \"beard/10\"];\nconst EYES = [\"eyes/1\", \"eyes/10\"];\n\nexport function enemyLook(troop: TroopDef, monsterId: number): CharacterLook {\n const rng = seededRandom(seedOf(`${troop.id}:${monsterId}`));\n const pick = <T>(list: readonly T[]): T =>\n list[Math.floor(rng() * list.length)]!;\n const hair = pick(VARIETY.hairColor);\n return {\n v: 1,\n parts: {\n Skin: \"skin/1\",\n Eyes: pick(EYES),\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Hair: pick(HAIR),\n Beard: troop.boss ? \"beard/4\" : pick(BEARD),\n Glasses: null,\n Pants: \"pants/26\",\n ...troop.gear,\n },\n colors: {\n Skin: pick(VARIETY.skin),\n Hair: hair,\n Beard: hair,\n Brows: hair,\n },\n };\n}\n"
352
351
  },
353
352
  {
354
353
  "path": "game/format.ts",
@@ -376,7 +375,7 @@
376
375
  },
377
376
  {
378
377
  "path": "game/progress.ts",
379
- "content": "// The campaign progress saved in the player's private custom data: how far the player has cleared,\n// the tips already shown (each Warcraft type is explained once), the ultimate the player keeps.\n\nimport type { UltId } from \"./battle\";\n\n/** The private custom-data key (\".\" and \"$\" are not allowed in keys). */\nexport const PROGRESS_KEY = \"hero_run_progress\";\n\nexport interface Progress {\n /** The highest level cleared (0 — none); the next one is open. */\n cleared: number;\n /** Tips already shown (\"armor:medium\", \"attack:magic\", …). */\n tips: string[];\n ult: UltId;\n /** Run chests opened — the first ones are the starter chests. */\n chests: number;\n}\n\nexport const NEW_PROGRESS: Progress = {\n cleared: 0,\n tips: [],\n ult: \"meteor\",\n chests: 0,\n};\n\nexport function parseProgress(raw: string | null | undefined): Progress {\n if (!raw) return { ...NEW_PROGRESS, tips: [] };\n try {\n const v = JSON.parse(raw) as Partial<Progress>;\n const cleared = Math.max(0, Math.floor(Number(v.cleared) || 0));\n const tips = Array.isArray(v.tips)\n ? v.tips.filter((t): t is string => typeof t === \"string\")\n : [];\n const ult: UltId = v.ult === \"frenzy\" ? \"frenzy\" : \"meteor\";\n const chests = Math.max(0, Math.floor(Number(v.chests) || 0));\n return { cleared, tips, ult, chests };\n } catch {\n return { ...NEW_PROGRESS, tips: [] };\n }\n}\n\nexport function serializeProgress(p: Progress): string {\n return JSON.stringify({\n cleared: p.cleared,\n tips: p.tips,\n ult: p.ult,\n chests: p.chests,\n });\n}\n"
378
+ "content": "// The campaign progress saved in the player's private custom data: how far the player has cleared,\n// the tips already shown (each attack or armor type is explained once), the ultimate the player keeps.\n\nimport type { UltId } from \"./battle\";\n\n/** The private custom-data key (\".\" and \"$\" are not allowed in keys). */\nexport const PROGRESS_KEY = \"hero_run_progress\";\n\nexport interface Progress {\n /** The highest level cleared (0 — none); the next one is open. */\n cleared: number;\n /** Tips already shown (\"armor:medium\", \"attack:magic\", …). */\n tips: string[];\n ult: UltId;\n /** Run chests opened — the first ones are the starter chests. */\n chests: number;\n}\n\nexport const NEW_PROGRESS: Progress = {\n cleared: 0,\n tips: [],\n ult: \"meteor\",\n chests: 0,\n};\n\nexport function parseProgress(raw: string | null | undefined): Progress {\n if (!raw) return { ...NEW_PROGRESS, tips: [] };\n try {\n const v = JSON.parse(raw) as Partial<Progress>;\n const cleared = Math.max(0, Math.floor(Number(v.cleared) || 0));\n const tips = Array.isArray(v.tips)\n ? v.tips.filter((t): t is string => typeof t === \"string\")\n : [];\n const ult: UltId = v.ult === \"frenzy\" ? \"frenzy\" : \"meteor\";\n const chests = Math.max(0, Math.floor(Number(v.chests) || 0));\n return { cleared, tips, ult, chests };\n } catch {\n return { ...NEW_PROGRESS, tips: [] };\n }\n}\n\nexport function serializeProgress(p: Progress): string {\n return JSON.stringify({\n cleared: p.cleared,\n tips: p.tips,\n ult: p.ult,\n chests: p.chests,\n });\n}\n"
380
379
  },
381
380
  {
382
381
  "path": "game/random.ts",
@@ -388,7 +387,7 @@
388
387
  },
389
388
  {
390
389
  "path": "game/sim.ts",
391
- "content": "// The balance simulator: the real Battle without a screen, and a player who keeps running the\n// campaign on the title's own config — chests rolled from its lootboxes, gear upgraded with its\n// gold, the hero built by the same fighterStats/heroGear as the game. `scripts/balance.ts` prints\n// the report; the tests keep the casual start casual.\n//\n// What it does NOT know: the real time (energy is counted in runs, not hours), daily free gems, the\n// player's taste (a card picker and a spending policy stand in for it). Prices follow the server's\n// rule \"base × curve from the first paid step\" — close enough for a balance read, not a bill.\n\nimport { evaluateCurve, type ScalarCurveSpec } from \"@idosgames/core\";\nimport { Battle, type CampChoice, type RunResult, type UltId } from \"./battle\";\nimport { levelOf, scout } from \"./campaign\";\nimport { typeFactor, type ArmorType, type AttackType } from \"./damage\";\nimport { CARD_BY_ID } from \"./cards\";\nimport {\n CHEST_IDS,\n FIRST_CHESTS,\n FIRST_CHEST_IDS,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n XP_ID,\n chestTier,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n} from \"./heroStats\";\nimport { seededRandom } from \"./random\";\n\n// ── One run ──────────────────────────────────────────────────────────────────────────────────\n\n/** What the hero brings into a run. */\nexport interface SimHero {\n stats: FighterStats;\n gear: HeroGear;\n}\n\n/** Picks one of the offered cards. */\nexport type CardPicker = (\n options: readonly string[],\n battle: Battle,\n random: () => number,\n) => string;\n\nconst RARITY_RANK = { common: 0, rare: 1, legendary: 2 } as const;\n\nexport const PICKERS: Readonly<Record<string, CardPicker>> = {\n /** The first card on the screen — a player who does not read. */\n first: (o) => o[0]!,\n /** Any card. */\n random: (o, _b, r) => o[Math.floor(r() * o.length)]!,\n /** The rarest card (a player who trusts the colour). */\n rarity: (o) =>\n [...o].sort(\n (a, b) =>\n RARITY_RANK[CARD_BY_ID.get(b)!.rarity] -\n RARITY_RANK[CARD_BY_ID.get(a)!.rarity],\n )[0]!,\n};\n\n/** The camp: health when hurt, rage otherwise. */\nexport const campByHealth = (b: Battle): CampChoice =>\n b.heroHp / Math.max(1, b.heroMaxHp()) < 0.6 ? \"heal\" : \"rage\";\n\nexport interface SimRunOptions {\n level: number;\n hero: SimHero;\n potions: number;\n ult?: UltId;\n picker?: CardPicker;\n camp?: (b: Battle) => CampChoice;\n seed: number;\n /** Cards the run starts with (the balance report measures one card this way). */\n cards?: readonly string[];\n}\n\nexport interface SimRunResult extends RunResult {\n /** Health left at the end (share of the max). */\n hpLeft: number;\n /** The run hit the time cap (a very long run — every fight does end), counted as a loss. */\n stalled: boolean;\n /** The longest fight with one segment (a wave, the boss), seconds. */\n longestFight: number;\n}\n\n/** Seconds of game time after which the simulator stops a run (a loss). Not a stalemate: troops never\n * heal and a blow always deals damage, so every fight ends — the cap only keeps the report fast. */\nexport const SIM_MAX_SECONDS = 900;\nconst SIM_STEP = 0.05;\n\n/**\n * One run, as the scene would play it: the same swing, gait and line spacing the screen sets on\n * the battle (a melee hero with the default rig — measured on the stand), potions and the\n * ultimate on auto.\n */\nexport function simulateRun(o: SimRunOptions): SimRunResult {\n const random = seededRandom(o.seed ^ 0x5bd1e995);\n const b = new Battle({\n level: levelOf(o.level),\n stats: o.hero.stats,\n heroAttack: o.hero.gear.attack,\n heroArmor: o.hero.gear.armor,\n shield: o.hero.gear.shield,\n ult: o.ult ?? \"meteor\",\n potions: o.potions,\n seed: o.seed,\n });\n b.setReach(o.hero.gear.melee ? \"melee\" : \"ranged\");\n b.setSwing(0.3, 0.55);\n b.setWalkSpeed(0.2);\n b.setMonsterGap(0.16);\n b.setWaveAhead(0.86);\n if (o.cards?.length) {\n b.setCards(o.cards);\n b.heroHp = b.heroMaxHp();\n b.heroShield = b.heroMaxShield();\n b.potions += b.mods.extraPotions;\n b.cardPotions += b.mods.extraPotions;\n }\n const picker = o.picker ?? PICKERS.random!;\n const camp = o.camp ?? campByHealth;\n let fight = 0;\n let fightSegment = b.segment;\n let longestFight = 0;\n while (!b.result && b.time < SIM_MAX_SECONDS) {\n const p = b.pending;\n if (p?.kind === \"cards\") b.takeCard(picker(p.options, b, random));\n else if (p?.kind === \"camp\") b.chooseCamp(camp(b));\n else {\n if (b.segment !== fightSegment) {\n fightSegment = b.segment;\n fight = 0;\n }\n const fighting = b.monsters.some((m) => b.engaged(m));\n b.tick(SIM_STEP);\n if (fighting) longestFight = Math.max(longestFight, (fight += SIM_STEP));\n }\n }\n const result: RunResult = b.result ?? {\n cleared: false,\n level: o.level,\n kills: b.kills,\n runLevel: b.runLevel,\n chests: b.chests,\n seconds: b.time,\n cards: [...b.cards],\n potionsUsed: b.potionsUsed,\n };\n return {\n ...result,\n hpLeft: b.result ? Math.max(0, b.heroHp) / Math.max(1, b.heroMaxHp()) : 0,\n stalled: !b.result,\n longestFight,\n };\n}\n\n// ── The title's config, as the simulator reads it ────────────────────────────────────────────\n\ninterface Entry {\n Type?: string;\n CurrencyID?: string;\n ItemID?: string;\n Amount?: number;\n}\ninterface Grant {\n Standard?: { Entries?: Entry[] | null } | null;\n}\ninterface PriceView {\n Cost?: Grant | null;\n}\n\nexport interface SimItem extends GearDefView, GearItemView {\n ItemID?: string;\n Equipment?: { AllowedSlotIDs?: string[] | null } | null;\n Upgrade?: GearDefView[\"Upgrade\"] & {\n MaxLevel?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n };\n}\n\n/** The sections the simulator reads (a title-seed.json has them all). */\nexport interface SimConfig {\n Character?: CharacterSection & {\n Definitions?: Record<\n string,\n {\n RankLadder?: {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n } | null;\n Equipment?: {\n Slots?: Record<string, { MinCharacterLevel?: number | null }> | null;\n } | null;\n }\n >;\n };\n Item?: { Catalogs?: Record<string, { Items?: Record<string, SimItem> }> };\n Lootbox?: {\n Definitions?: Record<\n string,\n {\n PriceOptions?: Record<string, PriceView> | null;\n RewardMultiplier?: {\n Source?: string;\n SourceKey?: string | null;\n Curve?: ScalarCurveSpec | null;\n Anchor?: number | null;\n IncludeRewards?: { Entries?: Entry[] | null } | null;\n } | null;\n RewardSlots?: Array<{\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n PityRules?: Array<{\n RuleID?: string;\n Threshold?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n }\n >;\n };\n Store?: {\n StoreOffers?: Record<\n string,\n {\n Pricing?: { Options?: Record<string, PriceView> | null } | null;\n Reward?: { Grant?: Grant | null } | null;\n }\n >;\n };\n}\n\n/** Every item of the config by id. */\nexport function simItems(config: SimConfig): Map<string, SimItem> {\n const out = new Map<string, SimItem>();\n for (const cat of Object.values(config.Item?.Catalogs ?? {}))\n for (const [id, it] of Object.entries(cat.Items ?? {})) out.set(id, it);\n return out;\n}\n\nfunction priceIn(\n options: Record<string, PriceView> | null | undefined,\n currencyID: string,\n): number | null {\n for (const o of Object.values(options ?? {}))\n for (const e of o.Cost?.Standard?.Entries ?? [])\n if (e.CurrencyID === currencyID) return Number(e.Amount ?? 0);\n return null;\n}\n\n/**\n * Roll a lootbox: each slot rolls MinRolls..MaxRolls times over its weighted pool. A\n * `RewardMultiplier` on a currency balance (the only source the simulator knows) scales the rewards\n * it selects, as the server's RewardProgressionResolver does — never below ×1.\n */\nexport function rollLootbox(\n config: SimConfig,\n lootboxID: string,\n random: () => number,\n balance: (currencyID: string) => number = () => 0,\n /** The player's pity counters (\"{lootbox}:{rule}\" → opens since the last trigger). */\n pity?: Map<string, number>,\n): Entry[] {\n const def = config.Lootbox?.Definitions?.[lootboxID];\n const spec = def?.RewardMultiplier;\n let mult = 1;\n if (spec?.Source === \"VirtualCurrencyBalance\" && spec.SourceKey) {\n const m = evaluateCurve(\n spec.Curve,\n 1,\n balance(spec.SourceKey),\n spec.Anchor ?? 0,\n );\n mult = spec.Curve?.MinResult != null ? m : Math.max(1, m);\n }\n const selected = (e: Entry) => {\n const inc = spec?.IncludeRewards?.Entries ?? [];\n return (\n !inc.length ||\n inc.some((x) =>\n e.Type === \"Item\"\n ? x.ItemID === e.ItemID\n : x.CurrencyID === e.CurrencyID,\n )\n );\n };\n const scaled = (e: Entry): Entry =>\n mult !== 1 && selected(e)\n ? { ...e, Amount: Math.round(Number(e.Amount ?? 0) * mult) }\n : e;\n const out: Entry[] = [];\n const roll = (\n pool: ReadonlyArray<{ Reward?: Grant | null; Weight?: number }>,\n ) => {\n const total = pool.reduce((t, x) => t + Math.max(0, x.Weight ?? 0), 0);\n if (total <= 0) return;\n let pick = random() * total;\n for (const x of pool) {\n pick -= Math.max(0, x.Weight ?? 0);\n if (pick < 0) {\n out.push(...(x.Reward?.Standard?.Entries ?? []).map(scaled));\n return;\n }\n }\n };\n for (const slot of def?.RewardSlots ?? []) {\n const min = Number(slot.MinRolls ?? 1);\n const max = Number(slot.MaxRolls ?? min);\n const rolls = min + Math.floor(random() * (max - min + 1));\n for (let i = 0; i < rolls; i++) roll(slot.Pool ?? []);\n }\n // pity: every Threshold-th opening adds a roll of the rule's pool (the server's LootboxHelpers)\n for (const rule of def?.PityRules ?? []) {\n const t = Number(rule.Threshold ?? 0);\n if (!pity || !rule.RuleID || t <= 0) continue;\n const key = `${lootboxID}:${rule.RuleID}`;\n const n = (pity.get(key) ?? 0) + 1;\n if (n >= t) roll(rule.Pool ?? []);\n pity.set(key, n % t);\n }\n return out;\n}\n\n// ── The player across the campaign ───────────────────────────────────────────────────────────\n\nexport interface SimPlayer {\n heroID: string;\n rank: number;\n /** Owned items: id → level (one copy of each is enough to wear it). */\n items: Map<string, number>;\n /** Slot → item id. */\n equipment: Record<string, string>;\n gold: number;\n gems: number;\n potions: number;\n xp: number;\n cleared: number;\n chestsOpened: number;\n pity: Map<string, number>;\n}\n\nexport function newPlayer(heroID = \"Knight\"): SimPlayer {\n // the seed's starting balances (Currency InitialDeposit)\n return {\n heroID,\n rank: 1,\n items: new Map(),\n equipment: {},\n gold: 500,\n gems: 300,\n potions: 3,\n xp: 0,\n cleared: 0,\n chestsOpened: 0,\n pity: new Map(),\n };\n}\n\n/** The hero as the game builds it from this player's state. */\nexport function simHero(config: SimConfig, p: SimPlayer): SimHero {\n const section = config.Character;\n const def = section?.Definitions?.[p.heroID];\n const items = simItems(config);\n const equipment: Record<string, { ItemID: string; Level: number }> = {};\n for (const [slot, id] of Object.entries(p.equipment))\n equipment[slot] = { ItemID: id, Level: p.items.get(id) ?? 1 };\n const stats = fighterStats({\n section,\n def,\n model: { Level: p.rank, StatLevels: {}, Equipment: equipment },\n items,\n });\n const gear = heroGear(\n {\n id: p.heroID,\n classId: def?.Classification?.ClassID ?? \"\",\n equipment: { ...p.equipment },\n },\n items,\n );\n return { stats, gear };\n}\n\n/**\n * How a weapon of `attack` fares on a level, and how hard a body of `armor` is hit there — read off\n * the scouting, as the player does before the run (the boss weighs as much as the whole road).\n */\nfunction levelTypes(level: number): {\n weapon: (attack: AttackType) => number;\n body: (armor: ArmorType) => number;\n} {\n const s = scout(levelOf(level));\n return {\n weapon: (attack) =>\n 0.5 *\n s.armor.reduce((t, a) => t + a.share * typeFactor(attack, a.type), 0) +\n 0.5 * typeFactor(attack, s.boss.armor),\n body: (armor) =>\n 0.5 *\n s.attack.reduce((t, a) => t + a.share * typeFactor(a.type, armor), 0) +\n 0.5 * typeFactor(s.boss.attack, armor),\n };\n}\n\nfunction slotsOf(it: SimItem | undefined): string[] {\n return it?.Equipment?.AllowedSlotIDs ?? [];\n}\n\n/**\n * How good a whole kit is on a level: damage per second against the road's armor (the Warcraft\n * table, crits counted) times the health it fights with against the road's attacks. Without a\n * level — plain numbers.\n */\nfunction kitScore(config: SimConfig, p: SimPlayer, level?: number): number {\n const { stats, gear } = simHero(config, p);\n const types = level ? levelTypes(level) : null;\n const dps =\n stats.damage *\n stats.attackSpeed *\n (1 + stats.critChance * (stats.critMultiplier - 1)) *\n (types ? types.weapon(gear.attack) : 1);\n const hp =\n (stats.maxHp * (gear.shield ? 1.5 : 1)) /\n (types ? Math.max(0.05, types.body(gear.armor)) : 1) /\n (1 - stats.dodge);\n return dps * hp;\n}\n\n/**\n * Wear the best owned item in every slot the hero may use at its rank: slot by slot, the item that\n * makes the whole kit score best (two passes — a two-handed bow drops the shield). With a `level`,\n * for its scouting, as a player would.\n */\nexport function equipBest(\n config: SimConfig,\n p: SimPlayer,\n level?: number,\n): void {\n const items = simItems(config);\n const slots =\n config.Character?.Definitions?.[p.heroID]?.Equipment?.Slots ?? {};\n for (let pass = 0; pass < 2; pass++)\n for (const [slotID, slot] of Object.entries(slots)) {\n if (p.rank < Number(slot.MinCharacterLevel ?? 0)) continue;\n let best = p.equipment[slotID] ?? null;\n let score = kitScore(config, p, level);\n for (const id of p.items.keys()) {\n if (id === best || !slotsOf(items.get(id)).includes(slotID)) continue;\n if (\n Object.entries(p.equipment).some(([k, v]) => v === id && k !== slotID)\n )\n continue;\n const was = p.equipment[slotID];\n p.equipment[slotID] = id;\n const sc = kitScore(config, p, level);\n if (was) p.equipment[slotID] = was;\n else delete p.equipment[slotID];\n if (sc > score * 1.0001) {\n score = sc;\n best = id;\n }\n }\n if (best) p.equipment[slotID] = best;\n }\n}\n\nfunction itemUpgradeCost(it: SimItem | undefined, next: number): number | null {\n const up = it?.Upgrade;\n if (!up || next > Number(up.MaxLevel ?? 0)) return null;\n const base = priceIn(up.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(evaluateCurve(up.CostCurve, base, next, 2));\n}\n\nfunction rankCost(config: SimConfig, p: SimPlayer): number | null {\n const ladder = config.Character?.Definitions?.[p.heroID]?.RankLadder;\n const next = p.rank + 1;\n if (!ladder || next > Number(ladder.MaxRank ?? 0)) return null;\n const base = priceIn(ladder.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(\n evaluateCurve(\n ladder.CostCurve,\n base,\n next,\n Number(ladder.FirstPaidRank ?? 2),\n ),\n );\n}\n\nfunction balanceOf(p: SimPlayer, currencyID: string): number {\n if (currencyID === \"GOLD\") return p.gold;\n if (currencyID === GEMS_ID) return p.gems;\n if (currencyID === POTION_ID) return p.potions;\n if (currencyID === XP_ID) return p.xp;\n return 0;\n}\n\nfunction grant(p: SimPlayer, entries: readonly Entry[]): void {\n for (const e of entries) {\n const n = Number(e.Amount ?? 0);\n if (e.Type === \"Item\" && e.ItemID) {\n if (!p.items.has(e.ItemID)) p.items.set(e.ItemID, 1);\n continue;\n }\n if (e.CurrencyID === \"GOLD\") p.gold += n;\n else if (e.CurrencyID === GEMS_ID) p.gems += n;\n else if (e.CurrencyID === POTION_ID) p.potions += n;\n else if (e.CurrencyID === XP_ID) p.xp += n;\n }\n}\n\n/**\n * Between runs: summon gear for gems, keep potions up from the shop, then spend the gold on the\n * cheapest upgrade (worn items and the rank) — a sensible player, not an optimal one.\n */\nexport function spend(\n config: SimConfig,\n p: SimPlayer,\n random: () => number,\n /** The level played next: the kit to invest in is picked for its scouting. */\n level?: number,\n): void {\n const summon = priceIn(\n config.Lootbox?.Definitions?.[\"gear_summon\"]?.PriceOptions,\n GEMS_ID,\n );\n while (summon && p.gems >= summon) {\n p.gems -= summon;\n grant(\n p,\n rollLootbox(\n config,\n \"gear_summon\",\n random,\n (id) => balanceOf(p, id),\n p.pity,\n ),\n );\n }\n const items = simItems(config);\n // the kit to invest in: the best one with every item at its max level, for the road ahead (a\n // legendary sword at level 1 is worth levelling while a levelled hammer still hits harder; a\n // hammer is not, on a road of chainmail)\n const potential: SimPlayer = {\n ...p,\n items: new Map(\n [...p.items.keys()].map((id) => [\n id,\n Math.max(1, Number(items.get(id)?.Upgrade?.MaxLevel ?? 1)),\n ]),\n ),\n equipment: { ...p.equipment },\n };\n equipBest(config, potential, level);\n p.equipment = potential.equipment;\n const potions = config.Store?.StoreOffers?.[\"potions_3\"];\n const potionPrice = priceIn(potions?.Pricing?.Options, \"GOLD\");\n if (potionPrice && p.potions < POTION_SLOTS && p.gold >= potionPrice) {\n p.gold -= potionPrice;\n grant(p, potions?.Reward?.Grant?.Standard?.Entries ?? []);\n }\n for (;;) {\n let best: { cost: number; apply: () => void } | null = null;\n for (const id of new Set(Object.values(p.equipment))) {\n const level = p.items.get(id) ?? 1;\n const cost = itemUpgradeCost(items.get(id), level + 1);\n if (cost != null && (!best || cost < best.cost))\n best = { cost, apply: () => p.items.set(id, level + 1) };\n }\n const rc = rankCost(config, p);\n if (rc != null && (!best || rc < best.cost))\n best = { cost: rc, apply: () => (p.rank += 1) };\n if (!best || best.cost > p.gold) break;\n p.gold -= best.cost;\n best.apply();\n }\n}\n\nexport interface CampaignOptions {\n config: SimConfig;\n /** Runs the player plays (a ticket each). */\n runs: number;\n seed: number;\n picker?: CardPicker;\n /** Pick the weapon and the body armor for the level's scouting (default true). */\n scouting?: boolean;\n /**\n * After this many losses in a row on the next level, farm the last cleared one (its victory chest\n * pays more than a defeat) for `farmRuns` runs, then try again — what a stuck player does.\n * 0 — never farm. Default 3 and 2.\n */\n farmAfter?: number;\n farmRuns?: number;\n}\n\nexport interface CampaignRun {\n run: number;\n level: number;\n /** A replay of a cleared level for its chest. */\n farm: boolean;\n cleared: boolean;\n stalled: boolean;\n seconds: number;\n longestFight: number;\n /** The hero's numbers going in. */\n damage: number;\n maxHp: number;\n potionsIn: number;\n potionsUsed: number;\n chest: string;\n gold: number;\n rank: number;\n}\n\n/** One player playing `runs` runs: always the next level not cleared yet. */\nexport function simulateCampaign(o: CampaignOptions): {\n player: SimPlayer;\n runs: CampaignRun[];\n} {\n const random = seededRandom(o.seed);\n const p = newPlayer();\n const runs: CampaignRun[] = [];\n const farmAfter = o.farmAfter ?? 3;\n const farmRuns = o.farmRuns ?? 2;\n let losses = 0;\n let farming = 0;\n for (let i = 0; i < o.runs; i++) {\n if (farmAfter > 0 && losses >= farmAfter && p.cleared > 0) {\n farming = farmRuns;\n losses = 0;\n }\n const farm = farming > 0;\n if (farm) farming--;\n const level = farm ? p.cleared : p.cleared + 1;\n if (o.scouting !== false) equipBest(o.config, p, level);\n const hero = simHero(o.config, p);\n const potionsIn = Math.min(POTION_SLOTS, p.potions);\n const r = simulateRun({\n level,\n hero,\n potions: potionsIn,\n picker: o.picker,\n seed: Math.floor(random() * 2 ** 31),\n });\n p.potions -= r.potionsUsed;\n p.xp += r.potionsUsed;\n if (r.cleared) p.cleared = Math.max(p.cleared, level);\n if (!farm) losses = r.cleared ? 0 : losses + 1;\n const tier = chestTier(r);\n const ids = p.chestsOpened < FIRST_CHESTS ? FIRST_CHEST_IDS : CHEST_IDS;\n const chest = ids[tier];\n grant(\n p,\n rollLootbox(o.config, chest, random, (id) => balanceOf(p, id), p.pity),\n );\n p.chestsOpened += 1;\n runs.push({\n run: i + 1,\n level,\n farm,\n cleared: r.cleared,\n stalled: r.stalled,\n seconds: r.seconds,\n longestFight: r.longestFight,\n damage: hero.stats.damage,\n maxHp: hero.stats.maxHp,\n potionsIn,\n potionsUsed: r.potionsUsed,\n chest,\n gold: p.gold,\n rank: p.rank,\n });\n spend(o.config, p, random, p.cleared + 1);\n }\n return { player: p, runs };\n}\n"
390
+ "content": "// The balance simulator: the real Battle without a screen, and a player who keeps running the\n// campaign on the title's own config — chests rolled from its lootboxes, gear upgraded with its\n// gold, the hero built by the same fighterStats/heroGear as the game. `scripts/balance.ts` prints\n// the report; the tests keep the casual start casual.\n//\n// What it does NOT know: the real time (energy is counted in runs, not hours), daily free gems, the\n// player's taste (a card picker and a spending policy stand in for it). Prices follow the server's\n// rule \"base × curve from the first paid step\" — close enough for a balance read, not a bill.\n\nimport { evaluateCurve, type ScalarCurveSpec } from \"@idosgames/core\";\nimport { Battle, type CampChoice, type RunResult, type UltId } from \"./battle\";\nimport { levelOf, scout } from \"./campaign\";\nimport { typeFactor, type ArmorType, type AttackType } from \"./damage\";\nimport { CARD_BY_ID } from \"./cards\";\nimport {\n CHEST_IDS,\n FIRST_CHESTS,\n FIRST_CHEST_IDS,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n XP_ID,\n chestTier,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n} from \"./heroStats\";\nimport { seededRandom } from \"./random\";\n\n// ── One run ──────────────────────────────────────────────────────────────────────────────────\n\n/** What the hero brings into a run. */\nexport interface SimHero {\n stats: FighterStats;\n gear: HeroGear;\n}\n\n/** Picks one of the offered cards. */\nexport type CardPicker = (\n options: readonly string[],\n battle: Battle,\n random: () => number,\n) => string;\n\nconst RARITY_RANK = { common: 0, rare: 1, legendary: 2 } as const;\n\nexport const PICKERS: Readonly<Record<string, CardPicker>> = {\n /** The first card on the screen — a player who does not read. */\n first: (o) => o[0]!,\n /** Any card. */\n random: (o, _b, r) => o[Math.floor(r() * o.length)]!,\n /** The rarest card (a player who trusts the colour). */\n rarity: (o) =>\n [...o].sort(\n (a, b) =>\n RARITY_RANK[CARD_BY_ID.get(b)!.rarity] -\n RARITY_RANK[CARD_BY_ID.get(a)!.rarity],\n )[0]!,\n};\n\n/** The camp: health when hurt, rage otherwise. */\nexport const campByHealth = (b: Battle): CampChoice =>\n b.heroHp / Math.max(1, b.heroMaxHp()) < 0.6 ? \"heal\" : \"rage\";\n\nexport interface SimRunOptions {\n level: number;\n hero: SimHero;\n potions: number;\n ult?: UltId;\n picker?: CardPicker;\n camp?: (b: Battle) => CampChoice;\n seed: number;\n /** Cards the run starts with (the balance report measures one card this way). */\n cards?: readonly string[];\n}\n\nexport interface SimRunResult extends RunResult {\n /** Health left at the end (share of the max). */\n hpLeft: number;\n /** The run hit the time cap (a very long run — every fight does end), counted as a loss. */\n stalled: boolean;\n /** The longest fight with one segment (a wave, the boss), seconds. */\n longestFight: number;\n}\n\n/** Seconds of game time after which the simulator stops a run (a loss). Not a stalemate: troops never\n * heal and a blow always deals damage, so every fight ends — the cap only keeps the report fast. */\nexport const SIM_MAX_SECONDS = 900;\nconst SIM_STEP = 0.05;\n\n/**\n * One run, as the scene would play it: the same swing, gait and line spacing the screen sets on\n * the battle (a melee hero with the default rig — measured on the stand), potions and the\n * ultimate on auto.\n */\nexport function simulateRun(o: SimRunOptions): SimRunResult {\n const random = seededRandom(o.seed ^ 0x5bd1e995);\n const b = new Battle({\n level: levelOf(o.level),\n stats: o.hero.stats,\n heroAttack: o.hero.gear.attack,\n heroArmor: o.hero.gear.armor,\n shield: o.hero.gear.shield,\n ult: o.ult ?? \"meteor\",\n potions: o.potions,\n seed: o.seed,\n });\n b.setReach(o.hero.gear.melee ? \"melee\" : \"ranged\");\n b.setSwing(0.3, 0.55);\n b.setWalkSpeed(0.2);\n b.setMonsterGap(0.16);\n b.setWaveAhead(0.86);\n if (o.cards?.length) {\n b.setCards(o.cards);\n b.heroHp = b.heroMaxHp();\n b.heroShield = b.heroMaxShield();\n b.potions += b.mods.extraPotions;\n b.cardPotions += b.mods.extraPotions;\n }\n const picker = o.picker ?? PICKERS.random!;\n const camp = o.camp ?? campByHealth;\n let fight = 0;\n let fightSegment = b.segment;\n let longestFight = 0;\n while (!b.result && b.time < SIM_MAX_SECONDS) {\n const p = b.pending;\n if (p?.kind === \"cards\") b.takeCard(picker(p.options, b, random));\n else if (p?.kind === \"camp\") b.chooseCamp(camp(b));\n else {\n if (b.segment !== fightSegment) {\n fightSegment = b.segment;\n fight = 0;\n }\n const fighting = b.monsters.some((m) => b.engaged(m));\n b.tick(SIM_STEP);\n if (fighting) longestFight = Math.max(longestFight, (fight += SIM_STEP));\n }\n }\n const result: RunResult = b.result ?? {\n cleared: false,\n level: o.level,\n kills: b.kills,\n runLevel: b.runLevel,\n chests: b.chests,\n seconds: b.time,\n cards: [...b.cards],\n potionsUsed: b.potionsUsed,\n };\n return {\n ...result,\n hpLeft: b.result ? Math.max(0, b.heroHp) / Math.max(1, b.heroMaxHp()) : 0,\n stalled: !b.result,\n longestFight,\n };\n}\n\n// ── The title's config, as the simulator reads it ────────────────────────────────────────────\n\ninterface Entry {\n Type?: string;\n CurrencyID?: string;\n ItemID?: string;\n Amount?: number;\n}\ninterface Grant {\n Standard?: { Entries?: Entry[] | null } | null;\n}\ninterface PriceView {\n Cost?: Grant | null;\n}\n\nexport interface SimItem extends GearDefView, GearItemView {\n ItemID?: string;\n Equipment?: { AllowedSlotIDs?: string[] | null } | null;\n Upgrade?: GearDefView[\"Upgrade\"] & {\n MaxLevel?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n };\n}\n\n/** The sections the simulator reads (a title-seed.json has them all). */\nexport interface SimConfig {\n Character?: CharacterSection & {\n Definitions?: Record<\n string,\n {\n RankLadder?: {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n } | null;\n Equipment?: {\n Slots?: Record<string, { MinCharacterLevel?: number | null }> | null;\n } | null;\n }\n >;\n };\n Item?: { Catalogs?: Record<string, { Items?: Record<string, SimItem> }> };\n Lootbox?: {\n Definitions?: Record<\n string,\n {\n PriceOptions?: Record<string, PriceView> | null;\n RewardMultiplier?: {\n Source?: string;\n SourceKey?: string | null;\n Curve?: ScalarCurveSpec | null;\n Anchor?: number | null;\n IncludeRewards?: { Entries?: Entry[] | null } | null;\n } | null;\n RewardSlots?: Array<{\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n PityRules?: Array<{\n RuleID?: string;\n Threshold?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n }\n >;\n };\n Store?: {\n StoreOffers?: Record<\n string,\n {\n Pricing?: { Options?: Record<string, PriceView> | null } | null;\n Reward?: { Grant?: Grant | null } | null;\n }\n >;\n };\n}\n\n/** Every item of the config by id. */\nexport function simItems(config: SimConfig): Map<string, SimItem> {\n const out = new Map<string, SimItem>();\n for (const cat of Object.values(config.Item?.Catalogs ?? {}))\n for (const [id, it] of Object.entries(cat.Items ?? {})) out.set(id, it);\n return out;\n}\n\nfunction priceIn(\n options: Record<string, PriceView> | null | undefined,\n currencyID: string,\n): number | null {\n for (const o of Object.values(options ?? {}))\n for (const e of o.Cost?.Standard?.Entries ?? [])\n if (e.CurrencyID === currencyID) return Number(e.Amount ?? 0);\n return null;\n}\n\n/**\n * Roll a lootbox: each slot rolls MinRolls..MaxRolls times over its weighted pool. A\n * `RewardMultiplier` on a currency balance (the only source the simulator knows) scales the rewards\n * it selects, as the server's RewardProgressionResolver does — never below ×1.\n */\nexport function rollLootbox(\n config: SimConfig,\n lootboxID: string,\n random: () => number,\n balance: (currencyID: string) => number = () => 0,\n /** The player's pity counters (\"{lootbox}:{rule}\" → opens since the last trigger). */\n pity?: Map<string, number>,\n): Entry[] {\n const def = config.Lootbox?.Definitions?.[lootboxID];\n const spec = def?.RewardMultiplier;\n let mult = 1;\n if (spec?.Source === \"VirtualCurrencyBalance\" && spec.SourceKey) {\n const m = evaluateCurve(\n spec.Curve,\n 1,\n balance(spec.SourceKey),\n spec.Anchor ?? 0,\n );\n mult = spec.Curve?.MinResult != null ? m : Math.max(1, m);\n }\n const selected = (e: Entry) => {\n const inc = spec?.IncludeRewards?.Entries ?? [];\n return (\n !inc.length ||\n inc.some((x) =>\n e.Type === \"Item\"\n ? x.ItemID === e.ItemID\n : x.CurrencyID === e.CurrencyID,\n )\n );\n };\n const scaled = (e: Entry): Entry =>\n mult !== 1 && selected(e)\n ? { ...e, Amount: Math.round(Number(e.Amount ?? 0) * mult) }\n : e;\n const out: Entry[] = [];\n const roll = (\n pool: ReadonlyArray<{ Reward?: Grant | null; Weight?: number }>,\n ) => {\n const total = pool.reduce((t, x) => t + Math.max(0, x.Weight ?? 0), 0);\n if (total <= 0) return;\n let pick = random() * total;\n for (const x of pool) {\n pick -= Math.max(0, x.Weight ?? 0);\n if (pick < 0) {\n out.push(...(x.Reward?.Standard?.Entries ?? []).map(scaled));\n return;\n }\n }\n };\n for (const slot of def?.RewardSlots ?? []) {\n const min = Number(slot.MinRolls ?? 1);\n const max = Number(slot.MaxRolls ?? min);\n const rolls = min + Math.floor(random() * (max - min + 1));\n for (let i = 0; i < rolls; i++) roll(slot.Pool ?? []);\n }\n // pity: every Threshold-th opening adds a roll of the rule's pool (the server's LootboxHelpers)\n for (const rule of def?.PityRules ?? []) {\n const t = Number(rule.Threshold ?? 0);\n if (!pity || !rule.RuleID || t <= 0) continue;\n const key = `${lootboxID}:${rule.RuleID}`;\n const n = (pity.get(key) ?? 0) + 1;\n if (n >= t) roll(rule.Pool ?? []);\n pity.set(key, n % t);\n }\n return out;\n}\n\n// ── The player across the campaign ───────────────────────────────────────────────────────────\n\nexport interface SimPlayer {\n heroID: string;\n rank: number;\n /** Owned items: id → level (one copy of each is enough to wear it). */\n items: Map<string, number>;\n /** Slot → item id. */\n equipment: Record<string, string>;\n gold: number;\n gems: number;\n potions: number;\n xp: number;\n cleared: number;\n chestsOpened: number;\n pity: Map<string, number>;\n}\n\nexport function newPlayer(heroID = \"Knight\"): SimPlayer {\n // the seed's starting balances (Currency InitialDeposit)\n return {\n heroID,\n rank: 1,\n items: new Map(),\n equipment: {},\n gold: 500,\n gems: 300,\n potions: 3,\n xp: 0,\n cleared: 0,\n chestsOpened: 0,\n pity: new Map(),\n };\n}\n\n/** The hero as the game builds it from this player's state. */\nexport function simHero(config: SimConfig, p: SimPlayer): SimHero {\n const section = config.Character;\n const def = section?.Definitions?.[p.heroID];\n const items = simItems(config);\n const equipment: Record<string, { ItemID: string; Level: number }> = {};\n for (const [slot, id] of Object.entries(p.equipment))\n equipment[slot] = { ItemID: id, Level: p.items.get(id) ?? 1 };\n const stats = fighterStats({\n section,\n def,\n model: { Level: p.rank, StatLevels: {}, Equipment: equipment },\n items,\n });\n const gear = heroGear(\n {\n id: p.heroID,\n classId: def?.Classification?.ClassID ?? \"\",\n equipment: { ...p.equipment },\n },\n items,\n );\n return { stats, gear };\n}\n\n/**\n * How a weapon of `attack` fares on a level, and how hard a body of `armor` is hit there — read off\n * the scouting, as the player does before the run (the boss weighs as much as the whole road).\n */\nfunction levelTypes(level: number): {\n weapon: (attack: AttackType) => number;\n body: (armor: ArmorType) => number;\n} {\n const s = scout(levelOf(level));\n return {\n weapon: (attack) =>\n 0.5 *\n s.armor.reduce((t, a) => t + a.share * typeFactor(attack, a.type), 0) +\n 0.5 * typeFactor(attack, s.boss.armor),\n body: (armor) =>\n 0.5 *\n s.attack.reduce((t, a) => t + a.share * typeFactor(a.type, armor), 0) +\n 0.5 * typeFactor(s.boss.attack, armor),\n };\n}\n\nfunction slotsOf(it: SimItem | undefined): string[] {\n return it?.Equipment?.AllowedSlotIDs ?? [];\n}\n\n/**\n * How good a whole kit is on a level: damage per second against the road's armor (the damage\n * table, crits counted) times the health it fights with against the road's attacks. Without a\n * level — plain numbers.\n */\nfunction kitScore(config: SimConfig, p: SimPlayer, level?: number): number {\n const { stats, gear } = simHero(config, p);\n const types = level ? levelTypes(level) : null;\n const dps =\n stats.damage *\n stats.attackSpeed *\n (1 + stats.critChance * (stats.critMultiplier - 1)) *\n (types ? types.weapon(gear.attack) : 1);\n const hp =\n (stats.maxHp * (gear.shield ? 1.5 : 1)) /\n (types ? Math.max(0.05, types.body(gear.armor)) : 1) /\n (1 - stats.dodge);\n return dps * hp;\n}\n\n/**\n * Wear the best owned item in every slot the hero may use at its rank: slot by slot, the item that\n * makes the whole kit score best (two passes — a two-handed bow drops the shield). With a `level`,\n * for its scouting, as a player would.\n */\nexport function equipBest(\n config: SimConfig,\n p: SimPlayer,\n level?: number,\n): void {\n const items = simItems(config);\n const slots =\n config.Character?.Definitions?.[p.heroID]?.Equipment?.Slots ?? {};\n for (let pass = 0; pass < 2; pass++)\n for (const [slotID, slot] of Object.entries(slots)) {\n if (p.rank < Number(slot.MinCharacterLevel ?? 0)) continue;\n let best = p.equipment[slotID] ?? null;\n let score = kitScore(config, p, level);\n for (const id of p.items.keys()) {\n if (id === best || !slotsOf(items.get(id)).includes(slotID)) continue;\n if (\n Object.entries(p.equipment).some(([k, v]) => v === id && k !== slotID)\n )\n continue;\n const was = p.equipment[slotID];\n p.equipment[slotID] = id;\n const sc = kitScore(config, p, level);\n if (was) p.equipment[slotID] = was;\n else delete p.equipment[slotID];\n if (sc > score * 1.0001) {\n score = sc;\n best = id;\n }\n }\n if (best) p.equipment[slotID] = best;\n }\n}\n\nfunction itemUpgradeCost(it: SimItem | undefined, next: number): number | null {\n const up = it?.Upgrade;\n if (!up || next > Number(up.MaxLevel ?? 0)) return null;\n const base = priceIn(up.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(evaluateCurve(up.CostCurve, base, next, 2));\n}\n\nfunction rankCost(config: SimConfig, p: SimPlayer): number | null {\n const ladder = config.Character?.Definitions?.[p.heroID]?.RankLadder;\n const next = p.rank + 1;\n if (!ladder || next > Number(ladder.MaxRank ?? 0)) return null;\n const base = priceIn(ladder.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(\n evaluateCurve(\n ladder.CostCurve,\n base,\n next,\n Number(ladder.FirstPaidRank ?? 2),\n ),\n );\n}\n\nfunction balanceOf(p: SimPlayer, currencyID: string): number {\n if (currencyID === \"GOLD\") return p.gold;\n if (currencyID === GEMS_ID) return p.gems;\n if (currencyID === POTION_ID) return p.potions;\n if (currencyID === XP_ID) return p.xp;\n return 0;\n}\n\nfunction grant(p: SimPlayer, entries: readonly Entry[]): void {\n for (const e of entries) {\n const n = Number(e.Amount ?? 0);\n if (e.Type === \"Item\" && e.ItemID) {\n if (!p.items.has(e.ItemID)) p.items.set(e.ItemID, 1);\n continue;\n }\n if (e.CurrencyID === \"GOLD\") p.gold += n;\n else if (e.CurrencyID === GEMS_ID) p.gems += n;\n else if (e.CurrencyID === POTION_ID) p.potions += n;\n else if (e.CurrencyID === XP_ID) p.xp += n;\n }\n}\n\n/**\n * Between runs: summon gear for gems, keep potions up from the shop, then spend the gold on the\n * cheapest upgrade (worn items and the rank) — a sensible player, not an optimal one.\n */\nexport function spend(\n config: SimConfig,\n p: SimPlayer,\n random: () => number,\n /** The level played next: the kit to invest in is picked for its scouting. */\n level?: number,\n): void {\n const summon = priceIn(\n config.Lootbox?.Definitions?.[\"gear_summon\"]?.PriceOptions,\n GEMS_ID,\n );\n while (summon && p.gems >= summon) {\n p.gems -= summon;\n grant(\n p,\n rollLootbox(\n config,\n \"gear_summon\",\n random,\n (id) => balanceOf(p, id),\n p.pity,\n ),\n );\n }\n const items = simItems(config);\n // the kit to invest in: the best one with every item at its max level, for the road ahead (a\n // legendary sword at level 1 is worth levelling while a levelled hammer still hits harder; a\n // hammer is not, on a road of chainmail)\n const potential: SimPlayer = {\n ...p,\n items: new Map(\n [...p.items.keys()].map((id) => [\n id,\n Math.max(1, Number(items.get(id)?.Upgrade?.MaxLevel ?? 1)),\n ]),\n ),\n equipment: { ...p.equipment },\n };\n equipBest(config, potential, level);\n p.equipment = potential.equipment;\n const potions = config.Store?.StoreOffers?.[\"potions_3\"];\n const potionPrice = priceIn(potions?.Pricing?.Options, \"GOLD\");\n if (potionPrice && p.potions < POTION_SLOTS && p.gold >= potionPrice) {\n p.gold -= potionPrice;\n grant(p, potions?.Reward?.Grant?.Standard?.Entries ?? []);\n }\n for (;;) {\n let best: { cost: number; apply: () => void } | null = null;\n for (const id of new Set(Object.values(p.equipment))) {\n const level = p.items.get(id) ?? 1;\n const cost = itemUpgradeCost(items.get(id), level + 1);\n if (cost != null && (!best || cost < best.cost))\n best = { cost, apply: () => p.items.set(id, level + 1) };\n }\n const rc = rankCost(config, p);\n if (rc != null && (!best || rc < best.cost))\n best = { cost: rc, apply: () => (p.rank += 1) };\n if (!best || best.cost > p.gold) break;\n p.gold -= best.cost;\n best.apply();\n }\n}\n\nexport interface CampaignOptions {\n config: SimConfig;\n /** Runs the player plays (a ticket each). */\n runs: number;\n seed: number;\n picker?: CardPicker;\n /** Pick the weapon and the body armor for the level's scouting (default true). */\n scouting?: boolean;\n /**\n * After this many losses in a row on the next level, farm the last cleared one (its victory chest\n * pays more than a defeat) for `farmRuns` runs, then try again — what a stuck player does.\n * 0 — never farm. Default 3 and 2.\n */\n farmAfter?: number;\n farmRuns?: number;\n}\n\nexport interface CampaignRun {\n run: number;\n level: number;\n /** A replay of a cleared level for its chest. */\n farm: boolean;\n cleared: boolean;\n stalled: boolean;\n seconds: number;\n longestFight: number;\n /** The hero's numbers going in. */\n damage: number;\n maxHp: number;\n potionsIn: number;\n potionsUsed: number;\n chest: string;\n gold: number;\n rank: number;\n}\n\n/** One player playing `runs` runs: always the next level not cleared yet. */\nexport function simulateCampaign(o: CampaignOptions): {\n player: SimPlayer;\n runs: CampaignRun[];\n} {\n const random = seededRandom(o.seed);\n const p = newPlayer();\n const runs: CampaignRun[] = [];\n const farmAfter = o.farmAfter ?? 3;\n const farmRuns = o.farmRuns ?? 2;\n let losses = 0;\n let farming = 0;\n for (let i = 0; i < o.runs; i++) {\n if (farmAfter > 0 && losses >= farmAfter && p.cleared > 0) {\n farming = farmRuns;\n losses = 0;\n }\n const farm = farming > 0;\n if (farm) farming--;\n const level = farm ? p.cleared : p.cleared + 1;\n if (o.scouting !== false) equipBest(o.config, p, level);\n const hero = simHero(o.config, p);\n const potionsIn = Math.min(POTION_SLOTS, p.potions);\n const r = simulateRun({\n level,\n hero,\n potions: potionsIn,\n picker: o.picker,\n seed: Math.floor(random() * 2 ** 31),\n });\n p.potions -= r.potionsUsed;\n p.xp += r.potionsUsed;\n if (r.cleared) p.cleared = Math.max(p.cleared, level);\n if (!farm) losses = r.cleared ? 0 : losses + 1;\n const tier = chestTier(r);\n const ids = p.chestsOpened < FIRST_CHESTS ? FIRST_CHEST_IDS : CHEST_IDS;\n const chest = ids[tier];\n grant(\n p,\n rollLootbox(o.config, chest, random, (id) => balanceOf(p, id), p.pity),\n );\n p.chestsOpened += 1;\n runs.push({\n run: i + 1,\n level,\n farm,\n cleared: r.cleared,\n stalled: r.stalled,\n seconds: r.seconds,\n longestFight: r.longestFight,\n damage: hero.stats.damage,\n maxHp: hero.stats.maxHp,\n potionsIn,\n potionsUsed: r.potionsUsed,\n chest,\n gold: p.gold,\n rank: p.rank,\n });\n spend(o.config, p, random, p.cleared + 1);\n }\n return { player: p, runs };\n}\n"
392
391
  },
393
392
  {
394
393
  "path": "game/themes.ts",
@@ -396,7 +395,7 @@
396
395
  },
397
396
  {
398
397
  "path": "game/units.ts",
399
- "content": "// The enemies — people like the hero, each one a TROOP of a kind (WC3's units): what it holds decides\n// its attack type and whether it shoots, what it wears decides its armor type, and the player reads\n// both off the picture — plate is heavy, a robe is no armor, a bow shoots arrows. A level lists troops\n// by id (campaign.ts); enemyLooks.ts dresses them.\n\nimport {\n armorTypeOf,\n attackTypeOf,\n type ArmorType,\n type AttackType,\n} from \"./damage\";\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type TroopId =\n | \"thug\"\n | \"archer\"\n | \"rogue\"\n | \"spearman\"\n | \"crossbowman\"\n | \"footman\"\n | \"acolyte\"\n | \"mage\"\n | \"maceman\"\n | \"gunner\"\n | \"berserker\"\n | \"warden\"\n | \"hammerguard\"\n | \"warlock\"\n // bosses — a hero of the enemy: hero armor, hero attack (from the third island on)\n | \"chief\"\n | \"warlord\"\n | \"archmage\"\n | \"dreadknight\";\n\nexport interface TroopDef {\n id: TroopId;\n /** What it wears and holds — the look, and through it the types. */\n gear: {\n Top: string | null;\n Hat: string | null;\n Weapon: string | null;\n Offhand: string | null;\n Gloves: string | null;\n Boots: string | null;\n Back: string | null;\n };\n /** Armor points (the WC3 value) at level 1; it grows a little with the level. */\n armor: number;\n /** Health and attack against a plain troop (1). */\n hp: number;\n attack: number;\n /** Seconds between its blows. */\n interval: number;\n /** How fast a brawler closes the last step, × the hero's run. */\n speed: number;\n boss?: boolean;\n /** The types, when the gear alone would say otherwise (a boss is a hero; a warlock casts chaos). */\n attackType?: AttackType;\n armorType?: ArmorType;\n}\n\nconst gear = (g: Partial<TroopDef[\"gear\"]>): TroopDef[\"gear\"] => ({\n Top: null,\n Hat: null,\n Weapon: null,\n Offhand: null,\n Gloves: null,\n Boots: \"boots/leather_boots\",\n Back: null,\n ...g,\n});\n\nexport const TROOPS: Readonly<Record<TroopId, TroopDef>> = {\n // ── unarmored and light: the first island ──\n thug: {\n id: \"thug\",\n gear: gear({ Weapon: \"weapon/dagger\" }),\n armor: 0,\n hp: 0.9,\n attack: 0.9,\n interval: 1.0,\n speed: 1.3,\n },\n archer: {\n id: \"archer\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/ranger_hood\",\n Weapon: \"weapon/hunting_bow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 0,\n hp: 0.75,\n attack: 0.75,\n interval: 1.6,\n speed: 1.1,\n },\n rogue: {\n id: \"rogue\",\n gear: gear({\n Top: \"top/leather_armor\",\n Weapon: \"weapon/broadsword\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1,\n attack: 1,\n interval: 1.2,\n speed: 1.2,\n },\n // ── medium: the second ──\n spearman: {\n id: \"spearman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/spear\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 1.1,\n attack: 1,\n interval: 1.2,\n speed: 1.1,\n },\n crossbowman: {\n id: \"crossbowman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/crossbow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 0.85,\n attack: 0.85,\n interval: 1.8,\n speed: 1.1,\n },\n // ── heavy and magic: the third ──\n footman: {\n id: \"footman\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/heater_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 4,\n hp: 1.3,\n attack: 0.9,\n interval: 1.3,\n speed: 1,\n },\n acolyte: {\n id: \"acolyte\",\n gear: gear({ Top: \"top/mage_robe\", Weapon: \"weapon/druid_staff\" }),\n armor: 0,\n hp: 0.7,\n attack: 0.9,\n interval: 1.7,\n speed: 1,\n },\n mage: {\n id: \"mage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n }),\n armor: 0,\n hp: 0.8,\n attack: 1.1,\n interval: 1.9,\n speed: 1,\n },\n // ── siege and fortified: the fourth ──\n maceman: {\n id: \"maceman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/mace\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 3,\n hp: 1.2,\n attack: 1.2,\n interval: 1.5,\n speed: 1,\n },\n gunner: {\n id: \"gunner\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/rifle\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 1,\n hp: 0.85,\n attack: 1.1,\n interval: 2,\n speed: 1,\n },\n berserker: {\n id: \"berserker\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/double_axe\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1.2,\n attack: 1.3,\n interval: 1.1,\n speed: 1.4,\n },\n warden: {\n id: \"warden\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/broadsword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 6,\n hp: 1.8,\n attack: 0.8,\n interval: 1.6,\n speed: 0.9,\n },\n // ── the fifth: walls broken by hammers, chaos ──\n hammerguard: {\n id: \"hammerguard\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/warhammer\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 5,\n hp: 1.4,\n attack: 1.3,\n interval: 1.6,\n speed: 1,\n },\n warlock: {\n id: \"warlock\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/knight_cape\",\n }),\n armor: 1,\n hp: 1,\n attack: 1.3,\n interval: 1.8,\n speed: 1,\n attackType: \"chaos\",\n },\n // ── bosses ──\n chief: {\n id: \"chief\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/battle_axe\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 9,\n attack: 2,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n warlord: {\n id: \"warlord\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/dark_blade\",\n Offhand: \"offhand/kite_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 4,\n hp: 10,\n attack: 2.1,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n archmage: {\n id: \"archmage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 8,\n attack: 2.4,\n interval: 1.6,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n dreadknight: {\n id: \"dreadknight\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/flame_sword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 6,\n hp: 12,\n attack: 2.2,\n interval: 1.5,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n};\n\n/** How far a shooter shoots (lane widths). */\nexport const TROOP_RANGE = 0.5;\n\n/** The troop's attack type: named, or by the weapon. */\nexport function troopAttack(t: TroopDef): AttackType {\n return t.attackType ?? attackTypeOf(t.gear.Weapon);\n}\n\n/** The troop's armor type: named, or by what it wears. */\nexport function troopArmor(t: TroopDef): ArmorType {\n return t.armorType ?? armorTypeOf(t.gear.Top, t.gear.Offhand);\n}\n\n/** Does it shoot (a bow, a crossbow, a gun, a staff) rather than come face to face? A boss never shoots. */\nexport function troopRanged(t: TroopDef): boolean {\n return !t.boss && !heroIsMelee(t.gear.Weapon);\n}\n\n/** Does it carry a shield — the blue bar of a pool that takes the blows first? */\nexport function troopShielded(t: TroopDef): boolean {\n return !!t.gear.Offhand;\n}\n"
398
+ "content": "// The enemies — people like the hero, each one a TROOP of a kind: what it holds decides\n// its attack type and whether it shoots, what it wears decides its armor type, and the player reads\n// both off the picture — plate is heavy, a robe is no armor, a bow shoots arrows. A level lists troops\n// by id (campaign.ts); enemyLooks.ts dresses them.\n\nimport {\n armorTypeOf,\n attackTypeOf,\n type ArmorType,\n type AttackType,\n} from \"./damage\";\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type TroopId =\n | \"thug\"\n | \"archer\"\n | \"rogue\"\n | \"spearman\"\n | \"crossbowman\"\n | \"footman\"\n | \"acolyte\"\n | \"mage\"\n | \"maceman\"\n | \"gunner\"\n | \"berserker\"\n | \"warden\"\n | \"hammerguard\"\n | \"warlock\"\n // bosses — a hero of the enemy: hero armor, hero attack (from the third island on)\n | \"chief\"\n | \"warlord\"\n | \"archmage\"\n | \"dreadknight\";\n\nexport interface TroopDef {\n id: TroopId;\n /** What it wears and holds — the look, and through it the types. */\n gear: {\n Top: string | null;\n Hat: string | null;\n Weapon: string | null;\n Offhand: string | null;\n Gloves: string | null;\n Boots: string | null;\n Back: string | null;\n };\n /** Armor points at level 1; it grows a little with the level. */\n armor: number;\n /** Health and attack against a plain troop (1). */\n hp: number;\n attack: number;\n /** Seconds between its blows. */\n interval: number;\n /** How fast a brawler closes the last step, × the hero's run. */\n speed: number;\n boss?: boolean;\n /** The types, when the gear alone would say otherwise (a boss is a hero; a warlock casts chaos). */\n attackType?: AttackType;\n armorType?: ArmorType;\n}\n\nconst gear = (g: Partial<TroopDef[\"gear\"]>): TroopDef[\"gear\"] => ({\n Top: null,\n Hat: null,\n Weapon: null,\n Offhand: null,\n Gloves: null,\n Boots: \"boots/leather_boots\",\n Back: null,\n ...g,\n});\n\nexport const TROOPS: Readonly<Record<TroopId, TroopDef>> = {\n // ── unarmored and light: the first island ──\n thug: {\n id: \"thug\",\n gear: gear({ Weapon: \"weapon/dagger\" }),\n armor: 0,\n hp: 0.9,\n attack: 0.9,\n interval: 1.0,\n speed: 1.3,\n },\n archer: {\n id: \"archer\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/ranger_hood\",\n Weapon: \"weapon/hunting_bow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 0,\n hp: 0.75,\n attack: 0.75,\n interval: 1.6,\n speed: 1.1,\n },\n rogue: {\n id: \"rogue\",\n gear: gear({\n Top: \"top/leather_armor\",\n Weapon: \"weapon/broadsword\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1,\n attack: 1,\n interval: 1.2,\n speed: 1.2,\n },\n // ── medium: the second ──\n spearman: {\n id: \"spearman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/spear\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 1.1,\n attack: 1,\n interval: 1.2,\n speed: 1.1,\n },\n crossbowman: {\n id: \"crossbowman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/crossbow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 0.85,\n attack: 0.85,\n interval: 1.8,\n speed: 1.1,\n },\n // ── heavy and magic: the third ──\n footman: {\n id: \"footman\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/heater_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 4,\n hp: 1.3,\n attack: 0.9,\n interval: 1.3,\n speed: 1,\n },\n acolyte: {\n id: \"acolyte\",\n gear: gear({ Top: \"top/mage_robe\", Weapon: \"weapon/druid_staff\" }),\n armor: 0,\n hp: 0.7,\n attack: 0.9,\n interval: 1.7,\n speed: 1,\n },\n mage: {\n id: \"mage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n }),\n armor: 0,\n hp: 0.8,\n attack: 1.1,\n interval: 1.9,\n speed: 1,\n },\n // ── siege and fortified: the fourth ──\n maceman: {\n id: \"maceman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/mace\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 3,\n hp: 1.2,\n attack: 1.2,\n interval: 1.5,\n speed: 1,\n },\n gunner: {\n id: \"gunner\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/rifle\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 1,\n hp: 0.85,\n attack: 1.1,\n interval: 2,\n speed: 1,\n },\n berserker: {\n id: \"berserker\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/double_axe\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1.2,\n attack: 1.3,\n interval: 1.1,\n speed: 1.4,\n },\n warden: {\n id: \"warden\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/broadsword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 6,\n hp: 1.8,\n attack: 0.8,\n interval: 1.6,\n speed: 0.9,\n },\n // ── the fifth: walls broken by hammers, chaos ──\n hammerguard: {\n id: \"hammerguard\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/warhammer\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 5,\n hp: 1.4,\n attack: 1.3,\n interval: 1.6,\n speed: 1,\n },\n warlock: {\n id: \"warlock\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/knight_cape\",\n }),\n armor: 1,\n hp: 1,\n attack: 1.3,\n interval: 1.8,\n speed: 1,\n attackType: \"chaos\",\n },\n // ── bosses ──\n chief: {\n id: \"chief\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/battle_axe\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 9,\n attack: 2,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n warlord: {\n id: \"warlord\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/dark_blade\",\n Offhand: \"offhand/kite_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 4,\n hp: 10,\n attack: 2.1,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n archmage: {\n id: \"archmage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 8,\n attack: 2.4,\n interval: 1.6,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n dreadknight: {\n id: \"dreadknight\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/flame_sword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 6,\n hp: 12,\n attack: 2.2,\n interval: 1.5,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n};\n\n/** How far a shooter shoots (lane widths). */\nexport const TROOP_RANGE = 0.5;\n\n/** The troop's attack type: named, or by the weapon. */\nexport function troopAttack(t: TroopDef): AttackType {\n return t.attackType ?? attackTypeOf(t.gear.Weapon);\n}\n\n/** The troop's armor type: named, or by what it wears. */\nexport function troopArmor(t: TroopDef): ArmorType {\n return t.armorType ?? armorTypeOf(t.gear.Top, t.gear.Offhand);\n}\n\n/** Does it shoot (a bow, a crossbow, a gun, a staff) rather than come face to face? A boss never shoots. */\nexport function troopRanged(t: TroopDef): boolean {\n return !t.boss && !heroIsMelee(t.gear.Weapon);\n}\n\n/** Does it carry a shield — the blue bar of a pool that takes the blows first? */\nexport function troopShielded(t: TroopDef): boolean {\n return !!t.gear.Offhand;\n}\n"
400
399
  },
401
400
  {
402
401
  "path": "heroArt.ts",
@@ -408,7 +407,7 @@
408
407
  },
409
408
  {
410
409
  "path": "index.ts",
411
- "content": "// @idosgames/mod-hero-run — Hero Run (Phaser + React) for the host shell: Obsidian Knight's runs with\n// Warcraft III's damage — the hero runs a level on its own, rage cards build the run, attack types\n// meet armor types. Heroes, gear, summons, quests and the shop are the platform's systems.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (a different screen over the same run, the run under a different screen) after copying this into\n// src/modules/.\n\nexport { heroRunModule } from \"./module\";\n\nexport { RunSession, pickHero } from \"./game/session\";\nexport {\n Battle,\n ULTS,\n rageFor,\n type BattleEvent,\n type BattleSnapshot,\n type RunResult,\n} from \"./game/battle\";\nexport {\n DAMAGE_TABLE,\n armorFactor,\n armorReduction,\n dealt,\n type ArmorType,\n type AttackType,\n} from \"./game/damage\";\nexport { CARDS, drawCards, modsOf, procsOf, type CardDef } from \"./game/cards\";\nexport { TROOPS, type TroopDef, type TroopId } from \"./game/units\";\nexport { levelOf, islandOf, scout, LEVELS_PER_ISLAND } from \"./game/campaign\";\nexport {\n pickEconomy,\n chestTier,\n playerLevel,\n POTION_SLOTS,\n} from \"./game/economy\";\nexport { PROGRESS_KEY } from \"./game/progress\";\nexport {\n fighterStats,\n resolveStats,\n statCost,\n statCap,\n type FighterStats,\n} from \"./game/heroStats\";\nexport { THEMES } from \"./game/themes\";\nexport { formatBig } from \"./game/format\";\nexport { HeroRunController } from \"./game/HeroRunController\";\nexport { BattleScene, type BattleSceneOptions } from \"./phaser/BattleScene\";\nexport { createHeroRunScene } from \"./scene\";\nexport { makeGamePanel } from \"./ui/GamePanel\";\nexport { characterUpgraded, levelCleared } from \"./events\";\n\n// The character system (layered 2D characters: skeleton, parts, colours, clips) — see character/index.ts.\nexport * from \"./character\";\n"
410
+ "content": "// @idosgames/mod-hero-run — Hero Run (Phaser + React) for the host shell: Obsidian Knight's runs with\n// attack and armor types — the hero runs a level on its own, rage cards build the run, attack types\n// meet armor types. Heroes, gear, summons, quests and the shop are the platform's systems.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (a different screen over the same run, the run under a different screen) after copying this into\n// src/modules/.\n\nexport { heroRunModule } from \"./module\";\n\nexport { RunSession, pickHero } from \"./game/session\";\nexport {\n Battle,\n ULTS,\n rageFor,\n type BattleEvent,\n type BattleSnapshot,\n type RunResult,\n} from \"./game/battle\";\nexport {\n DAMAGE_TABLE,\n armorFactor,\n armorReduction,\n dealt,\n type ArmorType,\n type AttackType,\n} from \"./game/damage\";\nexport { CARDS, drawCards, modsOf, procsOf, type CardDef } from \"./game/cards\";\nexport { TROOPS, type TroopDef, type TroopId } from \"./game/units\";\nexport { levelOf, islandOf, scout, LEVELS_PER_ISLAND } from \"./game/campaign\";\nexport {\n pickEconomy,\n chestTier,\n playerLevel,\n POTION_SLOTS,\n} from \"./game/economy\";\nexport { PROGRESS_KEY } from \"./game/progress\";\nexport {\n fighterStats,\n resolveStats,\n statCost,\n statCap,\n type FighterStats,\n} from \"./game/heroStats\";\nexport { THEMES } from \"./game/themes\";\nexport { formatBig } from \"./game/format\";\nexport { HeroRunController } from \"./game/HeroRunController\";\nexport { BattleScene, type BattleSceneOptions } from \"./phaser/BattleScene\";\nexport { createHeroRunScene } from \"./scene\";\nexport { makeGamePanel } from \"./ui/GamePanel\";\nexport { characterUpgraded, levelCleared } from \"./events\";\n\n// The character system (layered 2D characters: skeleton, parts, colours, clips) — see character/index.ts.\nexport * from \"./character\";\n"
412
411
  },
413
412
  {
414
413
  "path": "layout.ts",
@@ -416,7 +415,7 @@
416
415
  },
417
416
  {
418
417
  "path": "module.ts",
419
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { HeroRunController } from \"./game/HeroRunController\";\nimport { RunSession } from \"./game/session\";\nimport type { CampChoice, UltId } from \"./game/battle\";\nimport { ENERGY_ID, TICKET_ID, XP_ID } from \"./game/economy\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createHeroRunScene } from \"./scene\";\nimport { makeGamePanel } from \"./ui/GamePanel\";\nimport { characterUpgraded, heroSelected, levelCleared } from \"./events\";\nimport { t } from \"./i18n\";\nimport { heroArt } from \"./heroArt\";\n\n// Hero Run: Obsidian Knight's runs with Warcraft III's damage. The hero runs a level on its own —\n// waves of troops, a camp, fountains, a boss; kills fill the rage, and every run level is a choice of\n// one of three cards. A weapon's attack type against a troop's armor type decides how hard a blow\n// lands. A run costs energy (a ticket) and ends in a reward chest — both the server's (economy.ts).\n// Heroes, gear, summons, quests and the shop are the platform's systems (module.meta.json → uses).\nexport const heroRunModule: Module = defineModule({\n id: \"hero-run\",\n meta: {\n name: \"Hero Run\",\n type: \"game\",\n genre: \"roguelike-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const session = new RunSession(ctx.client, {\n onLevelCleared: (island, level) =>\n ctx.events.emit(levelCleared, { island, level }),\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\n onRankUp: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n });\n const box = createControllerBox<HeroRunController>();\n\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\n ctx.events.on(heroSelected, ({ characterId }) =>\n session.selectHero(characterId),\n );\n\n // The Heroes screen (and any other) draws this game's hero, not a picture of its own.\n ctx.characterArt.set(heroArt);\n\n ctx.registerScene(createHeroRunScene(box, session));\n ctx.registerPanel({\n id: \"game\",\n slot: \"overlay\",\n component: makeGamePanel(\n session,\n ctx.features,\n ctx.sharedUi.shouldDraw(\"menu\"),\n ),\n });\n // Lives inside the lobby: its balances and tabs around the fight, the app starts here.\n ctx.registerRoute({\n id: \"hero-run\",\n label: t(\"title\"),\n icon: \"⚔️\",\n inLobby: true,\n });\n\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\n // here: the level, the run, the hero and the troops, plus the actions the player's own buttons take.\n ctx.exposeToAgent({\n state: () => {\n const b = session.battle;\n const snap = b.snapshot();\n const hero = session.hero;\n return {\n mounted: box.get() !== null,\n playing: box.get()?.running ?? false,\n openLevel: session.openLevel(),\n selected: session.selected,\n running: session.running,\n ending: session.ending\n ? { ...session.ending.result, tier: session.ending.tier }\n : null,\n energy: session.balance(ENERGY_ID),\n tickets: session.balance(TICKET_ID),\n xp: session.balance(XP_ID),\n runCost: session.runCost(),\n economy: session.economy,\n run: {\n island: snap.island,\n segment: `${snap.segment + 1}/${snap.segments + 1}`,\n rage: Math.round(snap.rage * 10) / 10,\n rageTo: snap.rageTo,\n runLevel: snap.runLevel,\n pending: snap.pending,\n cards: snap.cards,\n potions: snap.potions,\n ult: snap.ult,\n ultReady: snap.ultReady,\n autoPlay: snap.autoPlay,\n kills: snap.kills,\n chests: snap.chests,\n },\n hero: hero\n ? {\n id: hero.id,\n rank: hero.rank,\n power: hero.power,\n gear: hero.gear,\n hp: Math.round(snap.heroHp),\n maxHp: Math.round(snap.heroMaxHp),\n shield: Math.round(snap.heroShield),\n fighter: hero.fighter,\n stats: hero.stats.map((s) => ({\n id: s.id,\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\n })),\n }\n : null,\n troops: b.monsters.map((m) => ({\n troop: m.troop.id,\n boss: m.boss,\n hp: Math.round(m.hp),\n shield: Math.round(m.shield),\n armorType: m.armorType,\n armor: b.armorOf(m),\n attackType: m.attackType,\n ranged: m.range > 0,\n stunned: m.stunned > 0,\n breaks: m.breaks,\n // how far ahead of the hero it stands on the road (lane widths)\n ahead: Math.round((m.x - b.heroX) * 100) / 100,\n })),\n feature: b.feature,\n };\n },\n actions: {\n select: async (args) => session.select(Number(args?.[\"level\"] ?? 1)),\n start: async () => {\n if (!(await session.start()))\n throw new Error(\n \"not now — a run is on or ending, no hero, or not enough energy\",\n );\n },\n takeCard: async (args) => {\n const id = String(args?.[\"id\"] ?? \"\");\n if (!session.takeCard(id))\n throw new Error(\n `no card choice open, or \"${id}\" is not offered — offered: ${JSON.stringify(session.battle.pending)}`,\n );\n },\n camp: async (args) => {\n const choice = String(args?.[\"choice\"] ?? \"heal\") as CampChoice;\n if (!(await session.chooseCamp(choice)))\n throw new Error(\"no camp now, or the gems are short\");\n },\n potion: async () => {\n if (!session.battle.drinkPotion())\n throw new Error(\"no potion, full health, or the run waits\");\n },\n ult: async () => {\n if (!session.battle.castUlt())\n throw new Error(\n \"the ultimate is spent (once a run) or the run waits\",\n );\n },\n setUlt: async (args) =>\n session.setUlt(\n (String(args?.[\"ult\"] ?? \"meteor\") === \"frenzy\"\n ? \"frenzy\"\n : \"meteor\") as UltId,\n ),\n setAuto: async (args) =>\n session.battle.setAutoPlay(Boolean(args?.[\"on\"])),\n reroll: async () => {\n if (!session.reroll())\n throw new Error(\"no card choice open, or no refresh left\");\n },\n abandon: async () => session.abandon(),\n claim: async () => {\n await session.claim();\n },\n },\n describeActions: {\n select: \"Pick a level on the map (an open one): { level: number }.\",\n start:\n \"Start the selected level (spends energy for a run ticket when none is waiting).\",\n takeCard:\n \"Take one of the three rage cards offered: { id } (state.run.pending.options).\",\n camp: \"The camp: { choice: heal | rage | both } (both costs gems).\",\n potion: \"Drink a potion now.\",\n ult: \"Cast the ultimate — once a run (a card may bring it back).\",\n setUlt: \"The ultimate for the next runs: { ult: meteor | frenzy }.\",\n setAuto: \"Potions and the ultimate by themselves: { on: boolean }.\",\n reroll: \"Refresh: three other cards for the open choice (once a run).\",\n abandon: \"Leave the run (it ends as lost).\",\n claim: \"Open the ended run's reward chest and go back to the map.\",\n },\n });\n },\n});\n"
418
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { HeroRunController } from \"./game/HeroRunController\";\nimport { RunSession } from \"./game/session\";\nimport type { CampChoice, UltId } from \"./game/battle\";\nimport { ENERGY_ID, TICKET_ID, XP_ID } from \"./game/economy\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createHeroRunScene } from \"./scene\";\nimport { makeGamePanel } from \"./ui/GamePanel\";\nimport { characterUpgraded, heroSelected, levelCleared } from \"./events\";\nimport { t } from \"./i18n\";\nimport { heroArt } from \"./heroArt\";\n\n// Hero Run: Obsidian Knight's runs with attack and armor types. The hero runs a level on its own —\n// waves of troops, a camp, fountains, a boss; kills fill the rage, and every run level is a choice of\n// one of three cards. A weapon's attack type against a troop's armor type decides how hard a blow\n// lands. A run costs energy (a ticket) and ends in a reward chest — both the server's (economy.ts).\n// Heroes, gear, summons, quests and the shop are the platform's systems (module.meta.json → uses).\nexport const heroRunModule: Module = defineModule({\n id: \"hero-run\",\n meta: {\n name: \"Hero Run\",\n type: \"game\",\n genre: \"roguelike-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const session = new RunSession(ctx.client, {\n onLevelCleared: (island, level) =>\n ctx.events.emit(levelCleared, { island, level }),\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\n onRankUp: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n });\n const box = createControllerBox<HeroRunController>();\n\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\n ctx.events.on(heroSelected, ({ characterId }) =>\n session.selectHero(characterId),\n );\n\n // The Heroes screen (and any other) draws this game's hero, not a picture of its own.\n ctx.characterArt.set(heroArt);\n\n ctx.registerScene(createHeroRunScene(box, session));\n ctx.registerPanel({\n id: \"game\",\n slot: \"overlay\",\n component: makeGamePanel(\n session,\n ctx.features,\n ctx.sharedUi.shouldDraw(\"menu\"),\n ),\n });\n // Lives inside the lobby: its balances and tabs around the fight, the app starts here.\n ctx.registerRoute({\n id: \"hero-run\",\n label: t(\"title\"),\n icon: \"⚔️\",\n inLobby: true,\n });\n\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\n // here: the level, the run, the hero and the troops, plus the actions the player's own buttons take.\n ctx.exposeToAgent({\n state: () => {\n const b = session.battle;\n const snap = b.snapshot();\n const hero = session.hero;\n return {\n mounted: box.get() !== null,\n playing: box.get()?.running ?? false,\n openLevel: session.openLevel(),\n selected: session.selected,\n running: session.running,\n ending: session.ending\n ? { ...session.ending.result, tier: session.ending.tier }\n : null,\n energy: session.balance(ENERGY_ID),\n tickets: session.balance(TICKET_ID),\n xp: session.balance(XP_ID),\n runCost: session.runCost(),\n economy: session.economy,\n run: {\n island: snap.island,\n segment: `${snap.segment + 1}/${snap.segments + 1}`,\n rage: Math.round(snap.rage * 10) / 10,\n rageTo: snap.rageTo,\n runLevel: snap.runLevel,\n pending: snap.pending,\n cards: snap.cards,\n potions: snap.potions,\n ult: snap.ult,\n ultReady: snap.ultReady,\n autoPlay: snap.autoPlay,\n kills: snap.kills,\n chests: snap.chests,\n },\n hero: hero\n ? {\n id: hero.id,\n rank: hero.rank,\n power: hero.power,\n gear: hero.gear,\n hp: Math.round(snap.heroHp),\n maxHp: Math.round(snap.heroMaxHp),\n shield: Math.round(snap.heroShield),\n fighter: hero.fighter,\n stats: hero.stats.map((s) => ({\n id: s.id,\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\n })),\n }\n : null,\n troops: b.monsters.map((m) => ({\n troop: m.troop.id,\n boss: m.boss,\n hp: Math.round(m.hp),\n shield: Math.round(m.shield),\n armorType: m.armorType,\n armor: b.armorOf(m),\n attackType: m.attackType,\n ranged: m.range > 0,\n stunned: m.stunned > 0,\n breaks: m.breaks,\n // how far ahead of the hero it stands on the road (lane widths)\n ahead: Math.round((m.x - b.heroX) * 100) / 100,\n })),\n feature: b.feature,\n };\n },\n actions: {\n select: async (args) => session.select(Number(args?.[\"level\"] ?? 1)),\n start: async () => {\n if (!(await session.start()))\n throw new Error(\n \"not now — a run is on or ending, no hero, or not enough energy\",\n );\n },\n takeCard: async (args) => {\n const id = String(args?.[\"id\"] ?? \"\");\n if (!session.takeCard(id))\n throw new Error(\n `no card choice open, or \"${id}\" is not offered — offered: ${JSON.stringify(session.battle.pending)}`,\n );\n },\n camp: async (args) => {\n const choice = String(args?.[\"choice\"] ?? \"heal\") as CampChoice;\n if (!(await session.chooseCamp(choice)))\n throw new Error(\"no camp now, or the gems are short\");\n },\n potion: async () => {\n if (!session.battle.drinkPotion())\n throw new Error(\"no potion, full health, or the run waits\");\n },\n ult: async () => {\n if (!session.battle.castUlt())\n throw new Error(\n \"the ultimate is spent (once a run) or the run waits\",\n );\n },\n setUlt: async (args) =>\n session.setUlt(\n (String(args?.[\"ult\"] ?? \"meteor\") === \"frenzy\"\n ? \"frenzy\"\n : \"meteor\") as UltId,\n ),\n setAuto: async (args) =>\n session.battle.setAutoPlay(Boolean(args?.[\"on\"])),\n reroll: async () => {\n if (!session.reroll())\n throw new Error(\"no card choice open, or no refresh left\");\n },\n abandon: async () => session.abandon(),\n claim: async () => {\n await session.claim();\n },\n },\n describeActions: {\n select: \"Pick a level on the map (an open one): { level: number }.\",\n start:\n \"Start the selected level (spends energy for a run ticket when none is waiting).\",\n takeCard:\n \"Take one of the three rage cards offered: { id } (state.run.pending.options).\",\n camp: \"The camp: { choice: heal | rage | both } (both costs gems).\",\n potion: \"Drink a potion now.\",\n ult: \"Cast the ultimate — once a run (a card may bring it back).\",\n setUlt: \"The ultimate for the next runs: { ult: meteor | frenzy }.\",\n setAuto: \"Potions and the ultimate by themselves: { on: boolean }.\",\n reroll: \"Refresh: three other cards for the open choice (once a run).\",\n abandon: \"Leave the run (it ends as lost).\",\n claim: \"Open the ended run's reward chest and go back to the map.\",\n },\n });\n },\n});\n"
420
419
  },
421
420
  {
422
421
  "path": "phaser/art.ts",
@@ -432,7 +431,7 @@
432
431
  },
433
432
  {
434
433
  "path": "phaser/BattleScene.ts",
435
- "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type Battle,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport type { Bolt } from \"../game/cards\";\nimport type { Effect } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport type { RunSession } from \"../game/session\";\nimport type { Theme } from \"../game/themes\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n axeTexture,\n bakeCommon,\n featureTexture,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the run the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the troops — people dressed in the\n// gear that gives their types — stand on the road, and so do the camp, the fountains and the chests.\n// A new run (a new Battle) clears the road. The field is the top part of the canvas (layout.ts); the\n// panel covers the rest. A blow's number is coloured by the Warcraft table: green — the attack type\n// is strong against that armor, grey — weak.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\ntype FloatKind = \"hit\" | \"strong\" | \"weak\" | \"crit\" | \"hurt\" | \"armor\";\nconst FLOAT_COLORS: Record<FloatKind, string> = {\n hit: \"#ffffff\",\n strong: \"#8dff6a\",\n weak: \"#b9b6c8\",\n crit: \"#ffb02e\",\n hurt: \"#ff5a4f\",\n armor: \"#7fc0ff\",\n};\n/** A blow's number by how its attack type fares against the armor (damage.ts). */\nconst EFFECT_KIND: Record<Effect, FloatKind> = {\n strong: \"strong\",\n even: \"hit\",\n weak: \"weak\",\n};\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private features = new Map<number, Phaser.GameObjects.Image>();\n /** The run the views were made for — a new one clears the road. */\n private drawn: Battle | null = null;\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: RunSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"hero-run-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = this.session.battle.level.island.theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (battle !== this.drawn) this.clearRoad(battle);\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.syncFeatures();\n this.driftClouds(dt);\n }\n\n /** A new run: the old road's troops, bodies, shots and camps are gone; the hero stands again. */\n private clearRoad(battle: Battle): void {\n this.drawn = battle;\n for (const v of this.enemies.values()) {\n v.bars.destroy();\n v.actor.destroy();\n }\n for (const c of this.corpses) c.actor.destroy();\n for (const img of [\n ...this.shots.values(),\n ...this.enemyShots.values(),\n ...this.features.values(),\n ])\n img.destroy();\n this.enemies.clear();\n this.corpses = [];\n this.killed.clear();\n this.shots.clear();\n this.enemyShots.clear();\n this.features.clear();\n this.hero?.rise();\n this.theme = null;\n }\n\n /** The camp, the fountain, the chest on the road ahead. */\n private syncFeatures(): void {\n const f = this.session.battle.feature;\n for (const [id, img] of this.features)\n if (f?.id !== id) {\n this.tweens.add({\n targets: img,\n alpha: 0,\n y: img.y - 20,\n duration: 300,\n onComplete: () => img.destroy(),\n });\n this.features.delete(id);\n }\n if (!f) return;\n let img = this.features.get(f.id);\n if (!img) {\n img = this.add\n .image(0, 0, featureTexture(this, f.kind, f.gives))\n .setOrigin(0.5, 1)\n .setDepth(14);\n this.features.set(f.id, img);\n }\n img\n .setScale(this.field.unit)\n .setPosition(this.roadX(f.x), this.field.ground + 4);\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && !b.heroDown);\n if (!hero || b.heroDown) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.heroMaxHp(),\n armor: b.heroShield,\n maxArmor: b.heroMaxShield(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(battle, m, m.speed * battle.walkSpeed * this.field.road);\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n { hp: m.hp, maxHp: m.maxHp, armor: m.shield, maxArmor: m.maxShield },\n );\n // a stunned troop is tinted until it shakes it off\n view.actor.setMarked(m.stunned > 0);\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : EFFECT_KIND[e.effect],\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the shield took whole shows blue, one that reached the health red, one turned into\n // healing green\n const onShield = e.shield >= e.damage - 1e-9;\n this.floatText(\n `${e.healed ? \"+\" : \"-\"}${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n e.healed ? \"strong\" : onShield ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(\n e.healed ? 0x8dff6a : onShield ? 0x9fc8ff : 0xff6060,\n 90,\n );\n }\n break;\n case \"blast\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.fire, x, ground - 30, 22);\n this.cameras.main.shake(140, 0.006);\n break;\n }\n case \"stun\": {\n const view = this.enemies.get(e.monsterId);\n if (view)\n this.burstAt(\n this.sparks,\n view.actor.view.root.x,\n ground - view.actor.view.displayHeight,\n 6,\n );\n break;\n }\n case \"bolt\":\n for (const id of e.targets) this.boltFx(e.bolt, id);\n break;\n case \"guard\": {\n const { x } = this.heroPos();\n this.floatText(\"🛡\", x, ground - this.heroHeight(), \"armor\");\n break;\n }\n case \"charge\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 10);\n break;\n case \"potion\":\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 18);\n break;\n case \"levelUp\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 60, 16);\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"end\":\n if (e.result.cleared) {\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n }\n break;\n case \"ult\":\n this.skillFx(e.ult);\n break;\n case \"ultBack\":\n // a card brought the ultimate back\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 20);\n this.cameras.main.flash(160, 200, 160, 255);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n }\n }\n\n /** A card's missile on its way to a troop: the damage is already dealt, this is how it looks. */\n private boltFx(kind: Bolt, monsterId: number): void {\n const view = this.enemies.get(monsterId);\n const { ground } = this.field;\n const hero = this.heroPos();\n const tx = view?.actor.view.root.x ?? this.field.w * 0.8;\n const ty = ground - (view?.actor.view.displayHeight ?? 70) * 0.55;\n if (kind === \"lightning\") {\n // a zigzag from the sky onto the troop, white in blue, gone in a blink\n const g = this.add.graphics().setDepth(36);\n const points: Array<[number, number]> = [];\n for (let k = 0; k <= 6; k++)\n points.push([\n tx + (k % 2 ? 12 : -12) * (k < 6 ? 1 : 0),\n -10 + ((ty + 10) * k) / 6,\n ]);\n for (const [w, c, a] of [\n [9, 0x3aa0ff, 0.55],\n [4, 0xffffff, 1],\n ] as const) {\n g.lineStyle(w, c, a);\n g.beginPath();\n g.moveTo(points[0]![0], points[0]![1]);\n for (const [x, y] of points.slice(1)) g.lineTo(x, y);\n g.strokePath();\n }\n this.burstAt(this.sparks, tx, ty, 10);\n this.tweens.add({\n targets: g,\n alpha: 0,\n duration: 260,\n onComplete: () => g.destroy(),\n });\n return;\n }\n const texture =\n kind === \"axe\"\n ? axeTexture(this)\n : missileTexture(this, kind === \"spear\" ? \"thrust\" : \"arrow\");\n const img = this.add.image(hero.x + 20, ground - 50, texture).setDepth(36);\n if (kind === \"spear\") {\n // through the whole line, off the screen's edge\n img.setScale(1.4, 1);\n this.tweens.add({\n targets: img,\n x: this.field.w + 60,\n duration: 320,\n ease: \"Quad.easeIn\",\n onComplete: () => img.destroy(),\n });\n return;\n }\n this.tweens.add({\n targets: img,\n x: tx,\n y: ty,\n angle: kind === \"axe\" ? 720 : 0,\n duration: kind === \"axe\" ? 300 : 220,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.sparks, tx, ty, 6);\n img.destroy();\n },\n });\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n private floatText(text: string, x: number, y: number, kind: FloatKind): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color: FLOAT_COLORS[kind],\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
434
+ "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type Battle,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport type { Bolt } from \"../game/cards\";\nimport type { Effect } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport type { RunSession } from \"../game/session\";\nimport type { Theme } from \"../game/themes\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n axeTexture,\n bakeCommon,\n featureTexture,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the run the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the troops — people dressed in the\n// gear that gives their types — stand on the road, and so do the camp, the fountains and the chests.\n// A new run (a new Battle) clears the road. The field is the top part of the canvas (layout.ts); the\n// panel covers the rest. A blow's number is coloured by the damage table: green — the attack type\n// is strong against that armor, grey — weak.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\ntype FloatKind = \"hit\" | \"strong\" | \"weak\" | \"crit\" | \"hurt\" | \"armor\";\nconst FLOAT_COLORS: Record<FloatKind, string> = {\n hit: \"#ffffff\",\n strong: \"#8dff6a\",\n weak: \"#b9b6c8\",\n crit: \"#ffb02e\",\n hurt: \"#ff5a4f\",\n armor: \"#7fc0ff\",\n};\n/** A blow's number by how its attack type fares against the armor (damage.ts). */\nconst EFFECT_KIND: Record<Effect, FloatKind> = {\n strong: \"strong\",\n even: \"hit\",\n weak: \"weak\",\n};\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private features = new Map<number, Phaser.GameObjects.Image>();\n /** The run the views were made for — a new one clears the road. */\n private drawn: Battle | null = null;\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: RunSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"hero-run-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = this.session.battle.level.island.theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (battle !== this.drawn) this.clearRoad(battle);\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.syncFeatures();\n this.driftClouds(dt);\n }\n\n /** A new run: the old road's troops, bodies, shots and camps are gone; the hero stands again. */\n private clearRoad(battle: Battle): void {\n this.drawn = battle;\n for (const v of this.enemies.values()) {\n v.bars.destroy();\n v.actor.destroy();\n }\n for (const c of this.corpses) c.actor.destroy();\n for (const img of [\n ...this.shots.values(),\n ...this.enemyShots.values(),\n ...this.features.values(),\n ])\n img.destroy();\n this.enemies.clear();\n this.corpses = [];\n this.killed.clear();\n this.shots.clear();\n this.enemyShots.clear();\n this.features.clear();\n this.hero?.rise();\n this.theme = null;\n }\n\n /** The camp, the fountain, the chest on the road ahead. */\n private syncFeatures(): void {\n const f = this.session.battle.feature;\n for (const [id, img] of this.features)\n if (f?.id !== id) {\n this.tweens.add({\n targets: img,\n alpha: 0,\n y: img.y - 20,\n duration: 300,\n onComplete: () => img.destroy(),\n });\n this.features.delete(id);\n }\n if (!f) return;\n let img = this.features.get(f.id);\n if (!img) {\n img = this.add\n .image(0, 0, featureTexture(this, f.kind, f.gives))\n .setOrigin(0.5, 1)\n .setDepth(14);\n this.features.set(f.id, img);\n }\n img\n .setScale(this.field.unit)\n .setPosition(this.roadX(f.x), this.field.ground + 4);\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && !b.heroDown);\n if (!hero || b.heroDown) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.heroMaxHp(),\n armor: b.heroShield,\n maxArmor: b.heroMaxShield(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(battle, m, m.speed * battle.walkSpeed * this.field.road);\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n { hp: m.hp, maxHp: m.maxHp, armor: m.shield, maxArmor: m.maxShield },\n );\n // a stunned troop is tinted until it shakes it off\n view.actor.setMarked(m.stunned > 0);\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : EFFECT_KIND[e.effect],\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the shield took whole shows blue, one that reached the health red, one turned into\n // healing green\n const onShield = e.shield >= e.damage - 1e-9;\n this.floatText(\n `${e.healed ? \"+\" : \"-\"}${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n e.healed ? \"strong\" : onShield ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(\n e.healed ? 0x8dff6a : onShield ? 0x9fc8ff : 0xff6060,\n 90,\n );\n }\n break;\n case \"blast\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.fire, x, ground - 30, 22);\n this.cameras.main.shake(140, 0.006);\n break;\n }\n case \"stun\": {\n const view = this.enemies.get(e.monsterId);\n if (view)\n this.burstAt(\n this.sparks,\n view.actor.view.root.x,\n ground - view.actor.view.displayHeight,\n 6,\n );\n break;\n }\n case \"bolt\":\n for (const id of e.targets) this.boltFx(e.bolt, id);\n break;\n case \"guard\": {\n const { x } = this.heroPos();\n this.floatText(\"🛡\", x, ground - this.heroHeight(), \"armor\");\n break;\n }\n case \"charge\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 10);\n break;\n case \"potion\":\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 18);\n break;\n case \"levelUp\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 60, 16);\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"end\":\n if (e.result.cleared) {\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n }\n break;\n case \"ult\":\n this.skillFx(e.ult);\n break;\n case \"ultBack\":\n // a card brought the ultimate back\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 20);\n this.cameras.main.flash(160, 200, 160, 255);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n }\n }\n\n /** A card's missile on its way to a troop: the damage is already dealt, this is how it looks. */\n private boltFx(kind: Bolt, monsterId: number): void {\n const view = this.enemies.get(monsterId);\n const { ground } = this.field;\n const hero = this.heroPos();\n const tx = view?.actor.view.root.x ?? this.field.w * 0.8;\n const ty = ground - (view?.actor.view.displayHeight ?? 70) * 0.55;\n if (kind === \"lightning\") {\n // a zigzag from the sky onto the troop, white in blue, gone in a blink\n const g = this.add.graphics().setDepth(36);\n const points: Array<[number, number]> = [];\n for (let k = 0; k <= 6; k++)\n points.push([\n tx + (k % 2 ? 12 : -12) * (k < 6 ? 1 : 0),\n -10 + ((ty + 10) * k) / 6,\n ]);\n for (const [w, c, a] of [\n [9, 0x3aa0ff, 0.55],\n [4, 0xffffff, 1],\n ] as const) {\n g.lineStyle(w, c, a);\n g.beginPath();\n g.moveTo(points[0]![0], points[0]![1]);\n for (const [x, y] of points.slice(1)) g.lineTo(x, y);\n g.strokePath();\n }\n this.burstAt(this.sparks, tx, ty, 10);\n this.tweens.add({\n targets: g,\n alpha: 0,\n duration: 260,\n onComplete: () => g.destroy(),\n });\n return;\n }\n const texture =\n kind === \"axe\"\n ? axeTexture(this)\n : missileTexture(this, kind === \"spear\" ? \"thrust\" : \"arrow\");\n const img = this.add.image(hero.x + 20, ground - 50, texture).setDepth(36);\n if (kind === \"spear\") {\n // through the whole line, off the screen's edge\n img.setScale(1.4, 1);\n this.tweens.add({\n targets: img,\n x: this.field.w + 60,\n duration: 320,\n ease: \"Quad.easeIn\",\n onComplete: () => img.destroy(),\n });\n return;\n }\n this.tweens.add({\n targets: img,\n x: tx,\n y: ty,\n angle: kind === \"axe\" ? 720 : 0,\n duration: kind === \"axe\" ? 300 : 220,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.sparks, tx, ty, 6);\n img.destroy();\n },\n });\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n private floatText(text: string, x: number, y: number, kind: FloatKind): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color: FLOAT_COLORS[kind],\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
436
435
  },
437
436
  {
438
437
  "path": "phaser/enemy.ts",
@@ -472,7 +471,7 @@
472
471
  },
473
472
  {
474
473
  "path": "ui/MapPanel.tsx",
475
- "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Card,\n Icon,\n InfoButton,\n LevelShield,\n Popup,\n ResourceCounter,\n buttonStyle,\n outlined,\n useNow,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { POTION_SLOTS } from \"../game/economy\";\nimport { ULTS, type UltId } from \"../game/battle\";\nimport { LEVELS_PER_ISLAND, levelOf, typesKnownBy } from \"../game/campaign\";\nimport {\n DAMAGE_TABLE,\n effectOf,\n type ArmorType,\n type AttackType,\n type Effect,\n} from \"../game/damage\";\nimport { ENERGY_ID, POTION_ID, TICKET_ID, nextEnergyIn } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t } from \"../i18n\";\nimport { TypeChip } from \"./types\";\n\n// The map between runs: the island's levels, the scouting of the picked one (what armor the road\n// wears against YOUR attack, what attacks it brings against YOUR armor — the colours say who wins),\n// the ultimate for the run, and the Run button with what it costs.\n\n/** An enemy attack that is strong against you is bad news: the chip shows it from your side. */\nconst MINE: Record<Effect, Effect> = {\n strong: \"weak\",\n even: \"even\",\n weak: \"strong\",\n};\n\nexport function MapPanel({ session }: { session: RunSession }): ReactNode {\n const level = session.level();\n const island = level.island;\n const hero = session.hero;\n const open = session.openLevel();\n const first = (island.index - 1) * LEVELS_PER_ISLAND + 1;\n const scout = session.scouting();\n const [table, setTable] = useState(false);\n const pl = session.playerLevel();\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <LevelShield level={pl.level} size={40} />\n <div style={{ ...outlined, fontSize: 16, flex: 1 }}>\n {t(\"island\")} {island.index}\n <span style={{ color: v.textDim, fontSize: 13 }}>\n {\" \"}\n · {t(island.theme.id)}\n </span>\n </div>\n <IslandArrow\n dir={-1}\n disabled={island.index <= 1}\n onClick={() => session.select(first - LEVELS_PER_ISLAND)}\n />\n <IslandArrow\n dir={1}\n disabled={first + LEVELS_PER_ISLAND > open}\n onClick={() => session.select(first + LEVELS_PER_ISLAND)}\n />\n </div>\n\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${LEVELS_PER_ISLAND}, minmax(0, 1fr))`,\n gap: 6,\n }}\n >\n {Array.from({ length: LEVELS_PER_ISLAND }, (_, i) => {\n const n = first + i;\n const cleared = n <= session.progress.cleared;\n const locked = n > open;\n const picked = n === session.selected;\n const boss = i === LEVELS_PER_ISLAND - 1;\n return (\n <button\n key={n}\n type=\"button\"\n className=\"idos-press\"\n disabled={locked}\n onClick={() => session.select(n)}\n data-tutorial-anchor={`hero:level:${n}`}\n style={{\n ...buttonStyle(\n picked\n ? \"gold\"\n : cleared\n ? \"green\"\n : locked\n ? \"grey\"\n : \"blue\",\n false,\n \"sm\",\n ),\n padding: \"6px 0\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n opacity: locked ? 0.6 : 1,\n }}\n >\n <span style={{ fontSize: 15 }}>\n {island.index}-{i + 1}\n </span>\n <Icon\n glyph={\n locked\n ? \"lock\"\n : cleared\n ? \"check\"\n : boss\n ? \"crown\"\n : \"swords\"\n }\n size={14}\n />\n </button>\n );\n })}\n </div>\n\n <Card style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span style={{ ...outlined, fontSize: 15, flex: 1 }}>\n {island.scouting\n ? t(\"scouting\")\n : `${t(\"level\")} ${island.index}-${level.level}`}\n </span>\n {island.scouting ? (\n <InfoButton label={t(\"table\")} onClick={() => setTable(true)} />\n ) : null}\n </div>\n {island.scouting && hero ? (\n <>\n <Row label={t(\"enemyArmor\")}>\n {scout.armor.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"armor\"\n type={a.type}\n share={a.share}\n effect={effectOf(hero.gear.attack, a.type)}\n />\n ))}\n </Row>\n <Row label={t(\"enemyAttack\")}>\n {scout.attack.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"attack\"\n type={a.type}\n share={a.share}\n effect={MINE[effectOf(a.type, hero.gear.armor)]}\n />\n ))}\n </Row>\n <Row label={t(\"boss\")}>\n <TypeChip\n kind=\"armor\"\n type={scout.boss.armor}\n effect={effectOf(hero.gear.attack, scout.boss.armor)}\n />\n <TypeChip\n kind=\"attack\"\n type={scout.boss.attack}\n effect={MINE[effectOf(scout.boss.attack, hero.gear.armor)]}\n />\n </Row>\n </>\n ) : null}\n {hero ? (\n <Row label={t(\"yourAttack\")}>\n <TypeChip kind=\"attack\" type={hero.gear.attack} />\n <TypeChip kind=\"armor\" type={hero.gear.armor} />\n {hero.gear.shield ? <Icon glyph=\"shield\" size={18} /> : null}\n </Row>\n ) : null}\n </Card>\n\n <UltPicker session={session} />\n <StartRow session={session} />\n\n {table ? (\n <TablePopup island={island.index} onClose={() => setTable(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction IslandArrow({\n dir,\n disabled,\n onClick,\n}: {\n dir: 1 | -1;\n disabled: boolean;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n onClick={onClick}\n aria-label={dir < 0 ? \"◀\" : \"▶\"}\n style={{\n ...buttonStyle(\"slate\", false, \"sm\"),\n width: 36,\n padding: \"4px 0\",\n opacity: disabled ? 0.45 : 1,\n }}\n >\n {dir < 0 ? \"◀\" : \"▶\"}\n </button>\n );\n}\n\nfunction Row({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n <span\n style={{ ...outlined, fontSize: 12, color: v.textDim, minWidth: 92 }}\n >\n {label}\n </span>\n {children}\n </div>\n );\n}\n\nfunction UltPicker({ session }: { session: RunSession }): ReactNode {\n const picked = session.progress.ult;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 13, minWidth: 92 }}>\n {t(\"ultimate\")}\n </span>\n {(Object.keys(ULTS) as UltId[]).map((u) => (\n <button\n key={u}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => session.setUlt(u)}\n data-tutorial-anchor={`hero:ult:${u}`}\n style={{\n ...buttonStyle(u === picked ? \"purple\" : \"grey\", false, \"sm\"),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n }}\n >\n <Icon glyph={u === \"meteor\" ? \"flame\" : \"energy\"} size={16} />\n {t(u)}\n </button>\n ))}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(picked === \"meteor\" ? \"meteorText\" : \"frenzyText\")}. {t(\"ultOnce\")}\n </div>\n </div>\n );\n}\n\nfunction StartRow({ session }: { session: RunSession }): ReactNode {\n const now = useNow(1000);\n const economy = session.economy;\n const cost = session.runCost();\n const energy = session.balance(ENERGY_ID);\n const wait = economy\n ? nextEnergyIn(economy.recharge, session.rechargeState(), energy, now)\n : null;\n const potions = economy\n ? Math.min(POTION_SLOTS, session.balance(POTION_ID))\n : POTION_SLOTS;\n const canPay = cost === 0 || energy >= cost;\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n flexWrap: \"wrap\",\n }}\n >\n {economy ? (\n <ResourceCounter currencyID={ENERGY_ID} amount={energy} size={24} />\n ) : null}\n <span\n style={{\n ...outlined,\n fontSize: 13,\n display: \"inline-flex\",\n gap: 4,\n alignItems: \"center\",\n }}\n title={t(\"potions\")}\n >\n <Icon glyph=\"potion\" size={18} /> {potions}/{POTION_SLOTS}\n </span>\n {wait !== null ? (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"nextEnergy\")} {formatDuration(wait)}\n </span>\n ) : null}\n <span style={{ flex: 1 }} />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={canPay && !!session.hero}\n disabled={!session.hero || session.busy}\n busy={session.busy}\n onClick={() => void session.start()}\n data-tutorial-anchor=\"hero:start\"\n >\n <Icon glyph=\"swords\" size={20} /> {t(\"play\")}\n <span style={{ fontSize: 13, marginLeft: 6 }}>\n {!economy ? (\n t(\"free\")\n ) : cost === 0 && session.balance(TICKET_ID) >= 1 ? (\n t(\"ticketReady\")\n ) : (\n <>\n <Icon glyph=\"energy\" size={14} /> {cost}\n </>\n )}\n </span>\n </Button>\n </div>\n );\n}\n\n/** The Warcraft table — only the types the player has met so far (they come island by island). */\nfunction TablePopup({\n island,\n onClose,\n}: {\n island: number;\n onClose: () => void;\n}): ReactNode {\n const known = typesKnownBy(island);\n const attacks = (Object.keys(DAMAGE_TABLE) as AttackType[]).filter(\n (a) => known.attack.has(a) || a === \"spells\",\n );\n const armors = (Object.keys(DAMAGE_TABLE.normal) as ArmorType[]).filter((a) =>\n known.armor.has(a),\n );\n return (\n <Popup title={t(\"table\")} onClose={onClose} width={520}>\n <div style={{ overflowX: \"auto\" }}>\n <table\n style={{\n borderCollapse: \"collapse\",\n width: \"100%\",\n ...outlined,\n fontSize: 12,\n }}\n >\n <thead>\n <tr>\n <th />\n {armors.map((a) => (\n <th key={a} style={{ padding: 4, color: v.textDim }}>\n {t(`armor.${a}` as Parameters<typeof t>[0])}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {attacks.map((atk) => (\n <tr key={atk}>\n <th style={{ padding: 4, textAlign: \"left\" }}>\n {t(`attack.${atk}` as Parameters<typeof t>[0])}\n </th>\n {armors.map((a) => {\n const f = DAMAGE_TABLE[atk][a];\n return (\n <td\n key={a}\n style={{\n padding: 4,\n textAlign: \"center\",\n color: f > 1 ? v.green : f < 1 ? v.textDim : v.text,\n }}\n >\n {Math.round(f * 100)}%\n </td>\n );\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </Popup>\n );\n}\n\n/** The tips of a level's new types, once each. */\nexport function TipPopup({ session }: { session: RunSession }): ReactNode {\n const tips = session.newTips();\n if (session.running || session.ending || tips.length === 0) return null;\n return (\n <Popup\n title={t(\"tipTitle\")}\n onClose={() => session.markTipsSeen(tips)}\n width={420}\n variant=\"center\"\n queued\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {tips.map((key) => (\n <div\n key={key}\n style={{ ...outlined, fontSize: 14, lineHeight: 1.35 }}\n >\n {t(`tip.${key.replace(\":\", \".\")}` as Parameters<typeof t>[0])}\n </div>\n ))}\n <Button tone=\"gold\" onClick={() => session.markTipsSeen(tips)}>\n {t(\"tipOk\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\n/** The level a level number names, for the HUD (`levelOf` without the road). */\nexport function levelTitle(index: number): string {\n const l = levelOf(index);\n return `${l.island.index}-${l.level}`;\n}\n"
474
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Card,\n Icon,\n InfoButton,\n LevelShield,\n Popup,\n ResourceCounter,\n buttonStyle,\n outlined,\n useNow,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { POTION_SLOTS } from \"../game/economy\";\nimport { ULTS, type UltId } from \"../game/battle\";\nimport { LEVELS_PER_ISLAND, levelOf, typesKnownBy } from \"../game/campaign\";\nimport {\n DAMAGE_TABLE,\n effectOf,\n type ArmorType,\n type AttackType,\n type Effect,\n} from \"../game/damage\";\nimport { ENERGY_ID, POTION_ID, TICKET_ID, nextEnergyIn } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t } from \"../i18n\";\nimport { TypeChip } from \"./types\";\n\n// The map between runs: the island's levels, the scouting of the picked one (what armor the road\n// wears against YOUR attack, what attacks it brings against YOUR armor — the colours say who wins),\n// the ultimate for the run, and the Run button with what it costs.\n\n/** An enemy attack that is strong against you is bad news: the chip shows it from your side. */\nconst MINE: Record<Effect, Effect> = {\n strong: \"weak\",\n even: \"even\",\n weak: \"strong\",\n};\n\nexport function MapPanel({ session }: { session: RunSession }): ReactNode {\n const level = session.level();\n const island = level.island;\n const hero = session.hero;\n const open = session.openLevel();\n const first = (island.index - 1) * LEVELS_PER_ISLAND + 1;\n const scout = session.scouting();\n const [table, setTable] = useState(false);\n const pl = session.playerLevel();\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <LevelShield level={pl.level} size={40} />\n <div style={{ ...outlined, fontSize: 16, flex: 1 }}>\n {t(\"island\")} {island.index}\n <span style={{ color: v.textDim, fontSize: 13 }}>\n {\" \"}\n · {t(island.theme.id)}\n </span>\n </div>\n <IslandArrow\n dir={-1}\n disabled={island.index <= 1}\n onClick={() => session.select(first - LEVELS_PER_ISLAND)}\n />\n <IslandArrow\n dir={1}\n disabled={first + LEVELS_PER_ISLAND > open}\n onClick={() => session.select(first + LEVELS_PER_ISLAND)}\n />\n </div>\n\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${LEVELS_PER_ISLAND}, minmax(0, 1fr))`,\n gap: 6,\n }}\n >\n {Array.from({ length: LEVELS_PER_ISLAND }, (_, i) => {\n const n = first + i;\n const cleared = n <= session.progress.cleared;\n const locked = n > open;\n const picked = n === session.selected;\n const boss = i === LEVELS_PER_ISLAND - 1;\n return (\n <button\n key={n}\n type=\"button\"\n className=\"idos-press\"\n disabled={locked}\n onClick={() => session.select(n)}\n data-tutorial-anchor={`hero:level:${n}`}\n style={{\n ...buttonStyle(\n picked\n ? \"gold\"\n : cleared\n ? \"green\"\n : locked\n ? \"grey\"\n : \"blue\",\n false,\n \"sm\",\n ),\n padding: \"6px 0\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n opacity: locked ? 0.6 : 1,\n }}\n >\n <span style={{ fontSize: 15 }}>\n {island.index}-{i + 1}\n </span>\n <Icon\n glyph={\n locked\n ? \"lock\"\n : cleared\n ? \"check\"\n : boss\n ? \"crown\"\n : \"swords\"\n }\n size={14}\n />\n </button>\n );\n })}\n </div>\n\n <Card style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span style={{ ...outlined, fontSize: 15, flex: 1 }}>\n {island.scouting\n ? t(\"scouting\")\n : `${t(\"level\")} ${island.index}-${level.level}`}\n </span>\n {island.scouting ? (\n <InfoButton label={t(\"table\")} onClick={() => setTable(true)} />\n ) : null}\n </div>\n {island.scouting && hero ? (\n <>\n <Row label={t(\"enemyArmor\")}>\n {scout.armor.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"armor\"\n type={a.type}\n share={a.share}\n effect={effectOf(hero.gear.attack, a.type)}\n />\n ))}\n </Row>\n <Row label={t(\"enemyAttack\")}>\n {scout.attack.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"attack\"\n type={a.type}\n share={a.share}\n effect={MINE[effectOf(a.type, hero.gear.armor)]}\n />\n ))}\n </Row>\n <Row label={t(\"boss\")}>\n <TypeChip\n kind=\"armor\"\n type={scout.boss.armor}\n effect={effectOf(hero.gear.attack, scout.boss.armor)}\n />\n <TypeChip\n kind=\"attack\"\n type={scout.boss.attack}\n effect={MINE[effectOf(scout.boss.attack, hero.gear.armor)]}\n />\n </Row>\n </>\n ) : null}\n {hero ? (\n <Row label={t(\"yourAttack\")}>\n <TypeChip kind=\"attack\" type={hero.gear.attack} />\n <TypeChip kind=\"armor\" type={hero.gear.armor} />\n {hero.gear.shield ? <Icon glyph=\"shield\" size={18} /> : null}\n </Row>\n ) : null}\n </Card>\n\n <UltPicker session={session} />\n <StartRow session={session} />\n\n {table ? (\n <TablePopup island={island.index} onClose={() => setTable(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction IslandArrow({\n dir,\n disabled,\n onClick,\n}: {\n dir: 1 | -1;\n disabled: boolean;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n onClick={onClick}\n aria-label={dir < 0 ? \"◀\" : \"▶\"}\n style={{\n ...buttonStyle(\"slate\", false, \"sm\"),\n width: 36,\n padding: \"4px 0\",\n opacity: disabled ? 0.45 : 1,\n }}\n >\n {dir < 0 ? \"◀\" : \"▶\"}\n </button>\n );\n}\n\nfunction Row({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n <span\n style={{ ...outlined, fontSize: 12, color: v.textDim, minWidth: 92 }}\n >\n {label}\n </span>\n {children}\n </div>\n );\n}\n\nfunction UltPicker({ session }: { session: RunSession }): ReactNode {\n const picked = session.progress.ult;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 13, minWidth: 92 }}>\n {t(\"ultimate\")}\n </span>\n {(Object.keys(ULTS) as UltId[]).map((u) => (\n <button\n key={u}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => session.setUlt(u)}\n data-tutorial-anchor={`hero:ult:${u}`}\n style={{\n ...buttonStyle(u === picked ? \"purple\" : \"grey\", false, \"sm\"),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n }}\n >\n <Icon glyph={u === \"meteor\" ? \"flame\" : \"energy\"} size={16} />\n {t(u)}\n </button>\n ))}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(picked === \"meteor\" ? \"meteorText\" : \"frenzyText\")}. {t(\"ultOnce\")}\n </div>\n </div>\n );\n}\n\nfunction StartRow({ session }: { session: RunSession }): ReactNode {\n const now = useNow(1000);\n const economy = session.economy;\n const cost = session.runCost();\n const energy = session.balance(ENERGY_ID);\n const wait = economy\n ? nextEnergyIn(economy.recharge, session.rechargeState(), energy, now)\n : null;\n const potions = economy\n ? Math.min(POTION_SLOTS, session.balance(POTION_ID))\n : POTION_SLOTS;\n const canPay = cost === 0 || energy >= cost;\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n flexWrap: \"wrap\",\n }}\n >\n {economy ? (\n <ResourceCounter currencyID={ENERGY_ID} amount={energy} size={24} />\n ) : null}\n <span\n style={{\n ...outlined,\n fontSize: 13,\n display: \"inline-flex\",\n gap: 4,\n alignItems: \"center\",\n }}\n title={t(\"potions\")}\n >\n <Icon glyph=\"potion\" size={18} /> {potions}/{POTION_SLOTS}\n </span>\n {wait !== null ? (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"nextEnergy\")} {formatDuration(wait)}\n </span>\n ) : null}\n <span style={{ flex: 1 }} />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={canPay && !!session.hero}\n disabled={!session.hero || session.busy}\n busy={session.busy}\n onClick={() => void session.start()}\n data-tutorial-anchor=\"hero:start\"\n >\n <Icon glyph=\"swords\" size={20} /> {t(\"play\")}\n <span style={{ fontSize: 13, marginLeft: 6 }}>\n {!economy ? (\n t(\"free\")\n ) : cost === 0 && session.balance(TICKET_ID) >= 1 ? (\n t(\"ticketReady\")\n ) : (\n <>\n <Icon glyph=\"energy\" size={14} /> {cost}\n </>\n )}\n </span>\n </Button>\n </div>\n );\n}\n\n/** The damage table — only the types the player has met so far (they come island by island). */\nfunction TablePopup({\n island,\n onClose,\n}: {\n island: number;\n onClose: () => void;\n}): ReactNode {\n const known = typesKnownBy(island);\n const attacks = (Object.keys(DAMAGE_TABLE) as AttackType[]).filter(\n (a) => known.attack.has(a) || a === \"spells\",\n );\n const armors = (Object.keys(DAMAGE_TABLE.normal) as ArmorType[]).filter((a) =>\n known.armor.has(a),\n );\n return (\n <Popup title={t(\"table\")} onClose={onClose} width={520}>\n <div style={{ overflowX: \"auto\" }}>\n <table\n style={{\n borderCollapse: \"collapse\",\n width: \"100%\",\n ...outlined,\n fontSize: 12,\n }}\n >\n <thead>\n <tr>\n <th />\n {armors.map((a) => (\n <th key={a} style={{ padding: 4, color: v.textDim }}>\n {t(`armor.${a}` as Parameters<typeof t>[0])}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {attacks.map((atk) => (\n <tr key={atk}>\n <th style={{ padding: 4, textAlign: \"left\" }}>\n {t(`attack.${atk}` as Parameters<typeof t>[0])}\n </th>\n {armors.map((a) => {\n const f = DAMAGE_TABLE[atk][a];\n return (\n <td\n key={a}\n style={{\n padding: 4,\n textAlign: \"center\",\n color: f > 1 ? v.green : f < 1 ? v.textDim : v.text,\n }}\n >\n {Math.round(f * 100)}%\n </td>\n );\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </Popup>\n );\n}\n\n/** The tips of a level's new types, once each. */\nexport function TipPopup({ session }: { session: RunSession }): ReactNode {\n const tips = session.newTips();\n if (session.running || session.ending || tips.length === 0) return null;\n return (\n <Popup\n title={t(\"tipTitle\")}\n onClose={() => session.markTipsSeen(tips)}\n width={420}\n variant=\"center\"\n queued\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {tips.map((key) => (\n <div\n key={key}\n style={{ ...outlined, fontSize: 14, lineHeight: 1.35 }}\n >\n {t(`tip.${key.replace(\":\", \".\")}` as Parameters<typeof t>[0])}\n </div>\n ))}\n <Button tone=\"gold\" onClick={() => session.markTipsSeen(tips)}>\n {t(\"tipOk\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\n/** The level a level number names, for the HUD (`levelOf` without the road). */\nexport function levelTitle(index: number): string {\n const l = levelOf(index);\n return `${l.island.index}-${l.level}`;\n}\n"
476
475
  },
477
476
  {
478
477
  "path": "ui/RunBar.tsx",
@@ -492,7 +491,7 @@
492
491
  },
493
492
  {
494
493
  "path": "module.meta.json",
495
- "content": "{\n \"id\": \"hero-run\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\", \"inventory\"],\n \"summary\": \"Roguelike runs in the style of Obsidian Knight with Warcraft III's damage: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.\",\n \"description\": \"A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage is Warcraft III's: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by the Frozen Throne table, and armor points by the WC3 formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.\",\n \"provides\": [\n \"roguelike runs of levels: waves, camp, fountains, chest, boss\",\n \"rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)\",\n \"Warcraft III attack types × armor types and the armor formula\",\n \"types from the gear: troops and the hero read off their look\",\n \"scouting of a level and type tips introduced island by island\",\n \"one ultimate a run (cards may bring it back) and potions\",\n \"energy → run ticket → reward chest (server-side through currency and lootboxes)\",\n \"2D procedural art (Phaser) with a layered 2D character\",\n \"the run's hero sheet: numbers with the cards' bonuses, card effects\"\n ],\n \"tags\": [\n \"roguelike\",\n \"rpg\",\n \"auto-battler\",\n \"obsidian-knight\",\n \"warcraft\",\n \"cards\",\n \"phaser\",\n \"2d\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"hero-run:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"hero-run:level-cleared@1\",\n \"when\": \"the hero beat a level's boss; island-level is the level just cleared\",\n \"payload\": {\n \"island\": \"number\",\n \"level\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
494
+ "content": "{\n \"id\": \"hero-run\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\", \"inventory\"],\n \"summary\": \"Roguelike runs in the style of Obsidian Knight with attack and armor types: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.\",\n \"description\": \"A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage goes by type: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by a damage table, and armor points by the formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.\",\n \"provides\": [\n \"roguelike runs of levels: waves, camp, fountains, chest, boss\",\n \"rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)\",\n \"attack types × armor types and the armor formula\",\n \"types from the gear: troops and the hero read off their look\",\n \"scouting of a level and type tips introduced island by island\",\n \"one ultimate a run (cards may bring it back) and potions\",\n \"energy → run ticket → reward chest (server-side through currency and lootboxes)\",\n \"2D procedural art (Phaser) with a layered 2D character\",\n \"the run's hero sheet: numbers with the cards' bonuses, card effects\"\n ],\n \"tags\": [\n \"roguelike\",\n \"rpg\",\n \"auto-battler\",\n \"obsidian-knight\",\n \"cards\",\n \"phaser\",\n \"2d\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"hero-run:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"hero-run:level-cleared@1\",\n \"when\": \"the hero beat a level's boss; island-level is the level just cleared\",\n \"payload\": {\n \"island\": \"number\",\n \"level\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
496
495
  }
497
496
  ]
498
497
  }