File size: 23,000 Bytes
b51e03a
3597f5a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
fcce7e3
 
 
 
 
369b236
fcce7e3
 
 
b51e03a
 
 
3597f5a
b51e03a
3597f5a
b51e03a
 
3597f5a
b51e03a
3597f5a
 
 
 
 
b51e03a
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
b51e03a
 
fcce7e3
 
 
369b236
 
3597f5a
 
 
 
 
369b236
fcce7e3
 
 
b51e03a
 
fcce7e3
b51e03a
fcce7e3
 
 
3597f5a
fcce7e3
369b236
fcce7e3
 
b51e03a
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369b236
fcce7e3
 
 
 
 
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369b236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
fcce7e3
369b236
 
fcce7e3
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
fcce7e3
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
369b236
 
 
 
 
 
fcce7e3
 
369b236
fcce7e3
369b236
fcce7e3
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
3597f5a
 
 
 
 
fcce7e3
 
 
 
 
 
 
369b236
fcce7e3
 
 
369b236
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
369b236
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
369b236
fcce7e3
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
3597f5a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
;
;
/* ============================================================================
   MAP-EXCLUSIVE ENVIRONMENTAL HAZARDS
   ----------------------------------------------------------------------------
   Every map already looked different. None of them PLAYED differently β€” the
   terrain changed the pathing and nothing else, so map choice was decoration.
   Each map now owns one hazard nobody else gets, drawn from what that place
   actually is:

     Vanguard Valley  DUST FRONT   a squall rolls across the open highway country,
                                   blinding and slowing everything inside it
     Highland Scar    ROCKSLIDE    the cliffs shed; fighting on steep ground has
                                   a cost the plains do not charge
     Shattered Isles  SQUALL       an ocean storm earths itself through the
                                   biggest piece of metal under the cell
     Relic Basin      RELIC PULSE  the thing in the crater is not inert, and the
                                   centre of the map is the richest ground on it

   Three rules the whole file obeys:
     * every hazard hits ALL THREE TEAMS. A hazard that only inconveniences the
       player is a difficulty setting wearing a costume.
     * every hazard is telegraphed before it lands, with enough warning to walk
       out. Damage from nowhere is not difficulty, it is noise.
     * every hazard scales with the difficulty setting, because the whole reason
       Easy was unplayable is that the map's third parties did not.
   ============================================================================ */

const HAZ={ map:'', mode:'', t:0, warn:0, cells:[], front:null, phase:0, count:0, faults:[], vision:[], lava:[] };

/* Shown on the map cards in Battle Setup, so the hazard is part of choosing a
   battlefield rather than a surprise thirty seconds in. */
