Infinite Bosses fcf9370 Units: seconds and arena units. These are the gameplay rules shipped with this build. ===== data.js ===== import {KITS,unitStats} from './kits.js'; import {SKILL_KEYS} from './input-bindings.js'; import {GAME_RELEASE} from './version.js'; export const VERSION=GAME_RELEASE.version; export const ARENA=24; export const KEYS=SKILL_KEYS; export const CLASSES={ "warrior": { "name": "Brammo", "role": "Tank", "color": "#ffae43", "icon": "sword", "passive": "Draw Fire makes nearby enemies target you." }, "paladin": { "name": "Solvi", "role": "Tank / Support", "color": "#ffdc5f", "icon": "shield", "passive": "Warm Circle strengthens healing near you." }, "priest": { "name": "Lumo", "role": "Healer", "color": "#fff1c4", "icon": "sun", "passive": "Brightmark boosts an ally for 60 seconds." }, "fire": { "name": "Fizzra", "role": "Ranged / Burst", "color": "#ff634f", "icon": "flame", "passive": "Kindle Link can restore mana when nearby allies cast attacks." }, "ice": { "name": "Nivvi", "role": "Ranged / Support", "color": "#52dfff", "icon": "snow", "passive": "Rime Feast restores health and mana when you defeat summons." }, "ranger": { "name": "Tavi", "role": "Ranged / Physical", "color": "#52ef9a", "icon": "bow", "passive": "Bullseye gives attacks a 7% chance to deal 2.5× damage." }, "rogue": { "name": "Skiv", "role": "Melee / Damage", "color": "#b89bdc", "icon": "dagger", "passive": "Backbite deals more damage from behind." }, "warlock": { "name": "Vellum", "role": "Ranged / Drain", "color": "#c48be9", "icon": "skull", "passive": "Blood Trade exchanges health for mana and damage." }, "deathknight": { "name": "Drenn", "role": "Melee / Sustain", "color": "#93a8dc", "icon": "skull", "passive": "Leech Edge restores health on impact." }, "druid": { "name": "Mossik", "role": "Hybrid / Healer", "color": "#b4cf7c", "icon": "leaf", "passive": "Wildshift switches between ranged healing and bear attacks." } }; for(const [key,hero]of Object.entries(CLASSES))Object.assign(hero,unitStats({class:key}),{skills:KITS[key]}); export const CLASS_ORDER=Object.keys(CLASSES); export const DIFFICULTIES={ "veryeasy": { "name": "Very Easy", "hp": 1, "damage": 1, "mana": 1, "revives": 0, "encounters": 8, "energyOffset": 5, "tier": 0, "flash": 1.2, "desc": "Eight bosses · forgiving mechanics · more extra lives and gold" }, "easy": { "name": "Easy", "hp": 1, "damage": 1, "mana": 1, "revives": 0, "encounters": 9, "energyOffset": 2, "tier": 1, "flash": 1, "desc": "Nine bosses · replenishing extra lives" }, "moderate": { "name": "Moderate", "hp": 1, "damage": 1, "mana": 1, "revives": 0, "encounters": 9, "energyOffset": -3, "tier": 2, "flash": 0.8, "desc": "Normal mechanics · nine bosses · extra lives" }, "normal": { "name": "Normal", "hp": 1, "damage": 1, "mana": 1, "revives": 0, "encounters": 10, "energyOffset": -3, "tier": 2, "flash": 0.8, "desc": "All ten bosses · Resilient armor" }, "hard": { "name": "Hard", "hp": 1, "damage": 1, "mana": 1, "revives": 0, "encounters": 10, "energyOffset": -8, "tier": 3, "flash": 0.1, "desc": "All ten bosses · stronger armor · no extra lives" } }; // Fixed opening, then the Shadow / Ice / Light triad in Classic raids. export const BOSSES=[ { "id": "fire", "name": "Kilnmaw", "title": "The Walking Kiln", "element": "FIRE", "color": "#ff8654", "hp": 19000, "model": "golem", "patterns": [ "eruption", "fireball", "flameburst", "turbine", "flamestrike" ], "energy": 900, "ultimate": "Last Furnace", "guide": "Leave Furnace Mark circles. Avoid the rotating Cinder Wheel. Interrupt Fivefold Furnace. During Last Furnace, follow the light. Save protection for the final wave.", "tip": "Leave Furnace Mark circles." }, { "id": "water", "name": "Undrill", "title": "The Tide Coil", "element": "WATER", "color": "#60cecb", "hp": 21000, "model": "serpent", "patterns": [ "tidal", "geyser", "adds", "tidal", "sweep" ], "energy": 900, "ultimate": "Deep Current", "guide": "Keep moving when Undertow Hunt marks the floor. Interrupt Tide Stitch. Kill the sea summons. Move between Deep Current’s orbiting waves.", "tip": "Keep moving when Undertow Hunt marks the floor." }, { "id": "brute", "name": "Craggo", "title": "The Rockbreaker", "element": "EARTH", "color": "#c8aa7b", "hp": 28000, "model": "golem", "patterns": [ "slam", "charge", "boulder", "adds", "slam" ], "energy": 700, "ultimate": "Crushing Pressure", "guide": "Keep a frontline hero near Craggo to limit his strength against distant targets. Dodge Fault Fan’s sweeping arms. Dodge the Boulder Rush that follows you. Crushing Pressure increases pressure for 17 seconds.", "tip": "Keep a frontline hero near Craggo to limit his strength against distant targets." }, { "id": "thunder", "name": "Razzik", "title": "The Spark Thief", "element": "STORM", "color": "#b4aeff", "hp": 25000, "model": "mage", "patterns": [ "lightning", "cross", "chain", "adds", "lightning" ], "energy": 400, "ultimate": "Storm Grip", "guide": "Spread when an ally receives Jolt Mark. Destroy Storm Anchors during Livewire Form. Razzik can swap positions and cast Stilltime. During Storm Grip, block just before you touch the boss. Contact ends your pull.", "tip": "Spread when an ally receives Jolt Mark." }, { "id": "druid", "name": "Briarok", "title": "The Wild Crown", "element": "NATURE", "color": "#a8d484", "hp": 27000, "model": "treant", "patterns": [ "roots", "adds", "regrowth", "seed", "sweep" ], "energy": 900, "ultimate": "Crownroot", "guide": "Briarok changes between caster, bear, bird and tree forms. Break Glider Crystals across nine sectors to force a landing. Dodge Seedburst impacts. Destroy Crown Pods.", "tip": "Briarok changes between caster, bear, bird and tree forms." }, { "id": "shadow", "name": "Hushmaw", "title": "The Quiet Hunger", "element": "SHADOW", "color": "#c194f5", "hp": 29000, "model": "reaper", "patterns": [ "void", "terror", "adds", "charge", "void" ], "energy": 800, "ultimate": "Hollow Circuit", "guide": "Eight Ritual Wisps weaken Hushmaw for 11 seconds. Break Split Wisps to free allies. Interrupt Dread Pulse. Hollow Circuit uses one steerable orb and eight Hollow Idols. Avoid the orb. Block the harvest blasts.", "tip": "Eight Ritual Wisps weaken Hushmaw for 11 seconds." }, { "id": "ice", "name": "Veylra", "title": "The Glass Winter", "element": "FROST", "color": "#89daf1", "hp": 40000, "model": "queen", "patterns": [ "spikes", "nova", "adds", "frost", "spikes" ], "energy": 1100, "ultimate": "Winter Spiral", "guide": "Break an ally’s Glass Prison before it expires. Interrupt Heat Drain. Use Rime Sparks to damage Veylra. Dodge three rounds of Winter Spiral. Leave the center as ice closes. Blocking costs all remaining mana during this phase.", "tip": "Break an ally’s Glass Prison before it expires." }, { "id": "light", "name": "Halovex", "title": "The Broken Halo", "element": "LIGHT", "color": "#ffe2a2", "hp": 11000, "model": "angel", "patterns": [ "beams", "holy", "regrowth", "adds", "cross" ], "energy": 800, "ultimate": "Halo Lock", "guide": "Halovex can restore full health twice. Defeat all three health bars. Interrupt Halo Mend. Leave the rebirth blast. Dodge Halo Seal. During Halo Lock, marked healing causes damage. Block large wisps and the closing ring.", "tip": "Halovex can restore full health twice." }, { "id": "ancient", "name": "Old Kurr", "title": "The First Root", "element": "ANCIENT", "color": "#99e9db", "hp": 16000, "model": "ancient", "patterns": [ "eruption", "tidal", "spikes", "lightning", "terror" ], "energy": 800, "ultimate": "Root Oath", "guide": "Destroy four Elder Roots. Interrupt Rootmend to weaken the boss. The later melee form summons Kurr’s Echo. Defeat the echo near the boss to weaken him again.", "tip": "Destroy four Elder Roots." }, { "id": "demon", "name": "Vorraka", "title": "The Rift Eater", "element": "DEMONIC", "color": "#f28b9f", "hp": 50000, "model": "demon", "patterns": [ "void", "cross", "adds", "flamestrike", "charge", "spikes" ], "energy": 800, "ultimate": "Riftbrand", "guide": "Reach a Rift Spark before Vorraka to weaken his Rift Shell armor. Dodge the explosion. Defeat his echoes. Vorraka keeps his remaining health when he enters Rift and Ruin forms.", "tip": "Reach a Rift Spark before Vorraka to weaken his Rift Shell armor." } ]; export const ITEMS=[]; // Retained for old saved-run rank item fields. Gear lives in builds.js. ===== kits.js ===== // Stable action IDs keep equipment and rank bonuses independent of labels. const spell=(key,name,type,cd,mana,power,extra={})=>({key,name,type,cd,mana,power,target:'enemy',range:15,castPoint:.14,icon:'gem',...extra}); export const KITS={ fire:[spell('Q','Ember Line','wave',2,21,240,{range:12.5,target:'point',width:2.2,element:'fire',icon:'flame',desc:'Send a wave of fire toward the target point.'}),spell('W','Cinder Tag','dot',6,19,220,{range:10,dot:26,duration:4,element:'fire',icon:'flame',desc:'A fireball burns its target after impact.'}),spell('E','Sky Ember','meteor',26,44,850,{range:80,channel:4,icon:'meteor',desc:'Stand still for four seconds to call down a meteor.'}),spell('R','Hotfoot','speed',23,22,0,{target:'none',duration:15,team:true,speed:1.35,icon:'wing',desc:'Haste nearby allies and restore their mana.'}),spell('F','Castbreak','interrupt',33,35,0,{range:20,instant:true,icon:'silence',desc:'Interrupt a marked boss spell.'})], ice:[spell('Q','Rime Dart','bolt',2,15,205,{range:12.5,drain:5,element:'ice',slow:1,icon:'snow',desc:'Hit an enemy with frost and drain boss energy.'}),spell('W','Rime Ring','aoe',6,18,230,{target:'none',radius:7,element:'ice',icon:'snow',desc:'Release a pulse of frost around you.'}),spell('E','Glaze Guard','shield',4,45,750,{target:'ally',range:15,duration:40,icon:'shield',desc:'Protect an ally with a shield of ice.'}),spell('R','Sleet Skid','slide',12,16,240,{range:13.75,element:'ice',icon:'wing',desc:'Slide toward an enemy. Units can block the slide.'})], warrior:[spell('Q','Draw Fire','taunt',7,0,0,{target:'none',radius:17.5,icon:'roar',desc:'Force nearby enemies to attack you.'}),spell('W','Big Shoulders','spirit',50,0,30,{target:'none',duration:12,bonusHp:4000,icon:'shield',desc:'Temporarily gain 4,000 health and 30 attack damage.'}),spell('E','Bellringer','strike',32,15,300,{range:3.75,drain:100,icon:'sword',desc:'A heavy blow weakens the enemy and drains energy.'}),spell('R','Shoulder Rush','charge',28,15,0,{target:'point',range:18.75,icon:'wing',desc:'Shoulder Rush toward the chosen point.'}),spell('T','Heelquake','aoe',7,20,160,{target:'none',radius:6,icon:'stomp',desc:'Slam the ground and damage nearby enemies.'}),spell('F','Shield Crack','interrupt',33,35,250,{range:5.625,icon:'shield',desc:'Bash a target. Interrupting a spell adds damage.'})], paladin:[spell('Q','Dawn Cut','strike',11,27,350,{range:3.75,drain:40,veil:2,element:'holy',icon:'sword',desc:'A holy strike grants brief invulnerability.'}),spell('W','Safe Hands','protect',30,48,0,{target:'ally',range:13.75,duration:8,icon:'shield',desc:'Reduce damage to an ally. Prevent a fatal hit.'}),spell('E','Mend','heal',1,29,500,{target:'ally',range:17.5,icon:'heal',desc:'Restore a nearby ally’s health.'}),spell('R','Revive','massres',90,125,0,{target:'none',channel:6,icon:'cross',desc:'Sacrifice yourself to return every fallen ally.'}),spell('T','Sun Rush','fury',15,8,40,{target:'none',duration:9,icon:'sun',desc:'Faster attacks restore mana and drain enemy energy.'}),spell('F','Draw Focus','taunt',16,25,0,{range:20,icon:'roar',desc:'Force the chosen enemy to focus on you.'})], priest:[spell('Q','Brightmark','blessing',0,0,0,{target:'ally',range:12.5,duration:60,icon:'sun',desc:'Bless an ally’s attack, armor, and regeneration.'}),spell('W','Care Chain','chainheal',13,45,750,{target:'ally',range:12.5,icon:'heal',desc:'Healing bounces to three additional allies.'}),spell('E','Mend','heal',1,18,475,{target:'ally',range:17.5,icon:'heal',desc:'A quick single-target heal.'}),spell('R','Full Bloom','fullheal',70,55,0,{target:'ally',range:18.75,icon:'sun',desc:'Fully restore health and 100 mana.'}),spell('T','Lumen Bolt','bolt',22,70,500,{range:12.5,drain:48,element:'holy',icon:'bolt',desc:'A holy bolt that drains boss energy.'}),spell('F','Revive','resurrect',15,50,0,{target:'corpse',range:8,channel:5,icon:'cross',desc:'Stand still for five seconds to raise a fallen ally.'})], ranger:[spell('Q','Pinpoint','bolt',16,22,275,{range:25,castPoint:1,icon:'bow',desc:'Aim for one second. Fire an arrow.'}),spell('W','Arrow Fan','multishot',10,24,200,{target:'none',range:17,icon:'bow',desc:'Fire at up to six enemies in range.'}),spell('E','Crackle Flask','dot',16,16,0,{range:15,dot:30,duration:10,weaken:.1,icon:'potion',desc:'A volatile flask causes damage over time.'}),spell('R','Fleetfoot','speed',15,0,0,{target:'none',duration:5,speed:2,icon:'wing',desc:'Double your movement speed briefly.'}),spell('F','Arrowstorm','barrage',45,45,50,{range:17,channel:4,tick:1/3,icon:'bow',desc:'Channel a rapid stream of arrows. Moving cancels it.'})], rogue:[spell('Q','Nerve Cut','strike',5,11,200,{range:3.75,drain:30,icon:'dagger',desc:'A precise strike drains enemy energy.'}),spell('W','Backbite','strike',5,11,450,{range:3.75,backstab:true,icon:'dagger',desc:'Deal 20% more damage from behind.'}),spell('E','Blindside','behind',16,0,0,{target:'none',range:80,icon:'wing',desc:'Appear directly behind the boss.'}),spell('R','Fleetfoot','speed',15,0,0,{target:'none',duration:5,speed:2,icon:'wing',desc:'Double your movement speed briefly.'}),spell('T','Open Mark','weaken',360,0,0,{range:7.5,duration:20,weaken:.5,icon:'target',desc:'Increase damage taken until its bonus damage cap is met.'}),spell('F','Cutting Rush','killer',20,0,90,{target:'none',duration:10,drain:8,icon:'dagger',desc:'Your hits deal extra damage and drain energy.'})], warlock:[spell('Q','Hexburn','dot',11,90,200,{range:10,dot:45,duration:10,drain:45,element:'shadow',icon:'skull',desc:'Curse an enemy with damage over time.'}),spell('W','Pulse Draw','drainchannel',11,75,100,{range:11.875,channel:5,tick:1,drain:16,icon:'skull',desc:'Channel stolen life and mana. Moving cancels it.'}),spell('E','Fangmark','malice',8,16,10,{target:'ally',range:15,duration:17,icon:'skull',desc:'Grant attack damage, speed, and life steal.'}),spell('R','Blood Trade','tainted',19,5,0,{target:'none',duration:15,icon:'skull',desc:'Trade health regeneration for mana and attack power.'}),spell('T','Gloom Flood','darkchannel',50,125,175,{range:80,channel:4,tick:1,icon:'skull',desc:'Sacrifice health to damage the boss each second.'}),spell('F','Castbreak','interrupt',33,35,0,{range:20,instant:true,icon:'silence',desc:'Interrupt a marked boss spell.'})], deathknight:[spell('Q','Tenfold','obliterate',12,28,20,{range:3.75,channel:2,tick:.2,drain:65,icon:'sword',desc:'Your hero lands ten rapid hits and one stronger final hit.'}),spell('W','Leech Edge','drain',6,19,200,{range:3.75,heal:300,drain:45,icon:'sword',desc:'Strike the enemy and restore your health.'}),spell('E','Grave Pop','blast',9,15,275,{target:'point',range:17,radius:3.8,icon:'skull',desc:'Send a spirit that bursts at the chosen point.'}),spell('R','Gravewell','healzone',31,45,250,{target:'none',duration:4,radius:5.5,icon:'heal',desc:'Distribute healing to close allies for four seconds.'}),spell('T','Ally Warp','shadowstep',10,5,0,{target:'point',range:22.5,duration:3.5,icon:'wing',desc:'Warp to the closest ally toward your chosen point.'}),spell('F','Borrowed Bones','reanimate',60,0,0,{target:'reanimate',range:15,icon:'cross',desc:'Corpse: raise a Boneling. Ally: spend 750 health to restore 600 mana to the ally. Ground: spend 450 health to restore 75 mana to yourself. No cooldown on ground casts.'})], druid:[spell('Q','Greenmend','renew',5,32,300,{target:'ally',range:17.5,dot:100,duration:6,icon:'leaf',desc:'Mend an ally immediately. Regeneration continues for six seconds.'}),spell('W','Seed Comet','bolt',15,24,300,{range:15,drain:20,icon:'star',desc:'Strike with a falling star and drain enemy energy.'}),spell('E','Wild Rush','speed',15,5,0,{target:'none',duration:5,speed:2,icon:'roar',desc:'Move at double speed for five seconds.'}),spell('R','Wildshift','transform',.25,0,0,{target:'none',instant:true,icon:'leaf',desc:'Cycle caster → bear → bird → caster. Each form has its own skills.'}),spell('F','Castbreak','interrupt',33,35,0,{range:20,instant:true,icon:'silence',desc:'Interrupt a marked boss spell.'})] }; // Keep primary controls first. Extra actions use the remaining keys. const core={fire:[0,1,4,2],ice:[0,1,2,3],warrior:[4,0,5,1],paladin:[0,2,1,4],priest:[2,1,0,3],ranger:[0,1,2,4],rogue:[0,1,2,5],warlock:[0,1,5,4],deathknight:[0,1,2,3],druid:[0,1,4,3]}; for(const [hero,slots] of Object.entries(core)){const all=KITS[hero],order=[...slots,...all.map((_,i)=>i).filter(i=>!slots.includes(i))];KITS[hero]=order.map((slot,i)=>({...all[slot],key:'QWERDF'[i]}));} KITS.fire[3]={...KITS.fire[3],channel:4,drain:40,desc:'Channel for 4 seconds to summon a meteor. The meteor drains 40 energy.'}; KITS.ranger[0]={...KITS.ranger[0],castPoint:1,desc:'Aim for 1 second. Fire an arrow.'}; export const VEILS={warrior:{name:'Iron Hold',duration:3,cd:12,mana:5},paladin:{name:'Sun Shell',duration:4,cd:18,mana:10},rogue:{name:'Firm Guard',duration:2,cd:8,mana:3},deathknight:{name:'Grave Shell',duration:2,cd:9,mana:5},default:{name:'Pulse Guard',duration:1.5,cd:12,mana:5}}; export const UNIT_STATS={fire:{hp:3000,range:15,attack:25,attackCd:1.7},ice:{hp:3000,range:15,attack:25,attackCd:1.7},warrior:{hp:5000,range:1.875,attack:30,attackCd:3},paladin:{hp:3500,range:1.875,attack:40,attackCd:3},priest:{hp:3000,range:15,attack:25,attackCd:1.7},ranger:{hp:2500,range:16.875,attack:25,attackCd:1.7},rogue:{hp:2500,range:1.5,attack:45,attackCd:1.8},warlock:{hp:3000,range:15,attack:25,attackCd:1.7},deathknight:{hp:3000,range:1.875,attack:75,attackCd:3},druid:{hp:3000,range:15,attack:25,attackCd:2}}; const growth={"A04Z":6,"A04L":50,"A016":6,"A02Y":20,"A0CY":12,"A038":21,"A04I":64,"A050":25,"A0DZ":5,"A05Q":22,"A05T":9,"A0AG":32,"A044":20,"A04S":12,"A046":15,"A047":25,"A00X":6,"A03P":8,"A04N":25,"A0BX":20,"A0BT":15,"A0BU":2,"A0BF":25,"A096":10}; export function skillFor(hero,slot){let s=kitFor(hero)?.[slot];if(!s)return null;if(hero.class==='druid'&&!hero.skeleton&&!hero.reaver&&(hero.form==='bear'||hero.bear)){if(slot===0)s=spell('Q','Paw Hammer','strike',7,15,200,{id:'A096',range:3.75,drain:25,icon:'roar',desc:'A heavy paw strike drains enemy energy.'});if(slot===1)s=spell('W','Clawrake','dot',18,15,130,{id:'A0BZ',range:3.75,dot:55,duration:10,icon:'dagger',desc:'Rake an enemy. Bleeding continues for 10 seconds.'});} if(hero.class==='druid'&&!hero.skeleton&&!hero.reaver){if(slot===4)return null;if(slot===2&&(hero.form==='bear'||hero.bear))s={...KITS.druid[4],key:'E'};if(slot===1&&hero.form==='bird')s={...s,id:'A0DZ',key:'W',name:'Seedburst',type:'vera',target:'point',range:22.5,cd:0,mana:7,power:195,radius:3.75,icon:'star',desc:'A charged projectile explodes on impact. Charges recover over time.'};} const rank=hero.build?.upgrades?.power||0,stats=hero.stats||{};s={...s};s.power+=(growth[s.id]||0)*rank; if(s.id==='A04J')s.dot+=rank;if(s.id==='A00X')s.dot+=rank*.5;if(s.id==='A02S')s.dot+=rank*3;if(s.id==='A0BZ')s.dot+=3.5*rank; if(s.id==='A03N')s.cd=Math.max(1,s.cd-(stats.waveCd||0)); if(s.id==='A09X')s.cd=Math.max(1,s.cd-(stats.killerCd||0)); if(s.id==='A05O'&&hero.class==='paladin'&&stats.holy)s.power+=100; if(s.id==='A04L'&&stats.meteorCast)s.channel=Math.max(.5,s.channel-stats.meteorCast); if(s.id==='A04S')s.channel+=stats.rapidDuration||0; if(s.id==='A07J')s.duration+=stats.spiritDuration||0; return s;} const ids={fire:['A04Z','A02S','A03C','A04L','A057'],ice:['A016','A02Y','A0CJ','A0CY'],warrior:['A038','A032','A03H','A07J','A04I','A0CP'],paladin:['A05Q','A05O','A0D4','A05T','A007','A0AE'],priest:['A054','A03N','A00W','A04Q','A050','A0BK'],ranger:['A0AG','A044','A04J','A04S','A04A'],rogue:['A046','A047','A051','A09X','A045','A0F6'],warlock:['A00X','A03P','A03C','A04N','A02P','A00Z'],deathknight:['A0BU','A0BT','A0BX','A0CV','A0C7','A0BS'],druid:['A0CF','A0BF','A0C1','A094','A098']}; for(const [key,kit]of Object.entries(KITS))kit.forEach((s,i)=>s.id=ids[key][i]); export const PASSIVES={fire:{id:'A04Y',name:'Kindle Link',desc:'Nearby allies can recover mana when casting offensive spells. Casters can also recover mana on attacks.'},ice:{id:'A0B0',name:'Rime Feast',desc:'Kills restore 150 health and 20 mana.'},paladin:{id:'AM04',name:'Warm Circle',desc:'Mend restores 100 extra health nearby, or 400 extra below 20% health.'},ranger:{id:'A041',name:'Bullseye',desc:'Attacks have a 7% chance to deal 2.5× damage.'}}; const action=(id,key,name,type,cd,mana,power,extra={})=>spell(key,name,type,cd,mana,power,{id,...extra}); const unsiphon=action('A0AO','Q','Bone Gift','unsiphon',9,0,200,{target:'ally',range:20,channel:5,tick:1,icon:'heal',desc:'Channel to give an ally 200 health and 8 mana each second. Each tick costs you 200 health.'}); const counter=action('A03C','W','Castbreak','interrupt',33,35,0,{range:20,instant:true,icon:'silence',desc:'Interrupt a marked boss spell.'}); export const FORM_KITS={ magi:[unsiphon,counter,action('A0AT','E','Brittlebone','osteoporosis',7,45,50,{range:15,duration:4,icon:'skull',desc:'Weaken the enemy’s attacks for 4 seconds. Deal 50 damage and drain 5 energy each second.'}),action('A0AR','R','Rib Lock','cage',12,35,0,{range:12.5,duration:3.5,icon:'shield',desc:'Immobilize an enemy for 3.5 seconds.'})], archer:[unsiphon,counter,action('A0AQ','E','Bone Volley','focusfire',12,8,0,{target:'none',duration:7,icon:'bow',desc:'Double attack speed and drain 3 energy per attack for 7 seconds.'})], warrior:[unsiphon,{...KITS.warrior[2],key:'W'},action('A0AP','E','Jointbreaker','boneshatter',8,15,250,{range:3.75,duration:6,icon:'sword',desc:'Deal 250 damage. Drain 30 energy. Slow movement and attacks by 25%.'}),action('A0AM','R','Last Rattle','postmortem',10,8,40,{target:'none',duration:6,icon:'skull',desc:'Attack faster. Hits recover 6 mana, drain 4 energy and can deal 40 extra shadow damage.'})], reaver:[action('A0E5','Q','Rift Grind','decimate',4,5,125,{target:'none',duration:3,radius:6,icon:'skull',desc:'Damage nearby enemies and allies for 3 seconds. Each takes 125 damage per second.'}),action('A0E8','W','Revive','reaverres',55,0,0,{target:'corpse',range:15,icon:'cross',desc:'Sacrifice yourself to revive a fallen ally with 40% health.'})] }; const massSkeleton=action('A0FA','F','Bone Rally','massskeleton',60,0,0,{target:'none',instant:true,icon:'cross',desc:'Raise every fallen ally as a Boneling for 40 seconds.'}); export function kitFor(h){if(h.skeleton?.mass)return [...FORM_KITS[h.skeleton.kind],massSkeleton];return h.reaver?FORM_KITS.reaver:h.skeleton?FORM_KITS[h.skeleton.kind]:KITS[h.class];} export function skillsFor(h){return (kitFor(h)||[]).map((_,slot)=>({slot,skill:skillFor(h,slot)})).filter(x=>x.skill);} export const baseMoveSpeed=h=>h.class==='druid'?6.8:7; export function unitStats(h){const speed=baseMoveSpeed(h);if(h.reaver)return {hp:3000,mana:200,attack:75,range:1.875,attackCd:3,speed};if(h.skeleton)return {hp:4000,mana:200,...({magi:{attack:30,range:16.875,attackCd:1.7},archer:{attack:73,range:16.875,attackCd:3.4},warrior:{attack:55,range:1.875,attackCd:3}}[h.skeleton.kind]),speed};return {...UNIT_STATS[h.class],mana:h.class==='rogue'?20:h.class==='priest'?250:200,speed};} export function defenseFor(h){return h.skeleton||h.reaver?{name:'Grave Shell',duration:1.5,cd:12,mana:0}:VEILS[h.class]||VEILS.default;} ===== builds.js ===== import {ORIGINAL_ITEMS} from './original-content.js'; // Stable item IDs preserve saved builds and shared loot pools. const casters=['fire','ice','priest','warlock','druid','ranger']; const item=(id,name,slot,stats={},extra={})=>({id,name,slot,stats,karma:2,icon:slot==='armor'?'shield':slot==='weapon'?'sword':slot==='tome'?'book':'gem',color:slot==='armor'?'#91b6c8':slot==='weapon'?'#d3b37f':'#b9a2d6',...extra}); export const EQUIPMENT=[ item('standard','Scout Armor','armor',{hp:1500,attack:10},{desc:'+1,500 health · +10 attack'}), item('dexterity','Fleet Armor','armor',{hp:1000,attackSpeed:.1,moveMult:.07},{desc:'+1,000 health · +10% attack speed · +7% move'}), item('fortified','Anchor Armor','armor',{hp:3000,moveMult:-.14},{desc:'+3,000 health · −14% move'}), item('regen','Moss Armor','armor',{hp:500,regen:50},{desc:'+500 health · restore 50 health / sec'}), item('mystical','Rune Armor','armor',{hp:1000,mana:100,veilCd:3},{classes:casters,desc:'+1,000 health · +100 mana · shield cooldown −3 sec'}), item('holy','Dawn Armor','armor',{hp:500,mana:200,holy:1},{classes:['priest','paladin'],desc:'+500 health · +200 mana · stronger healing'}), item('swift','Wind Armor','armor',{hp:500,moveFlat:.75},{desc:'+500 health · +0.75 move speed',active:{name:'Fleetfoot',kind:'sprint',duration:3,cd:15,mana:0},activeDesc:'Double move speed for 3 sec.'}), item('devoted','Oathblade','weapon',{hp:800,attack:10,spiritDuration:5},{karma:1,classes:['warrior'],desc:'+800 health · +10 attack · Big Shoulders lasts 5 sec longer'}), item('brave','Breakblade','weapon',{attack:10},{karma:1,classes:['warrior','paladin','deathknight'],desc:'+10 attack',active:{name:'Energy Tap',kind:'sap',drain:80,cd:30,mana:15},activeDesc:'Drain 80 boss energy.'}), item('ferocity','Bramble Bow','weapon',{attack:30,range:1.25,attackSpeed:.1,rapidDuration:4/3},{classes:['ranger'],icon:'bow',desc:'+30 attack · +10% attack speed · longer range & Arrowstorm'}), item('heart','Heartwood Bow','weapon',{hp:1000,attack:10,regen:30},{classes:['ranger'],icon:'bow',desc:'+1,000 health · +10 attack · restore 30 health / sec'}), item('demise','Splinter Bow','weapon',{attack:15,energyOnHit:8},{classes:['ranger'],icon:'bow',desc:'+15 attack · attacks drain 8 boss energy',active:{name:'Splinter Line',kind:'ruin',power:400,cd:40,mana:27},activeDesc:'Fire a line through enemies for 400 damage.'}), item('light','Dawn Rod','weapon',{hp:500,mana:100,waveCd:2},{classes:['priest'],desc:'+500 health · +100 mana · Care Chain cooldown −2 sec',active:{name:'Dawnmend',kind:'mend',power:500,duration:6,cd:75,mana:25},activeDesc:'Restore 3,000 health to the most injured ally over 6 sec.'}), item('advanced','Spire Rod','weapon',{hp:1000,mana:100,attack:20},{classes:['fire','ice','priest','warlock','druid'],desc:'+1,000 health · +100 mana · +20 attack'}), item('firestaff','Cinder Rod','weapon',{hp:1000,mana:100,meteorCast:1},{classes:['fire'],icon:'flame',desc:'+1,000 health · +100 mana · Sky Ember cast −1 sec'}), item('icestaff','Rime Rod','weapon',{hp:500,mana:200},{classes:['ice'],icon:'snow',desc:'+500 health · +200 mana',active:{name:'Rime Charge',kind:'chargepower',factor:1.36,duration:14,cd:45,mana:0},activeDesc:'+36% spell damage for 14 sec.'}), item('shadowstaff','Hush Rod','weapon',{hp:1500},{classes:['warlock'],icon:'skull',desc:'+1,500 health',active:{name:'Hush Pop',kind:'darkness',power:200,cd:30,mana:0},activeDesc:'Deal 200 damage, plus 250 below 30% boss health.'}), item('berserk','Rattle Knives','weapon',{attack:10,killerCd:3},{classes:['rogue'],icon:'dagger',desc:'+10 attack · Cutting Rush cooldown −3 sec',active:{name:'Knife Toss',kind:'dagger',power:300,cd:20,mana:0},activeDesc:'Throw a dagger for 300 damage.'}), item('cruel','Hook Knives','weapon',{attack:40,manaRegen:.5},{classes:['rogue'],icon:'dagger',desc:'+40 attack · restore 0.5 energy / sec'}), item('spiritdagger','Leech Knives','weapon',{attack:20},{classes:['rogue'],icon:'dagger',desc:'+20 attack',active:{name:'Leech Bite',kind:'lifesteal',power:1500,duration:4,cd:25,mana:0},activeDesc:'Your next attack within 4 sec heals you for 1,500.'}), item('vitality','Heart Bead','charm',{hp:500},{desc:'+500 health',active:{name:'Heart Pulse',kind:'heal',power:1500,cd:30,mana:0},activeDesc:'Restore 1,500 health.'}), item('serenity','Moss Bell','charm',{regen:10},{desc:'Restore 10 health / sec',active:{name:'Moss Ward',kind:'ward',power:175,duration:5,cd:35,mana:0},activeDesc:'A nearby ward heals allies for 175 / sec for 5 sec.'}), item('martyr','Pain Knot','charm',{attack:26,martyr:1},{desc:'+26 attack · +28% attacks above 80% health · take 15% reflected attack damage'}), item('reset','Clock Key','charm',{}, {classes:['fire','ice','warrior','priest','ranger','warlock','deathknight','druid'],desc:'Make your hero skills ready again.',active:{name:'Fresh Clock',kind:'reset',cd:80,mana:0},activeDesc:'Make all hero skills ready again.'}), item('surrender','Quiet Bell','charm',{}, {classes:['fire','ice','warrior','paladin','priest','ranger','warlock','deathknight','druid'],desc:'Delay the boss ultimate.',active:{name:'Energy Bleed',kind:'surrender',drain:45,duration:7,cd:60,mana:45},activeDesc:'Drain 45 boss energy / sec for 7 sec.'}), item('shieldtome','Shell Book','tome',{}, {icon:'book',desc:'Absorb damage for a short time.',active:{name:'Book Shell',kind:'barrier',power:4000,duration:6,cd:35,mana:10},activeDesc:'Absorb 4,000 damage for 6 sec.'}) ]; export const UPGRADES=[ {id:'health',name:'Vitality',karma:1,max:12,icon:'heal',color:'#9fc6a3',desc:'+500 maximum health'}, {id:'mana',name:'Capacity',karma:1,max:Infinity,icon:'potion',color:'#93b9db',desc:'+100 maximum mana',except:['rogue']}, {id:'power',name:'Ability Power',karma:1,max:13,icon:'flame',color:'#c2a0ce',desc:'Improve your hero’s abilities'}, {id:'speed',name:'Swiftness',karma:1,max:5,icon:'wing',color:'#87c6b7',desc:'+0.375 move speed'} ]; export const SLOT_KEYS={armor:'3',weapon:'4',charm:'5',tome:'6',offhand:'7',gem:'8'}; export const SLOTS=['armor','weapon','charm','tome','gem']; export const compatible=(item,h)=>!item.classes||item.classes.includes(h.class); export const equipmentFor=id=>EQUIPMENT.find(i=>i.id===id); export function freshBuild(){return {version:1,karma:1,consumables:{},itemUses:{},upgrades:{health:0,mana:0,power:0,speed:0},equipment:{armor:null,weapon:null,charm:null,tome:null},owned:[],itemCds:{},offers:[],spent:0};} export function ensureBuild(h){if(h.build?.version===1){h.build.consumables||={};h.build.itemUses||={};return h.build;}h.build=freshBuild();return h.build;} export function totals(h){const b=ensureBuild(h),out={hp:b.upgrades.health*500,mana:b.upgrades.mana*100,attack:0,attackSpeed:0,moveFlat:b.upgrades.speed*.375,moveMult:0,regen:0};for(const id of Object.values(b.equipment)){const i=equipmentFor(id);if(i)for(const [key,value]of Object.entries(i.stats))out[key]=(out[key]||0)+value;}return out;} export function lootOffers(h,random=Math.random){const b=ensureBuild(h),pool=EQUIPMENT.filter(i=>!i.legacy&&i.slot!=='gem'&&compatible(i,h)&&!b.owned.includes(i.id));const pick=items=>items.length?items[Math.min(items.length-1,Math.floor(random()*items.length))].id:null;const offers=[pick(pool.filter(i=>i.slot==='weapon')),pick(pool.filter(i=>i.slot==='armor')),pick(pool.filter(i=>['charm','tome'].includes(i.slot)))].filter(Boolean);for(const i of pool)if(offers.length<3&&!offers.includes(i.id))offers.push(i.id);return offers;} export function activeItems(h){const b=ensureBuild(h);return [...SLOTS,...(h.class==='rogue'?['offhand']:[])].map(slot=>({slot,item:equipmentFor(b.equipment[slot])})).filter(e=>e.item?.active);} export function purchase(h,id){const b=ensureBuild(h),upgrade=UPGRADES.find(u=>u.id===id);if(upgrade){if(upgrade.except?.includes(h.class)||b.upgrades[id]>=upgrade.max||b.karma<1)return false;b.karma--;b.spent++;b.upgrades[id]++;return true;}const i=equipmentFor(id);if(!i||!compatible(i,h))return false;if(!b.owned.includes(id)){if(!b.offers.includes(id)||b.karma10)return {letter:'Pathetic',gold:10};return {letter:'F',gold:15};} export function inheritedBuild(old,classKey){const build=JSON.parse(JSON.stringify(ensureBuild(old))),hero={class:classKey};for(const id of [...build.owned]){const item=equipmentFor(id);if(!item||!compatible(item,hero)){build.owned=build.owned.filter(x=>x!==id);if(item){build.karma+=item.karma;build.spent=Math.max(0,build.spent-item.karma);}for(const slot of Object.keys(build.equipment))if(build.equipment[slot]===id)build.equipment[slot]=null;delete build.itemCds[id];}}if(classKey==='rogue'&&build.upgrades.mana){build.karma+=build.upgrades.mana;build.spent=Math.max(0,build.spent-build.upgrades.mana);build.upgrades.mana=0;}if(classKey!=='rogue')delete build.equipment.offhand;build.offers=[];return build;} // Fifteen rotating drops omitted by the earlier compact prototype. EQUIPMENT.push( item('corrupt','Bite Armor','armor',{hp:-500,attack:60},{originalId:'I02E',desc:'+60 attack · −500 health'}), item('deathguard','Laststand Armor','armor',{deathGuard:1},{originalId:'I02V',classes:['druid','fire','ice','ranger','warlock'],desc:'A fatal hit triggers 1.5 sec protection. Costs 35 mana · 45 sec cooldown.'}), item('insight','Watch Armor','armor',{mana:100},{originalId:'I00Y',classes:['deathknight','druid','fire','ice','priest','ranger','warlock','warrior'],desc:'+100 mana',active:{name:'Watchguard',kind:'invulnerable',duration:1.5,cd:12,mana:45,shared:'protection'},activeDesc:'Block damage for 1.5 sec. Shares cooldown with Shell Up.'}), item('blackmagic','Bone Bell','charm',{blackMagic:1},{originalId:'I038',desc:'On death, control a Boneling for 40 sec with Bone Rally.',active:{name:'Bone Burst',kind:'quell',power:300,radius:5,cd:20,mana:0},activeDesc:'Blast an area. Kills summon Boneguards for 5 sec.'}), item('hope','Last Gift','charm',{hp:500},{originalId:'I010',classes:['deathknight','druid','fire','ice','ranger','rogue','warlock','warrior'],desc:'+500 health',active:{name:'Last Gift',kind:'hope',cd:60,mana:0},activeDesc:'Give your life to revive all allies except Lumo and Solvi at full health and mana.'}), item('inferno','Kiln Eye','charm',{}, {originalId:'I02U',classes:['druid','fire','ice','warlock'],desc:'One use per boss, after 30 sec.',active:{name:'Kiln Vortex',kind:'inferno',cd:0,mana:100,once:true,minTime:30,duration:2},activeDesc:'A fire vortex damages enemies and allies. You receive half its damage.'}), item('pig','Pocket Boar','charm',{hp:500},{originalId:'I02X',karma:1,gold:10,desc:'+500 health',active:{name:'Boar Decoy',kind:'pig',duration:6,cd:30,mana:0},activeDesc:'A decoy distracts the boss for 6 sec.'}), item('protection','Shell Token','charm',{}, {originalId:'I02M',classes:['deathknight','druid','fire','ice','priest','ranger','warlock','warrior'],desc:'Use twice. Recharge for 50 sec.',active:{name:'Shell Up',kind:'invulnerable',duration:1.5,cd:12,mana:20,shared:'insight',recharge:50},activeDesc:'Block all damage for 1.5 sec. Shares cooldown with Watchguard.'}), item('sacrifice','Final Spark','charm',{}, {originalId:'I02J',desc:'Your sacrifice does not lower the team’s rank.',active:{name:'Final Spark',kind:'sacrifice',power:1400,cd:0,mana:20},activeDesc:'Lose your life to deal 1,400 magic damage to the boss.'}), item('soulstealer','Hunger Bead','charm',{soulstealer:1},{originalId:'I03T',desc:'Kills restore 40% of the victim’s maximum health. Each kill increases this charm’s magic damage bonus.',active:{name:'Feed the Bead',kind:'soulstealer',duration:14,cd:60,mana:10},activeDesc:'+24% magic damage, plus 12% per kill, up to 120% extra. Death resets kills.'}), item('naturestaff','Moss Rod','weapon',{hp:1000,mana:100,veraCharges:3,veraRecharge:5},{originalId:'I01D',classes:['druid'],icon:'leaf',desc:'+1,000 health · +100 mana · Seedburst has 9 charges and recharges in 10 sec'}), item('depraved','Biteblade','weapon',{attack:20},{originalId:'I029',classes:['warrior','paladin','deathknight'],desc:'+20 attack',active:{name:'Bite Swing',kind:'reckless',power:400,selfCost:250,cd:20,mana:0},activeDesc:'Deal 400 physical damage around your target. Costs 250 health.'}), item('radiance','Sunblade','weapon',{attack:20,manaRegen:2},{originalId:'I02A',classes:['warrior','paladin','deathknight'],desc:'+20 attack · +2 mana / sec',active:{name:'Sun Share',kind:'radiance',cd:30,mana:0},activeDesc:'Your next hit distributes up to 2,250 health and 75 mana to nearby allies.'}), item('metamorphosis','Rift Book','tome',{}, {originalId:'I036',desc:'Become a Rift Beast for 15 sec.',active:{name:'Riftshift',kind:'metamorphosis',cd:45,mana:0},activeDesc:'Rift Grind damages enemies and allies. Revive costs your life.'}), item('oblivion','Sinkhole Book','tome',{}, {originalId:'I01X',desc:'One use per boss, after 30 sec.',active:{name:'Sink Vortex',kind:'oblivion',power:2450,duration:4,cd:0,mana:10,once:true,minTime:30},activeDesc:'A vortex damages everyone. Missed or resisted damage reflects 1.5× to you.'}) ); const originalAliases={I02D:'dexterity',I02C:'fortified',I001:'holy',I02B:'mystical',I00Z:'regen',I02Q:'standard',I02R:'swift',I011:'martyr',I02G:'reset',I02K:'serenity',I02L:'surrender',I00L:'vitality',I02F:'demise',I024:'ferocity',I027:'heart',I01B:'berserk',I028:'cruel',I01A:'spiritdagger',I01C:'firestaff',I01F:'icestaff',I026:'light',I01E:'shadowstaff',I039:'shieldtome'}; for(const i of EQUIPMENT){i.originalId||=Object.entries(originalAliases).find(([,id])=>id===i.id)?.[0];i.legacy=['devoted','brave','advanced'].includes(i.id);} const gems=[['firegem','I015','Cinder Gem','gemfire',8,10,10,250,['ice']],['watergem','I016','Tide Gem','gemwater',8,1,25,25,['fire']],['lightgem','I017','Dawn Gem','gemlight',2,45,1,0,['warlock','deathknight']],['shadowgem','I018','Hush Gem','gemshadow',5,10,5,0,['priest','paladin']]]; for(const [id,originalId,name,kind,charges,cd,mana,power,except]of gems)EQUIPMENT.push(item(id,name,'gem',{}, {originalId,karma:0,icon:'gem',charges,classes:Object.keys({fire:1,ice:1,warrior:1,paladin:1,priest:1,ranger:1,rogue:1,warlock:1,deathknight:1,druid:1}).filter(c=>!except.includes(c)),desc:charges+' charges · boss gem',active:{name,kind,cd,mana,power},activeDesc:({gemfire:'Deal 250 damage in a burst.',gemwater:'Summon a Tide Singer for 10 sec. Hits drain energy.',gemlight:'Restore full health and 30% mana.',gemshadow:'Warp toward an ally and double move speed for 3.5 sec.'})[kind]})); export const SUPPLIES=[ {id:'health_potion',originalId:'I01G',name:'Health Flask',price:5,icon:'heal',desc:'Restore 1,750 health',kind:'health',power:1750,cd:35}, {id:'mana_potion',originalId:'I01H',name:'Mana Flask',price:5,icon:'potion',desc:'Restore 300 mana',kind:'mana',power:300,cd:45}, {id:'essence',originalId:'I012',name:'Spare Spark',price:45,icon:'cross',desc:'Automatically return after 5 sec, with full health and mana',kind:'essence'}, {id:'karma',originalId:'I013',name:'Grit',price:35,icon:'gem',desc:'+1 Grit for this run',kind:'karma'}, {id:'random_powerup',originalId:'I014',name:'Mystery Boost',price:10,icon:'star',desc:'A random boost activates at the next boss',kind:'powerup'}, {id:'greed',originalId:'I04A',name:'Gold Sweep',price:10,icon:'coin',desc:'Collect all gold lying in the arena',kind:'greed'}, {id:'token',originalId:'I01P',name:'Survivor Token',price:10,icon:'coin',desc:'Survive without losing a life: +22 gold. Otherwise −5.',kind:'token'}, ...[['remedy','I008','Rescue Flask',15,'Revive an ally with 40% health'],['super_health','I02N','Large Health Flask',5,'Restore 3,000 health'],['super_mana','I02O','Large Mana Flask',5,'Restore 500 mana'],['savior','I02P','Guard Flask',5,'Block damage for 2 sec'],['spirits','I01U','Root Wisps',35,'Two charges. Summon 3 healing spirits for 12 sec'],['illumination','I01J','Squad Recall',45,'Spend all mana to revive allies at 60% health'],['energysap','I01L','Energy Tap',0,'Drain 400 energy above 20% boss health'],['manacontrol','I01R','Mana Burn',35,'Burn your mana to deal 2.2 damage per point'],['manatide','I04C','Mana Surge',15,'Restore 600 mana'],['replenishment','I01N','Full Flask',15,'Restore 4,500 health and 300 mana'],['omega','I01K','Wild Vortex',60,'Burn all mana for an unpredictable vortex. Hurts allies'],['natureswrath','I044',"Thornwake",40,'A dragon projectile deals 1,000–3,500 damage'],['infinityscroll','I03Z','Arena Rupture',40,'After a delay, deal 3,150 damage across the arena']].map(([id,originalId,name,price,desc])=>({id,originalId,name,price,desc,kind:id,icon:['remedy','illumination'].includes(id)?'cross':'book',rare:['I044','I03Z'].includes(originalId),lootOnly:['I008','I02N','I02O','I02P'].includes(originalId)})) ]; export const supplyFor=id=>SUPPLIES.find(i=>i.id===id); export function originalPools(){return Object.fromEntries(['Gp','Ge','he','Id'].map(pool=>[pool,ORIGINAL_ITEMS.filter(i=>i.pool===pool).map(i=>equipmentFor(i.id)?.id||EQUIPMENT.find(e=>e.originalId===i.id)?.id||SUPPLIES.find(s=>s.originalId===i.id)?.id).filter(Boolean)]));} export function freshLoot(){return {remaining:originalPools(),stock:[],rareChance:0,rareStock:[],gems:[],claimed:[]};} export function rollLoot(g,rank){const loot=g.s.loot||=freshLoot(),pick=pool=>{const list=loot.remaining[pool];return list?.length?list.splice(Math.floor(g.rand()*list.length),1)[0]:null;},averageDeaths=g.s.completed.reduce((n,c)=>n+(c.deaths||0),0)/Math.max(1,g.s.completed.length),fallback=['F','Pathetic'].includes(rank)&&averageDeaths>=8&&g.s.completed.length>=2;const rare=g.rand()*100<(['S','A','B'].includes(rank)?loot.rareChance:fallback?25:0)?pick('Id'):null;loot.rareStock=rare?[rare]:[];if(rare)loot.rareChance=0;loot.rareChance+=({S:15,A:10,B:5})[rank]||0;loot.stock=[pick('Gp'),pick('Gp'),pick('he'),pick('Ge')].filter(Boolean);if(g.s.completed.length>=6&&g.s.bossIndex!==7){const extra=pick('he');if(extra)loot.stock.push(extra);}if(g.s.completed.length===9){const extra=pick('Ge');if(extra)loot.stock.push(extra);}const gem=({0:'firegem',1:'watergem',5:'shadowgem',7:'lightgem'})[g.s.bossIndex];if(gem)loot.gems.push(gem);for(const h of g.s.heroes)h.build.offers=[...loot.stock,...loot.gems];return loot;} SUPPLIES.find(i=>i.id==='energysap').karma=1; ===== journey.js ===== // Training is separate. Every numbered level is a real boss fight. export const LEVELS=[ {id:1,name:'First flame',bosses:[0],squad:2,difficulty:'easy',rank:1,minPower:1,skills:2,block:true,hp:.8,damage:.9,reward:24,bonus:46,hint:'Dodge the flames. Break the guard together.',feature:'A real fight with one ally',tactics:true,revives:1,energy:1,pace:1.05}, {id:2,name:'Hold the line',bosses:[1],squad:3,difficulty:'easy',rank:2,minPower:2,skills:2,block:true,hp:.7,damage:.8,reward:30,bonus:45,hint:'Watch the strike. Use your second skill.',feature:'Three heroes and your second skill',tactics:true,revives:1,energy:1.1,pace:1}, {id:3,name:'Divide and strike',bosses:[2],squad:3,difficulty:'moderate',rank:2,minPower:2,skills:2,block:true,hp:.8,damage:.95,reward:40,bonus:50,hint:'Spread out. Strike during an opening.',feature:'Full boss mechanics and spread calls',tactics:true,revives:1,energy:1.15,pace:.95}, {id:4,karma:2,name:'First raid',bosses:[0,1,2],squad:4,difficulty:'moderate',rank:3,minPower:3,skills:2,block:true,hp:1.05,damage:1.35,reward:100,bonus:80,hint:'Choose an upgrade between bosses.',feature:'Build your hero between bosses',tactics:true,revives:1,energy:1.25,pace:.9}, {id:5,karma:4,name:'Storm watch',bosses:[3],squad:4,difficulty:'moderate',rank:4,minPower:4,skills:3,block:true,hp:1.05,damage:1.40,reward:90,bonus:90,hint:'Your third skill is ready.',feature:'Your third skill',tactics:true,revives:1,energy:1.3,pace:.9}, {id:6,karma:4,name:'Wild roots',bosses:[4],squad:5,difficulty:'moderate',rank:5,minPower:5,skills:3,block:true,hp:1.15,damage:1.45,reward:105,bonus:100,hint:'Use Squad to direct your allies.',feature:'Command your AI allies',tactics:true,revives:1,energy:1.3,pace:.88}, {id:7,karma:5,name:'Into shadow',bosses:[5],squad:6,difficulty:'normal',rank:6,minPower:6,skills:4,block:true,hp:1.05,damage:1.50,reward:115,bonus:110,hint:'Your ultimate skill is ready.',feature:'Your ultimate skill',tactics:true,revives:1,energy:1.35,pace:.88}, {id:8,karma:5,name:'Rising tide',bosses:[6],squad:6,difficulty:'normal',rank:7,minPower:7,skills:4,block:true,hp:1.10,damage:1.55,reward:130,bonus:120,hint:'Stay near allies who need help.',feature:'A larger squad',tactics:true,revives:1,energy:1.35,pace:.86}, {id:9,karma:8,name:'Fallen light',bosses:[7],squad:7,difficulty:'hard',rank:8,minPower:8,skills:6,block:true,hp:.95,damage:1.35,reward:150,bonus:135,hint:'Spread out when the boss marks your squad.',feature:'Your full skill set',tactics:true,revives:2,energy:1.25,pace:.95}, {id:10,karma:7,name:'Ancient pact',bosses:[8],squad:8,difficulty:'hard',rank:9,minPower:9,skills:6,block:true,hp:1.10,damage:1.60,reward:175,bonus:150,hint:'Rally your allies to a safe position.',feature:'Coordinate through boss phases',tactics:true,revives:2,energy:1.4,pace:.85}, {id:11,karma:8,name:'Vorraka',bosses:[9],squad:8,difficulty:'hard',rank:12,minPower:12,skills:6,block:true,hp:1.15,damage:1.65,reward:210,bonus:180,hint:'Deny energy. Protect your squad.',feature:'Eight heroes against Vorraka',tactics:true,revives:2,energy:1.45,pace:.85}, {id:12,karma:5,name:'The Long Raid',bosses:[0,1,2,3,4,5,6,7,8,9],squad:8,difficulty:'hard',rank:12,minPower:12,skills:6,block:true,hp:1.1,damage:1.5,reward:700,bonus:300,hint:'Build for the next boss. Lead your squad.',feature:'All ten bosses in one run',tactics:true,revives:2,energy:1.3,pace:.9} ]; export const ONBOARDING_BOSS={id:'training',name:'Training sentinel',title:'Practice with your ally',color:'#79c4bd'}; export const ONBOARDING={id:0,name:'Training',bosses:[0],squad:2,difficulty:'veryeasy',rank:1,minPower:1,skills:1,block:true,hp:1,damage:.4,simple:true,tactics:true,revives:2}; export const MAX_POWER=12; export const POWER_COSTS=[0,40,60,85,110,140,175,215,260,310,365,425]; export const levelFor=id=>LEVELS.find(l=>l.id===Number(id))||null; export const powerLevel=value=>Math.max(1,Math.min(MAX_POWER,Math.floor(Number(value)||1))); export const powerCost=p=>p>=MAX_POWER?0:POWER_COSTS[powerLevel(p)]; export const powerStats=p=>({health:1+(powerLevel(p)-1)*.15,damage:1+(powerLevel(p)-1)*.10,healing:1+(powerLevel(p)-1)*.08}); export function nextLevel(p){for(const level of LEVELS)if(!(p.clearedLevels||[]).includes(level.id))return level.id;return LEVELS.length;} export const canPlayLevel=(p,id)=>!!levelFor(id)&&id<=nextLevel(p)&&powerLevel(p.power)>=(levelFor(id).minPower||1); export function powerToLevel(p,id){const required=levelFor(id)?.minPower||1;let cost=0;for(let n=powerLevel(p.power);n=required};} export function farmLevel(p){return LEVELS.filter(l=>(p.clearedLevels||[]).includes(l.id)&&canPlayLevel(p,l.id)).at(-1)||LEVELS[0];} export const squadForDifficulty=d=>({veryeasy:3,easy:4,moderate:5,normal:6,hard:8}[d]||4); export const stageFor=s=>s.match?.kind==='onboarding'?ONBOARDING:s.match?.kind==='campaign'?levelFor(s.match.stage):null; export function skillAllowed(s,h,slot){const level=stageFor(s);return !level||h.ai||slot=.25?Math.floor(Math.min(.95,value)*20+1e-6)/20:0;} export function engaged(e){return !!e&&Number.isInteger(e.casts)&&e.casts>=1&&e.casts<=1000&&Number.isFinite(e.travel)&&e.travel>=0&&e.travel<=10000&&Number.isInteger(e.tactics)&&e.tactics>=0&&e.tactics<=1000&&(e.casts>=2||e.travel>=3||e.tactics>=1);} export function campaignPayout(receipt,profile){ const level=levelFor(receipt.stage),legacy=['veryeasy','veryeasy','veryeasy','easy','easy','easy','moderate','moderate','normal','normal','hard','hard']; if(!Number.isInteger(receipt.stage)||!level||receipt.target!==level.bosses.length||![level.difficulty,...(receipt.balance===undefined?[legacy[level.id-1]]:receipt.balance===2&&level.id<=3?['veryeasy']:[])].includes(receipt.difficulty)||receipt.cleared<0||receipt.cleared>level.bosses.length)return 0; if(receipt.result==='victory')return receipt.cleared===level.bosses.length?level.reward+(!(profile.clearedLevels||[]).includes(level.id)&&canPlayLevel(profile,level.id)?level.bonus:0):0; if(receipt.balance!==2)return receipt.cleared>0?Math.floor(level.reward*receipt.cleared/level.bosses.length*.5):0; if(!Array.isArray(receipt.progress)||receipt.progress.length!==level.bosses.length||receipt.cleared>=receipt.target||receipt.progress.some((p,i)=>!Number.isFinite(p)||p<0||p>1||i=1||i>receipt.cleared&&p!==0))return 0; if(!engaged(receipt.effort)||receipt.seconds<15)return 0; const milestone=progressMilestone(receipt.progress[receipt.cleared]||0); return Math.floor(level.reward*.7*(receipt.cleared+milestone)/level.bosses.length); } ===== orders.js ===== import {countStat} from './player-records.js'; import {stationaryTarget} from './portrait.js'; import {Pathfinder,distance,turn,angleDifference} from './pathing.js'; import {KITS,VEILS,UNIT_STATS,skillFor,kitFor,unitStats,baseMoveSpeed} from './kits.js'; import {phoenixCast,phoenixAttack,tickHeroSystems,reanimate,sanctuaryHeal,splitHealing,unitAttack,attackTiming,killHero,raiseSkeleton} from './hero-systems.js'; import {weakenBoss} from './boss-systems.js'; const hostileTypes=new Set(['strike','bolt','dot','drain','weaken','interrupt','slide','barrage','drainchannel','darkchannel','obliterate','meteor']); export class Orders { constructor(game,radius,obstacles){this.g=game;this.nav=new Pathfinder(radius,obstacles);} units(){const s=this.g.s;return [...s.heroes,...s.adds,...(s.boss?[s.boss]:[])].filter(x=>!x.dead);} entity(id){return this.g.getHero(id)||this.g.getEnemy(id);} init(h){h.radius=.4;h.turnRate=14;h.steer=null;h.order={kind:h.stationaryCombat?'hold':'stop',x:h.x,z:h.z};h.queue=[];h.path=[];h.pathGoal=null;h.swing=null;h.casting=null;h.buffs={};h.cd=kitFor(h).map(()=>0);h.formCds={};h.guard={x:h.x,z:h.z};h.attackTimer=0;h.moving=false;h.bear=false;h.form='caster';h.auto=true;h.acquire=Math.max(6,unitAttack(h).range+(h.stats?.range||0));h.status='Ready';} interrupt(h){h.steer=null;h.swing=null;h.rush=null;if(h.casting){this.g.fx('cancel',h.x,h.z,'#ffffff',1);h.casting=null;}h.channel=null;h.path=[];h.pathGoal=null;h.moving=false;} issue(h,order,queued=false){if(h.dead)return false;if(queued&&h.order&&!['stop','hold'].includes(h.order.kind)){if(h.queue.length>=16)return false;h.queue.push(order);return true;}h.queue=[];this.activate(h,order);return true;} activate(h,order){this.interrupt(h);h.order={...order};h.tx=order.x??h.x;h.tz=order.z??h.z;if(['stop','hold'].includes(order.kind)){h.guard={x:h.x,z:h.z};h.target=null;}else if(order.target)h.target=order.target;h.status=({move:'Moving',attack:'Attacking',attackmove:'Attack move',follow:'Following',stop:'Stopped',hold:'Holding',patrol:'Patrolling',cast:'Casting'})[order.kind]||'Ready';} done(h){if(h.queue.length){this.activate(h,h.queue.shift());return;}const hold=h.stationaryCombat||['hold','move','steer'].includes(h.order?.kind);h.order={kind:hold?'hold':'stop',x:h.x,z:h.z};h.guard={x:h.x,z:h.z};h.path=[];h.pathGoal=null;h.moving=false;h.status=hold?'Holding':'Ready';} command(h,cmd){if(cmd.type==='move'||cmd.type==='attackmove'||cmd.type==='patrol'){if(!Number.isFinite(cmd.x)||!Number.isFinite(cmd.z))return false;const p=this.nav.nearest({x:cmd.x,z:cmd.z},h.radius);if(!p)return false;return this.issue(h,{kind:cmd.type,x:p.x,z:p.z,origin:{x:h.x,z:h.z}},!!cmd.queue);} if(cmd.type==='stop'||cmd.type==='hold')return this.issue(h,{kind:cmd.type,x:h.x,z:h.z},!!cmd.queue); if(cmd.type==='target'||cmd.type==='attack'){const e=this.g.getEnemy(cmd.id);if(!e||e.dead)return false;return this.issue(h,{kind:'attack',target:e.id},!!cmd.queue);} if(cmd.type==='follow'){const e=this.g.getHero(cmd.id);if(!e||e.dead||e===h)return false;return this.issue(h,{kind:'follow',target:e.id},!!cmd.queue);} if(cmd.type==='skill'){const s=skillFor(h,cmd.slot);if(!s||h.cd[cmd.slot]>0||h.mana0)return false;let target=null;if(s.target==='enemy'){target=this.g.getEnemy(cmd.id)||this.g.getEnemy(h.target);if(!target||target.dead)return false;}if(s.target==='ally'){target=this.g.getHero(cmd.id)||this.g.nearestAlly(cmd);if(!target||target.dead)return false;}if(s.target==='reanimate'){target=this.g.getHero(cmd.id);if(target&&!target.dead&&h.hp<=750||!target&&h.hp<=450)return false;}if(s.target==='corpse'){target=this.g.getHero(cmd.id)||this.g.s.heroes.filter(a=>a.dead||a.skeleton).sort((a,b)=>distance(a,cmd)-distance(b,cmd))[0];if(!target?.dead&&!target?.skeleton)return false;} const p={x:Number.isFinite(cmd.x)?cmd.x:(target?.x??h.x),z:Number.isFinite(cmd.z)?cmd.z:(target?.z??h.z)}; if(s.type==='vera'&&(h.vera?.charges??6)<=0)return false;if(['resurrect','massres','reaverres'].includes(s.type)&&(h.resLock||0)>0)return false;if(s.type==='protect'&&target?.class==='paladin'&&target!==h)return false; if(s.type==='reanimate'&&(!target||this.inRange(h,target,s.range)))return this.beginCast(h,cmd.slot,p,target); if(s.instant&&s.target==='none')return this.beginCast(h,cmd.slot,p,null); return this.issue(h,{kind:'cast',slot:cmd.slot,x:p.x,z:p.z,target:target?.id,inputSeq:cmd.inputSeq},!!cmd.queue);} return false; } inRange(h,e,range){return distance(h,e)0)n*=.5;if(h.buffs.haste)n*=h.buffs.haste.factor;if(h.buffs.spirit)n*=.91;if(h.buffs.malice)n*=1.3;return Math.min(13.05,n);} move(h,p,dt){h.tx=p.x;h.tz=p.z;return this.nav.move(h,p,this.speed(h),dt,this.units());} acquire(h,range=7){const enemies=[this.g.s.boss,...this.g.s.adds].filter(e=>e&&!e.dead&&distance(h,e)distance(h,a)-distance(h,b))[0];} tick(h,dt){tickHeroSystems(this.g,h,dt);if(h.dead)return;h.attackTimer=Math.max(0,h.attackTimer-dt);h.attackAnim=Math.max(0,(h.attackAnim||0)-dt);h.hitFlash=Math.max(0,(h.hitFlash||0)-dt);h.moving=false;for(const [form,cds]of Object.entries(h.formCds||{}))if(form!==h.form)for(let i=0;i0||h.gatePaused){h.swing=null;h.casting=null;return;} if(h.rush){const r=h.rush,enemy=r.target?this.entity(r.target):null;const reached=this.nav.move(h,r,26,dt,this.units());r.left-=dt;h.status='Charging';if(reached||r.left<=0){if(enemy&&!enemy.dead&&distance(h,enemy)0){this.g.s.revives--;this.g.revive(h,corpse);}h.channel=null;this.done(h);}return;} if(h.casting){this.tickCast(h,dt);return;} if(h.steer&&h.steer.until>this.g.s.time){this.nav.steer(h,h.steer,this.speed(h),dt,this.units());return;}else if(h.steer){h.steer=null;this.done(h);} const o=h.order||{kind:'stop'};let enemy=null; if(o.kind==='rescue'){const ally=this.g.getHero(o.target);if(!ally?.dead||this.g.s.revives<=0){this.done(h);return;}if(distance(h,ally)<3.6)this.g.beginRevive(h);else this.move(h,ally,dt);return;} if(o.kind==='move'){if(this.move(h,o,dt))this.done(h);return;} if(o.kind==='cast'){const s=skillFor(h,o.slot);if(!s||h.cd[o.slot]>0||h.mana.18)return;this.beginCast(h,o.slot,p,target);return;} if(o.kind==='attack'){enemy=this.g.getEnemy(o.target);if(!enemy||enemy.dead){this.done(h);return;}} if(o.kind==='attackmove'||o.kind==='patrol'){enemy=this.g.getEnemy(h.target);if(!enemy||enemy.dead||distance(h,enemy)>13)enemy=this.acquire(h,h.acquire);if(!enemy){h.target=null;if(this.move(h,o,dt)){if(o.kind==='patrol'){const q=o.origin;o.origin={x:o.x,z:o.z};o.x=q.x;o.z=q.z;h.pathGoal=null;}else this.done(h);}return;}} if(o.kind==='follow'){const friend=this.g.getHero(o.target);if(!friend||friend.dead){this.done(h);return;}enemy=this.acquire(h,5);if(!enemy){if(distance(h,friend)>2.4)this.move(h,friend,dt);return;}} if(o.kind==='stop'){enemy=this.g.getEnemy(h.target);if(!enemy||enemy.dead||distance(enemy,h.guard||h)>h.acquire)enemy=this.acquire(h,h.acquire);if(!enemy){if(h.guard&&distance(h,h.guard)>1)this.move(h,h.guard,dt);if(h.queue.length)this.done(h);return;}} if(o.kind==='hold'){enemy=h.stationaryCombat?stationaryTarget(this.g,h,h.target):this.acquire(h,(h.bear?1.8:unitAttack(h).range+(h.stats?.range||0))+h.radius);if(!enemy){if(h.queue.length)this.done(h);return;}} if(enemy&&h.auto&&!(h.disarm>0)){h.target=enemy.id;this.attack(h,enemy,dt,o.kind==='hold');} } attack(h,e,dt,hold=false){const stats=unitAttack(h),range=h.bear?1.875:stats.range+(h.stats?.range||0),trueRange=range+h.radius+(e.radius||.4),d=distance(h,e),heading=Math.atan2(e.x-h.x,e.z-h.z); if(h.swing){turn(h,heading,dt);h.swing.left-=dt;if(h.swing.left<=0){if(distance(h,e)<=trueRange+.05)this.releaseAttack(h,e);h.swing=null;}return;} if(d>trueRange+(h.attackTimer>0?.35:0)){if(!hold)this.move(h,e,dt);return;}if(turn(h,heading,dt*(h.ai?1:3))>.18)return;if(h.attackTimer>0)return;if(d>trueRange)return; const timing=attackTiming(h,stats);h.attackTimer=timing.cycle;h.swing={id:(h.attackRelease||0)+1,target:e.id,left:timing.windup,total:timing.windup};h.attackAnim=.8;h.status='Attacking'; } releaseAttack(h,e){h.attackRelease=(h.attackRelease||0)+1;h.attackReleasedAt=this.g.s.time;h.attackCount++;countStat(h,'attacks');if(h.buffs.itemLifesteal){this.g.heal(h,h,h.buffs.itemLifesteal.power);delete h.buffs.itemLifesteal;}let critical=false,amount=unitAttack(h).attack+(h.stats?.attack||0)+(h.buffs.spirit?30:0)+(h.buffs.malice?10:0)+(h.buffs.fury?(h.buffs.fury.power||40):0)+0;if(h.skeleton?.kind==='archer'&&this.g.rand()<.15){critical=true;amount*=1.5;e.dots.push({source:h.id,power:15,left:10,tick:1});}if(h.buffs.rage)amount*=1;if(h.buffs.blessing)amount*=1.2;if(h.buffs.tainted)amount*=1.5;if(h.class==='ranger'&&!h.skeleton&&!h.reaver&&this.g.rand()<.07){critical=true;amount*=2.5;}if(h.buffs.fury){h.mana=Math.min(h.maxMana,h.mana+15);this.g.drainEnergy(this.g.s.boss,5);}phoenixAttack(this.g,h);if(h.buffs.focusfire)this.g.drainEnergy(e,3);if(h.buffs.postmortem){h.mana=Math.min(h.maxMana,h.mana+6);this.g.drainEnergy(e,4);if(this.g.rand()<.5)amount+=40;}if(h.buffs.radiance){const allies=this.g.alive().filter(a=>distance(h,a)<12.5);splitHealing(this.g,h,allies,2250,750);for(const a of allies)a.mana=Math.min(a.maxMana,a.mana+Math.min(25,75/allies.length));delete h.buffs.radiance;} if((h.bear?1.875:unitAttack(h).range+(h.stats?.range||0))>5)this.projectile(h,e,amount,{speed:24,color:this.g.color(h),physical:true,critical,shotKey:'attack-'+h.attackRelease});else{this.g.hurtEnemy(h,e,amount,false,null,{critical});this.g.fx('slash',e.x,e.z,this.g.color(h),1);if(h.buffs.malice)this.g.heal(h,h,amount);} } projectile(h,target,damage,{speed=25,color,physical=false,dot=0,duration=0,drain=0,element=null,slow=0,spell=null,critical=false,shotKey=null}={}){this.g.s.projectiles.push({id:this.g.uid(),owner:h.id,target:target.id,x:h.x,z:h.z,y:1.6,damage,speed,color:color||this.g.color(h),physical,dot,duration,drain,element,slow,spell:spell||(!physical?skillFor(h,h.casting?.slot)?.name:null),castId:physical?null:h.casting?.id,age:0,critical,shotKey:shotKey||(h.casting?.inputSeq?'cast-'+h.casting.inputSeq+'-'+(h.casting.shotSeq=(h.casting.shotSeq||0)+1):null)});} tickProjectiles(dt){const s=this.g.s;for(const p of s.projectiles){p.age+=dt;const target=this.entity(p.target);if(!target||target.dead){p.dead=true;continue;}const d=distance(p,target),step=p.speed*dt;if(d!p.dead&&p.age<6);} beginCast(h,slot,p,target){const s=skillFor(h,slot);if(!s||h.cd[slot]>0||h.mana0)return false;if(['massres','resurrect','reaverres'].includes(s.type)&&h.veil>0){this.done(h);return false;}h.swing=null;h.moving=false;h.mana-=s.mana;h.cd[slot]=s.type==='reanimate'&&!target?0:s.cd;if(s.type==='vera'){h.vera.charges--;h.vera.timer||=0;}phoenixCast(this.g,h,s);h.path=[];h.pathGoal=null;h.casting={id:this.g.announceCast(h,slot,s),slot,left:s.channel||s.castPoint||.3,total:s.channel||s.castPoint||.3,x:p.x,z:p.z,target:target?.id,inputSeq:h.order?.inputSeq||h.inputSeq,tick:s.tick||1,channel:!!s.channel};h.castFlash=.5;h.status=s.name;if(s.instant){this.resolve(h,s,h.casting);h.casting=null;this.done(h);}return true;} tickCast(h,dt){const c=h.casting,s=skillFor(h,c.slot);c.left-=dt;h.castFlash=.2;const target=this.entity(c.target)||this.g.s.boss; if(['barrage','drainchannel','darkchannel','obliterate','unsiphon'].includes(s.type)){if(!target||target.dead||!this.inRange(h,target,s.range+2)){h.casting=null;this.done(h);return;}c.tick-=dt;if(c.tick<=1e-8){c.tick+=s.tick;let amount=s.power;if(s.type==='darkchannel'){amount+=target.hp/target.maxHp<=.3?200:0;this.g.hurtHero(h,125+this.g.rand()*50,true);amount*=target.darknessResist>0?.4:1;}if(s.type==='unsiphon'){this.g.heal(h,target,Math.min(amount,h.hp));target.mana=Math.min(target.maxMana,target.mana+8);this.g.hurtHero(h,amount,true);}else this.projectile(h,target,amount,{speed:30,physical:s.type==='obliterate',drain:s.type==='obliterate'?6.5:s.type==='darkchannel'?35:s.drain||0,element:s.element});if(s.type==='drainchannel'){this.g.heal(h,h,amount*1.2);h.mana=Math.min(h.maxMana,h.mana+16);}}} if(c.left<=0){this.g.resolveCast(c.id);if(s.type==='obliterate'&&target&&!target.dead){const id=this.g.s.bossIndex;this.g.hurtEnemy(h,target,(400+(h.build?.upgrades.power||0)*16+([4,7].includes(id)?450:0))*(id===5?.3:1),true);}if(s.type==='darkchannel')target.darknessResist=15;if(!['barrage','drainchannel','darkchannel','obliterate','unsiphon'].includes(s.type))this.resolve(h,s,c);h.casting=null;this.done(h);}} tickBuffs(h,dt){for(const [name,b]of Object.entries(h.buffs)){b.left-=dt;if(name==='blessing')this.g.heal(h,h,(b.regen||20)*dt);if(name==='mend')this.g.heal(this.g.getHero(b.source)||h,h,b.power*dt,b.ritualSensitive);if(name==='surrender'){this.g.drainEnergy(this.g.s.boss,b.drain*dt);h.mana=0;}if(name==='renew'){this.g.heal(this.g.getHero(b.source)||h,h,100*dt,true);h.mana=Math.min(h.maxMana,h.mana+(h.class==='rogue'?1:3.5)*dt);}if(b.left<=0){if(name==='surrender')h.mana=Math.min(h.maxMana,b.mana);if(name==='spirit'){h.maxHp-=4000;h.hp=Math.min(h.hp,h.maxHp);}if(name==='icearmor')h.shield=0;if(name==='itemShield')h.shield=Math.max(0,h.shield-b.power);delete h.buffs[name];}}} buff(h,name,left,extra={}){h.buffs[name]={left,...extra};} resolve(h,s,c){const g=this.g,b=g.s.boss,p={x:c.x,z:c.z},enemy=g.getEnemy(c.target)||b,ally=g.getHero(c.target)||h;g.resolveCast(c.id);if(!['bolt','dot','multishot','wave','slide','charge'].includes(s.type))g.resolveCast(c.id,true); if(['bolt','dot'].includes(s.type)){if(s.weaken&&enemy){enemy.magicVulnerable=s.duration;}if(enemy&&!enemy.dead)this.projectile(h,enemy,s.power,{element:s.element,dot:s.dot||0,duration:s.duration||0,drain:s.drain||0,slow:s.slow||0});} if(s.type==='meteor'){if(!b.dead){g.hurtEnemy(h,b,s.power*(b.meteorResist>0?.4:1),true,null,{spell:s.name});b.meteorResist=15;g.drainEnergy(b,s.drain||40);g.fx('meteor',b.x,b.z,'#ffb546',5);}} if(s.type==='wave'){const dx=p.x-h.x,dz=p.z-h.z,d=Math.hypot(dx,dz)||1;g.hazard('wave',{x:h.x,z:h.z,dx:dx/d,dz:dz/d,width:s.width,range:s.range,speed:18,owner:h.id,friendly:true,castId:c.id,power:s.power,element:s.element,color:g.color(h),label:''},0,0,s.range/18+.1);} if(s.type==='strike'||s.type==='drain'){if(enemy&&!enemy.dead){let power=s.power;if(s.id==='A096'&&enemy.hp/enemy.maxHp<=.2)power+=145;if(s.id==='A04I'){enemy.attackWeaken=8;enemy.attackWeakenFactor=.25;}if(s.id==='A05Q'){enemy.energyDots||=[];enemy.energyDots.push({left:11,rate:4});if(h.buffs.fury){power+=h.buffs.fury.power;h.mana=Math.min(h.maxMana,h.mana+15);g.drainEnergy(enemy,5);}}if(s.backstab){const behind=Math.abs(angleDifference(Math.atan2(h.x-enemy.x,h.z-enemy.z),enemy.facing||0))>Math.PI*.6;if(behind)power*=1.2;}g.hurtEnemy(h,enemy,power,true,s.element,{spell:s.name});if(s.drain)g.drainEnergy(enemy,s.drain);if(s.heal)g.heal(h,h,s.heal);if(s.veil)h.veil=s.veil;g.fx('slash',enemy.x,enemy.z,g.color(h),2);}} if(s.type==='interrupt'){if(b.casting?.interruptible){g.log(h.name+' interrupted '+b.casting.name+'.','interrupt');g.callout('Interrupted',1.2);if(b.casting.action==='revitalize')weakenBoss(g,g.s.difficulty==='easy'?11:8);b.casting=null;b.patternTimer=Math.max(2,b.weak||0);g.hurtEnemy(h,b,s.power+(s.power?250:0),true);g.fx('interrupt',b.x,b.z,'#83dfff',3);}else if(s.power&&enemy&&!enemy.dead)g.hurtEnemy(h,enemy,s.power,true);} if(s.type==='taunt'){b.target=h.id;b.taunt=s.key==='Q'?7:6;g.fx('taunt',h.x,h.z,'#ffdf74',4);} if(s.type==='vera'||s.type==='blast'){g.s.zones.push({id:g.uid(),x:p.x,z:p.z,r:s.radius||3.8,left:.6,tick:.55,source:h.id,power:s.power,heal:false,oneShot:true,killHeal:s.type==='blast'?125:0,color:g.color(h)});g.fx('projectile',p.x,p.z,g.color(h),2);return;} if(s.type==='aoe'){const center=s.type==='aoe'?h:p;for(const e of [b,...g.s.adds])if(!e.dead&&distance(e,center)<(s.radius||6)+(e.radius||.4)){g.hurtEnemy(h,e,s.power,true,s.element,{spell:s.name});if(s.id==='A038'||s.id==='A02Y'){e.slow=s.id==='A038'?4:2.5;e.moveSlow=s.id==='A038'?.2:.1;e.attackSlow=s.id==='A038'?.1:.2;}}g.fx('burst',center.x,center.z,g.color(h),s.radius||6);} if(s.type==='multishot')for(const e of [b,...g.s.adds].filter(e=>!e.dead&&this.inRange(h,e,s.range)).slice(0,6))this.projectile(h,e,s.power,{}); if(['heal','renew','fullheal','chainheal'].includes(s.type)){if(!ally.dead){const healing=s.type==='fullheal'?ally.maxHp:s.power+(s.type==='heal'?sanctuaryHeal(h,ally):0);if(s.type==='chainheal'&&b.gate)g.loseLife(ally,healing);else g.heal(h,ally,healing,['heal','renew'].includes(s.type));if(s.type==='fullheal'&&ally.ritualSacrifice>0)g.loseLife(ally,ally.hp,true);if(s.type==='fullheal')ally.mana=Math.min(ally.maxMana,ally.mana+100);if(s.type==='renew')this.buff(ally,'renew',6,{source:h.id});if(s.type==='chainheal'){let previous=ally,others=g.alive().filter(x=>x!==ally);for(let i=1;i<=3&&others.length;i++){others.sort((a,b)=>distance(a,previous)-distance(b,previous));const next=others.shift();if(distance(next,previous)>12.5)break;if(b.gate)g.loseLife(next,s.power*Math.pow(.8,i));else g.heal(h,next,s.power*Math.pow(.8,i));g.fx('heal',next.x,next.z,'#75ffd1',1);previous=next;}}g.fx('heal',ally.x,ally.z,'#75ffd1',2);}} if(s.type==='shield'){ally.shield=s.power;this.buff(ally,'icearmor',40);g.fx('shield',ally.x,ally.z,'#82dfff',2);} if(s.type==='protect')this.buff(ally,'protect',8); if(s.type==='blessing')this.buff(ally,'blessing',60,{regen:20+(h.stats?.holy?25:0)}); if(s.type==='malice')this.buff(ally,'malice',17); if(s.type==='tainted')this.buff(h,'tainted',15); if(s.type==='fury')this.buff(h,'fury',9,{power:s.power,attackSpeed:1.5+(h.build?.upgrades.power||0)*.25}); if(s.type==='killer'){h.veil=0;this.buff(h,'killer',10);} if(s.type==='spirit'){if(!h.buffs.spirit){h.maxHp+=4000;h.hp+=4000;}this.buff(h,'spirit',s.duration);} if(s.type==='weaken'){b.weaken=s.duration;b.weakenFactor=s.weaken;b.weakenCap=1500;} if(s.type==='speed'){for(const a of s.team?g.alive():[h]){if(!s.team||distance(a,h)<25){this.buff(a,'haste',s.duration,{factor:s.speed});if(s.team&&a!==h&&a.class!=='rogue'&&distance(a,h)<12.5)a.mana=Math.min(a.maxMana,a.mana+24);}}g.fx('speed',h.x,h.z,g.color(h),2);} if(s.type==='transform'){h.formCds||={};h.formCds[h.form||'caster']=[...h.cd];h.form=({caster:'bear',bear:'bird',bird:'caster'})[h.form||'caster'];h.bear=h.form==='bear';h.cd=h.formCds[h.form]?[...h.formCds[h.form]]:KITS.druid.map(()=>0);h.cd[3]=.25;g.fx('burst',h.x,h.z,'#bded8b',2);} if(['behind','shadowstep','slide','charge'].includes(s.type)){let goal=p;if(s.type==='behind')goal={x:b.x-Math.sin(b.facing)*(b.radius+.8),z:b.z-Math.cos(b.facing)*(b.radius+.8)};if(s.type==='shadowstep'){this.buff(h,'haste',3.5,{factor:2});const friends=g.alive().filter(a=>a!==h).sort((a,b)=>distance(a,p)-distance(b,p));goal=friends[0]||h;}if(s.type==='slide'){goal={x:enemy.x+Math.sin(Math.atan2(h.x-enemy.x,h.z-enemy.z))*(enemy.radius+h.radius+.1),z:enemy.z+Math.cos(Math.atan2(h.x-enemy.x,h.z-enemy.z))*(enemy.radius+h.radius+.1)};}const legal=this.nav.nearest(goal,h.radius,this.units(),h);if(legal){if(s.type==='slide'||s.type==='charge'){h.rush={...legal,castId:c.id,left:2,target:s.type==='slide'?enemy.id:null,power:s.power,element:s.element};h.pathGoal=null;}else{h.x=legal.x;h.z=legal.z;h.tx=h.x;h.tz=h.z;}}g.fx('speed',h.x,h.z,g.color(h),3);} if(s.type==='healzone')g.s.zones.push({id:g.uid(),x:h.x,z:h.z,r:5.5,left:4,tick:0,source:h.id,power:1000,budget:1000,cap:500,heal:true,color:'#95e6d2'}); if(s.type==='reanimate')reanimate(g,h,g.getHero(c.target));if(s.type==='resurrect'||s.type==='reaverres'){const target=g.getHero(c.target);if(target?.dead||target?.skeleton){g.revive(h,target,s.type==='reaverres'?.4:Math.min(h.hp/h.maxHp,3000/target.maxHp));if(s.type==='reaverres')killHero(g,h,{exempt:true});}} if(s.type==='osteoporosis'){enemy.dots.push({source:h.id,power:50,left:4,tick:1,drain:5});enemy.attackWeaken=4;enemy.attackWeakenFactor=.1;} if(s.type==='cage')enemy.root=s.duration; if(s.type==='focusfire')this.buff(h,'focusfire',7);if(s.type==='postmortem')this.buff(h,'postmortem',6);if(s.type==='boneshatter'){g.hurtEnemy(h,enemy,250,true);g.drainEnergy(enemy,30);enemy.slow=6;enemy.moveSlow=.25;enemy.attackSlow=.25;} if(s.type==='decimate')g.s.zones.push({id:g.uid(),x:h.x,z:h.z,r:s.radius,left:3,tick:0,source:h.id,power:125,friendlyFire:true,color:'#bd8caa'}); if(s.type==='massskeleton')for(const a of g.s.heroes)if(a.dead)raiseSkeleton(g,a,40); if(s.type==='massres'){for(const a of g.s.heroes)if(a.dead||a.skeleton)g.revive(h,a,1);for(const a of g.s.heroes)a.resLock=45;h.veil=0;killHero(g,h,{exempt:true});} } } ===== sim.js ===== import {countStat,RECORD_KEYS} from './player-records.js'; import {CAMPAIGN_BALANCE,trackCampaignProgress,campaignEffort} from './campaign-progress.js'; import {initTeamwork,tickLearning,learningAI,tickTeamwork,teamworkDamage,teamCommand,recordLessonContact,recordTeamSkill,learningSkillAllowed,learningCommandAllowed} from './teamwork.js'; import {ONBOARDING,levelFor,stageFor,powerLevel,powerStats,skillAllowed} from './journey.js'; import {freshBuild,ensureBuild,totals,lootOffers,purchase,equipmentFor,activeItems,clearRank,inheritedBuild,rollLoot,freshLoot,supplyFor,SUPPLIES} from './builds.js'; import {Orders} from './orders.js'; import {VEILS,KITS,skillFor,kitFor,unitStats,defenseFor} from './kits.js'; import {CLASSES,CLASS_ORDER,BOSSES,DIFFICULTIES,ARENA,ITEMS} from './data.js'; import {tickHeroSystems,killHero,lifeSave,endSkeleton,endReaver,unitAttack} from './hero-systems.js'; import {activeExtra,consume,buySupply,tickItemSystems,canUseVortex,applyPowerup} from './item-systems.js'; import {initEncounter,tickBossSystems,runMechanic,startUltimate,nextMechanic,resolveBossCast,bossStats,onBossDeath} from './boss-systems.js'; export const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); export const dist=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); const point=(a)=>({x:a.x,z:a.z}); export class Game { constructor(config={}){if(config.kind==='onboarding')config={...config,stage:0,bossIndex:0,teamSize:ONBOARDING.squad,difficulty:ONBOARDING.difficulty,practice:true};if(config.kind==='campaign'){const level=levelFor(config.stage);if(!level)throw Error('Unknown level.');config={...config,bossIndex:level.bosses[0],teamSize:level.squad,difficulty:level.difficulty};}if(!DIFFICULTIES[config.difficulty||'easy'])config={...config,difficulty:'veryeasy'};this.seq=0;this.randomSeed=config.seed||32515;this.s={version:4,ruleset:8,loot:freshLoot(),continues:config.practice?99:0,continuesEnabled:config.continues!==false,shards:Math.floor(Math.max(0,Math.min(7,Number(config.shards)||0))),match:{id:config.matchId||('raid_'+Date.now().toString(36)+'_'+Math.random().toString(36).slice(2)),kind:config.kind||(config.practice?'practice':'raid'),target:config.kind==='onboarding'?1:config.kind==='campaign'?levelFor(config.stage).bosses.length:config.kind==='intro'?2:(config.practice?1:(DIFFICULTIES[config.difficulty||'easy'].encounters||10)),played:0,humans:1,finished:false,...(['campaign','onboarding'].includes(config.kind)?{stage:Number(config.stage)}:{})},power:config.kind==='campaign'?powerLevel(config.power):1,phase:'ready',time:0,fightTime:0,bossIndex:config.bossIndex||0,practice:!!config.practice,difficulty:config.difficulty||'easy',teamSize:clamp(config.teamSize||6,2,8),heroes:[],boss:null,adds:[],projectiles:[],casts:[],obstacles:[{x:-17,z:-8,r:1.05},{x:16,z:-11,r:1.1},{x:-19,z:9,r:.9},{x:19,z:8,r:1}],hazards:[],zones:[],fx:[],log:[],completed:[],votes:{},pings:[],chat:[],revives:config.kind==='campaign'?(levelFor(config.stage).revives??Math.max(1,Math.floor(config.teamSize/2))):DIFFICULTIES[config.difficulty||'easy'].revives,countdown:0,paused:false,callout:'',calloutUntil:0,kills:0,wipes:0,encounterDeaths:0,lastLoot:null}; if(config.kind==='campaign')Object.assign(this.s.match,{balance:CAMPAIGN_BALANCE,progress:Array(this.s.match.target).fill(0),effort:{casts:0,travel:0,tactics:0}}); let roster=(config.kind==='campaign'&&config.stage<=3?(config.hero==='ice'?['warrior','priest','fire','paladin','druid','ranger','rogue']:['ice','priest','warrior','fire','paladin','druid','ranger','rogue']):config.kind==='campaign'?['warrior','priest','ice','fire','paladin','druid','ranger','rogue']:['warrior','priest','ranger','fire','rogue','ice','druid','paladin']).filter(k=>k!==config.hero); for(let i=0;i{this.rt.init(h);h.build.offers=lootOffers(h,()=>this.rand());if(stageFor(this.s)){h.build.karma=stageFor(this.s).karma??1+Math.floor(stageFor(this.s).id/3);h.gold=20+stageFor(this.s).id*8;}h.essences=this.s.difficulty==='hard'?0:0;this.refreshStats(h,true);}); for(const h of this.s.heroes){countStat(h,'goldEarned',h.gold);countStat(h,'gritEarned',h.build.karma);} this.log('Your squad is ready. Prepare for '+BOSSES[this.s.bossIndex].name+'.','system'); } rand(){this.randomSeed=(Math.imul(1664525,this.randomSeed)+1013904223)>>>0;return this.randomSeed/4294967296;} uid(){return ++this.seq;} makeHero(id,key,name,ai,index){const c=CLASSES[key],angle=Math.PI/2+(index-2.5)*.32;return {id,class:key,power:this.s.power||1,name:String(name).slice(0,18),ai,x:Math.cos(angle)*10,z:Math.sin(angle)*10,tx:Math.cos(angle)*10,tz:Math.sin(angle)*10,hp:c.hp,maxHp:c.hp,mana:c.mana,maxMana:c.mana,shield:0,cd:KITS[key].map(()=>0),veil:0,veilCd:0,dashCd:0,potionCd:0,potions:[2,2],potionCds:[0,0],empowered:0,slow:0,stun:0,dead:false,deathAt:0,attackTimer:0,auto:true,target:'boss',facing:0,channel:null,threat:0,damage:0,healing:0,taken:0,resurrections:0,gold:20,essences:0,deaths:0,soulKills:0,level:1,build:freshBuild(),stats:{},items:{weapon:0,armor:0,focus:0,boots:0},attackCount:0,castFlash:0,think:0,aiX:0,aiZ:0,moving:false,bookStats:Object.fromEntries(RECORD_KEYS.map(k=>[k,0]))};} log(text,type='combat'){this.s.log.push({id:this.uid(),time:this.s.time,text,type});if(this.s.log.length>50)this.s.log.shift();} callout(text,duration=4){this.s.callout=text;this.s.calloutUntil=this.s.time+duration;} fx(kind,x,z,color,power=1,text='',owner=null){this.s.fx.push({id:this.uid(),kind,x,z,color,power,text,t:0,owner:owner||this.effectOwner||undefined});if(this.s.fx.length>150)this.s.fx.shift();} recruit(h,key,name){if(!CLASSES[key]||!['ready','intermission'].includes(this.s.phase))return false;if(h.class!==key){const build=inheritedBuild(h,key),saved={gold:h.gold,essences:h.essences||0,deaths:h.deaths||0,powerup:h.powerup||null,token:h.token||null,potions:[...h.potions],damage:h.damage,healing:h.healing,taken:h.taken,resurrections:h.resurrections,items:{...h.items},...(h.legacyScale?{legacyScale:h.legacyScale}:{})};Object.assign(h,this.makeHero(h.id,key,name,false,this.s.heroes.indexOf(h)),saved,{build});h.build.offers=lootOffers(h,()=>this.rand());}h.name=name;h.ai=false;this.rt.init(h);this.refreshStats(h,true);return true;} refreshStats(h,refill=false){const c=unitStats(h),oldHp=h.maxHp,oldMana=h.maxMana;h.stats=totals(h);h.maxHp=Math.round(c.hp*(h.legacyScale||1)*powerStats(h.power).health)+h.stats.hp+(h.items?.armor||0)*220;h.maxMana=c.mana+h.stats.mana+(h.items?.focus||0)*120;h.hp=refill?h.maxHp:Math.min(h.maxHp,h.hp+Math.max(0,h.maxHp-oldHp));h.mana=refill?h.maxMana:Math.min(h.maxMana,h.mana+Math.max(0,h.maxMana-oldMana));h.level=1+(h.build.spent||0);} drainEnergy(enemy,amount){if(enemy?.id!=='boss')return;enemy.mana=Math.max(0,enemy.mana-amount/(BOSSES[this.s.bossIndex].energy||800)*100);} useItem(h,slot,cmd={}){const item=equipmentFor(h.build?.equipment[slot]),a=item?.active;if(!a||h.dead||this.s.phase!=='fight'||(h.build.itemCds[item.id]||0)>0||h.mana0||a.once&&!canUseVortex(this,h,a.kind)||a.kind==='metamorphosis'&&(h.reaver||h.skeleton))return false;h.mana-=a.mana;h.build.itemCds[item.id]=a.cd;const id=this.announceCast(h,slot,{name:a.name});this.resolveCast(id);this.resolveCast(id,true);countStat(h,'items');const b=this.s.boss;if(activeExtra(this,h,item,cmd))return true; if(a.kind==='dagger')this.rt.projectile(h,b,a.power,{speed:30});if(a.kind==='lifesteal')this.rt.buff(h,'itemLifesteal',a.duration,{power:a.power}); if(a.kind==='heal'){this.heal(h,h,a.power);if(h.ritualSacrifice>0)this.loseLife(h,3500,true);} if(a.kind==='sprint')this.rt.buff(h,'haste',a.duration,{factor:2}); if(a.kind==='sap')this.drainEnergy(b,a.drain); if(a.kind==='darkness')this.hurtEnemy(h,b,a.power+(b.hp/b.maxHp<.3?250:0),true); if(a.kind==='ruin'){const dx=b.x-h.x,dz=b.z-h.z,d=Math.hypot(dx,dz)||1;this.hazard('wave',{x:h.x,z:h.z,dx:dx/d,dz:dz/d,width:1.7,range:28,speed:22,owner:h.id,friendly:true,castId:id,power:a.power,color:this.color(h),label:''},0,0,1.4);} if(a.kind==='mend'){const ally=this.getHero(cmd.id)||this.alive().slice().sort((x,y)=>x.hp/x.maxHp-y.hp/y.maxHp)[0]||h;this.rt.buff(ally,'mend',a.duration,{power:a.power,source:h.id,ritualSensitive:true});} if(a.kind==='ward')this.s.zones.push({id:this.uid(),x:h.x,z:h.z,r:6,left:a.duration,tick:0,source:h.id,power:a.power,heal:true,color:'#93d5ba'}); if(a.kind==='chargepower')this.rt.buff(this.getHero(cmd.id)||h,'spiritCharge',a.duration,{factor:a.factor}); if(a.kind==='reset')h.cd.fill(0); if(a.kind==='surrender'){this.rt.buff(h,'surrender',a.duration,{drain:a.drain,mana:h.mana});h.mana=0;} if(a.kind==='barrier'){h.shield+=a.power;this.rt.buff(h,'itemShield',a.duration,{power:a.power});} this.fx(a.kind==='heal'?'heal':'shield',h.x,h.z,this.color(h),1.7);return true; } announceCast(h,slot,skill){if(Number.isInteger(slot))countStat(h,'casts');if(Number.isInteger(slot))campaignEffort(this.s,h,'casts');const event={id:this.uid(),owner:h.id,slot,name:skill.name,inputSeq:h.order?.inputSeq||h.inputSeq,time:this.s.time,resolvedAt:null,impactAt:null};this.s.casts||=[];this.s.casts.push(event);this.s.casts=this.s.casts.slice(-48);return event.id;} resolveCast(id,impact=false){const event=this.s.casts?.find(e=>e.id===id);if(!event)return;if(impact){event.impactAt??=this.s.time;const h=this.getHero(event.owner),skill=Number.isInteger(event.slot)&&h?skillFor(h,event.slot):null;if(skill&&h?.casting){const ally=this.getHero(h.casting.target)||h,healing=['heal','chainheal','renew','fullheal'].includes(skill.type);if(skill.target==='ally'&&!ally.dead&&(!healing||ally.hpa.id===h.id);if(saved){const identity={ai:h.ai,name:h.name,bookStats:h.bookStats};Object.assign(h,JSON.parse(JSON.stringify(saved)),identity);}}}if(this.s.phase==='intermission'){for(const h of this.s.heroes)if(h.ai)this.aiBuild(h);if(stageFor(this.s)){this.s.bossIndex=stageFor(this.s).bosses[this.s.completed.length];}else{const choices=this.triadChoices(),opening=[0,1,2,3,4].find(i=>!this.s.completed.some(c=>c.boss===i));if(opening!==undefined)this.s.bossIndex=opening;else if(choices.length){const votes=Object.values(this.s.votes);this.s.bossIndex=choices.slice().sort((a,b)=>votes.filter(v=>v===b).length-votes.filter(v=>v===a).length||a-b)[0];}else if([5,6,7].includes(this.s.bossIndex))this.s.bossIndex=8;else this.s.bossIndex++;if(this.s.bossIndex>=BOSSES.length)return false;this.s.votes={};}} const def=BOSSES[this.s.bossIndex],d=DIFFICULTIES[this.s.difficulty];if(!this.s.completed.length)for(const h of this.s.heroes)if(h.ai){if(this.s.match.kind==='campaign'){this.aiBuild(h);continue;}while(h.potions[1]<5&&h.gold>=5)this.buy(h,'mana_potion');if(h.gold>=5)this.buy(h,'health_potion');if(h.build.karma>0)this.buy(h,['priest','ice','fire','warlock','druid'].includes(h.class)?'mana':'health');}this.peerDamage=[];this.s.phase=this.s.match.kind==='onboarding'?'fight':'countdown';this.s.countdown=this.s.match.kind==='onboarding'?0:stageFor(this.s)?2:4;this.s.fightTime=0;this.s.encounterDeaths=0;this.s.hazards=[];this.s.projectiles=[];this.s.adds=[];this.s.zones=[];this.s.fx=[];this.s.casts=[];this.s.summons=[];this.s.pickups=[];this.s.arenaGold={timer:5,made:0,seed:32515+this.s.bossIndex*97};this.s.delayed=[];this.s.vortex=null;this.s.paused=false;if(!retry)this.s.retryHeroes=JSON.parse(JSON.stringify(this.s.heroes)); const maxHp=Math.round(def.hp*d.hp*(this.s.teamSize/8)*(stageFor(this.s)?.hp||1));this.s.boss={id:'boss',x:0,z:-1,hp:maxHp,maxHp,mana:0,maxMana:100,dead:false,phase:1,stage:0,casting:null,attackTimer:2,patternTimer:3,pattern:0,ult:0,weaken:0,dots:[],target:'h1',enraged:false,secondLife:false,stun:0,facing:0,radius:this.s.bossIndex===2?1.125:1.5,turnRate:3}; initEncounter(this);this.s.heroes.forEach((h,i)=>{this.refreshStats(h,true);h.hp=h.maxHp;h.mana=h.maxMana;h.dead=false;h.cd=KITS[h.class].map(()=>0);h.shield=0;h.veil=0;h.veilCd=0;h.dashCd=0;h.empowered=0;h.slow=0;h.stun=0;h.channel=null;h.target='boss';h.auto=true;h.threat=0;h.potionCd=0;h.potionCds=[0,0];h.build.itemCds={};const a=Math.PI*.5+(i-(this.s.teamSize-1)/2)*.27;h.x=Math.cos(a)*10;h.z=Math.sin(a)*10;h.tx=h.x;h.tz=h.z;h.skeleton=null;h.reaver=null;h.blackMagicUsed=false;h.respawnAt=0;h.resLock=0;h.build.itemUses=Object.fromEntries(Object.entries(h.build.itemUses).filter(([id])=>equipmentFor(id)?.charges));this.rt.init(h);applyPowerup(this,h);}); initTeamwork(this);this.s.boss.progressStartHp=this.s.boss.hp;this.callout(def.name+' · '+def.title,4);this.log((retry?'Retrying ':'Entering ')+def.name+'.','system');return true; } restartRun(power=this.s.power){ if(!this.s.match.finished)return false; const s=this.s,fresh=new Game({hero:s.heroes[0].class,roster:s.heroes,teamSize:s.teamSize,difficulty:s.difficulty,shards:s.shards,continues:s.continuesEnabled,kind:s.match.kind,practice:s.practice,bossIndex:s.practice?s.bossIndex:0,stage:s.match.stage,power}); this.s=fresh.s;this.s.match.humans=Math.max(1,this.s.heroes.filter(h=>!h.ai).length);this.seq=fresh.seq;this.randomSeed=fresh.randomSeed;this.rt=new Orders(this,ARENA,this.s.obstacles);return true; } next(){return this.start();} triadChoices(){if(stageFor(this.s))return [];return this.s.phase==='intermission'&&[0,1,2,3,4].every(i=>this.s.completed.some(c=>c.boss===i))?[5,6,7].filter(i=>!this.s.completed.some(c=>c.boss===i)):[];} color(h){return CLASSES[h.class].color;} getHero(id){return this.s.heroes.find(h=>h.id===id);} getEnemy(id){return id==='boss'?this.s.boss:this.s.adds.find(a=>a.id===id);} command(id,cmd){const h=this.getHero(id);if(h)h.inputSeq=Number.isInteger(cmd?.inputSeq)?cmd.inputSeq:null;const previous=this.effectOwner;this.effectOwner=id;try{return this.performCommand(id,cmd);}finally{this.effectOwner=previous;}} performCommand(id,cmd){const h=this.getHero(id);if(!h||!cmd||this.inputGate&&!this.inputGate(cmd)||!learningCommandAllowed(this.s,h,cmd))return false; if(cmd.type==='skill'&&(!skillAllowed(this.s,h,cmd.slot)||!learningSkillAllowed(this.s,h,cmd.slot)))return false;if(cmd.type==='veil'&&stageFor(this.s)&&!stageFor(this.s).block&&!h.ai)return false; if(cmd.type==='tactic'){if(h.dead||this.s.phase!=='fight'||!['rally','spread','focus'].includes(cmd.tactic)||this.s.time-(h.lastTactic??-99)<1||stageFor(this.s)&&!stageFor(this.s).tactics)return false;h.lastTactic=this.s.time;countStat(h,'tactics');campaignEffort(this.s,h,'tactics');this.s.tactic={kind:cmd.tactic,owner:h.id,x:h.x,z:h.z,until:this.s.time+10};this.fx('ping',h.x,h.z,'#7deaff',3);teamCommand(this,h,cmd);return true;} if(cmd.type==='vote'){if(!this.triadChoices().includes(cmd.boss))return false;this.s.votes[h.id]=cmd.boss;return true;} if(cmd.type==='chat'){if(typeof cmd.text!=='string'||this.s.time-(h.lastChat??-99)<.7)return false;const text=cmd.text.trim().slice(0,160);if(!text)return false;h.lastChat=this.s.time;this.s.chat.push({id:this.uid(),owner:h.id,name:h.name,text,time:this.s.time});this.s.chat=this.s.chat.slice(-20);return true;} if(cmd.type==='ping'){if(!Number.isFinite(cmd.x)||!Number.isFinite(cmd.z)||this.s.time-(h.lastPing??-99)<.5)return false;h.lastPing=this.s.time;const p=this.bound(cmd.x,cmd.z);this.s.pings.push({...p,id:this.uid(),owner:h.id,time:this.s.time});this.fx('ping',p.x,p.z,'#ffe17f',3);return true;} if(cmd.type==='buy')return this.buy(h,cmd.item);if(cmd.type==='item')return this.useItem(h,cmd.slot,cmd);if(cmd.type==='consume')return consume(this,h,cmd.item);if(cmd.type==='givegem'){const ally=this.getHero(cmd.id),id=h.build.equipment.gem;if(!ally||ally===h||!id||ally.build.equipment.gem)return false;const item=equipmentFor(id);if(!item.classes.includes(ally.class))return false;h.build.equipment.gem=null;h.build.owned=h.build.owned.filter(x=>x!==id);ally.build.equipment.gem=id;ally.build.owned.push(id);ally.build.itemUses[id]=h.build.itemUses[id]??item.charges;delete h.build.itemUses[id];return true;} if(cmd.type==='stance'){if(typeof cmd.stationary!=='boolean')return false;h.stationaryCombat=cmd.stationary;if(cmd.stationary&&!h.casting&&!h.channel&&!h.rush&&!['cast','rescue'].includes(h.order?.kind)){this.rt.interrupt(h);h.order={kind:'hold',x:h.x,z:h.z};h.guard={x:h.x,z:h.z};}return true;} if(cmd.type==='auto'){h.auto=!h.auto;return true;} if(!['ready','fight','countdown'].includes(this.s.phase)||h.dead)return false;if(this.s.phase!=='fight'&&!['steer','move','patrol','follow','hold','stop'].includes(cmd.type))return false; if(cmd.type==='guard'){if(!h.stationaryCombat||h.casting||h.channel||h.rush||h.steer||['cast','rescue','move','attack'].includes(h.order?.kind))return false;h.auto=true;const ok=this.rt.issue(h,{kind:'hold',x:h.x,z:h.z});if(ok&&this.getEnemy(cmd.id))h.target=cmd.id;return ok;} if(cmd.type==='steer'){if(!Number.isFinite(cmd.x)||!Number.isFinite(cmd.z))return false;const length=Math.hypot(cmd.x,cmd.z);if(length<.02){if(h.steer){h.steer=null;this.rt.done(h);}return true;}const x=cmd.x/Math.max(1,length),z=cmd.z/Math.max(1,length);if(!h.steer||Math.hypot(h.steer.x-x,h.steer.z-z)>.02){this.rt.interrupt(h);h.queue=[];}h.steer={x,z,until:this.s.time+.35};h.order={kind:'steer',x:h.x,z:h.z};h.status='Moving';return true;} if(['move','attackmove','patrol','target','attack','follow','stop','hold','skill'].includes(cmd.type))return this.rt.command(h,cmd); if(cmd.type==='veil'&&h.veilCd<=0){const v=defenseFor(h);if(h.mana0&&(h.potionCds?.[cmd.slot]||0)<=0){h.potions[cmd.slot]--;countStat(h,'potions');h.potionCds||=[0,0];h.potionCds[cmd.slot]=cmd.slot===0?35:45;if(cmd.slot===0){this.heal(h,h,1750);if(h.ritualSacrifice>0)this.loseLife(h,3500,true);}else h.mana=Math.min(h.maxMana,h.mana+300);this.fx('heal',h.x,h.z,'#84e1bb',1);return true;} if(cmd.type==='revive'){if(!stageFor(this.s)||(h.build.consumables.remedy||0)>0)return consume(this,h,'remedy',cmd.id);const corpse=this.getHero(cmd.id)||this.s.heroes.filter(a=>a.dead).sort((a,b)=>dist(a,h)-dist(b,h))[0];if(!corpse?.dead||this.s.revives<=0)return false;if(dist(h,corpse)<3.6)return this.beginRevive(h);return this.rt.issue(h,{kind:'rescue',target:corpse.id});} if(cmd.type==='legacyrevive'){const corpse=this.getHero(cmd.id)||this.s.heroes.filter(a=>a.dead).sort((a,b)=>dist(a,h)-dist(b,h))[0];if(!corpse?.dead||this.s.revives<=0)return false;if(dist(h,corpse)<3.6)return this.beginRevive(h);return this.rt.issue(h,{kind:'rescue',target:corpse.id});} return false; } bound(x,z){const d=Math.hypot(x,z);if(d>ARENA-.65){const f=(ARENA-.65)/d;return{x:x*f,z:z*f};}return{x,z};} buy(h,id){if(!['ready','intermission','wipe'].includes(this.s.phase))return false;const gold=h.gold,grit=h.build?.karma||0,recordPurchase=()=>{countStat(h,'goldSpent',gold-h.gold);countStat(h,'gritSpent',grit-(h.build?.karma||0));countStat(h,'gritEarned',(h.build?.karma||0)-grit);};const supply=buySupply(this,h,id);if(supply!==null){if(supply)recordPurchase();return supply;}const item=equipmentFor(id),owned=h.build.owned.includes(id);if(!purchase(h,id))return false;recordPurchase();if(!owned&&this.s.loot){this.s.loot.stock=this.s.loot.stock.filter(x=>x!==id);this.s.loot.gems=this.s.loot.gems.filter(x=>x!==id);for(const a of this.s.heroes)a.build.offers=a.build.offers.filter(x=>x!==id);}this.refreshStats(h);this.log(h.name+' chose '+(equipmentFor(id)?.name||id)+'.','loot');return true;} loseLife(h,amount,strip=false){if(h.dead)return;if(strip){h.veil=0;h.shield=0;delete h.buffs.protect;}const actual=Math.min(h.hp,Math.max(0,amount));h.hp=Math.max(0,h.hp-amount);h.taken+=actual;countStat(h,'taken',actual);this.fx('number',h.x,h.z,'#db9cb5',1,'−'+Math.round(actual));if(h.hp<=0)killHero(this,h);} heal(source,target,amount,ritualSensitive=false){if(target.dead)return;amount*=powerStats(source?.power).healing;if(ritualSensitive&&target.ritualSacrifice>0){this.loseLife(target,amount,true);return;}const effective=Math.min(target.maxHp-target.hp,amount);target.hp+=effective;if(effective>40&&source?.casting&&this.getHero(target.id))recordTeamSkill(this,source);if(source){source.healing+=effective;countStat(source,'healing',effective);}if(effective>40){this.fx('number',target.x,target.z,'#8be2b3',1,'+'+Math.round(effective));this.s.fx[this.s.fx.length-1].owner=target.id;}} hurtHero(h,amount,bypass=false,context=null){if(!bypass&&h.netGrace>0&&!h.ai&&!h.dead&&amount>0){this.peerDamage??=[];if(this.peerDamage.length<256)this.peerDamage.push({id:h.id,amount,at:this.s.time+h.netGrace,context});return;}return this.applyHurtHero(h,amount,bypass);} flushPeerDamage(){const hits=this.peerDamage||[];this.peerDamage=[];for(const hit of hits){const h=this.getHero(hit.id);if(!h||h.dead)continue;if(hit.at>this.s.time){this.peerDamage.push(hit);continue;}if(hit.context?.hazard&&!this.hazardHit(hit.context.hazard,h))continue;this.applyHurtHero(h,hit.amount);}} applyHurtHero(h,amount,bypass=false){if(h.dead)return;if(!bypass&&h.veil>0){if(amount>0)countStat(h,'blocks');return;}let reduction=h.buffs?.protect?.6:1;if(h.buffs?.protect&&h.hp-amount*reduction<=0){delete h.buffs.protect;h.veil=3;return;}if(h.buffs?.powerProtection)reduction/=2.2;h.hitFlash=.12;amount*=stageFor(this.s)?.damage||1;amount*=reduction/(1+.06*((h.items?.armor||0)*3+(h.buffs?.blessing?3:0)));const absorb=Math.min(h.shield,amount);countStat(h,'absorbed',absorb);h.shield-=absorb;amount-=absorb;if(this.s.match.kind==='onboarding')amount=Math.min(amount,Math.max(0,h.hp-h.maxHp*.2));if(lifeSave(this,h,amount))return;countStat(h,'taken',Math.min(h.hp,Math.max(0,amount)));h.hp=Math.max(0,h.hp-amount);h.taken+=amount;if(amount>35){this.fx('number',h.x,h.z,'#f99590',1,'−'+Math.round(amount));this.s.fx[this.s.fx.length-1].owner=h.id;}if(h.hp<=0)killHero(this,h);} hurtEnemy(h,e,amount,skill=false,element=null,options={}){if(!e||e.dead||e.invulnerable||!h)return 0;if(e.id==='boss'&&e.infinity&&e.ult>0)return 0;let mult=(1+(h.items?.weapon||0)*.18)*(h.legacyScale||1)*powerStats(h.power).damage;if(h.stats?.martyr&&h.hp/h.maxHp>=.8)mult*=1.28;if(h.buffs?.spiritCharge)mult*=h.buffs.spiritCharge.factor;if(h.buffs?.soulstealer)mult*=h.buffs.soulstealer.factor;if(e.magicVulnerable>0&&skill&&!options.physical)mult*=1.1;if(e.weaken>0&&skill){const bonus=Math.min(e.weakenCap??Infinity,amount*(e.weakenFactor||.1));amount+=bonus;e.weakenCap=Math.max(0,(e.weakenCap??Infinity)-bonus);if(!e.weakenCap)e.weaken=0;}if(h.buffs?.killer){amount+=90;this.drainEnergy(e,8);}if(e.id==='boss'&&options.spell){const id=BOSSES[this.s.bossIndex].id,n=options.spell;if(n==='Ember Line'){if(id==='fire')mult*=.2;if(['ice','water'].includes(id))amount+=100;}if(n==='Cinder Tag'&&id==='fire')mult=0;if(n==='Sky Ember'&&id==='fire')mult*=.8;if(n==='Rime Dart'){if(['ice','water'].includes(id))mult*=.2;if(['fire','thunder'].includes(id))amount+=100;}if(n==='Rime Ring'&&['ice','water'].includes(id))mult*=.4;if(n==='Sleet Skid'&&['ice','water'].includes(id))mult*=.6;if(n==='Hexburn'){if(id==='shadow')mult*=.3;if(!options.dot&&['light','druid'].includes(id))amount+=200;}if(n==='Lumen Bolt'){if(id==='light')mult*=.2;if(id==='shadow')amount+=100;}if(n==='Gloom Flood'&&id==='shadow')mult*=.8;}amount*=mult;if(h.stats?.martyr)this.hurtHero(h,amount*.15,true);if(e.id==='boss'&&e.armorMultiplier)amount*=e.armorMultiplier;if(amount>0&&skill&&!options.dot)recordTeamSkill(this,h);amount=teamworkDamage(this,h,e,amount,options);if(amount<=0&&(this.s.learning||this.s.teamwork))return 0;const actual=Math.min(e.hp,Math.max(0,amount));e.hp=Math.max(0,e.hp-amount);h.damage+=actual;countStat(h,'damage',actual);if(actual>0&&options.critical&&!options.dot)countStat(h,'criticals');h.threat+=actual*(['warrior','paladin'].includes(h.class)?2:1);if(h.class==='rogue'&&!h.skeleton)h.mana=Math.min(h.maxMana,h.mana+4);this.fx('number',e.x+(this.rand()-.5)*1.5,e.z,'#ffe585',skill?1.2:.8,''+Math.round(actual));Object.assign(this.s.fx[this.s.fx.length-1],{owner:h.id,target:e.id,skill,critical:!!options.critical,dot:!!options.dot});e.lastImpact={at:this.s.time,power:skill?1.4:options.critical?1.6:1,source:h.id};e.hitFlash=.13;if(e.hp<=0&&e.id!=='boss'){e.dead=true;this.s.kills++;countStat(h,'addKills');countStat(h,'goldEarned',5);h.gold+=5;if(h.class==='ice'&&!h.skeleton&&!h.reaver){this.heal(h,h,150);h.mana=Math.min(h.maxMana,h.mana+20);}if(h.stats?.soulstealer){this.heal(h,h,e.maxHp*.4);h.soulKills=Math.min(8,(h.soulKills||0)+1);}this.fx('death',e.x,e.z,'#a2d2cb',1);}return actual;} nearestAlly(p){return this.s.heroes.filter(x=>!x.dead).sort((a,b)=>dist(a,p)-dist(b,p))[0];} cast(h,slot,p){const skill=skillFor(h,slot);return this.rt.command(h,{type:'skill',slot,x:p.x,z:p.z,id:p.id||(skill?.target==='ally'?this.nearestAlly(p)?.id:skill?.target==='corpse'?this.s.heroes.find(a=>a.dead)?.id:h.target)});} beginRevive(h){if(this.s.revives<=0||h.channel||h.stun>0)return false; const dead=this.s.heroes.filter(x=>x.dead&&dist(h,x)<4).sort((a,b)=>dist(a,h)-dist(b,h))[0];if(!dead)return false;this.rt.interrupt(h);h.channel={target:dead.id,left:2.5,total:2.5,x:h.x,z:h.z};h.tx=h.x;h.tz=h.z;return true;} revive(source,h,fraction=.5){const cd=[...h.cd],mana=h.skeleton?.body?.mana??h.mana;if(h.skeleton)endSkeleton(this,h);if(h.reaver)endReaver(this,h);this.rt.init(h);h.cd=cd;this.refreshStats(h);h.respawnAt=0;h.dead=false;h.hp=h.maxHp*fraction;h.mana=Math.min(h.maxMana,mana);h.veil=2;h.tx=h.x;h.tz=h.z;h.channel=null;source.resurrections++;if(source!==h)countStat(source,'revives');this.log(source.name+' revived '+h.name+'.','heal');this.fx('heal',h.x,h.z,'#e8df9c',4);} hazard(kind,p={},delay=1.6,damage=300,duration=.35){const h={id:this.uid(),kind,x:0,z:0,r:3,width:3,x2:0,z2:0,t:0,delay,duration,damage:damage*DIFFICULTIES[this.s.difficulty].damage,color:BOSSES[this.s.bossIndex].color,tick:0,hit:false,...p};this.s.hazards.push(h);return h;} hazardHit(h,p,pad=.15){if(h.kind==='safeUnion')return !h.points.some(q=>dist(q,p)dist(q,p)dist(o,p)<(h.orbRadius||1)+pad);if(h.kind==='circle')return dist(h,p)h.r-pad;if(h.kind==='ring'){const d=dist(h,p);return dh.inner-pad;}if(h.kind==='line'){let dx=h.x2-h.x,dz=h.z2-h.z,l=dx*dx+dz*dz,t=clamp(((p.x-h.x)*dx+(p.z-h.z)*dz)/(l||1),0,1);return Math.hypot(p.x-h.x-dx*t,p.z-h.z-dz*t)!h.dead);} pattern(type){return runMechanic(this,type);} ultimate(){return startUltimate(this);} hazardSoon(h,p,seconds=.45,pad=.65){if(this.hazardHit(h,p,pad))return true;if(!['missile','wave'].includes(h.kind)||h.t=h.delay+h.duration)return false;const travel=Math.min(seconds,h.delay+h.duration-h.t)*h.speed,dx=p.x-h.x,dz=p.z-h.z,along=Math.max(0,Math.min(travel,dx*h.dx+dz*h.dz));return Math.hypot(dx-h.dx*along,dz-h.dz*along)<(h.width||h.r||1)+pad;} danger(p,future=.7){let score=0;for(const a of this.s.adds)if(a.force&&!a.dead){const d=dist(a,p);if(d<10)score+=1000/Math.max(1,d-2.5);}for(const h of this.s.hazards){if(h.t>h.delay+h.duration||h.t=h.delay?400:130/(Math.max(.15,h.delay-h.t)))+(['safe','safeUnion'].includes(h.kind)?500:0);}return score;} aiProtection(h){if(h.veil>0)return true;const v=defenseFor(h);if(h.veilCd<=0&&h.mana>=v.mana)return this.command(h.id,{type:'veil'});if(h.build.consumables.savior&&!h.build.itemCds.savior)return consume(this,h,'savior');return false;} aiMove(h,p){p=this.bound(p.x,p.z);if(dist(h,p)<1.2){if(h.order.kind!=='hold')this.rt.issue(h,{kind:'hold',x:h.x,z:h.z});return;}if(h.order.kind!=='move'||dist(h.order,p)>1)this.rt.issue(h,{kind:'move',...p});} aiUltimatePosition(h,enemy){const b=this.s.boss,i=this.s.heroes.indexOf(h),angle=i*2.4; if(b.gate){const s=b.gate;if(['small','large','winddown'].includes(s.phase)){const large=this.s.hazards.filter(a=>a.gateLarge&&a.t<999).sort((a,c)=>dist(h,a)-dist(h,c))[0];if(large){const intercept=this.bound(large.x+large.dx*4,large.z+large.dz*4),nearest=this.alive().filter(a=>a.ai).sort((a,c)=>dist(a,intercept)-dist(c,intercept))[0];if(nearest===h&&this.danger(intercept)<1){this.aiMove(h,intercept);return true;}}const r=Math.max(8.5,Math.min(14,s.r-4));this.aiMove(h,{x:b.x+Math.sin(angle)*r,z:b.z+Math.cos(angle)*r});return true;}this.aiMove(h,{x:b.x+Math.sin(angle)*2.5,z:b.z+Math.cos(angle)*2.5});return true;} if(b.soulcall){const goal={x:b.x+Math.sin(angle)*17,z:b.z+Math.cos(angle)*17};if(dist(h,goal)>3){this.aiMove(h,goal);return true;}const range=unitAttack(h).range+(h.stats?.range||0)+h.radius+(enemy.radius||.4);if(enemy===b||dist(h,enemy)>range){this.aiMove(h,h);return true;}return false;} if(b.gravefrost&&enemy===b){const s=b.gravefrost,r=['wait','converge','outward','barrier'].includes(s.phase)||s.round===2&&s.thirdTicks>80?12.5:2;this.aiMove(h,{x:b.x+Math.sin(angle)*r,z:b.z+Math.cos(angle)*r});return true;} return false; } aiDefense(h){const b=this.s.boss;if(!b||b.dead||h.dead||!(b.kind==='ancient'||b.gate||b.soulcall))return;if(b.kind==='fire'&&b.ult>0&&b.ultAge<23.6)return; const force=this.s.adds.some(a=>a.force&&!a.dead&&dist(a,h)<10.5&&this.alive().some(t=>dist(a,t)<4.5)); const imminent=force||b.gate?.phase==='closing'||this.s.hazards.some(a=>!a.basic&&!a.gateLarge&&a.damage>=Math.min(3000,h.maxHp*.4)&&a.t>=a.delay-.15&&a.t0)return;h.think=.3+this.rand()*.13;const b=this.s.boss;if(!b||b.dead)return; const alive=this.alive(),low=alive.slice().sort((a,b)=>a.hp/a.maxHp-b.hp/b.maxHp)[0]||h; if(h.hp/h.maxHp<.28&&!(h.ritualSacrifice>0))this.command(h.id,{type:'potion',slot:0});if(h.mana/h.maxMana<.38)this.command(h.id,{type:'potion',slot:1}); if(b.gate?.phase==='closing')this.aiProtection(h); if(b.kind==='thunder'&&b.mana>80&&b.ult<=0&&dist(h,b)<10){const length=dist(h,b)||1;this.aiMove(h,{x:b.x+(h.x-b.x)/length*12,z:b.z+(h.z-b.z)/length*12});return;} if(b.linked?.some(l=>l.id===h.id&&l.active)){const length=dist(h,b)||1;if(!b.pullStarted){this.aiMove(h,{x:b.x+(h.x-b.x)/length*12,z:b.z+(h.z-b.z)/length*12});return;}const link=b.linked.find(l=>l.id===h.id),n=b.linked.filter(l=>l.active).length;let step=link.base+Math.floor(15/Math.max(1,n))+1+Math.floor(link.initial/100)*link.weight;if(step<=1)step=2;if(n<=3&&step<=10)step=20;if(n===1)step=36+Math.floor(link.initial/100);const ready=h.veil>0||h.veilCd<=0&&h.mana>=defenseFor(h).mana||h.build.consumables.savior;if((length-4)/(step/40/.03+this.rt.speed(h))<.8&&ready)this.aiProtection(h);this.aiMove(h,ready?b:{x:b.x+(h.x-b.x)/length*19,z:b.z+(h.z-b.z)/length*19});return;} if(b.soulcall&&dist(h,b)<15){const angle=this.s.heroes.indexOf(h)*2.4;this.aiMove(h,{x:b.x+Math.sin(angle)*17,z:b.z+Math.cos(angle)*17});return;} for(const {slot,item}of activeItems(h)){const kind=item.active.kind;if((['sap','surrender'].includes(kind)&&b.mana>50&&h.mana>item.active.mana+35)||(['chargepower','darkness','ruin','dagger','lifesteal'].includes(kind)&&h.mana>item.active.mana+35)||['heal','barrier','mend','ward'].includes(kind)&&low.hp/low.maxHp<.55||kind==='reset'&&h.cd.filter(c=>c>5).length>=2)this.useItem(h,slot);} const interruptSlot=kitFor(h).findIndex(s=>s.type==='interrupt');if(b.casting?.interruptible&&interruptSlot>=0&&h.cd[interruptSlot]<=0&&h.mana>=kitFor(h)[interruptSlot].mana){const reserved=this.getHero(b.casting.interrupter);if(!reserved||reserved.dead||reserved.order?.kind!=='cast'||b.casting.reservedUntila.ai&&kitFor(a).some((skill,i)=>skill.type==='interrupt'&&a.cd[i]<=0&&a.mana>=skill.mana)).sort((a,c)=>{const score=a=>dist(a,b)/(kitFor(a).find(s=>s.type==='interrupt')?.range||1);return score(a)-score(c);});if(candidates[0]===h&&this.command(h.id,{type:'skill',slot:interruptSlot,id:'boss',x:b.x,z:b.z})){b.casting.interrupter=h.id;b.casting.reservedUntil=this.s.time+1.8;return;}}} const danger=this.danger(h),safe=this.s.hazards.find(a=>['safe','safeUnion'].includes(a.kind)&&a.t!a.basic&&a.damage>0&&a.t>a.delay-.3&&a.t0&&h.build.consumables.savior)consume(this,h,'savior'); if((imminent||b.mana>96&&b.kind==='light')&&h.veilCd<=0&&!(b.kind==='fire'&&b.ult>0&&b.ultAge<23.6))this.command(h.id,{type:'veil'}); if((danger>0||safe)&&!h.channel){const idx=this.s.heroes.indexOf(h);const center=safe?.kind==='safeUnion'?safe.points.slice().sort((a,b)=>dist(a,h)-dist(b,h))[0]:safe;let goal=safe?{x:center.x+Math.sin(idx)*(safe.kind==='safeUnion'?10:1.4),z:center.z+Math.cos(idx)*(safe.kind==='safeUnion'?10:1.4)}:h,best=null,score=Infinity; for(let a=0;a<20;a++)for(const r of [2.5,5,8,12]){const p=this.bound(h.x+Math.sin(a*Math.PI/10)*r,h.z+Math.cos(a*Math.PI/10)*r);if(!this.rt.nav.legal(p,h.radius))continue;const value=this.danger(p)*20+(safe?dist(p,goal)*3:0)+dist(p,h)*.35;if(value0||dist(h,goal)>2)){if(h.order?.kind!=='move'||dist(h.order,best)>1)this.rt.issue(h,{kind:'move',...best});return;}} const tactic=this.s.tactic;if(tactic?.until>this.s.time&&['rally','spread'].includes(tactic.kind)&&danger<1&&!safe){const i=this.s.heroes.indexOf(h),angle=i/this.s.teamSize*Math.PI*2,r=tactic.kind==='spread'?10:2,goal=this.bound(tactic.x+Math.cos(angle)*r,tactic.z+Math.sin(angle)*r);if(dist(h,goal)>2&&this.danger(goal)<1){this.aiMove(h,goal);return;}} if(h.casting||h.channel||h.order?.kind==='cast')return;if(b.kind==='ancient'&&b.form==='melee'&&b.weak<=0&&['warrior','paladin'].includes(h.class)){const ghost=this.s.adds.find(a=>a.ghost&&!a.dead);if(ghost&&dist(ghost,b)>6){const goal=this.bound(ghost.x+(ghost.x-b.x)*.1,ghost.z+(ghost.z-b.z)*.1);this.rt.issue(h,{kind:'move',...goal});return;}} if(b.kind==='demon'&&b.form==='normal'&&b.ult<=0){const energy=this.s.adds.filter(a=>a.chaosEnergy&&!a.dead).sort((a,c)=>dist(h,a)-dist(h,c))[0];if(energy){const runners=alive.filter(a=>a.ai).sort((a,c)=>dist(a,energy)-dist(c,energy));if(runners[0]===h&&dist(h,energy)>3&&this.danger(energy)<1){this.rt.issue(h,{kind:'move',x:energy.x,z:energy.z});return;}}} const dead=this.s.heroes.filter(a=>a.dead).sort((a,b)=>dist(h,a)-dist(h,b))[0]; let enemy=this.s.tactic?.until>this.s.time&&this.s.tactic.kind==='focus'&&!b.infinity?b:this.s.adds.filter(a=>!a.dead&&!a.invulnerable&&(!a.ghost||dist(a,b)<7.5)).sort((a,b)=>(dist(h,a)-(a.prison||a.extract||a.force?50:0))-(dist(h,b)-(b.prison||b.extract||b.force?50:0)))[0]||b;if(['warrior','paladin'].includes(h.class)&&b.taunt>0)enemy=b;h.target=enemy.id; if(dead&&!dead.respawnAt&&(!b.soulcall||dist(dead,b)>14)){const res=kitFor(h).findIndex(s=>['resurrect','reanimate','massres'].includes(s.type));if(res>=0&&h.cd[res]<=0&&h.mana>=kitFor(h)[res].mana){this.command(h.id,{type:'skill',slot:res,id:dead.id,x:dead.x,z:dead.z});return;}if(stageFor(this.s)&&this.s.revives>0&&!alive.some(a=>a.channel?.target===dead.id)){this.command(h.id,{type:'revive',id:dead.id});return;}if((h.build.consumables.remedy||0)>0&&!alive.some(a=>a.channel?.target===dead.id)){if(dist(h,dead)<15){consume(this,h,'remedy');return;}this.rt.issue(h,{kind:'move',x:dead.x,z:dead.z});return;}} const priority=h.class==='priest'?[0,1,3,2,4,5]:h.class==='paladin'?[1,2,0,3]:kitFor(h).map((_,i)=>i); for(const i of priority){const s=skillFor(h,i),reserve=kitFor(h).some(a=>a.type==='interrupt')?35:0;if(!s||h.cd[i]>0||h.mana0)&&!(s.type==='chainheal'&&b.gate);if(s.type==='fullheal')use=low.hp/low.maxHp<.25&&!(low.ritualSacrifice>0); if(s.type==='blessing'){target=alive.find(a=>!a.buffs?.blessing)||h;use=!target.buffs?.blessing;} if(s.type==='shield')use=low.shield<100&&low.hp/low.maxHp<.9; if(s.type==='protect')use=low.hp/low.maxHp<.5&&!low.buffs?.protect; if(s.type==='malice')use=!low.buffs?.malice; if(s.type==='taunt')use=b.target!==h.id||b.taunt<1; if(s.type==='spirit')use=h.hp/h.maxHp<.5; if(s.type==='speed'&&!s.team)use=false; if(s.type==='charge'||s.type==='slide'||s.type==='behind'||s.type==='shadowstep')use=false; if(s.type==='aoe')use=dist(h,enemy)<(s.radius||6)+enemy.radius; if(b.infinity&&enemy===b&&!['heal','renew','chainheal','fullheal','blessing','shield','protect','taunt','spirit','speed','malice'].includes(s.type))use=false; if(b.soulcall&&target&&dist(h,target)>(s.range||0)+(target.radius||.4))use=false; if(use&&this.command(h.id,{type:'skill',slot:i,id:target.id,x:target.x,z:target.z}))return; } if(this.aiUltimatePosition(h,enemy))return;if(h.order?.kind==='move'&&h.path?.length)return;if(h.order?.kind!=='attack'||h.order.target!==enemy.id)this.rt.issue(h,{kind:'attack',target:enemy.id}); } update(dt){if(this.s.paused)return;dt=Math.min(dt,.1);this.s.time+=dt;this.s.pings=this.s.pings.filter(p=>this.s.time-p.time<4);for(const f of this.s.fx)f.t+=dt;this.s.fx=this.s.fx.filter(f=>f.t<1.5); if(this.s.phase==='ready'||this.s.phase==='countdown'){for(const h of this.s.heroes){h.moving=false;const o=h.order;if(h.steer&&h.steer.until>this.s.time){this.rt.nav.steer(h,h.steer,this.rt.speed(h),dt,this.rt.units());}else if(o?.kind==='move'||o?.kind==='patrol'){if(this.rt.move(h,o,dt)){if(o.kind==='patrol'){const q=o.origin;o.origin={x:o.x,z:o.z};o.x=q.x;o.z=q.z;h.pathGoal=null;}else this.rt.done(h);}}else if(o?.kind==='follow'){const friend=this.getHero(o.target);if(friend&&!friend.dead&&dist(h,friend)>2.4)this.rt.move(h,friend,dt);}}this.rt.nav.separate(this.rt.units());if(this.s.phase==='ready')return;this.s.countdown-=dt;if(this.s.countdown<=0){this.s.phase='fight';this.callout('FIGHT · '+BOSSES[this.s.bossIndex].tip,5);}return;} if(this.s.phase!=='fight')return;this.flushPeerDamage();this.s.fightTime+=dt;this.s.match.played+=dt;this.s.match.humans=Math.max(this.s.match.humans,this.s.heroes.filter(h=>!h.ai).length);const b=this.s.boss,d=DIFFICULTIES[this.s.difficulty],learning=tickLearning(this,dt);if(this.s.phase!=='fight')return;tickTeamwork(this,dt); for(const h of this.s.heroes){if(stageFor(this.s)?.simple&&!h.ai&&!h.dead){if(h.hp/h.maxHp<.35)this.command(h.id,{type:'potion',slot:0});if(h.mana/h.maxMana<.25)this.command(h.id,{type:'potion',slot:1});}if(h.dead){tickHeroSystems(this,h,dt);continue;}if(h.ai){if(learning)learningAI(this,h,dt);else this.ai(h,dt);}const controlled=!!h.steer||['move','attackmove','patrol'].includes(h.order?.kind),oldX=h.x,oldZ=h.z;this.effectOwner=h.id;this.rt.tick(h,dt);this.effectOwner=null;countStat(h,'distance',Math.hypot(h.x-oldX,h.z-oldZ));if(controlled)campaignEffort(this.s,h,'travel',Math.hypot(h.x-oldX,h.z-oldZ));} this.rt.tickProjectiles(dt);tickItemSystems(this,dt);trackCampaignProgress(this.s); if(b.hp<=0){if(!learning&&!this.s.training&&onBossDeath(this)){}else {this.win();return;}} b.phase=b.hp/b.maxHp<.3?3:b.hp/b.maxHp<.65?2:1;if(this.s.difficulty==='hard'&&this.s.bossIndex===0&&b.hp/b.maxHp<.25&&!b.enraged){b.enraged=true;this.callout('KILNMAW ENRAGED · Faster attacks',4);} b.meteorResist=Math.max(0,(b.meteorResist||0)-dt);b.hitFlash=Math.max(0,(b.hitFlash||0)-dt);b.weaken=Math.max(0,b.weaken-dt);b.taunt=Math.max(0,(b.taunt||0)-dt);b.stun=Math.max(0,b.stun-dt);b.ult=Math.max(0,b.ult-dt); for(const e of [b,...this.s.adds]){if(e.dead)continue;for(const dot of e.dots){dot.left-=dt;dot.tick-=dt;if(dot.tick<=0){const h=this.getHero(dot.source);if(h){this.hurtEnemy(h,e,dot.power,true,dot.element,{spell:dot.spell,dot:true});if(dot.drain)this.drainEnergy(e,dot.drain);}dot.tick+=1;}}e.dots=e.dots.filter(x=>x.left>0);if(e.energyDots){for(const dot of e.energyDots){dot.left-=dt;this.drainEnergy(e,dot.rate*dt);}e.energyDots=e.energyDots.filter(x=>x.left>0);}for(const key of ['magicVulnerable','attackWeaken','darknessResist','root','slow'])e[key]=Math.max(0,(e[key]||0)-dt);} if(stageFor(this.s)?.simple&&!learning){b.mana=0;b.patternTimer-=dt;if(b.patternTimer<=0){b.patternTimer=5.5;const target=this.alive().find(h=>!h.ai)||this.alive()[0];if(target)this.hazard('circle',{x:target.x,z:target.z,r:3.2,label:'',color:BOSSES[this.s.bossIndex].color},1.7,650,.35);}} const alive=this.alive();if(!alive.length){if(this.s.heroes.some(h=>h.respawnAt>this.s.time))return;this.s.phase='wipe';this.s.wipes++;this.callout('SQUAD DEFEATED',8);this.log('Raid wipe. Regroup and try again.','death');return;} if(b.ult<=0)b.infinity=false; if(!learning&&!this.s.training&&!stageFor(this.s)?.simple)tickBossSystems(this,dt); if(b.casting){if(b.casting.action==='coldsap'){const c=b.casting;c.data.tick-=dt;if(c.data.tick<=0){c.data.tick=.5;c.data.count++;for(const h of this.alive()){h.mana=Math.max(0,h.mana-12-c.data.count*(this.s.difficulty==='normal'||this.s.difficulty==='hard'?2:1));this.hurtHero(h,h.maxMana-h.mana);h.slow=1;}}}if(b.casting.action==='malady'){const t=this.getHero(b.casting.data.target)||this.alive()[0];b.casting.data.tick-=dt;if(b.casting.data.tick<=0&&t){b.casting.data.tick=.1;this.hurtHero(t,300);b.hp=Math.min(b.maxHp,b.hp+40);}}if(b.casting.action==='terrorchannel'){b.casting.data.tick-=dt;if(b.casting.data.tick<=0){b.casting.data.tick=.25;for(const h of this.alive())this.hurtHero(h,155,true);}}if(b.casting.action==='healchannel'){b.casting.data.tick-=dt;if(b.casting.data.tick<=0){b.casting.data.tick=.1;b.hp=Math.min(b.maxHp,b.hp+100);}}b.casting.left-=dt;if(b.casting.left<=0){const c=b.casting;b.casting=null;if(c.action)resolveBossCast(this,c);else if(c.type==='regrowth')b.hp=Math.min(b.maxHp,b.hp+1000);}} if(!b.casting&&b.ult<=0&&b.stun<=0&&b.weak<=0&&!b.rush&&!learning&&!this.s.training&&!stageFor(this.s)?.simple){b.patternTimer-=dt;if(b.patternTimer<=0){nextMechanic(this);b.patternTimer=(b.kind==='demon'?(b.form==='chaos'?6:b.form==='demon'?5:4):4)*(stageFor(this.s)?.pace||1);}} if(b.taunt<=0){const sorted=alive.slice().sort((a,b)=>b.threat-a.threat);b.target=sorted[0].id;} const tank=this.getHero(b.target)||alive[0],stats=bossStats(this);if(!learning&&!tank.dead&&!b.casting&&b.ult<=0&&!b.rush&&b.weak<=0){const aim=tank.decoy||tank,dd=dist(b,aim),heading=Math.atan2(aim.x-b.x,aim.z-b.z);b.attackTimer-=dt;b.attackAnim=Math.max(0,(b.attackAnim||0)-dt);const range=stats.range+b.radius+tank.radius; if(b.enemySwing){b.enemySwing.left-=dt;if(b.enemySwing.left<=0){const t=this.getHero(b.enemySwing.target);if(t&&!t.dead&&!t.decoy&&dist(b,t)0?1-(b.attackWeakenFactor||.25):1)*(b.powerup>0?1.5:1);if(stats.range>5){const length=dist(b,t)||1;this.hazard('missile',{x:b.x,z:b.z,dx:(t.x-b.x)/length,dz:(t.z-b.z)/length,speed:20,width:.6,homing:t.id,label:'',basic:true},0,amount,4);}else this.hurtHero(t,amount);}b.enemySwing=null;}} else if(dd>range&&stats.speed>.1&&!(b.root>0))this.rt.nav.move(b,aim,stats.speed*(b.slow>0?1-(b.moveSlow||.5):1),dt,this.rt.units()); else{const error=Math.atan2(Math.sin(heading-b.facing),Math.cos(heading-b.facing));b.facing+=Math.max(-3*dt,Math.min(3*dt,error));b.moving=false;if(b.attackTimer<=0&&Math.abs(error)<.25&&stats.attack>0&&dd0?1+(b.attackSlow||.2):1);b.attackAnim=.6;b.enemySwing={target:tank.id,left:.2};}}} for(const a of this.s.adds){if(a.dead||a.orb||a.force||a.ghost||a.prison||a.sacrifice||a.speed===0)continue;const t=alive.slice().sort((x,y)=>(dist(a,x)+(x.class==='priest'?-2:0))-(dist(a,y)+(y.class==='priest'?-2:0)))[0],dd=dist(a,t);if(dd>a.radius+t.radius+1.1)this.rt.nav.move(a,t,a.speed,dt,this.rt.units());a.timer-=dt;if(a.timer<=0&&dd!a.dead); for(const h of this.s.hazards){h.t+=dt;if(h.kind==='beam'){h.angle+=(h.angularSpeed||0)*dt;if(h.shrink)h.r=Math.max(1,h.r-h.shrink*dt);}if(this.tickMovingHazard(h,dt))continue;if(h.kind==='safe'&&h.moving){h.x=Math.sin(h.t*.35)*7;h.z=Math.cos(h.t*.35)*6;}if(h.th.delay+h.duration)continue;if(h.pool||h.kind==='safe'){h.tick-=dt;if(h.tick<=0){for(const a of this.alive())if(this.hazardHit(h,a)){recordLessonContact(this,h,a);this.hurtHero(a,h.damage,false,{hazard:{...h}});}h.tick=h.tickEvery||.75;}}else if(!h.hit){h.hit=true;for(const a of this.alive())if(this.hazardHit(h,a)){recordLessonContact(this,h,a);this.hurtHero(a,h.damage,false,{hazard:{...h}});if(h.slow)a.slow=h.slow;if(h.root)a.root=h.root;if(h.silence)a.silence=h.silence;}this.fx('burst',h.x,h.z,h.color,h.r||4);}} for(const h of this.s.hazards.slice())if(h.echoOnDeath&&h.t>=h.delay+h.duration){h.echoOnDeath=false;const len=dist(h,b)||1;this.hazard('missile',{x:h.x,z:h.z,dx:(b.x-h.x)/len,dz:(b.z-h.z)/len,speed:11.25,width:1.5,label:'Ember Echo'},0,250,3.5);} this.s.hazards=this.s.hazards.filter(h=>h.tdist(a,z)z.left>0); } tickMovingHazard(h,dt){if(h.manualMotion)return true;if(h.gateContract)h.currentRadius=Math.max(10,h.r-(h.t-h.delay)*.9);if(h.oscillate)h.currentRadius=Math.max(5,h.r*(.55+.45*Math.cos((h.t-h.delay)*.8)));if(h.expand){h.r=Math.max(2,(h.t-h.delay)*9);h.inner=Math.max(0,h.r-1.6);}if(!['missile','wave','orbit'].includes(h.kind))return false;if(h.th.delay+h.duration)return true;h.hitIds||=[]; if(h.kind==='orbit'){h.balls=[];const count=h.count||7,gap=h.gap||0;for(let i=0;i=350&&!h.brandHoming){h.brandHoming=true;h.duration=age+10;const eligible=this.alive().filter(a=>a.veil<=0);h.homing=eligible.length?eligible[Math.floor(this.rand()*eligible.length)].id:'boss';h.curve=0;}if(h.brandHoming){h.speed=33.333;if(h.homing==='boss'&&dist(h,this.s.boss)<2.5){this.s.boss.brandReturns=(this.s.boss.brandReturns||0)+1;h.t=h.delay+h.duration;return true;}}} if(h.homing){const t=h.homing==='boss'?this.s.boss:this.getHero(h.homing);if(t&&!t.dead){const length=dist(h,t)||1;h.dx=(t.x-h.x)/length;h.dz=(t.z-h.z)/length;}}if(h.bounce&&Math.hypot(h.x,h.z)>h.bounce){const a=Math.atan2(-h.x,-h.z)+(this.rand()-.5)*.77;h.dx=Math.sin(a);h.dz=Math.cos(a);}if(h.curve){const a=Math.atan2(h.dx,h.dz)+h.curve*dt;h.dx=Math.sin(a);h.dz=Math.cos(a);}if(h.burn){h.burnTimer=(h.burnTimer||0)-dt;if(h.burnTimer<=0){h.burnTimer=.3;this.hazard('circle',{x:h.x,z:h.z,r:6.25,pool:true,tickEvery:1,label:'Burn',color:h.color},0,25,3);}}h.x+=h.dx*h.speed*dt;h.z+=h.dz*h.speed*dt; const targets=h.friendly?[this.s.boss,...this.s.adds]:this.alive();for(const a of targets){if(a.dead||h.hitIds.includes(a.id)&&!h.pierce||!this.hazardHit(h,a,a.radius))continue;if(h.pierce){h.hitTimers||={};if((h.hitTimers[a.id]||0)>h.t)continue;h.hitTimers[a.id]=h.t+(h.tickEvery||.1);}else h.hitIds.push(a.id);if(h.friendly){const owner=this.getHero(h.owner);if(owner){this.hurtEnemy(owner,a,h.power,true,h.element);this.resolveCast(h.castId,true);}}else{this.hurtHero(a,h.damage,false,{hazard:{...h}});if(h.knockback){const len=dist(a,h)||1;Object.assign(a,this.bound(a.x+(a.x-h.x)/len*h.knockback,a.z+(a.z-h.z)/len*h.knockback));}if(h.kind==='missile'&&!h.pierce)h.t=h.delay+h.duration;this.fx('impact',a.x,a.z,h.color,2);}} return true; } win(){const s=this.s,b=s.boss;b.dead=true;b.diedAt=s.time;b.hp=0;b.infinity=false;s.hazards=[];s.zones=[];s.adds=[];const rank=clearRank(s.encounterDeaths||0);rank.gold+=s.difficulty==='easy'?5:s.difficulty==='veryeasy'?15+Math.min(20,(s.encounterDeaths||0)*2):0;const karma=s.difficulty==='veryeasy'&&(s.completed.length+1)%2===0?2:1;if(stageFor(s)){s.match.progress??=Array(s.match.target).fill(0);s.match.progress[s.completed.length]=1;}s.completed.push({boss:s.bossIndex,time:s.fightTime,rank:rank.letter,deaths:s.encounterDeaths||0,gold:rank.gold,karma});s.lastLoot={rank:rank.letter,gold:rank.gold,karma};for(const h of s.heroes){countStat(h,'goldEarned',rank.gold);countStat(h,'gritEarned',karma);h.gold+=rank.gold;ensureBuild(h).karma+=karma;if(h.skeleton)endSkeleton(this,h);if(h.reaver)endReaver(this,h);h.dead=false;this.rt.interrupt(h);this.refreshStats(h,true);h.channel=null;h.powerup=null;h.gatePaused=false;h.ritualSacrifice=0;h.thunderPuller=0;h.thunderAftershock=0;if(h.token){const gain=(h.deaths||0)===h.token.deaths?22:-5;countStat(h,gain>0?'goldEarned':'goldSpent',Math.abs(gain));h.gold+=gain;h.token=null;}const e=h.essences||0;if(s.difficulty==='veryeasy'||s.difficulty==='easy')h.essences=Math.min(5,e+(e<3?1:0)+(e<2?1:0)+(s.difficulty==='veryeasy'&&e<3?1:0));if(s.difficulty==='moderate'&&e<2)h.essences=e+1;if(s.difficulty==='normal'&&!e&&[2,4,6,8].includes(s.completed.length))h.essences=1;}rollLoot(this,rank.letter);if(s.continuesEnabled&&([2,6].includes(s.completed.length)||['easy','veryeasy'].includes(s.difficulty)&&s.completed.length>=2&&s.completed.length<=6&&s.continues<2))s.continues++;s.phase=s.practice||s.completed.length>=s.match.target||s.bossIndex===BOSSES.length-1?'victory':'intermission';if(s.phase==='victory')s.match.finished=true;this.fx('burst',b.x,b.z,BOSSES[s.bossIndex].color,12);this.callout(BOSSES[s.bossIndex].name.toUpperCase()+' DEFEATED',7);this.log('Rank '+rank.letter+' · +'+rank.gold+' gold · +'+karma+' Grit.','loot');} aiBuild(h){const build=ensureBuild(h);for(let slot=0;slot<2;slot++)while(h.potions[slot]<(slot===1?5:3)&&h.gold>=5)this.buy(h,slot===0?'health_potion':'mana_potion');if(h.essences<1&&h.gold>=45&&this.s.difficulty!=='hard')this.buy(h,'essence');while(h.gold>=45){if(!this.buy(h,'karma'))break;}for(const slot of ['weapon','armor','charm','tome']){if(build.equipment[slot])continue;const offer=build.offers.map(equipmentFor).find(i=>i?.slot===slot&&i.karma<=build.karma);if(offer)this.buy(h,offer.id);}while(build.karma>0){const manaGoal=h.class==='rogue'?0:['warrior','ranger','deathknight'].includes(h.class)?1:2,healthGoal=this.s.completed.length>=6?5000:4000,id=h.maxHp=3&&h.gold>=5&&!h.build.consumables.savior)this.buy(h,'savior');if(h.potions[0]<2)this.buy(h,'health_potion');if(h.potions[1]<2)this.buy(h,'mana_potion');} finish(){if(!['ready','countdown','fight','wipe','intermission'].includes(this.s.phase)||this.s.match.finished)return false;this.s.phase='defeat';this.s.match.finished=true;return true;} snapshot(){return {...JSON.parse(JSON.stringify(this.s)),randomSeed:this.randomSeed};} restore(state){if(!state||state.version!==4||!Array.isArray(state.heroes))return false;this.randomSeed=Number.isInteger(state.randomSeed)?state.randomSeed:32515;this.s=state;for(const h of state.heroes)if(!h.bookStats)h.bookStats={damage:h.damage||0,healing:h.healing||0,taken:h.taken||0,attacks:h.attackCount||0,revives:h.resurrections||0,deaths:h.deaths||0};if(state.match?.kind==='campaign'&&(state.ruleset<8||state.match.balance!==CAMPAIGN_BALANCE)){state.difficulty=stageFor(state).difficulty;if(state.learning&&!state.match.finished){state.learning=null;state.teamwork=null;state.phase='ready';state.boss.dead=false;}state.match.balance=CAMPAIGN_BALANCE;state.match.progress??=Array(state.match.target).fill(0);state.match.effort??={casts:0,travel:0,tactics:0};trackCampaignProgress(state);}if(!state.ruleset||state.ruleset<5){const map=[0,1,2,6,3,4,5,7,8,9];state.bossIndex=map[state.bossIndex]??0;for(const c of state.completed||[])c.boss=map[c.boss]??c.boss;state.votes={};for(const h of state.heroes){h.legacyScale=1+Math.max(0,(h.level||1)-1)*.1;ensureBuild(h).karma=1+(state.completed?.length||0);}state.ruleset=5;}this.s.ruleset=8;this.s.loot||=freshLoot();this.s.continues??=2;this.s.continuesEnabled??=true;this.s.match||={id:'legacy_'+Date.now().toString(36),kind:this.s.practice?'practice':'raid',target:this.s.practice?1:10,played:this.s.fightTime||0,humans:1,finished:['victory','defeat'].includes(this.s.phase)};this.s.votes||={};this.s.pings||=[];this.s.chat||=[];this.s.projectiles||=[];this.s.casts||=[];this.s.heroes.forEach(h=>{ensureBuild(h);h.essences??=0;h.deaths??=0;h.soulKills??=0;while(h.cd.lengthx.id||0));return true;} } ===== hero-systems.js ===== import {countStat} from './player-records.js'; import {kitFor,unitStats} from './kits.js'; const distance=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); export function underPhoenix(g,h){return g.alive().some(a=>a.class==='fire'&&!a.skeleton&&!a.reaver&&distance(a,h)<=12.5);} export function phoenixCast(g,h,s){if(!['ally','corpse','none','reanimate'].includes(s.target)&&underPhoenix(g,h)&&g.rand()<.1)h.mana=Math.min(h.maxMana,h.mana+20);} export function phoenixAttack(g,h){if(['fire','ice','priest','warlock','druid'].includes(h.class)&&!h.skeleton&&!h.reaver&&underPhoenix(g,h)&&g.rand()<.1)h.mana=Math.min(h.maxMana,h.mana+30);} export function skeletonKind(h){return h.class==='ranger'?'archer':['warrior','paladin','rogue','deathknight'].includes(h.class)?'warrior':'magi';} export function raiseSkeleton(g,h,duration=30,kind=skeletonKind(h)){ if(h.skeleton)endSkeleton(g,h); if(!h.dead)return false; h.skeleton={kind,left:duration,body:{x:h.x,z:h.z,mana:h.mana},cd:[...h.cd]}; h.dead=false;h.reaver=null;h.buffs={};h.cd=kitFor(h).map(()=>0);g.refreshStats(h,true);g.rt.interrupt(h);g.rt.done(h);h.veil=3;g.fx('burst',h.x,h.z,'#d5d0bb',3);return true; } export function endSkeleton(g,h){const f=h.skeleton;if(!f)return;h.skeleton=null;h.dead=true;h.x=f.body.x;h.z=f.body.z;h.mana=f.body.mana;h.cd=f.cd;h.hp=0;h.buffs={};g.refreshStats(h);g.rt.interrupt(h);} export function transformReaver(g,h){if(h.reaver||h.skeleton)return false;h.reaver={left:15,cd:[...h.cd],hp:h.hp/h.maxHp,mana:h.mana/h.maxMana,pacts:2};h.buffs={};h.cd=kitFor(h).map(()=>0);g.refreshStats(h,true);g.rt.interrupt(h);g.rt.done(h);return true;} export function endReaver(g,h){if(!h.reaver)return;const hp=h.hp/h.maxHp,mana=h.mana/h.maxMana,cd=h.reaver.cd;h.reaver=null;h.cd=cd;g.refreshStats(h);h.hp=Math.max(1,h.maxHp*hp);h.mana=h.maxMana*mana;g.rt.interrupt(h);g.rt.done(h);} export function tickHeroSystems(g,h,dt){ if(h.skeleton){h.skeleton.left-=dt;if(h.skeleton.left<=0){endSkeleton(g,h);return;}} if(h.reaver){h.reaver.left-=dt;if(h.reaver.left<=0)endReaver(g,h);} h.resLock=Math.max(0,(h.resLock||0)-dt);h.ritualSacrifice=Math.max(0,(h.ritualSacrifice||0)-dt);if(h.thunderAftershock>0){h.thunderAftershock-=dt;g.loseLife(h,1000*dt);h.mana=Math.max(0,h.mana-2*dt);}if(h.thunderPuller>0){h.thunderPuller-=dt;g.heal(h,h,dt);h.mana=Math.max(0,h.mana-20*dt);} if(h.class==='druid'){const max=6+(h.stats?.veraCharges||0);h.vera??={charges:max,timer:0};h.vera.charges=Math.min(max,h.vera.charges);if(h.vera.charges=15-(h.stats?.veraRecharge||0)){h.vera.timer=0;h.vera.charges++;}}} if(h.respawnAt&&h.dead&&g.s.time>=h.respawnAt){h.respawnAt=0;g.revive(h,h,1);h.mana=h.maxMana;} } export function reanimate(g,caster,target){ if(target?.dead||target?.skeleton){raiseSkeleton(g,target);return;} const ground=!target,cost=ground?450:750; if(caster.hp<=cost)return; g.hurtHero(caster,cost,true);const ally=target||caster;ally.mana=Math.min(ally.maxMana,ally.mana+(ground?75:600)); } export function sanctuaryHeal(caster,target){return caster.class==='paladin'&&distance(caster,target)<=7.5?(target.hp/target.maxHp<.2?400:100):0;} export function splitHealing(g,source,allies,budget,cap){if(!allies.length)return;const amount=Math.min(cap,budget/allies.length);for(const a of allies)g.heal(source,a,amount);} export function killHero(g,h,{exempt=false,permanent=false}={}){ if(h.dead)return; if(h.skeleton){endSkeleton(g,h);return;} if(h.reaver){h.reaver=null;g.refreshStats(h);} if(!exempt)g.s.encounterDeaths++; h.ritualSacrifice=0;h.thunderPuller=0;h.thunderAftershock=0;h.shock=null;h.siphon=null;h.malady=null;h.extract=null;h.prisonId=null;h.deaths=(h.deaths||0)+1;countStat(h,'deaths');h.dead=true;h.deathAt=g.s.time;h.hp=0;h.channel=null;h.casting=null;h.swing=null;h.queue=[];h.soulKills=0; if(!permanent&&h.essences>0){h.essences--;h.respawnAt=g.s.time+5;} else if(!permanent&&h.stats?.blackMagic&&!h.blackMagicUsed){h.blackMagicUsed=true;raiseSkeleton(g,h,40);h.skeleton.mass=true;h.cd=kitFor(h).map(()=>0);} g.log(h.name+' died.','death');g.fx('death',h.x,h.z,g.color(h),2); } export function lifeSave(g,h,amount){ if(h.hp>amount)return false; if(h.stats?.deathGuard&&h.mana>=35&&(h.build.itemCds.deathguard||0)<=0){h.mana-=35;h.build.itemCds.deathguard=45;h.veil=1.5;return true;} const reaver=g.alive().find(a=>a.reaver?.pacts>0&&distance(a,h)<=7.5&&a!==h); if(reaver){reaver.reaver.pacts--;h.veil=3;g.fx('shield',h.x,h.z,'#a8c6c4',2);return true;} return false; } export function unitAttack(h){const s=unitStats(h);return h.class==='druid'&&(h.form==='bear'||h.bear)&&!h.reaver&&!h.skeleton?{...s,range:1.875,attack:45,attackCd:1.8}:s;} export function attackTiming(h,stats=unitAttack(h)){ const b=h.buffs||{},speed=(b.fury?(b.fury.attackSpeed||1.5):b.malice?1.1:b.focusfire?2:b.postmortem||b.powerFury?1.5:1)*(1+(h.stats?.attackSpeed||0)); return {cycle:(h.bear?1.8:stats.attackCd)/speed,windup:(h.bear?.22:stats.range+(h.stats?.range||0)>5?.20:.16)/speed}; } ===== item-systems.js ===== import {countStat} from './player-records.js'; import {supplyFor,equipmentFor,ensureBuild,SUPPLIES} from './builds.js'; import {transformReaver,raiseSkeleton,killHero,splitHealing} from './hero-systems.js'; const dist=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); export function activeExtra(g,h,item,cmd={}){ const a=item.active,b=g.s.boss,ally=g.getHero(cmd.id)||g.alive().slice().sort((x,y)=>x.hp/x.maxHp-y.hp/y.maxHp)[0]||h; const p=Number.isFinite(cmd.x)&&Number.isFinite(cmd.z)?g.bound(cmd.x,cmd.z):g.getHero(cmd.id)||{x:b.x,z:b.z}; switch(a.kind){ case 'invulnerable':if(b.gravefrost&&!['prelude','barrier'].includes(b.gravefrost.phase))h.mana=0;h.veil=a.duration;if(a.shared)h.build.itemCds[a.shared]=a.cd;if(a.recharge){const uses=(h.build.itemUses[item.id]||0)+1;h.build.itemUses[item.id]=uses%2;if(uses%2===0)h.build.itemCds[item.id]=a.recharge;}break; case 'pig':h.decoy={left:a.duration,x:h.x,z:h.z};b.target=h.id;b.taunt=a.duration;break; case 'sacrifice':g.hurtEnemy(h,b,a.power,true);killHero(g,h,{exempt:true});break; case 'hope':for(const hero of g.s.heroes)if((hero.dead||hero.skeleton)&&!['priest','paladin'].includes(hero.class)){g.revive(h,hero,1);hero.mana=hero.maxMana;}killHero(g,h,{exempt:true});break; case 'soulstealer':g.rt.buff(h,'soulstealer',14,{factor:1.24+Math.min(8,h.soulKills||0)*.12});h.soulKills=0;break; case 'quell':{let count=0;for(const e of [b,...g.s.adds])if(!e.dead&&dist(e,p)<5+e.radius){g.hurtEnemy(h,e,300,true);if(e.dead&&count++<5)summon(g,h,'skeleton',5,55,1.875);}g.fx('burst',p.x,p.z,'#baabc5',5);break;} case 'reckless':g.hurtHero(h,250,true);for(const e of [b,...g.s.adds])if(!e.dead&&dist(e,b)<5)g.hurtEnemy(h,e,400,true,null,{physical:true});break; case 'radiance':g.rt.buff(h,'radiance',30);break; case 'metamorphosis':transformReaver(g,h);break; case 'inferno':case 'oblivion':startVortex(g,h,a.kind,p);break; case 'gemfire':for(const e of [b,...g.s.adds])if(!e.dead&&dist(e,h)<7.5)g.hurtEnemy(h,e,250,true);g.fx('burst',h.x,h.z,'#e8a377',7.5);break; case 'gemwater':summon(g,h,'siren',10,25,15,{drain:1});break; case 'gemlight':g.heal(h,h,h.maxHp);h.mana=Math.min(h.maxMana,h.mana+h.maxMana*.3);break; case 'gemshadow':{const friends=g.alive().filter(x=>x!==h).sort((x,y)=>dist(x,p)-dist(y,p));if(friends.length){const legal=g.rt.nav.nearest(friends[0],h.radius,g.rt.units(),h);if(legal){h.x=legal.x;h.z=legal.z;g.rt.interrupt(h);g.rt.done(h);}}g.rt.buff(h,'haste',3.5,{factor:2});break;} default:return false; } if(item.charges){h.build.itemUses[item.id]=(h.build.itemUses[item.id]??item.charges)-1;if(h.build.itemUses[item.id]<=0){h.build.equipment.gem=null;h.build.owned=h.build.owned.filter(id=>id!==item.id);}} return true; } export function summon(g,h,kind,left,attack,range,extra={}){g.s.summons||=[];const angle=g.rand()*Math.PI*2;g.s.summons.push({id:'friend'+g.uid(),owner:h.id,kind,left,x:h.x+Math.sin(angle),z:h.z+Math.cos(angle),attack,range,timer:.5,...extra});} export function startVortex(g,h,kind,p){const duration=kind==='inferno'?2:kind==='oblivion'?4:2.4,cap=kind==='inferno'?1750:kind==='oblivion'?2450:3000;g.s.vortex={kind,owner:h.id,x:p.x,z:p.z,r:12.5,left:duration,duration,budget:cap,actual:0,tick:0};h.build.itemUses[kind]=1;g.fx('burst',p.x,p.z,kind==='inferno'?'#e7a07f':'#baa3d1',12.5);} export function canUseVortex(g,h,kind){return !g.s.vortex&&!(h.build.itemUses[kind])&&g.s.fightTime>=30;} export function consume(g,h,id,targetId=null){ const i=supplyFor(id),build=ensureBuild(h),count=build.consumables[id]||0,b=g.s.boss; if(!i||!count||h.dead||h.stun>0||g.s.phase!=='fight'||(build.itemCds[id]||0)>0)return false; if(id==='energysap'&&(b.hp/b.maxHp<=.2||(b.sapUntil||0)>g.s.time))return false; if(id==='manacontrol'&&(b.controlUntil||0)>g.s.time)return false; if(id==='omega'&&!canUseVortex(g,h,'omega'))return false; const corpses=g.s.heroes.filter(a=>a.dead||a.skeleton);if(['remedy','illumination'].includes(id)&&!corpses.length)return false; build.consumables[id]--;countStat(h,['super_health','super_mana','replenishment'].includes(id)?'potions':'items');const event=g.announceCast(h,'supply',{name:i.name});g.resolveCast(event,true);g.resolveCast(event); switch(id){ case 'greed':{const amount=(g.s.pickups||[]).reduce((n,p)=>n+p.gold,0);h.gold+=amount;countStat(h,'goldEarned',amount);g.s.pickups=[];g.fx('number',h.x,h.z,'#e4c778',1,'+'+amount+' gold');break;} case 'super_health':g.heal(h,h,3000);if(h.ritualSacrifice>0)g.loseLife(h,3500,true);build.itemCds[id]=35;break; case 'super_mana':h.mana=Math.min(h.maxMana,h.mana+500);build.itemCds[id]=45;break; case 'savior':if(b.gravefrost&&!['prelude','barrier'].includes(b.gravefrost.phase))h.mana=0;h.veil=2;build.itemCds[id]=20;break; case 'remedy':g.revive(h,corpses.find(a=>a.id===targetId)||corpses.sort((a,b)=>dist(h,a)-dist(h,b))[0],.4);break; case 'illumination':h.mana=0;for(const a of corpses)g.revive(h,a,.6);break; case 'manatide':h.mana=Math.min(h.maxMana,h.mana+600);build.itemCds.manatide=build.itemCds.replenishment=45;break; case 'replenishment':g.heal(h,h,4500);if(h.ritualSacrifice>0)g.loseLife(h,9000,true);h.mana=Math.min(h.maxMana,h.mana+300);build.itemCds.manatide=build.itemCds.replenishment=45;break; case 'energysap':g.drainEnergy(b,400);b.sapUntil=g.s.time+40;break; case 'manacontrol':g.hurtEnemy(h,b,h.mana*2.2,true);h.mana=0;b.controlUntil=g.s.time+60;break; case 'spirits':for(let n=0;n<3;n++)summon(g,h,'ancient',12,25,15,{healAll:75});break; case 'omega':h.mana=0;startVortex(g,h,'omega',b);break; case 'natureswrath':{const length=dist(h,b);g.s.zones.push({id:g.uid(),source:h.id,x:b.x,z:b.z,r:5,left:Math.max(.2,length/18),tick:Math.max(.1,length/18-.05),power:Math.min(3500,1000+length*100),oneShot:true,stopEnergy:8,dragon:true,color:'#b0c4a2'});for(const a of g.s.adds)g.hurtEnemy(h,a,a.hp+1,true);break;} case 'infinityscroll':g.s.delayed||=[];g.s.delayed.push({kind:'infinityscroll',owner:h.id,left:2.5,power:3150});g.callout('ARENA RUPTURE · 3',2.5);break; } return true; } export function buySupply(g,h,id){ const item=supplyFor(id);if(!item)return null;const build=ensureBuild(h),count=build.consumables[id]||0,d=g.s.difficulty; if(h.gold(a.build?.consumables[id]||0)>0))return false; if(item.kind==='essence'&&(d==='hard'||h.essences>=(['easy','veryeasy'].includes(d)?5:2)))return false; if(item.kind==='token'&&h.token)return false; if(item.kind==='powerup'&&h.powerup)return false; if(['health_potion','mana_potion'].includes(id)&&h.potions[id==='health_potion'?0:1]>=5)return false; if(count>=5)return false; h.gold-=item.price;build.karma-=item.karma||0; if(id==='karma')build.karma++; else if(id==='essence')h.essences++; else if(id==='token')h.token={deaths:h.deaths||0}; else if(id==='random_powerup')h.powerup={pending:true}; else if(id==='health_potion'||id==='mana_potion')h.potions[id==='health_potion'?0:1]++; else build.consumables[id]=count+(id==='spirits'?2:1); if(item.rare)g.s.loot.rareStock=g.s.loot.rareStock.filter(x=>x!==id); return true; } export function tickItemSystems(g,dt){ const s=g.s,b=s.boss; if(s.arenaGold){const gold=s.arenaGold;gold.timer-=dt;if(gold.timer<=0&&gold.made<5){gold.timer+=5;const random=()=>{gold.seed=(Math.imul(gold.seed,1664525)+1013904223)>>>0;return gold.seed/4294967296;};if(random()<.5){gold.made++;const angle=random()*Math.PI*2,p=g.bound(Math.sin(angle)*(10+random()*12),Math.cos(angle)*(10+random()*12));s.pickups.push({id:'gold'+g.uid(),...p,gold:5});}}for(const p of s.pickups){const collector=g.alive().find(h=>dist(h,p)<1.3);if(collector){collector.gold+=p.gold;countStat(collector,'goldEarned',p.gold);p.collected=true;g.fx('number',p.x,p.z,'#e4c778',.8,'+5 gold');}}s.pickups=s.pickups.filter(p=>!p.collected);} for(const h of s.heroes){if(h.decoy){h.decoy.left-=dt;if(h.decoy.left<=0)h.decoy=null;}} for(const summon of s.summons||[]){summon.left-=dt;const owner=g.getHero(summon.owner);if(!owner||owner.dead)continue;const target=s.adds.find(a=>!a.dead&&!a.invulnerable)||b;const length=dist(summon,target);if(length>summon.range){summon.x+=(target.x-summon.x)/length*dt*5;summon.z+=(target.z-summon.z)/length*dt*5;}summon.timer-=dt;if(summon.timer<=0&&lengtha.left>0); if(s.vortex){const v=s.vortex,owner=g.getHero(v.owner);v.left-=dt;v.tick-=dt;if(v.tick<=0&&owner){v.tick=.2;const power=v.budget*.2/v.duration*(v.kind==='omega'?.25+g.rand()*1.5:1),before=b.hp;if(dist(b,v)!e.done); } export function applyPowerup(g,h){if(!h.powerup?.pending)return;const kind=['fury','regen','spellpower','divine'][Math.floor(g.rand()*4)];h.powerup={kind,pending:false};if(kind==='fury'){g.rt.buff(h,'powerFury',120);g.rt.buff(h,'haste',120,{factor:3});}if(kind==='regen'){g.rt.buff(h,'mend',3600,{source:h.id,power:65});g.rt.buff(h,'powerMana',3600);}if(kind==='spellpower'){h.mana=Math.min(h.maxMana,h.mana+150);g.rt.buff(h,'spiritCharge',16,{factor:2});}if(kind==='divine')g.rt.buff(h,'powerProtection',3600);} ===== boss-systems.js ===== const encounterNames={"Ancient Ghost":"Kurr’s Echo","Power Node":"Storm Anchor","Energy Orb":"Spark Orb","Extracted Shadow":"Split Wisp","Corruption Stream":"Murk Stream","Shadow Soul":"Hush Wisp","Ritual Soul":"Ritual Wisp","Ice Prison":"Glass Prison","Willing Sacrifice":"Offering","Ancient Root":"Elder Root","Spirit Force":"Root Runner","Forest Spirit":"Root Wisp","Pandemonium Clone":"Vorraka Echo","Barrier Crystal":"Glass Anchor","Sea Elemental":"Tidebody","Sea Spirit":"Tidewisp","Sea Wraith":"Tideling","Frost Shard":"Rime Splinter","Frost Wolf":"Rime Hound","Bird Crystal":"Glider Crystal","Living Pod":"Crown Pod","Pact Bird":"Oathwing","Chaos Energy":"Rift Spark","Soul Statue":"Hollow Idol","Cursed Soul":"Bitter Wisp","Falling seed":"Crown Seed","Siren":"Tide Singer","Skeleton":"Boneling","Skeleton Archer":"Bonebow","Skeleton Warrior":"Boneguard","Skeleton Warriors":"Boneguards","Skeleton magi":"Bonemage","Reaver Demon":"Rift Beast"}; import {stageFor} from './journey.js'; import {BOSSES,DIFFICULTIES} from './data.js'; import {ORIGINAL_MECHANICS} from './original-content.js'; import {beginSourceUltimate,tickSourceUltimates} from './ultimate-systems.js'; const TAU=Math.PI*2,rad=n=>n*Math.PI/180,distance=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); const point=a=>({x:a.x,z:a.z}); const polar=(p,r,a)=>({x:p.x+Math.sin(a)*r,z:p.z+Math.cos(a)*r}); export const FIRE_CHALLENGES=['Ember Twins','Ember Echo','Rimefire Duet','Drifting Light','Lights Out','Last Spark','Cinder Halo']; export const ENCOUNTER_RULES={ fire:{range:18.75,attack:250,cd:.5,speed:4.75,ultimate:'Last Furnace'},water:{range:75,attack:250,cd:.5,speed:4.75,ultimate:'Deep Current'},brute:{range:3.75,attack:850,cd:1,speed:3.75,ultimate:'Crushing Pressure'},thunder:{range:12.5,attack:150,cd:.85,speed:6.125,ultimate:'Storm Grip'},druid:{range:15,attack:500,cd:.5,speed:5.5,ultimate:'Crownroot'},shadow:{range:2.5,attack:700,cd:.75,speed:6.5,ultimate:'Hollow Circuit'},ice:{range:87.5,attack:350,cd:.4,speed:.025,ultimate:'Winter Spiral'},light:{range:75,attack:500,cd:.5,speed:4.75,ultimate:'Halo Lock'},ancient:{range:62.5,attack:750,cd:.6,speed:.025,ultimate:'Root Oath'},demon:{range:3.75,attack:750,cd:1,speed:5,ultimate:'Riftbrand'} }; export function initEncounter(g){const b=g.s.boss,id=BOSSES[g.s.bossIndex].id;b.kind=id;b.form=['druid','ancient'].includes(id)?'caster':'normal';b.events=[];b.actions=[];b.cycles=0;b.armorWindow=0;b.windowDamage=0;b.armorTier=0;b.armorMultiplier=1;b.energyTimer=0;b.patternTimer=4;b.weak=0;b.eternal=['shadow','ancient'].includes(id);b.gateTimer=0;if(id==='fire'){const pool=[1,2,3,4,5,6,7];b.fireShards=[];for(let n=0;n0)r.speed+=1;r.speed+=b.deniedSpeed||0;if(b.hellfire>0)Object.assign(r,{range:100,attack:150,cd:.1});return r;} const pick=g=>g.alive()[Math.floor(g.rand()*g.alive().length)]||g.s.heroes[0]; function circle(g,p,r,power,delay=1,label='',extra={}){return g.hazard('circle',{...point(p),r,label,...extra},delay,power,extra.duration||.2);} function ray(g,from,to,width,power,delay=.8,label='',extra={}){return g.hazard('line',{...point(from),x2:to.x,z2:to.z,width,label,...extra},delay,power,extra.duration||.2);} function missile(g,from,to,speed,width,power,delay=.4,label='',extra={}){const length=distance(from,to)||1;return g.hazard('missile',{...point(from),dx:(to.x-from.x)/length,dz:(to.z-from.z)/length,speed,width,label,...extra},delay,power,extra.duration||3);} function burst(g,from,count,speed,width,power,delay=.8,label='',extra={}){for(let i=0;i0){b.divinity--;b.hp=1;b.infinity=true;b.ult=4;b.casting=null;event(g,4,'rebirth');circle(g,b,15,1500,4,'Halo Charge');g.callout('HALO CHARGE · '+b.divinity+' charges remain',4);return true;}if(b.eternal&&b.weak<=0){b.hp=Math.min(b.maxHp,2000);g.callout('UNDYING SHELL · Create a weakened state',4);return true;}if(b.kind==='ancient'&&b.form==='caster'&&DIFFICULTIES[g.s.difficulty].tier>=2){b.form='melee';b.casting=null;b.rootPuzzle=0;b.maxHp=14000*g.s.teamSize/8;b.hp=b.maxHp;b.eternal=true;b.weak=0;b.gateTimer=0;b.phase=1;b.ult=1.6;b.infinity=true;spawnEncounterUnit(g,'Ancient Ghost',{x:12,z:0},3500,{ghost:true,speed:7});g.callout('OLD KURR · Final body',3);return true;}return false;} export function resolveBossCast(g,c){const b=g.s.boss,d=c.data||{};switch(c.action){case 'waterseal':b.hp=Math.min(b.maxHp,b.hp+1320);if(g.s.difficulty!=='veryeasy')b.mana=100;break;case 'heal':b.hp=Math.min(b.maxHp,b.hp+(d.power||3500));break;case 'revitalize':b.hp=Math.min(b.maxHp,b.hp+3500);break;case 'terror':for(const h of g.alive()){h.stun=Math.max(h.stun||0,2);g.hurtHero(h,270);}break;case 'wither':for(const h of g.alive())if(d.outside?distance(h,b)>12.5:distance(h,b)<12.5){h.silence=10;h.disarm=10;h.slow=10;h.buffs={};}break;default:if(c.action)runMechanic(g,c.action,true);}} export function runMechanic(g,id,resolved=false){ const b=g.s.boss,d=DIFFICULTIES[g.s.difficulty],target=pick(g),p=point(target),hard=d.tier>=2; const name=Object.values(ORIGINAL_MECHANICS).flat().find(m=>m.id===id)?.name||(id==='ancient_omega_x'?'Threefold Rupture':'Boss attack'); g.log(BOSSES[g.s.bossIndex].name+' · '+name+'.'); switch(id){ case 'fire_eruption':{if(b.artillery?.left>0){runMechanic(g,'fire_flamestrike');break;}b.artillery={origin:polar({x:0,z:0},18.75,g.rand()*TAU),left:b.fireShards?.includes(6)?60:10,tick:0};break;} case 'fire_flameburst':for(let burstIndex=0;burstIndex<4;burstIndex++)for(let n=0;n<9;n++)missile(g,b,polar(b,30,g.rand()*TAU),8,1.5,250,1.5+burstIndex*.1,'Cinder Scatter',{duration:3.9,echoOnDeath:b.fireShards?.includes(2)});break; case 'fire_flameturbine':{const r=distance(b,target)>6.875&&distance(b,target)<20?distance(b,target):17.5;g.hazard('orbit',{...point(b),r,orbRadius:2.5,count:1,angularSpeed:rad(12)/.05,tickEvery:.05,label:'Cinder Wheel'},.5,350,4.4);break;} case 'fire_hellfire':b.hellfire=3;g.callout('HEAT LOCK · Protect the target',3);break; case 'fire_flamestrike_v':if(!resolved){cast(g,name,g.s.difficulty==='hard'?1.6:hard?1.7:1.8,id);return true;}for(let n=0;n<5;n++)circle(g,polar(b,7.5,n*TAU/5),8.75,hard?(d.tier===3?150:100):50,0,name,{duration:2.7,pool:true,tickEvery:.33});break;case 'fire_flamestrike':circle(g,p,8.75,50,1.2,name,{duration:2.7,pool:true,tickEvery:.33});break; case 'fire_fireball':{let angle=g.rand()*TAU;for(let n=0;n<(b.fireShards?.includes(7)?12:1);n++){angle+=n*TAU/12;missile(g,b,polar(b,25,angle),d.tier===3?7.425:5.5,1.75,500,n*.05,'Kiln Shot',{duration:d.tier===3?2.222:3,burn:true});}break;} case 'water_waterseal':cast(g,'Tide Stitch',4.5,'waterseal');break; case 'water_under_the_sea':{b.ult=6.5;b.infinity=true;for(let n=0;n<4;n++){const t=g.alive()[n%g.alive().length],q=point(t);circle(g,q,5,d.tier>=2?15000:1250,1.5+n*1.2,'Undertow Hunt');circle(g,q,7.5,d.tier>=2?1000:750,1.5+n*1.2,'Wake Slap');event(g,1.5+n*1.2,'waterdash',q);}g.callout('UNDERTOW HUNT · KEEP MOVING',4);break;} case 'water_submerge':b.hidden=3;b.ult=Math.max(b.ult,3);b.infinity=true;event(g,3,'emerge',polar({x:0,z:0},13.75+g.rand()*8.75,g.rand()*TAU));break; case 'water_call_the_sea':for(let n=0;n<(d.tier===0?4:6);n++)event(g,1.5+n*.25,'seaadd',polar({x:0,z:0},g.rand()*22,g.rand()*TAU));break; case 'water_splash':for(let n=0;n<3;n++)event(g,n*.2,'splash',{target:target.id});break; case 'water_tornadocall':for(let n=0;n<3;n++)missile(g,polar(b,20,n*TAU/3),p,5,2,350,.8,'Tide Spiral',{homing:target.id,duration:8,tickEvery:.3,pierce:true});break; case 'water_tidalwave':for(let n=0;n<3;n++)g.hazard('wave',{x:-26,z:-14+n*14,dx:1,dz:0,width:2.5,speed:12,label:'Breaker Wall'},.8+n*.3,1200,4.5);break; case 'water_burst':for(let n=0;n<16;n++){const q=polar(p,3.75+g.rand()*1.25,g.rand()*TAU);circle(g,q,4.75,500,distance(b,q)/11.25+n*.2,'Spray Mines');}break; case 'brute_seismic_slam':for(const a of [-75,-30,15,60])missile(g,polar(b,5,b.facing+rad(a)),polar(b,40,b.facing+rad(a)),50,3.75,1500,1,'Fault Fan',{duration:3.6,pierce:true,tickEvery:.08,curve:rad(45)});break; case 'brute_intimidate':b.intimidate=17;for(const h of g.alive())h.intimidated=17;break; case 'brute_charge':b.rush={left:2,target:target.id,speed:17.85,damage:450,radius:3.75,tick:0,period:.04};g.callout('BOULDER RUSH · MOVE',1);break; case 'brute_slam_v':g.hazard('ring',{...point(b),r:2,inner:0,expand:true,knockback:9.2,label:'Ground Halo',tickEvery:.03,pool:true},.6,13,2.2);break; case 'brute_earthshock':circle(g,p,4,900+d.tier*150,.4,'Fault Pop');break; case 'brute_pummel':for(let n=0;n<6;n++)circle(g,polar(b,3.75,b.facing),6.75,n===5?1500:b.intimidate>0?3000:750,.2+n*.2+(n===5?.53:0),'Hammer Six');break; case 'brute_pull':target.forced={kind:'pull',x:b.x,z:b.z,speed:12.5,left:2};b.target=target.id;b.taunt=3;break; case 'brute_quake':circle(g,p,11.25,100,1.5,'Broken Ground',{duration:7.5,pool:true,tickEvery:1,slow:2,silence:6});break; case 'brute_barrage':for(let n=0;n<48;n++)circle(g,polar(b,25-Math.floor(n/6)*2.5,n*rad(22)),2.5,450,.8+n*.05,'Rock Rain');break; case 'brute_powerup':b.powerup=15;b.attackTimer=0;break; case 'thunder_thunderform':b.form=b.form==='thunder'?'normal':'thunder';if(b.form==='thunder')for(let n=0;n<6;n++)spawnEncounterUnit(g,'Power Node',polar(b,8.75,n*TAU/6),800,{node:true});b.ult=1;b.infinity=true;break; case 'thunder_powersurge':circle(g,polar(b,7.5,b.facing),8.75,600,.7,'Spark Shove',{knockback:8});break; case 'thunder_shock':target.shock={left:5,tick:1};break; case 'thunder_callthunder':for(const h of g.alive())circle(g,h,2.5,750,1.2,'Sky Pins');break; case 'thunder_seal_of_time':for(const h of g.alive())if(distance(h,b)<=11.875){h.stun=3.5;h.buffs={};}g.callout('STILLTIME',3.5);break; case 'thunder_displace':for(const h of g.alive()){const legal=g.rt.nav.nearest(polar(b,7.5,g.rand()*TAU),h.radius,g.rt.units(),h);if(legal)Object.assign(h,legal);}break;case 'thunder_swap':{const all=g.alive(),other=all.find(h=>h!==target);if(other){const q=point(target);Object.assign(target,point(other));Object.assign(other,q);}break;} case 'thunder_focus':case 'thunder_overwhelm':b.target=target.id;b.taunt=id.endsWith('overwhelm')?20:5;target.hp=target.maxHp;ray(g,b,target,2,500,1,name,{duration:5,pool:true,tickEvery:.5});break; case 'thunder_raezon':missile(g,b,p,54.17,2.5,400,.5,'Needlebolt',{homing:target.id,duration:2});break;case 'thunder_recoil':missile(g,b,p,41.67,4.625,distance(b,target)*40,.5,'Kickback',{homing:target.id,duration:2});break; case 'thunder_powerslide':b.rush={left:1,target:target.id,speed:25,damage:200,radius:4.625,tick:0,period:.04};break; case 'thunder_shuffle':{const all=g.alive().slice().sort(()=>g.rand()-.5);for(let n=0;n+1polar(b,r*1.875,n*rad(22))),r:2.5,label:'White Ring'},.7+r*.35,750,.2);break; case 'ice_gravefrost':startUltimate(g);break; case 'ice_frost_tomb':for(const h of g.alive())if(h.prisonId)circle(g,h,7.5,1500,.8,'Glass Grave');break; case 'light_heal_vi':cast(g,'Halo Mend',4,'healchannel',{power:100,tick:0});break; case 'light_divine_serpent':missile(g,b,p,8,2,600,.5,'Halo Coil',{homing:target.id,pierce:true,duration:8,tickEvery:.5,curve:.5});break; case 'light_holyseal':g.hazard('beam',{...p,angle:g.rand()*TAU,angularSpeed:rad(6)/.07,r:25,width:5.25,label:'Halo Seal',tickEvery:.07},.8,hard?125:85,4);break; case 'light_righteous_thunder':for(const h of g.alive())circle(g,h,3,750,1.2,'Gold Pins');break; case 'light_enlightenment':b.hp=Math.min(b.maxHp,b.hp+500);b.mana=Math.min(100,b.mana+12.5);break; case 'light_holystrike':circle(g,p,2,750,.3,'Gold Strike');break; case 'light_willing_sacrifice':case 'light_willing_sacrifice_v':{const count=id.endsWith('_v')?8:1;for(let n=0;ndistance(h,b)<15).length>g.alive().length/2;b.casting={name:close?'Leafrot · leave the circle':'Leafrot · enter the circle',total:2.7,left:2.7,interruptible:false,action:'wither',data:{outside:!close}};g.hazard(close?'circle':'safe',{...point(b),r:12.5,label:'Leafrot'},2.7,0,.2);if(!close)g.hazard('orbit',{...point(b),r:10,orbRadius:2.125,count:3,angularSpeed:1.5,tickEvery:.07,label:'Leafrot'},0,200,2.7);break;} case 'ancient_spirit_force':for(let n=0;n<(b.form==='melee'?2:1);n++)spawnEncounterUnit(g,'Spirit Force',polar(b,b.form==='melee'?22:3.75,n*TAU/2),800,{force:true,speed:6.25,lifetime:10,target:g.alive()[n%g.alive().length].id});break; case 'ancient_spiritswap':{const ghost=g.s.adds.find(a=>a.ghost&&!a.dead);if(ghost&&distance(b,ghost)>=17.5){const q=point(b);Object.assign(b,point(ghost));Object.assign(ghost,q);}break;} case 'ancient_teleport':Object.assign(b,polar({x:0,z:0},10,g.rand()*TAU));break; case 'ancient_hidden_nova':g.hazard('beam',{...point(b),angle:g.rand()*TAU,angularSpeed:rad(8)/.12,r:25.125,inner:9.875,oneSided:true,width:5.25,tickEvery:.12,label:'Buried Ring'},1,1000,2.4);break; case 'ancient_ancient_nova':burst(g,b,8,15,3.5,500,1,'Elder Ring',{pierce:true,tickEvery:.05,duration:3,stagger:.02});break; case 'ancient_ancient_blast':{const a=b.facing;ray(g,polar(b,-24,a),polar(b,45,a),18.75,9999,1.95,'Elder Beam',{duration:1.05,pool:true,tickEvery:.03});break;} case 'ancient_spiritwake':case 'ancient_spiritburst':case 'ancient_omega_x':if(id==='ancient_spiritwake'){for(const a of g.s.adds)if(a.kind==='Forest Spirit')a.dead=true;for(let n=0;n<7;n++)missile(g,polar(b,25,n*TAU/7),b,7,1.5,700,.2,'Root Recall');}else if(id==='ancient_spiritburst'){for(let n=0;n<3;n++)spawnEncounterUnit(g,'Forest Spirit',polar(b,6.25,n*TAU/3),235,{speed:3,attack:100,lifetime:30});}else for(let n=0;n<3;n++){const a=n*TAU/3;missile(g,polar(b,15,a),polar(b,15,a+rad(45)),5,1.5,5000,0,'Threefold Rupture',{duration:2.5,explode:true});}break; case 'ancient_naturespact':startUltimate(g);break; case 'demon_crush':for(let n=0;n<8;n++)circle(g,polar(p,12.5,n*TAU/8),3.125,750,.85+n*.25,'Rift Hammer');circle(g,p,12.5,3000,2.75,'Rift Hammer');break; case 'demon_rampage':case 'demon_rampage_v':for(let n=0;n<5;n++)event(g,n*.8,'demoncharge',{target:target.id});break; case 'demon_voidslam':event(g,1,'demoncharge',{target:target.id});break; case 'demon_chaos_v':for(let n=0;n<8;n++)circle(g,polar(b,8+n*1.5,n*TAU/8),4,1500,.7+n*.2,'Eightfold Rupture');break; case 'demon_malady':cast(g,'Rift Rot',5,'malady',{target:target.id,tick:0});break; case 'demon_red_death':g.hazard('beam',{...point(b),r:20,width:5,angle:b.facing,angularSpeed:rad(5)/.07,tickEvery:.07,shrink:8.93,label:'Red Ruin'},0,30,2.03);break; case 'demon_clone':for(let n=0;ndistance(p,q)>=10))break;}lights.push({...q,goal:null});} b.darkness={left:20,lights};b.ult=2;b.infinity=true;b.mana=740/900*100; g.hazard('safeUnion',{points:lights,r:12.5,pool:true,tickEvery:.5,label:'Lights Out'},2,10000,18); g.hazard('points',{points:lights,r:8.75,pool:true,tickEvery:1,label:'Burning cores'},2,50,18); g.callout('LIGHTS OUT · Stay in the light',4); } if(flags.includes(3)&&g.rand()<2/3){const ice=['ice_energy_flow','ice_coldsap','ice_blizzard_nova','ice_hidden_frost','ice_ice_lance'];runMechanic(g,ice[Math.floor(g.rand()*ice.length)]);event(g,1.5,'firepattern',{mechanic});return true;} return false; } function tickFireChallenges(g,dt){ const b=g.s.boss; if(b.artillery?.left>0){const a=b.artillery;a.left-=dt;a.tick-=dt;while(a.tick<=0){a.tick+=.2;const q=g.rand()<.1?point(pick(g)):g.bound(...Object.values(polar(a.origin,2.5+g.rand()*45,g.rand()*TAU)));circle(g,q,2.5,[400,600,800][Math.floor(g.rand()*3)],.9,'Kilnfall');}} if(!b.darkness)return; const dark=b.darkness;dark.left-=dt;b.mana=Math.min(90,b.mana); if(dark.left<18)for(const light of dark.lights){light.goal||=g.bound(...Object.values(polar(light,10,g.rand()*TAU)));const len=distance(light,light.goal)||1;light.x+=(light.goal.x-light.x)/len*2.5*dt;light.z+=(light.goal.z-light.z)/len*2.5*dt;if(len<.3)light.goal=null;} if(dark.left<=0){b.darkness=null;b.mana=100;startUltimate(g);} } export function startUltimate(g){ const b=g.s.boss,id=b.kind||BOSSES[g.s.bossIndex].id;b.mana=0;b.casting=null;b.ultAge=0;b.infinity=true;b.ult=14;b.ultKind=id;g.callout(ENCOUNTER_RULES[id].ultimate,4);g.log(BOSSES[g.s.bossIndex].name+' · '+ENCOUNTER_RULES[id].ultimate,'ultimate'); if(id==='ice')runMechanic(g,'ice_shatter'); if(beginSourceUltimate(g,id))return; if(id==='fire'){b.ult=25;b.light={x:0,z:6.25,angle:0,goal:null};const h=g.hazard('safe',{x:0,z:6,r:12.5,moving:false,label:'Follow the light',tickEvery:.5},8.8,10000,14);b.safeId=h.id;g.hazard('circle',{x:0,z:6,r:8.75,pool:true,label:'Burning core',tickEvery:1,light:true},8.8,g.s.difficulty==='hard'?100:50,14);event(g,21,'firewave');event(g,24,'firefinal');circle(g,b.light,50,9000,24,'Last Furnace');} if(id==='water'){b.ult=15;g.hazard('orbit',{...point(b),r:23,orbRadius:2,count:8,angularSpeed:.7,oscillate:true,tickEvery:.08,label:'Deep Current'},1,900,12);event(g,13,'waterpush');} if(id==='brute'){b.ult=0;b.infinity=false;b.intimidate=17;runMechanic(g,'brute_intimidate');} if(id==='druid'){b.oldForm=b.form;b.form='tree';b.ult=6.6;for(let n=0;n<4;n++)spawnEncounterUnit(g,'Living Pod',polar(b,17,n*TAU/4),900,{pod:true,lifetime:25,charge:g.rand()*.5});for(let n=0;n<8;n++){burst(g,b,8,8,1.5,350,1+n*.1,'Crownroot');burst(g,b,8,8,1.5,350,3.8+n*.1,'Crownroot');}event(g,2.8,'treefall');event(g,5.6,'treefall');event(g,6.6,'treeend');} if(id==='ancient'){b.ult=12;for(let n=0;n<13;n++)event(g,.2+n*.4,'pactbird');} if(id==='demon'){const transform=b.form==='normal';b.ult=25;if(transform){b.form='demon';b.formCasts=0;circle(g,b,7.5,6000,1.5,'Rift Form');}b.brandStarted=false;b.brandReturns=0;event(g,transform?3.3:1.6,'brandwaves');} } const hardDamage=g=>DIFFICULTIES[g.s.difficulty].tier>=2?900:300; export function tickBossSystems(g,dt){ const b=g.s.boss,d=DIFFICULTIES[g.s.difficulty],id=b.kind||BOSSES[g.s.bossIndex].id; if(!b.kind)initEncounter(g); if(id==='fire')tickFireChallenges(g,dt); b.ultAge=(b.ultAge||0)+dt;tickSourceUltimates(g,dt);const wasWeak=b.weak>0;b.weak=Math.max(0,(b.weak||0)-dt);if(wasWeak&&b.weak<=0&&id==='ancient'&&b.form==='melee'&&b.hp>0&&!g.s.adds.some(a=>a.ghost&&!a.dead)){const length=distance(b,{x:0,z:0})||1;const q=length>1?{x:b.x-b.x/length*13,z:b.z-b.z/length*13}:{x:13,z:0};spawnEncounterUnit(g,'Ancient Ghost',q,3500,{ghost:true,speed:7});}if(b.weak<=0&&['ancient','shadow'].includes(id))b.eternal=true; b.hellfire=Math.max(0,(b.hellfire||0)-dt);if(b.kind==='fire'&&b.ult<=0)for(const h of g.alive())if(distance(h,b)<7.5)g.hurtHero(h,200*dt);b.intimidate=Math.max(0,(b.intimidate||0)-dt);b.powerup=Math.max(0,(b.powerup||0)-dt);b.energyStop=Math.max(0,(b.energyStop||0)-dt); if(b.windowHp===undefined)b.windowHp=b.hp;const dealt=Math.max(0,b.windowHp-b.hp);b.windowDamage+=dealt;b.windowHp=b.hp;b.armorWindow+=dt; if((b.armorWindow>=22||b.windowDamage>=5000)&&d.tier>=2&&id!=='demon'){b.armorTier=[2000,2800,3100,4100,5000].filter(n=>b.windowDamage>n).length+(d.tier===3?1:0);b.armorWindow=0;b.windowDamage=0;} if(id==='demon')b.armorMultiplier=[.2,.3,.4,.5,.6,.7,.8,.9,1][Math.min(8,b.immortal||0)];else b.armorMultiplier=b.weak>0||b.hp/b.maxHp<=.2||d.tier<2?1:[1,.9,.75,.65,.5,.4,.3][Math.min(6,b.armorTier||0)]; if(b.ult<=0&&b.weak<=0&&b.energyStop<=0){b.energyTimer+=dt;if(b.energyTimer>=1){b.energyTimer-=1;const rate=(b.hp/b.maxHp<=.2?45:[17,21,26,30,35,40][Math.min(5,b.armorTier||0)])-d.energyOffset;b.mana=Math.min(b.darkness?90:100,b.mana+rate*(stageFor(g.s)?.energy||1)/BOSSES[g.s.bossIndex].energy*100);if(b.mana>=100)startUltimate(g);}} for(const e of b.events||[]){e.after-=dt;if(e.after>0||e.done)continue;e.done=true;const t=g.getHero(e.data.target)||pick(g);switch(e.action){case 'barriercrystal':spawnEncounterUnit(g,'Barrier Crystal',e.data,100,{iceAux:true,barrierCrystal:true,radius:1.8,lifetime:90});break;case 'firepattern':runMechanic(g,e.data.mechanic);break;case 'energyorb':spawnEncounterUnit(g,'Energy Orb',e.data,25,{orb:true,speed:5.625,lifetime:20,bossDamage:1200,energy:0});break;case 'brandwaves':{b.brandStarted=true;const facing=Math.atan2(-b.x,-b.z);for(let n=1;n<=8;n++){const angle=facing+rad(g.rand()*90-45);missile(g,polar(b,5,facing),polar(b,25,angle),41.667,2.5,2500,0,'Rift Wave',{brand:true,brandCount:20*n,brandStart:20*n,duration:10,bounce:23.5,knockback:7});}break;}case 'seaadd':{const a=spawnEncounterUnit(g,['Sea Elemental','Sea Spirit','Sea Wraith'][Math.floor(g.rand()*3)],e.data,50,{speed:13.05,attack:100,lifetime:50});circle(g,a,3.75,900,0,'Tide Brood');break;}case 'splash':g.hurtHero(t,d.tier===0?325:750);g.fx('burst',t.x,t.z,'#7aaec6',2);break;case 'iceshard':if(g.s.adds.filter(a=>a.iceAux&&!a.dead).length<13)spawnEncounterUnit(g,'Frost Shard',e.data,400,{iceAux:true,speed:2,attack:100,lifetime:60});break;case 'wintersoul':{const wolves=g.s.adds.filter(a=>a.kind==='Frost Wolf'&&!a.dead).length;for(const h of g.alive()){if(distance(h,b)<15){g.hurtHero(h,hardDamage(g)===900?1250:500,true);h.stun=1.5;h.buffs={};}g.hurtHero(h,wolves*200,true);}for(const a of g.s.adds)if(a.iceAux||a.kind==='Frost Wolf')a.dead=true;break;}case 'rebirth':b.hp=b.maxHp;b.infinity=false;b.ult=0;break;case 'waterdash':{const q=point(b);Object.assign(b,e.data);ray(g,q,b,5.5,d.tier>=2?9000:500,.1,'Undertow Hunt');break;}case 'waterpush':g.hazard('ring',{...point(b),r:1,inner:0,expand:true,knockback:8,pool:true,tickEvery:.05,label:'Deep Current'},0,100,3);break;case 'emerge':Object.assign(b,e.data);b.hidden=0;circle(g,b,5,2500,.2,'Deep Dive');b.mana=Math.min(100,b.mana+75/9);break;case 'shatter':{const add=g.s.adds.find(a=>!a.dead&&a.iceAux);if(add)add.hp=Math.max(0,add.hp-1500);break;}case 'firewave':{const light=b.light||b;missile(g,b,light,Math.max(4,distance(b,light)/2),3,0,0,'Furnace Wave',{duration:3,lightWave:true});g.callout('LAST FURNACE · SHIELD',3);break;}case 'firefinal':g.s.hazards=g.s.hazards.filter(h=>h.id!==b.safeId);break;case 'birdcrystal':spawnEncounterUnit(g,'Bird Crystal',e.data,d.tier<2?400:600,{crystal:e.data.index,lifetime:25});break;case 'treefall':circle(g,polar(b,10+g.rand()*10,g.rand()*TAU),5,800,.8,'Crown Seed');break;case 'treeend':b.form=b.oldForm||'caster';for(const a of g.s.adds)if(a.pod)a.dead=true;break;case 'gatesoul':missile(g,b,polar(b,23,g.rand()*TAU),25/2.8,2.125,110,0,'Halo Wisp',{bounce:23,pierce:true,duration:12,tickEvery:.07});break;case 'pactbird':spawnEncounterUnit(g,'Pact Bird',polar(b,23,g.rand()*TAU),50,{pact:true,orb:true,speed:9.25,goal:point(b),lifetime:10});break;case 'demoncharge':b.rush={left:.8,target:t.id,speed:b.form==='demon'?25:34.375,damage:b.form==='demon'?200:100,radius:b.form==='demon'?2.5:3.125,tick:0,period:.04,impact:750};break;case 'shadowrecover':b.hp=Math.min(b.maxHp,b.hp+2000);break;case 'demonresume':b.infinity=false;b.ult=0;break;}} b.events=(b.events||[]).filter(e=>!e.done); if(b.rush){const r=b.rush,t=g.getHero(r.target)||pick(g),len=distance(b,t)||1;const q=g.bound(b.x+(t.x-b.x)/len*r.speed*dt,b.z+(t.z-b.z)/len*r.speed*dt);Object.assign(b,q);r.left-=dt;r.tick-=dt;if(r.tick<=0){r.tick=r.period;for(const h of g.alive())if(distance(h,b)0&&b.ultAge>8.8&&b.ultAge<21){const light=b.light;light.goal||=g.bound(...Object.values(polar(light,d.tier>=2?15:12.5,g.rand()*TAU)));const len=distance(light,light.goal)||1;light.x+=(light.goal.x-light.x)/len*(b.fireShards?.includes(4)?8.75:d.tier>=2?6.25:5.25)*dt;light.z+=(light.goal.z-light.z)/len*(b.fireShards?.includes(4)?8.75:d.tier>=2?6.25:5.25)*dt;if(len<.4)light.goal=null;for(const h of g.s.hazards)if(h.id===b.safeId||h.light){h.x=light.x;h.z=light.z;}} if(id==='demon'&&b.brandStarted&&!g.s.hazards.some(h=>h.brand&&h.t=60&&!b.rootPuzzle){b.gateTimer=0;runMechanic(g,'ancient_revitalize');}} if(b.rootPuzzle){b.rootPuzzle-=dt;if(b.rootPuzzle<=0){b.rootPuzzle=0;const roots=g.s.adds.filter(a=>a.rootPuzzle&&!a.dead);if(roots.length){b.hp=Math.min(b.maxHp,b.hp+1000);for(const a of roots)a.dead=true;}else cast(g,'Rootmend',1.9,'revitalize');}} if(id==='shadow'&&b.ult<=0){b.ritualTimer=(b.ritualTimer||0)+dt;if(b.ritualTimer>=9){b.ritualTimer=0;const count=g.s.adds.filter(a=>a.ritual&&!a.dead).length;spawnEncounterUnit(g,'Ritual Soul',polar({x:0,z:0},18,count*TAU/8),200,{soul:true,ritual:true,invulnerable:true});}} if(id==='shadow'&&!b.soulcall&&g.s.adds.filter(a=>a.ritual&&!a.dead).length>=8&&b.weak<=0){for(const a of g.s.adds)if(a.soul)a.dead=true;weakenBoss(g,11);event(g,11,'shadowrecover');} if(id==='druid'&&b.form==='bird'){b.crystals||=Array.from({length:9},(_,i)=>i<(d.tier===0?4:d.tier===1?2:0));if(b.hp/b.maxHp<=.2||b.crystals.every(Boolean)){b.form='caster';circle(g,b,7.5,2000,.8,'Landing');}else{b.veraTimer=(b.veraTimer||0)+dt;if(b.veraTimer>=8){b.veraTimer=0;runMechanic(g,'druid_vera');}}} if(id==='demon'&&b.form==='normal'&&b.ult<=0){b.chaosTimer=(b.chaosTimer||0)+dt;if(b.chaosTimer>=8){b.chaosTimer=0;spawnEncounterUnit(g,'Chaos Energy',polar({x:0,z:0},18,g.rand()*TAU),1500,{chaosEnergy:true,invulnerable:true,lifetime:60});}for(const a of g.s.adds.filter(a=>a.chaosEnergy&&!a.dead)){const near=g.alive().find(h=>distance(h,a)<=3.75);if(near){a.dead=true;b.immortal=Math.min(8,(b.immortal||0)+1);b.deniedSpeed=(b.deniedSpeed||0)+.75;circle(g,a,6.25,1750,0,'DENIED');}else if(distance(a,b)<5){a.dead=true;startUltimate(g);}else if(!b.casting&&!b.rush)g.rt.nav.move(b,a,bossStats(g).speed,dt,g.rt.units());}} if(id==='demon'&&b.form==='normal'&&b.hp/b.maxHp<=.3){b.form='chaos';b.immortal=7;circle(g,b,7.5,6000,1.6,'Ruin Form');} for(const h of g.alive()){ for(const key of ['silence','disarm','intimidated'])h[key]=Math.max(0,(h[key]||0)-dt); if(h.forced){const f=h.forced,len=distance(h,f)||1;Object.assign(h,g.bound(h.x+(f.x-h.x)/len*f.speed*dt,h.z+(f.z-h.z)/len*f.speed*dt));f.left-=dt;if(f.left<=0||len<3.75)h.forced=null;} if(h.timeSeal){h.timeSeal.left-=dt;if(h.timeSeal.left<=0){Object.assign(h,g.bound(h.timeSeal.x,h.timeSeal.z));h.timeSeal=null;}} if(h.extract){h.extract.tick-=dt;const shadows=g.s.adds.filter(a=>a.extract===h.id&&!a.dead);if(!shadows.length){h.extract=null;h.stun=0;}else if(h.extract.tick<=0){h.extract.tick=.5;g.hurtHero(h,shadows.length*(d.tier>=2?150:100));}} for(const key of ['shock','siphon','malady']){const f=h[key];if(!f)continue;f.left-=dt;f.tick-=dt;if(f.tick<=0){f.tick=1;if(key==='shock'){g.hurtHero(h,300);for(const a of g.alive())if(a!==h&&distance(a,h)<5)g.hurtHero(a,500);}if(key==='siphon'){h.mana=Math.max(0,h.mana-10);g.hurtHero(h,150);}if(key==='malady')g.hurtHero(h,250);}if(f.left<=0)h[key]=null;} } for(const a of g.s.adds){if(a.prison){const h=g.getHero(a.prison);if(a.dead||a.hp<=0||a.lifetime<=0){if(a.lifetime<=0&&a.hp>0&&h){h.veil=0;g.hurtHero(h,h.hp+1,true);}a.dead=true;if(h){h.prisonId=null;h.stun=0;h.veil=0;}}else if(h)h.mana=Math.max(0,h.mana-5*dt);}if(a.dead||a.hp<=0){a.dead=true;if(a.crystal!==undefined){b.crystals||=Array(9).fill(false);b.crystals[a.crystal]=true;}if(a.ghost&&b.kind==='ancient'){if(distance(a,b)<=7.5)weakenBoss(g,8);else spawnEncounterUnit(g,'Ancient Ghost',a,3500,{ghost:true,speed:7});}continue;} if(a.lifetime!==undefined){a.lifetime-=dt;if(a.lifetime<=0)a.dead=true;} if(a.orb){const goal=a.goal||b,len=distance(a,goal)||1;a.x+=(goal.x-a.x)/len*a.speed*dt;a.z+=(goal.z-a.z)/len*a.speed*dt;if(len<1){if(a.bossDamage){b.hp=Math.max(0,b.hp-a.bossDamage);g.fx('number',b.x,b.z,'#9ee3e9',1,'−'+a.bossDamage);}else b.mana=Math.min(100,b.mana+(a.energy||4));a.dead=true;}} if(a.force){const t=g.getHero(a.target)||pick(g),len=distance(a,t)||1;a.x+=(t.x-a.x)/len*a.speed*dt;a.z+=(t.z-a.z)/len*a.speed*dt;if(a.lifetime<5)a.speed=Math.min(12.5,a.speed+dt*1.25);if(len<2.5){circle(g,a,8.125,20000,0,'Root Runner');a.dead=true;}} if(a.ghost){a.goal||=polar({x:0,z:0},19,g.rand()*TAU);g.rt.nav.move(a,a.goal,7,dt,g.rt.units());if(distance(a,a.goal)<1)a.goal=null;} if(a.sacrifice){const len=distance(a,a.goal)||1;a.x+=(a.goal.x-a.x)/len*a.speed*dt;a.z+=(a.goal.z-a.z)/len*a.speed*dt;if(len<1){b.hp=Math.min(b.maxHp,b.hp+1000);a.dead=true;}} } } export function nextMechanic(g){const b=g.s.boss,id=b.kind,all=ORIGINAL_MECHANICS[id].filter(m=>!m.id.endsWith('soulrelease')&&!m.id.endsWith('gate_to_paradise')&&!m.id.endsWith('soulcall')&&!m.id.endsWith('treeform')&&!m.id.endsWith('gravefrost')&&!m.id.endsWith('naturespact')&&!m.id.endsWith('brand_of_sacrifice'));let pool=all;if(id==='ancient'){pool=all.filter(m=>b.form==='melee'?!['ancient_revitalize','ancient_hidden_nova','ancient_ancient_nova'].includes(m.id):!['ancient_ancient_blast','ancient_spiritswap','ancient_revitalize'].includes(m.id));if(b.cycles%5===0)pool=[{id:'ancient_omega_x'}];}if(id==='druid'&&b.form==='bird')pool=all.filter(m=>['druid_vera','druid_natureswrath'].includes(m.id));else if(id==='druid')pool=all.filter(m=>b.form==='bear'?!['druid_virtue','druid_vera','druid_starfall'].includes(m.id):m.id!=='druid_fury');if(DIFFICULTIES[g.s.difficulty].tier<2)pool=pool.filter(m=>!/_v$|overwhelm|hellfire|wintersoul|gravefrost/.test(m.id));const m=pool[b.cycles%pool.length];b.cycles++;if(id==='fire'&&fireChallenges(g,m.id))return;runMechanic(g,m.id);if(id==='demon'&&b.form==='demon'){b.formCasts++;if(b.formCasts>=3){runMechanic(g,'demon_voidslam');b.form='normal';b.mana=0;}}} ===== ultimate-systems.js ===== const encounterNames={"Ancient Ghost":"Kurr’s Echo","Power Node":"Storm Anchor","Energy Orb":"Spark Orb","Extracted Shadow":"Split Wisp","Corruption Stream":"Murk Stream","Shadow Soul":"Hush Wisp","Ritual Soul":"Ritual Wisp","Ice Prison":"Glass Prison","Willing Sacrifice":"Offering","Ancient Root":"Elder Root","Spirit Force":"Root Runner","Forest Spirit":"Root Wisp","Pandemonium Clone":"Vorraka Echo","Barrier Crystal":"Glass Anchor","Sea Elemental":"Tidebody","Sea Spirit":"Tidewisp","Sea Wraith":"Tideling","Frost Shard":"Rime Splinter","Frost Wolf":"Rime Hound","Bird Crystal":"Glider Crystal","Living Pod":"Crown Pod","Pact Bird":"Oathwing","Chaos Energy":"Rift Spark","Soul Statue":"Hollow Idol","Cursed Soul":"Bitter Wisp","Falling seed":"Crown Seed","Siren":"Tide Singer","Skeleton":"Boneling","Skeleton Archer":"Bonebow","Skeleton Warrior":"Boneguard","Skeleton Warriors":"Boneguards","Skeleton magi":"Bonemage","Reaver Demon":"Rift Beast"}; // Independently reconstructed phase machines. Distances use 40 Warcraft units per world unit. import {DIFFICULTIES} from './data.js'; const TAU=Math.PI*2,rad=d=>d*Math.PI/180,dist=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); const point=p=>({x:p.x,z:p.z}),polar=(p,r,a)=>({x:p.x+Math.sin(a)*r,z:p.z+Math.cos(a)*r}); const randint=(g,a,b)=>a+Math.floor(g.rand()*(b-a+1)); const towards=(p,q,step)=>{const d=dist(p,q)||1;return {x:p.x+(q.x-p.x)/d*step,z:p.z+(q.z-p.z)/d*step};}; function disk(g,p,r,power){for(const h of g.alive())if(dist(h,p)<=r)g.hurtHero(h,power);} function warning(g,p,r,power,delay,label){return g.hazard('circle',{...point(p),r,label},delay,power,.15);} function pulse(g,p,r,points,power,delay=0){g.hazard('points',{points:Array.from({length:points},(_,i)=>polar(p,r,i/points*TAU)),r:2.125,label:'Boundary'},delay,power,.15);} function cue(g,text){g.s.boss.cue=text;g.s.boss.cueUntil=g.s.time+4;g.callout(text,4);} function clearProtection(h){h.veil=0;h.shield=0;delete h.buffs.protect;} function actor(g,name,p,hp,extra={}){const a={id:'ultadd'+g.uid(),kind:name,name:encounterNames[name]||name,...point(p),hp,maxHp:hp,radius:.55,turnRate:5,dead:false,dots:[],weaken:0,timer:1,attack:0,speed:0,facing:0,...extra};g.s.adds.push(a);return a;} function cloud(g,key,count,r=2.125){return g.hazard('orbit',{x:0,z:0,r:0,count,orbRadius:r,balls:[],manualMotion:true,sourceUlt:key,label:({gravefrost:'Winter Spiral',soulcall:'Hollow Circuit',gate:'Halo Lock'})[key]||'Boss attack'},0,0,999);} function orb(g,key,p,heading,width=2.125,extra={}){return g.hazard('missile',{...point(p),dx:Math.sin(heading),dz:Math.cos(heading),speed:0,width,manualMotion:true,sourceUlt:key,label:({gravefrost:'Winter Spiral',soulcall:'Hollow Circuit',gate:'Halo Lock'})[key]||'Boss attack',...extra},0,0,999);} function finish(g,key){const b=g.s.boss;b[key]=null;b.ult=0;b.infinity=false;b.cue='';g.s.hazards=g.s.hazards.filter(h=>h.sourceUlt!==key);} export function beginSourceUltimate(g,id){ const b=g.s.boss; if(!['thunder','shadow','ice','light'].includes(id))return false; b.ult=999; if(id==='thunder'){ b.linked=g.alive().map(h=>{if(h.buffs.spirit){h.maxHp-=4000;h.hp=Math.min(h.hp,h.maxHp);}h.buffs={};clearProtection(h);h.thunderPuller=20;return {id:h.id,initial:dist(h,b)*40,base:randint(g,1,20),weight:-1,active:true};}); if(b.linked.length)b.linked[randint(g,0,b.linked.length-1)].weight=0; b.pullAge=0;b.pullTick=0;b.pullStarted=false;b.pullEnding=0;cue(g,'SHIELD BEFORE CONTACT'); } if(id==='ice'){ b.gravefrost={phase:'prelude',timer:.5,round:0,spawn:0,angle:randint(g,1,360),missiles:[],age:0,ey:2,speed:10,shrink:0,direction:1,decelerations:0,thirdTicks:0,closeTicks:0,done:false,barrier:0}; Object.assign(b,{x:0,z:0});cue(g,'WINTER SPIRAL · WATCH THE SPIRAL'); } if(id==='light'){ Object.assign(b,{x:0,z:0}); b.gate={phase:'opening',timer:.5,r:30,angle:randint(g,1,360),vn:randint(g,20,50)/40,outer:[],small:[],large:[],queued:[],emitted:0,smallTimer:.5,largeTimer:1,largeCounter:0,tokens:[],moveTimer:.07,pause:0}; warning(g,b,5.25,9000,.1,'Halo Lock');cue(g,'HEALING HURTS · BLOCK LARGE SOULS'); } if(id==='shadow'){ Object.assign(b,{x:0,z:0});const ritual=g.s.adds.filter(a=>a.ritual&&!a.dead);for(const a of g.s.adds)if(a.ritual)a.hidden=true;else a.dead=true; b.soulcall={phase:'opening',timer:1,age:0,statues:[],ritual:ritual.map(a=>a.id),index:0,step:.5,harvest:false,target:null,returning:false}; cue(g,'HOLLOW CIRCUIT · STEER THE SOUL'); } return true; } function thunder(g,dt){const b=g.s.boss;if(!b.linked)return;b.pullAge+=dt;if(b.pullAge<.3)return;if(!b.pullStarted){b.pullStarted=true;for(const l of b.linked){const h=g.getHero(l.id);if(h)clearProtection(h);}b.pullTick=.03;return;} if(b.pullEnding){b.pullEnding-=dt;if(b.pullEnding<=0){b.linked=null;b.ult=0;b.infinity=false;b.cue='';}return;} b.pullTick-=dt;while(b.pullTick<=0){b.pullTick+=.03;let active=b.linked.filter(l=>l.active&&!g.getHero(l.id)?.dead);const n=active.length+(g.s.summons||[]).filter(s=>active.some(l=>l.id===s.owner)).length; for(const l of b.linked){const h=g.getHero(l.id);if(!h||h.dead){l.active=false;continue;}if(!l.active)continue;if(n===1){l.weight=1;l.base=20;}let step=l.base+Math.floor(15/Math.max(1,n))+1+Math.floor(l.initial/100)*l.weight;if(step<=1)step=2;if(n<=3&&step<=10)step=20;Object.assign(h,towards(h,b,step/40));} const contact=active.filter(l=>dist(g.getHero(l.id),b)<=4); if(contact.length){for(const l of b.linked){const h=g.getHero(l.id);if(h)l.weight=dist(h,b)<=10?Math.max(0,l.weight):randint(g,0,1);} for(const l of contact){const h=g.getHero(l.id);l.active=false;h.thunderPuller=0;g.hurtHero(h,90000);if(!h.dead&&g.s.difficulty!=='veryeasy')h.thunderAftershock=2;g.fx('burst',h.x,h.z,'#c4dae8',3);}} if(!b.linked.some(l=>l.active&&!g.getHero(l.id)?.dead)){b.pullEnding=2;break;} } } function beginIceRound(g,s){s.phase='spawn';s.timer=.03;s.spawn=0;s.missiles=[];s.cloudId=cloud(g,'gravefrost',34).id;} function refreshIceCloud(g,s){const h=g.s.hazards.find(h=>h.id===s.cloudId);if(h){h.balls=s.missiles.filter(m=>!m.dead).map(m=>point(m));h.damage=s.phase==='rotate'?(s.round===2?300:s.speed>=7?9000:900):s.phase==='converge'?240:0;}} function blastIce(g,s){disk(g,g.s.boss,10,3000);g.fx('burst',0,0,'#a9d2df',10);s.done=true;} function endIceRound(g,s){g.s.hazards=g.s.hazards.filter(h=>h.id!==s.cloudId);s.round++;s.done=false;s.shrink=0;s.age=0;s.speed=10;s.ey=0;s.decelerations=0;s.thirdTicks=0;s.closeTicks=0;if(s.round<3)beginIceRound(g,s);else{s.phase='barrier';s.timer=.14;s.barrier=0;cue(g,'GLASS RING · LEAVE THE RING');}} function iceStep(g,s){const b=g.s.boss; if(s.phase==='prelude'){beginIceRound(g,s);return;} if(s.phase==='spawn'){ s.spawn++;if(s.spawn===35){s.phase='rotate';s.timer=.05;s.speed=10;return;} s.angle+=randint(g,22,45);const r=(900+100*((s.spawn-1)%9))/40,p=polar(b,r,rad(s.angle));s.missiles.push({...p,angle:s.angle,originalRadius:r});s.timer+=.03;refreshIceCloud(g,s);return; } if(s.phase==='rotate'){ s.age++;s.ey++;if(s.round===2){s.thirdTicks++;if(s.thirdTicks>=45)s.speed=1;else{s.age--;s.speed=10;}} if(s.ey>=11&&s.round!==2){if(s.round===0&&s.speed===10)for(let i=28;i<34;i++)s.missiles[i].dead=true;s.speed-=2;s.decelerations++;if(s.decelerations===4&&g.rand()<.5)s.speed-=2;s.direction=g.rand()<.5?1:-1;s.ey=0;} if(s.shrink<22.5&&s.round>0){if(s.round===2){if(s.thirdTicks>=45)s.shrink+=3/40;}else s.shrink+=7/40;} if(s.age<(s.round===2?100:60)){ if(s.round===2&&s.thirdTicks>=45)s.shrink+=8/40; for(const m of s.missiles){if(m.dead)continue;m.angle+=s.speed*(s.round===2?s.direction:1);let r=m.originalRadius-s.shrink;if(s.round===2&&r<=5){r=5;s.speed=10;s.shrink+=1/40;}Object.assign(m,polar(b,r,rad(m.angle)));disk(g,m,2.125,s.round===2?(s.speed===10?300:200):s.speed>=7?9000:900);} if(s.round===2&&s.missiles.filter(m=>!m.dead&&dist(m,b)<=6.25).length>=31&&++s.closeTicks>=12)s.age=180; s.timer+=.05;refreshIceCloud(g,s);return; } s.phase=s.round===2?'outward':'wait';s.timer=s.round===2?.04:1.5;s.done=false;return; } if(s.phase==='wait'){s.phase='converge';s.timer=.04;cue(g,'ICE CLOSES · LEAVE CENTER');return;} if(s.phase==='converge'){ if(s.done){blastIce(g,s);endIceRound(g,s);return;} if(s.missiles.filter(m=>!m.dead&&dist(m,b)<=8.75).length>=25)s.done=true; for(const m of s.missiles)if(!m.dead){Object.assign(m,towards(m,b,m.originalRadius/30));disk(g,m,2.125,240);}s.timer+=.04;refreshIceCloud(g,s);return; } if(s.phase==='outward'){if(s.done){endIceRound(g,s);return;}blastIce(g,s);s.timer+=.04;return;} if(s.phase==='barrier'){ if(s.barrier>=15){finish(g,'gravefrost');return;} const p=polar(b,15,rad(1408+(++s.barrier)*24));disk(g,p,3.75,750);warning(g,p,4.75,500,.7,'Glass Ring');s.crystals||=[];s.crystals.push({...p,left:.7});s.timer+=.14; } } function ice(g,dt){const s=g.s.boss.gravefrost;if(!s)return;s.timer-=dt;let loops=0;while(s.timer<=0&&g.s.boss.gravefrost&&loops++<12)iceStep(g,s); for(const c of s.crystals||[]){c.left-=dt;if(c.left<=0&&!c.done){c.done=true;actor(g,'Barrier Crystal',c,100,{iceAux:true,barrierCrystal:true,radius:1.8,lifetime:90});}} // The last warnings and their collision crystals outlive normal phase cleanup. if(!g.s.boss.gravefrost)for(const c of s.crystals||[])if(!c.done)g.s.boss.events.push({after:Math.max(0,c.left),action:'barriercrystal',data:point(c)}); } function makeGateOuter(g,s){const h=cloud(g,'gate',16,2.25);s.outerId=h.id;s.outer=Array.from({length:6},(_,i)=>s.angle+(i+1)*60);s.boundaryCount=6;} function gateOrb(g,s,large=false){const b=g.s.boss;let a; if(large){if(!s.tokens.length)s.tokens=Array.from({length:8},(_,i)=>i*TAU/8);a=s.tokens.splice(randint(g,0,s.tokens.length-1),1)[0];warning(g,b,7.5,5000,.2,'Gate emission');} else a=g.rand()*TAU; const h=orb(g,'gate',b,a,large?3.2:2.125,{color:large?'#fff1bd':'#b8cfe0',gateSmall:!large,gateLarge:large,predict:!large,speed:large?7.678571:8.928571,heading:a});(large?s.large:s.small).push(h.id);s.emitted++; } function gateMove(g,s,step=.07,assembly=false){const b=g.s.boss,outer=g.s.hazards.find(h=>h.id===s.outerId);s.outer=s.outer.map(a=>a+(assembly?10:10));if(outer){outer.balls=s.outer.map(a=>polar(b,s.r,rad(a)));outer.damage=assembly?12000:1500;} if(outer)for(const p of outer.balls)disk(g,p,assembly?2.125:2.25,assembly?12000:1500); for(const id of s.small){const h=g.s.hazards.find(h=>h.id===id);if(!h)continue; if(dist(h,b)>=s.r){if(assembly){s.outer.push(h.heading*180/Math.PI);s.boundaryCount++;h.t=9999;continue;}h.heading=Math.atan2(b.x-h.x,b.z-h.z)+rad(randint(g,-22,22));} h.dx=Math.sin(h.heading);h.dz=Math.cos(h.heading);h.speed=assembly?15.625:8.928571;h.x+=h.dx*h.speed*step;h.z+=h.dz*h.speed*step;disk(g,h,2.125,assembly?220:110); } for(const id of s.large){const h=g.s.hazards.find(h=>h.id===id);if(!h)continue;const contacts=g.alive().filter(a=>dist(h,a)<=2.5); if(contacts.length){for(const a of contacts)clearProtection(a);h.t=9999;continue;} if(dist(h,b)>=s.r){h.t=9999;pulse(g,b,s.r,Math.max(1,Math.floor(s.r)),5000);s.r=Math.max(1,s.r-2.5);s.pause=.2;continue;} h.x+=h.dx*7.678571*step;h.z+=h.dz*7.678571*step; } s.small=s.small.filter(id=>g.s.hazards.some(h=>h.id===id&&h.t<999));s.large=s.large.filter(id=>g.s.hazards.some(h=>h.id===id&&h.t<999)); } function gate(g,dt){const b=g.s.boss,s=b.gate;if(!s)return; if(s.phase==='opening'){s.timer-=dt;if(s.timer<=0){makeGateOuter(g,s);for(const h of g.alive()){h.ritualSacrifice=35;if(dist(h,b)<=30)g.loseLife(h,DIFFICULTIES[g.s.difficulty].tier>=2?1250:500);}pulse(g,b,30,30,5000);s.phase='small';}return;} for(const e of s.queued){e.left-=dt;if(e.left<=0&&!e.done){e.done=true;gateOrb(g,s,e.large);}}s.queued=s.queued.filter(e=>!e.done); if(s.phase==='small'){s.smallTimer-=dt;if(s.smallTimer<=0){s.smallTimer+=.5;if(s.emitted>=10)s.phase='large';else for(let n=0,count=randint(g,1,3);n=22||s.r<=10){s.phase='winddown';s.timer=3;}else if(s.largeCounter>2){s.largeCounter=randint(g,0,1);for(let n=0,count=randint(g,1,2);nh.id===id);if(h)h.t=9999;}s.large=[];s.phase='assembly';s.moveTimer=.2;for(const h of g.alive())h.gatePaused=dist(h,b)>s.r;cue(g,'GATE CLOSES · ENTER THE CIRCLE');}} s.pause=Math.max(0,s.pause-dt);if(s.pause>0)return;s.moveTimer-=dt;const period=['assembly','expanding','closing'].includes(s.phase)?.04:.07; while(s.moveTimer<=0){s.moveTimer+=period; if(['small','large','winddown'].includes(s.phase)){gateMove(g,s);continue;} if(s.phase==='assembly'){gateMove(g,s,.04,true);if(s.boundaryCount>=16||!s.small.length){s.outer=Array.from({length:16},(_,i)=>i*360/16);for(const id of s.small){const h=g.s.hazards.find(h=>h.id===id);if(h)h.t=9999;}s.small=[];pulse(g,b,s.r,Math.max(1,Math.floor(s.r*40/35)),5000);s.phase='expanding';s.moveTimer=.5;cue(g,'CLOSING RING · TIME YOUR SHIELD');}continue;} s.outer=s.outer.map(a=>a+randint(g,5,10));const h=g.s.hazards.find(h=>h.id===s.outerId);if(h){h.balls=s.outer.map(a=>polar(b,s.r,rad(a)));h.damage=9000;}if(h)for(const p of h.balls)disk(g,p,5,9000); if(s.phase==='expanding'){s.r+=s.vn;if(s.r>=40)s.phase='closing';} else if(s.phase==='closing'){s.r-=2*s.vn+1.25;if(s.r<=1.25){for(const h of g.s.heroes){h.ritualSacrifice=0;h.gatePaused=false;}finish(g,'gate');break;}} } } const STATUES=[[0,912],[640,640],[912,16],[640,-640],[0,-896],[-624,-640],[-896,0],[-640,640]]; function soulTarget(g,s){const b=g.s.boss,living=g.s.adds.filter(a=>a.statue&&!a.dead);if(!living.length){s.returning=true;s.target='boss';return;} const eligible=living.filter(a=>!a.excluded);if(!eligible.length){for(const a of living)destroyStatue(g,a);s.target=null;return;} if(s.harvest)s.target=eligible[randint(g,0,eligible.length-1)].id;else{s.target=eligible.sort((a,c)=>g.alive().filter(h=>dist(h,c)<=13.75).length-g.alive().filter(h=>dist(h,a)<=13.75).length)[0].id;} } function destroyStatue(g,a){if(a.dead)return;a.dead=true;warning(g,a,13.75,4000,.2,'Wisp Harvest');for(let n=0;n<5;n++)disk(g,g.s.boss,11.25,6000);} function soulAdds(g,s,p){if(s.index>=10)return;const make=()=>actor(g,g.rand()<.5?'Skeleton Archer':'Skeleton Warrior',p,220,{soulAdd:true,speed:9.375,attack:55});make();if([2,4,6,8].includes(s.index))make();if([3,5,7].includes(s.index))actor(g,'Cursed Soul',p,220,{soulAdd:true,speed:9.5,attack:90});} function soulFinish(g,s){const b=g.s.boss;for(const a of g.s.adds)if(a.soulAdd&&!a.dead){b.hp=Math.min(b.maxHp,b.hp+200);a.dead=true;}for(const id of s.ritual){const a=g.s.adds.find(a=>a.id===id);if(a)a.hidden=false;}finish(g,'soulcall');} function soul(g,dt){const b=g.s.boss,s=b.soulcall;if(!s)return;s.age+=dt;s.timer-=dt; if(s.phase==='opening'){if(s.timer<=0){s.statues=STATUES.map(([x,z])=>actor(g,'Soul Statue',{x:b.x+x/40,z:b.z+z/40},100,{statue:true,invulnerable:true}));s.phase='orb';s.timer=.5;}return;} if(s.phase==='orb'){if(s.timer<=0){s.orbId=orb(g,'soulcall',b,0,2.5,{predict:true,speed:12.5,damage:500,duration:30}).id;s.phase='wait';s.timer=1;}return;} if(s.phase==='wait'){if(s.timer<=0){s.phase='circuit';s.timer=.04;soulTarget(g,s);}return;} if(s.phase==='cleanup'){if(s.timer<=0)soulFinish(g,s);return;} if(s.age>=31&&!s.expired){s.expired=true;for(const a of s.statues)if(!a.dead)destroyStatue(g,a);s.target=null;} while(s.timer<=0){s.timer+=.04;const h=g.s.hazards.find(h=>h.id===s.orbId);if(!h){s.phase='cleanup';s.timer=1.2;break;} const target=s.target==='boss'?b:g.s.adds.find(a=>a.id===s.target&&!a.dead); if(!target){s.index++;s.step+=.05;soulTarget(g,s);continue;} if(s.returning&&dist(h,b)<=2.5){s.phase='cleanup';s.timer=1.2;h.t=9999;break;} const contact=g.s.adds.find(a=>a.statue&&!a.dead&&!a.excluded&&dist(a,h)<=1.875); if(contact){ if(s.index>=9){s.step+=.3;destroyStatue(g,contact);}else{ if(s.index>=8&&!s.harvest){s.harvest=true;for(const a of s.statues)a.excluded=false;cue(g,'WISP HARVEST · SHIELD THE BLASTS');} if(!s.harvest)warning(g,contact,13.75,1300,.2,'Marked statue');for(let n=0;n<4;n++)disk(g,b,10,400); } for(const a of g.s.adds)if(a.statue&&!a.dead)a.excluded=dist(a,h)<=20;soulAdds(g,s,h);s.target=null;continue; } const len=dist(h,target)||1;h.dx=(target.x-h.x)/len;h.dz=(target.z-h.z)/len;h.speed=s.step/.04;h.x+=h.dx*s.step;h.z+=h.dz*s.step;disk(g,h,2.5,500); } } export function tickSourceUltimates(g,dt){thunder(g,dt);ice(g,dt);gate(g,dt);soul(g,dt);} ===== teamwork.js ===== import {stageFor} from './journey.js'; import {skillFor} from './kits.js'; import {unitAttack} from './hero-systems.js'; import {TELEGRAPH_COLORS} from './telegraphs.js'; const distance=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); const alive=s=>s.heroes.filter(h=>!h.dead); const owner=s=>s.heroes.find(h=>h.id===s.learning?.owner)||s.heroes.find(h=>!h.ai)||s.heroes[0]; export const learningLevel=s=>{const level=stageFor(s);return s.match?.kind==='onboarding'?level:null;}; function say(g,h,text){if(!h)return;const id=g.announceCast(h,'team',{name:text});g.resolveCast(id);g.resolveCast(id,true);} function clearBeat(g){g.s.hazards=g.s.hazards.filter(h=>!h.lesson);g.s.boss.casting=null;} function danger(g,kind,point,delay=2.5,fraction=.08){ const lead=owner(g.s),signal=kind==='safe'?'safe':point.signal||'danger'; return g.hazard(kind,{...point,lesson:true,signal,color:TELEGRAPH_COLORS[signal],damage:lead.maxHp*fraction/(stageFor(g.s).damage||1)},delay,0,.3); } function clearPoint(g,h,step=5,avoid=[]){ for(const reach of [step,step+2,step+4]){ const candidates=Array.from({length:32},(_,i)=>g.bound(h.x+Math.sin(i*Math.PI/16)*reach,h.z+Math.cos(i*Math.PI/16)*reach)); const point=candidates.filter(p=>distance(p,h)>reach-.5&&g.rt.nav.legal(p,h.radius)&&distance(p,g.s.boss)>g.s.boss.radius+h.radius+.6&&!alive(g.s).some(a=>a!==h&&distance(a,p)<1.1)&&!avoid.some(a=>distance(a,p)<(a.r||6.5))).sort((a,b)=>distance(a,g.s.boss)-distance(b,g.s.boss))[0]; if(point)return point; } return g.bound(h.x+5,h.z); } function kitePoint(g,h){ const b=g.s.boss,reach=Math.max(b.radius+h.radius+.2,Math.min(7,unitAttack(h).range+(h.stats?.range||0)+b.radius+h.radius-.6)); const points=Array.from({length:32},(_,i)=>({x:b.x+Math.sin(i*Math.PI/16)*reach,z:b.z+Math.cos(i*Math.PI/16)*reach})); return points.filter(p=>distance(h,p)>2.8&&g.rt.nav.legal(p,h.radius)&&alive(g.s).every(a=>a===h||distance(a,p)>1.1)).sort((a,b)=>distance(h,a)-distance(h,b))[0]||points[0]; } const jointAttackReady=(state,id)=>state.participants.includes(id)&&state.participants.length>=2&&(!state.requiresSkill||state.skillParticipants?.includes(id)); function planSpread(g){ const s=g.s,l=s.learning,lead=owner(s),members=alive(s),origins=members.map(h=>({...h,r:2.8})),targets={}; for(const h of members.filter(a=>a!==lead)){ const preferred=s.heroes.indexOf(h)/s.teamSize*Math.PI*2; const options=Array.from({length:32},(_,i)=>{const turn=i===0?0:(i%2?1:-1)*Math.ceil(i/2)*Math.PI/16;return g.bound(h.x+Math.cos(preferred+turn)*5,h.z+Math.sin(preferred+turn)*5);}); targets[h.id]=options.find(p=>distance(p,h)>3.5&&g.rt.nav.legal(p,h.radius)&&distance(p,s.boss)>s.boss.radius+h.radius+.5&&origins.every(a=>distance(p,a)>a.r)&&Object.values(targets).every(a=>distance(p,a)>6.5))||clearPoint(g,h,5,Object.values(targets)); } targets[lead.id]=clearPoint(g,lead,5,[...Object.values(targets),...origins]); l.spreadTargets=targets;l.marker={...targets[lead.id],color:TELEGRAPH_COLORS.target}; } function beat(g,phase){ const s=g.s,l=s.learning,h=owner(s);clearBeat(g);h.hp=Math.max(h.hp,h.maxHp*.6); Object.assign(l,{phase,started:s.time,called:false,hold:0,hit:[],blocked:[],ends:0,marker:null,text:'',action:null,feedback:null,advanceAt:0,retryAt:0,skillHitAt:0,lastRefund:null,refundAt:0}); for(const a of s.heroes)if(a.ai&&!a.dead){a.auto=false;g.rt.issue(a,{kind:'hold',x:a.x,z:a.z});} if(phase==='move'){g.rt.interrupt(h);h.auto=false;h.stationaryCombat=true;h.steer=null;h.queue=[];g.rt.issue(h,{kind:'hold',x:h.x,z:h.z});l.text='Move to the light';l.origin={x:h.x,z:h.z};l.marker={...clearPoint(g,h,3.5),color:TELEGRAPH_COLORS.target};} if(phase==='dodge'){ l.text='Leave the red area';l.origin={x:h.x,z:h.z};l.marker={...clearPoint(g,h),color:TELEGRAPH_COLORS.target}; const zone=danger(g,'circle',{x:h.x,z:h.z,r:2.7});l.ends=s.time+zone.delay+.35;l.zone=zone.id; s.boss.casting={name:({1:'Flame strike',2:'Water burst',3:'Toxic burst'})[l.level]||'Training strike',left:zone.delay,total:zone.delay,lesson:true}; } if(phase==='block'){ l.text='Watch the wind-up';g.rt.interrupt(h);h.steer=null;h.queue=[];g.rt.issue(h,{kind:'hold',x:h.x,z:h.z});h.veilCd=0;h.veil=0;h.mana=Math.max(h.mana,70); const zone=danger(g,'line',{x:s.boss.x,z:s.boss.z,x2:h.x,z2:h.z,width:2.5,signal:'block'},2.6); l.zone=zone.id;l.ends=s.time+zone.delay+.35;l.impactAt=s.time+zone.delay; s.boss.casting={name:'Heavy strike',left:zone.delay,total:zone.delay,lesson:true}; } if(phase==='kite'){ g.rt.interrupt(h);h.steer=null;h.queue=[];h.auto=false;g.rt.issue(h,{kind:'hold',x:h.x,z:h.z}); l.kite={phase:'move',shots:h.attackCount};l.origin={x:h.x,z:h.z};l.marker=kitePoint(g,h);l.text='Move to the light'; } if(phase==='rally'){l.text='Call your ally';l.action='rally';} if(phase==='spread'){l.text='Call Spread out';l.action='spread';} if(phase==='strike'){ l.text='Use your skill';l.skill=l.level===3?1:0;l.skillsUnlocked=true; for(const a of s.heroes){a.cd[l.skill]=0;a.mana=Math.min(a.maxMana,a.mana+(skillFor(a,l.skill)?.mana||0));} } if(phase==='focus'){ l.text='Attack together';l.action='focus';l.participants=[];l.skillParticipants=[];l.requiresSkill=true;l.skill=0; h.cd[0]=0;h.mana=Math.min(h.maxMana,h.mana+(skillFor(h,0)?.mana||0)); if(['heal','renew','chainheal','fullheal'].includes(skillFor(h,0)?.type)){const ally=alive(s).find(a=>a!==h)||h;ally.hp=Math.min(ally.hp,ally.maxHp*.65);} } if(phase==='finish'){ l.text='Guard broken';l.burstUntil=s.time+9;s.boss.teamGuard=false; const zone=danger(g,'circle',{x:h.x,z:h.z,r:2.7},2.5,.1);l.zone=zone.id; for(const a of alive(s))if(a.ai){a.auto=true;g.rt.issue(a,{kind:'attack',target:'boss'});} say(g,s.heroes.find(a=>a.ai),'Now');g.fx('interrupt',s.boss.x,s.boss.z,TELEGRAPH_COLORS.safe,3); } } function advance(g,text){ const l=g.s.learning;clearBeat(g);l.successes++;l.action=null;l.marker=null;l.feedback={text,success:true};l.advanceAt=g.s.time+.65; if(l.phase==='block')l.blockLearned=true; if(['move','kite'].includes(l.phase))owner(g.s).auto=true; } function retry(g,text){const l=g.s.learning;clearBeat(g);l.action=null;l.marker=null;l.feedback={text,success:false};l.retryAt=g.s.time+1.1;} export function initTeamwork(g){ const s=g.s,level=stageFor(s);s.learning=null;s.teamwork=null;if(!level)return; if(s.match.kind==='onboarding'){ const h=s.heroes.find(h=>!h.ai)||s.heroes[0],maxHp=1500; Object.assign(s.boss,{hp:maxHp,maxHp,mana:0,ult:0,infinity:false,casting:null,attackTimer:999,patternTimer:999,armorMultiplier:1,teamGuard:true,anchored:true}); s.learning={version:4,level:1,owner:h.id,phase:'ready',index:0,sequence:['move','dodge','block','kite','rally','spread','strike','focus','finish'],skillsUnlocked:false,blockLearned:false,calls:0,successes:0}; h.x=-3;h.z=6;h.facing=Math.PI; for(const [i,a]of s.heroes.slice(1).entries()){a.x=-7-i*2;a.z=7;g.rt.init(a);a.auto=false;} s.boss.x=0;s.boss.z=0;g.rt.init(h);return; } if(level.id>=1)s.teamwork={sequence:level.id>=11?['spread','rally','focus']:level.id>=9?['rally','focus']:level.id===3?['spread','focus']:['focus'],index:0,called:false,hold:0,participants:[],skillParticipants:[],requiresSkill:true,complete:false,active:false,successes:0,cycle:0,cycles:level.id>=11?3:level.id>=4?2:1}; } export function learningSkillAllowed(s,h,slot){return !s.learning||h.ai||s.learning.phase!=='block'&&!(s.learning.phase==='focus'&&!s.learning.called)&&s.learning.skillsUnlocked&&slot0)l.blocked.push(h.id);else l.hit.push(h.id); if(zone.signal==='block'&&h.id===l.owner){g.s.boss.attackAnim=.8;g.fx(h.veil>0?'shield':'slash',h.x,h.z,zone.color,2);} } export function teamCommand(g,h,command){ if(command.type!=='tactic')return;const s=g.s,l=s.learning,t=s.teamwork; if(l&&l.action===command.tactic&&!l.called){ l.called=true;l.calledAt=s.time;l.calls++;l.callOwner=h.id;l.point={x:h.x,z:h.z};l.marker={...l.point,color:TELEGRAPH_COLORS.safe};say(g,s.heroes.find(a=>a.ai),'With you'); if(l.phase==='spread'){ for(const a of alive(s))danger(g,'circle',{x:a.x,z:a.z,r:2.4,subject:a.id},3.2,.08); l.ends=s.time+3.55;planSpread(g); s.boss.casting={name:'Toxic burst',left:3.2,total:3.2,lesson:true}; } } if(t&&!t.complete&&t.sequence[t.index]===command.tactic){t.called=true;t.calledAt=s.time;t.point={x:h.x,z:h.z};t.owner=h.id;t.participants=[];t.skillParticipants=[];t.hold=0;} } function wipe(g){g.s.phase='wipe';g.s.wipes++;g.callout('Squad defeated',3);} const supportTypes=new Set(['heal','renew','chainheal','fullheal','shield','spirit','blessing','malice','protect','speed','taunt']); export function learningApproach(s,h=owner(s)){ const l=s.learning,b=s.boss;if(!l||l.feedback||!['strike','focus','finish'].includes(l.phase)&&!(l.phase==='kite'&&l.kite?.phase==='attack'))return null; const skill=l.phase==='strike'?skillFor(h,l.skill):null; let reach; if(skill){if(supportTypes.has(skill.type))return null;reach=(skill.target==='none'?(skill.radius||skill.range||6):skill.range||15)+(b.radius||1.5);} else reach=unitAttack(h).range+(h.stats?.range||0)+(h.radius||.4)+(b.radius||1.5); if(distance(h,b)a!==h&&!a.dead)){if(!l.rescueAlly)clearBeat(g);if(s.revives<=0)wipe(g);l.rescueAlly=true;return true;} if(l.rescueAlly||l.rescuing){l.rescueAlly=l.rescuing=false;beat(g,l.phase==='ready'?'move':l.phase);} if(l.phase==='ready'){beat(g,l.sequence[0]);return true;} if(l.advanceAt){if(s.time>=l.advanceAt){l.index++;beat(g,l.sequence[l.index]||'finish');}return true;} if(l.retryAt){if(s.time>=l.retryAt)beat(g,l.phase);return true;} if(l.phase==='move'&&l.marker&&distance(h,l.marker)<.8&&distance(h,l.origin)>2)advance(g,'Moved'); else if(l.phase==='dodge'&&s.time>=l.ends){if(!l.hit.includes(h.id))advance(g,'Dodged');else retry(g,'Move before it lands');} else if(l.phase==='block'&&s.time>=l.ends){if(l.blocked.includes(h.id))advance(g,'Blocked');else retry(g,'Block just before impact');} else if(l.phase==='kite'){ if(l.kite.phase==='attack'&&h.order.kind==='attack')h.auto=true; if(l.kite.phase==='move'&&distance(h,l.marker)<.85&&distance(h,l.origin)>2){l.kite.phase='attack';l.kite.shots=h.attackCount;l.marker=null;l.text='Stop to attack';} else if(l.kite.phase==='attack'&&h.attackCount>l.kite.shots){ l.kite.phase='move-again';l.origin={x:h.x,z:h.z};l.marker=clearPoint(g,h,3.5);l.text='Move again';h.auto=false;g.rt.interrupt(h);g.rt.issue(h,{kind:'hold',x:h.x,z:h.z}); }else if(l.kite.phase==='move-again'&&distance(h,l.marker)<.85&&distance(h,l.origin)>2)advance(g,'Nice rhythm'); } else if(l.phase==='rally'&&l.called){ const members=alive(s),near=members.filter(a=>distance(a,l.point)<4); l.hold=near.length>=Math.min(2,members.length)?l.hold+dt:0; if(!l.ends&&l.hold>.35){ const zone=danger(g,'safe',{...l.point,r:4.5},2,.1);l.ends=s.time+2.35;l.zone=zone.id;l.text='Stay in the green circle';l.marker=null; b.casting={name:'Nova',left:2,total:2,lesson:true}; } if(l.ends&&s.time>=l.ends){ if(!l.hit.includes(h.id)&&distance(h,l.point)<4.1&&members.some(a=>a!==h&&distance(a,l.point)<4.1))advance(g,'Safe together');else retry(g,'Stay with your ally'); } } else if(l.phase==='spread'&&l.called){ const members=alive(s),separated=members.every(a=>members.every(b=>a===b||distance(a,b)>4.5)); if(s.time>=l.ends){if(separated&&!l.hit.includes(h.id))advance(g,'Room for everyone');else retry(g,'Move clear of your allies');} } else if(l.phase==='strike'){ const event=s.casts.findLast(c=>!g.getHero(c.owner)?.ai&&c.slot===l.skill&&c.time>=l.started),caster=event&&g.getHero(event.owner),skill=event&&skillFor(caster,l.skill); if(event&&event.impactAt!==null&&event.resolvedAt!==null&&(l.skillHitAt>=event.time||supportTypes.has(skill.type))&&s.time-event.impactAt>.75&&!caster.casting)advance(g,'Skill landed'); else if(event&&s.time-event.time>.6&&!caster.casting&&caster.order.kind!=='cast'&&l.lastRefund!==event.id&&(event.resolvedAt===null||event.impactAt!==null&&!supportTypes.has(skill.type)&&l.skillHitAt14){retry(g,'Try Focus again');return true;} const event=s.casts.findLast(c=>c.owner===l.callOwner&&c.slot===l.skill&&c.time>=l.started),caster=event&&g.getHero(event.owner); if(event&&!l.skillParticipants.includes(l.callOwner)&&s.time-event.time>.6&&!caster.casting&&caster.order.kind!=='cast'&&l.lastRefund!==event.id&&(event.resolvedAt===null||event.impactAt!==null)){ l.lastRefund=event.id;caster.cd[l.skill]=0;caster.mana=Math.min(caster.maxMana,caster.mana+(skillFor(caster,l.skill)?.mana||0));l.refundAt=s.time; } l.hold=jointAttackReady(l,l.callOwner)?l.hold+dt:0;if(l.hold>.4)advance(g,'Together'); } else if(l.phase==='finish'&&s.time>l.burstUntil){l.burstUntil=s.time+8;const zone=danger(g,'circle',{x:h.x,z:h.z,r:2.7},2.1,.1);l.zone=zone.id;} return true; } export function learningAI(g,h){ const s=g.s,l=s.learning,lead=owner(s);if(!l||h.dead)return; if(h.hp/h.maxHp<.4)g.command(h.id,{type:'potion',slot:0}); if(lead.dead){if(s.revives>0&&!h.channel){if(distance(h,lead)<3.6)g.beginRevive(h);else g.aiMove(h,lead);}return;} if(h.channel||h.casting)return; if(l.phase==='rally'&&l.called&&!l.feedback){ const i=s.heroes.indexOf(h),angle=(i-1)*Math.PI*1.1+.3; g.aiMove(h,{x:l.point.x+Math.cos(angle)*2,z:l.point.z+Math.sin(angle)*2});return; } if(l.phase==='spread'&&l.called&&!l.feedback){g.aiMove(h,l.spreadTargets[h.id]);return;} if(l.phase==='finish'&&s.tactic?.until>s.time&&['rally','spread'].includes(s.tactic.kind)&&g.danger(h)<1){const t=s.tactic,i=s.heroes.indexOf(h),a=i/s.teamSize*Math.PI*2,r=t.kind==='spread'?10:2,goal=g.bound(t.x+Math.cos(a)*r,t.z+Math.sin(a)*r);if(distance(h,goal)>2&&g.danger(goal)<1){g.aiMove(h,goal);return;}} if(l.phase==='focus'&&l.called||l.phase==='finish'){ h.auto=true; if(g.danger(h)>0){const zone=s.hazards.find(z=>z.lesson&&g.hazardHit(z,h));if(zone){const d=distance(h,zone)||1;g.aiMove(h,{x:zone.x+(h.x-zone.x)/d*5,z:zone.z+(h.z-zone.z)/d*5});return;}} if(h.order.kind!=='attack'||h.order.target!=='boss')g.rt.issue(h,{kind:'attack',target:'boss'});return; } h.auto=false;if(h.order.kind!=='hold')g.rt.issue(h,{kind:'hold',x:h.x,z:h.z}); } export function tickTeamwork(g,dt){ const s=g.s,t=s.teamwork,b=s.boss;if(!t||b.dead)return;const threshold=teamThreshold(t);if(t.complete&&threshold!==null&&b.hp<=b.maxHp*threshold+.001){t.cycle++;t.index=0;t.complete=false;t.called=false;t.hold=0;t.participants=[];t.skillParticipants=[];}if(t.complete)return;t.active=s.fightTime>4;if(!t.active)return; const action=t.sequence[t.index];if(t.called){ const members=alive(s),lead=g.getHero(t.owner);let ready=false; if(lead&&!lead.dead){ if(action==='rally')ready=members.filter(a=>distance(a,t.point)<6).length>=Math.max(2,Math.ceil(members.length*.6))&&distance(lead,t.point)<6; if(action==='spread')ready=members.filter(a=>members.every(b=>a===b||distance(a,b)>4)).length>=Math.max(2,Math.ceil(members.length*.65)); if(action==='focus')ready=jointAttackReady(t,lead.id); } t.hold=ready?t.hold+dt:0;if(t.hold>.4){t.successes++;t.index++;t.called=false;t.hold=0;if(t.index>=t.sequence.length){t.complete=true;t.burstUntil=s.time+6;b.teamGuard=false;g.fx('interrupt',b.x,b.z,'#8affe7',4);say(g,s.heroes.find(h=>h.ai),'Now');}} else if(s.time-t.calledAt>14){t.called=false;t.participants=[];t.skillParticipants=[];} } b.teamGuard=!t.complete&&b.hp<=b.maxHp*(teamFloor(t)+.02); } export function recordTeamSkill(g,h){ const l=g.s.learning,t=l?.phase==='focus'?l:g.s.teamwork; if(!h||h.ai||!t?.called||t===g.s.teamwork&&t.sequence[t.index]!=='focus')return; t.skillParticipants??=[];if(!t.skillParticipants.includes(h.id))t.skillParticipants.push(h.id);if(!t.participants.includes(h.id))t.participants.push(h.id); } export function teamFloor(t){return t.cycles===3?[.68,.35,.12][t.cycle]:t.cycles===2?[.55,.12][t.cycle]:.18;} export function teamThreshold(t){return t.cycle>=t.cycles-1?null:t.cycles===3?[.64,.29][t.cycle]:.50;} export function teamworkDamage(g,h,e,amount,options={}){ if(e.id!=='boss'||amount<=0)return amount;const s=g.s,l=s.learning,t=s.teamwork; const add=state=>{if(!state.participants.includes(h.id)&&!options.dot)state.participants.push(h.id);}; if(l){ if(l.phase==='focus'&&l.called)add(l); if(l.phase==='strike'&&!h.ai&&!options.dot&&options.spell===skillFor(h,l.skill)?.name)l.skillHitAt=s.time; const floor=({move:1,dodge:.82,block:.76,kite:.70,rally:.66,spread:.66,strike:.34,focus:.18})[l.phase]??0;e.teamGuard=l.phase!=='finish'; return Math.min(amount*(l.phase==='finish'?4:1),Math.max(0,e.hp-e.maxHp*floor)); } if(t){if(t.called&&t.sequence[t.index]==='focus')add(t);if(!t.complete)return Math.min(amount,Math.max(0,e.hp-e.maxHp*teamFloor(t)));const burst=amount*(s.timeh.id===localId)||owner(s);const skillLesson=l.phase==='strike'||l.phase==='focus'&&l.called&&!l.skillParticipants.includes(h.id);if(skillLesson&&h.casting?.slot===l.skill)return null; const cast=skillLesson?s.casts.findLast(c=>c.owner===h.id&&c.slot===l.skill&&c.time>=l.started):null,castAccepted=skillLesson&&(h.order?.kind==='cast'&&h.order.slot===l.skill||cast&&cast.id!==l.lastRefund),approach=castAccepted||l.phase==='finish'&&s.hazards.some(z=>z.lesson&&z.t=l.impactAt-1&&s.timez.lesson&&z.tz.lesson&&z.kind==='circle'&&distance(h,z)Math.max(a,Math.min(b,x)); export function encounterProgress(s){ const b=s.boss;if(!b||b.dead)return b?.dead?1:0;let fraction=clamp(1-b.hp/Math.max(1,b.kind==='ancient'&&b.form==='melee'?b.maxHp:b.progressStartHp||b.maxHp),0,1); if(b.kind==='light'){const waiting=b.events?.some(e=>e.action==='rebirth'&&!e.done);fraction=((2-(b.divinity??2))+(waiting?0:fraction))/3;} if(b.kind==='ancient'&&DIFFICULTIES[s.difficulty].tier>=2)fraction=(b.form==='melee'?1:0)/2+fraction/2; return clamp(fraction,0,.95); } export function trackCampaignProgress(s){ if(s.match?.kind!=='campaign')return; const m=s.match;m.progress??=Array(m.target).fill(0);m.effort??={casts:0,travel:0,tactics:0}; for(let i=0;iObject.hasOwn(HERO_PRICES,k)))];out.shards=Math.max(0,Math.floor(Number(p?.shards)||0));out.power=powerLevel(p?.power);out.clearedLevels=[...new Set((p?.clearedLevels||[]).filter(id=>Number.isInteger(id)&&levelFor(id)))].sort((a,b)=>a-b);out.trainingShaderSeed=out.tutorialComplete?trainingShaderSeed(p):Number.isInteger(p?.trainingShaderSeed)&&p.trainingShaderSeed>0?p.trainingShaderSeed>>>0:null;out.starterShader=typeof p?.starterShader==='string'&&out.shaders.includes(p.starterShader)?p.starterShader:null;out.targetHero=Object.hasOwn(HERO_PRICES,p?.targetHero)?p.targetHero:null;out.journal=normalizeJournal(p?.journal,p||{});out.receipts=(p?.receipts||[]).filter(r=>r&&typeof r.id==='string').map(r=>({...r})).slice(-1000);return out;} export const RAID_TARGETS={veryeasy:8,easy:9,moderate:9,normal:10,hard:10}; export const DEPTH_REWARDS=[0,0,0,45,75,110,150,195,245,300,360]; export function matchReward(receipt,profile=freshProfile()){ if(!receipt||!['intro','raid','campaign'].includes(receipt.kind)||!['victory','defeat'].includes(receipt.result)||typeof receipt.id!=='string'||!/^[a-zA-Z0-9_-]{8,80}$/.test(receipt.id))return 0; if(receipt.kind==='campaign'){if(!Number.isInteger(receipt.cleared)||!Number.isFinite(receipt.seconds)||receipt.seconds<0)return 0;return campaignPayout(receipt,profile);} const count=receipt.cleared,limit=receipt.kind==='intro'?2:receipt.difficulty?RAID_TARGETS[receipt.difficulty]:10; if(!Number.isInteger(limit)||receipt.target!==undefined&&receipt.target!==limit||!Number.isInteger(count)||count<0||count>limit||!Number.isFinite(receipt.seconds)||receipt.seconds<0)return 0; if(receipt.result==='victory'&&count!==limit)return 0; if(receipt.kind==='intro')return receipt.result==='victory'&&!profile.introComplete?400:0; // No idle rewards or early-exit farming. A full clear is worth much more. const depth=DEPTH_REWARDS[count];if(!depth)return 0; return depth+(receipt.result==='victory'?180:0)+(receipt.difficulty==='hard'&&receipt.result==='victory'?60:0); } export function applyProfileAction(raw,action){ const p=normalizeProfile(raw);if(!action||typeof action.type!=='string')throw Error('Invalid profile action.'); const priorShards=p.shards;let reward=0; if(action.type==='restore-save'){ if(action.profile?.season!==PROGRESSION_EPOCH)throw Error('This save is from an older release.'); const restored=normalizeProfile(action.profile);if(restored.starter&&!restored.owned.includes(restored.starter))throw Error('Invalid starter progress.'); restored.revision=p.revision+1;return {profile:restored,reward:0}; } else if(action.type==='migrate'){ if(p.starter)return {profile:p,reward:0}; if(action.profile?.season!==PROGRESSION_EPOCH||!STARTERS.includes(action.profile?.starter))throw Error('This progress is from an older release.'); const imported=normalizeProfile(action.profile);if(!imported.owned.includes(imported.starter))throw Error('Invalid starter progress.'); imported.revision=p.revision+1;return {profile:imported,reward:0}; } else if(action.type==='starter'){if(p.starter){if(p.starter===action.hero)return {profile:p,reward:0};throw Error('Your starter is already chosen.');}if(!STARTERS.includes(action.hero))throw Error('Choose one of the three starters.');p.starter=action.hero;p.owned=[action.hero];} else if(action.type==='tutorial'){if(!p.starter)throw Error('Choose your starter first.');const step=Math.max(0,Math.min(30,Math.floor(action.step||0))),version=action.version||p.tutorialVersion||3;if(version!==p.tutorialVersion)p.tutorialStep=0;p.tutorialVersion=version;p.tutorialStep=Math.max(p.tutorialStep,step);if(action.complete){p.tutorialComplete=true;p.trainingShaderSeed=trainingShaderSeed({...p,trainingShaderSeed:p.trainingShaderSeed||action.trainingShaderSeed});}} else if(action.type==='upgrade-power'){if(!p.starter)throw Error('Choose your starter first.');if(p.power>=MAX_POWER)throw Error('Your squad is at maximum level.');if(action.from!==p.power)throw Error('Your squad level changed. Try again.');const cost=powerCost(p.power);if(p.shards[part,action.shader])),fx:Object.fromEntries(Object.keys(FX_SLOTS).map(key=>[key,action.shader]))});p.activePresets[hero]=id;} } else if(action.type==='buy-shader'){if(!p.starter)throw Error('Choose your starter first.');const shader=Object.hasOwn(SHADERS,action.shader)?SHADERS[action.shader]:null;if(!shader)throw Error('Unknown shader.');if(p.shaders.includes(action.shader))throw Error('You already own this shader.');if(p.shards24)throw Error('Use a preset name of 1 to 24 characters.'); if(!action.parts||typeof action.parts!=='object'||Array.isArray(action.parts)||Object.keys(action.parts).length>HERO_SHAPES[action.hero].length)throw Error('Invalid paint selection.'); for(const [part,id] of Object.entries(action.parts)){if(!HERO_SHAPES[action.hero].includes(part))throw Error('Unknown shape.');if(!p.shaders.includes(id))throw Error('Unlock this shader before saving.');} if(action.fx!==undefined&&(!action.fx||typeof action.fx!=='object'||Array.isArray(action.fx)||Object.keys(action.fx).length>3))throw Error('Invalid effect paint.'); for(const [key,id]of Object.entries(action.fx||{})){if(!Object.hasOwn(FX_SLOTS,key))throw Error('Unknown effect.');if(!p.shaders.includes(id))throw Error('Unlock this shader before saving.');} const fx=cleanFx(action.fx,p.shaders); const index=list.findIndex(x=>x.id===action.id),preset={id:action.id,name,parts:cleanParts(action.hero,action.parts,p.shaders),...(Object.keys(fx).length?{fx}:{})};if(index<0){if(list.length>=8)throw Error('You can save eight presets per hero.');list.push(preset);}else list[index]=preset;p.activePresets[action.hero]=action.id; }else if(action.type==='equip-preset'){if(action.id&&!list.some(x=>x.id===action.id))throw Error('Preset not found.');if(action.id)p.activePresets[action.hero]=action.id;else delete p.activePresets[action.hero];} else{const index=list.findIndex(x=>x.id===action.id);if(index<0)throw Error('Preset not found.');list.splice(index,1);if(p.activePresets[action.hero]===action.id)delete p.activePresets[action.hero];} } else if(action.type==='record-run'){if(!p.starter)throw Error('Choose your starter first.');const r=applyRunRecord(p.journal,action.record);if(!r.changed)return {profile:p,reward:0};p.journal=r.journal;} else if(action.type==='reward'){ const r=action.receipt,prior=p.receipts.find(x=>x.id===r?.id),incremental=r?.kind==='campaign'&&prior?.kind==='campaign'; if(prior&&(!incremental||prior.stage!==r.stage||prior.result==='victory'))return {profile:p,reward:0}; if(!p.starter)throw Error('Choose your starter first.'); const entitled=matchReward(r,p);reward=Math.max(0,entitled-(incremental?prior.amount||0:0)); if(!reward){if(prior)return {profile:p,reward:0};throw Error('This match is not eligible for shards.');} p.shards+=reward;if(!prior)p.matches++;if(r.result==='victory')p.wins++; if(r.kind==='intro'&&r.result==='victory')p.introComplete=true; if(r.kind==='campaign'&&r.result==='victory'&&canPlayLevel(p,r.stage)){if(!p.clearedLevels.includes(r.stage))p.clearedLevels.push(r.stage);p.introComplete=true;} const entry={id:r.id,amount:(prior?.amount||0)+reward,result:r.result,kind:r.kind,...(r.stage?{stage:r.stage}:{})}; if(prior)Object.assign(prior,entry);else p.receipts.push(entry);p.receipts=p.receipts.slice(-1000); } else throw Error('Unknown profile action.'); if(reward>0)p.journal.shardsEarned+=reward;if(p.shardsglobalThis.fetch(...args),timers={}){ this.storage=storage;this.fetcher=fetcher;this.failedWrites=new Set();this.retryDelay=1000;this.setTimer=timers.setTimeout?timers.setTimeout.bind(timers):(fn,delay)=>globalThis.setTimeout(fn,delay);this.clearTimer=timers.clearTimeout?timers.clearTimeout.bind(timers):id=>globalThis.clearTimeout(id); const saved=this.read(PROFILE_KEY,null);this.wasReset=saved?.season!==PROGRESSION_EPOCH;this.profile=this.wasReset?freshProfile():normalizeProfile(saved);this.pending=this.wasReset?[]:this.read('ib-profile-pending-v1',[]); if(this.wasReset){for(const key of ['ib-save-v3','ib-save-v4']){try{this.storage.removeItem(key);}catch{}}this.persist();} this.online=false;this.busy=false;this.token=this.read('ib-profile-token-v1',null)||(globalThis.crypto.randomUUID?.()||Array.from(crypto.getRandomValues(new Uint8Array(24)),x=>x.toString(16).padStart(2,'0')).join(''));this.write('ib-profile-token-v1',this.token); } read(k,f){try{return JSON.parse(this.storage.getItem(k))??f;}catch{return f;}} write(k,v){try{this.storage.setItem(k,JSON.stringify(v));this.failedWrites.delete(k);return true;}catch{this.failedWrites.add(k);return false;}} get localError(){return this.failedWrites.size?'Could not save on this device. Copy a save code.':'';} get saveStatus(){ if(this.localError)return {state:'error',text:this.localError}; if(this.syncBlocked)return {state:'error',text:'Sync failed. Progress stays on this device.'}; if(!this.online)return {state:'local',text:'Saved on this device. Waiting to sync.'}; if(this.pending.length||this.busy)return {state:'syncing',text:'Saved on this device. Syncing…'}; return {state:'saved',text:'Progress saved.'}; } get syncStatus(){return this.saveStatus.state;} persist(){const profile=this.write(PROFILE_KEY,this.profile),pending=this.write('ib-profile-pending-v1',this.pending);if(this.token&&this.failedWrites.has('ib-profile-token-v1'))this.write('ib-profile-token-v1',this.token);return profile&&pending;} clearRetry(){if(this.retryTimer!==undefined){this.clearTimer(this.retryTimer);this.retryTimer=undefined;}} scheduleRetry(){ if(!this.autoSync||this.replacing||this.syncBlocked||this.online&&!this.pending.length||this.retryTimer!==undefined)return; const delay=this.retryDelay;this.retryDelay=Math.min(60000,delay*2); this.retryTimer=this.setTimer(()=>{this.retryTimer=undefined;return this.retrySync().catch(()=>{});},delay);this.retryTimer?.unref?.(); } startSync(events=typeof window!=='undefined'?window:null){ if(!this.autoSync){this.autoSync=true;this.syncEvents=events;this.onSyncWake=()=>{if(typeof document==='undefined'||!document.hidden)this.retrySync().catch(()=>{});};this.onSyncOnline=()=>this.retrySync().catch(()=>{});events?.addEventListener('online',this.onSyncOnline);events?.addEventListener('focus',this.onSyncWake);} return this.retrySync(); } stopSync(){this.autoSync=false;this.clearRetry();this.syncEvents?.removeEventListener('online',this.onSyncOnline);this.syncEvents?.removeEventListener('focus',this.onSyncWake);this.syncEvents=null;} retrySync(){if(this.replacing)return Promise.resolve(this.profile);this.persist();this.clearRetry();this.syncBlocked=false;return this.online?this.flush():this.init();} async replace(raw){ this.replacing=true;this.clearRetry(); try{await Promise.allSettled([this.initializing,this.flushing].filter(Boolean));const action={type:'restore-save',profile:normalizeProfile(raw)};this.profile=applyProfileAction(this.profile,action).profile;this.pending=[action];this.tracker=null;this.syncBlocked=false;try{this.storage.removeItem('ib-profile-migrate-v1');}catch{}this.persist();await this.flush();return this.profile;} finally{this.replacing=false;this.scheduleRetry();} } async request(action){const r=await this.fetcher(action?.type==='migrate'?'/api/profile/import':action?.type==='restore-save'?'/api/profile/restore':'/api/profile',{method:action?'POST':'GET',headers:{'Content-Type':'application/json','X-Player-Key':this.token,'X-Progression-Epoch':PROGRESSION_EPOCH},...(action?{body:JSON.stringify(action)}:{}),signal:AbortSignal.timeout(4500)});const d=await r.json();if(!r.ok){const e=Error(d.error||'Could not sync your profile.');e.status=r.status;throw e;}if(!d.profile)throw Error('Profile service unavailable.');return d;} init(){return this.initializing??=this.initialize().finally(()=>{this.initializing=null;});} async initialize(){ try{const migration=this.read('ib-profile-migrate-v1',null);const remote=await this.request(migration?{type:'migrate',profile:migration}:undefined);if(migration)this.storage.removeItem('ib-profile-migrate-v1');this.online=true;this.clearRetry();const actions=this.pending.slice();this.profile=normalizeProfile(remote.profile);this.pending=[];for(const action of actions){try{this.profile=applyProfileAction(this.profile,action).profile;this.pending.push(action);}catch{}}this.persist();await this.flush();} catch{this.online=false;this.scheduleRetry();}return this.profile; } act(action){if(action?.type==='tutorial'&&action.complete&&!this.profile.trainingShaderSeed&&!Number.isInteger(action.trainingShaderSeed))action={...action,trainingShaderSeed:globalThis.crypto.getRandomValues(new Uint32Array(1))[0]||1};const revision=this.profile.revision,result=applyProfileAction(this.profile,action);this.profile=result.profile;if(result.profile.revision!==revision){this.pending.push(action);this.persist();this.flush().catch(()=>{this.online=false;this.scheduleRetry();});}return result;} flush(){if(!this.online){this.scheduleRetry();return Promise.resolve();}if(this.flushing)return this.flushing;this.flushing=this.flushPending().finally(()=>{this.flushing=null;this.scheduleRetry();});return this.flushing;} async flushPending(){ this.busy=true;try{while(this.pending.length){const action=this.pending[0];let d; try{d=await this.request(action);}catch(error){ if(error.status>=400&&error.status<500&&action.type==='restore-save'){this.online=false;this.syncBlocked=true;this.error=error.message;break;} if(error.status>=400&&error.status<500){d=await this.request();this.error=error.message;}else{this.online=false;break;} } this.pending.shift();let next=normalizeProfile(d.profile);const keep=[];for(const a of this.pending){try{next=applyProfileAction(next,a).profile;keep.push(a);}catch{}}this.pending=keep;this.profile=next;this.persist(); }}catch{this.online=false;}finally{this.busy=false;if(this.online&&!this.pending.length){this.retryDelay=1000;this.clearRetry();}} } track(state,id,{joined=false}={}){ if(!canRecord(state))return;const h=state.heroes.find(h=>h.id===id);if(!h)return;const key=state.match.id+'_'+id; if(this.tracker?.record.id!==key){if(this.tracker)this.commitStats();this.tracker=new RunTracker(state,h,this.profile.journal.runs.find(r=>r.id===key),joined);}else this.tracker.observe(state,h); if(this.tracker.urgent||Date.now()-(this.lastStatsWrite||0)>15000)this.commitStats(); } commitStats(){if(!this.tracker?.dirty)return;const record=this.tracker.snapshot();this.act({type:'record-run',record});this.tracker.dirty=this.tracker.urgent=false;this.lastStatsWrite=Date.now();} abandonStats(){if(!this.tracker)return;this.tracker.abandon();this.commitStats();} abandonRun(id){ if(this.tracker?.record.id===id){this.abandonStats();return;} const record=this.profile.journal.runs.find(r=>r.id===id)||this.profile.journal.history.find(r=>r.id===id); if(record?.status==='active')this.act({type:'record-run',record:{...record,status:'abandoned',endedAt:Date.now()}}); } owns(hero){return this.profile.owned.includes(hero);} look(hero){return selectedLook(this.profile,hero);} reward(state){const receipt=receiptFor(state);if(!receipt)return 0;return this.act({type:'reward',receipt}).reward;} } ===== pathing.js ===== export const distance=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); export const angleDifference=(a,b)=>Math.atan2(Math.sin(a-b),Math.cos(a-b)); export function turn(unit,angle,dt){const delta=angleDifference(angle,unit.facing||0),max=(unit.turnRate||3)*dt;unit.facing=(unit.facing||0)+Math.max(-max,Math.min(max,delta));return Math.abs(angleDifference(angle,unit.facing));} function segmentDistance(p,a,b){const dx=b.x-a.x,dz=b.z-a.z,t=Math.max(0,Math.min(1,((p.x-a.x)*dx+(p.z-a.z)*dz)/(dx*dx+dz*dz||1)));return Math.hypot(p.x-a.x-dx*t,p.z-a.z-dz*t);} export class Pathfinder { constructor(radius,obstacles=[]){this.radius=radius;this.obstacles=obstacles;this.cell=.7;} legal(p,r=.4,blockers=[],ignore=null){if(!Number.isFinite(p.x)||!Number.isFinite(p.z)||Math.hypot(p.x,p.z)>this.radius-r-.08)return false;for(const o of this.obstacles)if(distance(p,o)({...o,radius:o.r})),...blockers])if(o!==ignore&&!o.dead&&segmentDistance(o,a,b)max?{x:p.x/d*max,z:p.z/d*max}:{x:p.x,z:p.z};if(this.legal(q,r,blockers,ignore))return q;for(let ring=1;ring<55;ring++){const n=12+ring*3;for(let j=0;jx+','+z,at=(x,z)=>({x:x*cell,z:z*cell}),sx=Math.round(from.x/cell),sz=Math.round(from.z/cell),gx=Math.round(goal.x/cell),gz=Math.round(goal.z/cell),start={x:sx,z:sz,g:0,f:0,parent:null},open=[start],nodes=new Map([[key(sx,sz),start]]),closed=new Set();let best=start,bestDist=Infinity; const heuristic=(x,z)=>Math.hypot(x-gx,z-gz);let iterations=0; while(open.length&&iterations++<6500){let idx=0;for(let i=1;i=old.g)continue;const next={x,z,g:cost,f:cost+heuristic(x,z),parent:n};nodes.set(k,next);open.push(next);}} const raw=[];let node=best;while(node?.parent){raw.push(at(node.x,node.z));node=node.parent;}raw.reverse();if(bestDist<1.5)raw.push(goal);if(!raw.length)return [];const smooth=[];let anchor=from;for(let i=0;i0||unit.root>0){unit.moving=false;return false;}const r=unit.radius||.4; if(!unit.pathGoal||distance(unit.pathGoal,destination)>.65){unit.pathGoal={x:destination.x,z:destination.z};unit.path=this.route(unit,destination,r);unit.pathBlocked=0;} if(!unit.path?.length){unit.moving=false;return distance(unit,destination)<.6;} let waypoint=unit.path[0];if(distance(unit,waypoint)<.18){unit.path.shift();if(!unit.path.length){unit.moving=false;return true;}waypoint=unit.path[0];} const heading=Math.atan2(waypoint.x-unit.x,waypoint.z-unit.z),error=turn(unit,heading,dt);if(error>1.2){unit.moving=false;return false;} const step=Math.min(speed*dt,distance(unit,waypoint));let selected=null,bestScore=Infinity;const side=unit.avoidSide||1; for(const angle of [0,.3*side,-.3*side,.65*side,-.65*side,1.05*side,-1.05*side,1.5*side,-1.5*side]){const a=heading+angle,p={x:unit.x+Math.sin(a)*step,z:unit.z+Math.cos(a)*step};if(!this.legal(p,r,blockers,unit))continue;const score=distance(p,waypoint)+Math.abs(angle)*.05;if(score.008;unit.pathBlocked=0;}else{unit.moving=false;unit.pathBlocked=(unit.pathBlocked||0)+dt;if(unit.pathBlocked>.35){unit.path=this.route(unit,destination,r,blockers,unit);unit.pathBlocked=0;}} return distance(unit,destination)<.28; } steer(unit,direction,speed,dt,blockers=[]){if(unit.dead||unit.stun>0||unit.root>0){unit.moving=false;return;}const length=Math.hypot(direction.x,direction.z);if(length<.02){unit.moving=false;return;}const dx=direction.x/Math.max(1,length),dz=direction.z/Math.max(1,length),step=speed*dt;turn(unit,Math.atan2(dx,dz),dt);const full={x:unit.x+dx*step,z:unit.z+dz*step},horizontal={x:full.x,z:unit.z},vertical={x:unit.x,z:full.z};const options=Math.abs(dx)>Math.abs(dz)?[full,horizontal,vertical]:[full,vertical,horizontal];const next=options.find(p=>distance(unit,p)>.001&&this.legal(p,unit.radius,blockers,unit));unit.moving=!!next;if(next){unit.x=next.x;unit.z=next.z;}unit.tx=unit.x+dx;unit.tz=unit.z+dz;} separate(units){for(let pass=0;pass<4;pass++)for(let i=0;i=minimum-.01)continue;const angle=d<.0001?(i+j)*2.399:Math.atan2(a.x-b.x,a.z-b.z),depth=minimum-d+.005,aw=a.anchored?0:b.anchored?1:a.id==='boss'?.06:b.id==='boss'?.94:.5,bw=1-aw;const ap={x:a.x+Math.sin(angle)*depth*aw,z:a.z+Math.cos(angle)*depth*aw},bp={x:b.x-Math.sin(angle)*depth*bw,z:b.z-Math.cos(angle)*depth*bw};if(this.legal(ap,a.radius||.4))Object.assign(a,ap);if(this.legal(bp,b.radius||.4))Object.assign(b,bp);}} } } ===== targeting.js ===== import {skillFor} from './kits.js'; const dist=(a,b)=>Math.hypot(a.x-b.x,a.z-b.z); export function enemies(game){return [game.s.boss,...game.s.adds].filter(e=>e&&!e.dead&&!e.invulnerable);} export function attackTarget(game,hero,locked=null){const all=enemies(game),lock=all.find(e=>e.id===locked&&dist(hero,e)<28);return lock||all.filter(e=>dist(hero,e)<23).sort((a,b)=>(a.id==='boss'?-8:0)+dist(a,hero)-((b.id==='boss'?-8:0)+dist(b,hero)))[0]||null;} export function skillTarget(game,hero,slot,{point=null,target=null,auto=true,self=false,locked=null}={}){ const skill=skillFor(hero,slot);if(!skill)return null; if(skill.target==='reanimate'){const explicit=game.getHero(target);if(explicit)return {id:explicit.id,x:explicit.x,z:explicit.z};if(point&&!auto)return {x:point.x,z:point.z};const dead=game.s.heroes.filter(a=>a.dead).sort((a,b)=>dist(hero,a)-dist(hero,b))[0];return dead?{id:dead.id,x:dead.x,z:dead.z}:{x:hero.x,z:hero.z};} if(skill.target==='none')return {id:hero.id,x:hero.x,z:hero.z}; const range=(skill.range||15)+hero.radius+2; if(skill.target==='point'){const p=point||attackTarget(game,hero,locked)||{x:hero.x+Math.sin(hero.facing)*6,z:hero.z+Math.cos(hero.facing)*6};const d=dist(hero,p),r=skill.range||15;return {x:hero.x+(p.x-hero.x)*Math.min(1,r/(d||1)),z:hero.z+(p.z-hero.z)*Math.min(1,r/(d||1))};} let pool=skill.target==='enemy'?enemies(game):game.s.heroes.filter(h=>skill.target==='corpse'?h.dead||h.skeleton:!h.dead); if(skill.type==='protect')pool=pool.filter(a=>a===hero||a.class!=='paladin'); if(auto&&skill.target==='ally'&&['heal','renew','chainheal','fullheal'].includes(skill.type)){pool=pool.filter(a=>!(a.ritualSacrifice>0));if(skill.type==='chainheal'&&game.s.boss?.gate)return null;} if(self&&skill.target==='ally')return {id:hero.id,x:hero.x,z:hero.z}; const explicit=pool.find(e=>e.id===target);if(explicit)return {id:explicit.id,x:explicit.x,z:explicit.z}; const near=pool.filter(e=>dist(hero,e)<=range+(e.radius||.4)); let chosen=null; if(point&&!auto){chosen=near.slice().sort((a,b)=>dist(a,point)-dist(b,point))[0];if(chosen&&dist(chosen,point)>3.5){const dx=point.x-hero.x,dz=point.z-hero.z,len=Math.hypot(dx,dz)||1;chosen=near.filter(e=>{const ex=e.x-hero.x,ez=e.z-hero.z,d=Math.hypot(ex,ez)||1;return (ex*dx+ez*dz)/(d*len)>.92;}).sort((a,b)=>dist(a,point)-dist(b,point))[0];}} else if(skill.target==='enemy'){chosen=near.find(e=>e.id===locked)||near.find(e=>e.id==='boss')||near.sort((a,b)=>dist(a,hero)-dist(b,hero))[0];} else if(skill.target==='ally'){chosen=near.sort((a,b)=>(a.hp/a.maxHp-b.hp/b.maxHp)||dist(a,hero)-dist(b,hero))[0]||(pool.includes(hero)?hero:null);} else chosen=near.sort((a,b)=>dist(a,hero)-dist(b,hero))[0]; return chosen?{id:chosen.id,x:chosen.x,z:chosen.z}:null; } export function stickVector(dx,dy,radius=52){const length=Math.hypot(dx,dy);if(lengthMath.hypot(a.x-b.x,a.z-b.z); export const TUTORIAL_VERSION=8; export function tutorialSteps(hero,touch=false){return [ {id:'move',title:'MOVE TO THE LIGHT',gesture:touch?'stick':'right',label:touch?'Drag the left stick':'Right-click to move',focus:touch?'[data-control="stick"]':null}, {id:'attack',title:'ATTACK THE TARGET',gesture:touch?'attack':'right',label:touch?'Tap Attack':'Right-click the target',focus:touch?'[data-control="attack"]':null}, {id:'spell',title:'USE YOUR FIRST SKILL',gesture:touch?'tap':'Q',label:touch?'Tap your skill':'Press Q to cast',slot:0,focus:'[data-slot="0"][data-control="skill"]'}, {id:'avoid',title:'LEAVE THE RED AREA',gesture:touch?'stick':'right',label:'Move before the hit',focus:touch?'[data-control="stick"]':null}, {id:'defense',title:'BLOCK THE HIT',gesture:touch?'tap':'SPACE',label:'Stay still. Block the hit.',focus:'[data-action="veil"]'}, {id:'kite',title:'MOVE BETWEEN ATTACKS',gesture:touch?'attack':'A',label:touch?'Move to the light. Tap Attack.':'Move to the light. Press A.'}, {id:'finish',title:'DEFEAT THE BOSS',gesture:touch?'attack':'A',label:'Attack and use your skills'}, {id:'gear',title:'BUY AN UPGRADE',gesture:'tap',label:'Spend Grit. Keep the upgrade for this run.'} ];} export class Tutorial { constructor(game,controls,view,hooks={}){this.g=game;this.controls=controls;this.view=view;this.hooks=hooks;this.steps=tutorialSteps(game.s.heroes[0].class,!!controls.touch).filter(step=>!hooks.quick||['move','attack','spell','avoid'].includes(step.id));this.index=Math.min(hooks.start||0,this.steps.length-1);this.events=[];this.flags=[];this.complete=false;this.lastDamage=0;game.inputGate=c=>this.allows(c);this.enter();} get autoCombat(){return this.controls.autoCombat??this.controls.portraitMode;} get step(){ const active=this.steps[this.index];if(!active)return active; const mode=!this.controls.touch?'keyboard':this.controls.portraitMode?'portrait':'landscape'; if(this.displayMode!==mode){ const overrides=mode==='keyboard'?{move:{label:'Move to the light',gesture:'WASD',focus:null},attack:{label:'Stop moving to attack',gesture:'release',focus:null},spell:{label:'Press Q to cast',gesture:'Q'},avoid:{label:'Move out of the red area',gesture:'WASD',focus:null},kite:{label:'Move. Stop to attack.',gesture:'release'},finish:{label:'Dodge. Stop to attack.',gesture:'release'}}:mode==='portrait'?{move:{label:'Drag to the light',gesture:'stick',focus:'[data-control="stick"]'},attack:{label:'Stand still to attack',gesture:'release',focus:null},avoid:{label:'Drag out of the red area',gesture:'stick'},kite:{label:'Move. Release to attack.',gesture:'release'},finish:{label:'Dodge. Stop to attack.',gesture:'release'}}:{}; this.displaySteps=tutorialSteps(this.hero.class,!!this.controls.touch).map(step=>({...step,...overrides[step.id]}));this.displayMode=mode; } return this.displaySteps.find(step=>step.id===active.id); } get hero(){return this.g.s.heroes[0];} allows(c){if(this.complete||c.type==='stance')return true;if(c.type==='steer'&&Math.hypot(c.x,c.z)<.02)return true;if(['hold','stop'].includes(c.type))return true;const id=this.step.id;if(id==='finish')return !['buy','item','vote'].includes(c.type);if(id==='gear')return c.type==='buy';if(['move','avoid'].includes(id))return ['move','steer'].includes(c.type);if(id==='attack')return c.type==='attack'||this.autoCombat&&c.type==='guard';if(id==='spell')return c.type==='skill'&&c.slot===0;if(id==='defense')return c.type==='veil';if(id==='kite')return ['move','steer'].includes(c.type)||this.kite?.phase==='attack'&&(this.autoCombat?['guard','attack'].includes(c.type):c.type==='attack'&&(this.controls.touch?c.source==='attack-button':c.source==='attack-key'));return false;} emit(type,detail={}){this.events.push({type,...detail});} prepare(){this.controls.cancelAll?.();const s=this.g.s,h=this.hero;s.training=true;s.phase='fight';s.paused=false;s.countdown=0;s.calloutUntil=0;s.hazards=[];s.projectiles=[];s.fx=[];s.casts=[];s.adds=[];s.zones=[];Object.assign(s.boss,{x:0,z:-2,hp:200000,maxHp:200000,dead:false,mana:0,ult:99999,infinity:false,casting:null,enemySwing:null});for(const [i,a]of s.heroes.entries()){this.g.rt.interrupt(a);a.ai=false;a.auto=false;a.dead=false;a.hp=a.maxHp;a.mana=a.maxMana;a.cd.fill(0);a.veil=0;a.veilCd=0;a.potionCd=0;a.potions=[2,2];if(i){a.x=-10+(i-1)*2.5;a.z=-12;}}h.x=0;h.z=5;h.facing=Math.PI;this.controls.selection=[h.id];this.controls.mode=null;this.controls.follow=true;this.controls.cameraPause=0;this.view.center({x:h.x,z:h.z});} enter(){this.age=0;this.passedAt=null;this.events=[];this.flags=[];const id=this.step.id;if(id!=='gear')this.prepare();const h=this.hero,s=this.g.s; if(id==='move'){h.x=-3;h.z=6;this.flags=[{x:5,z:6}];} if(['attack','spell','finish'].includes(id)){h.z=h.class==='warrior'?1:5;this.flags=[{x:0,z:-2,entity:'boss'}];} if(id==='spell'){const skill=skillFor(h,0);if(skill.target==='ally'){h.hp=h.maxHp*.45;this.flags=[{x:h.x,z:h.z,entity:h.id}];}if(skill.type==='interrupt')s.boss.casting={name:'Training blast',interruptible:true,left:999,total:999};} if(id==='avoid'){h.x=-3;h.z=5;this.flags=[{x:5,z:5}];this.makeDanger(4,300);} if(id==='defense')this.makeDanger(3,500); if(id==='kite'){const melee=['warrior','paladin','rogue','deathknight'].includes(h.class);this.kite={phase:'move',round:0,shots:h.attackCount,origin:{x:h.x,z:h.z},points:melee?[{x:-2.3,z:0},{x:2.3,z:0}]:[{x:-4,z:5},{x:4,z:5}]};h.x=0;h.z=melee?1:5;this.flags=[this.kite.points[0]];} if(id==='finish'){s.boss.hp=120;s.boss.maxHp=120;h.auto=true;} if(id==='gear'){s.phase='intermission';s.boss.dead=true;s.hazards=[];s.projectiles=[];h.build.karma=Math.max(2,h.build.karma);this.karmaStart=h.build.karma;} for(const a of s.heroes)this.g.rt.issue(a,{kind:'hold',x:a.x,z:a.z});this.lastDamage=h.damage;this.hooks.change?.(this); } moveTarget(p){if(!['move','avoid','kite'].includes(this.step.id))return null;return this.flags.find(flag=>distance(p,flag)<1.6)||null;} makeDanger(delay,damage){this.g.s.hazards=[];const signal=this.step.id==='defense'?'block':'danger';this.danger=this.g.hazard('circle',{x:this.hero.x,z:this.hero.z,r:3.2,label:'',lesson:true,signal,color:TELEGRAPH_COLORS[signal]},delay,damage,.4);this.healthStart=this.hero.hp;} command(command,ok){if(!ok)return;if(['attack','guard'].includes(command.type)){this.hero.auto=true;if(this.step.id==='kite')this.kite.commanded=true;}else if(['move','steer'].includes(command.type)&&this.step.id!=='finish')this.hero.auto=false;this.emit('command',{command});} next(){this.hooks.progress?.(this.index+1);this.index++;if(this.index>=this.steps.length){this.complete=true;this.g.inputGate=null;this.view.tutorialMarkers=[];this.hooks.done?.();return;}this.hooks.feedback?.();this.enter();} tick(dt){if(this.complete||this.g.s.paused)return;this.age+=dt;const h=this.hero,s=this.g.s,id=this.step.id,commands=this.events.filter(e=>e.type==='command').map(e=>e.command);if(h.dead)this.g.revive(s.heroes[1],h,1);if(!['finish','gear'].includes(id)){s.boss.hp=Math.max(10000,s.boss.hp);s.boss.ult=99999;}let passed=false; if(id==='move')passed=distance(h,this.flags[0])<1.1||this.passedAt!==null; if(id==='attack')passed=commands.some(c=>['attack','guard'].includes(c.type))&&h.damage>this.lastDamage; if(id==='spell'){const cast=s.casts?.findLast(c=>c.owner===h.id&&c.slot===0);passed=!!cast&&cast.resolvedAt!==null&&cast.impactAt!==null&&!h.casting&&s.time-cast.impactAt>=.9;} if(id==='avoid'){passed=this.age>.5&&distance(h,this.danger)>this.danger.r+.3;if(this.age>4.6&&!passed){h.hp=h.maxHp;this.age=0;this.makeDanger(4,300);}} if(id==='defense'){if(this.danger.hit)passed=h.hp===this.healthStart&&h.veilCd>0;if(this.age>3.8&&!passed){h.hp=h.maxHp;h.veilCd=0;this.age=0;this.makeDanger(3,500);}} if(id==='kite'){const k=this.kite;if(k.phase==='move'&&distance(h,this.flags[0])<1.15){k.phase='attack';k.shots=h.attackCount;k.commanded=false;this.flags=[{x:0,z:-2,entity:'boss'}];this.hooks.change?.(this);}if(k.phase==='attack'&&k.commanded&&h.attackCount>k.shots){h.auto=false;this.g.rt.issue(h,{kind:'hold',x:h.x,z:h.z});k.round++;if(k.round>=2)passed=true;else{k.phase='move';this.flags=[k.points[k.round]];this.hooks.change?.(this);}}} if(id==='finish')passed=s.boss.dead||s.phase==='victory'; if(id==='gear')passed=h.build.karma({...p,label:'',kind:id==='avoid'?'safe':'goal'})); if(passed){this.passedAt??=this.age;if(this.age-this.passedAt>=(id==='spell'||id==='defense'||id==='finish'?.65:.4))this.next();}else this.passedAt=null;this.events=this.events.slice(-40); } } ===== input-bindings.js ===== export const SKILL_KEYS=['Q','E','R','F','Z','X']; export const MOVE_KEYS=new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowLeft','ArrowDown','ArrowRight']); export const ITEM_KEYS={Digit3:'armor',Digit4:'weapon',Digit5:'charm',Digit6:'tome',Digit7:'offhand',Digit8:'gem',KeyV:'tome',KeyN:'offhand',KeyH:'gem'}; export const skillKey=slot=>SKILL_KEYS[slot]||''; export function keyboardVector(keys){ const x=Number(keys.has('KeyD')||keys.has('ArrowRight'))-Number(keys.has('KeyA')||keys.has('ArrowLeft')); const z=(Number(keys.has('KeyS')||keys.has('ArrowDown'))-Number(keys.has('KeyW')||keys.has('ArrowUp')))*1.4; const length=Math.hypot(x,z);return length?{x:x/length,z:z/length}:{x:0,z:0}; } export const TACTIC_KEYS={Digit1:'rally',Digit2:'spread',Digit3:'focus'}; export function squadHotkey(code,open){return open?TACTIC_KEYS[code]||null:null;}