; ; /* ============================================================ SIMULATION v2 — 10k units, turrets, beams, terrain-aware ============================================================ */ // wk: p kinetic | b beam | e explosive | g gauss | f fire | s sonic | i ion | m melee | n none const TYPES=[ /* STRIKER WAS THE ANSWER TO EVERYTHING. Measured across the roster it sat at 1.346 damage-per-cost against a median of 0.383 — three and a half times the next tier of units — and it was ALSO near the top for health-per-cost. A unit that is simultaneously the most efficient attacker and one of the most durable is not a choice, it is the correct answer, and every other ground unit in the game existed as a worse version of it. The fix is not a flat nerf: it is giving the unit a shape. Cost up, damage down, and health cut hardest, so the Striker becomes what a tier-1 skirmisher should be — the fastest thing you can field and the first thing that dies. It still wins early because it arrives early, and it stops scaling into the late game where the heavy chassis are supposed to take over. */ {name:'Striker', spr:'bot', tur:null, size:12, r:4.4, hp:40, dmg:5.4,rng:62, cool:.65, spd:38, psp:340, ptype:0, aoe:0, wk:'p', tg:'a', cm:15, ce:34, bt:1.1, air:0, tier:1, legs:1}, /* RHINO was the next universal answer after the Striker pass: 0.702 DPS and 7.95 HP per mass, while also being the faster-to-field durable chassis. It needs to lead an early push, not erase the reason to graduate into a Goliath. The price increase does most of the work; the small stat trim keeps its head-on advantage from scaling back through the cheaper unit count. */ {name:'Rhino', spr:'tankH', tur:'tankT', size:16, r:6.0, hp:130, dmg:16, rng:88, cool:1.1, spd:27, psp:300, ptype:1, aoe:0, wk:'p', tg:'a', cm:26, ce:100, bt:2.6, air:0, tier:1}, {name:'Goliath', spr:'heavyH', tur:'heavyT', size:21, r:7.8, hp:450, dmg:42, rng:104, cool:1.6, spd:22, psp:300, ptype:1, aoe:10, wk:'p', tg:'a', cm:64, ce:250, bt:6.0, air:0, tier:2, legs:1}, {name:'Thumper', spr:'artyH', tur:'artyT', size:17, r:6.4, hp:135, dmg:60, rng:265, cool:3.7, spd:22, psp:150, ptype:2, aoe:38, wk:'e', tg:'g', cm:56, ce:230, bt:5.2, air:0, tier:2, minRng:80}, {name:'Commander',spr:'cdr', tur:null, size:32, r:11.5,hp:5200,dmg:135, rng:140,cool:1.25,spd:30, psp:330, ptype:3, aoe:48, wk:'e', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', hero:'nova', legs:1}, {name:'Wasp', spr:'gun', tur:null, size:15, r:5.4, hp:135, dmg:13, rng:78, cool:.75, spd:74, psp:380, ptype:0, aoe:0, wk:'p', tg:'a', cm:30, ce:150, bt:3.4, air:1, tier:1}, {name:'Longbow', spr:'longbow',tur:null, size:16, r:5.8, hp:110, dmg:95, rng:205, cool:3.0, spd:26, psp:0, ptype:0, aoe:0, wk:'b', tg:'a', cm:60, ce:240, bt:5.5, air:0, tier:2}, /* HORNET was the only strictly dominated purchase in the whole roster. Measured against the Vulture — a TIER ONE unit costing 6 less mass — it lost on splash-adjusted output (0.40 vs 1.57 per mass), on durability (3.23 vs 4.05 hp per mass) AND on range (135 vs 190) simultaneously. Nothing was traded for that; the tech that unlocked it was simply wasted. Its case has always been that it shoots ground as well as air, so it now buys the longer reach of the two and still pays for it in efficiency — a decision instead of a trap. Numbers from tools/balance-audit.mjs, not from feel. */ {name:'Hornet', spr:'hornet', tur:null, size:16, r:6.0, hp:210, dmg:27, rng:175, cool:1.8, spd:40, psp:150, ptype:4, aoe:24, wk:'e', tg:'a', cm:48, ce:200, bt:4.5, air:0, tier:2}, {name:'TITAN', spr:'titan', tur:null, size:46, r:18, hp:14000,dmg:160,rng:175, cool:.5, spd:15, psp:0, ptype:0, aoe:0, wk:'b', tg:'a', cm:900, ce:3600, bt:45, air:0, tier:3, legs:1}, {name:'Pyro', spr:'pyro', tur:null, size:16, r:6.0, hp:210, dmg:11, rng:58, cool:.38, spd:36, psp:130, ptype:5, aoe:18, wk:'m', tg:'g', cm:40, ce:160, bt:3.5, air:0, tier:1, legs:1}, /* VULTURE stays a hard counter — it cannot shoot ground at all, so a game with no enemy air makes its whole cost a dead stat, and that asymmetry earns it the best rate in the roster. But it was measured at 1.57 splash-adjusted output per mass against a tier-1 median of 0.58, and at 40 dps from 190px it was doing 70% of a STATIC anti-air emplacement's damage from a mobile tier-1 chassis while outranging every aircraft in the game. Air was not a counterplay problem, it was an unaffordable one. Trimmed to ~32 dps at 172px: still the sharpest counter on the board, no longer an eraser. */ {name:'Vulture', spr:'vulture',tur:null, size:16, r:6.0, hp:170, dmg:52, rng:172, cool:1.6, spd:42, psp:300, ptype:8, aoe:26, wk:'e', tg:'air',cm:42, ce:170, bt:3.6, air:0, tier:1}, {name:'Bulwark', spr:'bulwark',tur:null, size:21, r:7.8, hp:950, dmg:0, rng:0, cool:9, spd:28, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:90, ce:380, bt:7.0, air:0, tier:2, upkeepE:5}, {name:'Ravager', spr:'rav', tur:null, size:16, r:5.6, hp:118, dmg:27, rng:20, cool:.9, spd:49, psp:0, ptype:0, aoe:0, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1, brood:1, bldMul:1.55}, {name:'Alpha Ravager',spr:'rav',tur:null, size:28, r:10, hp:780, dmg:82, rng:26, cool:1.25,spd:38, psp:0, ptype:0, aoe:12, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1, brood:1, bldMul:1.75}, /* Naval hulls are authored longer than land chassis. Applying the universal 1.5x command-view exaggeration to their full `size` made a Dreadnought as large as a city block and left no room to read a fleet. `vscale` is render only: collision, selection tolerance, range, health and spacing stay on the measured simulation values above. */ {name:'Corvette', spr:'corv', tur:null, size:20, r:7.5, hp:320, dmg:24, rng:115, cool:1.0, spd:42, psp:120, ptype:6, aoe:0, wk:'i', tg:'a', cm:55, ce:220, bt:5.0, air:0, tier:1, naval:1,vscale:.66}, {name:'Dreadnought',spr:'dread',tur:null, size:32, r:12, hp:1300, dmg:88, rng:290, cool:5.0, spd:20, psp:150, ptype:2, aoe:34, wk:'e', tg:'g', cm:170, ce:680, bt:12, air:0, tier:2, naval:1,vscale:.54}, {name:'Bombard', spr:'bombH', tur:'bombT', size:21, r:8, hp:400, dmg:95, rng:400, cool:5.8, spd:14, psp:150, ptype:9, aoe:44, wk:'e', tg:'g', cm:140, ce:560, bt:10, air:0, tier:2, minRng:100}, {name:'Raptor', spr:'raptor', tur:null, size:18, r:6.5, hp:280, dmg:85, rng:52, cool:3.8, spd:62, psp:130, ptype:7, aoe:32, wk:'e', tg:'g', cm:70, ce:330, bt:6.5, air:1, tier:1}, /* SCORCHER is the Pyro's graduation and had the Pyro's profile exactly inverted. Classed as BEAM it did x1.60 into heavy plate and x0.60 into light — measured, the second-hardest anti-heavy weapon in the game, on a flamethrower. A player who learns at tier 1 that flame cooks infantry got punished for carrying that lesson forward, and the roster quietly had a 95-mass answer to armour that no card advertised. Now INCENDIARY, which is what the sprite, the thermal-gel projectile and the tier-1 version all already said. Per-shot damage comes down with it: incendiary carries the largest crowd multiplier in WK_HORDE, so keeping 15 would have made this the best unit in the game against everything light. */ {name:'Scorcher', spr:'pyro', tur:null, size:24, r:8.5, hp:640, dmg:13, rng:80, cool:.34, spd:24, psp:140, ptype:5, aoe:38, wk:'f', tg:'g', cm:95, ce:390, bt:7.5, air:0, tier:2}, /* CONSTRUCTOR — unarmed, and the only mobile source of build range besides the Commander. Cheap enough to lose, valuable enough to escort. */ {name:'Constructor',spr:'bot', tur:null, size:15, r:5.6, hp:220, dmg:0, rng:0, cool:9, spd:44, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:35, ce:140, bt:4.0, air:0, tier:1, builder:1, legs:1}, /* ========================================================================== SECOND WAVE — the roles the original roster had no answer for. Every entry here exists because something was unanswerable without it: a shield with no counter, a swarm with no crowd control, heavy armour that only one unit could hurt, and a tech tree with nothing between tier 1 chaff and a nine-hundred-mass titan. ========================================================================== */ {name:'Reaper', spr:'reaper', tur:null, size:19, r:7.0, hp:300, dmg:26, rng:120, cool:1.5, spd:30, psp:230, ptype:8, aoe:52, wk:'e', tg:'g', cm:72, ce:290, bt:5.4, air:0, tier:2, cat:'aoe'}, {name:'Cinder', spr:'cinder', tur:null, size:17, r:6.2, hp:260, dmg:19, rng:96, cool:1.1, spd:34, psp:170, ptype:2, aoe:46, wk:'f', tg:'g', cm:58, ce:230, bt:4.4, air:0, tier:2, cat:'aoe'}, {name:'Lancer', spr:'lancer', tur:null, size:18, r:6.6, hp:190, dmg:150,rng:230, cool:4.2, spd:24, psp:900, ptype:1, aoe:0, wk:'g', tg:'a', cm:82, ce:330, bt:6.2, air:0, tier:2, cat:'at'}, /* Labelled ANTI-TANK, it does x1.05 into heavy armour — the intel panel was printing "High-damage armor hunter" over a unit that is mediocre against armour on purpose (see WKM.s). Its real job is the only thing in the roster that ignores the Bulwark bubble, which no screen said out loud. `cat` is now honest; the pierce is advertised in the intel copy instead. Both categories feed the same assault class ability, so nothing is lost. */ {name:'Resonator',spr:'reson', tur:null, size:18, r:6.6, hp:340, dmg:34, rng:130, cool:1.7, spd:28, psp:240, ptype:6, aoe:18, wk:'s', tg:'a', cm:66, ce:270, bt:5.0, air:0, tier:2, cat:'veh'}, {name:'Warden', spr:'warden', tur:null, size:16, r:6.0, hp:420, dmg:0, rng:0, cool:9, spd:36, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:62, ce:250, bt:5.0, air:0, tier:2, cat:'sup', medic:1}, {name:'Kestrel', spr:'kestrel',tur:null, size:14, r:5.2, hp:120, dmg:14, rng:150, cool:1.0, spd:96, psp:420, ptype:0, aoe:0, wk:'p', tg:'a', cm:34, ce:150, bt:3.0, air:1, tier:1, cat:'air', scout:1}, {name:'Basilisk', spr:'basil', tur:'basilT', size:26, r:9.5, hp:1100, dmg:120,rng:190, cool:2.6, spd:18, psp:340, ptype:1, aoe:20, wk:'g', tg:'a', cm:260, ce:1050, bt:14, air:0, tier:3, cat:'exp'}, {name:'Harbinger',spr:'harb', tur:null, size:24, r:9.0, hp:760, dmg:44, rng:210, cool:2.0, spd:22, psp:200, ptype:7, aoe:60, wk:'e', tg:'g', cm:190, ce:760, bt:11, air:0, tier:3, cat:'aoe'}, /* ---- FACTION HEROES ------------------------------------------------------ One per faction, and each one changes how its army plays rather than just hitting harder. They are never buildable: a faction fields its own. */ {name:'Lord Darion Vex', spr:'praetor',tur:null, size:34, r:12, hp:6200, dmg:130,rng:250, cool:2.4, spd:22, psp:210, ptype:9, aoe:70, wk:'e', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'legion'}, /* rng 165 outranged every basic defence in the game (turret/bunker 155) — combined with the fastest hero chassis it made the green Commander a solo army: kite, farm, never get hit. 150 keeps the raid identity (fast, long for a hero) but puts it inside defensive fire. */ {name:'Broker Lys Renn', spr:'archon', tur:null, size:30, r:11, hp:4600, dmg:70, rng:150, cool:.55, spd:38, psp:240, ptype:6, aoe:10, wk:'s', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', hero:'syndicate'}, {name:'The Brood Sovereign',spr:'brood',tur:null, size:38, r:14, hp:7400, dmg:110,rng:34, cool:1.2, spd:30, psp:0, ptype:0, aoe:26, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'horde', brood:1, bldMul:1.65}, /* A Tidecaster is not built. A critical mass of nearby Ravagers grows one, trading an individual body for coordination, speed and a target-minded tide. Its own health stays deliberately low: the counterplay is to pick the leader out of the crowd and let the mass dissolve back into animals. */ {name:'Brood Tidecaster',spr:'brood',tur:null, size:24, r:8.5, hp:390, dmg:24, rng:142, cool:2.4, spd:40, psp:175, ptype:6, aoe:26, wk:'s', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'sup', legs:1, brood:1, caster:1, bldMul:1.35}, /* Mobile resource utility. Prospectors earn less than an Extractor but can work a field before territory reaches it; the authored mining beam makes the economic action readable from the ordinary battle camera. */ {name:'Prospector',spr:'warden',tur:null, size:17, r:6.2, hp:190, dmg:0, rng:0, cool:9, spd:39, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:52, ce:210, bt:5.0, air:0, tier:1, cat:'sup', miner:1}, ]; /* UNIT CATEGORIES. Used by the build menu, the unit card and the AI's composition logic, so a role is a real thing the game reasons about rather than a description in a tooltip. */ const UCAT={ inf:{nm:'INFANTRY', em:'🤖'}, veh:{nm:'ARMOUR', em:'🛡'}, at :{nm:'ANTI-TANK', em:'🎯'}, aoe:{nm:'CROWD CONTROL', em:'💥'}, art:{nm:'ARTILLERY', em:'💣'}, aa :{nm:'ANTI-AIR', em:'🛩'}, air:{nm:'AIRCRAFT', em:'✈'}, nav:{nm:'NAVAL', em:'⚓'}, sup:{nm:'SUPPORT', em:'🛠'}, exp:{nm:'EXPERIMENTAL', em:'☢'}, hero:{nm:'HERO', em:'★'} }; /* Fill in the categories the original roster never declared, from what each unit actually does. Doing it here rather than by hand in every row keeps the table readable and the classification in one place. */ (function classify(){ const byName={ Striker:'inf', Rhino:'veh', Goliath:'veh', Thumper:'art', Commander:'hero', Wasp:'air', Longbow:'at', Hornet:'veh', TITAN:'exp', Pyro:'inf', Vulture:'aa', Bulwark:'sup', Ravager:'inf', 'Alpha Ravager':'exp', Corvette:'nav', Dreadnought:'nav', Bombard:'art', Raptor:'air', Scorcher:'aoe', Constructor:'sup' }; for(const T of TYPES) if(!T.cat) T.cat=byName[T.name]||'veh'; })(); const MF_DOM_LAND=1,MF_DOM_AIR=2,MF_DOM_NAVAL=4; function mfDomainOfType(T){return T.air?MF_DOM_AIR:T.naval?MF_DOM_NAVAL:MF_DOM_LAND;} for(const T of TYPES){ T.domainMask=mfDomainOfType(T); T.targetMask=T.tg==='air'?MF_DOM_AIR:T.tg==='g'?(MF_DOM_LAND|MF_DOM_NAVAL):(MF_DOM_LAND|MF_DOM_AIR|MF_DOM_NAVAL); T.preferMask=T.cat==='aa'?MF_DOM_AIR:T.naval?MF_DOM_NAVAL:(T.air&&T.tg!=='g'?MF_DOM_AIR:0); } function mfTargetAllowed(T,j){return !!(T.targetMask&mfDomainOfType(TYPES[utype[j]]));} function mfCombatFactionTeam(team){ if(team===0)return (typeof playerFaction!=='undefined'&&playerFaction)||'nova'; if(team===1)return (typeof AI!=='undefined'&&AI&&AI.fac)||'legion'; return 'horde'; } function mfCombatFaction(i){return mfCombatFactionTeam(uteam[i]);} /* Weapon family still owns the projectile silhouette. This palette is a thin army signature layered around it: cold precision for Nova, furnace heat for Dominion, split-spectrum phase energy for Syndicate, and bile/spores for Brood. Keeping it here makes Canvas particles and WebGL ordnance resolve the same faction even in mirror matches. */ function mfFactionFxPalette(team){ const f=mfCombatFactionTeam(team); if(f==='legion')return {key:f,a:[255,72,42],b:[255,178,70]}; if(f==='syndicate')return {key:f,a:[91,238,183],b:[180,82,255]}; if(f==='horde')return {key:f,a:[186,82,245],b:[167,255,78]}; return {key:'nova',a:[88,218,255],b:[234,252,255]}; } function mfDomainSpeedMul(i){ const T=TYPES[utype[i]],f=mfCombatFaction(i); return f==='syndicate'&&(T.air||T.naval)?1.10:f==='legion'&&T.naval?.92:1; } function mfDomainDamageMul(i,tg){ if(tg<0)return 1; const A=TYPES[utype[i]],D=mfDomainOfType(TYPES[utype[tg]]),f=mfCombatFaction(i); if(f==='nova'&&A.air&&D===MF_DOM_AIR)return 1.10; if(f==='legion'&&A.naval&&D===MF_DOM_NAVAL)return 1.15; if(f==='syndicate'&&((A.air&&D===MF_DOM_AIR)||(A.naval&&D===MF_DOM_NAVAL)))return 1.12; return 1; } const UT_ENGINEER=19; // index of the Constructor in TYPES const UT_BROOD_CASTER=31, UT_MINER=32; const SHIELD_REDUCE=0.72, SHIELD_R=95; const AGGRO_MULT=1.9, AGGRO_ADD=70; const TITAN_STOMP_DMG=210, TITAN_STOMP_R=85; // ---------- unit arrays ---------- const ux=new Float32Array(MAXU), uy=new Float32Array(MAXU); const uang=new Float32Array(MAXU), uturr=new Float32Array(MAXU); const utx=new Float32Array(MAXU), uty=new Float32Array(MAXU); const uhp=new Float32Array(MAXU), uhpm=new Float32Array(MAXU); const ucool=new Float32Array(MAXU), ubuff=new Float32Array(MAXU), ustomp=new Float32Array(MAXU); /* Contextual class abilities use their own effect channel. `ubuff` is the Commander surge and intentionally boosts everything; class doctrine needs narrower, readable trades so an interceptor does not receive the same buff as a breakthrough tank. 1=breakthrough, 2=intercept, 3=service shield. */ const uclassBuff=new Uint8Array(MAXU), uclassBuffT=new Float32Array(MAXU); function classDmgMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.28:uclassBuffT[i]>0&&uclassBuff[i]===2?1.12:1;} function classCoolMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?.78:uclassBuffT[i]>0&&uclassBuff[i]===2?.65:1;} function classSpdMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.22:uclassBuffT[i]>0&&uclassBuff[i]===2?1.58:1;} function classRngMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===2?1.22:1;} function classTakenMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.12:uclassBuffT[i]>0&&uclassBuff[i]===2?.86:uclassBuffT[i]>0&&uclassBuff[i]===3?.72:1;} /* Brood leadership is intentionally offensive utility rather than hidden bonus health. A player who kills the visible Tidecaster immediately strips the speed, damage and cadence from every nearby creature. */ const ubroodLed=new Float32Array(MAXU), uMineT=new Float32Array(MAXU); const uMineNode=new Int16Array(MAXU); uMineNode.fill(-1); function broodDmgMul(i){return ubroodLed[i]>0?1.18:1;} function broodCoolMul(i){return ubroodLed[i]>0?.82:1;} function broodSpdMul(i){return ubroodLed[i]>0?1.14:1;} const utype=new Uint8Array(MAXU), uteam=new Uint8Array(MAXU); /* -1 = directly commanded player/enemy/wildlife; 0..2 = shared-control allied AI slot. Same-team combat rules make allies genuinely friendly while this owner tag lets their lightweight director move only its own reinforcements. */ const uAllyBase=new Int8Array(MAXU);uAllyBase.fill(-1); /* Commander owner for population ledgers. Same ids as skirmish spawn: -1 player, 0..2 = aiSlots. Team 0 copies this into uAllyBase so the ally director keeps matching on the tag it already owns. */ const uCmd=new Int8Array(MAXU);uCmd.fill(-1); const ualive=new Uint8Array(MAXU), ustate=new Uint8Array(MAXU); const usel=new Uint8Array(MAXU); const utgt=new Int32Array(MAXU); /* ---- UNIT HANDLES ---------------------------------------------------------- Slots are recycled the instant a unit dies, and with tens of thousands of insects churning the free list every second a slot can be reborn on a different team between one frame and the next. Every consumer checked that its target index was ALIVE; none checked that it was still the same unit. So a tank kept firing at "its" target after that index had been refilled by a friendly, and an in-flight shell homed on and damaged whoever now occupied the slot. Each slot therefore carries a GENERATION, bumped on every spawn. A target is valid only if the slot is alive AND its generation still matches the one recorded when the target was acquired. `utgtg` and `ptgtg` hold the expected generation for unit targets and projectile targets respectively. */ const ugen=new Int32Array(MAXU); const uwalk=new Float32Array(MAXU); // gait phase, advanced by distance covered const utgtg=new Int32Array(MAXU); function liveTgt(tg,gen){ return tg>=0 && ualive[tg] && ugen[tg]===gen; } /* Hostility is checked separately: a valid handle can still point at an ally once orders, splash re-targeting or a captured slot get involved. */ function foeTgt(self,tg,gen){ return liveTgt(tg,gen) && uteam[tg]!==uteam[self]; } const ukills=new Uint16Array(MAXU), uvet=new Uint8Array(MAXU); const umov=new Uint8Array(MAXU); // moving flag → drives baked walk cycles const ushielded=new Float32Array(MAXU); const ufield=new Int16Array(MAXU); /* Seconds of environmental debuff remaining — dust, EMP dazzle, concussion. Set by src/hazards.js, read in exactly two places: movement speed and weapon range. One array rather than one per hazard, because every hazard that degrades a unit degrades it the same way: it cannot see and it cannot run. */ const uhaz=new Float32Array(MAXU); const HAZ_SPD=0.72, HAZ_RNG=0.70; /* Fire persistence: seconds of flame remaining per unit. Set by incendiary hits (wk==='f') in dealDamage, drives persistent flame particles in unitTick, and spawns extra fire on death in killUnit. */ const ufireT=new Float32Array(MAXU); /* MARCH ORDER. Set on the units of an AI attack wave while it is crossing the map (see ai.js), cleared when the wave breaks or arrives. It exists because of a specific failure: the neutral bug infestation seeds its hives away from BOTH starting corners, which puts them squarely in the middle — directly on the only path between the two bases. A wave using ordinary attack-move stopped for every bug it met, ground to a halt around the midpoint, and eventually retreated. The player's chosen opponent never arrived and the match was, in practice, always against the wildlife. A marching column keeps walking and shoots on the move. It still returns fire — weapons are independent of movement — it simply does not adopt a bug as a destination. Only while EN ROUTE: within arrival range the flag is cleared and normal engagement resumes, so this cannot make a wave ignore the base it came to attack. */ const umarch=new Uint8Array(MAXU); /* EMP stun, in seconds. A stunned unit neither moves nor fires. Declared here with the rest of the per-unit state so it is impossible to miss when reading what a slot carries — and it is cleared in spawnUnit() alongside the others, because a recycled slot inherits every array it is not explicitly told to reset. That exact omission on umarch shipped as a live bug in 1.32.18. */ const ustun=new Float32Array(MAXU); /* Seconds since a unit last took a real hit (counts DOWN from 6). Only heroes read it: commander regeneration drops to quarter strength while under fire, so focus fire accumulates instead of being mopped up between volleys. This is the difference between a hero that raids and a hero that solos. */ const uHurtT=new Float32Array(MAXU); /* Seconds remaining on a live support beam (Warden heal / Constructor repair). Drives the aura effect between cadence scans without re-scanning every frame, and is cleared in spawnUnit() like every other per-slot array. */ const uheal=new Float32Array(MAXU); let freeList=[], unitHigh=0; const teamCount=[0,0,0]; /* Population is a gameplay budget AND a mobile stability budget. MAXU remains large because the renderer and save format need a wide slot address space. Each commander SEAT is FACTION_POP_CAP (1000). Compact 2 seats → 2000, standard 3 → 3000, large 4 → 4000. That 4000 is the theatre SUM, never a team blob and never this constant. HUD chip is the player seat (Track 5 hudPlayerPop). SESS_MAX_UNITS=4000 is a map-total snapshot — debt, not a pop cap. Do not set this to 2000 or 4000. */ const FACTION_POP_CAP=1000; const POP_PLAYER_SLOT=-1; const popCmdCount=new Uint16Array(4); // index 0 = player (-1), 1..3 = aiSlots 0..2 const simHot={unitTickMs:0,live:0,team0:0,team1:0,team2:0}; function populationTheatre(){ const k=typeof battlefieldPresetKey==='function'&&typeof battlefieldPreset!=='undefined' ?battlefieldPresetKey(battlefieldPreset):'standard'; return k==='compact'||k==='large'?k:'standard'; } function populationPlayerSlot(){ return POP_PLAYER_SLOT; } function popCmdIndex(slot){ const s=slot==null?POP_PLAYER_SLOT:slot|0; if(s<-1||s>2) return 0; return s+1; } function popCmdInc(slot){ popCmdCount[popCmdIndex(slot)]++; } function popCmdDec(slot){ const k=popCmdIndex(slot); if(popCmdCount[k]) popCmdCount[k]--; } function nCommandersOnTeam(team){ /* Match-setup count, not living heroes: a dead commander must not shrink the team ceiling and strand the army that still belongs to that slot. */ if(team===0){ let n=1; if(typeof AI!=='undefined'&&AI&&AI.allies&&AI.allies.length) n+=AI.allies.length; return n; } if(team===1){ if(typeof AI!=='undefined'&&AI&&AI.bases&&AI.bases.length) return AI.bases.length; return Math.max(1, enemyHeroIdxs.length||1); } return 0; } function populationTeamCeiling(team){ if(team===0||team===1) return FACTION_POP_CAP*nCommandersOnTeam(team); return populationCapFor(team); } function populationCapFor(team){ /* Per-team DISPLAY helper stays 1000 (or wildlife bugCap). Callers that need a theatre total use populationTeamCeiling. */ if(team===2&&!(typeof broodIsEnemy==='function'&&broodIsEnemy())&&typeof bugCap==='function')return bugCap(); return FACTION_POP_CAP; } function populationUsedFor(team){ if((team===1||team===2)&&typeof broodIsEnemy==='function'&&broodIsEnemy())return teamCount[1]+teamCount[2]; return teamCount[team]||0; } function populationCapForCommander(slot){ return FACTION_POP_CAP; } function populationUsedForCommander(slot){ return popCmdCount[popCmdIndex(slot)]||0; } function populationDefaultSeat(team,x,y){ if(team===0) return POP_PLAYER_SLOT; /* Team 1 must never fall through to the player bucket (-1). Missing tags bind to the nearest enemy base, else aiSlots[0]. */ if(typeof AI!=='undefined'&&AI&&AI.bases&&AI.bases.length){ if(x!=null&&y!=null){ let best=AI.bases[0],bd=1e18; for(const B of AI.bases){ const d=dist2(x,y,B.x,B.y); if(d2)return false; /* A scripted Commander arrival must never be deleted because ordinary units filled the last slot one frame earlier. Commanders are spawned at setup, so this exception cannot be used by factories to exceed the cap. */ if(T.cat==='hero') return true; if(team===2){ if(typeof broodIsEnemy==='function'&&broodIsEnemy()){ const s=populationResolveSlot(2,slot,x,y); return populationUsedForCommander(s)=populationTeamCeiling(team)) return false; const s=populationResolveSlot(team,slot,x,y); return populationUsedForCommander(s)1) return; const next=populationResolveSlot(uteam[i],slot,ux[i],uy[i]); const prev=uCmd[i]; if(prev===next){ if(uteam[i]===0) uAllyBase[i]=next; return; } popCmdDec(prev); uCmd[i]=next; if(uteam[i]===0) uAllyBase[i]=next; popCmdInc(next); } function populationResetLedgers(){ popCmdCount.fill(0); uCmd.fill(-1); } function populationRecountLedgers(){ popCmdCount.fill(0); for(let i=0;i=0) popCmdInc(uCmd[i]); } } const titanCount=[0,0]; let heroIdx=-1, enemyHeroIdx=-1, enemyHeroIdxs=[]; function isEnemyCommander(i){ return enemyHeroIdxs.indexOf(i)>=0; } function livingEnemyCommanders(){ enemyHeroIdxs=enemyHeroIdxs.filter(i=>i>=0&&ualive[i]&&uteam[i]===1); enemyHeroIdx=enemyHeroIdxs.length?enemyHeroIdxs[0]:-1; return enemyHeroIdxs; } function unitIsBrood(i){ return uteam[i]===2 || !!(TYPES[utype[i]]&&TYPES[utype[i]].brood) || (uteam[i]===1 && typeof AI!=='undefined' && AI.fac==='horde'); } function findLand(x,y){ if(isWalkable(x,y)) return [x,y]; for(let r=20;r<400;r+=24){ for(let a=0;a[i,ugen[i],k]),targets,form,created:stats.t}; return ci; } function tickMoveCohorts(){ uCohesion.fill(1,0,unitHigh); for(let ci=0;ciualive[e[0]]&&ugen[e[0]]===e[1]&&uteam[e[0]]===0&&uMoveCohort[e[0]]===ci); C.members=live; if(!live.length){moveCohorts[ci]=null;continue;} let far=0,arrived=0; for(const e of live){ const i=e[0],P=C.targets[e[2]];if(!P)continue; const d=Math.hypot(ux[i]-P.x,uy[i]-P.y);far=Math.max(far,d);if(d<=12)arrived++; } if(arrived===live.length){ for(const e of live)uMoveCohort[e[0]]=-1; moveCohorts[ci]=null;continue; } for(const e of live){ const i=e[0],P=C.targets[e[2]];if(!P)continue; const d=Math.hypot(ux[i]-P.x,uy[i]-P.y),lead=far-d; /* Only leaders are throttled. Rear units keep their authored speed, so cohesion never makes a slow vehicle even slower or deadlock the move. */ uCohesion[i]=d<=12&&far>34?.18:lead>140?.52:lead>75?.76:1; } } } const uhold=new Uint8Array(MAXU); // hold-position stance /* ================= GUARD (escort) + QUEUED ORDERS ========================= Two orders the genre treats as basic that this simulation had no state for. GUARD is `ustate===7`. It is NOT `umode===2`, which the stance table also calls GUARD: that one is the dug-in combat stance (−45% damage taken, −60% speed) and lives in the abilities popover. The collision is inherited; keep the two apart when reading this file. What makes a guard order different from an attack-move parked on top of a friendly is that every decision is taken FROM THE ANCHOR, not from wherever the escort happens to be standing: threats are searched around the guarded thing, and a lock is dropped as soon as the enemy leaves the anchor's leash. Searching from the escort is what turns an escort into a wandering attack-move that abandons the thing it was told to protect — the exact failure the order exists to prevent. QUEUED ORDERS are per-unit chains, advanced per unit. Platoon-synchronised advance is what tickPatrolRoutes does for patrol legs, and it is the wrong rule here: one damaged straggler must not hold the whole force at node 2. C&C3 and SupCom both advance per unit, so a slow chassis simply arrives late and catches up at the next node. */ const uGuard=new Int32Array(MAXU), uGuardG=new Int32Array(MAXU); uGuard.fill(-1); uGuardG.fill(-1); /* Stand-off ring, threat radius and leash — all measured from the anchor. ENGAGE < LEASH deliberately: the escort takes the fight that comes to the thing it guards and follows it a little way, but breaks off long before it can be baited out of position by a single fast scout. */ const GUARD_STAND=52, GUARD_ENGAGE=230, GUARD_LEASH=330; /* null | array of steps. A plain array, not a typed array: depth is bounded by QUEUE_MAX and only ever holds a chain a player placed by hand, so 34k mostly-null slots is cheaper than eight parallel typed arrays. */ const uQueue=new Array(MAXU); const QUEUE_MAX=8; /* Kind of the LIVE node (not the remaining chain). 0 waypoint, 1 named chase, 2 guard. queueTick used to wait on any lock, so a stray scout on node 1 froze an entire A-MOVE chain. Kind is one byte because the alternative is sniffing umarch/ufield, which air waypoints and named chases share. */ const uQkind=new Uint8Array(MAXU); function guardEntityLive(h,g){ if(h>=0) return !!(ualive[h]&&ugen[h]===g&&uteam[h]===0); if(h<=-2){ const B=blds[-2-h]; return !!(B&&B.alive&&B.team===0); } return false; } function guardEntityPos(h){ if(h>=0) return [ux[h],uy[h],TYPES[utype[h]].r||6]; const B=blds[-2-h]; return [B.x,B.y,B.r||18]; } /* Position of any order handle in the encoding orders already use everywhere: >=0 unit slot, relic handle, or -2-b for a building. */ function orderTgPos(tg){ if(tg>=0) return ualive[tg]?[ux[tg],uy[tg]]:null; if(typeof isRelicTg==='function'&&isRelicTg(tg)){ const R=relics[relicOf(tg)]; return R&&R.alive?[R.x,R.y]:null; } const B=blds[-2-tg]; return B&&B.alive?[B.x,B.y]:null; } function guardStop(i){ ustate[i]=0; uGuard[i]=-1; uGuardG[i]=-1; utgt[i]=-1; utgtg[i]=-1; utx[i]=ux[i]; uty[i]=uy[i]; ufield[i]=-1; umarch[i]=0; } function guardSteer(i,acqMod){ if(!guardEntityLive(uGuard[i],uGuardG[i])){ /* Anchor destroyed, slot recycled, or a restored save wrote ustate 7 with no guard table behind it (session.js snapshots ustate, not orders). Standing down here is what makes that restore path safe. */ guardStop(i); return; } const A=guardEntityPos(uGuard[i]), T=TYPES[utype[i]]; if(utgt[i]!==-1){ const P=orderTgPos(utgt[i]); if(!P||dist2(P[0],P[1],A[0],A[1])>GUARD_LEASH*GUARD_LEASH){ utgt[i]=-1; utgtg[i]=-1; } } if(utgt[i]===-1 && T.wk!=='n' && (i+tick)%acqMod===0){ const e=findEnemyDomain(A[0],A[1],uteam[i],GUARD_ENGAGE,T.targetMask,T.preferMask); if(e>=0){ utgt[i]=e; utgtg[i]=ugen[e]; } } /* The post is a deterministic golden-angle ring slot. No per-order solve, and escorts do not swap places every time the anchor turns — the same reason control groups store a stable slot instead of re-sorting. */ const a=i*2.399963, stand=A[2]+GUARD_STAND+(T.r||4); utx[i]=clamp(A[0]+Math.cos(a)*stand,15,MAP-15); uty[i]=clamp(A[1]+Math.sin(a)*stand,15,MAP-15); /* No flow field. The leash is 330wu, comfortably inside the range where direct steering plus the walkability slide already routes correctly, and a per-tick requestField for a MOVING anchor would evict the field ring (and pay an O(unitHigh) detach) several times a second. */ ufield[i]=-1; uhold[i]=0; umarch[i]=0; } function queueClear(i){ if(uQueue[i]) uQueue[i]=null; uQkind[i]=0; } /* step: {t:0 move | 1 attack | 2 guard, x, y, h:handle, g:generation, mv:move-only} */ function queueApply(i,s){ const T=TYPES[utype[i]]; uhold[i]=0; uPatrolRoute[i]=-1; uMoveCohort[i]=-1; uGuard[i]=-1; uGuardG[i]=-1; uQkind[i]=0; if(s.t===2&&guardEntityLive(s.h,s.g)){ uQkind[i]=2; ustate[i]=7; uGuard[i]=s.h; uGuardG[i]=s.g; utgt[i]=-1; utgtg[i]=-1; umarch[i]=0; ufield[i]=-1; return; } if(s.t===1){ const P=orderTgPos(s.h); if(P&&(s.h<=-2||foeTgt(i,s.h,s.g))){ uQkind[i]=1; ustate[i]=2; utgt[i]=s.h; utgtg[i]=s.g; utx[i]=P[0]; uty[i]=P[1]; umarch[i]=0; ufield[i]=-1; return; } /* The named target died while the chain was still walking. Fall through and run the node as an attack-move onto its last known position rather than skipping it — silently teleporting the plan forward is worse than arriving somewhere the player did point at. */ } const legal=T.air?[s.x,s.y]:T.naval?(findWater(s.x,s.y)||[ux[i],uy[i]]):findLand(s.x,s.y); ustate[i]=s.mv?1:2; utgt[i]=-1; utgtg[i]=-1; umarch[i]=s.mv?0:1; utx[i]=clamp(legal[0],15,MAP-15); uty[i]=clamp(legal[1],15,MAP-15); ufield[i]=T.air?-1:requestField(legal[0],legal[1],!!T.naval); } function queueNext(i){ const Q=uQueue[i]; if(!Q||!Q.length){ uQueue[i]=null; return false; } const s=Q.shift(); if(!Q.length) uQueue[i]=null; queueApply(i,s); return true; } function queueTick(i){ const Q=uQueue[i]; if(!Q||!Q.length){ uQueue[i]=null; return; } if(ustate[i]===7||uQkind[i]===2) return; // guard is terminal; it never arrives /* Named chase is the node: wait until the lock dies, then advance even if the hull never stood on the corpse. A waypoint (move / A-MOVE) advances on arrival WHILE shooting — otherwise a scout on node 1 holds the chain. */ if(uQkind[i]===1){ if(utgt[i]!==-1) return; queueNext(i); return; } const arrive=Math.max(15,(TYPES[utype[i]].r||4)*2.4); if(ustate[i]!==0 && dist2(ux[i],uy[i],utx[i],uty[i])>arrive*arrive) return; queueNext(i); } /* ================= UNIT COMBAT MODES ====================================== A mode is a deliberate trade, never a free upgrade: you give up something real to get something real, and it takes time to switch, so committing to a mode is a decision you can be punished for. Each chassis family gets the mode that suits how it actually fights, which is what makes a mixed army worth microing instead of a single blob you A-move. 0 NORMAL 1 SIEGE 2 GUARD 3 OVERDRIVE 4 SCOUT/CLOAK 5 SUPPRESS */ const umode=new Uint8Array(MAXU); // current mode const umodeT=new Float32Array(MAXU); // deploy/undeploy timer — mode is locked while >0 const MODES=[ {id:0,nm:'MOBILE', em:'▸', ds:'Standard footing'}, {id:1,nm:'SIEGE', em:'⛯', ds:'Rooted: +75% range, +45% damage, cannot move'}, {id:2,nm:'GUARD', em:'⛊', ds:'Dug in: −45% damage taken, −60% speed'}, {id:3,nm:'OVERDRIVE',em:'⏵', ds:'Redlined: +60% fire rate, +35% speed, bleeds HP'}, {id:4,nm:'GHOST', em:'◌', ds:'Silent running: unseen until you fire, −35% speed'}, {id:5,nm:'SUPPRESS', em:'≋', ds:'Sustained fire: +85% rate, −30% range, no movement'}, {id:6,nm:'ASSIST', em:'↯', ds:'Tractor beam: accelerate a factory, build site or Command HQ'}, {id:7,nm:'SURVEY', em:'⌾', ds:'Explore the nearest unclaimed phase-crystal field'}, ]; /* Which modes a chassis can actually take. Artillery roots to shoot further; assault armour digs in; light frames redline or go quiet; flamers suppress. */ const UNIT_MODES=[ [0,3], // 0 Striker — redline [0,2], // 1 Rhino — dig in [0,2,3], // 2 Goliath — dig in or redline [0,1], // 3 Thumper — siege [0,3], // 4 Commander — redline [0,4], // 5 Wasp — ghost [0,1], // 6 Longbow — siege [0,5], // 7 Hornet — suppress [0,2], // 8 TITAN — dig in [0,5], // 9 Pyro — suppress [0,4], // 10 Vulture — ghost [0,2], // 11 Bulwark — dig in [0], // 12 Ravager [0], // 13 Alpha Ravager [0,2], // 14 Corvette — dig in [0,1], // 15 Dreadnought — siege [0,1], // 16 Bombard — siege [0,3], // 17 Raptor — redline [0,5], // 18 Scorcher — suppress [0], // 19 Constructor [0,5], // 20 Reaper — suppress: root and saturate [0,5], // 21 Cinder [0,1], // 22 Lancer — siege: dig in for the long shot [0,2], // 23 Resonator — guard [0], // 24 Warden [0,4], // 25 Kestrel — scout [0,1], // 26 Basilisk — siege [0,1], // 27 Harbinger — siege [0,1], // 28 Praetor — siege [0,2], // 29 Archon — guard [0,3], // 30 Broodmother — overdrive [0], // 31 Tidecaster — biological leader [0,6,7], // 32 Prospector — mine / assist / survey ]; const MODE_SWITCH=1.6; // seconds locked while deploying /* MODES ARE TRADES. The first pass promised "a deliberate trade, never a free upgrade" and then made every mode net positive: siege paid immobility, which artillery never wanted; overdrive's bleed took 48 seconds to matter in a 10-second fight; guard was a pure gift on a unit whose damage is zero. Every cost now bites inside the length of an actual engagement. 1 SIEGE +75% range, +45% damage — rooted AND fragile while deployed 2 GUARD -45% damage taken — and -55% damage dealt, so it is a decision 3 OVERDRIVE +60% rate, +35% speed — bleeds 5%/s, ~14 s to the auto-cutout 4 SCOUT +faster, sees further — thinner armour 5 SUPPRESS +85% rate, -30% range — rooted, and accuracy falls off */ function modeRngMul(m){ return m===1?1.75 : m===5?0.70 : 1; } function modeDmgMul(m){ return m===1?1.45 : m===2?0.45 : m===5?0.80 : 1; } function modeCoolMul(m){ return m===3?0.625 : m===5?0.54 : 1; } function modeSpdMul(m){ return (m===1||m===5)?0 : m===2?0.40 : m===3?1.18 : m===4?0.65 : 1; } function modeTakenMul(m){ return m===2?0.55 : m===1?1.35 : m===3?1.15 : m===4?1.25 : 1; } function unitModes(ty){ return UNIT_MODES[ty]||[0]; } /* The miner's mode 0 is MINE rather than MOBILE — a chassis-specific override of the shared table. Pulled out of unitModeDef so there is ONE owner of "which base entry does this (chassis, mode) pair resolve to", which src/factext.js mirrors when it looks for a faction's word for it. */ const MODE_MINE={id:0,nm:'MINE',em:'⛏',ds:'Lock a mining tractor beam onto an active phase-crystal field'}; function modeBaseDef(ty,m){ if(ty===UT_MINER&&m===0) return MODE_MINE; return MODES[m]||MODES[0]; } /* THE ARMY'S OWN WORD FOR ITS OWN POSTURE. A stance was the last thing in the game still spoken in one voice: a Brood "SIEGE" and a Dominion "SIEGE" read identically in an army that owns no machinery. `id` is the mode number and stays untouched — only the label, glyph and sentence are resolved through the player's kit (see src/factext.js), and every faction's sentence carries the same numbers as the base table. */ function unitModeDef(ty,m){ const base=modeBaseDef(ty,m); if(typeof factionModeName!=='function') return base; const kit=(typeof factionTextKit==='function')?factionTextKit(0):undefined; return {id:base.id, nm:factionModeName(m,ty,kit)||base.nm, em:factionModeEm(m,ty,kit)||base.em, ds:factionModeDesc(m,ty,kit)||base.ds}; } function cycleMode(i){ const list=unitModes(utype[i]); if(list.length<2) return false; const cur=list.indexOf(umode[i]); const next=list[(cur+1)%list.length]; return setMode(i,next); } function setMode(i,m){ if(!ualive[i]||umodeT[i]>0||umode[i]===m) return false; if(unitModes(utype[i]).indexOf(m)<0) return false; umode[i]=m; umodeT[i]=MODE_SWITCH; /* Rooting yourself stops you dead — you can't shuffle out of a siege commitment for free. */ if(m===1||m===5){ utx[i]=ux[i]; uty[i]=uy[i]; } addParticle(3,ux[i],uy[i],0,0,0.45,TYPES[utype[i]].size*2.2, 150,220,255); return true; } function modeCount(team,m){ let n=0; for(let i=0;i=MAXU) return -1; i=unitHigh++; } if(typeof battlefieldClampPoint==='function'){ const bp=battlefieldClampPoint(x,y,(TYPES[type].r||4)+8);x=bp[0];y=bp[1]; } const L=TYPES[type].naval? (findWater(x,y)||[x,y]) : findLand(x,y); x=L[0]; y=L[1]; if(typeof battlefieldClampPoint==='function'){ const bp=battlefieldClampPoint(x,y,(TYPES[type].r||4)+8);x=bp[0];y=bp[1]; } ux[i]=x; uy[i]=y; uang[i]=team?Math.PI:0; uturr[i]=uang[i]; utx[i]=x; uty[i]=y; uhp[i]=T.hp*(team===0?resHpMult*typeHpMult[type]*(T.cat==='hero'?commanderHpMult:1):(team===1?aiHpMult*(WC.iron?1.25:1):1)); uhpm[i]=uhp[i]; ucool[i]=Math.random()*T.cool; ubuff[i]=0; ustomp[i]=0; uclassBuff[i]=0;uclassBuffT[i]=0;ubroodLed[i]=0;uMineT[i]=0;uMineNode[i]=-1; utype[i]=type; uteam[i]=team; ualive[i]=1; ustate[i]=0; usel[i]=0; const slot=populationResolveSlot(team,cmdSlot,x,y); uCmd[i]=slot; uAllyBase[i]=team===0?slot:-1; ugen[i]=(ugen[i]+1)|0; // this slot is now a different unit uwalk[i]=Math.random()*6.283; // desynchronise the gait across a squad utgt[i]=-1; utgtg[i]=-1; ukills[i]=0; uvet[i]=0; ushielded[i]=0; uhaz[i]=0; ufireT[i]=0; ufield[i]=-1; uhold[i]=0; /* A recycled slot inherits every array it is not explicitly cleared from. umarch=1 means "walk to the goal, do not skirmish", so a unit spawned into a dead marcher's slot silently refused to engage for the rest of the match. main.js clears the array between matches, which is why this only ever surfaced mid-match and read as an aggro/AI bug rather than a spawn bug. */ umarch[i]=0; ustun[i]=0; uheal[i]=0; uHurtT[i]=0; uCrash[i]=0; ualt[i]=0; uCtime[i]=0; uPatrolRoute[i]=-1; uPatrolStep[i]=0; uPatrolSlot[i]=0; uMoveCohort[i]=-1; uCohesion[i]=1; /* Same recycled-slot hazard as umarch above, and the reason a save restored into ustate 7 is harmless: guardSteer finds no anchor and stands the unit down instead of escorting whatever the dead occupant was escorting. */ uGuard[i]=-1; uGuardG[i]=-1; uQueue[i]=null; uQkind[i]=0; umode[i]=0; umodeT[i]=0; teamCount[team]++; if(team<2) popCmdInc(slot); else if(team===2&&slot>=0&&typeof broodIsEnemy==='function'&&broodIsEnemy()) popCmdInc(slot); if(type===8) titanCount[team]++; if(typeof gridLink==='function') gridLink(i); return i; } /* Hero-ness is a property of the TYPE, not of slot 4. Anything keyed to utype===4 breaks the moment a faction lands its own commander; behaviours ask this instead. */ function unitIsHero(i){ const T=TYPES[utype[i]]; return !!(T&&T.cat==='hero'); } /* Cruise height matches render3d's long-standing +58. Crash integrates this AGL so a Wasp does not pop at cruise and a falling Atlas still meets dirt. Size >=28 is the existing shell-vs-pock gate (Atlas 34, Ascendant 45; Raptor 18 stays on the ordinary pock). */ const AIR_CRUISE_ALT=58, AIR_CRASH_LARGE=28; const uCrash=new Uint8Array(MAXU); const ualt=new Float32Array(MAXU); const uCvx=new Float32Array(MAXU), uCvy=new Float32Array(MAXU), uCvz=new Float32Array(MAXU); const uCpitch=new Float32Array(MAXU), uCroll=new Float32Array(MAXU); const uCdPitch=new Float32Array(MAXU), uCdRoll=new Float32Array(MAXU), uCspin=new Float32Array(MAXU); const uCtime=new Float32Array(MAXU); function unitAirAlt(i){ if(i>=0&&uCrash[i]) return ualt[i]; return AIR_CRUISE_ALT; } function unitAirLarge(T){ return !!(T&&(T.size>=AIR_CRASH_LARGE||T.airTransport||T.massfleshAir)); } function emitAirSmoke(i,T,crashing){ if(typeof gpfxAirSmoke!=='function') return; const p=typeof mfVfxQ==='function'?mfVfxQ():1; const hpFrac=uhpm[i]>0?uhp[i]/uhpm[i]:0; const rich=crashing||hpFrac<0.18; /* HIGH every 3–5 ticks, MEDIUM 5–8, LOW 8–14. Do not share the ground combat smoke cadence — that path stays on dirt. */ const mod=p>=0.95?(rich?3:5):p>=0.65?(rich?5:8):(rich?8:14); if((i+tick)%mod) return; const h=(typeof terrainH==='function'?terrainH(ux[i],uy[i]):0)+(uCrash[i]?ualt[i]:AIR_CRUISE_ALT); const heading=uang[i]-Math.PI/2; const vx=uCrash[i]?uCvx[i]:Math.cos(heading)*(T.spd||20)*0.28; const vy=uCrash[i]?uCvy[i]:Math.sin(heading)*(T.spd||20)*0.28; gpfxAirSmoke(ux[i],uy[i],h,vx,vy,{size:T.size,rich:rich,crash:!!crashing}); } function beginAirCrash(i){ if(!ualive[i]||uCrash[i]) return false; const T=TYPES[utype[i]]; if(!T||!T.air) return false; /* Mechanical air must not take the Brood ichor burst at cruise height. Brood fliers still crash, then orgfxOnDeath runs on impact via killUnit. */ uCrash[i]=1; usel[i]=0; uhold[i]=1; ustate[i]=0; utgt[i]=-1; utgtg[i]=-1; umarch[i]=0; uQueue[i]=null; uQkind[i]=0; uPatrolRoute[i]=-1; uMoveCohort[i]=-1; uhp[i]=0; ualt[i]=AIR_CRUISE_ALT; const heading=uang[i]-Math.PI/2; const spd=(T.spd||24)*(umov[i]?0.88:0.38); uCvx[i]=Math.cos(heading)*spd; uCvy[i]=Math.sin(heading)*spd; uCvz[i]=8+Math.random()*12; uCpitch[i]=0.18+Math.random()*0.35; uCroll[i]=(Math.random()-0.5)*0.7; uCdPitch[i]=1.05+Math.random()*1.55; uCdRoll[i]=(Math.random()<0.5?-1:1)*(1.5+Math.random()*2.1); uCspin[i]=(Math.random()-0.5)*2.6; uCtime[i]=0; return true; } function airCrashTick(i,dt){ const T=TYPES[utype[i]]; if(!T){ killUnit(i,true); return; } const drag=Math.pow(0.90,dt*10); uCvx[i]*=drag; uCvy[i]*=drag; uCvz[i]-=124*dt; let nx=clamp(ux[i]+uCvx[i]*dt,8,MAP-8), ny=clamp(uy[i]+uCvy[i]*dt,8,MAP-8); if(typeof battlefieldClampPoint==='function'){ const bp=battlefieldClampPoint(nx,ny,T.r+8); nx=bp[0]; ny=bp[1]; } ux[i]=nx; uy[i]=ny; ualt[i]+=uCvz[i]*dt; uang[i]+=uCspin[i]*dt; uCpitch[i]+=uCdPitch[i]*dt; uCroll[i]+=uCdRoll[i]*dt; uCtime[i]+=dt; umov[i]=1; if(typeof gridRelink==='function') gridRelink(i); if(perfScale>0.22) emitAirSmoke(i,T,true); /* Floor is hull thickness, not zero — a 34-size Atlas kissing dirt at alt=0 buried the mesh a frame before the blast. 3.6s is the off-map failsafe if gravity ever loses the wreck. */ if(ualt[i]<=Math.max(2.8,T.size*0.08) || uCtime[i]>3.6) killUnit(i); } function killUnit(i, silent){ if(!ualive[i]) return; const T0=TYPES[utype[i]]; if(!silent && !uCrash[i] && T0 && T0.air){ beginAirCrash(i); return; } const crashImpact=uCrash[i]; uCrash[i]=0; const brood=unitIsBrood(i); ualive[i]=0; usel[i]=0; teamCount[uteam[i]]--; if(typeof gridUnlink==='function') gridUnlink(i); if(uteam[i]<2) popCmdDec(uCmd[i]); else if(uteam[i]===2&&uCmd[i]>=0) popCmdDec(uCmd[i]); uCmd[i]=POP_PLAYER_SLOT; uAllyBase[i]=-1; /* Orders retain generation handles, but clearing the live pointers here lets route/cohort maintenance compact the gap on its very next pass. */ uMoveCohort[i]=-1;uPatrolRoute[i]=-1;uGuard[i]=-1;uQueue[i]=null;uQkind[i]=0; if(utype[i]===8) titanCount[uteam[i]]--; freeList.push(i); const T=TYPES[utype[i]]; if(!silent){ /* Infantry, wildlife and Brood used to inherit the vehicle fireball (flash + mushroom + white shards). Size>24 Brood (Alpha, Sovereign) still fell through and drowned orgfxOnDeath. Keep the warhead for machines only. */ const organic=brood || (!unitIsHero(i) && !T.air && !!T.legs && T.size<=16); if(organic){ const cr=brood?48:190, cg=brood?180:18, cb=brood?40:14; addParticle(0,ux[i],uy[i],0,0,.16,T.size*1.15, cr,cg,cb); addParticle(3,ux[i],uy[i],0,0,.28,T.size*1.05, cr,cg,cb); addParticle(1,ux[i],uy[i],rr(-2,2),rr(-8,-3),.55,T.size*.38, brood?58:72,brood?64:42,brood?48:36); } else { spawnExplosion(ux[i],uy[i], T.size*0.9, uteam[i]); } if(T.size>=20){ const wuv=UNIT_UV[T.spr]; if(wuv) shatterFrame(ux[i],uy[i],T.size*2.05,wuv[angFrame(uang[i])%wuv.length],uteam[i],T.size>=40?3:2,0.7+T.size/40); } const civic=typeof cityGroundAt==='function' && cityGroundAt(ux[i],uy[i])>=1; if(T.size>=16 && !(brood&&civic)) addCrater(ux[i],uy[i],T.size*1.85); if(T.size>=18 && !(brood&&civic)){ /* Large air crash uses the existing titan-scale blast (size*2.6 / 0.085). Do not retune pock/shell radii — Raptor 18 still takes the ordinary pock. CITYG bowls stay on applyDeform's 0.55×; brood+civic still skip. */ if(crashImpact && T.air && unitAirLarge(T)) deformTerrain(ux[i],uy[i],T.size*2.6, 0.085, 'blast'); else deformTerrain(ux[i],uy[i],T.size*1.55, 0.048, T.size>=28?'shell':'pock'); } /* Wreckage from EVERY loss, both sides. Your own dead armour is salvage too — a grinder in your own territory quietly refunds you. */ /* Wildlife leaves no salvage. It used to: every insect death pushed a wreck worth eight mass into a 460-entry ring buffer that evicts from the front, so a single tide flushed every piece of genuinely valuable structure and city salvage out of the array before the player could reach it — and paid an O(n) memmove per corpse to do it. */ /* What lands on the ground is decided by material doctrine, in one place (dropRemains): scrap from machines, biomass from the grown, nothing from the fallen. The size gate that protects the wreck ring buffer from tide spam lives inside it. */ dropRemains(i); /* Ichor stays on the corpse. Type 4 is a fire sprite (ignores RGB) and 60–110 speed launched shards into orbit — both read as cheap garnish around the real hull. */ if((T.legs||brood) && T.size<=24 && perfScale>0.35){ const isOrg=brood; const cr=isOrg?48:190, cg=isOrg?180:18, cb=isOrg?40:14; const n=Math.round((3+T.size*0.2)*perfScale); for(let k=0;k{ if(j!==i&&ualive[j]&&uteam[j]!==vt) dealDamage(j,vd*(1-0.55*Math.sqrt(dist2(vx,vy,ux[j],uy[j]))/vr),vt,-1); }); addParticle(3,vx,vy,0,0,.45,vr, 255,150,70); } if(brood){ if(typeof orgfxOnDeath==='function') orgfxOnDeath(ux[i],uy[i],T.size,T.name); sfx('cre_death',ux[i],uy[i],clamp(T.size/18,0.65,1.8)); if(T.size>=30) sfx('boomsmall',ux[i],uy[i],T.size/28); } else sfx('boom', ux[i],uy[i], T.size/16); } if(i===heroIdx) heroIdx=-1; if(isEnemyCommander(i)){ enemyHeroIdxs=enemyHeroIdxs.filter(h=>h!==i); enemyHeroIdx=enemyHeroIdxs.length?enemyHeroIdxs[0]:-1; } } // ---------- spatial hash ---------- const CS=44, GW=Math.ceil(MAP/CS)+2; const gHead=new Int32Array(GW*GW), gNext=new Int32Array(MAXU); const uGridCell=new Int32Array(MAXU); gHead.fill(-1); gNext.fill(-1); uGridCell.fill(-1); // zeros would make cell walks cycle on unit 0 function gCell(x,y){ return clamp(y/CS|0,0,GW-1)*GW + clamp(x/CS|0,0,GW-1); } function gridUnlink(i){ const c=uGridCell[i]; if(c<0) return; let j=gHead[c], prev=-1; while(j>=0){ if(j===i){ if(prev<0) gHead[c]=gNext[i]; else gNext[prev]=gNext[i]; break; } prev=j; j=gNext[j]; } gNext[i]=-1; uGridCell[i]=-1; } function gridLink(i){ /* Unlink first: a recycled slot or a test wipe that skipped killUnit would otherwise prepend into its own chain and livelock findEnemy. */ if(uGridCell[i]>=0) gridUnlink(i); const c=gCell(ux[i],uy[i]); uGridCell[i]=c; gNext[i]=gHead[c]; gHead[c]=i; } function gridRelink(i){ const c=gCell(ux[i],uy[i]); if(c===uGridCell[i]) return; gridUnlink(i); gridLink(i); } /* Match reset and proofs only. Live ticks keep the hash via spawn, kill, and cell-change relink — filling gHead every step was O(GW²) throwaway work. Do not raise FACTION_POP_CAP or armyCap to "pay for" this. */ function rebuildGrid(){ gHead.fill(-1); for(let i=0;i=0){ if(intelCanTarget(j,team)){ const air=TYPES[utype[j]].air; if(!((mode===1&&!air)||(mode===2&&air))){ const d=dist2(x,y,ux[j],uy[j]); if(d=0){ if(intelCanTarget(j,team)){ const D=mfDomainOfType(TYPES[utype[j]]); if(mask&D){const d=dist2(x,y,ux[j],uy[j]),score=d*((prefer&D)?.62:1);if(scorenaval?!!(NAVW&&NAVW[i]&&NAVCOMP[i]===NAV_MAIN):!!PASS[i]; let goal=ffCell(tx,ty); if(!pass(goal)){ // nudge goal to the nearest legal cell for this medium const gx=goal%PGS, gy=goal/PGS|0; outer: for(let r=1;r<24;r++) for(let a=0;a=PGS||ny2>=PGS) continue; const n=ny2*PGS+nx2; if(!pass(n)||dist[n]!==65535) continue; if(k&1){ // diagonal: forbid corner cutting if(!pass(cy*PGS+nx2)||!pass(ny2*PGS+cx)) continue; } dist[n]=cd+((k&1)?3:2); ffQueue[qt++]=n; } } for(let c=0;c=PGS||ny2>=PGS) continue; const dn=dist[ny2*PGS+nx2]; if(dn=0){ if(ualive[j] && dist2(x,y,ux[j],uy[j])<=r2) fn(j); j=gNext[j]; } } } // ---------- buildings ---------- /* STRUCTURE CATEGORIES. Same purpose as the unit ones: the build menu groups by them, so a player looking for "how do I stop air" has somewhere to look. */ const BCAT={ eco :{nm:'ECONOMY', em:'⛏'}, prod:{nm:'PRODUCTION', em:'🏭'}, nav :{nm:'NAVAL', em:'⚓'}, def :{nm:'DEFENCE', em:'🛡'}, tech:{nm:'TECH', em:'🔬'}, wall:{nm:'FORTIFICATION', em:'🧱'}, sup:{nm:'SUPPORT', em:'📡'}, sup2:{nm:'SUPERWEAPON',em:'☄'} }; const BT={ mex: {name:'Extractor', spr:'mex', size:32, r:15, hp:600, cm:45, ce:120, bt:6, desc:'+4 Mass (on ◆)', em:'⛏', clvl:1}, pgen: {name:'Reactor', spr:'pgen', size:32, r:15, hp:500, cm:40, ce:0, bt:5, desc:'+14 Energy', em:'☀', clvl:1}, fac: {name:'Factory', spr:'fac', size:48, r:24, hp:1600, cm:130, ce:380, bt:10, desc:'Builds units', em:'🏭', clvl:1}, turret:{name:'Sentinel', spr:'turB', size:28, r:13, hp:900, cm:90, ce:260, bt:7, desc:'Laser turret', em:'🎯', clvl:1, bcat:'def'}, bunker:{name:'Bulwark', spr:'turB', size:36, r:17, hp:2800, cm:145, ce:420, bt:10, desc:'Armored close-defence cannon', em:'🏰', clvl:2, bcat:'def'}, sgen: {name:'Aegis Barrier',spr:'sgen', size:32, r:15, hp:1200, cm:135, ce:480, bt:10, desc:'Projects a shield field and repairs nearby structures', em:'🛡', clvl:2}, tgate: {name:'Titan Gate',spr:'tgate', size:66, r:33, hp:3200, cm:420, ce:1600, bt:22, desc:'Builds TITANs', em:'🌌', req:'techlab', clvl:8}, nest: {name:'Ravager Nest',spr:'nest',size:52, r:26, hp:2400, cm:0, ce:0, bt:1, desc:'Wildlife hive — bounty 200 mass', em:'🐛'}, harbor:{name:'Harbor', spr:'harbor',size:52, r:26, hp:1400, cm:150, ce:500, bt:12, desc:'Floating shipyard — navigable water only', em:'⚓', clvl:4, bcat:'nav', placement:'water'}, seafort:{name:'Sea Bastion',spr:'towerB',size:42,r:21,hp:2200,cm:285,ce:980,bt:17,desc:'Floating dual-purpose coastal battery',em:'◉',clvl:5,bcat:'nav',placement:'water'}, bastion:{name:'Concussion Mortar',spr:'towerB',size:42, r:21, hp:2600, cm:320, ce:1300, bt:20, desc:'Long-range explosive shells stun clustered targets', em:'💣', req:'techlab', clvl:6, bcat:'def'}, techlab:{name:'Research Complex',spr:'techlab',size:42,r:21,hp:2400,cm:180,ce:680,bt:14,desc:'Shielded studies bank account ◆ Data',em:'🔬',clvl:3,bcat:'tech'}, aatower:{name:'Skyguard', spr:'aatower',size:30,r:14, hp:1000, cm:100, ce:340, bt:8, desc:'Anti-air flak', em:'🛩', clvl:2, bcat:'def'}, airfield:{name:'Airfield',spr:'airfield',size:52,r:26,hp:1500, cm:160, ce:520, bt:12, desc:'Builds aircraft', em:'✈', clvl:5, bcat:'prod'}, uplink:{name:'Targeting Array',spr:'uplink', size:30, r:14,hp:800, cm:120, ce:480, bt:9, desc:'Research-gated territory relay that extends the HQ build grid and boosts nearby towers', em:'📡', clvl:4, req:'techlab', bcat:'sup'}, hq: {name:'Carrier HQ',spr:'carrier',size:76, r:34, hp:6000, cm:0, ce:0, bt:1, desc:'Deployed super carrier — wide build zone', em:'🚀', clvl:99}, hellstorm:{name:'Hellfire Rotary',spr:'hellB',size:34, r:16, hp:1400, cm:190, ce:680, bt:11, desc:'Rapid rotary fire shreds swarms and light armor', em:'🌪', clvl:4, req:'techlab', bcat:'def'}, arc: {name:'Tesla Coil', spr:'arcB', size:32, r:15, hp:1100, cm:230, ce:850, bt:12, desc:'Chain lightning arcs through packed enemies', em:'⚡', clvl:5, req:'techlab'}, rail: {name:'Rail Battery',spr:'towerB',size:40,r:20,hp:1900,cm:275,ce:980,bt:16,desc:'Long-range anti-heavy penetrator',em:'➤',clvl:5,req:'techlab',bcat:'def'}, nova: {name:'NOVA Missile Silo',spr:'novaB', size:52, r:26, hp:3200, cm:680, ce:2700, bt:30, desc:'Strategic superweapon with map-wide strike range', em:'☄', clvl:7, req:'techlab'}, minelaser:{name:'Mining Laser',spr:'towerB',size:38,r:18,hp:1650,cm:250,ce:920,bt:15,desc:'Sustained beam melts heavy armor',em:'◈',clvl:5,req:'techlab',bcat:'def'}, missilebastion:{name:'Missile Bastion',spr:'novaB',size:44,r:21,hp:2200,cm:340,ce:1240,bt:18,desc:'Long-range guided area-defense salvos',em:'➟',clvl:6,req:'techlab',bcat:'def'}, plasma:{name:'Plasma Charger',spr:'arcB',size:40,r:19,hp:1800,cm:310,ce:1160,bt:17,desc:'Charged ion blast with heavy splash',em:'◉',clvl:6,req:'techlab',bcat:'def'}, stormcaller:{name:'Stormcaller Battery',spr:'novaB',size:46,r:22,hp:2000,cm:380,ce:1500,bt:20,desc:'Banks a heavy charge, then rains a 16-shell barrage on massed attackers',em:'🌩',clvl:6,req:'techlab',bcat:'def'}, wall: {name:'Barricade', spr:'wall', size:22, r:11, hp:1700, cm:12, ce:16, bt:1.2,desc:'Blocks ground units', em:'🧱', clvl:1}, gate: {name:'Gate', spr:'wall', size:22, r:11, hp:1500, cm:18, ce:30, bt:1.5,desc:'Wall your units pass through', em:'🚪', clvl:1}, geo: {name:'Geo Plant', spr:'pgen', size:34, r:16, hp:750, cm:95, ce:160, bt:8, desc:'+30 Energy (on ✦ geyser)', em:'✦', clvl:2}, silo: {name:'Silo', spr:'silo', size:28, r:13, hp:900, cm:70, ce:220, bt:6, desc:'+600 mass / +2000 energy storage', em:'🛢', clvl:2}, fab: {name:'Fabricator',spr:'fabB', size:32, r:15, hp:800, cm:130, ce:460, bt:9, desc:'Burns 58 energy → +3.6 mass', em:'🔥', clvl:3}, }; /* ---------- armor & damage matrix (visible counters) ---------- armor classes per unit type index: 0 LIGHT, 1 MEDIUM, 2 HEAVY */ /* Armour class per unit index. Must stay the same length as TYPES — the new entries continue the list in order. */ /* Fill in any structure category not declared inline, in one place. */ (function classifyBld(){ const by={mex:'eco',pgen:'eco',geo:'eco',fab:'eco',silo:'eco', fac:'prod',tgate:'prod',harbor:'nav',airfield:'prod',hq:'prod',seafort:'nav', turret:'def',bunker:'def',aatower:'def',bastion:'def',hellstorm:'def',arc:'def',rail:'def',nest:'def', minelaser:'def',missilebastion:'def',plasma:'def', techlab:'tech',uplink:'sup',sgen:'sup', wall:'wall',gate:'wall',nova:'sup2'}; for(const k in BT) if(!BT[k].bcat) BT[k].bcat=by[k]||'sup'; })(); const ARM=[0,1,2,1,2, 0,1,1,2, 1,1,2, 0,2, 1,2, 1,0, 1, 0, 1,1,1,1,0,0,2,2, // Reaper Cinder Lancer Resonator Warden Kestrel Basilisk Harbinger 2,1,2,0,0, // Praetor Archon Broodmother Tidecaster Prospector 2,2,0]; // Atlas Skycrane, Massflesh Carrier, Massflesh Ascendant const ARM_NM=['LIGHT','MEDIUM','HEAVY']; /* WHY 33-35 ARE LISTED HERE AND ALSO ASSIGNED IN airlift.js. Those three chassis are appended to TYPES by src/airlift.js, which then wrote their armour class and nothing else did. That left this table three entries short, and a short table does not fail — `dmgMul` reads `ARM[i] || 0`, so a missing entry silently resolves to LIGHT, the worst possible default: LIGHT takes kinetic x1.55, claws x1.60 and incendiary x1.75 while gauss collapses to x0.45. Any heavy body that fell off the end of this list therefore had its counter triangle inverted with nothing anywhere saying so. The live game was covered by airlift.js, but every reader of THIS file alone was not — including tools/extract-design-db.mjs, which is the measurement the balance pass runs on. The 2026-08-14 review consequently reported a 1850 HP transport and a 2850 HP carrier as LIGHT when the running game treats both as HEAVY. These values match what airlift.js installs, so the table now agrees with the simulation and no multiplier moved. The Ascendant stays LIGHT on purpose: it is the winged Massflesh state, and airlift.js's own header sets the counter as "landed = HEAVY ground target -> anti-tank, winged = AIR target -> anti-air". Flak is EXPLOSIVE, which is x1.35 vs light and x0.70 vs heavy, so promoting it would halve the strength of the documented answer to a breakthrough flight. */ /* MAKE THE DRIFT LOUD. The invariant above used to be a comment, and a comment cannot fail. Deferred by a macrotask because TYPES is still growing when this file runs: the manifest loads airlift.js afterwards. Missing classes are the only failure — extra trailing ARM slots (this file listing 33-35 before airlift.js appends those TYPES) must not alarm extract-design-db / replay tooling that evaluate sim.js alone. console.error, not throw: a thrown Error on boot would black-screen a shipping match over a table typo, and the design-DB already prints ARM.length vs TYPES.length. */ if(typeof setTimeout==='function') setTimeout(()=>{ if(ARM.length0)) return; B.kills=(B.kills||0)+got; if(B.team===0){ resM[0]=Math.min(RES_MCAP[0],resM[0]+3*got); resE[0]=Math.min(RES_ECAP[0],resE[0]+9*got); if(perfScale>0.4) addParticle(0,B.x,B.y-8,0,-16,.5,8, 255,215,120); } const v=B.vet||0; if(v=DEF_VET_TIERS[v]){ B.vet=v+1; addParticle(3,B.x,B.y,0,0,.8,BT[B.type].size*1.9, 255,205,96); if(B.team===0){ toast('🎖 '+BT[B.type].name+' promoted to VETERAN '+['I','II','III'][v]+' — +'+(12*(v+1))+'% damage'); sfx('level',B.x,B.y,.9); } } } function bldDmgMul(B){ return bldDmgMulAt(B,B.lvl||1); } function bldRngTierMul(type,lvl){ return type==='turret'? (lvl===3?1.4:lvl===2?1.2:1) : type==='bunker'? (lvl===3?1.35:lvl===2?1.2:1) : type==='aatower'? (lvl===3?1.38:lvl===2?1.25:1) : type==='hellstorm'? (lvl===3?1.25:lvl===2?1.15:1) : type==='arc'? (lvl===3?1.30:lvl===2?1.18:1) : type==='rail'? (lvl===3?1.32:lvl===2?1.18:1) : type==='bastion'? (lvl===3?1.35:lvl===2?1.2:1) : type==='minelaser'? (lvl===3?1.28:lvl===2?1.15:1) : type==='missilebastion'? (lvl===3?1.25:lvl===2?1.15:1) : type==='plasma'? (lvl===3?1.25:lvl===2?1.15:1) : type==='stormcaller'? (lvl===3?1.22:lvl===2?1.12:1):1; } function bldRngMulAt(B,lvl){ let m=bldRngTierMul(B.type,lvl); if(B.team===0&&typeof defenseFocus!=='undefined'&&defenseFocus&&DEFT[B.type]) m*=1.10; if(B.boost>0) m*=B.boostM||UPLINK_BOOST; if(B.team<2) m*=fortOf(B.team).rng; // interlocking fields of fire return m; } function bldRngMul(B){ return bldRngMulAt(B,B.lvl||1); } function hasBld(team,type){ for(const B of bldLive) if(B.alive&&B.team===team&&B.type===type&&B.prog>=1) return true; return false; } function startUpgrade(b){ const B=blds[b], path=BUP[B.type]; if(!path) return 'This structure has no upgrades'; const lvl=B.lvl||1; if(B.type==='fac'&&B.tier===2) return 'Already Tech 2'; if(lvl-1>=path.length) return 'Max level reached'; const U=path[lvl-1]; if(U.req && !hasBld(B.team,U.req)) return 'Requires a '+BT[U.req].name; if(B.team===0 && U.clvl && heroLvl0) return 'Already upgrading'; if(!canAfford(B.team,U.cm,U.ce)) return 'Need '+U.cm+' mass, '+U.ce+' energy'; pay(B.team,U.cm,U.ce); B.upT=U.t; B.upMax=U.t; return null; } function finishUpgrade(B){ if(B.type==='fac'){ B.tier=2; if(B.team===0) toast('🏭 Factory upgraded to TECH 2'); return; } B.lvl=(B.lvl||1)+1; if(B.type==='turret'||B.type==='bunker'||B.type==='aatower'||B.type==='hellstorm'||B.type==='arc'||B.type==='rail'||B.type==='bastion'||B.type==='seafort'|| B.type==='minelaser'||B.type==='missilebastion'||B.type==='plasma'||B.type==='stormcaller'||B.type==='sgen'||B.type==='uplink'||B.type==='nova'){ B.hpm*=B.lvl===3?1.5:1.15; B.hp=Math.min(B.hpm,B.hp+B.hpm*0.3); } if(B.team===0) toast('⬆ '+BT[B.type].name+' upgraded to Mk'+B.lvl); } /* ---------- research (Tech Lab) ---------- */ const RESEARCH=[ {id:'bal1', nm:'Ballistics I', em:'🗡', ds:'+12% unit damage', cm:120, ce:500, t:35, req:null, clvl:3}, {id:'plate1',nm:'Plating I', em:'🛡', ds:'+15% unit HP', cm:130, ce:550, t:40, req:null, clvl:3}, {id:'optics',nm:'Optics Array', em:'🔭', ds:'+12% unit range', cm:110, ce:480, t:35, req:null, clvl:4}, {id:'nano', nm:'Field Automation', em:'⚙', ds:'+25% build speed · +2 Constructor/Prospector cap', cm:140, ce:600, t:40, req:null, clvl:4}, {id:'fusion',nm:'Fusion Cells', em:'⚡', ds:'+30% energy income', cm:120, ce:500, t:35, req:null, clvl:4}, {id:'hardpoint',nm:'Hardpoint Bracing',em:'🏰',ds:'+25% structure HP',cm:140,ce:580,t:42,req:null,clvl:3}, {id:'contain',nm:'Containment Lattice',em:'◆',ds:'+50% Research Complex shield',cm:135,ce:620,t:45,req:null,clvl:4}, {id:'defnet',nm:'Defence Network',em:'🎯',ds:'+15% defensive structure damage',cm:210,ce:920,t:58,req:'hardpoint',clvl:5}, {id:'bal2', nm:'Ballistics II', em:'⚔', ds:'+15% unit damage', cm:200, ce:900, t:55, req:'bal1', clvl:6}, {id:'plate2',nm:'Plating II', em:'🔰', ds:'+20% unit HP', cm:220, ce:950, t:60, req:'plate1', clvl:6}, ]; /* Research is paid as it runs, so deleting a nearly-finished study was both a combat loss and a silent resource wipe. The field network remembers the paid elapsed time for the current match; rebuilding a Complex can resume it. This is deliberately not account persistence—winning the match still owns permanent progression. */ let researched={}, resDone=0, researchCarry={}; function bankResearchProgress(id,t){ researchCarry[id]=Math.max(researchCarry[id]||0,t||0); } function researchResumeTime(id){ return researchCarry[id]||0; } let resHpMult=1, resRngMult=1, resEnergyMult=1; /* PER-CHASSIS BOOSTS — item 5b, "consumables lock to unit TYPE". Every consumable in this game was a global scalar applied once at match start: readying Repair Nanites made the whole army 8% tougher and asked you nothing. Locking a charge to ONE chassis is a decision — which of your units is carrying this match — and it has to pay more than spreading it thin, or there is nothing to decide. Indexed by TYPES slot; reset with the world. */ const typeHpMult=new Float32Array(TYPES.length+8).fill(1); const typeDmgMult=new Float32Array(TYPES.length+8).fill(1); function resetTypeBoosts(){ typeHpMult.fill(1); typeDmgMult.fill(1); } function typeBoostSummary(){ const out=[]; for(let t=0;tr.id===id).nm+' complete · +3 ◆ Data at debrief'); sfx('notify'); } /* (volumetric terrain destruction lives in the engine layer — see applyDeform) */ const FAC_UP={cm:170, ce:650, t:16}; /* DEFENSES MUST OUTRANGE HEROES. At 155 range and 36 damage a Sentinel could not scratch a raiding Commander: every playable hero except Nova's outranged it and 36dps loses to baseline hero regen alone. 170 range puts the tower inside every basic hero's face, and 46dps makes standing in it a decision. */ const TURRET_DMG=46, TURRET_RNG=170, TURRET_COOL=1.0; /* A one-cycle containment relay gives defenders a readable response window when a raid reaches the tech core. It prevents a single burst from erasing the building, then only rearms after the Complex is repaired and its buffer has recovered. Sustained pressure still kills it. */ const TECH_GUARD={trigger:.38,floor:.14,duration:5.5,rearm:28,hpReady:.86,shieldReady:.90}; /* One source of truth for defensive previews. The firing code still owns the behaviours (chain, splash, air-only, minimum range); this table exposes the numbers those behaviours actually consume so the panel cannot advertise a different weapon from the one on the battlefield. */ const DEF_WEAPON_DATA={ turret:{dmg:TURRET_DMG,rng:TURRET_RNG,cool:TURRET_COOL,wk:'b'}, bunker:{dmg:BUNKER.dmg,rng:BUNKER.rng,cool:BUNKER.cool,wk:'e'}, aatower:{dmg:AA.dmg,rng:AA.rng,cool:AA.cool,wk:'e'}, bastion:{dmg:BASTION.dmg,rng:BASTION.rng,min:BASTION.minRng,cool:BASTION.cool,wk:'e'}, seafort:{dmg:BASTION.dmg*.84,rng:BASTION.rng*.86,min:BASTION.minRng*.72,cool:BASTION.cool*.82,wk:'e'}, hellstorm:{dmg:HELL.dmg,rng:HELL.rng,cool:HELL.cool,wk:'p'}, arc:{dmg:ARC.dmg,rng:ARC.rng,cool:ARC.cool,wk:'b'}, rail:{dmg:RAIL.dmg,rng:RAIL.rng,cool:RAIL.cool,wk:'g'}, minelaser:{dmg:MINELASER.dmg,rng:MINELASER.rng,cool:MINELASER.cool,wk:'b'}, missilebastion:{dmg:MISSILE_BASTION.dmg,rng:MISSILE_BASTION.rng,cool:MISSILE_BASTION.cool,wk:'e'}, plasma:{dmg:PLASMA_CHARGER.dmg,rng:PLASMA_CHARGER.rng,cool:PLASMA_CHARGER.cool,wk:'i'}, stormcaller:{dmg:STORM.dmg*STORM.shells,rng:STORM.rng,min:STORM.minRng,cool:STORM.cd,wk:'e'} }; const BLD_HP_UPGRADES={turret:1,bunker:1,aatower:1,hellstorm:1,arc:1,rail:1,bastion:1,seafort:1, minelaser:1,missilebastion:1,plasma:1,stormcaller:1,sgen:1,uplink:1,nova:1}; function bldWeaponSnapshot(B,lvl){ const W=DEF_WEAPON_DATA[B.type]; if(!W) return null; const L=lvl||B.lvl||1; return {damage:W.dmg*bldDmgMulAt(B,L),range:W.rng*bldRngMulAt(B,L), minRange:W.min||0,rate:1/W.cool,dps:W.dmg*bldDmgMulAt(B,L)/W.cool,wk:W.wk}; } function bldSupportSnapshot(B,lvl){ const L=lvl||B.lvl||1,up=Math.max(0,L-1); if(B.type==='sgen') return {field:120+up*18,repair:(12+up*7)*2}; if(B.type==='uplink') return {field:UPLINK_R*(1+up*.18),boost:UPLINK_BOOST+up*.125}; if(B.type==='nova') return {recharge:NOVA.cd/(L===3?1.30:L===2?1.15:1),damage:NOVA.dmg}; if(B.type==='mex') return {income:L===3?11:L===2?7:4}; if(B.type==='pgen') return {income:L===3?38:L===2?24:14}; return null; } function bldProjectedHp(B,lvl){ let hp=B.hpm,L=B.lvl||1; if(!BLD_HP_UPGRADES[B.type]) return hp; while(L=100?Math.round(v):Math.round(v*10)/10; } function bldPanelStatText(B){ const W=bldWeaponSnapshot(B,B.lvl||1); if(W) return 'DAMAGE '+bldNum(W.damage)+' · RANGE '+bldNum(W.range)+' · RATE '+bldNum(W.rate)+'/s'; const S=bldSupportSnapshot(B,B.lvl||1); if(B.type==='sgen') return 'FIELD '+bldNum(S.field)+' · STRUCTURE REPAIR '+bldNum(S.repair)+'/s'; if(B.type==='uplink') return 'FIELD '+bldNum(S.field)+' · RANGE BOOST +'+bldNum((S.boost-1)*100)+'%'; if(B.type==='nova') return 'STRIKE '+NOVA.dmg+' · RECHARGE '+bldNum(S.recharge)+'s'; if(B.type==='mex'){ const D=B.dep>=0?deposits[B.dep]:null,tier=depositTier(D),rate=S.income*(DEPOSIT_YIELD[tier]||0); return tier>0?'MASS +'+bldNum(rate)+'/s · FIELD TIER '+tier+' · '+Math.ceil(D.remaining)+' ORE LEFT' :'FIELD DEPLETED · RELOCATE MINERS'; } if(B.type==='pgen') return 'ENERGY INCOME +'+S.income+'/s'; return (BT[B.type].bcat||'STRUCTURE').toUpperCase()+' · HP '+Math.ceil(B.hpm); } function bldUpgradeDeltaText(B){ const path=BUP[B.type],L=B.lvl||1;if(!path||L-1>=path.length) return ''; const N=L+1,W0=bldWeaponSnapshot(B,L),W1=bldWeaponSnapshot(B,N); if(W0&&W1) return 'MK'+L+' → MK'+N+' · DAMAGE '+bldNum(W0.damage)+' → '+bldNum(W1.damage) +' · RANGE '+bldNum(W0.range)+' → '+bldNum(W1.range)+' · HP '+Math.ceil(B.hpm)+' → '+Math.ceil(bldProjectedHp(B,N)); const S0=bldSupportSnapshot(B,L),S1=bldSupportSnapshot(B,N); if(B.type==='sgen') return 'MK'+L+' → MK'+N+' · FIELD '+bldNum(S0.field)+' → '+bldNum(S1.field) +' · REPAIR '+bldNum(S0.repair)+' → '+bldNum(S1.repair)+'/s'; if(B.type==='uplink') return 'MK'+L+' → MK'+N+' · FIELD '+bldNum(S0.field)+' → '+bldNum(S1.field) +' · BOOST +'+bldNum((S0.boost-1)*100)+'% → +'+bldNum((S1.boost-1)*100)+'%'; if(B.type==='nova') return 'MK'+L+' → MK'+N+' · RECHARGE '+bldNum(S0.recharge)+'s → '+bldNum(S1.recharge)+'s' +' · HP '+Math.ceil(B.hpm)+' → '+Math.ceil(bldProjectedHp(B,N)); if(B.type==='mex'||B.type==='pgen') return 'MK'+L+' → MK'+N+' · INCOME '+S0.income+' → '+S1.income+'/s'; return 'MK'+L+' → MK'+N+' · '+path[L-1].desc.replace(/^Mk\d:\s*/, ''); } /* The panel must expose the whole investment before the first button press. Hiding Mk3 behind a completed Mk2 makes an upgrade feel like an unexplained tax, especially on a phone where returning to a structure takes effort. */ function bldDisplayLevel(B){ if(!B) return 1; /* Factories store tech as tier, everything else as Mk lvl. One read so billboards and selection chrome cannot disagree. */ if(B.type==='fac') return B.tier===2?2:1; return Math.max(1,B.lvl||1); } function bldUpgradePlanText(B){ const path=BUP[B.type]; if(!path) return ''; const L=typeof bldDisplayLevel==='function'?bldDisplayLevel(B):(B.type==='fac'?(B.tier===2?2:1):(B.lvl||1)); const out=[]; for(let i=0;i0&&path[completed]) invested+=path[completed].cm||0; return Math.round(invested*0.5); } const blds=[]; const BCS=130, BGW=Math.ceil(MAP/BCS)+1; let bGrid=[]; /* Rebuilding the structure grid also re-rasterises the build territory — a 37,636-cell sweep with a walkability test per cell. Doing that inside the damage handler meant a swarm chewing through a thirty-segment wall line ran it thirty times in a few frames. The grid itself is cheap and stays immediate; the expensive territory pass is deferred to one rebuild at the end of the tick, which is soon enough for something the player reads as a static overlay. */ function rebuildBGrid(deferZone){ /* `deferZone` skips the territory pass entirely: buildZoneTick already re-rasterises on a 1.2 s cadence, which is the mechanism that exists precisely because terrain and occupancy drift under the player. Structure deaths ride that instead of forcing their own sweep. */ if(!deferZone && typeof markBuildZone==='function') markBuildZone(); if(typeof refreshFabList==='function'){ refreshFabList(); refreshBldLive(); } bGrid=new Array(BGW*BGW); for(let b=0;b=3; break; } } if(type==='geo') for(let gi=0;gi0) dmg*=0.72; // protected by an Aegis Relay if(B.team<2&&B.type!=='nest') dmg*=fortOf(B.team).armor; // perimeter hardening if(B.shield>0){ const take=Math.min(B.shield,dmg); B.shield-=take; dmg-=take; addParticle(0,B.x+rr(-B.r,B.r),B.y+rr(-B.r,B.r),0,0,.18,9,110,220,255); if(dmg<=0){ B.hitT=0.2; return; } } /* Trigger on the threshold crossing, including a nominally lethal hit. Once active, the floor is absolute for its short lifetime; the attacker must remain on target after the relay burns out. */ if(B.type==='techlab'&&B.guardReady&&B.hp>B.hpm*TECH_GUARD.floor&&B.hp-dmg<=B.hpm*TECH_GUARD.trigger){ B.guardReady=false; B.guardT=TECH_GUARD.duration+(researched.contain?2:0); B.guardCharge=0; if(B.team===0){ toast('◆ RESEARCH CONTAINMENT — '+Math.ceil(B.guardT)+'s to reinforce the Complex'); sfx('surge',B.x,B.y,1.25); buzz([45,35,70]); } } if(B.type==='techlab'&&B.guardT>0) B.hp=Math.max(B.hpm*TECH_GUARD.floor,B.hp-dmg*.24); else B.hp-=dmg; B.hitT=0.35; // drives the flinch/spark anim if(typeof orgfxOnBld==='function') orgfxOnBld(B,dmg,B.hp<=0); if(B.hp>0 && B.hp0.35){ addParticle(0,B.x+rr(-B.r*0.55,B.r*0.55),B.y+rr(-B.r*0.55,B.r*0.55),0,0,.35,4.8,255,140,40); if(Math.random()<0.65) addParticle(0,B.x+rr(-B.r*0.40,B.r*0.40),B.y+rr(-B.r*0.40,B.r*0.40),0,0,.28,3.8,255,128,36); } if(B.hp<=0){ B.alive=false; B.fallT=stats.t; if(B.type==='mex'&&B.dep>=0) redirectProspectorsFromNode(B.dep,B.team); const Tb0=BT[B.type], bsz=Tb0.size; const civic=typeof cityGroundAt==='function' && cityGroundAt(B.x,B.y)>=1; /* A nest is grown tissue. Masonry collapse + wreck fire is the same drowning fireball the large-Brood unit path used to fire. orgfxOnBld already sprayed ichor; puddles stay billboards. Civic: no crater. */ const grown=B.type==='nest'||B.team===2 ||(typeof orgfxBldOrganic==='function'&&orgfxBldOrganic(B)); if(!grown) spawnBuildingCollapse(B.x,B.y,bsz,civic); if(!(grown&&civic)){ addCrater(B.x,B.y, civic?Math.min(bsz*1.05,64):Math.min(bsz*1.75,120)); deformTerrain(B.x,B.y, civic?Math.min(bsz*1.15,52):Math.min(bsz*1.65,115), civic?0.032:0.072, civic?'shell':'blast'); } addRubble(B.x,B.y,bsz*0.9); addRubble(B.x+rr(-bsz*0.35,bsz*0.35),B.y+rr(-bsz*0.35,bsz*0.35),bsz*0.55); if(!grown){ if(civic){ addGroundBurn(B.x,B.y, Math.max(bsz*1.35,48), 1); spawnCivicWreckFire(B.x,B.y,bsz); } else { addGroundBurn(B.x,B.y, Math.max(bsz*0.95,28), 1); spawnCivicWreckFire(B.x,B.y,bsz*0.72); } } shake=Math.max(shake,6); sfx('boom',B.x,B.y,1.6); rebuildBGrid(true); // territory catches up at end of tick /* Every structure that falls — yours, theirs, a wildlife nest — leaves a debris field worth roughly half what it cost to raise. Whoever holds the ground afterwards gets paid, which is why you counter-attack. */ const Tb=BT[B.type], big=Tb.size>=46; /* A nest is grown tissue, not a fabricated building. */ if(B.type==='nest') addWreckField(B.x,B.y, 0, Tb.size*2.2, WRECK_BIO, Tb.size*0.75, big?4:2); else addWreckField(B.x,B.y, Tb.cm*0.55+18, Tb.ce*0.30, WRECK_STRUCT, Tb.size*0.75, big?4:2); if(B.type==='mex') for(const D of deposits) if(D.x===B.x&&D.y===B.y) D.taken=false; if(B.type==='geo') for(const G of geysers) if(G.x===B.x&&G.y===B.y) G.taken=false; if(attTeam===0) heroXP(26); if(B.type==='nest'&&attTeam===0){ stats.nests=(stats.nests||0)+1; // counted for daily orders resM[0]=Math.min(RES_MCAP[0],resM[0]+200); heroXP(60); toast('🐛 Nest destroyed — +200 mass bounty!'); } } } /* Structural patch applied by a Constructor (and future repair sources). HP only ever restores toward the ceiling — upgrades set hpm first, then repair follows it — so a partially-upgraded turret cannot be healed past its tier. */ function repairBld(B,amt){ if(!B||!B.alive||B.prog<1) return; B.hp=Math.min(B.hpm,B.hp+amt); } // ---------- resource nodes: mass deposits, rich veins, energy geysers ---------- // Procedurally seeded per map — every battlefield has its own economy layout, // always point-mirrored so both commanders get a fair start. const deposits=[], geysers=[]; const DEPOSIT_BAND=1000; const DEPOSIT_YIELD=[0,1,1.48,2.12]; /* Geo output stays at the existing +30/s; the new capacity only turns an unlimited binary prop into a strategic three-stage field. Three 9k bands last fifteen minutes at base output, so ordinary match balance is unchanged while long Large-map wars eventually need a second power site. */ const GEYSER_BAND=9000; function makeDeposit(x,y,rich,starter){ /* Tier is both a gameplay value and a silhouette. Starter fields begin at Tier II, rare contested veins at Tier III, and ordinary expansion pockets at Tier I. Every band is finite and visibly collapses into the next. */ const patterned=((Math.floor(x/97)+Math.floor(y/113))&3)===0; const tier=rich?3:(starter||patterned?2:1); return {x,y,taken:false,rich:tier===3,starter:starter||'',initialTier:tier, tier,capacity:DEPOSIT_BAND*tier,remaining:DEPOSIT_BAND*tier,depleted:false,pulse:rr(0,TAU)}; } function depositTier(D){ if(!D||D.remaining<=0){ if(D){D.remaining=0;D.tier=0;D.depleted=true;} return 0; } D.tier=clamp(Math.ceil(D.remaining/DEPOSIT_BAND),1,D.initialTier||3); D.depleted=false; return D.tier; } function depositYield(D){ return DEPOSIT_YIELD[depositTier(D)]||0; } function drainDeposit(D,amount){ if(!D||amount<=0||depositTier(D)<=0) return 0; const got=Math.min(D.remaining,amount),before=D.tier; D.remaining-=got; const after=depositTier(D); if(after!==before){ D.phaseFlash=1.4; if(typeof refreshResourceTerrainNode==='function')refreshResourceTerrainNode(D,'mass',before,after); } return got; } function makeGeyser(x,y,starter){ return {x,y,taken:false,starter:starter||'',initialTier:3,tier:3, capacity:GEYSER_BAND*3,remaining:GEYSER_BAND*3,depleted:false,pulse:rr(0,TAU)}; } function geyserTier(G){ if(!G)return 0; /* Saves created before finite geysers have no capacity fields. Promote them in place rather than treating a valid old node as depleted. */ if(!Number.isFinite(G.capacity)||G.capacity<=0)G.capacity=GEYSER_BAND*3; if(!Number.isFinite(G.remaining))G.remaining=G.capacity; if(!Number.isFinite(G.initialTier)||G.initialTier<1)G.initialTier=3; if(G.remaining<=0){G.remaining=0;G.tier=0;G.depleted=true;return 0;} G.tier=clamp(Math.ceil(G.remaining/GEYSER_BAND),1,G.initialTier); G.depleted=false;return G.tier; } function drainGeyser(G,amount){ const before=geyserTier(G); if(!G||amount<=0||before<=0)return 0; const got=Math.min(G.remaining,amount);G.remaining-=got; const after=geyserTier(G); if(after!==before){ G.phaseFlash=1.4; if(typeof refreshResourceTerrainNode==='function')refreshResourceTerrainNode(G,'energy',before,after); } return got; } function setupDeposits(){ deposits.length=0; geysers.length=0; const def=MAPDEFS[curMap]||MAPDEFS.vanguard; srand((def.seed^0x5f3759)|1); if(typeof skirmishSpawnPoints==='function'){ const starts=skirmishSpawnPoints(),all=[],gall=[]; const addNode=(arr,x,y,rich,minD,starter)=>{ x=clamp(x,100,MAP-100); y=clamp(y,100,MAP-100); /* Objectives belong inside the red tactical line. Starter fields near an inlet are projected inward; random expansion fields are rejected by the loops below so their authored spacing remains natural. */ if(typeof battlefieldClampPoint==='function'){ const p=battlefieldClampPoint(x,y,112); x=p[0]; y=p[1]; } for(const p of arr) if(dist2(x,y,p[0],p[1]){ if(typeof battlefieldContains==='function'&&(!battlefieldContains(x,y,112)|| !battlefieldContains(MAP-x,MAP-y,112)))return false; for(const p of pts){ if(dist2(x,y,p[0],p[1])minD*minD; // keep clear of own mirror point }; // guaranteed starter economy near each base pts.push([bx+rr(100,190), by-rr(110,190), 0]); pts.push([bx-rr(10,110), by-rr(230,320), 0]); pts.push([bx+rr(230,330), by+rr(-40,60), 0]); // contested field — some rich let tries=0; while(pts.length<9 && tries++<400){ const x=rr(240,MAP-240), y=rr(240,MAP-240); if(!farOK(x,y,165)) continue; if(dist2(x,y,bx,by)<330*330 || dist2(x,y,MAP-bx,MAP-by)<330*330) continue; pts.push([x,y, pts.length===4||pts.length===7 ?1:0]); } const gp=[]; tries=0; while(gp.length<2 && tries++<300){ const x=rr(300,MAP-300), y=rr(300,MAP-300); let ok=dist2(x,y,bx,by)>380*380 && dist2(x,y,MAP-bx,MAP-by)>380*380; if(ok&&typeof battlefieldContains==='function')ok=battlefieldContains(x,y,112)&&battlefieldContains(MAP-x,MAP-y,112); if(ok) for(const p of pts) if(dist2(x,y,p[0],p[1])<150*150||dist2(x,y,MAP-p[0],MAP-p[1])<150*150){ ok=false; break; } if(ok) for(const g of gp) if(dist2(x,y,g[0],g[1])<220*220||dist2(x,y,MAP-g[0],MAP-g[1])<220*220){ ok=false; break; } if(ok) gp.push([x,y]); } const all=[], gall=[]; for(const p of pts){ all.push(p); all.push([MAP-p[0],MAP-p[1],p[2]]); } for(const p of gp){ gall.push(p); gall.push([MAP-p[0],MAP-p[1]]); } window.__depPts=all.concat(gall); // terrain raises land under all nodes for(const p of all) deposits.push(makeDeposit(p[0],p[1],!!p[2],'')); for(const p of gall) geysers.push(makeGeyser(p[0],p[1],'')); } function geyserAt(x,y,rad){ for(let g=0;g0&&dist2(x,y,G.x,G.y)1?1:t); best=Math.min(best,Math.sqrt(dist2(x,y,A.x+dx*t,A.y+dy*t))); } return best; } const ax=MAP*SP_LO, ay=MAP*SP_HI, bx=MAP*SP_HI, by=MAP*SP_LO; const dx=bx-ax, dy=by-ay, L2=dx*dx+dy*dy; let t=((x-ax)*dx+(y-ay)*dy)/L2; t=t<0?0:(t>1?1:t); return Math.sqrt(dist2(x,y,ax+dx*t,ay+dy*t)); } const LANE_CLEAR=430; function setupNests(){ if(typeof infestationOn==='boolean'&&!infestationOn) return; const def=MAPDEFS[curMap]||MAPDEFS.vanguard; srand((def.seed^0x1234f)|1); /* Starting hive count was a flat 5 on every difficulty — the map opened with the SAME infestation footprint whether the player picked Easy or Hard, so even before a single eruption timer fired, Easy already had more hives on the ground than its own spread cap (nestSpreadT's `3+tier` ceiling) allows it to grow into. Fewer hives at the whistle on Easy means fewer ambient- trickle sources and fewer eruption origins for the whole match, which is the actual "opening to build" the player needs. Hard is left at 5 — the original, intentionally-hard baseline. */ const nestGoal=Math.max(1, Math.round([3,4,5][diffLvl()] * (broodIsEnemy()?1:0.55) * ((typeof planetInfestMul==='function')?planetInfestMul():1))); let n=0, tries=0; const placed=[]; while(n=0) ustate[i]=2; } n++; } } /* ============================================================ VOLUMETRIC SHATTER — objects break into ballistic fragments carved from their own baked 3D frame (real z-physics: launch, gravity, tumble, bounce, settle; moving ground shadows) ============================================================ */ const MAXSH=384; const shX=new Float32Array(MAXSH), shY=new Float32Array(MAXSH); // ground position const shVX=new Float32Array(MAXSH), shVY=new Float32Array(MAXSH); const shZ=new Float32Array(MAXSH), shVZ=new Float32Array(MAXSH); // altitude physics const shRot=new Float32Array(MAXSH), shSpin=new Float32Array(MAXSH); const shLife=new Float32Array(MAXSH), shSize=new Float32Array(MAXSH); const shU0=new Float32Array(MAXSH), shV0=new Float32Array(MAXSH), shU1=new Float32Array(MAXSH), shV1=new Float32Array(MAXSH); const shR=new Uint8Array(MAXSH), shG=new Uint8Array(MAXSH), shB=new Uint8Array(MAXSH); const shBounced=new Uint8Array(MAXSH); let shHead=0; const SH_G=290; // gravity (wu/s²) function addShard(x,y,vx,vy,vz,size,uv,tr,tg,tb){ const i=shHead; shHead=(shHead+1)%MAXSH; shX[i]=x; shY[i]=y; shVX[i]=vx; shVY[i]=vy; shZ[i]=0.1; shVZ[i]=vz; shRot[i]=Math.random()*TAU; shSpin[i]=rr(-7,7); shLife[i]=2.6+Math.random()*1.2; shSize[i]=size; shU0[i]=uv[0]; shV0[i]=uv[1]; shU1[i]=uv[2]; shV1[i]=uv[3]; shR[i]=tr; shG[i]=tg; shB[i]=tb; shBounced[i]=0; } // break a baked frame into an N×N grid of textured fragments function shatterFrame(x,y,worldSize,uv,team,grid,power){ if(!unitTexReady||!uv) return; const n=grid||3, cell=worldSize/n; const tc=TEAMC[team]||[255,255,255]; for(let gy=0;gy0.001||shVZ[i]>0){ shVZ[i]-=SH_G*dt; shZ[i]+=shVZ[i]*dt; shX[i]+=shVX[i]*dt; shY[i]+=shVY[i]*dt; shRot[i]+=shSpin[i]*dt; if(shZ[i]<=0&&shVZ[i]<0){ // ground impact shZ[i]=0; if(!shBounced[i]&&shVZ[i]<-60){ shBounced[i]=1; shVZ[i]*=-0.32; shVX[i]*=0.5; shVY[i]*=0.5; shSpin[i]*=0.4; if(Math.random()<0.5) addParticle(1,shX[i],shY[i],rr(-3,3),rr(-6,-2),.4,shSize[i]*0.5, 120,112,96); } else { shVZ[i]=0; shVX[i]=0; shVY[i]=0; shSpin[i]*=0.1; } } } } } /* ---------- day/night cycle ---------- */ let dayT=0.08; // 0=noon .5=midnight; full cycle 8 min const DAY_LEN=480; /* Daylight lock is a tactical accessibility option, not a cosmetic filter. It deliberately wins over Blood Moon / Eclipse: a player who has disabled the cycle asked for an always-readable battlefield, including on a map that would otherwise force permanent darkness. */ function dayCycleOn(){ return !(typeof META!=='undefined'&&META.settings&&META.settings.dayNight===false); } function nightAmt(){ return dayCycleOn() ? (1-Math.cos(dayT*TAU))/2 : 0; } /* ---------- meteor storms ---------- */ const meteors=[]; // {x,y,t} let stormTimer=200; /* ---------- alien infestation — HIVEWORLD escalation ---------- THE INFESTATION IGNORED THE DIFFICULTY SETTING ENTIRELY, and that single omission was behind two separate complaints that sounded unrelated. Every number below — tier clock, eruption size, hive count, the Tide — ran off match time alone. So "Easy" put the same 220-bugs-per-hive tide on the field as Hard, four minutes in, and a new player was buried by a third party they never chose to fight. That is the "easy is too hard, I get swarmed" report. The second report — "enemy factions never make it to the player" — is the SAME BUG seen from the other end. Bugs are hostile to team 1 as well, and the AI's attack waves have to cross the same carpet. They were being shredded in transit and the retreat rule (below 28% strength) sent the survivors home, so the enemy army the player picked never arrived. The player only ever fought bugs. So the infestation now scales on every axis, and on Easy it is a hazard rather than the main event. The AI is the opponent; the hive is weather. */ let infestT=230, nestSpreadT=170, infestLvl=0, tideT=260; /* Difficulty index, safely — sim.js is parsed before ai.js defines AI. Prefer the UI's `difficulty` global over AI.diff: newSkirmish() calls setupNests() BEFORE aiSetup(difficulty) runs, so AI.diff still holds the PREVIOUS match's value (or the default 1) while the hives are placed. Reading `difficulty` instead means the very first thing that spawns on the map — the starting hive count — actually sees what the player picked. */ function diffLvl(){ if(typeof difficulty==='number') return clamp(difficulty|0,0,2); return (typeof AI!=='undefined'&&AI&&typeof AI.diff==='number')?clamp(AI.diff|0,0,2):1; } /* WHOSE ARMY IS THE HIVE? It is the Umbral Brood's. That is the design rule this whole section now obeys, and it was missing: the infestation ran at full strength no matter who the player chose to fight, so picking the Ascendancy still meant spending the match killing bugs while the Ascendancy's army was somewhere else entirely. The opponent the player picked has to be the opponent they fight. Against the Brood the hive IS the war and stays as fierce as it ever was. Against anyone else it drops to wildlife: a hazard around the nests, a reason to escort an expansion, and nothing that decides a match. */ function broodIsEnemy(){ return (typeof AI!=='undefined' && AI && AI.fac === 'horde'); } /* One multiplier for every quantity in this section: how many spawn, how often, how many can stand at once. Difficulty first, then the faction rule. */ function infQty(){ /* Hiveworld overrides the faction rule: you asked for the swarm. Brood homeworld maps also thicken the carpet even when the AI is someone else — the planet is the hive. Nova homeworlds stay wildlife-thin. */ const world=(typeof planetInfestMul==='function')?planetInfestMul():1; return [0.18, 0.5, 1.0][diffLvl()] * ((broodIsEnemy() || WC.swarm) ? 1 : 0.35) * (WC.swarm ? 2.2 : 1) * world; } function planetInfestMul(){ const D=(typeof MAPDEFS!=='undefined'&&MAPDEFS[curMap])?MAPDEFS[curMap]:{}; if(D.infest!=null) return D.infest; const fac=typeof mapHomeFac==='function'?mapHomeFac():'nova'; return fac==='horde'?1.7:fac==='nova'?0.4:fac==='legion'?0.75:0.55; } function bugCap(){ const D=diffLvl(),k=populationTheatre(),mapMul={compact:.78,standard:1,large:1.22}[k]||1; /* Hundreds still read as a tide at RTS camera distance. Thousands multiply pathing, targeting, fog and billboard traffic until Android reclaims the WebGL context. Keep Unholy dangerous through cadence and composition, not by allocating an unrenderable carpet. */ let hard=[210,360,620][D]*mapMul*(broodIsEnemy()?1:.42)*(WC.swarm?1.22:1); if(typeof planetInfestMul==='function'&&planetInfestMul()>1) hard*=1.22; if(typeof META!=='undefined'&&META.settings&&META.settings.perf==='low')hard=Math.min(hard,220); return Math.max(80,Math.min(FACTION_POP_CAP,Math.round(hard))); } function infTier(){ // threat tier I–V, escalates with match time /* Easy takes nearly twice as long to reach each tier, so the early game is actually an early game. */ const D=diffLvl(), brood=broodIsEnemy(); let t2=1+Math.floor(stats.t/([290,205,155][D]*(brood?1:1.7))); if(WC.wild) t2++; /* A ceiling, not just a slower climb. Wildlife should never reach the tier where the map starts erupting on its own — that escalation belongs to the faction whose army it is. */ const cap = brood ? 5 : [2,2,3][D]; return Math.min(cap,Math.max(1,t2)); } function liveNests(){ const n=[]; for(const B of bldLive) if(B.alive&&B.type==='nest') n.push(B); return n; } /* Eruptions QUEUE their broods and the ground pours bugs continuously — thousands stream out over seconds instead of one hitch. */ const bugQ=[]; function bugQPending(){ let n=0; for(const q of bugQ) n+=q.n; return n; } function nestErupt(N,count,tier){ let tp=null,td=1e18, te=null,tde=1e18; // nearest structures of each side for(const B of blds){ if(!B.alive||B.prog<1) continue; const dd=dist2(B.x,B.y,N.x,N.y); if(B.team===0&&dd0&&bugQ.length){ const q=bugQ[0]; const batch=Math.min(q.n,budget,70); for(let k=0;k=0?q.seat:undefined); if(i>=0){ ustate[i]=2; ubuff[i]=q.tier>=4?6:0; // frenzied at high tiers const r3=Math.random(); /* Who the brood walks at. Half of every eruption marching straight for the player made the hive feel personal rather than ambient; on Easy most of a tide now wanders or goes for the AI instead. */ const atPlayer=[0.26,0.38,0.5][diffLvl()]*(broodIsEnemy()?1:0.7); if(r3=0){ utx[i]=clamp(q.tpx+rr(-140,140),20,MAP-20); uty[i]=clamp(q.tpy+rr(-140,140),20,MAP-20); } else if(r3=0){ utx[i]=clamp(q.tex+rr(-140,140),20,MAP-20); uty[i]=clamp(q.tey+rr(-140,140),20,MAP-20); } else { utx[i]=clamp(q.x+rr(-600,600),20,MAP-20); uty[i]=clamp(q.y+rr(-600,600),20,MAP-20); } } } q.n-=batch; budget-=batch; if(q.n<=0) bugQ.shift(); if((tick&7)===0&&bugQ.length) addParticle(1,q.x+rr(-40,40),q.y+rr(-30,30),rr(-6,6),rr(-16,-8),1.0,14, 90,110,50); } } /* ---------- ambient bird flocks ---------- */ const birds=[]; // {x,y,vx,vy,n,ph} function envTick(dt){ if(!dayCycleOn()) dayT=0.08; // stable clear daylight else if(WC.moon || WC.dark) dayT=0.5; // wildcard: endless night else dayT=(dayT+dt/DAY_LEN)%1; // bird flocks drift across the map (day only) for(let b=birds.length-1;b>=0;b--){ const F=birds[b]; F.x+=F.vx*dt; F.y+=F.vy*dt; F.ph+=dt*9; if(F.x<-200||F.x>MAP+200||F.y<-200||F.y>MAP+200) birds.splice(b,1); } if(birds.length<2 && nightAmt()<0.6 && Math.random()