const MAPHAZ={
  vanguard:{em:'πŸŒͺ', nm:'DUST FRONTS',  ds:'Squalls cross the valley β€” sight and speed cut inside'},
  highland:{em:'β›°', nm:'FAULT COLLAPSE',ds:'Marked shelves crack, telegraph, then collapse into impassable scars'},
  isles:   {em:'⚑', nm:'SQUALL LINES', ds:'Lightning earths through the largest massed force'},
  crater:  {em:'β—ˆ', nm:'RELIC PULSES', ds:'The basin floor blacks out systems and drains grids'},
};
const MF_HAZARD_PROFILES={
  dust:{em:'πŸŒͺ',nm:'ASH FRONT',ds:'Abrasive fronts cut sight and movement across exposed ground',mode:'vanguard'},
  storm:{em:'🌩',nm:'TEMPEST CELLS',ds:'Charged weather blinds advancing formations',mode:'vanguard'},
  calm:{em:'β—Ž',nm:'CLEAR SKIES',ds:'Working civic weather β€” no battlefield tax on the opening build',mode:'calm'},
  spores:{em:'β—Œ',nm:'SPORE BLOOM',ds:'Dense alien spores conceal units and slow movement',mode:'vanguard'},
  heat:{em:'♨',nm:'THERMAL SURGE',ds:'Heat distortion sweeps exposed routes and drains combat tempo',mode:'heat'},
  collapse:{em:'β›°',nm:'TERRAIN COLLAPSE',ds:'Telegraphed shelves fracture into impassable scars',mode:'highland'},
  eruption:{em:'πŸŒ‹',nm:'MAGMA ERUPTION',ds:'Unstable vents rupture, then leave dangerous molten ground',mode:'eruption'},
  meteor:{em:'β˜„',nm:'ORBITAL DEBRIS',ds:'Non-organic space weather telegraphs destructive impact zones',mode:'meteor'},
  squall:{em:'⚑',nm:'SQUALL LINES',ds:'Electrical storms earth through concentrated forces',mode:'isles'},
  whiteout:{em:'❄',nm:'WHITEOUT',ds:'Frozen fronts erase vision before slamming massed units',mode:'whiteout'},
  flood:{em:'≋',nm:'FLOOD SURGE',ds:'Rising channels isolate formations before a violent surge',mode:'flood'},
  /* Dead MAPDEFS string. Live sites use `heat`; keep the alias so a leftover
     key still runs the thermal tick instead of falling through to dust. */
  solray_corridor:{em:'πŸŒ…',nm:'SOLAR FOCUS',ds:'Heat distortion sweeps exposed routes and drains combat tempo',mode:'heat'},
  pulse:{em:'β—ˆ',nm:'RELIC PULSES',ds:'Buried systems black out units and drain local grids',mode:'crater'}
};
function mapHazardKey(map){
  const D=(typeof MAPDEFS!=='undefined'&&MAPDEFS[map])?MAPDEFS[map]:null;
  return (D&&D.hazard)||({vanguard:'dust',highland:'collapse',isles:'squall',crater:'pulse'}[map])||'storm';
}
function mapHazardDef(map){
  const key=mapHazardKey(map);
  const P=MF_HAZARD_PROFILES[key]||MF_HAZARD_PROFILES.storm;
  const legacy=MAPHAZ[map];
  /* Legacy MAPHAZ rows have copy and no mode. Homeworld sites have a
     profile key and no MAPHAZ row. Merge so UI and hazTick always agree. */
  if(legacy) return {em:legacy.em,nm:legacy.nm,ds:legacy.ds,mode:P.mode||map};
  return P;
}
function mapHazardMode(map){ return (mapHazardDef(map).mode)||map; }

function hazDiff(){ return (typeof diffLvl==='function')?diffLvl():1; }
/* Long gaps on Easy, and a much smaller bite. */
function hazEvery(base){
  const p=typeof battlefieldPresetDef==='function'?battlefieldPresetDef():{haz:1};
  const fac=typeof mapHomeFac==='function'?mapHomeFac():'';
  /* Nova homeworlds are beginner theatres: weather exists, it does not bully
     the opening. Brood worlds bite sooner. */
  const world=fac==='nova'?1.85:fac==='horde'?0.82:1;
  return base*[1.85,1.3,1][hazDiff()]*(p.haz||1)*world;
}
function hazDmg(base){ return base*[0.45,0.75,1][hazDiff()]; }

function hazReset(){
  HAZ.map=(typeof curMap!=='undefined')?curMap:'';
  HAZ.mode=mapHazardMode(HAZ.map);
  HAZ.cells.length=0; HAZ.front=null; HAZ.phase=0; HAZ.warn=0; HAZ.count=0;
  HAZ.faults.length=0; HAZ.vision.length=0; HAZ.lava.length=0;
  /* Nothing happens for the first stretch of any match. A hazard during the
     opening build is just a tax on not knowing the map yet. */
  HAZ.t=hazEvery([150,120,95][hazDiff()]);
  if(HAZ.mode==='calm') HAZ.t=1e9;
  if(typeof uhaz!=='undefined') uhaz.fill(0);
  if(HAZ.mode==='highland') hazSeedFaults();
}
function hazName(){
  const D=mapHazardDef(HAZ.map);
  return (D&&D.nm)||'';
}

/* Apply the blind/slow debuff β€” see uhaz in sim.js, read by movement and by
   weapon range. Buildings are immune; they were not going anywhere anyway. */
function hazBlind(x,y,r,secs){
  const r2=r*r;
  for(let i=0;i<unitHigh;i++){
    if(!ualive[i]) continue;
    if(dist2(ux[i],uy[i],x,y)<r2 && uhaz[i]<secs) uhaz[i]=secs;
  }
}
/* Damage is attributed to team 2 rather than a fourth "environment" team: the
   damage accumulator is a three-slot array and a stray index 3 would quietly
   poison it with NaN. Nothing reads the attacker for kill credit here β€” the
   attacker index is -1 β€” so the only effect is that the hive gets blamed for
   the weather, which is close enough to true on this planet. */
function hazHurt(x,y,r,dmg){
  const r2=r*r;
  for(let i=0;i<unitHigh;i++){
    if(!ualive[i]) continue;
    const d2=dist2(ux[i],uy[i],x,y);
    if(d2<r2) dealDamage(i,dmg*(1-Math.sqrt(d2)/r*0.55),2,-1);
  }
  /* damageBld takes an INDEX into blds, not the object, and it is the only path
     that handles death properly β€” rubble, salvage, territory rebuild. */
  for(let b=0;b<blds.length;b++){
    const B=blds[b];
    if(!B||!B.alive) continue;
    const d2=dist2(B.x,B.y,x,y);
    if(d2<r2) damageBld(b,dmg*(1-Math.sqrt(d2)/r*0.55)*0.6,2);
  }
}

/* ---- VANGUARD VALLEY β€” the dust front -------------------------------------
   A band that crosses the whole map on a fixed heading. It does no damage at
   all: it takes away sight and speed, which on open highway country is the
   thing that actually decides fights. Armies caught mid-march arrive late and
   half-blind; armies that waited lose tempo instead. */
function hazFrontTick(dt){
  const F=HAZ.front;
  if(!F) return;
  F.p+=F.sp*dt;
  const span=Math.hypot(MAP,MAP);
  /* Sample points along the band and blind everything near them. */
  const cx=MAP/2+F.dx*(F.p-span*0.5), cy=MAP/2+F.dy*(F.p-span*0.5);
  F.cx=cx; F.cy=cy;
  const px=-F.dy, py=F.dx;
  for(let k=-6;k<=6;k++){
    const sx=cx+px*k*(MAP/13), sy=cy+py*k*(MAP/13);
    if(sx<-200||sy<-200||sx>MAP+200||sy>MAP+200) continue;
    hazBlind(sx,sy,F.w*0.62,1.1);
    /* Whiteout copy promises a slam after the blind. Dust fronts stay
       sight/speed only β€” damage here is the ice, not the ash. */
    if(HAZ.mode==='whiteout'&&(tick+k)%6===0) hazHurt(sx,sy,F.w*0.42,hazDmg(18));
    if(typeof addParticle==='function' && (tick+k)%2===0 && perfScale>0.35){
      for(let n=0;n<2;n++)
        addParticle(1, sx+rr(-F.w*0.5,F.w*0.5), sy+rr(-F.w*0.45,F.w*0.45),
                    F.dx*26+rr(-8,8), F.dy*26+rr(-8,8), 1.5+Math.random(),
                    52+Math.random()*40, 168,150,116);
    }
  }
  if(F.p>span+F.w){ HAZ.front=null; }
}

/* ---- HIGHLAND SCAR β€” rockslides -------------------------------------------
   Sited by slope, not at random: the hazard belongs to the cliffs, so holding
   the high ground is a real trade rather than a free advantage. */
function hazPickSteep(){
  let best=null, bs=-1;
  for(let t=0;t<40;t++){
    const x=rr(220,MAP-220), y=rr(220,MAP-220);
    if(typeof hAt!=='function') break;
    const h=hAt(x,y);
    if(h<0.02) continue;                                   // not underwater
    const s=Math.abs(hAt(x+34,y)-h)+Math.abs(hAt(x,y+34)-h)
           +Math.abs(hAt(x-34,y)-h)+Math.abs(hAt(x,y-34)-h);
    if(s>bs){ bs=s; best=[x,y]; }
  }
  return bs>0.012?best:null;
}

/* Fault shelves are authored by the live heightfield, not by screen-space
   decoration.  Dormant shelves remain visible before they arm, giving players
   a route-planning choice; the normal hazard warning is still the final clear
   signal to evacuate. */
function hazSeedFaults(){
  const want=5;
  for(let tries=0;HAZ.faults.length<want&&tries<90;tries++){
    const p=hazPickSteep(); if(!p) continue;
    if(typeof farFromStartZones==='function'&&!farFromStartZones(p[0],p[1],430)) continue;
    let clear=true;
    for(const F of HAZ.faults) if(dist2(p[0],p[1],F.x,F.y)<280*280){ clear=false; break; }
    if(clear) HAZ.faults.push({x:p[0],y:p[1],r:105+Math.random()*28,state:0,at:0});
  }
}

/* Fog sources ask this at their own position.  Unit debuffs and actual storm
   geometry now reduce line of sight, rather than merely lowering weapon range
   while the fog map continued to reveal everything. */
function hazVisionMult(x,y,unitIdx){
  let m=1;
  if(unitIdx>=0&&typeof uhaz!=='undefined'&&uhaz[unitIdx]>0) m=0.44;
  const F=HAZ.front;
  if(F&&F.cx!=null){
    const along=Math.abs((x-F.cx)*F.dx+(y-F.cy)*F.dy);
    if(along<F.w*0.64) m=Math.min(m,0.42);
  }
  const now=typeof stats!=='undefined'?stats.t:0;
  for(const V of HAZ.vision){
    if(V.until<=now) continue;
    if(dist2(x,y,V.x,V.y)<V.r*V.r) m=Math.min(m,V.m||0.45);
  }
  return m;
}

/* Low ground just above the water table β€” floodplains and channels β€” without
   calling deformTerrain / stampWaterRipple. Those belong to the crater-water
   sibling; this strike is a combat tax, not a mesh rewrite. */
function hazPickChannel(){
  const wh=typeof WATER_H==='number'?WATER_H:0.335;
  let best=null, bs=1e9;
  if(typeof hAt!=='function') return null;
  for(let t=0;t<50;t++){
    const x=rr(200,MAP-200), y=rr(200,MAP-200);
    const h=hAt(x,y);
    if(h<wh-0.008||h>wh+0.048) continue;
    const score=Math.abs(h-wh);
    if(score<bs){ bs=score; best=[x,y]; }
  }
  return best;
}

/* ---- SHATTERED ISLES β€” squall lightning -----------------------------------
   Earths through the largest mass under the cell. Massing an army in one place
   on an ocean map is exactly what you want to do and exactly what gets hit. */
function hazPickMass(){
  let best=null, bs=0;
  const step=Math.max(1,unitHigh/220|0);
  for(let i=0;i<unitHigh;i+=step){
    if(!ualive[i]||uteam[i]===2) continue;
    let n=0;
    for(let j=0;j<unitHigh;j+=step) if(ualive[j]&&dist2(ux[i],uy[i],ux[j],uy[j])<170*170) n++;
    if(n>bs){ bs=n; best=[ux[i],uy[i]]; }
  }
  for(const B of bldLive){
    if(!B.alive||B.team===2||B.prog<1) continue;
    let n=1.5;
    for(const C of bldLive) if(C.alive&&dist2(B.x,B.y,C.x,C.y)<200*200) n+=1.2;
    if(n>bs){ bs=n; best=[B.x,B.y]; }
  }
  return bs>=3?best:null;
}

function hazTick(dt){
  if(typeof running==='undefined'||!running||demoMode||gameEnded) return;
  if(HAZ.map!==curMap) hazReset();
  const D=hazDiff();
  const now=typeof stats!=='undefined'?stats.t:0;
  for(let i=HAZ.vision.length-1;i>=0;i--) if(HAZ.vision[i].until<=now) HAZ.vision.splice(i,1);

  /* Eruptions leave readable, persistent danger instead of becoming a single
     orange explosion that players can immediately forget. Damage pulses are
     bounded and the pool expires, so it cannot permanently wall off a route. */
  for(let i=HAZ.lava.length-1;i>=0;i--){
    const L=HAZ.lava[i]; L.life-=dt; L.pulse-=dt;
    if(L.life<=0){ HAZ.lava.splice(i,1); continue; }
    if(L.pulse<=0){ L.pulse=.78; hazHurt(L.x,L.y,L.r,hazDmg(34)); }
    if(typeof addParticle==='function'&&perfScale>.32&&Math.random()<dt*9){
      const a=Math.random()*TAU, rr0=Math.sqrt(Math.random())*L.r;
      addParticle(Math.random()<.42?0:1,L.x+Math.cos(a)*rr0,L.y+Math.sin(a)*rr0,
                  rr(-3,3),rr(-18,-5),.5+Math.random()*.7,8+Math.random()*14,255,104,30);
    }
  }

  if(HAZ.front) hazFrontTick(dt);

  /* Telegraph phase: markers are on the ground, damage has not landed yet. */
  if(HAZ.warn>0){
    HAZ.warn-=dt;
    for(const c of HAZ.cells){
      if(typeof addParticle==='function' && (tick%3)===0 && perfScale>0.35)
        addParticle(0, c[0]+rr(-c[2],c[2]), c[1]+rr(-c[2],c[2]), 0,-14, .5, 8,
                    c[3][0],c[3][1],c[3][2]);
    }
    if(HAZ.warn<=0){ hazStrike(); HAZ.cells.length=0; }
    return;
  }

  HAZ.t-=dt;
  if(HAZ.t>0) return;

  if(HAZ.mode==='vanguard'||HAZ.mode==='whiteout'||HAZ.mode==='spore_bloom'||HAZ.mode==='spores'){
    HAZ.t=hazEvery(115+Math.random()*50);
    const a=Math.random()*TAU;
    HAZ.front={dx:Math.cos(a),dy:Math.sin(a),p:0,
               sp:118+Math.random()*40, w:[300,400,480][D]};
    HAZ.count++;
    const Dfn=mapHazardDef(HAZ.map);
    toast((Dfn&&Dfn.em||'πŸŒͺ')+' '+(Dfn&&Dfn.nm||'FRONT')+' rolling in β€” sight and speed cut inside it');
    sfx('alarm');
  }
  else if(HAZ.mode==='calm'){
    HAZ.t=1e9;
  }
  else if(HAZ.mode==='heat'){
    HAZ.t=hazEvery(90+Math.random()*35);
    HAZ.cells.length=0;
    const n=[2,3,4][D];
    for(let k=0;k<n;k++){
      const p=[rr(200,MAP-200),rr(200,MAP-200)];
      HAZ.cells.push([p[0],p[1],90+Math.random()*30,[255,180,60]]);
    }
    HAZ.warn=3.5; HAZ.phase=5;
    for(const c of HAZ.cells) mmPing(c[0],c[1]);
    toast('πŸŒ… SOLAR FOCUS BEAM DISPATCHED β€” clear the illuminated corridor');
    sfx('alarm');
  }
  else if(HAZ.mode==='highland'){
    HAZ.t=hazEvery(95+Math.random()*40);
    HAZ.cells.length=0;
    const n=[1,2,3][D];
    for(let k=0;k<n;k++){
      let F=HAZ.faults.find(q=>q.state===0);
      if(!F){ hazSeedFaults(); F=HAZ.faults.find(q=>q.state===0); }
      if(F){
        F.state=1; F.at=now;
        const c=[F.x,F.y,F.r,[220,176,98]]; c.fault=F; HAZ.cells.push(c);
      }
    }
    if(!HAZ.cells.length){ HAZ.t=20; return; }
    HAZ.warn=4.8; HAZ.phase=1;
    for(const c of HAZ.cells) mmPing(c[0],c[1]);
    toast('β›° FAULT COLLAPSE β€” cracked shelves are giving way, clear the marked ground');
    sfx('alarm');
  }
  else if(HAZ.mode==='eruption'){
    HAZ.t=hazEvery(82+Math.random()*38);
    HAZ.cells.length=0;
    const n=[1,2,3][D];
    for(let k=0;k<n;k++){
      const p=hazPickSteep()||[rr(240,MAP-240),rr(240,MAP-240)];
      HAZ.cells.push([p[0],p[1],86+Math.random()*24,[255,92,28]]);
    }
    HAZ.warn=4.4; HAZ.phase=4;
    for(const c of HAZ.cells) mmPing(c[0],c[1]);
    toast('πŸŒ‹ MAGMA PRESSURE RISING β€” evacuate the marked vents');
    sfx('alarm');
  }
  else if(HAZ.mode==='flood'){
    HAZ.t=hazEvery(85+Math.random()*35);
    HAZ.cells.length=0;
    const n=[2,3,4][D];
    for(let k=0;k<n;k++){
      const p=hazPickChannel()||[rr(220,MAP-220),rr(220,MAP-220)];
      HAZ.cells.push([p[0],p[1],88+Math.random()*24,[70,190,220]]);
    }
    HAZ.warn=3.8; HAZ.phase=7;
    for(const c of HAZ.cells) mmPing(c[0],c[1]);
    toast('≋ FLOOD SURGE β€” channels are rising, clear the marked low ground');
    sfx('alarm');
  }
  else if(HAZ.mode==='isles'){
    HAZ.t=hazEvery(80+Math.random()*35);
    HAZ.cells.length=0;
    const n=[2,3,5][D];
    for(let k=0;k<n;k++){
      const p=hazPickMass();
      if(p) HAZ.cells.push([clamp(p[0]+rr(-70,70),40,MAP-40),
                            clamp(p[1]+rr(-70,70),40,MAP-40),70,[150,225,255]]);
    }
    if(!HAZ.cells.length){ HAZ.t=18; return; }
    HAZ.warn=2.6; HAZ.phase=2;
    mmPing(HAZ.cells[0][0],HAZ.cells[0][1]);
    toast('⚑ Squall line overhead β€” lightning earthing through massed armour');
    sfx('alarm');
  }
  else if(HAZ.mode==='meteor'){
    HAZ.t=hazEvery(88+Math.random()*40);
    HAZ.cells.length=0;
    const n=[2,3,4][D];
    for(let k=0;k<n;k++){
      const p=[rr(220,MAP-220),rr(220,MAP-220)];
      HAZ.cells.push([p[0],p[1],80+Math.random()*28,[210,230,255]]);
    }
    HAZ.warn=3.8; HAZ.phase=6;
    for(const c of HAZ.cells) mmPing(c[0],c[1]);
    toast('β˜„ ORBITAL DEBRIS INBOUND β€” clear the marked impact lanes');
    sfx('alarm');
  }
  else if(HAZ.mode==='crater'){
    HAZ.t=hazEvery(125+Math.random()*45);
    HAZ.cells=[[MAP/2,MAP/2,[300,380,460][D],[200,160,255]]];
    HAZ.warn=4.0; HAZ.phase=3;
    mmPing(MAP/2,MAP/2);
    toast('β—ˆ The relic is waking β€” pulse building at the basin floor');
    sfx('alarm');
  }
  else {
    /* Unknown mode used to wait 60s forever and never strike. Treat it as a
       front so an authored MAPDEFS.hazard the profiles missed still plays. */
    HAZ.mode='vanguard'; HAZ.t=0;
  }
}

function hazStrike(){
  const D=hazDiff();
  HAZ.count++;
  if(HAZ.phase===1){                                    // rockslide
    for(const c of HAZ.cells){
      if(c.fault){ c.fault.state=2; c.fault.at=stats.t; }
      spawnExplosion(c[0],c[1],c[2]*0.5,2);
      hazHurt(c[0],c[1],c[2],hazDmg(210));
      hazBlind(c[0],c[1],c[2]*1.2,2.2);
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.2,until:stats.t+3.2,m:0.48});
      /* A collapse is terrain, not a decal: the deeper bowl is processed by
         the existing deformation queue, re-lights the mesh and updates the
         passability grid if the shelf tears below the water table. */
      if(typeof deformTerrain==='function') deformTerrain(c[0],c[1],c[2]*0.78,0.105);
      if(typeof addParticle==='function')
        for(let k=0;k<14;k++)
          addParticle(1,c[0]+rr(-c[2]*0.6,c[2]*0.6),c[1]+rr(-c[2]*0.6,c[2]*0.6),
                      rr(-14,14),rr(-24,-6),1.8,44+Math.random()*40, 150,136,116);
    }
    shake=Math.max(shake,5);
  }
  else if(HAZ.phase===2){                               // lightning
    for(const c of HAZ.cells){
      spawnExplosion(c[0],c[1],c[2]*0.7,2);
      hazHurt(c[0],c[1],c[2],hazDmg(260));
      hazBlind(c[0],c[1],c[2]*1.4,1.6);                 // EMP dazzle
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.45,until:stats.t+2.4,m:0.36});
      if(typeof addParticle==='function')
        for(let k=0;k<10;k++)
          addParticle(0,c[0]+rr(-c[2]*0.5,c[2]*0.5),c[1]+rr(-c[2]*0.5,c[2]*0.5),
                      0,rr(-40,-10),.45,10, 190,240,255);
    }
    shake=Math.max(shake,4);
    if(typeof flashScreen==='function'&&HAZ.cells.length>2) flashScreen();
  }
  else if(HAZ.phase===3){                               // relic pulse
    const c=HAZ.cells[0];
    spawnExplosion(c[0],c[1],c[2]*0.4,2);
    hazHurt(c[0],c[1],c[2],hazDmg(150));
    hazBlind(c[0],c[1],c[2]*1.15,4.5);                  // long systems blackout
    HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.15,until:stats.t+5.2,m:0.32});
    /* It eats power too β€” the basin punishes anyone who parks their grid on it. */
    for(let t=0;t<2;t++) if(typeof resE!=='undefined') resE[t]=Math.max(0,resE[t]*0.88);
    if(typeof addParticle==='function')
      for(let k=0;k<20;k++){
        const a=Math.random()*TAU;
        addParticle(0,c[0]+Math.cos(a)*c[2]*0.5,c[1]+Math.sin(a)*c[2]*0.5,
                    Math.cos(a)*70,Math.sin(a)*70,.7,13, 200,150,255);
      }
    shake=Math.max(shake,7);
    toast('β—ˆ RELIC PULSE β€” systems down across the basin');
  }
  else if(HAZ.phase===4){                               // magma eruption
    for(const c of HAZ.cells){
      spawnExplosion(c[0],c[1],c[2]*.75,2);
      hazHurt(c[0],c[1],c[2]*1.08,hazDmg(320));
      hazBlind(c[0],c[1],c[2]*1.3,2.4);
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.35,until:stats.t+4,m:.5});
      HAZ.lava.push({x:c[0],y:c[1],r:c[2]*.82,life:34+Math.random()*14,pulse:.2});
      if(typeof deformTerrain==='function') deformTerrain(c[0],c[1],c[2]*.65,.055);
      if(typeof addParticle==='function') for(let k=0;k<28;k++){
        const a=Math.random()*TAU;
        addParticle(k%3?0:1,c[0]+Math.cos(a)*rr(0,c[2]*.5),c[1]+Math.sin(a)*rr(0,c[2]*.5),
                    Math.cos(a)*rr(12,55),Math.sin(a)*rr(12,55),.6+Math.random(),
                    10+Math.random()*22,255,92,24);
      }
    }
    shake=Math.max(shake,11);
    if(typeof flashScreen==='function') flashScreen();
    toast('πŸŒ‹ MAGMA ERUPTION β€” molten ground remains lethal');
    if(typeof sfx==='function'&&HAZ.cells.length) sfx('boom',HAZ.cells[0][0],HAZ.cells[0][1],2.2);
  }
  else if(HAZ.phase===5){                               // thermal / solray
    for(const c of HAZ.cells){
      spawnExplosion(c[0],c[1],c[2]*0.55,2);
      hazHurt(c[0],c[1],c[2],hazDmg(180));
      hazBlind(c[0],c[1],c[2]*1.15,2.8);
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.2,until:stats.t+3.4,m:0.4});
    }
    shake=Math.max(shake,4);
    toast('♨ THERMAL SURGE β€” exposed ground is blinding');
  }
  else if(HAZ.phase===6){                               // orbital debris
    for(const c of HAZ.cells){
      spawnExplosion(c[0],c[1],c[2]*0.7,2);
      hazHurt(c[0],c[1],c[2],hazDmg(240));
      hazBlind(c[0],c[1],c[2]*1.25,1.8);
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.3,until:stats.t+2.6,m:0.38});
      if(typeof addParticle==='function')
        for(let k=0;k<12;k++)
          addParticle(0,c[0]+rr(-c[2]*0.5,c[2]*0.5),c[1]+rr(-c[2]*0.5,c[2]*0.5),
                      0,rr(-50,-12),.5,12, 200,230,255);
    }
    shake=Math.max(shake,6);
    if(typeof flashScreen==='function') flashScreen();
    toast('β˜„ DEBRIS STRIKE β€” non-organic weather from orbit');
  }
  else if(HAZ.phase===7){                               // flood surge
    for(const c of HAZ.cells){
      spawnExplosion(c[0],c[1],c[2]*0.55,2);
      hazHurt(c[0],c[1],c[2],hazDmg(190));
      hazBlind(c[0],c[1],c[2]*1.35,3.6);
      HAZ.vision.push({x:c[0],y:c[1],r:c[2]*1.4,until:stats.t+4.2,m:0.40});
      if(typeof addParticle==='function')
        for(let k=0;k<14;k++)
          addParticle(1,c[0]+rr(-c[2]*0.55,c[2]*0.55),c[1]+rr(-c[2]*0.55,c[2]*0.55),
                      rr(-10,10),rr(-28,-4),1.2,28+Math.random()*22, 70,190,220);
    }
    shake=Math.max(shake,5);
    toast('≋ SURGE HIT β€” low ground is isolated');
  }
  HAZ.phase=0;
}