Akshaykumarbm commited on
Commit
1123bef
·
verified ·
1 Parent(s): 1f2f7b2

Upload folder using huggingface_hub

Browse files
DESIGN_REFERENCE.md ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pyre — Design Reference for React Implementation
2
+
3
+ > Extracted from `frontend_new_desgin/index.html`, `js/renderer.js`, `js/sim.js`, and `js/app.js`.
4
+ > Goal: 16 × 16 grid with per-agent **footprint trail** and **real-time health arc**.
5
+
6
+ ---
7
+
8
+ ## 1. Design Tokens (CSS Custom Properties → JS constants)
9
+
10
+ ```ts
11
+ // Surfaces
12
+ const SURFACE = {
13
+ bg: '#f5f2ed', // page background — warm off-white
14
+ panel: '#ffffff', // card / side panel
15
+ inset: '#faf9f6', // subtle inset (chart bg)
16
+ hover: '#f0ede8',
17
+ border: '#e4dfd7',
18
+ borderStrong: '#cdc7be',
19
+ };
20
+
21
+ // Text
22
+ const TEXT = {
23
+ primary: '#1c1712',
24
+ secondary: '#6b6460',
25
+ muted: '#a8a29e',
26
+ };
27
+
28
+ // Semantic
29
+ const SEMANTIC = {
30
+ fire: '#c2410c',
31
+ fireBg: 'rgba(194,65,12,0.08)',
32
+ blue: '#1d4ed8',
33
+ blueBg: 'rgba(29,78,216,0.08)',
34
+ green: '#166534',
35
+ greenBg: 'rgba(22,101,52,0.08)',
36
+ amber: '#92400e',
37
+ amberBg: 'rgba(146,64,14,0.08)',
38
+ red: '#b91c1c',
39
+ redBg: 'rgba(185,28,28,0.08)',
40
+ };
41
+
42
+ // Canvas dark-world palette (inside the grid canvas)
43
+ const CANVAS_COLORS = {
44
+ bg: '#1e242c', // dark slate background
45
+ wallBase: '#4f443b',
46
+ floorVis: '#302620',
47
+ obstBase: '#140d0a',
48
+ exitBase: '#062210',
49
+ exitGlow: '#22c55e',
50
+ agentCore: '#0ea5e9', // sky-blue (healthy)
51
+ agentGlow: '#38bdf8',
52
+ agentBright:'#e0f2fe',
53
+ fogSeen: 'rgba(30,36,44,0.30)', // explored but out of view
54
+ fogUnseen: 'rgba(20,24,30,0.95)', // never seen
55
+ };
56
+
57
+ // Typography
58
+ const FONTS = {
59
+ mono: "'DM Mono', 'JetBrains Mono', monospace",
60
+ sans: "'DM Sans', 'Inter', system-ui, sans-serif",
61
+ };
62
+ ```
63
+
64
+ ---
65
+
66
+ ## 2. Overall Layout
67
+
68
+ ```
69
+ ┌──────────────────────── 44px topbar ─────────────────────────┐
70
+ │ 🔥 Pyre | Demo/Live | Easy/Medium/Hard [Live chip] │
71
+ ├─────────────────────────────────┬────────────────────────────┤
72
+ │ │ 320px side panel │
73
+ │ canvas-zone (centered) │ ─ Episode stats │
74
+ │ aspect-ratio: 1 │ ─ Reward chart │
75
+ │ max-size fills available │ ─ Fire size chart │
76
+ │ space after header+footer │ ─ Action histogram │
77
+ │ │ ─ Event log │
78
+ ├──────────────────────── 48px botbar ─────────────────────────┤
79
+ │ ⏮ ▶ ⏭ ↺ | ½× 1× 2× 4× 8× | Seed [42] kbd hints │
80
+ └──────────────────────────────────────────────────────────────┘
81
+ ```
82
+
83
+ ### Key layout CSS
84
+ ```css
85
+ .shell {
86
+ display: grid;
87
+ grid-template-rows: 44px 1fr 48px;
88
+ height: 100vh;
89
+ min-width: 1280px;
90
+ }
91
+ .content {
92
+ display: grid;
93
+ grid-template-columns: 1fr 320px;
94
+ }
95
+ /* Canvas aspect-ratio 1:1, fills available area */
96
+ .canvas-frame {
97
+ aspect-ratio: 1;
98
+ max-width: min(calc(100vh - 140px), calc(100vw - 360px));
99
+ border-radius: 8px;
100
+ box-shadow: 0 0 0 1px #cdc7be, 0 20px 60px rgba(0,0,0,.18), 0 4px 12px rgba(0,0,0,.10);
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 3. The 16 × 16 Grid
107
+
108
+ ### Cell Types (integer constants)
109
+ | Value | Name | Description |
110
+ |-------|-------------|-------------|
111
+ | 0 | `FLOOR` | Walkable tile |
112
+ | 1 | `WALL` | Impassable solid |
113
+ | 2 | `DOOR_OPEN` | Open doorway |
114
+ | 3 | `DOOR_CLOSED`| Closed door (blocks movement & slows fire) |
115
+ | 4 | `EXIT` | Evacuation point |
116
+ | 5 | `OBSTACLE` | Charred / impassable ruin |
117
+
118
+ ### Grid storage
119
+ ```ts
120
+ // Flat array, row-major: index = y * W + x
121
+ const cellGrid = new Array(16 * 16); // cell types
122
+ const fireGrid = new Float32Array(256); // 0–1 fire intensity
123
+ const smokeGrid = new Float32Array(256);// 0–1 smoke density
124
+ ```
125
+
126
+ ### Cell pixel size
127
+ ```ts
128
+ const CANVAS_SIZE = 576; // px (the <canvas> width & height)
129
+ const GRID = 16;
130
+ const cellSize = Math.floor(CANVAS_SIZE / GRID); // = 36px per cell
131
+ ```
132
+
133
+ ---
134
+
135
+ ## 4. Per-Cell Visual Design
136
+
137
+ ### FLOOR tile
138
+ ```
139
+ Background: #302620 (dark warm brown)
140
+ Checkered accent (even x+y): rgba(255,248,230, 0.035)
141
+ Grout lines (top edge + left edge of each cell):
142
+ color: rgba(0,0,0,0.3), 1px wide
143
+ Inner corner detail:
144
+ 2px inset shadow strip rgba(0,0,0,0.06) at top-left
145
+ ```
146
+
147
+ ### WALL tile *(pseudo-3D isometric look)*
148
+ ```
149
+ 3D extrusion height: 8px upward
150
+ Front shadow face (bottom): rgba(0,0,0,0.60), height 8px
151
+ Right-side drop shadow: rgba(0,0,0,0.30), width 3px
152
+ Top face: #4f443b
153
+ Top catch-light (top edge 3px): rgba(255,248,232, 0.25)
154
+ Left highlight (2px strip): rgba(255,248,232, 0.15)
155
+ Mortar texture lines (3 horizontal + vertical splits):
156
+ color: rgba(0,0,0,0.20), 1px
157
+ ```
158
+
159
+ ### OBSTACLE tile *(charred ruin)*
160
+ ```
161
+ 3D elevation: 4px upward
162
+ Front face shadow: #0a0705
163
+ Top face: #140d0a
164
+ Diagonal char texture: 45° stripes rgba(0,0,0,0.6) every 5px
165
+ Ember edge glow (1px border): rgba(255,80,0, 0.35)
166
+ ```
167
+
168
+ ### EXIT tile
169
+ ```
170
+ Base fill: #062210
171
+ Subtle green tint: rgba(34,197,94, 0.07) inset 2px
172
+ Animated outer glow ring:
173
+ pulse = 0.62 + 0.38 * sin(time * 2.8)
174
+ shadowBlur: cellSize * 1.1 * pulse
175
+ strokeStyle: #22c55e (blocked: #ef4444)
176
+ Center symbol: ⇥ (blocked: ✕) font bold 50% of cellSize
177
+ Corner bracket accents: rgba(74,222,128, 0.75)
178
+ ```
179
+
180
+ ### DOOR_CLOSED tile
181
+ ```
182
+ Frame: #291508 (2px rails top/bottom, 2px stiles left/right)
183
+ Door body wood: #5c3621
184
+ Three horizontal panel lines: rgba(0,0,0,0.35) at 14%, 50%, 82% height
185
+ Panel inset shadow: rgba(0,0,0,0.13)
186
+ Doorknob: gold circle rgba(210,168,75,0.88), radius 2.5px at right center
187
+ ```
188
+
189
+ ### DOOR_OPEN tile
190
+ ```
191
+ Dark passage: rgba(0,0,0,0.38)
192
+ Door panel pressed to left: #5c3621, width 4px
193
+ Top frame: #291508, height 2px
194
+ Faint blue passage glow: rgba(125,211,252,0.10)
195
+ ```
196
+
197
+ ---
198
+
199
+ ## 5. Fire Rendering (layered radial gradients)
200
+
201
+ ### Fire color ramp (`fireColor(t, alpha)`)
202
+ ```
203
+ t=0.00→0.15: black → deep red #620600 → #ba0600
204
+ t=0.15→0.40: deep red → orange → #f63600
205
+ t=0.40→0.65: orange → yellow → #f6b200
206
+ t=0.65→0.85: yellow → bright → #ffcd29
207
+ t=0.85→1.00: bright → white-hot → #ffffd3
208
+ ```
209
+
210
+ ### Per-cell fire: 4 volumetric gradient layers
211
+ ```
212
+ Each frame: flicker factor f1 = 0.82 + 0.18*sin(time*9.5 + x*2.7 + y*3.14)
213
+ Wind offsets: windDx = windVec[0] * cellSize * 0.35 * intensity
214
+
215
+ Layer 1 (bright core): radius=cs*0.35, alpha=0.98, offset=(dx*0.2, dy*0.2 - cs*0.1)
216
+ Layer 2 (inner flame): radius=cs*0.65, alpha=0.75, offset=(dx*0.5, dy*0.5)
217
+ Layer 3 (flame body): radius=cs*0.95, alpha=0.45, offset=(dx, dy)
218
+ Layer 4 (heat glow): radius=cs*1.40, alpha=0.18, offset=(dx*1.5, dy*1.5)
219
+
220
+ Blend mode: 'screen' (Canvas2D globalCompositeOperation)
221
+
222
+ White-hot core when intensity > 0.48:
223
+ radius=cs*0.17, rgba(255,255,230, (t-0.48)*1.7) → transparent
224
+ ```
225
+
226
+ ### Ambient fire glow (normal blend, under everything)
227
+ ```
228
+ Applied when fire ≥ 0.28
229
+ Large radial gradient on floor:
230
+ radius = cellSize * (2.8 + fire * 2.0)
231
+ center: rgba(255,100,10, fire*0.65)
232
+ mid: rgba(200,60,0, fire*0.325)
233
+ edge: transparent
234
+ ```
235
+
236
+ ---
237
+
238
+ ## 6. Smoke Rendering
239
+
240
+ ```
241
+ Smoke color: rgb(200, 210, 220) — bright thick white/grey
242
+ Animated offset:
243
+ phase[x,y] = random seed per cell (0..2π)
244
+ dx = sin(time*0.35 + phase) * 1.8
245
+ dy = cos(time*0.28 + phase + 1.2) * 1.8
246
+
247
+ Base puff:
248
+ radialGradient at (center + offset), radius = cellSize * 0.85
249
+ alpha = min(0.85, smoke * 0.95) → 0 at edge
250
+
251
+ Secondary density puff (when smoke > 0.3):
252
+ radius = cellSize * 0.60
253
+ offset orthogonal to primary: (dy*1.5, dx*1.5)
254
+ lighter tint rgb(215,225,240), alpha*0.5
255
+ ```
256
+
257
+ ---
258
+
259
+ ## 7. Fog of War
260
+
261
+ ```
262
+ Visibility radius (BFS from agent position):
263
+ smoke ≤ 0.2 → radius 5 cells
264
+ smoke 0.2–0.5 → radius 3 cells
265
+ smoke > 0.5 → radius 2 cells
266
+
267
+ Rendering (drawn over all cells outside visible set):
268
+ Explored but not currently visible: rgba(30,36,44, 0.30) [semi-transparent]
269
+ Never explored: rgba(20,24,30, 0.95) [nearly opaque]
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 8. Agent Rendering — Body
275
+
276
+ ```tsx
277
+ // All coordinates: agentPixelX = agentX * cellSize + cellSize/2
278
+
279
+ const pulse = 0.82 + 0.18 * sin(time * 3.6) // slow body pulse
280
+ const pulse2 = 0.70 + 0.30 * sin(time * 2.1 + 1.0) // aura pulse
281
+ const r = cellSize * 0.38 * pulse // body radius
282
+
283
+ // Color by health ratio (health/100):
284
+ health > 60% → #0ea5e9 (sky blue)
285
+ health > 30% → #f59e0b (amber)
286
+ health ≤ 30% → #ef4444 (red)
287
+ evacuated → #4ade80 (green)
288
+ ```
289
+
290
+ ### Drawing order (bottom to top):
291
+ 1. **Outer aura** — faint radial gradient, radius `cellSize*0.74*pulse2`
292
+ - center→edge: transparent → `rgba(56,189,248,0.09)` → transparent
293
+ 2. **Drop shadow** — dark ellipse offset (+2, +3), width `r*0.9`, height `r*0.48`
294
+ 3. **Glow** — `ctx.shadowBlur = cellSize*0.90`, `shadowColor = agentColor`
295
+ 4. **Body sphere** — radial gradient from highlight offset `(-r*0.28, -r*0.28)`:
296
+ ```
297
+ stop 0.00: #ffffff (specular highlight)
298
+ stop 0.32: #e0f2fe (bright rim)
299
+ stop 0.70: agentColor (body color)
300
+ stop 1.00: rgba(0,80,160,0.55)
301
+ ```
302
+ 5. **Outline** — `rgba(255,255,255,0.88)`, lineWidth 1.5
303
+
304
+ ---
305
+
306
+ ## 9. Agent Health Arc Ring ← **Key feature**
307
+
308
+ Drawn around the agent body every frame:
309
+
310
+ ```tsx
311
+ const arcRadius = bodyRadius * 1.65;
312
+
313
+ // Track (always visible, faint):
314
+ ctx.arc(px, py, arcRadius, 0, Math.PI*2);
315
+ ctx.strokeStyle = 'rgba(255,255,255,0.07)';
316
+ ctx.lineWidth = 2.5;
317
+
318
+ // Filled arc (health portion, clockwise from top):
319
+ const startAngle = -Math.PI / 2; // 12 o'clock
320
+ const endAngle = startAngle + (health/100) * Math.PI * 2;
321
+ ctx.arc(px, py, arcRadius, startAngle, endAngle);
322
+ ctx.lineCap = 'round';
323
+ ctx.lineWidth = 3.0;
324
+
325
+ // Arc color by health:
326
+ health > 60%: '#4ade80' (green)
327
+ health > 30%: '#fbbf24' (amber)
328
+ health ≤ 30%: '#f87171' (red)
329
+
330
+ // Arc glow (drawn twice):
331
+ ctx.shadowBlur = 8;
332
+ ctx.shadowColor = arcColor;
333
+ ctx.stroke(); // called twice for glow effect
334
+ ```
335
+
336
+ **In React**, implement as an SVG circle or Canvas arc that updates every animation frame with `requestAnimationFrame`.
337
+
338
+ ---
339
+
340
+ ## 10. Agent Footprint Trail ← **Key feature**
341
+
342
+ The trail records the last **14 positions** of the agent.
343
+
344
+ ```tsx
345
+ // agentTrail: Array<{px: number, py: number, t: number}>
346
+ // Updated every frame: push new position to front if moved > 1px
347
+
348
+ // Drawing (older = more transparent, smaller):
349
+ for (let i = 1; i < trail.length; i++) {
350
+ const alpha = (1 - i / trail.length) * 0.42; // fades out
351
+ const radius = Math.max(1.5, (1 - i / trail.length) * cellSize * 0.22);
352
+
353
+ ctx.beginPath();
354
+ ctx.arc(trail[i].px, trail[i].py, radius, 0, Math.PI * 2);
355
+ ctx.fillStyle = `rgba(14, 165, 233, ${alpha})`; // sky-blue trail dots
356
+ ctx.fill();
357
+ }
358
+ ```
359
+
360
+ **Visual result**: A dotted comet-tail of fading sky-blue circles showing the agent's recent path. Each dot: 42% max opacity, fading to ~3% at the tail. Radius shrinks from `cellSize*0.22` to 1.5px.
361
+
362
+ **In React**: Maintain a `trailPositions` state array (length ≤ 14). Push current `[x,y]` each step. Render as absolutely-positioned `div` circles or SVG `<circle>` elements on top of the grid.
363
+
364
+ ---
365
+
366
+ ## 11. HUD Overlays (float above canvas)
367
+
368
+ ### Top-Left HUD Card — Health
369
+ ```
370
+ Background: rgba(14,10,8, 0.82)
371
+ Border: 1px rgba(255,255,255,0.10)
372
+ Border-radius: 6px
373
+ Padding: 8px 11px
374
+ Backdrop-filter: blur(8px)
375
+ Text color: #e8e0d8
376
+
377
+ Layout:
378
+ Row 1: [HP label] [progress bar 80px] [numeric value]
379
+ Row 2: [status text] [smoke level text right-aligned]
380
+
381
+ HP bar:
382
+ bg: rgba(255,255,255,0.08), height 4px, radius 2px
383
+ fill class "g" → background: #4ade80
384
+ fill class "m" → background: #fbbf24
385
+ fill class "c" → background: #f87171 + animation: blink 0.5s infinite alternate
386
+ @keyframes blink { to { opacity: 0.3 } }
387
+
388
+ Status text colors:
389
+ "Good" → #4ade80
390
+ "Moderate" → #fbbf24
391
+ "Low" → #fb923c
392
+ "Critical" → #f87171
393
+
394
+ Health thresholds:
395
+ > 75 → "Good"
396
+ > 50 → "Moderate"
397
+ > 25 → "Low"
398
+ ≤ 25 → "Critical"
399
+ ```
400
+
401
+ ### Top-Right HUD Card — Step Counter
402
+ ```
403
+ Same card style, text right-aligned
404
+
405
+ Step display: "42 / 150" color #93c5fd (light blue)
406
+ Progress bar: 90px wide, 3px high, background #60a5fa
407
+ Metadata line: "medium · small_office" color rgba(168,162,158,0.6)
408
+ ```
409
+
410
+ ### Bottom-Center Episode Banner
411
+ ```
412
+ Position: absolute, bottom 14px, horizontally centered
413
+ Font: DM Mono 13px, font-weight 500
414
+
415
+ EVACUATED: bg rgba(21,128,61,0.80), border rgba(74,222,128,.3), text #dcfce7 → "✓ EVACUATED"
416
+ INCAPACITATED: bg rgba(153,27,27,0.80), border rgba(248,113,113,.3), text #fee2e2 → "✗ INCAPACITATED"
417
+ TIMEOUT: bg rgba(120,53,15,0.80), border rgba(251,191,36,.3), text #fef3c7 → "⏱ TIMEOUT"
418
+ ```
419
+
420
+ ### Bottom-Right Wind Rose
421
+ ```
422
+ Position: absolute, bottom 10px, right 10px
423
+ Size: 68×68px canvas, circular shape
424
+ Background: rgba(14,10,8,0.80)
425
+ Border: 1px rgba(255,255,255,0.10)
426
+ border-radius: 50%
427
+
428
+ Compass ring: rgba(120,140,220,0.28), lineWidth 1.5
429
+ Cardinal labels N/E/S/W: rgba(160,172,232,0.55), 10.9px monospace
430
+ Arrow: rgba(251,191,36,0.95), lineWidth 2.5, arrowhead filled
431
+ Center label: current wind direction (e.g. "NE"), bold 12px, rgba(255,210,80,0.95)
432
+ CALM: "CALM" label, rgba(160,182,232,0.75)
433
+ ```
434
+
435
+ ---
436
+
437
+ ## 12. Side Panel
438
+
439
+ ```
440
+ Width: 320px
441
+ Background: #ffffff
442
+ Border-left: 1px #e4dfd7
443
+ Scrollbar: 3px wide, thumb #e4dfd7
444
+ ```
445
+
446
+ ### Section Header style
447
+ ```css
448
+ font-family: DM Mono;
449
+ font-size: 9px;
450
+ font-weight: 500;
451
+ letter-spacing: 0.12em;
452
+ text-transform: uppercase;
453
+ color: #a8a29e;
454
+ ```
455
+
456
+ ### Stat Cards (2-column grid)
457
+ ```
458
+ 2 columns, gap 6px
459
+ Each card: bg #faf9f6, border 1px #e4dfd7, border-radius 6px, padding 8px 10px
460
+ Label: 10px, color #a8a29e
461
+ Value: DM Mono 14px 500, color varies:
462
+ fire → #c2410c
463
+ blue → #1d4ed8
464
+ amber → #92400e
465
+ ```
466
+
467
+ ### Stat values displayed
468
+ ```
469
+ Reward: totalReward.toFixed(2) class=blue
470
+ Burned: percentBurned + "%" class=fire
471
+ Wind: windDir (e.g. "NE") class=amber
472
+ Humidity: humidity*100 + "%"
473
+ Spread ρ: pSpread.toFixed(2) color=fire
474
+ ```
475
+
476
+ ### Mini Line Charts
477
+ ```
478
+ bg: #faf9f6
479
+ Grid lines: rgba(0,0,0,0.06), 0.5px, 4 horizontal rows
480
+ Line width: 1.8px
481
+ Endpoint dot: 3px radius circle, same color as line
482
+ Value label: 10px monospace, right-aligned above dot
483
+
484
+ Reward chart: color #1d4ed8, fill rgba(29,78,216,0.08)
485
+ Fire chart: color #c2410c, fill rgba(194,65,12,0.08)
486
+ ```
487
+
488
+ ### Action Distribution Histogram
489
+ ```
490
+ Horizontal bars, labels on left (54px), bars from 60px
491
+ Bar height: floor((height-8) / numActions) - 2
492
+ Bar bg: rgba(0,0,0,0.05)
493
+
494
+ Colors:
495
+ move → #1d4ed8
496
+ door → #c2410c
497
+ wait → #7c3aed
498
+ look → #166534
499
+
500
+ Count label: right of bar, same color
501
+ ```
502
+
503
+ ### Event Log
504
+ ```
505
+ Each row: flex, gap 7px, DM Mono 10px
506
+ Step number: 9px, min-width 22px, color #a8a29e
507
+ Text: 10px, color #6b6460, flex:1, line-height 1.4
508
+ Reward delta: 9px, min-width 32px, right-aligned
509
+ positive → #166534
510
+ negative → #b91c1c
511
+
512
+ Alarm rows (burnout events):
513
+ border-left: 2px #c2410c
514
+ background: rgba(194,65,12,0.08)
515
+
516
+ Row animation: @keyframes ein { from { opacity:0; translateY(-2px) } }
517
+ Hover: background #f0ede8
518
+ ```
519
+
520
+ ---
521
+
522
+ ## 13. Ember Particle System
523
+
524
+ ```
525
+ Max particles: 220
526
+ Spawned per frame from cells with fire ≥ 0.3 (9% chance per cell)
527
+
528
+ Each particle:
529
+ position: (cellCenter ± random*3)
530
+ velocity: upward cone (-π/2 ± 0.75rad), speed 0.4-1.4 + windInfluence*0.7
531
+ vertical acceleration: -0.012 (rises)
532
+ horizontal drag: *0.97 per frame
533
+ life: 1.0 → 0 at decay rate 0.010–0.025/frame
534
+
535
+ Types (60% ember / 40% spark):
536
+ ember: rgba(255, 80+100*life, 0, life*0.92)
537
+ spark: rgba(255, 210+45*life, 60*life, life*0.92)
538
+
539
+ Radius = particle.size (1.3–3.7) * life
540
+ ```
541
+
542
+ ---
543
+
544
+ ## 14. React Implementation Guide
545
+
546
+ ### Component Tree (suggested)
547
+ ```
548
+ <PyreGrid> ← 16×16 canvas/div container
549
+ <GridCanvas> ← main Canvas2D renderer
550
+ <AgentLayer> ← SVG or div overlay for agent + trail
551
+ <FootprintTrail /> ← last 14 positions as fading dots
552
+ <AgentBody /> ← pulsing sphere
553
+ <HealthArcRing /> ← SVG arc ring around agent
554
+ <HUDOverlay position="tl"> ← health bar HUD card
555
+ <HUDOverlay position="tr"> ← step counter HUD card
556
+ <WindRose /> ← mini canvas, bottom-right
557
+ <EpisodeBanner /> ← bottom-center banner
558
+ ```
559
+
560
+ ### State shape
561
+ ```ts
562
+ interface AgentState {
563
+ x: number; // grid column [0..15]
564
+ y: number; // grid row [0..15]
565
+ health: number; // 0–100
566
+ alive: boolean;
567
+ evacuated: boolean;
568
+ trail: { x: number; y: number }[]; // last 14 positions, [0]=newest
569
+ }
570
+
571
+ interface GridState {
572
+ cellGrid: number[]; // 256 ints — cell types
573
+ fireGrid: Float32Array; // 256 floats — 0..1
574
+ smokeGrid: Float32Array; // 256 floats — 0..1
575
+ visibleCells: Set<string>; // "x,y" keys
576
+ exploreSet: Set<string>; // "x,y" keys
577
+ }
578
+ ```
579
+
580
+ ### Health arc in React (SVG approach)
581
+ ```tsx
582
+ const CELL = 36; // px
583
+
584
+ function HealthArcRing({ x, y, health }: { x: number; y: number; health: number }) {
585
+ const cx = x * CELL + CELL / 2;
586
+ const cy = y * CELL + CELL / 2;
587
+ const bodyR = CELL * 0.38;
588
+ const arcR = bodyR * 1.65;
589
+ const circumference = 2 * Math.PI * arcR;
590
+ const filled = (health / 100) * circumference;
591
+
592
+ const color = health > 60 ? '#4ade80' : health > 30 ? '#fbbf24' : '#f87171';
593
+
594
+ return (
595
+ <svg style={{ position:'absolute', top:0, left:0, pointerEvents:'none' }}
596
+ width={16*CELL} height={16*CELL}>
597
+ {/* Track */}
598
+ <circle cx={cx} cy={cy} r={arcR}
599
+ fill="none" stroke="rgba(255,255,255,0.07)" strokeWidth={2.5} />
600
+ {/* Arc — starts at 12 o'clock, clockwise */}
601
+ <circle cx={cx} cy={cy} r={arcR}
602
+ fill="none"
603
+ stroke={color} strokeWidth={3}
604
+ strokeLinecap="round"
605
+ strokeDasharray={`${filled} ${circumference}`}
606
+ transform={`rotate(-90, ${cx}, ${cy})`}
607
+ style={{ filter: `drop-shadow(0 0 4px ${color})`, transition: 'stroke-dasharray 0.3s' }}
608
+ />
609
+ </svg>
610
+ );
611
+ }
612
+ ```
613
+
614
+ ### Footprint trail in React (SVG approach)
615
+ ```tsx
616
+ function FootprintTrail({ trail }: { trail: { x: number; y: number }[] }) {
617
+ const CELL = 36;
618
+ return (
619
+ <svg style={{ position:'absolute', top:0, left:0, pointerEvents:'none' }}
620
+ width={16*CELL} height={16*CELL}>
621
+ {trail.slice(1).map((pos, i) => {
622
+ const total = trail.length;
623
+ const alpha = ((total - 1 - i) / total) * 0.42;
624
+ const radius = Math.max(1.5, ((total - 1 - i) / total) * CELL * 0.22);
625
+ const cx = pos.x * CELL + CELL / 2;
626
+ const cy = pos.y * CELL + CELL / 2;
627
+ return (
628
+ <circle key={i} cx={cx} cy={cy} r={radius}
629
+ fill={`rgba(14,165,233,${alpha.toFixed(3)})`} />
630
+ );
631
+ })}
632
+ </svg>
633
+ );
634
+ }
635
+ ```
636
+
637
+ ### Trail update logic
638
+ ```ts
639
+ const MAX_TRAIL = 14;
640
+
641
+ function updateTrail(
642
+ trail: { x: number; y: number }[],
643
+ newX: number,
644
+ newY: number
645
+ ): { x: number; y: number }[] {
646
+ const last = trail[0];
647
+ if (last && last.x === newX && last.y === newY) return trail;
648
+ return [{ x: newX, y: newY }, ...trail].slice(0, MAX_TRAIL);
649
+ }
650
+ ```
651
+
652
+ ### Animation loop pattern
653
+ ```tsx
654
+ useEffect(() => {
655
+ let raf: number;
656
+ const loop = () => {
657
+ setTime(t => t + 0.016); // drives fire flicker, smoke drift, pulse
658
+ raf = requestAnimationFrame(loop);
659
+ };
660
+ raf = requestAnimationFrame(loop);
661
+ return () => cancelAnimationFrame(raf);
662
+ }, []);
663
+ ```
664
+
665
+ ---
666
+
667
+ ## 15. Fire & Smoke Constants
668
+
669
+ ```ts
670
+ const FIRE_IGNITION = 0.1; // newly ignited
671
+ const FIRE_BURNING = 0.3; // actively spreading
672
+ const FIRE_GAIN = 0.15; // per-step intensity increase
673
+ const BURNOUT_TICKS = 5; // steps at max before becoming obstacle
674
+ const SMOKE_SPREAD = 0.20; // fraction diffused per step
675
+ const SMOKE_DOOR_FACT = 0.40; // doors slow smoke
676
+ const SMOKE_DECAY = 0.02; // per-step natural decay
677
+ const EXIT_BLOCK_FIRE = 0.50; // fire level that blocks an exit
678
+ ```
679
+
680
+ ---
681
+
682
+ ## 16. Tile Grid Overlay
683
+
684
+ A very subtle grid drawn on top of all layers:
685
+ ```
686
+ strokeStyle: rgba(255,255,255,0.04)
687
+ lineWidth: 1
688
+ Pattern: full vertical + horizontal lines every cellSize px
689
+ ```
690
+
691
+ ---
692
+
693
+ ## 17. Summary — What to Build in React
694
+
695
+ | Feature | Implementation |
696
+ |---------|---------------|
697
+ | 16×16 grid | CSS grid or Canvas, 36px cells (576px total) |
698
+ | Cell textures | Canvas2D `fillRect` with per-type colors |
699
+ | Fire effect | Radial gradients, 'screen' blend mode, animated `t` |
700
+ | Smoke drift | Radial gradient circles with animated offsets |
701
+ | Fog of war | Semi-transparent overlays driven by BFS visibility set |
702
+ | **Agent body** | Pulsing SVG circle with radial gradient |
703
+ | **Health arc ring** | SVG `<circle>` with `strokeDasharray` trick, color-coded |
704
+ | **Footprint trail** | SVG `<circle>` array, fade + shrink by index |
705
+ | HUD cards | `position:absolute` divs with `backdrop-filter: blur` |
706
+ | HP bar | `width` transition, color class switch (green/amber/red/blink) |
707
+ | Wind rose | Small Canvas2D or SVG compass |
708
+ | Mini charts | Canvas2D line chart with fill area |
709
+ | Event log | Scrolling list, alarm rows with fire border |
demo.html ADDED
@@ -0,0 +1,1414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
6
+ <title>Pyre — Crisis Navigation Demo</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ --s0:#f5f2ed; --s1:#ffffff; --s2:#faf9f6; --s3:#f0ede8;
14
+ --bd:#e4dfd7; --bd2:#cdc7be;
15
+ --t1:#1c1712; --t2:#6b6460; --t3:#a8a29e;
16
+ --fire:#c2410c; --fire2:rgba(194,65,12,0.08);
17
+ --blue:#1d4ed8; --blue2:rgba(29,78,216,0.08);
18
+ --green:#166534; --green2:rgba(22,101,52,0.08);
19
+ --amber:#92400e; --amber2:rgba(146,64,14,0.08);
20
+ --red:#b91c1c; --red2:rgba(185,28,28,0.08);
21
+ --mono:'DM Mono','JetBrains Mono',monospace;
22
+ --sans:'DM Sans','Inter',system-ui,sans-serif;
23
+ --r:6px;
24
+ }
25
+
26
+ html, body { height:100%; background:var(--s0); color:var(--t1); font-family:var(--sans); overflow:hidden; font-size:13px; }
27
+
28
+ /* ── Shell ── */
29
+ .shell { display:grid; grid-template-rows:44px 1fr 48px; height:100vh; min-width:1100px; }
30
+
31
+ /* ── Topbar ── */
32
+ .topbar {
33
+ background:var(--s1); border-bottom:1px solid var(--bd);
34
+ display:flex; align-items:center; padding:0 20px; gap:16px;
35
+ }
36
+ .brand { display:flex; align-items:center; gap:9px; }
37
+ .brand-icon {
38
+ width:28px; height:28px; border-radius:6px;
39
+ background:var(--fire); display:flex; align-items:center; justify-content:center;
40
+ font-size:14px; flex-shrink:0;
41
+ }
42
+ .brand-name { font-family:var(--mono); font-size:14px; font-weight:500; letter-spacing:.05em; }
43
+ .brand-sep { width:1px; height:18px; background:var(--bd); }
44
+ .brand-sub { font-size:11px; color:var(--t3); }
45
+ .topbar-sep { width:1px; height:20px; background:var(--bd); }
46
+ .topbar-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
47
+
48
+ .seg { display:flex; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--s2); }
49
+ .seg button {
50
+ background:none; border:none; padding:5px 11px;
51
+ font-family:var(--mono); font-size:11px; font-weight:500; color:var(--t2);
52
+ cursor:pointer; letter-spacing:.03em; transition:background .1s,color .1s;
53
+ }
54
+ .seg button:hover { background:var(--s3); color:var(--t1); }
55
+ .seg button.on { background:var(--s1); color:var(--t1); box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
56
+ .seg button.on.fire { color:var(--fire); }
57
+ .seg button + button { border-left:1px solid var(--bd); }
58
+
59
+ .live-chip {
60
+ font-family:var(--mono); font-size:10px; font-weight:500;
61
+ padding:3px 10px; border-radius:12px; border:1px solid; letter-spacing:.05em;
62
+ }
63
+ .live-chip.demo { border-color:var(--bd2); color:var(--t2); }
64
+ .live-chip.online { border-color:rgba(22,101,52,.4); color:var(--green); background:var(--green2); }
65
+
66
+ /* ── Content ── */
67
+ .content { display:grid; grid-template-columns:1fr 300px; overflow:hidden; min-height:0; }
68
+
69
+ /* ── Canvas zone ── */
70
+ .canvas-zone {
71
+ display:flex; align-items:center; justify-content:center;
72
+ background:var(--s0); padding:20px; position:relative; overflow:hidden;
73
+ }
74
+ .canvas-frame {
75
+ position:relative; border-radius:10px; overflow:hidden;
76
+ box-shadow:0 0 0 1px var(--bd2),0 24px 64px rgba(0,0,0,.20),0 6px 16px rgba(0,0,0,.12);
77
+ max-width:min(calc(100vh - 140px), calc(100vw - 340px));
78
+ max-height:calc(100vh - 140px);
79
+ aspect-ratio:1;
80
+ }
81
+ #mainCanvas { display:block; width:100%; height:100%; }
82
+
83
+ /* ── HUD overlays ── */
84
+ .hud-tl, .hud-tr { position:absolute; top:12px; pointer-events:none; z-index:2; }
85
+ .hud-tl { left:12px; }
86
+ .hud-tr { right:12px; }
87
+ .hud-card {
88
+ background:rgba(12,9,7,0.84);
89
+ border:1px solid rgba(255,255,255,0.10);
90
+ border-radius:8px; padding:9px 12px;
91
+ backdrop-filter:blur(10px); color:#e8e0d8;
92
+ min-width:140px;
93
+ }
94
+ .hud-row { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
95
+ .hud-row:last-child { margin-bottom:0; }
96
+ .hlbl { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.8); letter-spacing:.10em; }
97
+ .hval { font-family:var(--mono); font-size:13px; font-weight:500; color:#f0e8e0; }
98
+
99
+ .hbar-bg { width:84px; height:5px; background:rgba(255,255,255,.08); border-radius:3px; overflow:hidden; flex:1; }
100
+ .hbar-fill { height:100%; border-radius:3px; transition:width .35s,background .35s; }
101
+ .hbar-fill.g { background:#4ade80; }
102
+ .hbar-fill.m { background:#fbbf24; }
103
+ .hbar-fill.c { background:#f87171; animation:hblink .5s infinite alternate; }
104
+ @keyframes hblink { to { opacity:.25 } }
105
+
106
+ .hstatus { font-family:var(--mono); font-size:9px; }
107
+ .hstatus.good { color:#4ade80; }
108
+ .hstatus.moderate { color:#fbbf24; }
109
+ .hstatus.low { color:#fb923c; }
110
+ .hstatus.critical { color:#f87171; }
111
+
112
+ .step-val { font-family:var(--mono); font-size:13px; font-weight:500; color:#93c5fd; }
113
+ .sbar-bg { width:100%; height:3px; background:rgba(255,255,255,.07); border-radius:2px; overflow:hidden; margin-top:5px; }
114
+ .sbar-fill { height:100%; border-radius:2px; background:#60a5fa; transition:width .35s; }
115
+ .step-meta { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.55); margin-top:3px; }
116
+
117
+ #epBanner {
118
+ display:none; position:absolute;
119
+ bottom:14px; left:50%; transform:translateX(-50%);
120
+ font-family:var(--mono); font-size:13px; font-weight:500;
121
+ letter-spacing:.04em; padding:10px 22px;
122
+ border-radius:8px; backdrop-filter:blur(10px);
123
+ pointer-events:none; white-space:nowrap; z-index:3;
124
+ }
125
+ #epBanner.ok { background:rgba(21,128,61,.82); border:1px solid rgba(74,222,128,.3); color:#dcfce7; }
126
+ #epBanner.bad { background:rgba(153,27,27,.82); border:1px solid rgba(248,113,113,.3); color:#fee2e2; }
127
+ #epBanner.tmo { background:rgba(120,53,15,.82); border:1px solid rgba(251,191,36,.3); color:#fef3c7; }
128
+
129
+ /* ── Legend badge ── */
130
+ .legend {
131
+ position:absolute; bottom:12px; left:12px; z-index:2; pointer-events:none;
132
+ display:flex; flex-direction:column; gap:4px;
133
+ }
134
+ .leg-row { display:flex; align-items:center; gap:5px; font-family:var(--mono); font-size:9px; color:rgba(220,210,200,.7); }
135
+ .leg-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
136
+
137
+ /* ── Side panel ── */
138
+ .side {
139
+ background:var(--s1); border-left:1px solid var(--bd);
140
+ overflow-y:auto; overflow-x:hidden;
141
+ scrollbar-width:thin; scrollbar-color:var(--bd) transparent;
142
+ display:flex; flex-direction:column;
143
+ }
144
+ ::-webkit-scrollbar { width:3px; }
145
+ ::-webkit-scrollbar-thumb { background:var(--bd); border-radius:2px; }
146
+
147
+ .side-sec { padding:14px 16px; border-bottom:1px solid var(--bd); }
148
+ .side-sec:last-child { border-bottom:none; flex:1; min-height:0; display:flex; flex-direction:column; }
149
+ .sec-hd {
150
+ font-family:var(--mono); font-size:9px; font-weight:500;
151
+ letter-spacing:.12em; text-transform:uppercase; color:var(--t3);
152
+ margin-bottom:10px; display:flex; align-items:center; justify-content:space-between;
153
+ }
154
+
155
+ .sg { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
156
+ .sc { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); padding:8px 10px; }
157
+ .sc-l { font-size:10px; color:var(--t3); margin-bottom:2px; }
158
+ .sc-v { font-family:var(--mono); font-size:14px; font-weight:500; color:var(--t1); }
159
+ .sc-v.fire { color:var(--fire); }
160
+ .sc-v.blue { color:var(--blue); }
161
+ .sc-v.amber { color:var(--amber); }
162
+
163
+ .meta-row {
164
+ display:flex; justify-content:space-between; align-items:center;
165
+ margin-top:8px; padding-top:8px; border-top:1px solid var(--bd); font-size:11px;
166
+ }
167
+
168
+ /* Health legend bar */
169
+ .health-legend { display:flex; gap:3px; margin-top:8px; }
170
+ .hl-seg { height:4px; border-radius:2px; flex:1; }
171
+ .hl-seg.g { background:#4ade80; }
172
+ .hl-seg.m { background:#fbbf24; }
173
+ .hl-seg.l { background:#fb923c; }
174
+ .hl-seg.c { background:#f87171; }
175
+
176
+ /* Sparkline chart */
177
+ .chart-wrap { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); overflow:hidden; margin-top:6px; }
178
+ .chart-wrap canvas { display:block; width:100% !important; }
179
+
180
+ /* Event log */
181
+ .elog { display:flex; flex-direction:column; gap:1px; overflow-y:auto; flex:1; }
182
+ .erow {
183
+ display:flex; align-items:baseline; gap:7px;
184
+ padding:3px 6px; border-radius:4px;
185
+ font-family:var(--mono); font-size:10px;
186
+ border-left:2px solid transparent;
187
+ animation:ein .15s ease;
188
+ }
189
+ @keyframes ein { from { opacity:0; transform:translateY(-2px) } }
190
+ .erow:hover { background:var(--s3); }
191
+ .erow.alarm { border-left-color:var(--fire); background:var(--fire2); }
192
+ .estep { color:var(--t3); min-width:22px; font-size:9px; }
193
+ .etext { color:var(--t2); flex:1; line-height:1.4; }
194
+ .erwd { font-size:9px; min-width:32px; text-align:right; }
195
+ .erwd.p { color:var(--green); }
196
+ .erwd.n { color:var(--red); }
197
+
198
+ /* ── Bottom bar ── */
199
+ .botbar {
200
+ background:var(--s1); border-top:1px solid var(--bd);
201
+ display:flex; align-items:center; padding:0 16px; gap:6px;
202
+ }
203
+ .bsep { width:1px; height:20px; background:var(--bd); margin:0 4px; }
204
+ .icn-btn {
205
+ width:30px; height:30px; display:flex; align-items:center; justify-content:center;
206
+ background:var(--s2); border:1px solid var(--bd); border-radius:var(--r);
207
+ color:var(--t2); font-size:12px; cursor:pointer;
208
+ transition:background .1s,color .1s,border-color .1s;
209
+ }
210
+ .icn-btn:hover { background:var(--s3); color:var(--t1); border-color:var(--bd2); }
211
+ .icn-btn:active { transform:scale(.94); }
212
+ .icn-btn.play { background:var(--fire2); border-color:rgba(194,65,12,.3); color:var(--fire); }
213
+ .icn-btn.play:hover { background:rgba(194,65,12,.14); }
214
+
215
+ .seed-wrap { display:flex; align-items:center; gap:6px; }
216
+ .seed-lbl { font-size:11px; color:var(--t3); }
217
+ #seedInput {
218
+ width:60px; background:var(--s2); border:1px solid var(--bd);
219
+ color:var(--t1); font-family:var(--mono); font-size:11px;
220
+ padding:4px 8px; border-radius:var(--r); outline:none;
221
+ }
222
+ #seedInput:focus { border-color:var(--bd2); }
223
+ .keys { margin-left:auto; display:flex; gap:12px; align-items:center; font-size:10px; color:var(--t3); }
224
+ .keys kbd {
225
+ background:var(--s2); border:1px solid var(--bd2); border-radius:3px;
226
+ padding:1px 5px; font-family:var(--mono); font-size:10px; color:var(--t2);
227
+ }
228
+
229
+ /* Toast */
230
+ #toast {
231
+ position:fixed; bottom:58px; left:50%;
232
+ transform:translateX(-50%) translateY(6px);
233
+ background:var(--t1); color:var(--s0);
234
+ font-family:var(--mono); font-size:11px;
235
+ padding:7px 16px; border-radius:20px;
236
+ opacity:0; pointer-events:none; z-index:99;
237
+ transition:opacity .22s,transform .22s;
238
+ }
239
+ #toast.up { opacity:1; transform:translateX(-50%) translateY(0); }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="shell">
244
+
245
+ <!-- ── Topbar ── -->
246
+ <header class="topbar">
247
+ <div class="brand">
248
+ <div class="brand-icon">🔥</div>
249
+ <span class="brand-name">Pyre</span>
250
+ <div class="brand-sep"></div>
251
+ <span class="brand-sub">Crisis Navigation Environment</span>
252
+ </div>
253
+ <div class="topbar-sep"></div>
254
+ <div class="seg">
255
+ <button class="diff-btn on fire" data-diff="easy">Easy</button>
256
+ <button class="diff-btn" data-diff="medium">Medium</button>
257
+ <button class="diff-btn" data-diff="hard">Hard</button>
258
+ </div>
259
+ <div class="topbar-right">
260
+ <span id="liveChip" class="live-chip demo">Demo Mode</span>
261
+ </div>
262
+ </header>
263
+
264
+ <!-- ── Content ── -->
265
+ <div class="content">
266
+
267
+ <!-- Canvas zone -->
268
+ <div class="canvas-zone">
269
+ <div class="canvas-frame">
270
+ <canvas id="mainCanvas" width="576" height="576"></canvas>
271
+
272
+ <!-- Top-left HUD: Health -->
273
+ <div class="hud-tl">
274
+ <div class="hud-card">
275
+ <div class="hud-row">
276
+ <span class="hlbl">HP</span>
277
+ <div class="hbar-bg"><div id="healthBar" class="hbar-fill g" style="width:100%"></div></div>
278
+ <span id="healthVal" class="hval">100</span>
279
+ </div>
280
+ <div class="hud-row" style="gap:10px">
281
+ <span id="healthStatus" class="hstatus good">Good</span>
282
+ <span id="smokeTag" style="font-family:var(--mono);font-size:9px;color:rgba(168,162,158,.6);margin-left:auto">smoke: clear</span>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Top-right HUD: Steps -->
288
+ <div class="hud-tr">
289
+ <div class="hud-card" style="text-align:right">
290
+ <div class="hud-row" style="justify-content:flex-end">
291
+ <span class="hlbl">STEP</span>
292
+ <span id="stepCount" class="step-val">0 / 200</span>
293
+ </div>
294
+ <div class="sbar-bg"><div id="stepBar" class="sbar-fill" style="width:0%"></div></div>
295
+ <div id="diffTag" class="step-meta">easy · small_office</div>
296
+ </div>
297
+ </div>
298
+
299
+ <!-- Episode end banner -->
300
+ <div id="epBanner"></div>
301
+
302
+ <!-- Legend -->
303
+ <div class="legend">
304
+ <div class="leg-row"><div class="leg-dot" style="background:#0ea5e9"></div>Agent (human)</div>
305
+ <div class="leg-row"><div class="leg-dot" style="background:rgba(2,132,199,.45)"></div>Footprint trail</div>
306
+ <div class="leg-row"><div class="leg-dot" style="background:#c2410c"></div>Fire</div>
307
+ <div class="leg-row"><div class="leg-dot" style="background:rgba(200,210,220,.7)"></div>Smoke</div>
308
+ <div class="leg-row"><div class="leg-dot" style="background:#22c55e"></div>Exit</div>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <!-- Side panel -->
314
+ <aside class="side">
315
+
316
+ <div class="side-sec">
317
+ <div class="sec-hd">Agent Status</div>
318
+ <div class="sg">
319
+ <div class="sc"><div class="sc-l">Health</div><div id="sHealthVal" class="sc-v blue">100</div></div>
320
+ <div class="sc"><div class="sc-l">Status</div><div id="sStatusVal" class="sc-v">Good</div></div>
321
+ <div class="sc"><div class="sc-l">Smoke</div><div id="sSmokeVal" class="sc-v">clear</div></div>
322
+ <div class="sc"><div class="sc-l">Reward</div><div id="sRewardVal" class="sc-v blue">0.00</div></div>
323
+ </div>
324
+ <div class="health-legend">
325
+ <div class="hl-seg g" title="Good (>75)"></div>
326
+ <div class="hl-seg m" title="Moderate (>50)"></div>
327
+ <div class="hl-seg l" title="Low (>25)"></div>
328
+ <div class="hl-seg c" title="Critical (≤25)"></div>
329
+ </div>
330
+ </div>
331
+
332
+ <div class="side-sec">
333
+ <div class="sec-hd">
334
+ Episode
335
+ <span id="sBurnVal" style="color:var(--fire);font-family:var(--mono);font-size:10px">0% burned</span>
336
+ </div>
337
+ <div class="sg">
338
+ <div class="sc"><div class="sc-l">Fire cells</div><div id="sFireVal" class="sc-v fire">0</div></div>
339
+ <div class="sc"><div class="sc-l">Step</div><div id="sStepVal" class="sc-v">0</div></div>
340
+ <div class="sc"><div class="sc-l">Wind</div><div id="sWindVal" class="sc-v amber">CALM</div></div>
341
+ <div class="sc"><div class="sc-l">Spread ρ</div><div id="sSpreadVal" class="sc-v fire">0.10</div></div>
342
+ </div>
343
+ <div class="meta-row">
344
+ <span style="color:var(--t3);font-size:11px">Exits blocked</span>
345
+ <span id="sExitBlocked" style="font-family:var(--mono);font-size:11px;color:var(--green)">0 / 2</span>
346
+ </div>
347
+ </div>
348
+
349
+ <div class="side-sec">
350
+ <div class="sec-hd">
351
+ Cumulative Reward
352
+ <span id="sRewardLast" style="color:var(--t2);font-size:10px">—</span>
353
+ </div>
354
+ <div class="chart-wrap" style="height:50px">
355
+ <canvas id="rewardChart" height="50"></canvas>
356
+ </div>
357
+ </div>
358
+
359
+ <div class="side-sec">
360
+ <div class="sec-hd">
361
+ Active Fire
362
+ <span id="sFireLast" style="color:var(--t2);font-size:10px">0 cells</span>
363
+ </div>
364
+ <div class="chart-wrap" style="height:44px">
365
+ <canvas id="fireChart" height="44"></canvas>
366
+ </div>
367
+ </div>
368
+
369
+ <div class="side-sec">
370
+ <div class="sec-hd">Event Log</div>
371
+ <div id="eventLog" class="elog">
372
+ <div class="erow">
373
+ <span class="estep">000</span>
374
+ <span class="etext">Episode started. Assess surroundings.</span>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
379
+ </aside>
380
+ </div>
381
+
382
+ <!-- ── Bottom bar ── -->
383
+ <footer class="botbar">
384
+ <button id="resetBtn" class="icn-btn" title="Reset (R)">↺</button>
385
+ <button id="playBtn" class="icn-btn play" title="Play / Pause (Space)">▶</button>
386
+ <button id="stepBtn" class="icn-btn" title="Step once (→)">⏭</button>
387
+ <div class="bsep"></div>
388
+ <div class="seg">
389
+ <button class="spd-btn" data-s="0.5">½×</button>
390
+ <button class="spd-btn on" data-s="1">1×</button>
391
+ <button class="spd-btn" data-s="2">2×</button>
392
+ <button class="spd-btn" data-s="4">4×</button>
393
+ </div>
394
+ <div class="bsep"></div>
395
+ <div class="seed-wrap">
396
+ <span class="seed-lbl">Seed</span>
397
+ <input id="seedInput" type="number" value="42" min="0" max="9999">
398
+ </div>
399
+ <div class="keys">
400
+ <span><kbd>Space</kbd> play</span>
401
+ <span><kbd>→</kbd> step</span>
402
+ <span><kbd>R</kbd> reset</span>
403
+ </div>
404
+ </footer>
405
+ </div>
406
+
407
+ <div id="toast"></div>
408
+
409
+ <script>
410
+ 'use strict';
411
+
412
+ // ── Constants ─────────────────────────────────────────────────────────────────
413
+ const FLOOR=0, WALL=1, DOOR_OPEN=2, DOOR_CLOSED=3, EXIT=4, OBSTACLE=5;
414
+ const FIRE_IGNITION=0.1, FIRE_BURNING=0.3, FIRE_GAIN=0.15, BURNOUT_TICKS=5;
415
+ const SMOKE_SPREAD=0.20, SMOKE_DECAY=0.025, SMOKE_DOOR=0.4;
416
+ const EXIT_BLOCK=0.50;
417
+ const CARDINAL=[[0,-1],[0,1],[-1,0],[1,0]];
418
+ const WIND_DIRS={N:[0,-1],NE:[1,-1],E:[1,0],SE:[1,1],S:[0,1],SW:[-1,1],W:[-1,0],NW:[-1,-1],CALM:[0,0]};
419
+
420
+ const DIFFICULTY = {
421
+ easy: { nSrc:[1,1], spread:[.10,.20], humid:[.30,.50], winds:['CALM'], maxSteps:200 },
422
+ medium: { nSrc:[2,3], spread:[.15,.35], humid:[.15,.40], winds:Object.keys(WIND_DIRS), maxSteps:150 },
423
+ hard: { nSrc:[3,4], spread:[.30,.50], humid:[.05,.20], winds:['N','NE','E','SE','S','SW','W','NW'], maxSteps:100 },
424
+ };
425
+
426
+ // ── Seeded PRNG (mulberry32) ──────────────────────────────────────────────────
427
+ function makePRNG(seed) {
428
+ let s = seed >>> 0;
429
+ return () => {
430
+ s += 0x6D2B79F5;
431
+ let t = s;
432
+ t = Math.imul(t ^ t>>>15, t|1);
433
+ t ^= t + Math.imul(t^t>>>7, t|61);
434
+ return ((t^t>>>14)>>>0) / 4294967296;
435
+ };
436
+ }
437
+
438
+ // ── Fire color ramp ───────────────────────────────────────────────────────────
439
+ function fireColor(t, a=1) {
440
+ t = Math.max(0, Math.min(1, t));
441
+ let r,g,b;
442
+ if (t<.15){ const s=t/.15; r=~~(98+88*s); g=~~(6*s); b=0; }
443
+ else if (t<.40){ const s=(t-.15)/.25; r=~~(186+60*s); g=~~(6+60*s); b=0; }
444
+ else if (t<.65){ const s=(t-.40)/.25; r=~~(246+9*s); g=~~(66+112*s); b=~~(9*s); }
445
+ else if (t<.85){ const s=(t-.65)/.20; r=255; g=~~(178+57*s); b=~~(9+32*s); }
446
+ else { const s=(t-.85)/.15; r=255; g=~~(235+20*s); b=~~(41+214*s); }
447
+ return `rgba(${r},${g},${b},${a})`;
448
+ }
449
+
450
+ // ── Floor plan: small office 16×16 ───────────────────────────────────────────
451
+ const LAYOUT = [
452
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
453
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
454
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
455
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
456
+ [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
457
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
458
+ [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
459
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
460
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
461
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
462
+ [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
463
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
464
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
465
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
466
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
467
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
468
+ ].flat();
469
+ const W=16, H=16;
470
+ const EXITS=[[0,6],[15,8]];
471
+ const DOORS=[[2,4],[6,4],[10,4],[14,4],[2,10],[6,10],[10,10],[14,10]];
472
+
473
+ // ── BFS utilities ─────────────────────────────────────────────────────────────
474
+ function bfsDist(ax, ay, exits, cellGrid) {
475
+ const targets = exits.filter(([ex,ey])=>cellGrid[ey*W+ex]!==OBSTACLE);
476
+ if (!targets.length) return 999;
477
+ const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]];
478
+ while (q.length) {
479
+ const [x,y,d]=q.shift();
480
+ for (const [dx,dy] of CARDINAL) {
481
+ const nx=x+dx, ny=y+dy;
482
+ if (nx<0||nx>=W||ny<0||ny>=H) continue;
483
+ const k=`${nx},${ny}`;
484
+ if (vis.has(k)) continue;
485
+ const ct=cellGrid[ny*W+nx];
486
+ if (ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
487
+ if (targets.some(([ex,ey])=>ex===nx&&ey===ny)) return d+1;
488
+ vis.add(k); q.push([nx,ny,d+1]);
489
+ }
490
+ }
491
+ return 999;
492
+ }
493
+
494
+ function computeVisible(ax, ay, cellGrid, smokeGrid) {
495
+ const smoke=smokeGrid[ay*W+ax];
496
+ const radius=smoke>.5?2:smoke>.2?3:5;
497
+ const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]], seen=new Set([`${ax},${ay}`]);
498
+ while (q.length) {
499
+ const [x,y,d]=q.shift();
500
+ if (d>=radius) continue;
501
+ for (const [dx,dy] of CARDINAL) {
502
+ const nx=x+dx, ny=y+dy;
503
+ if (nx<0||nx>=W||ny<0||ny>=H) continue;
504
+ const k=`${nx},${ny}`;
505
+ if (seen.has(k)) continue;
506
+ seen.add(k);
507
+ const ct=cellGrid[ny*W+nx];
508
+ if (ct===WALL) continue;
509
+ vis.add(k); q.push([nx,ny,d+1]);
510
+ }
511
+ }
512
+ return vis;
513
+ }
514
+
515
+ // ── Simulation state ──────────────────────────────────────────────────────────
516
+ let state = null;
517
+
518
+ function buildState(difficulty='easy', seed=42) {
519
+ const p = DIFFICULTY[difficulty];
520
+ const rng = makePRNG(seed);
521
+
522
+ const cellGrid = LAYOUT.slice();
523
+ // Randomly close some doors
524
+ const doorReg = {};
525
+ DOORS.forEach((d,i) => {
526
+ doorReg[`door_${i+1}`] = d;
527
+ if (rng()<0.3) cellGrid[d[1]*W+d[0]] = DOOR_CLOSED;
528
+ });
529
+
530
+ const nSrc = Math.round(p.nSrc[0] + rng()*(p.nSrc[1]-p.nSrc[0]));
531
+ const pSpread = p.spread[0] + rng()*(p.spread[1]-p.spread[0]);
532
+ const humidity = p.humid[0] + rng()*(p.humid[1]-p.humid[0]);
533
+ const windDir = p.winds[Math.floor(rng()*p.winds.length)];
534
+ const wv = WIND_DIRS[windDir]||[0,0];
535
+
536
+ const fireGrid = new Float32Array(W*H);
537
+ const smokeGrid = new Float32Array(W*H);
538
+ const burnTimers= new Int32Array(W*H);
539
+
540
+ // Spawn agent in corridor
541
+ const spawns=[];
542
+ for(let y=5;y<=9;y++) for(let x=1;x<=14;x++) if(cellGrid[y*W+x]===FLOOR) spawns.push([x,y]);
543
+ const sp = spawns[Math.floor(rng()*spawns.length)];
544
+
545
+ // Place fire far from agent and exits
546
+ const candidates=[];
547
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
548
+ if(cellGrid[y*W+x]===FLOOR) {
549
+ const farAgent=Math.abs(x-sp[0])+Math.abs(y-sp[1])>=5;
550
+ const farExits=EXITS.every(([ex,ey])=>Math.abs(x-ex)+Math.abs(y-ey)>=4);
551
+ if(farAgent&&farExits) candidates.push([x,y]);
552
+ }
553
+ }
554
+ candidates.sort(()=>rng()-.5);
555
+ for(let i=0;i<Math.min(nSrc,candidates.length);i++) {
556
+ const [fx,fy]=candidates[i];
557
+ fireGrid[fy*W+fx]=FIRE_IGNITION;
558
+ }
559
+
560
+ const visible = computeVisible(sp[0],sp[1],cellGrid,smokeGrid);
561
+ const explore = new Set(visible);
562
+
563
+ return {
564
+ cellGrid, fireGrid, smokeGrid, burnTimers, doorReg,
565
+ pSpread, humidity, windDir, wx:wv[0], wy:wv[1],
566
+ effectiveSpread: pSpread * Math.max(0, 1-humidity),
567
+ maxSteps: p.maxSteps, stepCount:0,
568
+ agentX:sp[0], agentY:sp[1],
569
+ agentHealth:100, agentAlive:true, agentEvacuated:false,
570
+ totalReward:0, lastReward:0,
571
+ rewardHistory:[], fireSizeHistory:[],
572
+ eventLog:[{step:0,text:'Episode started. Assess your surroundings.',reward:0}],
573
+ visibleCells:visible, exploreSet:explore,
574
+ difficulty, seed,
575
+ rng2: makePRNG(seed+1),
576
+ };
577
+ }
578
+
579
+ // ── Fire simulation step ──────────────────────────────────────────────────────
580
+ function simStep(s) {
581
+ const {cellGrid,fireGrid,smokeGrid,burnTimers,pSpread,effectiveSpread,wx,wy,rng2} = s;
582
+ const ignite = new Uint8Array(W*H);
583
+ const burned = [];
584
+
585
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
586
+ const i=y*W+x;
587
+ if(fireGrid[i]<FIRE_BURNING) continue;
588
+ for(const [dx,dy] of CARDINAL) {
589
+ const nx=x+dx, ny=y+dy;
590
+ if(nx<0||nx>=W||ny<0||ny>=H) continue;
591
+ const ni=ny*W+nx, nct=cellGrid[ni];
592
+ if(nct===WALL||nct===OBSTACLE||fireGrid[ni]>0) continue;
593
+ let p = nct===DOOR_CLOSED ? effectiveSpread*.15 : effectiveSpread;
594
+ const dot=dx*wx+dy*wy;
595
+ p *= (dot>0?2.0:dot<0?0.5:1.0);
596
+ if(rng2()<Math.min(1,p)) ignite[ni]=1;
597
+ }
598
+ }
599
+
600
+ const nf=fireGrid.slice(), nt=burnTimers.slice();
601
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
602
+ const i=y*W+x, ct=cellGrid[i];
603
+ if(ct===WALL||ct===OBSTACLE) continue;
604
+ if(fireGrid[i]>0) {
605
+ nf[i]=Math.min(1,fireGrid[i]+FIRE_GAIN);
606
+ if(fireGrid[i]>=FIRE_BURNING) nt[i]++;
607
+ if(nt[i]>=BURNOUT_TICKS&&nf[i]>=1) {
608
+ cellGrid[i]=OBSTACLE; nf[i]=0; nt[i]=0; burned.push([x,y]);
609
+ }
610
+ } else if(ignite[i]) { nf[i]=FIRE_IGNITION; nt[i]=0; }
611
+ }
612
+ for(let i=0;i<W*H;i++) { fireGrid[i]=nf[i]; burnTimers[i]=nt[i]; }
613
+
614
+ // Smoke
615
+ const ns=smokeGrid.slice();
616
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
617
+ const i=y*W+x, ct=cellGrid[i];
618
+ if(ct===WALL||ct===OBSTACLE) continue;
619
+ if(fireGrid[i]>=FIRE_BURNING) ns[i]=Math.min(1,smokeGrid[i]+0.3);
620
+ for(const [dx,dy] of CARDINAL) {
621
+ const nx=x+dx, ny=y+dy;
622
+ if(nx<0||nx>=W||ny<0||ny>=H) continue;
623
+ const ni=ny*W+nx, nct=cellGrid[ni];
624
+ if(nct===WALL||nct===OBSTACLE) continue;
625
+ const diff=smokeGrid[i]-smokeGrid[ni];
626
+ if(diff<=0) continue;
627
+ let rate=SMOKE_SPREAD;
628
+ if(nct===DOOR_CLOSED) rate*=SMOKE_DOOR;
629
+ ns[ni]=Math.min(1,ns[ni]+Math.min(diff*rate,diff*.5));
630
+ }
631
+ ns[i]=Math.max(0,ns[i]-SMOKE_DECAY);
632
+ }
633
+ for(let i=0;i<W*H;i++) smokeGrid[i]=ns[i];
634
+ return burned;
635
+ }
636
+
637
+ // ── Demo agent (BFS toward exit) ──────────────────────────────────────────────
638
+ function agentAct(s) {
639
+ const {agentX:ax,agentY:ay,cellGrid,fireGrid,doorReg} = s;
640
+ const dirs=['north','south','east','west'];
641
+ const deltas={north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
642
+
643
+ // Check if adjacent to closed door
644
+ for(const dir of dirs) {
645
+ const [dx,dy]=deltas[dir];
646
+ const nx=ax+dx, ny=ay+dy;
647
+ if(nx<0||nx>=W||ny<0||ny>=H) continue;
648
+ if(cellGrid[ny*W+nx]===DOOR_CLOSED) {
649
+ for(const [id,[ddx,ddy]] of Object.entries(doorReg)) {
650
+ if(ddx===nx&&ddy===ny) return {action:'door',target_id:id,door_state:'open'};
651
+ }
652
+ }
653
+ }
654
+
655
+ let bestDir=null, bestDist=Infinity;
656
+ for(const dir of dirs) {
657
+ const [dx,dy]=deltas[dir];
658
+ const nx=ax+dx, ny=ay+dy;
659
+ if(nx<0||nx>=W||ny<0||ny>=H) continue;
660
+ const ct=cellGrid[ny*W+nx];
661
+ if(ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
662
+ if(fireGrid[ny*W+nx]>0.35) continue;
663
+ const d=bfsDist(nx,ny,EXITS,cellGrid);
664
+ if(d<bestDist) { bestDist=d; bestDir=dir; }
665
+ }
666
+ if(!bestDir||Math.random()<0.12) {
667
+ const shuffled=[...dirs].sort(()=>Math.random()-.5);
668
+ for(const dir of shuffled) {
669
+ const [dx,dy]=deltas[dir];
670
+ const nx=ax+dx, ny=ay+dy;
671
+ if(nx<0||nx>=W||ny<0||ny>=H) continue;
672
+ const ct=cellGrid[ny*W+nx];
673
+ if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED&&fireGrid[ny*W+nx]<0.4) return {action:'move',direction:dir};
674
+ }
675
+ }
676
+ return bestDir ? {action:'move',direction:bestDir} : {action:'wait'};
677
+ }
678
+
679
+ function doStep(s) {
680
+ if(!s.agentAlive||s.agentEvacuated||s.stepCount>=s.maxSteps) return;
681
+ const action = agentAct(s);
682
+ const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
683
+ const prevX=s.agentX, prevY=s.agentY;
684
+ let feedback='';
685
+
686
+ if(action.action==='move') {
687
+ const [dx,dy]=deltas[action.direction]||[0,0];
688
+ const nx=s.agentX+dx, ny=s.agentY+dy;
689
+ if(nx>=0&&nx<W&&ny>=0&&ny<H) {
690
+ const ct=s.cellGrid[ny*W+nx];
691
+ if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED) {
692
+ s.agentX=nx; s.agentY=ny; feedback=`Moved ${action.direction}`;
693
+ } else feedback='Path blocked';
694
+ }
695
+ } else if(action.action==='door') {
696
+ const pos=s.doorReg[action.target_id];
697
+ if(pos) {
698
+ const [dx,dy]=pos;
699
+ if(Math.abs(dx-s.agentX)+Math.abs(dy-s.agentY)<=2) {
700
+ if(s.cellGrid[dy*W+dx]===DOOR_CLOSED) {
701
+ s.cellGrid[dy*W+dx]=DOOR_OPEN;
702
+ feedback=`Opened ${action.target_id}`;
703
+ }
704
+ }
705
+ }
706
+ } else if(action.action==='wait') {
707
+ feedback='Waiting...';
708
+ }
709
+
710
+ // Check evacuation
711
+ if(s.cellGrid[s.agentY*W+s.agentX]===EXIT && s.fireGrid[s.agentY*W+s.agentX]<EXIT_BLOCK) {
712
+ s.agentEvacuated=true; feedback='EVACUATED — reached safety!';
713
+ }
714
+
715
+ // Fire step
716
+ const burned = simStep(s);
717
+
718
+ // Damage
719
+ const ai=s.agentY*W+s.agentX;
720
+ const smoke=s.smokeGrid[ai], fire=s.fireGrid[ai];
721
+ let dmg=0;
722
+ if(smoke>=.8) dmg+=5; else if(smoke>=.5) dmg+=2; else if(smoke>=.2) dmg+=.5;
723
+ if(fire>=FIRE_BURNING) dmg+=10;
724
+ s.agentHealth=Math.max(0,s.agentHealth-dmg);
725
+ if(s.agentHealth<=0) { s.agentAlive=false; feedback='Incapacitated!'; }
726
+
727
+ s.stepCount++;
728
+ s.visibleCells=computeVisible(s.agentX,s.agentY,s.cellGrid,s.smokeGrid);
729
+ for(const k of s.visibleCells) s.exploreSet.add(k);
730
+
731
+ // Reward
732
+ const prevDist=bfsDist(prevX,prevY,EXITS,s.cellGrid);
733
+ const curDist=bfsDist(s.agentX,s.agentY,EXITS,s.cellGrid);
734
+ let reward=-0.01;
735
+ if(curDist<prevDist) reward+=0.1;
736
+ if(smoke>=.5||fire>=FIRE_BURNING) reward-=0.5;
737
+ reward-=0.02*dmg;
738
+ if(s.agentEvacuated) reward+=5+(0.05*Math.max(0,s.maxSteps-s.stepCount));
739
+ if(!s.agentAlive) reward-=10;
740
+ if(action.action==='door') reward+=0.5;
741
+
742
+ s.lastReward=Math.round(reward*1000)/1000;
743
+ s.totalReward=Math.round((s.totalReward+reward)*1000)/1000;
744
+ s.rewardHistory.push(s.totalReward);
745
+ s.fireSizeHistory.push(Array.from(s.fireGrid).filter(f=>f>=FIRE_BURNING).length);
746
+
747
+ if(feedback) s.eventLog.unshift({step:s.stepCount,text:feedback,reward:s.lastReward});
748
+ if(burned.length) s.eventLog.unshift({step:s.stepCount,text:`${burned.length} cell(s) burned out`,reward:0,isAlert:true});
749
+ if(s.eventLog.length>50) s.eventLog.length=50;
750
+ }
751
+
752
+ // ── Renderer ──────────────────────────────────────────────────────────────────
753
+ const canvas = document.getElementById('mainCanvas');
754
+ const ctx = canvas.getContext('2d');
755
+ const CS = 36; // cell size px
756
+ let time = 0;
757
+ let agentTrail = []; // [{px,py}] max 14
758
+ let smokePhases; // per-cell random phase
759
+
760
+ function initRender() {
761
+ smokePhases = new Float32Array(W*H).map(()=>Math.random()*Math.PI*2);
762
+ agentTrail = [];
763
+ }
764
+
765
+ function renderFrame(s) {
766
+ time += 0.016;
767
+ const {cellGrid,fireGrid,smokeGrid,agentX,agentY,visibleCells,exploreSet} = s;
768
+
769
+ // ── Background ──
770
+ ctx.fillStyle='#c8b890';
771
+ ctx.fillRect(0,0,576,576);
772
+
773
+ // ── Cell base ──
774
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) drawCellBase(ctx,x,y,cellGrid[y*W+x],CS);
775
+
776
+ // ── Fire ambient (multiply — warms light floor tiles naturally) ──
777
+ ctx.save();
778
+ ctx.globalCompositeOperation='multiply';
779
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
780
+ const f=fireGrid[y*W+x];
781
+ if(f<0.28) continue;
782
+ drawFireAmbient(ctx,x,y,f,CS);
783
+ }
784
+ ctx.restore();
785
+
786
+ // ── Fire cell (multiply — burns vivid deep orange-red into light tiles) ──
787
+ ctx.save();
788
+ ctx.globalCompositeOperation='multiply';
789
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
790
+ const f=fireGrid[y*W+x];
791
+ if(f<0.05) continue;
792
+ drawFireCell(ctx,x,y,f,CS);
793
+ }
794
+ ctx.restore();
795
+
796
+ // ── Smoke ──
797
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
798
+ const sm=smokeGrid[y*W+x];
799
+ if(sm<0.08) continue;
800
+ drawSmoke(ctx,x,y,sm,CS,smokePhases[y*W+x]);
801
+ }
802
+
803
+ // ── Exits & doors ──
804
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
805
+ const ct=cellGrid[y*W+x];
806
+ if(ct===EXIT) drawExit(ctx,x,y,CS,fireGrid[y*W+x]);
807
+ else if(ct===DOOR_OPEN||ct===DOOR_CLOSED) drawDoor(ctx,x,y,ct,CS);
808
+ }
809
+
810
+ // ── Visibility shading — all cells visible, dim by knowledge tier ──
811
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
812
+ const k=`${x},${y}`;
813
+ if(visibleCells.has(k)) continue; // fully lit — no overlay
814
+ // Explored but out of sight: light desaturating veil
815
+ // Unexplored: heavier veil — but never fully opaque, structure always readable
816
+ ctx.fillStyle = exploreSet.has(k)
817
+ ? 'rgba(180,174,165,0.30)' // explored — slight dim
818
+ : 'rgba(140,134,126,0.55)'; // unseen — moderate dim, structure visible
819
+ ctx.fillRect(x*CS,y*CS,CS,CS);
820
+ }
821
+
822
+ // ── Agent vision lantern — warm radial highlight on visible area ──
823
+ const agPx=agentX*CS+CS/2, agPy=agentY*CS+CS/2;
824
+ const vR=5.5*CS;
825
+ const vGr=ctx.createRadialGradient(agPx,agPy,0,agPx,agPy,vR);
826
+ vGr.addColorStop(0, 'rgba(255,248,215,0.28)');
827
+ vGr.addColorStop(0.45,'rgba(255,244,200,0.14)');
828
+ vGr.addColorStop(0.80,'rgba(255,240,180,0.04)');
829
+ vGr.addColorStop(1, 'rgba(255,240,180,0)');
830
+ ctx.fillStyle=vGr;
831
+ ctx.beginPath(); ctx.arc(agPx,agPy,vR,0,Math.PI*2); ctx.fill();
832
+
833
+ // ── Trail ──
834
+ updateTrail(agentX,agentY);
835
+ drawTrail(ctx);
836
+
837
+ // ── Agent ──
838
+ drawAgent(ctx,agentX,agentY,s.agentHealth,s.agentAlive,s.agentEvacuated,CS);
839
+
840
+ // ── Grid overlay ──
841
+ ctx.strokeStyle='rgba(0,0,0,0.07)';
842
+ ctx.lineWidth=0.5;
843
+ for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CS,0);ctx.lineTo(x*CS,H*CS);ctx.stroke();}
844
+ for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CS);ctx.lineTo(W*CS,y*CS);ctx.stroke();}
845
+ }
846
+
847
+ // ── Cell base drawing ─────────────────────────────────────────────────────────
848
+ function drawCellBase(ctx,x,y,ct,cs) {
849
+ const px=x*cs, py=y*cs;
850
+ switch(ct) {
851
+ case WALL: {
852
+ const wh=9;
853
+ // Front shadow face (very dark — makes wall pop from floor)
854
+ ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(px,py+cs-wh,cs,wh);
855
+ // Right drop shadow
856
+ ctx.fillStyle='rgba(0,0,0,0.30)'; ctx.fillRect(px+cs,py-wh+2,4,cs+2);
857
+ // Top face — dark concrete gray
858
+ ctx.fillStyle='#5e5850'; ctx.fillRect(px,py-wh,cs,cs);
859
+ // Top edge catch-light (bright white strip)
860
+ ctx.fillStyle='rgba(255,255,255,0.80)'; ctx.fillRect(px,py-wh,cs,3);
861
+ // Left highlight strip
862
+ ctx.fillStyle='rgba(255,255,255,0.35)'; ctx.fillRect(px,py-wh+3,2,cs-3);
863
+ // Bottom-right inner shadow for depth
864
+ ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+cs-3,py-wh+3,3,cs-3);
865
+ ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+3,py-wh+cs-3,cs-6,3);
866
+ // Mortar lines
867
+ ctx.fillStyle='rgba(0,0,0,0.18)';
868
+ ctx.fillRect(px+3,py-wh+~~(cs*.34),cs-6,1);
869
+ ctx.fillRect(px+3,py-wh+~~(cs*.67),cs-6,1);
870
+ break;
871
+ }
872
+ case OBSTACLE: {
873
+ const oh=5;
874
+ // Deep shadow underface
875
+ ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(px,py+cs-oh,cs,oh);
876
+ ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.fillRect(px+cs,py-oh+2,4,cs);
877
+ // Dark charred surface
878
+ ctx.fillStyle='#3a3530'; ctx.fillRect(px,py-oh,cs,cs);
879
+ // Diagonal char texture
880
+ ctx.strokeStyle='rgba(0,0,0,0.30)'; ctx.lineWidth=0.9;
881
+ for(let d=0;d<cs+cs;d+=5){
882
+ ctx.beginPath();
883
+ ctx.moveTo(px+Math.max(0,d-cs),py-oh+Math.min(d,cs));
884
+ ctx.lineTo(px+Math.min(d,cs),py-oh+Math.max(0,d-cs));
885
+ ctx.stroke();
886
+ }
887
+ // Vivid ember edge glow
888
+ ctx.fillStyle='rgba(230,80,0,0.80)';
889
+ ctx.fillRect(px,py-oh,cs,1); ctx.fillRect(px,py-oh+cs-1,cs,1);
890
+ ctx.fillRect(px,py-oh+1,1,cs-2); ctx.fillRect(px+cs-1,py-oh+1,1,cs-2);
891
+ break;
892
+ }
893
+ case EXIT: {
894
+ ctx.fillStyle='#e6f4ec'; ctx.fillRect(px,py,cs,cs);
895
+ ctx.fillStyle='rgba(34,197,94,0.18)'; ctx.fillRect(px+2,py+2,cs-4,cs-4);
896
+ break;
897
+ }
898
+ default: { // FLOOR, DOOR — Minecraft-style strong checkerboard
899
+ // Alternating tiles: warm sand-tan vs golden-tan (like reference image 2)
900
+ ctx.fillStyle = (x+y)%2===0 ? '#e8d8b8' : '#d0be98';
901
+ ctx.fillRect(px, py, cs, cs);
902
+ // Subtle inner bevel (top-left lighter, bottom-right darker)
903
+ ctx.fillStyle = 'rgba(255,255,255,0.20)';
904
+ ctx.fillRect(px, py, cs, 2); ctx.fillRect(px, py+2, 2, cs-2);
905
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
906
+ ctx.fillRect(px, py+cs-2, cs, 2); ctx.fillRect(px+cs-2, py, 2, cs-2);
907
+ }
908
+ }
909
+ }
910
+
911
+ // ── Fire rendering ────────────────────────────────────────────────────────────
912
+ function drawFireAmbient(ctx,x,y,fire,cs) {
913
+ // multiply: deeply warms floor around fire — reduce G+B channels
914
+ const px=x*cs+cs/2, py=y*cs+cs/2;
915
+ const r=cs*(3.0+fire*2.2);
916
+ const a=0.72+fire*0.25; // high alpha for vivid multiply effect
917
+ const gr=ctx.createRadialGradient(px,py,0,px,py,r);
918
+ gr.addColorStop(0, `rgba(255,50,0,${a})`); // deep red-orange core
919
+ gr.addColorStop(0.30, `rgba(255,90,0,${a*0.80})`); // orange mid
920
+ gr.addColorStop(0.60, `rgba(255,150,20,${a*0.45})`);
921
+ gr.addColorStop(0.85, `rgba(255,200,60,${a*0.15})`);
922
+ gr.addColorStop(1, 'rgba(255,255,255,0)');
923
+ ctx.fillStyle=gr;
924
+ ctx.fillRect(px-r,py-r,r*2,r*2);
925
+ }
926
+
927
+ function drawFireCell(ctx,x,y,fire,cs) {
928
+ const px=x*cs+cs/2, py=y*cs+cs/2;
929
+ const f1=0.82+0.18*Math.sin(time*9.5+x*2.7+y*3.14);
930
+ const eff=fire*f1;
931
+ // multiply blend: alpha values are high so colors deeply saturate the light floor
932
+ const layers=[
933
+ {r:cs*.35, a:.99, ox:0, oy:-cs*.10}, // white-hot core
934
+ {r:cs*.68, a:.95, ox:0, oy:0}, // inner flame
935
+ {r:cs*1.0, a:.82, ox:0, oy:0}, // flame body
936
+ {r:cs*1.55,a:.60, ox:0, oy:0}, // outer heat glow
937
+ ];
938
+ for(const {r,a,ox,oy} of layers) {
939
+ const cx=px+ox, cy=py+oy;
940
+ const gr=ctx.createRadialGradient(cx,cy,0,cx,cy,r);
941
+ gr.addColorStop(0, fireColor(eff, a));
942
+ gr.addColorStop(.35, fireColor(eff*.80, a*.75));
943
+ gr.addColorStop(.70, fireColor(eff*.40, a*.40));
944
+ gr.addColorStop(1, fireColor(eff*.10, 0));
945
+ ctx.fillStyle=gr;
946
+ ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
947
+ }
948
+ // White-hot core punch-through (visible even in multiply)
949
+ if(eff>.45) {
950
+ const gr2=ctx.createRadialGradient(px,py-cs*.12,0,px,py-cs*.12,cs*.20);
951
+ gr2.addColorStop(0,`rgba(255,200,50,${Math.min(1,(eff-.45)*2.2)})`);
952
+ gr2.addColorStop(1,'rgba(255,140,0,0)');
953
+ ctx.fillStyle=gr2;
954
+ ctx.beginPath(); ctx.arc(px,py-cs*.12,cs*.20,0,Math.PI*2); ctx.fill();
955
+ }
956
+ }
957
+
958
+ // ── Smoke ─────────────────────────────────────────────────────────────────────
959
+ function drawSmoke(ctx,x,y,smoke,cs,phase) {
960
+ const px=x*cs+cs/2, py=y*cs+cs/2;
961
+ const dx=Math.sin(time*.35+phase)*1.8, dy=Math.cos(time*.28+phase+1.2)*1.8;
962
+ const alpha=Math.min(0.85,smoke*.95);
963
+ const r=cs*.85;
964
+ ctx.save();
965
+ ctx.translate(px+dx,py+dy);
966
+ const gr=ctx.createRadialGradient(0,0,0,0,0,r);
967
+ gr.addColorStop(0,`rgba(72,82,96,${alpha})`);
968
+ gr.addColorStop(.5,`rgba(72,82,96,${alpha*.75})`);
969
+ gr.addColorStop(1,'rgba(72,82,96,0)');
970
+ ctx.fillStyle=gr;
971
+ ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.fill();
972
+ if(smoke>.3) {
973
+ const gr2=ctx.createRadialGradient(dy*1.5,dx*1.5,0,dy*1.5,dx*1.5,cs*.6);
974
+ gr2.addColorStop(0,`rgba(88,98,114,${alpha*.5})`);
975
+ gr2.addColorStop(1,'rgba(88,98,114,0)');
976
+ ctx.fillStyle=gr2;
977
+ ctx.beginPath(); ctx.arc(dy*1.5,dx*1.5,cs*.6,0,Math.PI*2); ctx.fill();
978
+ }
979
+ ctx.restore();
980
+ }
981
+
982
+ // ── Exit ──────────────────────────────────────────────────────────────────────
983
+ function drawExit(ctx,x,y,cs,fire) {
984
+ const px=x*cs, py=y*cs;
985
+ const pulse=0.62+0.38*Math.sin(time*2.8+x+y);
986
+ const blocked=fire>=EXIT_BLOCK;
987
+ const col=blocked?'#ef4444':'#22c55e';
988
+ ctx.save();
989
+ ctx.shadowBlur=cs*1.1*pulse; ctx.shadowColor=col;
990
+ ctx.strokeStyle=col; ctx.lineWidth=2;
991
+ ctx.strokeRect(px+2,py+2,cs-4,cs-4);
992
+ ctx.fillStyle=blocked?'rgba(220,38,38,.11)':'rgba(34,197,94,.11)';
993
+ ctx.fillRect(px+3,py+3,cs-6,cs-6);
994
+ ctx.shadowBlur=0;
995
+ const sa=0.55+0.35*pulse;
996
+ ctx.fillStyle=blocked?`rgba(220,38,38,${sa})`:`rgba(22,163,74,${sa})`;
997
+ ctx.font=`bold ${~~(cs*.50)}px sans-serif`;
998
+ ctx.textAlign='center'; ctx.textBaseline='middle';
999
+ ctx.fillText(blocked?'✕':'⇥',px+cs/2,py+cs/2+1);
1000
+ // Corner brackets
1001
+ ctx.fillStyle=blocked?'rgba(220,38,38,.85)':'rgba(22,163,74,.85)';
1002
+ const ca=5;
1003
+ ctx.fillRect(px,py,ca,1); ctx.fillRect(px,py,1,ca);
1004
+ ctx.fillRect(px+cs-ca,py,ca,1); ctx.fillRect(px+cs-1,py,1,ca);
1005
+ ctx.fillRect(px,py+cs-1,ca,1); ctx.fillRect(px,py+cs-ca,1,ca);
1006
+ ctx.fillRect(px+cs-ca,py+cs-1,ca,1); ctx.fillRect(px+cs-1,py+cs-ca,1,ca);
1007
+ ctx.restore();
1008
+ }
1009
+
1010
+ // ── Door ──────────────────────────────────────────────────────────────────────
1011
+ function drawDoor(ctx,x,y,ct,cs) {
1012
+ const px=x*cs, py=y*cs;
1013
+ if(ct===DOOR_OPEN) {
1014
+ ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.fillRect(px+4,py,cs-6,cs);
1015
+ ctx.fillStyle='#5c3621'; ctx.fillRect(px,py,4,cs);
1016
+ ctx.fillStyle='#291508'; ctx.fillRect(px,py,cs,2);
1017
+ ctx.fillStyle='rgba(125,211,252,0.10)'; ctx.fillRect(px+5,py+2,cs-7,cs-4);
1018
+ } else {
1019
+ ctx.fillStyle='#5c3621'; ctx.fillRect(px+2,py+1,cs-4,cs-2);
1020
+ ctx.fillStyle='#291508';
1021
+ ctx.fillRect(px,py,cs,2); ctx.fillRect(px,py+cs-2,cs,2);
1022
+ ctx.fillRect(px,py+2,2,cs-4); ctx.fillRect(px+cs-2,py+2,2,cs-4);
1023
+ ctx.fillStyle='rgba(0,0,0,0.35)';
1024
+ ctx.fillRect(px+4,py+~~(cs*.14),cs-8,1);
1025
+ ctx.fillRect(px+4,py+~~(cs*.50),cs-8,1);
1026
+ ctx.fillRect(px+4,py+~~(cs*.82),cs-8,1);
1027
+ ctx.fillStyle='rgba(0,0,0,0.13)';
1028
+ ctx.fillRect(px+4,py+~~(cs*.14)+2,cs-8,~~(cs*.34));
1029
+ ctx.fillRect(px+4,py+~~(cs*.50)+2,cs-8,~~(cs*.30));
1030
+ ctx.beginPath(); ctx.arc(px+cs-8,py+~~(cs/2),2.5,0,Math.PI*2);
1031
+ ctx.fillStyle='rgba(210,168,75,.88)'; ctx.fill();
1032
+ }
1033
+ }
1034
+
1035
+ // ── Agent trail ───────────────────────────────────────────────────────────────
1036
+ function updateTrail(ax,ay) {
1037
+ const px=ax*CS+CS/2, py=ay*CS+CS/2;
1038
+ const last=agentTrail[0];
1039
+ if(!last||Math.abs(last.px-px)>1||Math.abs(last.py-py)>1) {
1040
+ agentTrail.unshift({px,py});
1041
+ }
1042
+ while(agentTrail.length>14) agentTrail.pop();
1043
+ }
1044
+
1045
+ function drawTrail(ctx) {
1046
+ for(let i=1;i<agentTrail.length;i++) {
1047
+ const ratio = 1-i/agentTrail.length;
1048
+ const alpha = ratio * 0.70;
1049
+ const r = Math.max(1.5, ratio * CS * 0.24);
1050
+ ctx.beginPath();
1051
+ ctx.arc(agentTrail[i].px,agentTrail[i].py,r,0,Math.PI*2);
1052
+ ctx.fillStyle=`rgba(2,132,199,${alpha.toFixed(3)})`;
1053
+ ctx.fill();
1054
+ }
1055
+ }
1056
+
1057
+ // ── Agent color themes (body = shirt/arms, matches reference image) ───────────
1058
+ const AGENT_COLORS = {
1059
+ healthy: { body:'#3b82f6', dark:'#1d4ed8', ring:'#fbbf24', ringGlow:'#f59e0b' },
1060
+ moderate: { body:'#f97316', dark:'#c2410c', ring:'#fb923c', ringGlow:'#ea580c' },
1061
+ low: { body:'#dc2626', dark:'#991b1b', ring:'#f87171', ringGlow:'#dc2626' },
1062
+ evacuated:{ body:'#16a34a', dark:'#14532d', ring:'#4ade80', ringGlow:'#22c55e' },
1063
+ };
1064
+
1065
+ // ── Minecraft-style blocky pixel character ────────────────────────────────────
1066
+ // Drawn entirely with fillRect — crisp pixel art, no anti-aliasing curves.
1067
+ // Layout (in "units" u = cs/18 ≈ 2px):
1068
+ // Rows 0-1 : Helmet (10u wide incl brim, 2u tall)
1069
+ // Rows 2-5 : Head (8u wide, 4u tall, skin + eyes)
1070
+ // Rows 6-10: Body+Arms (body 6u, arms 2u each side → 10u total, 5u tall)
1071
+ // Rows 11-15: Legs (3u each, 5u tall)
1072
+ // Total: 16u tall × 10u wide → 32px × 20px at cs=36
1073
+ function drawMinecraftAgent(ctx, px, py, cs, theme) {
1074
+ const u = cs / 18; // ~2px at cs=36
1075
+ const ow = 10 * u; // total character width
1076
+ const oh = 16 * u; // total character height
1077
+ const ox = Math.round(px - ow / 2); // top-left X
1078
+ const oy = Math.round(py - oh / 2); // top-left Y (centered on cell)
1079
+
1080
+ // ── Color palette ──
1081
+ const skin = '#f4c08a'; // face / skin
1082
+ const helmTop = '#b8c8d8'; // hard-hat top — steel blue-gray
1083
+ const helmBrm = '#7a98ae'; // brim — darker
1084
+ const helmVis = '#d4e8f8'; // visor stripe — pale blue
1085
+ const body = theme.body; // health-coded shirt
1086
+ const bodyShd = theme.dark; // shirt shadow / dark side
1087
+ const pants = '#1e3a6e'; // navy trousers
1088
+ const pantsShd= '#142856';
1089
+ const eye = '#1a1818';
1090
+ const skin2 = '#e8a870'; // skin shadow / cheek
1091
+
1092
+ // helper: fill a rectangle in character-local units
1093
+ const r = (col, dx, dy, dw, dh) => {
1094
+ ctx.fillStyle = col;
1095
+ ctx.fillRect(ox + dx*u, oy + dy*u, dw*u, dh*u);
1096
+ };
1097
+
1098
+ // ─── HELMET ────────────────────────────────────────────────────────────────
1099
+ r(helmBrm, 0, 0, 10, 1); // brim (full width)
1100
+ r(helmTop, 1, 1, 8, 1); // dome top
1101
+ r(helmVis, 2, 1, 6, 0.5); // visor line on brim
1102
+
1103
+ // ─── HEAD ──────────────────────────────────────────────────────────────────
1104
+ r(skin, 1, 2, 8, 4); // face (skin)
1105
+ r(skin2, 1, 5, 8, 1); // chin / lower shadow
1106
+ r(eye, 2, 3, 1, 1); // left eye
1107
+ r(eye, 5, 3, 1, 1); // right eye
1108
+ r('rgba(160,80,40,0.35)', 3, 5, 2, 0.5); // mouth hint
1109
+
1110
+ // ─── BODY ──────────────────────────────────────────────────────────────────
1111
+ r(body, 2, 6, 6, 5); // torso
1112
+ r(bodyShd, 2, 6, 1, 5); // left-edge shadow (depth)
1113
+ r(bodyShd, 2,10, 6, 1); // bottom shadow
1114
+
1115
+ // ─── ARMS ──────────────────────────────────────────────────────────────────
1116
+ r(body, 0, 6, 2, 5); // left arm
1117
+ r(bodyShd, 0, 6, 1, 5); // left arm shadow
1118
+ r(body, 8, 6, 2, 5); // right arm
1119
+ r(bodyShd, 9, 6, 1, 5); // right arm right-edge shadow
1120
+
1121
+ // ─── LEGS ──────────────────────────────────────────────────────────────────
1122
+ r(pants, 2, 11, 3, 5); // left leg
1123
+ r(pantsShd, 2, 11, 1, 5); // left leg shadow
1124
+ r(pants, 5, 11, 3, 5); // right leg
1125
+ r(pantsShd, 5, 11, 1, 5); // right leg shadow
1126
+ r('rgba(0,0,0,0.18)', 4, 11, 1, 5); // centre gap between legs
1127
+ }
1128
+
1129
+ // ── Agent: golden aura + Minecraft character + health arc ────────────────────
1130
+ function drawAgent(ctx, ax, ay, health, alive, evacuated, cs) {
1131
+ if (!alive && !evacuated) return;
1132
+ const px = ax*cs + cs/2, py = ay*cs + cs/2;
1133
+ // Aura pulse — two independent oscillators (matching original frontend)
1134
+ const pulse2 = 0.68 + 0.32*Math.sin(time*2.1 + 1.0);
1135
+ const pulse3 = 0.80 + 0.20*Math.sin(time*4.8); // faster inner shimmer
1136
+
1137
+ const hRatio = Math.max(0, Math.min(1, health/100));
1138
+ const theme = evacuated ? AGENT_COLORS.evacuated
1139
+ : hRatio > 0.6 ? AGENT_COLORS.healthy
1140
+ : hRatio > 0.3 ? AGENT_COLORS.moderate
1141
+ : AGENT_COLORS.low;
1142
+
1143
+ const arcBaseR = cs * 0.42; // fixed radius (no size-pulse — keeps pixel char stable)
1144
+
1145
+ // ── 1. GOLDEN OUTER GLOW (large halo — like reference image) ──────────────
1146
+ ctx.save();
1147
+ const auraR = cs * 0.85 * pulse2;
1148
+ const aGr = ctx.createRadialGradient(px, py, arcBaseR*0.55, px, py, auraR);
1149
+ aGr.addColorStop(0, 'rgba(0,0,0,0)');
1150
+ aGr.addColorStop(0.40, `rgba(251,191,36,${0.28 * pulse3})`); // gold inner
1151
+ aGr.addColorStop(0.72, `rgba(245,158,11,${0.14 * pulse3})`); // amber mid
1152
+ aGr.addColorStop(1, 'rgba(0,0,0,0)');
1153
+ ctx.fillStyle = aGr;
1154
+ ctx.beginPath(); ctx.arc(px, py, auraR, 0, Math.PI*2); ctx.fill();
1155
+ ctx.restore();
1156
+
1157
+ // ── 2. GROUND SHADOW (ellipse under feet) ─────────────────────────────────
1158
+ ctx.save();
1159
+ ctx.fillStyle = 'rgba(0,0,0,0.22)';
1160
+ ctx.beginPath();
1161
+ ctx.ellipse(px + 1, py + cs*0.46, cs*0.24, cs*0.055, 0, 0, Math.PI*2);
1162
+ ctx.fill();
1163
+ ctx.restore();
1164
+
1165
+ // ── 3. CHARACTER SHADOW GLOW (subtle colored shadow under sprite) ──────────
1166
+ ctx.save();
1167
+ ctx.shadowBlur = cs * 0.55;
1168
+ ctx.shadowColor = theme.ring;
1169
+ // Draw an invisible rect at the character centre just to emit the shadow
1170
+ ctx.fillStyle = 'rgba(0,0,0,0)';
1171
+ ctx.fillRect(px - 1, py - 1, 2, 2);
1172
+ ctx.restore();
1173
+
1174
+ // ── 4. MINECRAFT CHARACTER (crisp pixel art — no transforms) ──────────────
1175
+ drawMinecraftAgent(ctx, px, py, cs, theme);
1176
+
1177
+ // ── 5. HEALTH ARC RING (gold/orange/red — clockwise from 12 o'clock) ──────
1178
+ ctx.save();
1179
+ const arcR = arcBaseR * 1.68;
1180
+
1181
+ // Track ring (always full circle, subtle)
1182
+ ctx.beginPath(); ctx.arc(px, py, arcR, 0, Math.PI*2);
1183
+ ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.lineWidth = 4; ctx.stroke();
1184
+
1185
+ // Health fill arc
1186
+ if (hRatio > 0) {
1187
+ const arcCol = theme.ring;
1188
+ const arcGlow = theme.ringGlow;
1189
+ ctx.beginPath();
1190
+ ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + hRatio*Math.PI*2);
1191
+ ctx.strokeStyle = arcCol;
1192
+ ctx.lineWidth = 4.5;
1193
+ ctx.lineCap = 'round';
1194
+ // Double-stroke for bright glow (same technique as original frontend)
1195
+ ctx.shadowBlur = 14;
1196
+ ctx.shadowColor = arcGlow;
1197
+ ctx.stroke();
1198
+ ctx.shadowBlur = 6;
1199
+ ctx.stroke();
1200
+ }
1201
+ ctx.restore();
1202
+ }
1203
+
1204
+ // ── Mini sparkline chart ──────────────────────────────────────────────────────
1205
+ function drawSparkline(canvasEl, data, color, fill) {
1206
+ if(!canvasEl) return;
1207
+ const c=canvasEl.getContext('2d');
1208
+ const w=canvasEl.offsetWidth||canvasEl.parentElement.clientWidth||240;
1209
+ const h=canvasEl.height;
1210
+ canvasEl.width=w;
1211
+ c.fillStyle='#faf9f6'; c.fillRect(0,0,w,h);
1212
+ if(data.length<2) return;
1213
+ const yMin=Math.min(...data), yMax=Math.max(...data);
1214
+ const yRange=(yMax-yMin)||1;
1215
+ const pad=4;
1216
+ // Grid lines
1217
+ c.strokeStyle='rgba(0,0,0,0.06)'; c.lineWidth=.5;
1218
+ for(let i=0;i<=2;i++){
1219
+ const gy=pad+(h-2*pad)*(i/2);
1220
+ c.beginPath(); c.moveTo(0,gy); c.lineTo(w,gy); c.stroke();
1221
+ }
1222
+ const toX=i=>(i/(data.length-1))*w;
1223
+ const toY=v=>h-pad-((v-yMin)/yRange)*(h-2*pad);
1224
+ // Fill area
1225
+ c.beginPath(); c.moveTo(toX(0),h);
1226
+ for(let i=0;i<data.length;i++) c.lineTo(toX(i),toY(data[i]));
1227
+ c.lineTo(toX(data.length-1),h); c.closePath();
1228
+ c.fillStyle=fill; c.fill();
1229
+ // Line
1230
+ c.beginPath(); c.moveTo(toX(0),toY(data[0]));
1231
+ for(let i=1;i<data.length;i++) c.lineTo(toX(i),toY(data[i]));
1232
+ c.strokeStyle=color; c.lineWidth=1.8; c.stroke();
1233
+ // Dot
1234
+ const lx=toX(data.length-1), ly=toY(data[data.length-1]);
1235
+ c.beginPath(); c.arc(lx,ly,3,0,Math.PI*2);
1236
+ c.fillStyle=color; c.fill();
1237
+ }
1238
+
1239
+ // ── HUD & UI updates ──────────────────────────────────────────────────────────
1240
+ function updateHUD(s) {
1241
+ // Health bar
1242
+ const hPct=s.agentHealth/100;
1243
+ const bar=document.getElementById('healthBar');
1244
+ if(bar){ bar.style.width=`${s.agentHealth}%`; bar.className='hbar-fill '+(hPct>.6?'g':hPct>.3?'m':'c'); }
1245
+ setText('healthVal', Math.round(s.agentHealth));
1246
+
1247
+ const status = s.agentHealth>75?'Good':s.agentHealth>50?'Moderate':s.agentHealth>25?'Low':'Critical';
1248
+ const hStatus=document.getElementById('healthStatus');
1249
+ if(hStatus){ hStatus.textContent=status; hStatus.className='hstatus '+status.toLowerCase(); }
1250
+
1251
+ // Smoke
1252
+ const smoke=s.smokeGrid[s.agentY*W+s.agentX];
1253
+ const smokeLabel=smoke>.8?'heavy':smoke>.5?'moderate':smoke>.2?'light':'clear';
1254
+ setText('smokeTag',`smoke: ${smokeLabel}`);
1255
+
1256
+ // Steps
1257
+ setText('stepCount',`${s.stepCount} / ${s.maxSteps}`);
1258
+ const sb=document.getElementById('stepBar');
1259
+ if(sb) sb.style.width=`${100*s.stepCount/s.maxSteps}%`;
1260
+ setText('diffTag',`${s.difficulty} · small_office · ${s.windDir}`);
1261
+
1262
+ // Episode banner
1263
+ const banner=document.getElementById('epBanner');
1264
+ if(banner) {
1265
+ if(s.agentEvacuated){ banner.textContent='✓ EVACUATED — reached safety'; banner.className='ok'; banner.style.display='block'; }
1266
+ else if(!s.agentAlive){ banner.textContent='✗ INCAPACITATED'; banner.className='bad'; banner.style.display='block'; }
1267
+ else if(s.stepCount>=s.maxSteps){ banner.textContent='⏱ TIMEOUT'; banner.className='tmo'; banner.style.display='block'; }
1268
+ else banner.style.display='none';
1269
+ }
1270
+
1271
+ // Side panel
1272
+ setText('sHealthVal', Math.round(s.agentHealth));
1273
+ setText('sStatusVal', status);
1274
+ setText('sSmokeVal', smokeLabel);
1275
+ setText('sRewardVal', s.totalReward.toFixed(2));
1276
+ const fireCells=s.fireSizeHistory[s.fireSizeHistory.length-1]||0;
1277
+ setText('sFireVal', fireCells);
1278
+ setText('sStepVal', s.stepCount);
1279
+ setText('sWindVal', s.windDir);
1280
+ setText('sSpreadVal',s.pSpread.toFixed(2));
1281
+ const burnedPct=Math.round(100*Array.from(s.fireGrid).filter(f=>f>0).length/(W*H));
1282
+ setText('sBurnVal',`${burnedPct}% burned`);
1283
+ const exitBlock=EXITS.filter(([ex,ey])=>s.fireGrid[ey*W+ex]>=EXIT_BLOCK).length;
1284
+ setText('sExitBlocked',`${exitBlock} / ${EXITS.length}`);
1285
+ document.getElementById('sExitBlocked').style.color = exitBlock>0?'var(--fire)':'var(--green)';
1286
+
1287
+ // Reward delta
1288
+ if(s.lastReward!==undefined) {
1289
+ const lr=s.lastReward;
1290
+ const el=document.getElementById('sRewardLast');
1291
+ if(el){ el.textContent=(lr>0?'+':'')+lr.toFixed(2); el.style.color=lr>0?'var(--green)':'var(--red)'; }
1292
+ }
1293
+
1294
+ // Charts
1295
+ drawSparkline(document.getElementById('rewardChart'), s.rewardHistory,'#1d4ed8','rgba(29,78,216,0.08)');
1296
+ drawSparkline(document.getElementById('fireChart'), s.fireSizeHistory,'#c2410c','rgba(194,65,12,0.08)');
1297
+ const fl=document.getElementById('sFireLast');
1298
+ if(fl) fl.textContent=`${fireCells} cells`;
1299
+
1300
+ // Event log
1301
+ const log=document.getElementById('eventLog');
1302
+ if(log) {
1303
+ log.innerHTML=s.eventLog.slice(0,18).map(e=>`
1304
+ <div class="erow${e.isAlert?' alarm':''}">
1305
+ <span class="estep">${String(e.step).padStart(3,'0')}</span>
1306
+ <span class="etext">${e.text}</span>
1307
+ ${e.reward?`<span class="erwd ${e.reward>0?'p':'n'}">${e.reward>0?'+':''}${e.reward.toFixed(2)}</span>`:''}
1308
+ </div>`).join('');
1309
+ }
1310
+ }
1311
+
1312
+ function setText(id,val) {
1313
+ const el=document.getElementById(id);
1314
+ if(el) el.textContent=val;
1315
+ }
1316
+
1317
+ function showToast(msg) {
1318
+ const t=document.getElementById('toast');
1319
+ if(!t) return;
1320
+ t.textContent=msg; t.classList.add('up');
1321
+ setTimeout(()=>t.classList.remove('up'),3000);
1322
+ }
1323
+
1324
+ // ── App controller ────────────────────────────────────────────────────────────
1325
+ let playing=false, speed=1, stepTimer=null, rafId=null;
1326
+ let difficulty='easy';
1327
+
1328
+ function getSeed() { return parseInt(document.getElementById('seedInput')?.value)||42; }
1329
+
1330
+ function newEpisode() {
1331
+ clearTimer();
1332
+ state = buildState(difficulty, getSeed());
1333
+ initRender();
1334
+ updateHUD(state);
1335
+ setText('liveChip','Demo Mode');
1336
+ document.getElementById('epBanner').style.display='none';
1337
+ playing=false; updatePlayBtn();
1338
+ setTimeout(()=>{ playing=true; updatePlayBtn(); startTimer(); },350);
1339
+ }
1340
+
1341
+ function tick() {
1342
+ if(!state) return;
1343
+ doStep(state);
1344
+ updateHUD(state);
1345
+ if(state.agentEvacuated||!state.agentAlive||state.stepCount>=state.maxSteps) {
1346
+ playing=false; clearTimer(); updatePlayBtn();
1347
+ setTimeout(()=>newEpisode(), 2800);
1348
+ }
1349
+ }
1350
+
1351
+ function startTimer() {
1352
+ clearTimer();
1353
+ stepTimer=setInterval(tick, Math.max(50,1000/speed));
1354
+ }
1355
+ function clearTimer() { if(stepTimer){ clearInterval(stepTimer); stepTimer=null; } }
1356
+ function togglePlay() {
1357
+ playing=!playing;
1358
+ if(playing) startTimer(); else clearTimer();
1359
+ updatePlayBtn();
1360
+ }
1361
+ function updatePlayBtn() {
1362
+ const b=document.getElementById('playBtn');
1363
+ if(b){ b.textContent=playing?'⏸':'▶'; b.classList.toggle('play',!playing); }
1364
+ }
1365
+
1366
+ // Render loop (independent of sim tick rate)
1367
+ function startRender() {
1368
+ const loop=()=>{
1369
+ if(state) renderFrame(state);
1370
+ rafId=requestAnimationFrame(loop);
1371
+ };
1372
+ rafId=requestAnimationFrame(loop);
1373
+ }
1374
+
1375
+ // ── Controls ──────────────────────────────────────────────────────────────────
1376
+ document.getElementById('playBtn')?.addEventListener('click',togglePlay);
1377
+ document.getElementById('stepBtn')?.addEventListener('click',()=>{ if(state&&!state.agentEvacuated&&state.agentAlive) { doStep(state); updateHUD(state); } });
1378
+ document.getElementById('resetBtn')?.addEventListener('click',newEpisode);
1379
+
1380
+ document.querySelectorAll('.diff-btn').forEach(b=>{
1381
+ b.addEventListener('click',()=>{
1382
+ document.querySelectorAll('.diff-btn').forEach(x=>{x.classList.remove('on','fire');});
1383
+ b.classList.add('on','fire');
1384
+ difficulty=b.dataset.diff;
1385
+ newEpisode();
1386
+ });
1387
+ });
1388
+
1389
+ document.querySelectorAll('.spd-btn').forEach(b=>{
1390
+ b.addEventListener('click',()=>{
1391
+ document.querySelectorAll('.spd-btn').forEach(x=>x.classList.remove('on'));
1392
+ b.classList.add('on'); speed=parseFloat(b.dataset.s);
1393
+ if(playing){ clearTimer(); startTimer(); }
1394
+ });
1395
+ });
1396
+
1397
+ document.getElementById('seedInput')?.addEventListener('change',newEpisode);
1398
+
1399
+ window.addEventListener('keydown',e=>{
1400
+ if(e.target.tagName==='INPUT') return;
1401
+ if(e.code==='Space'){ e.preventDefault(); togglePlay(); }
1402
+ else if(e.code==='ArrowRight'&&state&&!state.agentEvacuated&&state.agentAlive){ doStep(state); updateHUD(state); }
1403
+ else if(e.code==='KeyR') newEpisode();
1404
+ });
1405
+
1406
+ // ── Boot ──────────────────────────────────────────────────────────────────────
1407
+ window.addEventListener('DOMContentLoaded',()=>{
1408
+ newEpisode();
1409
+ startRender();
1410
+ showToast('Press Space to pause · → to step · R to reset');
1411
+ });
1412
+ </script>
1413
+ </body>
1414
+ </html>
evals_hf.ipynb CHANGED
@@ -26,10 +26,18 @@
26
  },
27
  {
28
  "cell_type": "code",
29
- "execution_count": null,
30
  "id": "c3d4e5f6",
31
  "metadata": {},
32
- "outputs": [],
 
 
 
 
 
 
 
 
33
  "source": [
34
  "import csv\n",
35
  "import json\n",
@@ -65,7 +73,7 @@
65
  },
66
  {
67
  "cell_type": "markdown",
68
- "id": "d4e5f6a7",
69
  "metadata": {},
70
  "source": [
71
  "## Cell 2 — Config ✏️ Edit this cell to change eval settings"
@@ -73,10 +81,23 @@
73
  },
74
  {
75
  "cell_type": "code",
76
- "execution_count": null,
77
  "id": "e5f6a7b8",
78
  "metadata": {},
79
- "outputs": [],
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  "source": [
81
  "# ── Model ─────────────────────────────────────────────────────────────────────\n",
82
  "MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
@@ -123,10 +144,19 @@
123
  },
124
  {
125
  "cell_type": "code",
126
- "execution_count": null,
127
  "id": "a7b8c9d0",
128
  "metadata": {},
129
- "outputs": [],
 
 
 
 
 
 
 
 
 
130
  "source": [
131
  "ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
132
  " {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
@@ -195,10 +225,18 @@
195
  },
196
  {
197
  "cell_type": "code",
198
- "execution_count": null,
199
  "id": "c9d0e1f2",
200
  "metadata": {},
201
- "outputs": [],
 
 
 
 
 
 
 
 
202
  "source": [
203
  "class HFChatModel(SimpleChatModel):\n",
204
  " \"\"\"\n",
@@ -308,10 +346,18 @@
308
  },
309
  {
310
  "cell_type": "code",
311
- "execution_count": null,
312
  "id": "e1f2a3b4",
313
  "metadata": {},
314
- "outputs": [],
 
 
 
 
 
 
 
 
315
  "source": [
316
  "def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
317
  " \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
@@ -466,10 +512,18 @@
466
  },
467
  {
468
  "cell_type": "code",
469
- "execution_count": null,
470
  "id": "a3b4c5d6",
471
  "metadata": {},
472
- "outputs": [],
 
 
 
 
 
 
 
 
473
  "source": [
474
  "def run_episode(\n",
475
  " llm: HFChatModel,\n",
@@ -637,10 +691,54 @@
637
  },
638
  {
639
  "cell_type": "code",
640
- "execution_count": null,
641
  "id": "c5d6e7f8",
642
  "metadata": {},
643
- "outputs": [],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  "source": [
645
  "# Health check first — fail fast before waiting for the large model to load\n",
646
  "try:\n",
@@ -855,13 +953,9 @@
855
  ],
856
  "metadata": {
857
  "kernelspec": {
858
- "display_name": "Python 3",
859
  "language": "python",
860
  "name": "python3"
861
- },
862
- "language_info": {
863
- "name": "python",
864
- "version": "3.10.0"
865
  }
866
  },
867
  "nbformat": 4,
 
26
  },
27
  {
28
  "cell_type": "code",
29
+ "execution_count": 1,
30
  "id": "c3d4e5f6",
31
  "metadata": {},
32
+ "outputs": [
33
+ {
34
+ "name": "stdout",
35
+ "output_type": "stream",
36
+ "text": [
37
+ "Imports OK\n"
38
+ ]
39
+ }
40
+ ],
41
  "source": [
42
  "import csv\n",
43
  "import json\n",
 
73
  },
74
  {
75
  "cell_type": "markdown",
76
+ "id": "1f1d9271",
77
  "metadata": {},
78
  "source": [
79
  "## Cell 2 — Config ✏️ Edit this cell to change eval settings"
 
81
  },
82
  {
83
  "cell_type": "code",
84
+ "execution_count": 2,
85
  "id": "e5f6a7b8",
86
  "metadata": {},
87
+ "outputs": [
88
+ {
89
+ "name": "stdout",
90
+ "output_type": "stream",
91
+ "text": [
92
+ "Model : Qwen/Qwen3-1.7B\n",
93
+ "4-bit : False\n",
94
+ "Temperature: 0.3\n",
95
+ "Levels : all 3 (easy, medium, hard)\n",
96
+ "Seeds : [1, 2, 3]\n",
97
+ "Output dir : ./outputs/hf_evals\n"
98
+ ]
99
+ }
100
+ ],
101
  "source": [
102
  "# ── Model ─────────────────────────────────────────────────────────────────────\n",
103
  "MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
 
144
  },
145
  {
146
  "cell_type": "code",
147
+ "execution_count": 3,
148
  "id": "a7b8c9d0",
149
  "metadata": {},
150
+ "outputs": [
151
+ {
152
+ "name": "stdout",
153
+ "output_type": "stream",
154
+ "text": [
155
+ "Difficulties to evaluate : ['easy', 'medium', 'hard']\n",
156
+ "Total episodes : 9\n"
157
+ ]
158
+ }
159
+ ],
160
  "source": [
161
  "ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
162
  " {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
 
225
  },
226
  {
227
  "cell_type": "code",
228
+ "execution_count": 4,
229
  "id": "c9d0e1f2",
230
  "metadata": {},
231
+ "outputs": [
232
+ {
233
+ "name": "stdout",
234
+ "output_type": "stream",
235
+ "text": [
236
+ "HFChatModel class defined.\n"
237
+ ]
238
+ }
239
+ ],
240
  "source": [
241
  "class HFChatModel(SimpleChatModel):\n",
242
  " \"\"\"\n",
 
346
  },
347
  {
348
  "cell_type": "code",
349
+ "execution_count": 5,
350
  "id": "e1f2a3b4",
351
  "metadata": {},
352
+ "outputs": [
353
+ {
354
+ "name": "stdout",
355
+ "output_type": "stream",
356
+ "text": [
357
+ "Prompt builder and action parser defined.\n"
358
+ ]
359
+ }
360
+ ],
361
  "source": [
362
  "def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
363
  " \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
 
512
  },
513
  {
514
  "cell_type": "code",
515
+ "execution_count": 6,
516
  "id": "a3b4c5d6",
517
  "metadata": {},
518
+ "outputs": [
519
+ {
520
+ "name": "stdout",
521
+ "output_type": "stream",
522
+ "text": [
523
+ "Episode runner defined.\n"
524
+ ]
525
+ }
526
+ ],
527
  "source": [
528
  "def run_episode(\n",
529
  " llm: HFChatModel,\n",
 
691
  },
692
  {
693
  "cell_type": "code",
694
+ "execution_count": 7,
695
  "id": "c5d6e7f8",
696
  "metadata": {},
697
+ "outputs": [
698
+ {
699
+ "ename": "RuntimeError",
700
+ "evalue": "Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
701
+ "output_type": "error",
702
+ "traceback": [
703
+ "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
704
+ "\u001b[31mConnectionRefusedError\u001b[39m Traceback (most recent call last)",
705
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:204\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 203\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m204\u001b[39m sock = \u001b[30;43mconnection\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mcreate_connection\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 205\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_dns_host\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mport\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 206\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 207\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 208\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 209\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 210\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m socket.gaierror \u001b[38;5;28;01mas\u001b[39;00m e:\n",
706
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:85\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 84\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m---> \u001b[39m\u001b[32m85\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m err\n\u001b[32m 86\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[32m 87\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
707
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:73\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 72\u001b[39m sock.bind(source_address)\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[30;43msock\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43msa\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 74\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
708
+ "\u001b[31mConnectionRefusedError\u001b[39m: [Errno 111] Connection refused",
709
+ "\nThe above exception was the direct cause of the following exception:\n",
710
+ "\u001b[31mNewConnectionError\u001b[39m Traceback (most recent call last)",
711
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:787\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 786\u001b[39m \u001b[38;5;66;03m# Make the request on the HTTPConnection object\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m787\u001b[39m response = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_make_request\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 788\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mconn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 789\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 790\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 791\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout_obj\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 792\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 793\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 794\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 795\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 796\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 797\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 798\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 799\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mresponse_kw\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 800\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 802\u001b[39m \u001b[38;5;66;03m# Everything went great!\u001b[39;00m\n",
712
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:493\u001b[39m, in \u001b[36mHTTPConnectionPool._make_request\u001b[39m\u001b[34m(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 492\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m493\u001b[39m \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 494\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 495\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 496\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 497\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 498\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 499\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 500\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 501\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 504\u001b[39m \u001b[38;5;66;03m# We are swallowing BrokenPipeError (errno.EPIPE) since the server is\u001b[39;00m\n\u001b[32m 505\u001b[39m \u001b[38;5;66;03m# legitimately able to close the connection after sending a valid response.\u001b[39;00m\n\u001b[32m 506\u001b[39m \u001b[38;5;66;03m# With this behaviour, the received response is still readable.\u001b[39;00m\n",
713
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:500\u001b[39m, in \u001b[36mHTTPConnection.request\u001b[39m\u001b[34m(self, method, url, body, headers, chunked, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 499\u001b[39m \u001b[38;5;28mself\u001b[39m.putheader(header, value)\n\u001b[32m--> \u001b[39m\u001b[32m500\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mendheaders\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[38;5;66;03m# If we're given a body we start sending that in chunks.\u001b[39;00m\n",
714
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1333\u001b[39m, in \u001b[36mHTTPConnection.endheaders\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1332\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m CannotSendHeader()\n\u001b[32m-> \u001b[39m\u001b[32m1333\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_send_output\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmessage_body\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
715
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1093\u001b[39m, in \u001b[36mHTTPConnection._send_output\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1092\u001b[39m \u001b[38;5;28;01mdel\u001b[39;00m \u001b[38;5;28mself\u001b[39m._buffer[:]\n\u001b[32m-> \u001b[39m\u001b[32m1093\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmsg\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1095\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m message_body \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 1096\u001b[39m \n\u001b[32m 1097\u001b[39m \u001b[38;5;66;03m# create a consistent interface to message_body\u001b[39;00m\n",
716
+ "\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1037\u001b[39m, in \u001b[36mHTTPConnection.send\u001b[39m\u001b[34m(self, data)\u001b[39m\n\u001b[32m 1036\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m.auto_open:\n\u001b[32m-> \u001b[39m\u001b[32m1037\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1038\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m:\n",
717
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:331\u001b[39m, in \u001b[36mHTTPConnection.connect\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 330\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mconnect\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m331\u001b[39m \u001b[38;5;28mself\u001b[39m.sock = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_new_conn\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 332\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m._tunnel_host:\n\u001b[32m 333\u001b[39m \u001b[38;5;66;03m# If we're tunneling it means we're connected to our proxy.\u001b[39;00m\n",
718
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:219\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 218\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mOSError\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m--> \u001b[39m\u001b[32m219\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m NewConnectionError(\n\u001b[32m 220\u001b[39m \u001b[38;5;28mself\u001b[39m, \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mFailed to establish a new connection: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00me\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 221\u001b[39m ) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01me\u001b[39;00m\n\u001b[32m 223\u001b[39m sys.audit(\u001b[33m\"\u001b[39m\u001b[33mhttp.client.connect\u001b[39m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m, \u001b[38;5;28mself\u001b[39m.host, \u001b[38;5;28mself\u001b[39m.port)\n",
719
+ "\u001b[31mNewConnectionError\u001b[39m: HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused",
720
+ "\nThe above exception was the direct cause of the following exception:\n",
721
+ "\u001b[31mMaxRetryError\u001b[39m Traceback (most recent call last)",
722
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:645\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 644\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m645\u001b[39m resp = \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43murlopen\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 646\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 647\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 648\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 649\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 650\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mredirect\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 651\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43massert_same_host\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 652\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 653\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 654\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmax_retries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 655\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 656\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 657\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 659\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m (ProtocolError, \u001b[38;5;167;01mOSError\u001b[39;00m) \u001b[38;5;28;01mas\u001b[39;00m err:\n",
723
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:841\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 839\u001b[39m new_e = ProtocolError(\u001b[33m\"\u001b[39m\u001b[33mConnection aborted.\u001b[39m\u001b[33m\"\u001b[39m, new_e)\n\u001b[32m--> \u001b[39m\u001b[32m841\u001b[39m retries = \u001b[30;43mretries\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mincrement\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 842\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43merror\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mnew_e\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_pool\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_stacktrace\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43msys\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mexc_info\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m[\u001b[39;49m\u001b[30;43m2\u001b[39;49m\u001b[30;43m]\u001b[39;49m\n\u001b[32m 843\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 844\u001b[39m retries.sleep()\n",
724
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/retry.py:535\u001b[39m, in \u001b[36mRetry.increment\u001b[39m\u001b[34m(self, method, url, response, error, _pool, _stacktrace)\u001b[39m\n\u001b[32m 534\u001b[39m reason = error \u001b[38;5;129;01mor\u001b[39;00m ResponseError(cause)\n\u001b[32m--> \u001b[39m\u001b[32m535\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m MaxRetryError(_pool, url, reason) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mreason\u001b[39;00m \u001b[38;5;66;03m# type: ignore[arg-type]\u001b[39;00m\n\u001b[32m 537\u001b[39m log.debug(\u001b[33m\"\u001b[39m\u001b[33mIncremented Retry for (url=\u001b[39m\u001b[33m'\u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m'\u001b[39m\u001b[33m): \u001b[39m\u001b[38;5;132;01m%r\u001b[39;00m\u001b[33m\"\u001b[39m, url, new_retry)\n",
725
+ "\u001b[31mMaxRetryError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
726
+ "\nDuring handling of the above exception, another exception occurred:\n",
727
+ "\u001b[31mConnectionError\u001b[39m Traceback (most recent call last)",
728
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n",
729
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:73\u001b[39m, in \u001b[36mget\u001b[39m\u001b[34m(url, params, **kwargs)\u001b[39m\n\u001b[32m 63\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33mr\u001b[39m\u001b[33;03m\"\"\"Sends a GET request.\u001b[39;00m\n\u001b[32m 64\u001b[39m \n\u001b[32m 65\u001b[39m \u001b[33;03m:param url: URL for the new :class:`Request` object.\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 70\u001b[39m \u001b[33;03m:rtype: requests.Response\u001b[39;00m\n\u001b[32m 71\u001b[39m \u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43mget\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
730
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:59\u001b[39m, in \u001b[36mrequest\u001b[39m\u001b[34m(method, url, **kwargs)\u001b[39m\n\u001b[32m 58\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m sessions.Session() \u001b[38;5;28;01mas\u001b[39;00m session:\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43msession\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
731
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:592\u001b[39m, in \u001b[36mSession.request\u001b[39m\u001b[34m(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)\u001b[39m\n\u001b[32m 591\u001b[39m send_kwargs.update(settings)\n\u001b[32m--> \u001b[39m\u001b[32m592\u001b[39m resp = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mprep\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43msend_kwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 594\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m resp\n",
732
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:706\u001b[39m, in \u001b[36mSession.send\u001b[39m\u001b[34m(self, request, **kwargs)\u001b[39m\n\u001b[32m 705\u001b[39m \u001b[38;5;66;03m# Send the request\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m706\u001b[39m r = \u001b[30;43madapter\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 708\u001b[39m \u001b[38;5;66;03m# Total elapsed time of the request (approximately)\u001b[39;00m\n",
733
+ "\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:678\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 676\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m SSLError(e, request=request)\n\u001b[32m--> \u001b[39m\u001b[32m678\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mConnectionError\u001b[39;00m(e, request=request)\n\u001b[32m 680\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m ClosedPoolError \u001b[38;5;28;01mas\u001b[39;00m e:\n",
734
+ "\u001b[31mConnectionError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
735
+ "\nDuring handling of the above exception, another exception occurred:\n",
736
+ "\u001b[31mRuntimeError\u001b[39m Traceback (most recent call last)",
737
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 3\u001b[39m health = requests.get(f\"{ENV_URL}/health\", timeout=\u001b[32m5\u001b[39m)\n\u001b[32m 4\u001b[39m health.raise_for_status()\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n\u001b[32m 9\u001b[39m \u001b[38;5;66;03m# Build and load the HF model — this is the expensive step\u001b[39;00m\n\u001b[32m 10\u001b[39m llm = HFChatModel(\n",
738
+ "\u001b[31mRuntimeError\u001b[39m: Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))"
739
+ ]
740
+ }
741
+ ],
742
  "source": [
743
  "# Health check first — fail fast before waiting for the large model to load\n",
744
  "try:\n",
 
953
  ],
954
  "metadata": {
955
  "kernelspec": {
956
+ "display_name": "Python 3 (ipykernel)",
957
  "language": "python",
958
  "name": "python3"
 
 
 
 
959
  }
960
  },
961
  "nbformat": 4,
frontend/README.md CHANGED
@@ -1,73 +1,93 @@
1
- # React + TypeScript + Vite
2
-
3
- This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
-
5
- Currently, two official plugins are available:
6
-
7
- - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
- - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
-
10
- ## React Compiler
11
-
12
- The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
-
14
- ## Expanding the ESLint configuration
15
-
16
- If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
-
18
- ```js
19
- export default defineConfig([
20
- globalIgnores(['dist']),
21
- {
22
- files: ['**/*.{ts,tsx}'],
23
- extends: [
24
- // Other configs...
25
-
26
- // Remove tseslint.configs.recommended and replace with this
27
- tseslint.configs.recommendedTypeChecked,
28
- // Alternatively, use this for stricter rules
29
- tseslint.configs.strictTypeChecked,
30
- // Optionally, add this for stylistic rules
31
- tseslint.configs.stylisticTypeChecked,
32
-
33
- // Other configs...
34
- ],
35
- languageOptions: {
36
- parserOptions: {
37
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
- tsconfigRootDir: import.meta.dirname,
39
- },
40
- // other options...
41
- },
42
- },
43
- ])
44
- ```
45
-
46
- You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
-
48
- ```js
49
- // eslint.config.js
50
- import reactX from 'eslint-plugin-react-x'
51
- import reactDom from 'eslint-plugin-react-dom'
52
-
53
- export default defineConfig([
54
- globalIgnores(['dist']),
55
- {
56
- files: ['**/*.{ts,tsx}'],
57
- extends: [
58
- // Other configs...
59
- // Enable lint rules for React
60
- reactX.configs['recommended-typescript'],
61
- // Enable lint rules for React DOM
62
- reactDom.configs.recommended,
63
- ],
64
- languageOptions: {
65
- parserOptions: {
66
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
- tsconfigRootDir: import.meta.dirname,
68
- },
69
- // other options...
70
- },
71
- },
72
- ])
73
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pyre Frontend Visualization
2
+
3
+ A cinematic real-time visualization for the **Pyre Crisis Navigation Environment** a reinforcement learning environment where an LLM agent navigates a burning building.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # Open directly in a browser — no build step needed
9
+ open frontend/index.html
10
+ ```
11
+
12
+ The app runs entirely in-browser. **Demo mode** simulates the fire physics in JavaScript (no server required). **Live mode** connects to the deployed environment.
13
+
14
+ ---
15
+
16
+ ## Demo mode vs Live mode
17
+
18
+ | | Demo | Live |
19
+ |---|---|---|
20
+ | Server needed | ✗ | ✓ |
21
+ | Fire physics | JS port (exact match) | Python server |
22
+ | Full reward rubric | Simplified | Complete |
23
+ | Toggle | Default | Click "Live" in topbar |
24
+
25
+ **Live server:** `https://krooz-pyre-env.hf.space`
26
+
27
+ ---
28
+
29
+ ## Controls
30
+
31
+ | Key | Action |
32
+ |---|---|
33
+ | `Space` | Play / pause |
34
+ | `→` | Single step |
35
+ | `R` | New episode |
36
+ | `1`–`5` | Speed ½× / 1× / 2× / 4× / 8× |
37
+
38
+ Bottom bar: difficulty selector, seed input, speed control, reset.
39
+
40
+ ---
41
+
42
+ ## Recording episodes (Python)
43
+
44
+ ```bash
45
+ pip install requests # only stdlib used, no install needed
46
+
47
+ python bridge/recorder.py \
48
+ --url https://krooz-pyre-env.hf.space/web \
49
+ --episodes 10 \
50
+ --difficulty medium \
51
+ --out episodes/
52
+ ```
53
+
54
+ Episodes are saved as JSON files to `episodes/`. Each file contains full frame-by-frame grid data (cell, fire, smoke grids + agent position + visible cells).
55
+
56
+ ---
57
+
58
+ ## File structure
59
+
60
+ ```
61
+ frontend/
62
+ ├── index.html Main app — open this
63
+ └── js/
64
+ ├── sim.js JS port of pyre_env fire simulation + floor plans
65
+ ├── renderer.js Canvas2D rendering (fire particles, fog-of-war, agent trail)
66
+ └── app.js App controller, charts, HUD, live/demo modes
67
+
68
+ bridge/
69
+ └── recorder.py Record live episodes to JSON for replay
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Architecture notes
75
+
76
+ **Rendering:** HTML5 Canvas 2D — sufficient at 60fps for 16×16 grids; additive blending (`globalCompositeOperation: lighter`) for fire glow; ember particle pool (200 max); fog-of-war via per-cell alpha overlay.
77
+
78
+ **Demo agent:** BFS toward nearest unblocked exit, 15% random exploration, avoids fire cells > 0.4 intensity.
79
+
80
+ **Live bridge:** Polls `/web/scene` every 800ms; applies grid state to the same rendering pipeline.
81
+
82
+ ---
83
+
84
+ ## Demo script (30-second stage walkthrough)
85
+
86
+ 1. **Open** `frontend/index.html` — fire simulation starts automatically at 1×
87
+ 2. **Point out** the dark floor plan canvas with glowing fire cells, fog-of-war, and cyan agent dot
88
+ 3. **Slow to ½×** to show per-step fire propagation and smoke spread
89
+ 4. **Speed to 4×** — show agent navigating toward exits (green glow), closing doors (blue bars) to slow fire
90
+ 5. **Highlight** the side panel: cumulative reward curve dipping on smoke exposure, fire cell count climbing, action histogram
91
+ 6. **Describe partial observability** — the dark unexplored cells vs. visible corridor
92
+ 7. **Reset (R)** with a different seed to show episode variety
93
+ 8. If server is available: click **Live** — "Connected" chip turns green, real Python environment takes over
frontend/dist/assets/index-8TVqpTW-.css ADDED
@@ -0,0 +1 @@
 
 
1
+ *,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#f5f2ed;--s1:#fff;--s2:#faf9f6;--s3:#f0ede8;--bd:#e4dfd7;--bd2:#cdc7be;--t1:#1c1712;--t2:#6b6460;--t3:#a8a29e;--fire:#c2410c;--fire2:#c2410c14;--blue:#1d4ed8;--blue2:#1d4ed814;--green:#166534;--green2:#16653414;--amber:#92400e;--amber2:#92400e14;--red:#b91c1c;--red2:#b91c1c14;--gold:#d97706;--gold2:#d977061f;--mono:"DM Mono", "JetBrains Mono", monospace;--sans:"DM Sans", "Inter", system-ui, sans-serif;--r:8px}body{background:var(--bg);color:var(--t1);font-family:var(--sans);font-size:13px;line-height:1.5;overflow:hidden}.shell{grid-template-rows:48px 1fr;height:100vh;display:grid;overflow:hidden}.topbar{background:var(--s1);border-bottom:1px solid var(--bd);z-index:20;align-items:center;gap:16px;padding:0 20px;display:flex;box-shadow:0 1px 3px #0000000a}.brand{align-items:center;gap:10px;display:flex}.brand-icon{background:linear-gradient(135deg,#c2410c,#f97316);border-radius:6px;justify-content:center;align-items:center;width:28px;height:28px;font-size:14px;display:flex}.brand-name{font-family:var(--mono);color:var(--t1);letter-spacing:.06em;text-transform:uppercase;font-size:15px;font-weight:500}.brand-sep{color:var(--bd2);margin:0 2px}.brand-sub{color:var(--t3);letter-spacing:.08em;font-size:11px;font-family:var(--mono)}.topbar-right{align-items:center;gap:12px;margin-left:auto;display:flex}.live-chip{font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--bd);color:var(--t3);background:var(--s2);border-radius:20px;align-items:center;gap:5px;padding:4px 10px;font-size:10px;font-weight:500;display:flex}.live-chip:before{content:"";background:var(--bd2);border-radius:50%;width:6px;height:6px}.live-chip.online{color:var(--green);background:var(--green2);border-color:#16653433}.live-chip.online:before{background:#22c55e;animation:1.2s infinite blink}.live-chip.offline{color:var(--t3);background:var(--s2)}.live-chip.error{color:var(--red);background:var(--red2);border-color:#b91c1c33}.live-chip.error:before{background:var(--red)}@keyframes blink{0%,to{opacity:1}50%{opacity:.3}}.topbar-ep{font-family:var(--mono);color:var(--t3);align-items:center;gap:6px;font-size:10px;display:flex}.topbar-ep .ep-id{color:var(--blue);font-weight:500}.content{grid-template-columns:1fr 300px;height:100%;display:grid;overflow:hidden}.canvas-zone{background:var(--bg);flex-direction:column;justify-content:flex-start;align-items:center;gap:16px;padding:20px;display:flex;overflow-y:auto}.canvas-frame{box-shadow:0 0 0 1px var(--bd2), 0 4px 24px #0000001a, 0 1px 4px #0000000f;background:#d4c9a8;border-radius:10px;flex-shrink:0;line-height:0;position:relative;overflow:hidden}#map-canvas{max-width:100%;max-height:calc(100vh - 168px);image-rendering:pixelated;display:block}.hud-overlay{pointer-events:none;z-index:10;justify-content:space-between;gap:8px;padding:10px;display:flex;position:absolute;top:0;left:0;right:0}.hud-card{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#ede6dc;background:#0c0805d1;border:1px solid #ffffff1a;border-radius:6px;flex-direction:column;gap:3px;min-width:130px;padding:7px 10px;display:flex}.hud-card-center{border-color:#fbbf242e;min-width:160px}.hud-r{align-items:center;gap:6px;display:flex}.hlbl{font-family:var(--mono);color:#a8a29eb3;letter-spacing:.1em;text-transform:uppercase;flex-shrink:0;font-size:9px}.hval{font-family:var(--mono);color:#f0e8e0;text-align:right;min-width:26px;margin-left:auto;font-size:13px;font-weight:500}.hbar-bg{background:#ffffff1a;border-radius:3px;flex:1;height:5px;overflow:hidden}.hbar-fill{border-radius:3px;height:100%;transition:width .35s}.hbar-fill.g{background:linear-gradient(90deg,#22c55e,#4ade80)}.hbar-fill.m{background:linear-gradient(90deg,#f59e0b,#fbbf24)}.hbar-fill.c{background:linear-gradient(90deg,#ef4444,#f87171)}.hstatus{font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;border-radius:3px;padding:2px 6px;font-size:9px;font-weight:500}.hstatus.good{color:#4ade80;background:#4ade801f}.hstatus.moderate{color:#fbbf24;background:#fbbf241f}.hstatus.low{color:#fb923c;background:#fb923c1f}.hstatus.critical{color:#f87171;background:#f871711f;animation:1s infinite pulse-red}@keyframes pulse-red{0%,to{opacity:1}50%{opacity:.5}}.step-val{font-family:var(--mono);color:#f0e8e0;font-size:12px;font-weight:500}.sbar-bg{background:#ffffff1a;border-radius:2px;width:100%;height:3px;margin-top:1px;overflow:hidden}.sbar-fill{background:linear-gradient(90deg, var(--blue), #60a5fa);border-radius:2px;height:100%;transition:width .35s}.step-meta{font-family:var(--mono);color:#a8a29e8c;letter-spacing:.06em;text-transform:uppercase;margin-top:1px;font-size:8px}.legend{background:var(--s1);border:1px solid var(--bd);border-radius:6px;flex-wrap:wrap;align-items:center;gap:14px;width:100%;max-width:640px;padding:8px 12px;display:flex}.legend-item{font-family:var(--mono);color:var(--t2);letter-spacing:.05em;text-transform:uppercase;align-items:center;gap:5px;font-size:9px;display:flex}.leg-swatch{border-radius:2px;flex-shrink:0;width:12px;height:12px}.dialog{background:var(--s1);border:1px solid var(--bd);border-left:3px solid var(--fire);border-radius:var(--r);font-family:var(--mono);color:var(--t2);width:100%;max-width:640px;padding:10px 14px;font-size:12px;line-height:1.5}.dialog-who{color:var(--t3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:3px;font-size:9px;display:block}.side{background:var(--s1);border-left:1px solid var(--bd);flex-direction:column;display:flex;overflow:hidden auto}.side-sec{border-bottom:1px solid var(--bd);padding:14px 16px}.sec-hd{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.12em;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:9px;font-weight:600;display:flex}.sg{grid-template-columns:1fr 1fr;gap:6px;display:grid}.sc{background:var(--s2);border:1px solid var(--bd);border-radius:6px;padding:8px 10px}.sc-l{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:2px;font-size:9px}.sc-v{font-family:var(--mono);color:var(--t1);font-size:14px;font-weight:500}.sc-v.fire{color:var(--fire)}.sc-v.blue{color:var(--blue)}.sc-v.green{color:var(--green)}.sc-v.amber{color:var(--amber)}.sc-v.gold{color:var(--gold)}.srow{border-bottom:1px solid #00000008;justify-content:space-between;align-items:center;padding:5px 0;font-size:12px;display:flex}.srow:last-child{border-bottom:none}.sv{font-family:var(--mono);font-weight:500}.sv.blue{color:var(--blue)}.sv.green{color:var(--green)}.sv.hot{color:var(--fire)}.sv.danger{color:var(--red)}.sv.warn{color:var(--amber)}.sv.gold{color:var(--gold)}.bar-w{margin-top:10px}.bar-lbl{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.06em;justify-content:space-between;margin-bottom:5px;font-size:9px;display:flex}.bar-bg{background:var(--s2);border:1px solid var(--bd);border-radius:4px;height:6px;overflow:hidden}.bar-fill{border-radius:4px;height:100%;transition:width .4s cubic-bezier(.4,0,.2,1)}.elog{flex-direction:column;gap:2px;max-height:160px;display:flex;overflow-y:auto}.erow{background:0 0;border-radius:4px;align-items:flex-start;gap:6px;padding:4px 6px;font-size:11px;display:flex}.erow.alarm{background:var(--red2);border-left:2px solid var(--red)}.erow.warn-row{background:var(--amber2);border-left:2px solid var(--amber)}.estep{font-family:var(--mono);color:var(--t3);flex-shrink:0;min-width:26px;padding-top:1px;font-size:9px}.etext{color:var(--t2);flex:1;font-size:11px;line-height:1.4}.erwd{font-family:var(--mono);flex-shrink:0;padding-top:1px;font-size:9px;font-weight:500}.erwd.p{color:var(--green)}.erwd.n{color:var(--red)}.dpad{grid-template-columns:repeat(3,1fr);gap:5px;max-width:140px;margin:0 auto;display:grid}.dpad-center{justify-content:center;align-items:center;display:flex}.ctrl-grid{grid-template-columns:repeat(3,1fr);gap:5px;display:grid}.ctrl-row{gap:5px;margin-top:8px;display:flex}.ctrl-btn{background:var(--s2);color:var(--t2);border:1px solid var(--bd);font-family:var(--mono);letter-spacing:.04em;cursor:pointer;text-align:center;border-radius:5px;flex:1;padding:7px 6px;font-size:10px;font-weight:500;transition:all .1s}.ctrl-btn:hover:not(:disabled){background:var(--s3);color:var(--t1);border-color:var(--bd2)}.ctrl-btn:disabled{opacity:.4;cursor:not-allowed}.ctrl-btn.accent{background:var(--fire2);color:var(--fire);border-color:#c2410c40}.ctrl-btn.accent:hover{color:var(--fire);background:#c2410c26}.ctrl-btn.active{background:var(--blue2);color:var(--blue);border-color:#1d4ed840}.ctrl-btn.active:hover{background:#1d4ed826}.ctrl-btn.play{color:#fff;background:linear-gradient(135deg,#c2410c,#f97316);border:none;font-size:11px;box-shadow:0 2px 8px #c2410c4d}.ctrl-btn.play:hover{filter:brightness(1.05);box-shadow:0 3px 12px #c2410c73}.ctrl-status{font-family:var(--mono);color:var(--t3);background:var(--s2);border:1px solid var(--bd);border-left:3px solid var(--bd2);border-radius:5px;margin-top:10px;padding:7px 10px;font-size:10px}.ctrl-status.error{color:var(--red);background:var(--red2);border-left-color:var(--red)}.door-grid{grid-template-columns:1fr 1fr;gap:4px;display:grid}.door-btn{background:var(--s2);border:1px solid var(--bd);font-family:var(--mono);cursor:pointer;color:var(--t2);text-align:center;border-radius:5px;padding:5px 4px;font-size:9px;transition:all .1s}.door-btn:hover{background:var(--s3);color:var(--t1)}.door-btn.open{color:var(--green);background:var(--green2);border-color:#16653440}.door-btn.closed{color:var(--amber);background:var(--amber2);border-color:#92400e40}.door-btn.failed{color:var(--red);background:var(--red2);border-color:#b91c1c40}.report-box{background:var(--s2);border:1px solid var(--bd);max-height:160px;font-family:var(--mono);color:var(--t2);white-space:pre-wrap;border-radius:5px;padding:10px;font-size:9px;line-height:1.5;overflow-y:auto}::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
frontend/dist/assets/index-CWSK8ZuR.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html CHANGED
@@ -1,14 +1,17 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
8
- <script type="module" crossorigin src="/assets/index-DmWZ9HHA.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-S-HKcJzd.css">
10
- </head>
11
- <body>
12
- <div id="root"></div>
13
- </body>
14
- </html>
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Pyre — Crisis Navigation</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ <script type="module" crossorigin src="/assets/index-CWSK8ZuR.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-8TVqpTW-.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>
frontend/index.html CHANGED
@@ -1,13 +1,16 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
8
- </head>
9
- <body>
10
- <div id="root"></div>
11
- <script type="module" src="/src/main.tsx"></script>
12
- </body>
13
- </html>
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Pyre — Crisis Navigation</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/src/App.css CHANGED
@@ -1,367 +1,703 @@
1
- @import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
2
-
3
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4
 
 
5
  :root {
6
- --bg-color: #050608;
7
- --panel-bg: rgba(13, 17, 23, 0.85);
8
- --accent-blue: #00f2ff;
9
- --accent-red: #ff3c3c;
10
- --accent-green: #39ff14;
11
- --accent-yellow: #fefe33;
12
- --text-primary: #e6edf3;
13
- --text-secondary: #8b949e;
14
- --border-color: #30363d;
15
- --glass-border: rgba(255, 255, 255, 0.1);
16
- --font-main: 'Rajdhani', sans-serif;
17
- --font-mono: 'Share Tech Mono', monospace;
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
 
20
  body {
21
- background: var(--bg-color);
22
- color: var(--text-primary);
23
- font-family: var(--font-main);
24
- overflow-x: hidden;
25
- background-image:
26
- radial-gradient(circle at 50% 50%, rgba(0, 242, 255, 0.03) 0%, transparent 50%),
27
- linear-gradient(rgba(13, 17, 23, 0.9), rgba(13, 17, 23, 0.9)),
28
- url('https://www.transparenttextures.com/patterns/carbon-fibre.png');
 
 
 
 
 
 
29
  }
30
 
31
- .app-container {
32
- min-height: 100vh;
33
- padding: 24px;
 
34
  display: flex;
35
- flex-direction: column;
36
  align-items: center;
 
 
 
 
37
  }
38
 
39
- .dashboard-header {
40
- width: 100%;
41
- max-width: 1400px;
42
  display: flex;
43
- justify-content: space-between;
44
  align-items: center;
45
- padding: 0 0 20px 0;
46
- border-bottom: 2px solid var(--border-color);
47
- margin-bottom: 24px;
48
- position: relative;
49
  }
50
 
51
- .dashboard-header::after {
52
- content: '';
53
- position: absolute;
54
- bottom: -2px;
55
- left: 0;
56
- width: 150px;
57
- height: 2px;
58
- background: var(--accent-blue);
59
- box-shadow: 0 0 10px var(--accent-blue);
60
- }
61
-
62
- .dashboard-title {
63
- font-size: 28px;
64
- font-weight: 700;
65
- letter-spacing: 2px;
 
 
66
  text-transform: uppercase;
67
- color: var(--text-primary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  display: flex;
69
  align-items: center;
70
  gap: 12px;
71
  }
72
 
73
- .dashboard-title span {
74
- color: var(--accent-blue);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
 
77
- .outer {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  display: grid;
79
- grid-template-columns: 1fr 350px;
80
- gap: 24px;
81
- width: 100%;
82
- max-width: 1400px;
83
  }
84
 
85
- /* ── Main Viewport ─────────���───────────── */
86
- .viewport-container {
87
  display: flex;
88
  flex-direction: column;
 
 
 
 
89
  gap: 16px;
 
90
  }
91
 
92
- .screen-shell {
93
- background: var(--panel-bg);
94
- border: 1px solid var(--glass-border);
95
- border-radius: 8px;
96
- padding: 16px;
97
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
98
  position: relative;
 
99
  overflow: hidden;
 
 
 
 
 
 
 
100
  }
101
 
102
- .screen-shell::before {
103
- content: '';
104
- position: absolute;
105
- top: 0; left: 0; right: 0; height: 1px;
106
- background: linear-gradient(90deg, transparent, var(--accent-blue), transparent);
107
  }
108
 
109
- .screen-bezel {
110
- background: #000;
111
- border-radius: 4px;
112
- line-height: 0;
113
- position: relative;
114
- overflow: auto;
115
- border: 1px solid #1a1a1a;
116
- max-height: 700px;
 
 
117
  }
118
 
119
- #map-canvas {
120
- display: block;
121
- image-rendering: pixelated;
122
- margin: 0 auto;
 
 
 
 
 
 
 
123
  }
124
 
125
- /* ── HUD ────────────────────────────────────── */
126
- .hud {
127
- display: grid;
128
- grid-template-columns: repeat(4, 1fr);
129
- gap: 12px;
130
- margin-top: 16px;
131
  }
132
 
133
- .hud-card {
134
- background: rgba(255, 255, 255, 0.03);
135
- border: 1px solid var(--glass-border);
136
- border-radius: 4px;
137
- padding: 12px;
138
  display: flex;
139
- flex-direction: column;
140
- gap: 4px;
141
  }
142
 
143
- .hud-label {
144
- font-size: 11px;
145
- color: var(--text-secondary);
 
 
146
  text-transform: uppercase;
147
- letter-spacing: 1px;
148
  }
149
 
150
- .hud-value {
151
- font-family: var(--font-mono);
152
- font-size: 18px;
153
- font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
154
  }
155
 
156
- .hud-hp { color: var(--accent-red); }
157
- .hud-step { color: var(--accent-blue); }
158
- .hud-act { color: var(--accent-green); }
159
- .hud-moves { color: var(--accent-yellow); }
 
160
 
161
- /* ── Dialog / Narrative ─────────────────────── */
162
- .dialog {
163
- background: rgba(0, 0, 0, 0.4);
164
- border: 1px solid var(--border-color);
165
- border-radius: 4px;
166
- padding: 16px;
167
- font-family: var(--font-mono);
168
- font-size: 14px;
169
- color: #fff;
170
- min-height: 60px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  display: flex;
172
- flex-direction: column;
173
- gap: 8px;
174
- border-left: 4px solid var(--accent-blue);
 
 
 
 
 
 
175
  }
176
 
177
- .dialog-who {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  font-size: 12px;
179
- color: var(--accent-blue);
180
- font-weight: 700;
 
 
 
 
 
 
 
181
  text-transform: uppercase;
 
 
 
182
  }
183
 
184
- /* ── Panels ─────────────────────────────── */
185
- .panel {
 
 
186
  display: flex;
187
  flex-direction: column;
188
- gap: 20px;
 
189
  }
190
 
191
- .card {
192
- background: var(--panel-bg);
193
- border: 1px solid var(--glass-border);
194
- border-radius: 8px;
195
- padding: 16px;
196
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
197
  }
198
 
199
- .card-title {
200
- font-size: 14px;
201
- font-weight: 700;
202
- color: var(--accent-blue);
 
203
  text-transform: uppercase;
204
- letter-spacing: 1.5px;
205
- margin-bottom: 16px;
206
- padding-bottom: 8px;
207
- border-bottom: 1px solid var(--border-color);
208
  display: flex;
 
209
  justify-content: space-between;
210
  }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  .srow {
213
  display: flex;
214
  justify-content: space-between;
215
- font-size: 13px;
216
- padding: 6px 0;
217
- border-bottom: 1px solid rgba(255, 255, 255, 0.02);
 
218
  }
219
 
220
  .srow:last-child { border-bottom: none; }
221
 
222
- .sv { font-family: var(--font-mono); font-weight: 600; }
223
- .sv.blue { color: var(--accent-blue); }
224
- .sv.warn { color: var(--accent-yellow); }
225
- .sv.hot { color: #ff8c00; }
226
- .sv.danger { color: var(--accent-red); }
227
- .sv.green { color: var(--accent-green); }
228
-
229
- .bar-w { margin-top: 12px; }
230
- .bar-lbl {
231
- font-size: 11px;
232
- color: var(--text-secondary);
233
- display: flex;
234
- justify-content: space-between;
235
- margin-bottom: 6px;
 
 
 
 
 
 
236
  }
 
237
  .bar-bg {
238
- background: #161b22;
239
- border-radius: 10px;
240
- height: 8px;
 
241
  overflow: hidden;
242
- border: 1px solid var(--border-color);
243
  }
 
244
  .bar-fill {
245
  height: 100%;
246
- background: linear-gradient(90deg, var(--accent-red), var(--accent-green));
247
  transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
248
- box-shadow: 0 0 8px rgba(57, 255, 20, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
 
251
- /* ── Controls ─────────────────────────────── */
252
  .ctrl-grid {
253
  display: grid;
254
  grid-template-columns: repeat(3, 1fr);
255
- gap: 8px;
 
 
 
 
 
 
256
  }
257
 
258
  .ctrl-btn {
259
- background: #21262d;
260
- color: var(--text-primary);
261
- border: 1px solid var(--border-color);
262
- border-radius: 4px;
263
- padding: 10px 4px;
264
- font-family: var(--font-main);
265
- font-size: 12px;
266
- font-weight: 600;
 
267
  cursor: pointer;
268
- transition: all 0.2s;
269
- text-transform: uppercase;
 
270
  }
271
 
272
  .ctrl-btn:hover:not(:disabled) {
273
- background: #30363d;
274
- border-color: var(--accent-blue);
275
- color: var(--accent-blue);
276
- box-shadow: 0 0 10px rgba(0, 242, 255, 0.2);
277
  }
278
 
279
- .ctrl-btn:active:not(:disabled) {
280
- transform: translateY(1px);
 
281
  }
282
 
283
- .ctrl-btn.wide { grid-column: span 3; }
284
  .ctrl-btn.accent {
285
- background: var(--accent-blue);
286
- color: #000;
287
- border: none;
288
  }
 
289
  .ctrl-btn.accent:hover {
290
- background: #70faff;
 
291
  }
292
 
293
- .ctrl-input {
294
- grid-column: span 2;
295
- background: #0d1117;
296
- color: var(--text-primary);
297
- border: 1px solid var(--border-color);
298
- border-radius: 4px;
299
- padding: 10px;
300
- font-family: var(--font-mono);
301
- font-size: 13px;
302
  }
303
 
304
- .ctrl-status {
305
- margin-top: 16px;
306
- font-family: var(--font-mono);
307
- font-size: 12px;
308
- padding: 8px;
309
- background: rgba(0,0,0,0.2);
310
- border-radius: 4px;
311
  }
312
 
313
- /* ── API Report ─────────────────────────────── */
314
- .report-box {
315
- background: #0d1117;
316
- border: 1px solid var(--border-color);
317
- border-radius: 4px;
318
- padding: 12px;
319
- max-height: 250px;
320
- overflow-y: auto;
321
- font-family: var(--font-mono);
322
  font-size: 11px;
323
- color: #7ee787;
324
- white-space: pre-wrap;
325
- margin-bottom: 12px;
326
  }
327
 
328
- .report-meta {
329
- font-size: 11px;
330
- color: var(--text-secondary);
331
- margin-bottom: 8px;
332
- font-family: var(--font-mono);
333
  }
334
 
335
- /* ── Scrollbars ─────────────────────────────── */
336
- ::-webkit-scrollbar { width: 6px; height: 6px; }
337
- ::-webkit-scrollbar-track { background: var(--bg-color); }
338
- ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
339
- ::-webkit-scrollbar-thumb:hover { background: #484f58; }
 
 
 
 
 
 
340
 
341
- /* ── Modern Accents ─────────────────────────────── */
342
- .scanline {
343
- position: absolute;
344
- top: 0; left: 0; width: 100%; height: 2px;
345
- background: rgba(0, 242, 255, 0.1);
346
- opacity: 0.1;
347
- pointer-events: none;
348
- animation: scan 8s linear infinite;
349
  }
350
 
351
- @keyframes scan {
352
- from { transform: translateY(0); }
353
- to { transform: translateY(100vh); }
 
 
354
  }
355
 
356
- .door-row {
357
- display: flex;
358
- align-items: center;
359
- justify-content: space-between;
360
- padding: 8px 0;
361
- border-bottom: 1px solid rgba(255,255,255,0.05);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  }
363
 
364
- .door-id { color: var(--accent-blue); font-family: var(--font-mono); font-weight: 700; }
365
- .d-open { color: var(--accent-green); }
366
- .d-closed { color: var(--accent-yellow); }
367
- .d-failed { color: var(--accent-red); }
 
1
+ /* ── Reset ── */
 
2
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
 
4
+ /* ── Design Tokens ── */
5
  :root {
6
+ --bg: #f5f2ed;
7
+ --s1: #ffffff;
8
+ --s2: #faf9f6;
9
+ --s3: #f0ede8;
10
+ --bd: #e4dfd7;
11
+ --bd2: #cdc7be;
12
+ --t1: #1c1712;
13
+ --t2: #6b6460;
14
+ --t3: #a8a29e;
15
+ --fire: #c2410c;
16
+ --fire2: rgba(194,65,12,0.08);
17
+ --blue: #1d4ed8;
18
+ --blue2: rgba(29,78,216,0.08);
19
+ --green: #166534;
20
+ --green2:rgba(22,101,52,0.08);
21
+ --amber: #92400e;
22
+ --amber2:rgba(146,64,14,0.08);
23
+ --red: #b91c1c;
24
+ --red2: rgba(185,28,28,0.08);
25
+ --gold: #d97706;
26
+ --gold2: rgba(217,119,6,0.12);
27
+ --mono: 'DM Mono', 'JetBrains Mono', monospace;
28
+ --sans: 'DM Sans', 'Inter', system-ui, sans-serif;
29
+ --r: 8px;
30
  }
31
 
32
  body {
33
+ background: var(--bg);
34
+ color: var(--t1);
35
+ font-family: var(--sans);
36
+ font-size: 13px;
37
+ line-height: 1.5;
38
+ overflow: hidden;
39
+ }
40
+
41
+ /* ── Shell ── */
42
+ .shell {
43
+ display: grid;
44
+ grid-template-rows: 48px 1fr;
45
+ height: 100vh;
46
+ overflow: hidden;
47
  }
48
 
49
+ /* ── Topbar ── */
50
+ .topbar {
51
+ background: var(--s1);
52
+ border-bottom: 1px solid var(--bd);
53
  display: flex;
 
54
  align-items: center;
55
+ padding: 0 20px;
56
+ gap: 16px;
57
+ z-index: 20;
58
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
59
  }
60
 
61
+ .brand {
 
 
62
  display: flex;
 
63
  align-items: center;
64
+ gap: 10px;
 
 
 
65
  }
66
 
67
+ .brand-icon {
68
+ width: 28px;
69
+ height: 28px;
70
+ background: linear-gradient(135deg, #c2410c, #f97316);
71
+ border-radius: 6px;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ font-size: 14px;
76
+ }
77
+
78
+ .brand-name {
79
+ font-family: var(--mono);
80
+ font-size: 15px;
81
+ font-weight: 500;
82
+ color: var(--t1);
83
+ letter-spacing: 0.06em;
84
  text-transform: uppercase;
85
+ }
86
+
87
+ .brand-sep {
88
+ color: var(--bd2);
89
+ margin: 0 2px;
90
+ }
91
+
92
+ .brand-sub {
93
+ font-size: 11px;
94
+ color: var(--t3);
95
+ letter-spacing: 0.08em;
96
+ font-family: var(--mono);
97
+ }
98
+
99
+ .topbar-right {
100
+ margin-left: auto;
101
  display: flex;
102
  align-items: center;
103
  gap: 12px;
104
  }
105
 
106
+ .live-chip {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 5px;
110
+ font-family: var(--mono);
111
+ font-size: 10px;
112
+ font-weight: 500;
113
+ letter-spacing: 0.08em;
114
+ text-transform: uppercase;
115
+ padding: 4px 10px;
116
+ border-radius: 20px;
117
+ border: 1px solid var(--bd);
118
+ color: var(--t3);
119
+ background: var(--s2);
120
+ }
121
+
122
+ .live-chip::before {
123
+ content: '';
124
+ width: 6px;
125
+ height: 6px;
126
+ border-radius: 50%;
127
+ background: var(--bd2);
128
+ }
129
+
130
+ .live-chip.online {
131
+ color: var(--green);
132
+ background: var(--green2);
133
+ border-color: rgba(22,101,52,0.2);
134
  }
135
 
136
+ .live-chip.online::before { background: #22c55e; animation: blink 1.2s infinite; }
137
+
138
+ .live-chip.offline {
139
+ color: var(--t3);
140
+ background: var(--s2);
141
+ }
142
+
143
+ .live-chip.error {
144
+ color: var(--red);
145
+ background: var(--red2);
146
+ border-color: rgba(185,28,28,0.2);
147
+ }
148
+
149
+ .live-chip.error::before { background: var(--red); }
150
+
151
+ @keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0.3 } }
152
+
153
+ .topbar-ep {
154
+ font-family: var(--mono);
155
+ font-size: 10px;
156
+ color: var(--t3);
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ }
161
+
162
+ .topbar-ep .ep-id {
163
+ color: var(--blue);
164
+ font-weight: 500;
165
+ }
166
+
167
+ /* ── Content Layout ── */
168
+ .content {
169
  display: grid;
170
+ grid-template-columns: 1fr 300px;
171
+ overflow: hidden;
172
+ height: 100%;
 
173
  }
174
 
175
+ /* ── Canvas Zone (left) ── */
176
+ .canvas-zone {
177
  display: flex;
178
  flex-direction: column;
179
+ align-items: center;
180
+ justify-content: flex-start;
181
+ padding: 20px;
182
+ overflow-y: auto;
183
  gap: 16px;
184
+ background: var(--bg);
185
  }
186
 
187
+ .canvas-frame {
 
 
 
 
 
188
  position: relative;
189
+ border-radius: 10px;
190
  overflow: hidden;
191
+ box-shadow:
192
+ 0 0 0 1px var(--bd2),
193
+ 0 4px 24px rgba(0,0,0,0.10),
194
+ 0 1px 4px rgba(0,0,0,0.06);
195
+ background: #d4c9a8;
196
+ line-height: 0;
197
+ flex-shrink: 0;
198
  }
199
 
200
+ #map-canvas {
201
+ display: block;
202
+ max-width: 100%;
203
+ max-height: calc(100vh - 48px - 120px);
204
+ image-rendering: pixelated;
205
  }
206
 
207
+ /* ── HUD Overlays on Canvas ── */
208
+ .hud-overlay {
209
+ position: absolute;
210
+ top: 0; left: 0; right: 0;
211
+ display: flex;
212
+ justify-content: space-between;
213
+ pointer-events: none;
214
+ z-index: 10;
215
+ padding: 10px;
216
+ gap: 8px;
217
  }
218
 
219
+ .hud-card {
220
+ background: rgba(12, 8, 5, 0.82);
221
+ backdrop-filter: blur(10px);
222
+ border: 1px solid rgba(255,255,255,0.10);
223
+ border-radius: 6px;
224
+ padding: 7px 10px;
225
+ color: #ede6dc;
226
+ display: flex;
227
+ flex-direction: column;
228
+ gap: 3px;
229
+ min-width: 130px;
230
  }
231
 
232
+ .hud-card-center {
233
+ min-width: 160px;
234
+ border-color: rgba(251,191,36,0.18);
 
 
 
235
  }
236
 
237
+ .hud-r {
 
 
 
 
238
  display: flex;
239
+ align-items: center;
240
+ gap: 6px;
241
  }
242
 
243
+ .hlbl {
244
+ font-family: var(--mono);
245
+ font-size: 9px;
246
+ color: rgba(168,162,158,0.7);
247
+ letter-spacing: 0.10em;
248
  text-transform: uppercase;
249
+ flex-shrink: 0;
250
  }
251
 
252
+ .hval {
253
+ font-family: var(--mono);
254
+ font-size: 13px;
255
+ font-weight: 500;
256
+ color: #f0e8e0;
257
+ margin-left: auto;
258
+ min-width: 26px;
259
+ text-align: right;
260
+ }
261
+
262
+ .hbar-bg {
263
+ flex: 1;
264
+ height: 5px;
265
+ background: rgba(255,255,255,0.10);
266
+ border-radius: 3px;
267
+ overflow: hidden;
268
  }
269
 
270
+ .hbar-fill {
271
+ height: 100%;
272
+ border-radius: 3px;
273
+ transition: width 0.35s ease;
274
+ }
275
 
276
+ .hbar-fill.g { background: linear-gradient(90deg, #22c55e, #4ade80); }
277
+ .hbar-fill.m { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
278
+ .hbar-fill.c { background: linear-gradient(90deg, #ef4444, #f87171); }
279
+
280
+ .hstatus {
281
+ font-family: var(--mono);
282
+ font-size: 9px;
283
+ font-weight: 500;
284
+ letter-spacing: 0.06em;
285
+ text-transform: uppercase;
286
+ padding: 2px 6px;
287
+ border-radius: 3px;
288
+ }
289
+
290
+ .hstatus.good { color: #4ade80; background: rgba(74,222,128,0.12); }
291
+ .hstatus.moderate { color: #fbbf24; background: rgba(251,191,36,0.12); }
292
+ .hstatus.low { color: #fb923c; background: rgba(251,146,60,0.12); }
293
+ .hstatus.critical { color: #f87171; background: rgba(248,113,113,0.12); animation: pulse-red 1s infinite; }
294
+
295
+ @keyframes pulse-red { 0%,100%{opacity:1} 50%{opacity:0.5} }
296
+
297
+ .step-val {
298
+ font-family: var(--mono);
299
+ font-size: 12px;
300
+ font-weight: 500;
301
+ color: #f0e8e0;
302
+ }
303
+
304
+ .sbar-bg {
305
+ height: 3px;
306
+ background: rgba(255,255,255,0.10);
307
+ border-radius: 2px;
308
+ overflow: hidden;
309
+ width: 100%;
310
+ margin-top: 1px;
311
+ }
312
+
313
+ .sbar-fill {
314
+ height: 100%;
315
+ background: linear-gradient(90deg, var(--blue), #60a5fa);
316
+ border-radius: 2px;
317
+ transition: width 0.35s ease;
318
+ }
319
+
320
+ .step-meta {
321
+ font-family: var(--mono);
322
+ font-size: 8px;
323
+ color: rgba(168,162,158,0.55);
324
+ letter-spacing: 0.06em;
325
+ text-transform: uppercase;
326
+ margin-top: 1px;
327
+ }
328
+
329
+ /* ── Legend below canvas ── */
330
+ .legend {
331
  display: flex;
332
+ gap: 14px;
333
+ flex-wrap: wrap;
334
+ align-items: center;
335
+ padding: 8px 12px;
336
+ background: var(--s1);
337
+ border: 1px solid var(--bd);
338
+ border-radius: 6px;
339
+ width: 100%;
340
+ max-width: 640px;
341
  }
342
 
343
+ .legend-item {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 5px;
347
+ font-family: var(--mono);
348
+ font-size: 9px;
349
+ color: var(--t2);
350
+ letter-spacing: 0.05em;
351
+ text-transform: uppercase;
352
+ }
353
+
354
+ .leg-swatch {
355
+ width: 12px;
356
+ height: 12px;
357
+ border-radius: 2px;
358
+ flex-shrink: 0;
359
+ }
360
+
361
+ /* ── Dialog / Field Report ── */
362
+ .dialog {
363
+ background: var(--s1);
364
+ border: 1px solid var(--bd);
365
+ border-left: 3px solid var(--fire);
366
+ border-radius: var(--r);
367
+ padding: 10px 14px;
368
+ font-family: var(--mono);
369
  font-size: 12px;
370
+ color: var(--t2);
371
+ width: 100%;
372
+ max-width: 640px;
373
+ line-height: 1.5;
374
+ }
375
+
376
+ .dialog-who {
377
+ font-size: 9px;
378
+ color: var(--t3);
379
  text-transform: uppercase;
380
+ letter-spacing: 0.10em;
381
+ display: block;
382
+ margin-bottom: 3px;
383
  }
384
 
385
+ /* ── Side Panel (right) ── */
386
+ .side {
387
+ background: var(--s1);
388
+ border-left: 1px solid var(--bd);
389
  display: flex;
390
  flex-direction: column;
391
+ overflow-y: auto;
392
+ overflow-x: hidden;
393
  }
394
 
395
+ .side-sec {
396
+ border-bottom: 1px solid var(--bd);
397
+ padding: 14px 16px;
 
 
 
398
  }
399
 
400
+ .sec-hd {
401
+ font-family: var(--mono);
402
+ font-size: 9px;
403
+ font-weight: 600;
404
+ color: var(--t3);
405
  text-transform: uppercase;
406
+ letter-spacing: 0.12em;
407
+ margin-bottom: 10px;
 
 
408
  display: flex;
409
+ align-items: center;
410
  justify-content: space-between;
411
  }
412
 
413
+ /* ── Stat Grid (2×N) ── */
414
+ .sg {
415
+ display: grid;
416
+ grid-template-columns: 1fr 1fr;
417
+ gap: 6px;
418
+ }
419
+
420
+ .sc {
421
+ background: var(--s2);
422
+ border: 1px solid var(--bd);
423
+ border-radius: 6px;
424
+ padding: 8px 10px;
425
+ }
426
+
427
+ .sc-l {
428
+ font-family: var(--mono);
429
+ font-size: 9px;
430
+ color: var(--t3);
431
+ text-transform: uppercase;
432
+ letter-spacing: 0.08em;
433
+ margin-bottom: 2px;
434
+ }
435
+
436
+ .sc-v {
437
+ font-family: var(--mono);
438
+ font-size: 14px;
439
+ font-weight: 500;
440
+ color: var(--t1);
441
+ }
442
+
443
+ .sc-v.fire { color: var(--fire); }
444
+ .sc-v.blue { color: var(--blue); }
445
+ .sc-v.green { color: var(--green); }
446
+ .sc-v.amber { color: var(--amber); }
447
+ .sc-v.gold { color: var(--gold); }
448
+
449
+ /* ── Status Rows ── */
450
  .srow {
451
  display: flex;
452
  justify-content: space-between;
453
+ align-items: center;
454
+ padding: 5px 0;
455
+ font-size: 12px;
456
+ border-bottom: 1px solid rgba(0,0,0,0.03);
457
  }
458
 
459
  .srow:last-child { border-bottom: none; }
460
 
461
+ .sv { font-family: var(--mono); font-weight: 500; }
462
+ .sv.blue { color: var(--blue); }
463
+ .sv.green { color: var(--green); }
464
+ .sv.hot { color: var(--fire); }
465
+ .sv.danger{ color: var(--red); }
466
+ .sv.warn { color: var(--amber); }
467
+ .sv.gold { color: var(--gold); }
468
+
469
+ /* ── Health bar in side panel ── */
470
+ .bar-w { margin-top: 10px; }
471
+
472
+ .bar-lbl {
473
+ font-family: var(--mono);
474
+ font-size: 9px;
475
+ color: var(--t3);
476
+ display: flex;
477
+ justify-content: space-between;
478
+ margin-bottom: 5px;
479
+ text-transform: uppercase;
480
+ letter-spacing: 0.06em;
481
  }
482
+
483
  .bar-bg {
484
+ background: var(--s2);
485
+ border: 1px solid var(--bd);
486
+ border-radius: 4px;
487
+ height: 6px;
488
  overflow: hidden;
 
489
  }
490
+
491
  .bar-fill {
492
  height: 100%;
 
493
  transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
494
+ border-radius: 4px;
495
+ }
496
+
497
+ /* ── Event Log ── */
498
+ .elog {
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 2px;
502
+ max-height: 160px;
503
+ overflow-y: auto;
504
+ }
505
+
506
+ .erow {
507
+ display: flex;
508
+ align-items: flex-start;
509
+ gap: 6px;
510
+ padding: 4px 6px;
511
+ border-radius: 4px;
512
+ font-size: 11px;
513
+ background: transparent;
514
+ }
515
+
516
+ .erow.alarm {
517
+ background: var(--red2);
518
+ border-left: 2px solid var(--red);
519
+ }
520
+
521
+ .erow.warn-row {
522
+ background: var(--amber2);
523
+ border-left: 2px solid var(--amber);
524
+ }
525
+
526
+ .estep {
527
+ font-family: var(--mono);
528
+ font-size: 9px;
529
+ color: var(--t3);
530
+ min-width: 26px;
531
+ padding-top: 1px;
532
+ flex-shrink: 0;
533
+ }
534
+
535
+ .etext {
536
+ color: var(--t2);
537
+ line-height: 1.4;
538
+ flex: 1;
539
+ font-size: 11px;
540
+ }
541
+
542
+ .erwd {
543
+ font-family: var(--mono);
544
+ font-size: 9px;
545
+ font-weight: 500;
546
+ flex-shrink: 0;
547
+ padding-top: 1px;
548
+ }
549
+
550
+ .erwd.p { color: var(--green); }
551
+ .erwd.n { color: var(--red); }
552
+
553
+ /* ── Direction Pad ── */
554
+ .dpad {
555
+ display: grid;
556
+ grid-template-columns: repeat(3, 1fr);
557
+ gap: 5px;
558
+ max-width: 140px;
559
+ margin: 0 auto;
560
+ }
561
+
562
+ .dpad-center {
563
+ display: flex;
564
+ align-items: center;
565
+ justify-content: center;
566
  }
567
 
568
+ /* ── Control Buttons ── */
569
  .ctrl-grid {
570
  display: grid;
571
  grid-template-columns: repeat(3, 1fr);
572
+ gap: 5px;
573
+ }
574
+
575
+ .ctrl-row {
576
+ display: flex;
577
+ gap: 5px;
578
+ margin-top: 8px;
579
  }
580
 
581
  .ctrl-btn {
582
+ background: var(--s2);
583
+ color: var(--t2);
584
+ border: 1px solid var(--bd);
585
+ border-radius: 5px;
586
+ padding: 7px 6px;
587
+ font-family: var(--mono);
588
+ font-size: 10px;
589
+ font-weight: 500;
590
+ letter-spacing: 0.04em;
591
  cursor: pointer;
592
+ transition: all 0.1s;
593
+ text-align: center;
594
+ flex: 1;
595
  }
596
 
597
  .ctrl-btn:hover:not(:disabled) {
598
+ background: var(--s3);
599
+ color: var(--t1);
600
+ border-color: var(--bd2);
 
601
  }
602
 
603
+ .ctrl-btn:disabled {
604
+ opacity: 0.4;
605
+ cursor: not-allowed;
606
  }
607
 
 
608
  .ctrl-btn.accent {
609
+ background: var(--fire2);
610
+ color: var(--fire);
611
+ border-color: rgba(194,65,12,0.25);
612
  }
613
+
614
  .ctrl-btn.accent:hover {
615
+ background: rgba(194,65,12,0.15);
616
+ color: var(--fire);
617
  }
618
 
619
+ .ctrl-btn.active {
620
+ background: var(--blue2);
621
+ color: var(--blue);
622
+ border-color: rgba(29,78,216,0.25);
 
 
 
 
 
623
  }
624
 
625
+ .ctrl-btn.active:hover {
626
+ background: rgba(29,78,216,0.15);
 
 
 
 
 
627
  }
628
 
629
+ .ctrl-btn.play {
630
+ background: linear-gradient(135deg, #c2410c, #f97316);
631
+ color: white;
632
+ border: none;
 
 
 
 
 
633
  font-size: 11px;
634
+ box-shadow: 0 2px 8px rgba(194,65,12,0.30);
 
 
635
  }
636
 
637
+ .ctrl-btn.play:hover {
638
+ box-shadow: 0 3px 12px rgba(194,65,12,0.45);
639
+ filter: brightness(1.05);
 
 
640
  }
641
 
642
+ .ctrl-status {
643
+ margin-top: 10px;
644
+ font-family: var(--mono);
645
+ font-size: 10px;
646
+ color: var(--t3);
647
+ padding: 7px 10px;
648
+ background: var(--s2);
649
+ border-radius: 5px;
650
+ border: 1px solid var(--bd);
651
+ border-left: 3px solid var(--bd2);
652
+ }
653
 
654
+ .ctrl-status.error {
655
+ color: var(--red);
656
+ background: var(--red2);
657
+ border-left-color: var(--red);
 
 
 
 
658
  }
659
 
660
+ /* ── Door List ── */
661
+ .door-grid {
662
+ display: grid;
663
+ grid-template-columns: 1fr 1fr;
664
+ gap: 4px;
665
  }
666
 
667
+ .door-btn {
668
+ background: var(--s2);
669
+ border: 1px solid var(--bd);
670
+ border-radius: 5px;
671
+ padding: 5px 4px;
672
+ font-family: var(--mono);
673
+ font-size: 9px;
674
+ cursor: pointer;
675
+ color: var(--t2);
676
+ text-align: center;
677
+ transition: all 0.1s;
678
+ }
679
+
680
+ .door-btn:hover { background: var(--s3); color: var(--t1); }
681
+ .door-btn.open { color: var(--green); border-color: rgba(22,101,52,0.25); background: var(--green2); }
682
+ .door-btn.closed { color: var(--amber); border-color: rgba(146,64,14,0.25); background: var(--amber2); }
683
+ .door-btn.failed { color: var(--red); border-color: rgba(185,28,28,0.25); background: var(--red2); }
684
+
685
+ /* ── API Report ── */
686
+ .report-box {
687
+ background: var(--s2);
688
+ border: 1px solid var(--bd);
689
+ border-radius: 5px;
690
+ padding: 10px;
691
+ max-height: 160px;
692
+ overflow-y: auto;
693
+ font-family: var(--mono);
694
+ font-size: 9px;
695
+ color: var(--t2);
696
+ white-space: pre-wrap;
697
+ line-height: 1.5;
698
  }
699
 
700
+ /* ── Scrollbar ── */
701
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
702
+ ::-webkit-scrollbar-track { background: transparent; }
703
+ ::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 2px; }
frontend/src/App.tsx CHANGED
@@ -4,37 +4,54 @@ import type { Observation, Door, ApiReport, SceneResponse } from './types';
4
  import Map2D from './components/Map2D';
5
  import HUD from './components/HUD';
6
  import ControlPanel from './components/ControlPanel';
7
- import { StatusCard, StatusRow } from './components/StatusCard';
8
  import APIReport from './components/APIReport';
9
 
10
  const DOOR_CLOSED = 3;
11
- const OBSTACLE = 5;
 
 
 
 
 
 
 
12
 
13
  function App() {
14
- const [observation, setObservation] = useState<Observation | null>(null);
15
- const [sceneData, setSceneData] = useState<SceneResponse | null>(null);
16
- const [isAutoWait, setIsAutoWait] = useState(false);
17
- const [isPolling, setIsPolling] = useState(true); // Default to polling on
18
- const [status, setStatus] = useState('idle');
19
- const [isError, setIsError] = useState(false);
20
- const [apiReport, setApiReport] = useState<ApiReport | null>(null);
21
  const [agentMoveCount, setAgentMoveCount] = useState(0);
22
  const [agentMoveFlash, setAgentMoveFlash] = useState(0);
23
-
24
- const prevAgentPos = useRef({ x: -1, y: -1 });
 
25
  const autoWaitTimer = useRef<number | null>(null);
26
- const pollTimer = useRef<number | null>(null);
 
 
 
 
 
 
27
 
28
  const setStatusMsg = (msg: string, error = false) => {
29
  setStatus(msg);
30
  setIsError(error);
31
  };
32
 
 
 
 
 
33
  const applyObservation = useCallback((obs: Observation) => {
34
  const newX = obs.map_state.agent_x;
35
  const newY = obs.map_state.agent_y;
36
-
37
- if (prevAgentPos.current.x !== -1 && (newX !== prevAgentPos.current.x || newY !== prevAgentPos.current.y)) {
38
  setAgentMoveFlash(18);
39
  setAgentMoveCount(c => c + 1);
40
  }
@@ -42,23 +59,21 @@ function App() {
42
  setObservation(obs);
43
  }, []);
44
 
45
- const updateReport = (kind: string, request: any, response: any) => {
46
  const mapState = response?.observation?.map_state || response?.map_state || response?.graph;
47
  const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown';
48
- const step = mapState?.step_count ?? response?.observation?.elapsed_steps ?? response?.labels?.episode?.step ?? '-';
49
- const doors = mapState?.door_registry ? Object.keys(mapState.door_registry).length : (response?.labels?.map?.door_registry ? Object.keys(response.labels.map.door_registry).length : 0);
50
- const reward = Number(response?.reward ?? 0).toFixed(3);
51
- const done = Boolean(response?.done);
52
-
53
  setApiReport({
54
  call_type: kind,
55
  request,
56
  response,
57
- meta: `${kind.toUpperCase()} | template=${template} | step=${step} | doors=${doors} | reward=${reward} | done=${done}`
58
  });
59
  };
60
 
61
- const apiCall = async (path: string, payload: any) => {
62
  const res = await fetch(path, {
63
  method: 'POST',
64
  headers: { 'Content-Type': 'application/json' },
@@ -73,22 +88,22 @@ function App() {
73
 
74
  const resetLive = async (difficulty = 'medium') => {
75
  try {
76
- setStatusMsg('Initiating System Reset...');
77
  const payload = { difficulty };
78
  const data = await apiCall('/reset', payload);
79
  const obs: Observation = data.observation;
80
- // Add metadata from reset response if available
81
- if (data.observation && data.observation.map_state) {
82
- obs.metadata = {
83
- fire_sources: data.observation.fire_sources_count ?? 0,
84
- fire_spread_rate: data.observation.fire_spread_rate ?? 0,
85
- humidity: data.observation.humidity ?? 0,
86
- difficulty: difficulty
87
- };
88
  }
89
  applyObservation(obs);
90
  updateReport('reset', payload, data);
91
- setStatusMsg(`System Ready. Reward: ${Number(data.reward || 0).toFixed(2)}`);
 
92
  } catch (err: any) {
93
  setStatusMsg(`Reset Failed: ${err.message}`, true);
94
  }
@@ -96,45 +111,52 @@ function App() {
96
 
97
  const resetUntilDoors = async () => {
98
  try {
99
- setStatusMsg('Searching for Optimal Map Layout...');
100
  for (let i = 1; i <= 8; i++) {
101
  const payload = { difficulty: 'medium' };
102
- const data = await apiCall('/reset', payload);
103
  const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
104
  if (doorCount > 0) {
105
  const obs: Observation = data.observation;
106
  obs.metadata = {
107
- fire_sources: data.observation.fire_sources_count ?? 0,
108
- fire_spread_rate: data.observation.fire_spread_rate ?? 0,
109
- humidity: data.observation.humidity ?? 0,
110
- difficulty: 'medium'
111
  };
112
  applyObservation(obs);
113
  updateReport('reset', payload, data);
114
- setStatusMsg(`System Ready. Doors detected in sector.`);
 
115
  return;
116
  }
117
  }
118
- setStatusMsg('Optimal Layout Not Found.', true);
119
  } catch (err: any) {
120
  setStatusMsg(`Search Failed: ${err.message}`, true);
121
  }
122
  };
123
 
124
- const runAction = async (actionObj: any, label: string) => {
125
  try {
126
- setStatusMsg(`Executing Action: ${label}...`);
127
  const payload = { action: actionObj };
128
- const data = await apiCall('/step', payload);
129
  const obs: Observation = data.observation;
130
- // We might need to keep metadata from previous state if not in step response
131
- obs.metadata = observation?.metadata;
132
  applyObservation(obs);
133
  updateReport('step', payload, data);
134
- setStatusMsg(`Action Executed. Reward: ${Number(data.reward || 0).toFixed(2)}`);
 
 
 
 
 
 
 
135
  if (data.done) setIsAutoWait(false);
136
  } catch (err: any) {
137
- setStatusMsg(`Execution Error: ${err.message}`, true);
138
  }
139
  };
140
 
@@ -146,16 +168,13 @@ function App() {
146
  setSceneData(scene);
147
  updateReport('scene', {}, scene);
148
 
149
- // Map SceneResponse to Observation for the Map2D component
150
  const { labels, graph } = scene;
151
-
152
- // Flatten the 3D grid back to 1D arrays for the legacy renderer
153
- const cell_grid: number[] = [];
154
- const fire_grid: number[] = [];
155
  const smoke_grid: number[] = [];
156
-
157
- for(let y=0; y<graph.height; y++) {
158
- for(let x=0; x<graph.width; x++) {
159
  const [type, fire, smoke] = graph.grid[y][x];
160
  cell_grid.push(type);
161
  fire_grid.push(fire);
@@ -164,49 +183,40 @@ function App() {
164
  }
165
 
166
  const visible_cells: [number, number][] = [];
167
- for(let y=0; y<graph.height; y++) {
168
- for(let x=0; x<graph.width; x++) {
169
- if (graph.grid[y][x][4] === 1.0) { // is_visible channel
170
- visible_cells.push([x, y]);
171
- }
172
  }
173
  }
174
 
175
- const totalFire = fire_grid.reduce((a, b) => a + b, 0);
176
- if (totalFire > 0) {
177
- console.log(`[DEBUG] Scene received with fire: total_intensity=${totalFire.toFixed(2)}, cells=${fire_grid.filter(v => v > 0).length}`);
178
- }
179
-
180
  const pseudoObs: Observation = {
181
  map_state: {
182
- cell_grid,
183
- fire_grid,
184
- smoke_grid,
185
- agent_x: labels.agent.x,
186
- agent_y: labels.agent.y,
187
  visible_cells,
188
- door_registry: labels.map.door_registry,
189
  exit_positions: labels.map.exit_positions,
190
- step_count: labels.episode.step,
191
- max_steps: labels.episode.max_steps,
192
- grid_w: graph.width,
193
- grid_h: graph.height,
194
- template_name: labels.episode.template,
195
  },
196
- agent_health: labels.agent.health,
197
- location_label: labels.agent.location,
198
- smoke_level: labels.agent.smoke_level,
199
- wind_dir: labels.episode.wind_dir,
200
- fire_visible: labels.agent.fire_visible,
201
- fire_direction: labels.agent.fire_direction,
202
  last_action_feedback: labels.agent.last_action_feedback,
203
  narrative: '',
204
  metadata: {
205
- fire_sources: labels.episode.fire_sources,
206
- fire_spread_rate: labels.episode.fire_spread_rate,
207
- humidity: labels.episode.humidity,
208
- difficulty: labels.episode.difficulty,
209
- }
210
  };
211
  applyObservation(pseudoObs);
212
  } catch (err: any) {
@@ -214,7 +224,6 @@ function App() {
214
  }
215
  }, [applyObservation, observation?.metadata]);
216
 
217
- // Auto Wait Effect
218
  useEffect(() => {
219
  if (isAutoWait) {
220
  autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
@@ -224,7 +233,6 @@ function App() {
224
  return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); };
225
  }, [isAutoWait]);
226
 
227
- // Polling Effect (Scene - 2 times per second)
228
  useEffect(() => {
229
  if (isPolling) {
230
  fetchAndApplyScene();
@@ -235,102 +243,107 @@ function App() {
235
  return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
236
  }, [isPolling, fetchAndApplyScene]);
237
 
 
 
 
 
 
 
 
238
  const setup = async () => {
239
  setIsPolling(false);
240
  setAgentMoveCount(0);
241
  setAgentMoveFlash(0);
 
242
  prevAgentPos.current = { x: -1, y: -1 };
243
  await resetLive();
244
  setIsPolling(true);
245
  };
246
 
247
- const copyToClipboard = (text: string) => {
248
- navigator.clipboard.writeText(text).then(() => setStatusMsg('Payload Copied.'));
249
- };
250
-
251
- const buildResetCurl = () => `curl -X POST "http://127.0.0.1:8000/reset" -H "Content-Type: application/json" -d "{\\"difficulty\\":\\"medium\\"}"`;
252
- const buildSceneCurl = () => `curl -X GET "http://127.0.0.1:8000/scene"`;
253
- const buildStepCurl = () => {
254
- const action = apiReport?.request?.action || { action: 'wait' };
255
- return `curl -X POST "http://127.0.0.1:8000/step" -H "Content-Type: application/json" -d "${JSON.stringify({ action }).replace(/"/g, '\\"')}"`;
256
- };
257
-
258
- const doors: Door[] = Object.entries(observation?.map_state.door_registry || {}).map(([id, [x, y]]) => {
259
- const ct = observation?.map_state.cell_grid[y * observation.map_state.grid_w + x];
260
- let state: 'open' | 'closed' | 'failed' = 'open';
261
- if (ct === DOOR_CLOSED) state = 'closed';
262
- if (ct === OBSTACLE) state = 'failed';
263
- return { id, x, y, state };
264
- }).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
265
-
266
- const fireMax = observation ? Math.max(...observation.map_state.fire_grid) : 0;
267
- const fireCells = observation ? observation.map_state.fire_grid.filter(v => v > 0.05).length : 0;
268
- const smokeCells = observation ? observation.map_state.smoke_grid.filter(v => v > 0.05).length : 0;
269
- const exploredPct = observation ? Math.round((new Set(observation.map_state.visible_cells.map(([x,y]) => `${x},${y}`)).size / observation.map_state.cell_grid.length) * 100) : 0;
270
 
271
  return (
272
- <div className="app-container">
273
- <header className="dashboard-header">
274
- <div className="dashboard-title">
275
- PYRE <span>EVACUATION PROTOCOL</span>
 
 
 
 
276
  </div>
277
- <div className="hud-card" style={{ padding: '8px 16px', flexDirection: 'row', gap: '20px' }}>
278
- <div style={{ display: 'flex', flexDirection: 'column' }}>
279
- <span className="hud-label">Episode ID</span>
280
- <span className="sv blue" style={{ fontSize: '12px' }}>{sceneData?.labels.episode.id.slice(0, 8) || 'OFFLINE'}</span>
281
- </div>
282
- <div style={{ display: 'flex', flexDirection: 'column' }}>
283
- <span className="hud-label">Difficulty</span>
284
- <span className="sv green" style={{ fontSize: '12px', textTransform: 'uppercase' }}>{sceneData?.labels.episode.difficulty || 'MEDIUM'}</span>
285
- </div>
286
- <div style={{ display: 'flex', flexDirection: 'column' }}>
287
- <span className="hud-label">Status</span>
288
- <span className={`sv ${isError ? 'danger' : 'green'}`} style={{ fontSize: '12px' }}>{isError ? 'ERROR' : 'SYNCED'}</span>
289
  </div>
 
 
 
290
  </div>
291
  </header>
292
 
293
- <div className="outer">
294
- {/* MAIN VIEWPORT */}
295
- <div className="viewport-container">
296
- <div className="screen-shell">
297
- <div className="scanline"></div>
298
- <div className="screen-bezel">
299
- <Map2D
300
- observation={observation}
301
- agentMoveFlash={agentMoveFlash}
302
- />
303
- </div>
304
  <HUD observation={observation} agentMoveCount={agentMoveCount} />
305
  </div>
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  <div className="dialog">
308
  <span className="dialog-who">Field Report</span>
309
- <span>{observation?.last_action_feedback || 'Establishing neural link with sector...'}</span>
310
  </div>
311
-
312
- <StatusCard title="Surroundings & Analysis">
313
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
314
- <div>
315
- <StatusRow label="Visible Objects" value={sceneData?.labels.surroundings.visible_objects.length || 0} />
316
- <StatusRow label="Blocked Exits" value={sceneData?.labels.surroundings.blocked_exit_ids.length || 0} className="danger" />
317
- </div>
318
- <div>
319
- <StatusRow label="Audible Signals" value={sceneData?.labels.surroundings.audible_signals.length || 0} className="warn" />
320
- <StatusRow label="Available Actions" value={sceneData?.labels.surroundings.available_actions.length || 0} className="green" />
321
- </div>
322
- </div>
323
- {sceneData?.labels.surroundings.visible_objects.length ? (
324
- <div style={{ marginTop: '10px', fontSize: '12px', opacity: 0.8 }}>
325
- DETECTION: {sceneData.labels.surroundings.visible_objects.map(o => `${o.type} (${o.id})`).join(', ')}
326
- </div>
327
- ) : null}
328
- </StatusCard>
329
  </div>
330
 
331
- {/* RIGHT CONTROL PANEL */}
332
- <div className="panel">
333
- <ControlPanel
 
 
334
  onAction={runAction}
335
  onReset={resetLive}
336
  onResetDoors={resetUntilDoors}
@@ -344,42 +357,101 @@ function App() {
344
  isError={isError}
345
  />
346
 
347
- <StatusCard title="🔥 Hazard Logistics">
348
- <StatusRow label="Ignition Sources" value={observation?.metadata?.fire_sources || 0} className="hot" />
349
- <StatusRow label="Spread Probability" value={`${((observation?.metadata?.fire_spread_rate || 0) * 100).toFixed(1)}%`} className="danger" />
350
- <StatusRow label="Fire Cells" value={fireCells} className="hot" />
351
- <StatusRow label="Smoke Cells" value={smokeCells} className="warn" />
352
- <StatusRow label="Exploration" value={`${exploredPct}%`} className="blue" />
353
- </StatusCard>
354
-
355
- <StatusCard title="Agent Biometrics">
356
- <StatusRow label="POSITION" value={`(${observation?.map_state.agent_x}, ${observation?.map_state.agent_y})`} className="blue" />
357
- <StatusRow label="SECTOR" value={observation?.location_label || 'Unknown'} />
358
- <StatusRow label="VITAL SIGNS" value={sceneData?.labels.agent.health_status || 'NOMINAL'} className={observation && observation.agent_health < 30 ? 'danger' : 'green'} />
359
- <StatusRow label="SMOKE EXPOSURE" value={observation?.smoke_level || 'None'} className="warn" />
 
 
 
 
 
 
 
 
 
 
 
 
360
  <div className="bar-w">
361
- <div className="bar-lbl"><span>SYSTEM INTEGRITY</span><span>{Math.round(observation?.agent_health || 0)}%</span></div>
 
 
 
362
  <div className="bar-bg">
363
- <div className="bar-fill" style={{ width: `${observation?.agent_health || 0}%` }}></div>
364
  </div>
365
  </div>
366
- </StatusCard>
367
-
368
- <StatusCard title="Environmental Readings">
369
- <StatusRow label="TEMPLATE" value={observation?.map_state.template_name || 'UNKNOWN'} className="green" />
370
- <StatusRow label="DIFFICULTY" value={observation?.metadata?.difficulty?.toUpperCase() || 'MEDIUM'} className="blue" />
371
- <StatusRow label="ATM. HUMIDITY" value={`${Math.round((observation?.metadata?.humidity || 0) * 100)}%`} />
372
- <StatusRow label="WIND VECTOR" value={observation?.wind_dir || 'CALM'} className="blue" />
373
- <StatusRow label="MAX HEAT" value={fireMax.toFixed(2)} className="danger" />
374
- </StatusCard>
375
-
376
- <APIReport
377
- report={apiReport}
378
- onCopyReset={() => copyToClipboard(buildResetCurl())}
379
- onCopyStep={() => copyToClipboard(buildStepCurl())}
380
- onCopyScene={() => copyToClipboard(buildSceneCurl())}
381
- />
382
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  </div>
384
  </div>
385
  );
 
4
  import Map2D from './components/Map2D';
5
  import HUD from './components/HUD';
6
  import ControlPanel from './components/ControlPanel';
 
7
  import APIReport from './components/APIReport';
8
 
9
  const DOOR_CLOSED = 3;
10
+ const OBSTACLE = 5;
11
+
12
+ interface EventEntry {
13
+ step: number;
14
+ text: string;
15
+ reward: number;
16
+ isAlarm?: boolean;
17
+ }
18
 
19
  function App() {
20
+ const [observation, setObservation] = useState<Observation | null>(null);
21
+ const [sceneData, setSceneData] = useState<SceneResponse | null>(null);
22
+ const [isAutoWait, setIsAutoWait] = useState(false);
23
+ const [isPolling, setIsPolling] = useState(true);
24
+ const [status, setStatus] = useState('Idle — waiting for connection');
25
+ const [isError, setIsError] = useState(false);
26
+ const [apiReport, setApiReport] = useState<ApiReport | null>(null);
27
  const [agentMoveCount, setAgentMoveCount] = useState(0);
28
  const [agentMoveFlash, setAgentMoveFlash] = useState(0);
29
+ const [eventLog, setEventLog] = useState<EventEntry[]>([]);
30
+
31
+ const prevAgentPos = useRef({ x: -1, y: -1 });
32
  const autoWaitTimer = useRef<number | null>(null);
33
+ const pollTimer = useRef<number | null>(null);
34
+ const logEndRef = useRef<HTMLDivElement | null>(null);
35
+
36
+ /* scroll event log to bottom */
37
+ useEffect(() => {
38
+ logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
39
+ }, [eventLog]);
40
 
41
  const setStatusMsg = (msg: string, error = false) => {
42
  setStatus(msg);
43
  setIsError(error);
44
  };
45
 
46
+ const pushLog = (text: string, step: number, reward: number, isAlarm = false) => {
47
+ setEventLog(prev => [...prev.slice(-49), { step, text, reward, isAlarm }]);
48
+ };
49
+
50
  const applyObservation = useCallback((obs: Observation) => {
51
  const newX = obs.map_state.agent_x;
52
  const newY = obs.map_state.agent_y;
53
+ if (prevAgentPos.current.x !== -1 &&
54
+ (newX !== prevAgentPos.current.x || newY !== prevAgentPos.current.y)) {
55
  setAgentMoveFlash(18);
56
  setAgentMoveCount(c => c + 1);
57
  }
 
59
  setObservation(obs);
60
  }, []);
61
 
62
+ const updateReport = (kind: string, request: unknown, response: any) => {
63
  const mapState = response?.observation?.map_state || response?.map_state || response?.graph;
64
  const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown';
65
+ const step = mapState?.step_count ?? response?.observation?.elapsed_steps ?? response?.labels?.episode?.step ?? '-';
66
+ const reward = Number(response?.reward ?? 0).toFixed(3);
67
+ const done = Boolean(response?.done);
 
 
68
  setApiReport({
69
  call_type: kind,
70
  request,
71
  response,
72
+ meta: `${kind.toUpperCase()} | template=${template} | step=${step} | reward=${reward} | done=${done}`,
73
  });
74
  };
75
 
76
+ const apiCall = async (path: string, payload: unknown) => {
77
  const res = await fetch(path, {
78
  method: 'POST',
79
  headers: { 'Content-Type': 'application/json' },
 
88
 
89
  const resetLive = async (difficulty = 'medium') => {
90
  try {
91
+ setStatusMsg('Initiating Reset...');
92
  const payload = { difficulty };
93
  const data = await apiCall('/reset', payload);
94
  const obs: Observation = data.observation;
95
+ if (data.observation?.map_state) {
96
+ obs.metadata = {
97
+ fire_sources: data.observation.fire_sources_count ?? 0,
98
+ fire_spread_rate:data.observation.fire_spread_rate ?? 0,
99
+ humidity: data.observation.humidity ?? 0,
100
+ difficulty,
101
+ };
 
102
  }
103
  applyObservation(obs);
104
  updateReport('reset', payload, data);
105
+ setStatusMsg(`Ready. Reward: ${Number(data.reward || 0).toFixed(2)}`);
106
+ pushLog('Episode reset. Assess surroundings.', obs.map_state.step_count, data.reward ?? 0);
107
  } catch (err: any) {
108
  setStatusMsg(`Reset Failed: ${err.message}`, true);
109
  }
 
111
 
112
  const resetUntilDoors = async () => {
113
  try {
114
+ setStatusMsg('Searching for layout with doors...');
115
  for (let i = 1; i <= 8; i++) {
116
  const payload = { difficulty: 'medium' };
117
+ const data = await apiCall('/reset', payload);
118
  const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
119
  if (doorCount > 0) {
120
  const obs: Observation = data.observation;
121
  obs.metadata = {
122
+ fire_sources: data.observation.fire_sources_count ?? 0,
123
+ fire_spread_rate:data.observation.fire_spread_rate ?? 0,
124
+ humidity: data.observation.humidity ?? 0,
125
+ difficulty: 'medium',
126
  };
127
  applyObservation(obs);
128
  updateReport('reset', payload, data);
129
+ setStatusMsg(`System Ready ${doorCount} door(s) detected.`);
130
+ pushLog(`Layout found (${doorCount} doors). Doors detected.`, obs.map_state.step_count, 0);
131
  return;
132
  }
133
  }
134
+ setStatusMsg('Optimal layout not found after 8 attempts.', true);
135
  } catch (err: any) {
136
  setStatusMsg(`Search Failed: ${err.message}`, true);
137
  }
138
  };
139
 
140
+ const runAction = async (actionObj: unknown, label: string) => {
141
  try {
142
+ setStatusMsg(`Action: ${label}`);
143
  const payload = { action: actionObj };
144
+ const data = await apiCall('/step', payload);
145
  const obs: Observation = data.observation;
146
+ obs.metadata = observation?.metadata;
 
147
  applyObservation(obs);
148
  updateReport('step', payload, data);
149
+ const rwd = Number(data.reward || 0);
150
+ setStatusMsg(`Executed. Reward: ${rwd.toFixed(2)}`);
151
+ pushLog(
152
+ obs.last_action_feedback || label,
153
+ obs.map_state.step_count,
154
+ rwd,
155
+ rwd < -0.5,
156
+ );
157
  if (data.done) setIsAutoWait(false);
158
  } catch (err: any) {
159
+ setStatusMsg(`Error: ${err.message}`, true);
160
  }
161
  };
162
 
 
168
  setSceneData(scene);
169
  updateReport('scene', {}, scene);
170
 
 
171
  const { labels, graph } = scene;
172
+ const cell_grid: number[] = [];
173
+ const fire_grid: number[] = [];
 
 
174
  const smoke_grid: number[] = [];
175
+
176
+ for (let y = 0; y < graph.height; y++) {
177
+ for (let x = 0; x < graph.width; x++) {
178
  const [type, fire, smoke] = graph.grid[y][x];
179
  cell_grid.push(type);
180
  fire_grid.push(fire);
 
183
  }
184
 
185
  const visible_cells: [number, number][] = [];
186
+ for (let y = 0; y < graph.height; y++) {
187
+ for (let x = 0; x < graph.width; x++) {
188
+ if (graph.grid[y][x][4] === 1.0) visible_cells.push([x, y]);
 
 
189
  }
190
  }
191
 
 
 
 
 
 
192
  const pseudoObs: Observation = {
193
  map_state: {
194
+ cell_grid, fire_grid, smoke_grid,
195
+ agent_x: labels.agent.x,
196
+ agent_y: labels.agent.y,
 
 
197
  visible_cells,
198
+ door_registry: labels.map.door_registry,
199
  exit_positions: labels.map.exit_positions,
200
+ step_count: labels.episode.step,
201
+ max_steps: labels.episode.max_steps,
202
+ grid_w: graph.width,
203
+ grid_h: graph.height,
204
+ template_name: labels.episode.template,
205
  },
206
+ agent_health: labels.agent.health,
207
+ location_label: labels.agent.location,
208
+ smoke_level: labels.agent.smoke_level,
209
+ wind_dir: labels.episode.wind_dir,
210
+ fire_visible: labels.agent.fire_visible,
211
+ fire_direction: labels.agent.fire_direction,
212
  last_action_feedback: labels.agent.last_action_feedback,
213
  narrative: '',
214
  metadata: {
215
+ fire_sources: labels.episode.fire_sources,
216
+ fire_spread_rate:labels.episode.fire_spread_rate,
217
+ humidity: labels.episode.humidity,
218
+ difficulty: labels.episode.difficulty,
219
+ },
220
  };
221
  applyObservation(pseudoObs);
222
  } catch (err: any) {
 
224
  }
225
  }, [applyObservation, observation?.metadata]);
226
 
 
227
  useEffect(() => {
228
  if (isAutoWait) {
229
  autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
 
233
  return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); };
234
  }, [isAutoWait]);
235
 
 
236
  useEffect(() => {
237
  if (isPolling) {
238
  fetchAndApplyScene();
 
243
  return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
244
  }, [isPolling, fetchAndApplyScene]);
245
 
246
+ useEffect(() => {
247
+ if (agentMoveFlash > 0) {
248
+ const timer = setTimeout(() => setAgentMoveFlash(f => f - 1), 50);
249
+ return () => clearTimeout(timer);
250
+ }
251
+ }, [agentMoveFlash]);
252
+
253
  const setup = async () => {
254
  setIsPolling(false);
255
  setAgentMoveCount(0);
256
  setAgentMoveFlash(0);
257
+ setEventLog([]);
258
  prevAgentPos.current = { x: -1, y: -1 };
259
  await resetLive();
260
  setIsPolling(true);
261
  };
262
 
263
+ /* Derived state */
264
+ const doors: Door[] = Object.entries(observation?.map_state.door_registry || {})
265
+ .map(([id, [x, y]]) => {
266
+ const ct = observation?.map_state.cell_grid[y * (observation?.map_state.grid_w ?? 16) + x];
267
+ let state: 'open' | 'closed' | 'failed' = 'open';
268
+ if (ct === DOOR_CLOSED) state = 'closed';
269
+ if (ct === OBSTACLE) state = 'failed';
270
+ return { id, x, y, state };
271
+ })
272
+ .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
273
+
274
+ const fireCells = observation?.map_state.fire_grid.filter(v => v > 0.05).length ?? 0;
275
+ const exploredPct = observation
276
+ ? Math.round((new Set(observation.map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`)).size
277
+ / observation.map_state.cell_grid.length) * 100)
278
+ : 0;
279
+
280
+ const hp = Math.round(observation?.agent_health ?? 0);
281
+ const hpColor = hp >= 60 ? 'var(--green)' : hp >= 30 ? 'var(--amber)' : 'var(--red)';
282
+ const isOnline = isPolling && !isError;
283
+ const epId = sceneData?.labels.episode.id?.slice(0, 8) ?? '—';
 
 
284
 
285
  return (
286
+ <div className="shell">
287
+ {/* ── Topbar ── */}
288
+ <header className="topbar">
289
+ <div className="brand">
290
+ <div className="brand-icon">🔥</div>
291
+ <span className="brand-name">Pyre</span>
292
+ <span className="brand-sep">/</span>
293
+ <span className="brand-sub">Crisis Navigation</span>
294
  </div>
295
+
296
+ <div className="topbar-right">
297
+ <div className="topbar-ep">
298
+ Episode <span className="ep-id">{epId}</span>
 
 
 
 
 
 
 
 
299
  </div>
300
+ <span className={`live-chip ${isError ? 'error' : isOnline ? 'online' : 'offline'}`}>
301
+ {isError ? 'Error' : isOnline ? 'Live' : 'Idle'}
302
+ </span>
303
  </div>
304
  </header>
305
 
306
+ {/* ── Body ── */}
307
+ <div className="content">
308
+
309
+ {/* ── Left: Canvas Zone ── */}
310
+ <div className="canvas-zone">
311
+ <div className="canvas-frame">
312
+ <Map2D observation={observation} agentMoveFlash={agentMoveFlash} />
 
 
 
 
313
  <HUD observation={observation} agentMoveCount={agentMoveCount} />
314
  </div>
315
 
316
+ {/* Legend */}
317
+ <div className="legend">
318
+ {[
319
+ { color:'#5e5850', label:'Wall' },
320
+ { color:'#3a3530', label:'Obstacle' },
321
+ { color:'#e6f4ec', label:'Exit' },
322
+ { color:'#7c5c3c', label:'Door' },
323
+ { color:'#f97316', label:'Fire' },
324
+ { color:'rgba(72,82,96,0.7)', label:'Smoke' },
325
+ { color:'#3b82f6', label:'Agent' },
326
+ { color:'rgba(2,132,199,0.6)', label:'Trail'},
327
+ ].map(({ color, label }) => (
328
+ <div key={label} className="legend-item">
329
+ <div className="leg-swatch" style={{ background: color, border:'1px solid rgba(0,0,0,0.1)' }} />
330
+ {label}
331
+ </div>
332
+ ))}
333
+ </div>
334
+
335
+ {/* Field Report */}
336
  <div className="dialog">
337
  <span className="dialog-who">Field Report</span>
338
+ {observation?.last_action_feedback || 'Establishing link to field systems...'}
339
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  </div>
341
 
342
+ {/* ── Right: Side Panel ── */}
343
+ <aside className="side">
344
+
345
+ {/* Controls */}
346
+ <ControlPanel
347
  onAction={runAction}
348
  onReset={resetLive}
349
  onResetDoors={resetUntilDoors}
 
357
  isError={isError}
358
  />
359
 
360
+ {/* Agent Biometrics */}
361
+ <div className="side-sec">
362
+ <div className="sec-hd">Agent Biometrics</div>
363
+ <div className="sg">
364
+ <div className="sc">
365
+ <div className="sc-l">Health</div>
366
+ <div className="sc-v" style={{ color: hpColor }}>{hp}%</div>
367
+ </div>
368
+ <div className="sc">
369
+ <div className="sc-l">Status</div>
370
+ <div className="sc-v" style={{ color: hpColor }}>
371
+ {sceneData?.labels.agent.health_status ?? 'NOMINAL'}
372
+ </div>
373
+ </div>
374
+ <div className="sc">
375
+ <div className="sc-l">Position</div>
376
+ <div className="sc-v blue">
377
+ ({observation?.map_state.agent_x ?? '—'},{observation?.map_state.agent_y ?? '—'})
378
+ </div>
379
+ </div>
380
+ <div className="sc">
381
+ <div className="sc-l">Sector</div>
382
+ <div className="sc-v">{observation?.location_label ?? 'Unknown'}</div>
383
+ </div>
384
+ </div>
385
  <div className="bar-w">
386
+ <div className="bar-lbl">
387
+ <span>System Integrity</span>
388
+ <span>{hp}%</span>
389
+ </div>
390
  <div className="bar-bg">
391
+ <div className="bar-fill" style={{ width: `${hp}%`, background: hpColor }} />
392
  </div>
393
  </div>
394
+ </div>
395
+
396
+ {/* Environment */}
397
+ <div className="side-sec">
398
+ <div className="sec-hd">Environment</div>
399
+ <div className="sg">
400
+ <div className="sc">
401
+ <div className="sc-l">Hazard Cells</div>
402
+ <div className="sc-v fire">{fireCells}</div>
403
+ </div>
404
+ <div className="sc">
405
+ <div className="sc-l">Explored</div>
406
+ <div className="sc-v blue">{exploredPct}%</div>
407
+ </div>
408
+ <div className="sc">
409
+ <div className="sc-l">Wind</div>
410
+ <div className="sc-v">{observation?.wind_dir ?? 'CALM'}</div>
411
+ </div>
412
+ <div className="sc">
413
+ <div className="sc-l">Humidity</div>
414
+ <div className="sc-v amber">
415
+ {Math.round((observation?.metadata?.humidity ?? 0) * 100)}%
416
+ </div>
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ {/* Event Log */}
422
+ <div className="side-sec" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
423
+ <div className="sec-hd">
424
+ Event Log
425
+ <span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'var(--t3)' }}>
426
+ {eventLog.length} events
427
+ </span>
428
+ </div>
429
+ <div className="elog">
430
+ {eventLog.length === 0 && (
431
+ <div style={{ color: 'var(--t3)', fontFamily: 'var(--mono)', fontSize: '10px', padding: '4px' }}>
432
+ No events yet…
433
+ </div>
434
+ )}
435
+ {eventLog.map((e, i) => (
436
+ <div key={i} className={`erow ${e.isAlarm ? 'alarm' : ''}`}>
437
+ <span className="estep">T{e.step}</span>
438
+ <span className="etext">{e.text}</span>
439
+ <span className={`erwd ${e.reward >= 0 ? 'p' : 'n'}`}>
440
+ {e.reward >= 0 ? '+' : ''}{e.reward.toFixed(2)}
441
+ </span>
442
+ </div>
443
+ ))}
444
+ <div ref={logEndRef} />
445
+ </div>
446
+ </div>
447
+
448
+ {/* API Report */}
449
+ <div className="side-sec">
450
+ <div className="sec-hd">Network Activity</div>
451
+ <APIReport report={apiReport} onCopyReset={() => {}} onCopyStep={() => {}} onCopyScene={() => {}} />
452
+ </div>
453
+
454
+ </aside>
455
  </div>
456
  </div>
457
  );
frontend/src/components/APIReport.tsx CHANGED
@@ -10,10 +10,9 @@ interface APIReportProps {
10
 
11
  const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep, onCopyScene }) => {
12
  return (
13
- <div className="card">
14
- <div className="card-title">Network Telemetry</div>
15
- <div className="report-meta">
16
- {report ? report.meta : 'Awaiting data packets...'}
17
  </div>
18
  <div className="report-box">
19
  {report ? JSON.stringify({
@@ -22,10 +21,10 @@ const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep,
22
  response: report.response
23
  }, null, 2) : '{}'}
24
  </div>
25
- <div className="report-actions">
26
- <button className="ctrl-btn" onClick={onCopyScene}>CURL Scene</button>
27
- <button className="ctrl-btn" onClick={onCopyReset}>CURL Reset</button>
28
- <button className="ctrl-btn" onClick={onCopyStep}>CURL Step</button>
29
  </div>
30
  </div>
31
  );
 
10
 
11
  const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep, onCopyScene }) => {
12
  return (
13
+ <div>
14
+ <div className="report-meta" style={{ marginBottom: '8px' }}>
15
+ {report ? report.meta : 'Awaiting telemetry...'}
 
16
  </div>
17
  <div className="report-box">
18
  {report ? JSON.stringify({
 
21
  response: report.response
22
  }, null, 2) : '{}'}
23
  </div>
24
+ <div className="ctrl-grid" style={{ marginTop: '12px' }}>
25
+ <button className="ctrl-btn" onClick={onCopyScene}>Scene</button>
26
+ <button className="ctrl-btn" onClick={onCopyReset}>Reset</button>
27
+ <button className="ctrl-btn" onClick={onCopyStep}>Step</button>
28
  </div>
29
  </div>
30
  );
frontend/src/components/ControlPanel.tsx CHANGED
@@ -1,8 +1,8 @@
1
- import React, { useState } from 'react';
2
  import type { Door } from '../types';
3
 
4
  interface ControlPanelProps {
5
- onAction: (action: any, label: string) => void;
6
  onReset: (difficulty: string) => void;
7
  onResetDoors: () => void;
8
  onSetup: () => void;
@@ -16,51 +16,73 @@ interface ControlPanelProps {
16
  }
17
 
18
  const ControlPanel: React.FC<ControlPanelProps> = ({
19
- onAction, onReset, onResetDoors, onSetup,
20
- doors, isAutoWait, toggleAutoWait, isPolling, togglePolling,
21
- status, isError
 
22
  }) => {
23
-
24
- const [doorId, setDoorId] = useState(doors[0]?.id || 'door_1');
25
 
26
  return (
27
- <div className="card">
28
- <div className="card-title">Tactical Interface</div>
29
- <div className="ctrl-grid">
30
- <button className="ctrl-btn wide accent" onClick={onSetup}>Establish Link & Start Polling</button>
31
-
32
- <button className="ctrl-btn" onClick={() => onAction({ action: 'move', direction: 'north' }, 'MOVE N')}>▲ N</button>
33
- <button className="ctrl-btn" onClick={() => onAction({ action: 'look', direction: 'north' }, 'LOOK N')}>🔍 N</button>
34
- <button className="ctrl-btn" onClick={() => onReset('medium')}>Reset</button>
35
- <button className="ctrl-btn" onClick={onResetDoors}>Seek Doors</button>
36
-
37
- <button className="ctrl-btn" onClick={() => onAction({ action: 'move', direction: 'west' }, 'MOVE W')}>◀ W</button>
38
- <button className="ctrl-btn" onClick={() => onAction({ action: 'wait' }, 'WAIT')}>Wait</button>
39
- <button className="ctrl-btn" onClick={() => onAction({ action: 'move', direction: 'east' }, 'MOVE E')}>E ▶</button>
40
-
41
- <button className="ctrl-btn" onClick={() => onAction({ action: 'look', direction: 'west' }, 'LOOK W')}>🔍 W</button>
42
- <button className="ctrl-btn" onClick={() => onAction({ action: 'move', direction: 'south' }, 'MOVE S')}>▼ S</button>
43
- <button className="ctrl-btn" onClick={() => onAction({ action: 'look', direction: 'south' }, 'LOOK S')}>🔍 S</button>
44
-
45
- <input
46
- className="ctrl-input"
47
- value={doorId}
48
- onChange={(e) => setDoorId(e.target.value)}
49
- placeholder="door_id"
50
- />
51
- <button className="ctrl-btn" onClick={() => onAction({ action: 'door', target_id: doorId, door_state: 'open' }, `OPEN ${doorId}`)}>Open</button>
52
- <button className="ctrl-btn" onClick={() => onAction({ action: 'door', target_id: doorId, door_state: 'close' }, `CLOSE ${doorId}`)}>Close</button>
53
-
54
- <button className="ctrl-btn" onClick={toggleAutoWait}>
55
- Auto: {isAutoWait ? 'ON' : 'OFF'}
56
  </button>
57
- <button className="ctrl-btn" onClick={togglePolling}>
58
- Poll: {isPolling ? 'ON' : 'OFF'}
59
  </button>
60
  </div>
61
- <div className="ctrl-status" style={{ color: isError ? 'var(--accent-red)' : 'var(--text-secondary)' }}>
62
- <span style={{ color: 'var(--accent-blue)', marginRight: '8px' }}>&gt;</span> {status}
 
 
 
 
 
 
 
 
 
63
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  </div>
65
  );
66
  };
 
1
+ import React from 'react';
2
  import type { Door } from '../types';
3
 
4
  interface ControlPanelProps {
5
+ onAction: (action: unknown, label: string) => void;
6
  onReset: (difficulty: string) => void;
7
  onResetDoors: () => void;
8
  onSetup: () => void;
 
16
  }
17
 
18
  const ControlPanel: React.FC<ControlPanelProps> = ({
19
+ onAction, onSetup, doors,
20
+ isAutoWait, toggleAutoWait,
21
+ isPolling, togglePolling,
22
+ status, isError,
23
  }) => {
24
+ const mv = (dir: string) => onAction({ action: 'move', direction: dir }, `MOVE ${dir.toUpperCase()}`);
 
25
 
26
  return (
27
+ <div className="side-sec">
28
+ <div className="sec-hd">Tactical Controls</div>
29
+
30
+ {/* D-Pad */}
31
+ <div className="ctrl-grid" style={{ maxWidth: 160, margin: '0 auto', marginBottom: 8 }}>
32
+ {/* Row 1 */}
33
+ <div />
34
+ <button className="ctrl-btn" onClick={() => mv('north')}></button>
35
+ <div />
36
+ {/* Row 2 */}
37
+ <button className="ctrl-btn" onClick={() => mv('west')}>◀</button>
38
+ <button className="ctrl-btn accent" onClick={() => onAction({ action: 'wait' }, 'WAIT')} title="Wait">●</button>
39
+ <button className="ctrl-btn" onClick={() => mv('east')}>▶</button>
40
+ {/* Row 3 */}
41
+ <div />
42
+ <button className="ctrl-btn" onClick={() => mv('south')}>▼</button>
43
+ <div />
44
+ </div>
45
+
46
+ {/* Secondary actions */}
47
+ <div className="ctrl-row">
48
+ <button className="ctrl-btn" onClick={() => onAction({ action: 'explore' }, 'EXPLORE')}>SCAN</button>
49
+ <button className={`ctrl-btn ${isAutoWait ? 'active' : ''}`} onClick={toggleAutoWait}>
50
+ {isAutoWait ? 'STOP AUTO' : 'AUTO'}
 
 
 
 
 
51
  </button>
52
+ <button className={`ctrl-btn ${isPolling ? 'active' : ''}`} onClick={togglePolling}>
53
+ {isPolling ? 'LIVE ●' : 'LIVE ○'}
54
  </button>
55
  </div>
56
+
57
+ {/* Reboot */}
58
+ <div className="ctrl-row" style={{ marginTop: 6 }}>
59
+ <button className="ctrl-btn play" onClick={onSetup} style={{ flex: 'none', width: '100%' }}>
60
+ ↺ REBOOT EPISODE
61
+ </button>
62
+ </div>
63
+
64
+ {/* Status line */}
65
+ <div className={`ctrl-status ${isError ? 'error' : ''}`}>
66
+ {status}
67
  </div>
68
+
69
+ {/* Doors */}
70
+ {doors.length > 0 && (
71
+ <div style={{ marginTop: 12 }}>
72
+ <div className="sec-hd" style={{ marginBottom: 6 }}>Proximity Doors</div>
73
+ <div className="door-grid">
74
+ {doors.map(d => (
75
+ <button
76
+ key={d.id}
77
+ className={`door-btn ${d.state}`}
78
+ onClick={() => onAction({ action: 'interact', door_id: d.id }, `INTERACT ${d.id}`)}
79
+ >
80
+ {d.id} [{d.state}]
81
+ </button>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ )}
86
  </div>
87
  );
88
  };
frontend/src/components/HUD.tsx CHANGED
@@ -3,46 +3,140 @@ import type { Observation } from '../types';
3
 
4
  interface HUDProps {
5
  observation: Observation | null;
6
- agentMoveCount: number;
7
  }
8
 
9
- const HUD: React.FC<HUDProps> = ({ observation, agentMoveCount }) => {
10
- if (!observation) return (
11
- <div className="hud">
12
- <div className="hud-card"><span className="hud-label">Biometrics</span><span className="hud-value">OFFLINE</span></div>
13
- <div className="hud-card"><span className="hud-label">Chronology</span><span className="hud-value">-- / --</span></div>
14
- <div className="hud-card"><span className="hud-label">Telemetry</span><span className="hud-value">WAITING</span></div>
15
- <div className="hud-card"><span className="hud-label">Displacement</span><span className="hud-value">0</span></div>
16
- </div>
17
- );
18
-
19
- const { map_state, agent_health } = observation;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  return (
22
- <div className="hud">
 
23
  <div className="hud-card">
24
- <span className="hud-label">Vitality</span>
25
- <span className="hud-value hud-hp">{Math.round(agent_health)}%</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  </div>
27
- <div className="hud-card">
28
- <span className="hud-label">Atmosphere</span>
29
- <span className={`hud-value ${observation.smoke_level === 'heavy' ? 'danger' : observation.smoke_level === 'moderate' ? 'warn' : ''}`}>
30
- {observation.smoke_level.toUpperCase()}
31
- </span>
32
- </div>
33
- <div className="hud-card">
34
- <span className="hud-label">Fire Vision</span>
35
- <span className={`hud-value ${observation.fire_visible ? 'hot' : 'green'}`}>
36
- {observation.fire_visible ? `${observation.fire_direction?.toUpperCase()}` : 'CLEAR'}
37
- </span>
38
- </div>
39
- <div className="hud-card">
40
- <span className="hud-label">Chronology</span>
41
- <span className="hud-value hud-step">{map_state.step_count} <span style={{fontSize: '10px', opacity: 0.6}}>/ {map_state.max_steps}</span></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  </div>
43
- <div className="hud-card">
44
- <span className="hud-label">Displacement</span>
45
- <span className="hud-value hud-moves">{agentMoveCount}</span>
 
 
 
 
 
 
 
 
 
46
  </div>
47
  </div>
48
  );
 
3
 
4
  interface HUDProps {
5
  observation: Observation | null;
6
+ agentMoveCount?: number;
7
  }
8
 
9
+ /* Unicode compass arrows for each wind direction */
10
+ const WIND_ARROW: Record<string, string> = {
11
+ N: '↑', S: '↓', E: '→', W: '←',
12
+ NE: '↗', NW: '↖', SE: '↘', SW: '↙',
13
+ CALM: '·',
14
+ };
15
+
16
+ /* Rotation degrees so the arrow visually points in the right direction */
17
+ const WIND_DEG: Record<string, number> = {
18
+ N: 0, NE: 45, E: 90, SE: 135,
19
+ S: 180, SW: 225, W: 270, NW: 315, CALM: 0,
20
+ };
21
+
22
+ const HUD: React.FC<HUDProps> = ({ observation, agentMoveCount = 0 }) => {
23
+ if (!observation) return null;
24
+
25
+ const { map_state, agent_health, smoke_level, wind_dir, fire_visible, metadata } = observation;
26
+ const hPct = Math.round(agent_health);
27
+ const sPct = Math.round((map_state.step_count / map_state.max_steps) * 100);
28
+
29
+ let hBarClass = 'g';
30
+ let hStatusLabel = 'Nominal';
31
+ let hStatusClass = 'good';
32
+
33
+ if (hPct < 30) { hBarClass = 'c'; hStatusLabel = 'Critical'; hStatusClass = 'critical'; }
34
+ else if (hPct < 60) { hBarClass = 'm'; hStatusLabel = 'Moderate'; hStatusClass = 'moderate'; }
35
+
36
+ const windDir = wind_dir || 'CALM';
37
+ const windArrow = WIND_ARROW[windDir] ?? '?';
38
+ const windDeg = WIND_DEG[windDir] ?? 0;
39
+ const spreadRate = metadata?.fire_spread_rate ?? 0;
40
+ const humidity = metadata?.humidity ?? 0;
41
 
42
  return (
43
+ <div className="hud-overlay">
44
+ {/* ── Left: Health ── */}
45
  <div className="hud-card">
46
+ <div className="hud-r">
47
+ <span className="hlbl">HP</span>
48
+ <div className="hbar-bg">
49
+ <div className={`hbar-fill ${hBarClass}`} style={{ width: `${hPct}%` }} />
50
+ </div>
51
+ <span className="hval">{hPct}</span>
52
+ </div>
53
+ <div className="hud-r" style={{ gap: '8px', marginTop: '2px' }}>
54
+ <span className={`hstatus ${hStatusClass}`}>{hStatusLabel}</span>
55
+ <span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'rgba(168,162,158,.55)', marginLeft: 'auto' }}>
56
+ 💨 {smoke_level || 'clear'}
57
+ </span>
58
+ </div>
59
+ {agentMoveCount > 0 && (
60
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '8px', color: 'rgba(168,162,158,.4)', marginTop: '2px' }}>
61
+ moves: {agentMoveCount}
62
+ </div>
63
+ )}
64
  </div>
65
+
66
+ {/* ── Center: Wind & Hazard ── */}
67
+ <div className="hud-card hud-card-center">
68
+ {/* Compass rose */}
69
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
70
+ <div style={{ position: 'relative', width: 32, height: 32, flexShrink: 0 }}>
71
+ {/* compass ring */}
72
+ <svg width="32" height="32" viewBox="0 0 32 32" style={{ position: 'absolute', top: 0, left: 0 }}>
73
+ <circle cx="16" cy="16" r="14" fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
74
+ <text x="16" y="6" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">N</text>
75
+ <text x="16" y="28" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">S</text>
76
+ <text x="4" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">W</text>
77
+ <text x="28" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">E</text>
78
+ </svg>
79
+ {/* direction arrow */}
80
+ <div style={{
81
+ position: 'absolute', top: '50%', left: '50%',
82
+ transform: `translate(-50%,-50%) rotate(${windDeg}deg)`,
83
+ fontSize: windDir === 'CALM' ? '10px' : '14px',
84
+ color: windDir === 'CALM' ? 'rgba(168,162,158,0.6)' : '#fbbf24',
85
+ lineHeight: 1,
86
+ transition: 'transform 0.6s ease',
87
+ }}>
88
+ {windArrow}
89
+ </div>
90
+ </div>
91
+
92
+ <div>
93
+ <div className="hlbl">Wind</div>
94
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '13px', fontWeight: 500, color: '#f0e8e0', lineHeight: 1.2 }}>
95
+ {windDir}
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
101
+ <div>
102
+ <div className="hlbl">Spread</div>
103
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: spreadRate > 0.5 ? '#f87171' : '#fbbf24' }}>
104
+ {(spreadRate * 100).toFixed(0)}%
105
+ </div>
106
+ </div>
107
+ <div>
108
+ <div className="hlbl">Humidity</div>
109
+ <div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: humidity > 0.6 ? '#60a5fa' : '#a8a29e' }}>
110
+ {(humidity * 100).toFixed(0)}%
111
+ </div>
112
+ </div>
113
+ {fire_visible && (
114
+ <div style={{ alignSelf: 'center' }}>
115
+ <span style={{
116
+ fontFamily: 'var(--mono)', fontSize: '9px', fontWeight: 600,
117
+ color: '#f87171', background: 'rgba(239,68,68,0.15)',
118
+ border: '1px solid rgba(239,68,68,0.3)',
119
+ padding: '2px 5px', borderRadius: '3px', letterSpacing: '0.06em',
120
+ animation: 'pulse-red 1s infinite',
121
+ }}>
122
+ 🔥 FIRE
123
+ </span>
124
+ </div>
125
+ )}
126
+ </div>
127
  </div>
128
+
129
+ {/* ── Right: Steps ── */}
130
+ <div className="hud-card" style={{ textAlign: 'right', minWidth: 'auto' }}>
131
+ <div className="hud-r" style={{ justifyContent: 'flex-end' }}>
132
+ <span className="step-val">{map_state.step_count} / {map_state.max_steps}</span>
133
+ </div>
134
+ <div className="sbar-bg">
135
+ <div className="sbar-fill" style={{ width: `${sPct}%` }} />
136
+ </div>
137
+ <div className="step-meta">
138
+ {metadata?.difficulty ?? 'medium'} · {map_state.template_name}
139
+ </div>
140
  </div>
141
  </div>
142
  );
frontend/src/components/Map2D.tsx CHANGED
@@ -6,523 +6,467 @@ interface Map2DProps {
6
  agentMoveFlash: number;
7
  }
8
 
9
- const CELL = 36;
10
- const FLOOR = 0;
11
- const WALL = 1;
12
- const DOOR_OPEN = 2;
13
  const DOOR_CLOSED = 3;
14
- const EXIT = 4;
15
- const OBSTACLE = 5;
16
- const DOOR_FAILED = 6;
17
-
18
- const ITEMS: Record<string, string> = {
 
 
 
 
19
 
 
 
 
 
 
 
20
  };
21
 
22
- const WALL_ITEMS: Record<string, string> = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- };
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => {
27
  const canvasRef = useRef<HTMLCanvasElement>(null);
 
 
 
 
28
 
29
- useEffect(() => {
30
- if (!observation) return;
 
31
  const canvas = canvasRef.current;
32
- if (!canvas) return;
33
  const ctx = canvas.getContext('2d');
34
  if (!ctx) return;
35
 
36
- const { map_state } = observation;
37
- const { grid_w: W, grid_h: H } = map_state;
38
 
39
- // Helper functions
 
 
 
40
  const idx = (x: number, y: number) => y * W + x;
41
- const isOuter = (x: number, y: number) => x === 0 || x === W - 1 || y === 0 || y === H - 1;
42
- const getZone = (x: number, y: number) => {
43
- const ct = map_state.cell_grid[idx(x, y)];
44
- if (ct !== FLOOR && ct !== EXIT) return '';
45
- if (y >= 5 && y <= 9) return 'corridor';
46
- return y <= 4 ? 'north' : 'south';
47
- };
48
-
49
- const getHeat = (x: number, y: number) => {
50
- let h = 0;
51
- const radius = 5;
52
- for (let dy = -radius; dy <= radius; dy++) {
53
- for (let dx = -radius; dx <= radius; dx++) {
54
- const nx = x + dx, ny = y + dy;
55
- if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
56
- const fi = map_state.fire_grid[idx(nx, ny)];
57
- if (fi <= 0) continue;
58
- const d = Math.sqrt(dx * dx + dy * dy);
59
- h = Math.max(h, fi * Math.max(0, 1 - d / (radius + 1)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
  }
62
- return Math.min(1, h * 1.15);
63
- };
64
-
65
- // Drawing functions
66
- const drawFloorCorridor = (px: number, py: number) => {
67
- const s = CELL, hs = s / 2;
68
- ctx.fillStyle = '#C8A260'; ctx.fillRect(px, py, s, s);
69
- ctx.fillStyle = '#D8B270'; ctx.fillRect(px, py, hs, hs);
70
- ctx.fillStyle = '#D8B270'; ctx.fillRect(px + hs, py + hs, hs, hs);
71
- ctx.fillStyle = '#B89050'; ctx.fillRect(px + hs, py, hs, hs);
72
- ctx.fillStyle = '#B89050'; ctx.fillRect(px, py + hs, hs, hs);
73
- ctx.strokeStyle = '#A07838'; ctx.lineWidth = 0.8;
74
- ctx.beginPath(); ctx.moveTo(px + hs, py); ctx.lineTo(px + hs, py + s); ctx.stroke();
75
- ctx.beginPath(); ctx.moveTo(px, py + hs); ctx.lineTo(px + s, py + hs); ctx.stroke();
76
- ctx.strokeStyle = '#886020'; ctx.lineWidth = 0.5;
77
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
78
- };
79
-
80
- const drawFloorRoom = (px: number, py: number) => {
81
- const s = CELL;
82
- ctx.fillStyle = '#E2D8C4'; ctx.fillRect(px, py, s, s);
83
- ctx.fillStyle = '#ECE4D0'; ctx.fillRect(px + 2, py + 2, s - 4, s - 4);
84
- ctx.strokeStyle = '#CCC2AE'; ctx.lineWidth = 0.7;
85
- ctx.beginPath(); ctx.moveTo(px + s / 2, py + 2); ctx.lineTo(px + s / 2, py + s - 2); ctx.stroke();
86
- ctx.beginPath(); ctx.moveTo(px + 2, py + s / 2); ctx.lineTo(px + s - 2, py + s / 2); ctx.stroke();
87
- ctx.strokeStyle = '#BCB2A0'; ctx.lineWidth = 0.5;
88
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
89
- };
90
-
91
- const drawWallOuter = (px: number, py: number) => {
92
- const s = CELL;
93
- ctx.fillStyle = '#5C6470'; ctx.fillRect(px, py, s, s);
94
- ctx.fillStyle = '#80889A'; ctx.fillRect(px, py, s, 6);
95
- ctx.fillStyle = '#70788A'; ctx.fillRect(px, py + 6, s, 3);
96
- ctx.fillStyle = '#404850'; ctx.fillRect(px, py + s - 3, s, 3);
97
- ctx.strokeStyle = '#4A5260'; ctx.lineWidth = 0.7;
98
- [13, 23, 30].forEach(ly => { ctx.beginPath(); ctx.moveTo(px, py + ly); ctx.lineTo(px + s, py + ly); ctx.stroke(); });
99
- ctx.lineWidth = 0.4;
100
- [[0, 14, 10], [14, 10, 18], [24, 9, 9]].forEach(([ry, h, off]) => {
101
- for (let vx = off; vx < s; vx += 18) { if (vx > 0 && vx < s) { ctx.beginPath(); ctx.moveTo(px + vx, py + ry + 8); ctx.lineTo(px + vx, py + ry + 8 + h); ctx.stroke(); } }
102
- });
103
- ctx.strokeStyle = '#282E38'; ctx.lineWidth = 1;
104
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
105
- };
106
-
107
- const drawWallInner = (px: number, py: number) => {
108
- const s = CELL;
109
- ctx.fillStyle = '#726860'; ctx.fillRect(px, py, s, s);
110
- ctx.fillStyle = '#928880'; ctx.fillRect(px, py, s, 5);
111
- ctx.fillStyle = '#827870'; ctx.fillRect(px, py + 5, s, 2);
112
- ctx.fillStyle = '#524840'; ctx.fillRect(px, py + s - 2, s, 2);
113
- ctx.strokeStyle = '#605850'; ctx.lineWidth = 0.6;
114
- for (let i = 1; i <= 4; i++) { const ly = py + Math.round(i * s / 5); ctx.beginPath(); ctx.moveTo(px + 2, ly); ctx.lineTo(px + s - 2, ly); ctx.stroke(); }
115
- ctx.strokeStyle = '#403830'; ctx.lineWidth = 1;
116
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
117
- };
118
-
119
- const drawWallWithItem = (px: number, py: number, item: string) => {
120
- const x = px / CELL | 0, y = py / CELL | 0;
121
- if (isOuter(x, y)) drawWallOuter(px, py); else drawWallInner(px, py);
122
- const s = CELL, cx = px + s / 2;
123
- if (item === 'alarm_wall') {
124
- ctx.fillStyle = '#AA0000'; ctx.fillRect(px + 8, py + 10, 20, 16);
125
- ctx.fillStyle = '#CC1010'; ctx.fillRect(px + 8, py + 10, 20, 3);
126
- ctx.fillStyle = '#FFD040';
127
- ctx.beginPath(); ctx.arc(cx, py + 19, 4, 0, Math.PI * 2); ctx.fill();
128
- ctx.fillStyle = '#AA0000';
129
- ctx.beginPath(); ctx.arc(cx, py + 19, 2.5, 0, Math.PI * 2); ctx.fill();
130
- ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
131
- ctx.fillStyle = '#FF4040'; ctx.fillText('FIRE', cx, py + 30);
132
- ctx.fillStyle = 'rgba(255,0,0,0.10)'; ctx.fillRect(px, py, s, s);
133
- }
134
- if (item === 'exit_sign') {
135
- ctx.fillStyle = '#00A030'; ctx.fillRect(px + 5, py + 12, 26, 12);
136
- ctx.fillStyle = '#00C840'; ctx.fillRect(px + 5, py + 12, 26, 3);
137
- ctx.fillStyle = '#FFFFFF';
138
- ctx.font = 'bold 5px monospace'; ctx.textAlign = 'center';
139
- ctx.fillText('EXIT', cx, py + 22);
140
- ctx.fillStyle = '#80FF80'; ctx.beginPath();
141
- ctx.moveTo(cx - 2, py + 9); ctx.lineTo(cx + 4, py + 12); ctx.lineTo(cx - 2, py + 12); ctx.closePath(); ctx.fill();
142
- }
143
- };
144
-
145
- const drawDoorOpen = (px: number, py: number) => {
146
- const s = CELL, fw = 5;
147
- drawFloorCorridor(px, py);
148
- ctx.fillStyle = '#5C2408'; ctx.fillRect(px, py, fw, s); ctx.fillRect(px + s - fw, py, fw, s); ctx.fillRect(px, py, s, fw);
149
- ctx.fillStyle = '#8A3A18'; ctx.fillRect(px + 1, py + fw, fw - 2, s - fw - 1); ctx.fillRect(px + s - fw + 1, py + fw, fw - 2, s - fw - 1);
150
- ctx.fillStyle = 'rgba(200,230,255,0.05)'; ctx.fillRect(px + fw, py + fw, s - fw * 2, s - fw);
151
- ctx.fillStyle = '#28A040'; ctx.fillRect(px + fw, py + fw, s - fw * 2, 3);
152
- ctx.fillStyle = '#40C060'; ctx.fillRect(px + fw, py + fw, s - fw * 2, 1);
153
- };
154
-
155
- const drawDoorClosed = (px: number, py: number) => {
156
- const s = CELL;
157
- ctx.fillStyle = '#240E05'; ctx.fillRect(px, py, s, s);
158
- ctx.fillStyle = '#7A3E16'; ctx.fillRect(px + 3, py + 2, s - 6, s - 4);
159
- ctx.fillStyle = '#9A5E34'; ctx.fillRect(px + 3, py + 2, s - 6, 3); ctx.fillRect(px + 3, py + 2, 3, s - 4);
160
- ctx.fillStyle = '#5A2608'; ctx.fillRect(px + 3, py + s - 4, s - 6, 2); ctx.fillRect(px + s - 6, py + 2, 3, s - 4);
161
- ctx.fillStyle = '#6A2E10'; ctx.fillRect(px + 8, py + 7, s - 16, s - 16);
162
- ctx.fillStyle = '#8A4E2E'; ctx.fillRect(px + 8, py + 7, s - 16, 2); ctx.fillRect(px + 8, py + 7, 2, s - 16);
163
- ctx.fillStyle = '#4A1E08'; ctx.fillRect(px + 8, py + s - 11, s - 16, 2); ctx.fillRect(px + s - 10, py + 7, 2, s - 16);
164
- ctx.fillStyle = '#C89020'; ctx.beginPath(); ctx.arc(px + s - 10, py + s / 2, 3, 0, Math.PI * 2); ctx.fill();
165
- ctx.fillStyle = '#F0C040'; ctx.beginPath(); ctx.arc(px + s - 11, py + s / 2 - 1, 1.5, 0, Math.PI * 2); ctx.fill();
166
- ctx.strokeStyle = '#140800'; ctx.lineWidth = 1; ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
167
- };
168
-
169
- const drawDoorFailed = (px: number, py: number) => {
170
- const s = CELL;
171
- ctx.fillStyle = '#180C04'; ctx.fillRect(px, py, s, s);
172
- ctx.fillStyle = '#D0A858'; ctx.fillRect(px + 5, py + 5, s - 10, s - 10);
173
- ctx.fillStyle = '#B08840'; ctx.fillRect(px + 5, py + 5, s - 10, s - 10);
174
- ctx.fillStyle = '#281408'; ctx.fillRect(px + 7, py + 7, 8, 6); ctx.fillRect(px + 18, py + 18, 8, 5);
175
- ctx.fillStyle = '#380E04'; ctx.fillRect(px + 12, py + 12, 8, 8);
176
- ctx.fillStyle = '#300A02';
177
- ctx.fillRect(px, py, 4, s); ctx.fillRect(px + s - 4, py, 4, s); ctx.fillRect(px, py, s, 4);
178
- ctx.fillStyle = '#A03010'; ctx.fillRect(px + 2, py + 8, 2, 2); ctx.fillRect(px + s - 4, py + 14, 2, 2);
179
- ctx.fillStyle = '#D05020'; ctx.fillRect(px + 2, py + 9, 1, 1); ctx.fillRect(px + s - 4, py + 15, 1, 1);
180
- ctx.strokeStyle = 'rgba(200,30,10,0.7)'; ctx.lineWidth = 1.5;
181
- ctx.beginPath(); ctx.moveTo(px + 6, py + 6); ctx.lineTo(px + s - 6, py + s - 6); ctx.stroke();
182
- ctx.beginPath(); ctx.moveTo(px + s - 6, py + 6); ctx.lineTo(px + 6, py + s - 6); ctx.stroke();
183
- ctx.strokeStyle = '#100600'; ctx.lineWidth = 1;
184
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
185
- };
186
-
187
- const drawExit = (px: number, py: number) => {
188
- const s = CELL;
189
- ctx.fillStyle = '#0C7224'; ctx.fillRect(px, py, s, s);
190
- ctx.fillStyle = '#1A9038'; ctx.fillRect(px + 2, py + 2, s - 4, s - 4);
191
- ctx.fillStyle = '#30A850'; ctx.fillRect(px + 4, py + 4, s - 8, s - 8);
192
- ctx.fillStyle = '#FFFFFF';
193
- const cx = px + s / 2, ay = py + 6;
194
- ctx.beginPath(); ctx.moveTo(cx, ay); ctx.lineTo(cx + 7, ay + 8); ctx.lineTo(cx + 4, ay + 8);
195
- ctx.lineTo(cx + 4, ay + 19); ctx.lineTo(cx - 4, ay + 19); ctx.lineTo(cx - 4, ay + 8); ctx.lineTo(cx - 7, ay + 8);
196
- ctx.closePath(); ctx.fill();
197
- ctx.font = 'bold 5px monospace'; ctx.textAlign = 'center';
198
- ctx.fillStyle = '#FFFFFF'; ctx.fillText('EXIT', cx, py + s - 4);
199
- ctx.strokeStyle = '#085018'; ctx.lineWidth = 2; ctx.strokeRect(px + 1, py + 1, s - 2, s - 2);
200
- ctx.strokeStyle = '#48C860'; ctx.lineWidth = 0.5; ctx.strokeRect(px + 3.5, py + 3.5, s - 7, s - 7);
201
- };
202
-
203
- const drawDesk = (px: number, py: number) => {
204
- const s = CELL;
205
- ctx.fillStyle = '#7B5A30'; ctx.fillRect(px + 2, py + 14, s - 4, s - 20);
206
- ctx.fillStyle = '#9B7A50'; ctx.fillRect(px + 2, py + 14, s - 4, 3); ctx.fillRect(px + 2, py + 14, 3, s - 20);
207
- ctx.fillStyle = '#5A3A18'; ctx.fillRect(px + 3, py + s - 8, 3, 6); ctx.fillRect(px + s - 6, py + s - 8, 3, 6);
208
- ctx.fillStyle = '#F0E8D0'; ctx.fillRect(px + 6, py + 17, 9, 4);
209
- ctx.fillStyle = '#C8C0B0'; ctx.fillRect(px + 17, py + 16, 7, 3);
210
- };
211
-
212
- const drawMonitor = (px: number, py: number) => {
213
- const s = CELL;
214
- ctx.fillStyle = '#1A2030'; ctx.fillRect(px + 6, py + 4, s - 12, 15);
215
- ctx.fillStyle = '#283050'; ctx.fillRect(px + 7, py + 5, s - 14, 13);
216
- ctx.fillStyle = '#102848'; ctx.fillRect(px + 8, py + 6, s - 16, 11);
217
- ctx.fillStyle = '#204880'; ctx.fillRect(px + 9, py + 7, s - 18, 4);
218
- ctx.fillStyle = '#38A0D8'; ctx.fillRect(px + 9, py + 7, s - 18, 1);
219
- ctx.fillStyle = '#286080'; ctx.fillRect(px + 9, py + 10, s - 18, 1);
220
- ctx.fillStyle = '#204060'; ctx.fillRect(px + 9, py + 12, 8, 1);
221
- ctx.fillStyle = '#00E060'; ctx.fillRect(px + s / 2 + 5, py + 19, 2, 2);
222
- ctx.fillStyle = '#202028'; ctx.fillRect(px + s / 2 - 2, py + 20, 5, 4); ctx.fillRect(px + s / 2 - 4, py + 23, 9, 2);
223
- };
224
-
225
- const drawChair = (px: number, py: number) => {
226
- const s = CELL;
227
- ctx.fillStyle = '#2A3050'; ctx.fillRect(px + 7, py + 16, s - 14, 10);
228
- ctx.fillStyle = '#3A4068'; ctx.fillRect(px + 7, py + 16, s - 14, 3);
229
- ctx.fillStyle = '#2A3050'; ctx.fillRect(px + 9, py + 8, s - 18, 9);
230
- ctx.fillStyle = '#3A4068'; ctx.fillRect(px + 9, py + 8, s - 18, 2);
231
- ctx.fillStyle = '#303844'; ctx.fillRect(px + 5, py + 16, 4, 7); ctx.fillRect(px + s - 9, py + 16, 4, 7);
232
- ctx.strokeStyle = '#484E58'; ctx.lineWidth = 1.2;
233
- const base = [0, -8, 6, 5, -6, 5, -9, -2, 9, -2];
234
- for (let i = 0; i < 5; i++) {
235
- ctx.beginPath(); ctx.moveTo(px + s / 2, py + s - 4); ctx.lineTo(px + s / 2 + base[i * 2], py + s - 4 + base[i * 2 + 1]); ctx.stroke();
236
- }
237
- };
238
-
239
- const drawPlant = (px: number, py: number) => {
240
- const s = CELL, cx = px + s / 2;
241
- ctx.fillStyle = '#C06030'; ctx.fillRect(cx - 6, py + 24, 12, 8);
242
- ctx.fillStyle = '#E08050'; ctx.fillRect(cx - 6, py + 24, 12, 2);
243
- ctx.fillStyle = '#4A2A10'; ctx.fillRect(cx - 5, py + 26, 10, 4);
244
- ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx, py + 15, 7, 0, Math.PI * 2); ctx.fill();
245
- ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx - 6, py + 19, 5, 0, Math.PI * 2); ctx.fill();
246
- ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx + 6, py + 19, 5, 0, Math.PI * 2); ctx.fill();
247
- ctx.fillStyle = '#30C050'; ctx.beginPath(); ctx.arc(cx - 1, py + 13, 4, 0, Math.PI * 2); ctx.fill();
248
- ctx.fillStyle = '#48D860'; ctx.beginPath(); ctx.arc(cx - 2, py + 12, 2, 0, Math.PI * 2); ctx.fill();
249
- };
250
-
251
- const drawFiling = (px: number, py: number) => {
252
- const s = CELL;
253
- ctx.fillStyle = '#788090'; ctx.fillRect(px + 6, py + 3, s - 12, s - 8);
254
- ctx.fillStyle = '#90A0B0'; ctx.fillRect(px + 6, py + 3, s - 12, 2);
255
- ctx.fillStyle = '#586070'; ctx.fillRect(px + 6, py + s - 8, s - 12, 3);
256
- ctx.strokeStyle = '#485060'; ctx.lineWidth = 0.8;
257
- [11, 19, 27].forEach(y => { ctx.beginPath(); ctx.moveTo(px + 6, py + y); ctx.lineTo(px + s - 6, py + y); ctx.stroke(); });
258
- ctx.fillStyle = '#B8C8D8';
259
- [8, 16, 24].forEach(dy => { ctx.fillRect(px + s / 2 - 4, py + dy + 1, 8, 2); });
260
- };
261
-
262
- const drawExtinguisher = (px: number, py: number) => {
263
- const s = CELL, cx = px + s / 2;
264
- ctx.fillStyle = '#BE0808'; ctx.fillRect(cx - 5, py + 8, 10, 20);
265
- ctx.fillStyle = '#E01010'; ctx.fillRect(cx - 5, py + 8, 10, 3);
266
- ctx.fillStyle = '#DD2828'; ctx.fillRect(cx - 4, py + 11, 3, 14);
267
- ctx.fillStyle = '#282828'; ctx.fillRect(cx - 1, py + 4, 3, 5);
268
- ctx.fillRect(cx + 1, py + 4, 5, 2);
269
- ctx.fillStyle = '#FFFFFF'; ctx.fillRect(cx - 5, py + 16, 10, 3);
270
- ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
271
- ctx.fillText('EXT', cx, py + 23);
272
- ctx.fillStyle = '#700000'; ctx.fillRect(cx - 5, py + 26, 10, 2);
273
- ctx.fillStyle = 'rgba(220,30,10,0.12)'; ctx.fillRect(px, py, s, s);
274
- };
275
-
276
- const drawHazardMarker = (px: number, py: number) => {
277
- const s = CELL, cx = px + s / 2, cy = py + s / 2;
278
- ctx.fillStyle = 'rgba(255,190,0,0.18)'; ctx.fillRect(px, py, s, s);
279
- ctx.fillStyle = '#E8C000';
280
- ctx.beginPath(); ctx.moveTo(cx, py + 4); ctx.lineTo(px + s - 4, cy); ctx.lineTo(cx, py + s - 4); ctx.lineTo(px + 4, cy); ctx.closePath(); ctx.fill();
281
- ctx.fillStyle = '#8A7200';
282
- ctx.beginPath(); ctx.moveTo(cx, py + 9); ctx.lineTo(px + s - 9, cy); ctx.lineTo(cx, py + s - 9); ctx.lineTo(px + 9, cy); ctx.closePath(); ctx.fill();
283
- ctx.fillStyle = '#FFE030'; ctx.font = 'bold 11px monospace';
284
- ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
285
- ctx.fillText('!', cx, cy + 1); (ctx as any).textBaseline = 'alphabetic';
286
- ctx.strokeStyle = '#A08000'; ctx.lineWidth = 1;
287
- ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
288
- };
289
-
290
- const drawSprinkler = (px: number, py: number) => {
291
- const s = CELL, cx = px + s / 2;
292
- ctx.fillStyle = 'rgba(50,100,200,0.14)'; ctx.fillRect(px, py, s, s);
293
- ctx.fillStyle = '#909098'; ctx.fillRect(cx - 2, py + 1, 4, 4);
294
- ctx.fillStyle = '#A8A8B0'; ctx.fillRect(cx - 5, py + 4, 10, 2);
295
- ctx.fillStyle = '#58A0E8';
296
- [[s * 0.25, s * 0.32], [s * 0.5, s * 0.38], [s * 0.75, s * 0.32],
297
- [s * 0.18, s * 0.52], [s * 0.5, s * 0.58], [s * 0.82, s * 0.52],
298
- [s * 0.3, s * 0.72], [s * 0.7, s * 0.72]].forEach(([wx, wy]) => {
299
- ctx.beginPath(); ctx.ellipse(px + wx, py + wy, 1.5, 2.5, 0, 0, Math.PI * 2); ctx.fill();
300
- });
301
- };
302
-
303
- const drawAlarmTriggered = (px: number, py: number) => {
304
- const s = CELL, cx = px + s / 2;
305
- ctx.fillStyle = 'rgba(200,0,0,0.18)'; ctx.fillRect(px, py, s, s);
306
- ctx.fillStyle = '#B80000'; ctx.fillRect(px + 5, py + 6, s - 10, s - 12);
307
- ctx.fillStyle = '#D81010'; ctx.fillRect(px + 5, py + 6, s - 10, 3);
308
- ctx.fillStyle = '#880000'; ctx.fillRect(px + 5, py + s - 9, s - 10, 3);
309
- ctx.fillStyle = '#FFD030'; ctx.beginPath(); ctx.arc(cx, py + s / 2, 6, 0, Math.PI * 2); ctx.fill();
310
- ctx.fillStyle = '#CC0000'; ctx.beginPath(); ctx.arc(cx, py + s / 2, 4, 0, Math.PI * 2); ctx.fill();
311
- ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
312
- ctx.fillStyle = '#FF6060'; ctx.fillText('FIRE', cx, py + s - 4);
313
- };
314
-
315
- const drawSensorNode = (px: number, py: number) => {
316
- const s = CELL, cx = px + s / 2;
317
- ctx.fillStyle = '#C0C8D0'; ctx.beginPath(); ctx.arc(cx, py + 9, 6, 0, Math.PI * 2); ctx.fill();
318
- ctx.fillStyle = '#D8E0E8'; ctx.beginPath(); ctx.arc(cx, py + 9, 5, 0, Math.PI * 2); ctx.fill();
319
- ctx.fillStyle = '#00FF70'; ctx.beginPath(); ctx.arc(cx, py + 9, 2, 0, Math.PI * 2); ctx.fill();
320
- ctx.strokeStyle = 'rgba(0,200,80,0.35)'; ctx.lineWidth = 0.8;
321
- [9, 13, 17].forEach(r => { ctx.beginPath(); ctx.arc(cx, py + 9, r, Math.PI * 1.2, Math.PI * 1.8); ctx.stroke(); });
322
- ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
323
- ctx.fillStyle = '#80A8C8'; ctx.fillText('SENS', cx, py + 22);
324
- ctx.fillStyle = 'rgba(0,180,100,0.12)'; ctx.fillRect(px, py, s, s);
325
- };
326
-
327
- const drawItem = (item: string, px: number, py: number) => {
328
- ctx.save();
329
- switch (item) {
330
- case 'desk': drawDesk(px, py); break;
331
- case 'monitor': drawMonitor(px, py); break;
332
- case 'chair': drawChair(px, py); break;
333
- case 'plant': drawPlant(px, py); break;
334
- case 'filing': drawFiling(px, py); break;
335
- case 'extinguisher': drawExtinguisher(px, py); break;
336
- case 'hazard': drawHazardMarker(px, py); break;
337
- case 'sprinkler': drawSprinkler(px, py); break;
338
- case 'alarm': drawAlarmTriggered(px, py); break;
339
- case 'sensor': drawSensorNode(px, py); break;
340
- }
341
- ctx.restore();
342
- };
343
-
344
- const drawTemperature = (px: number, py: number, x: number, y: number) => {
345
- const heat = getHeat(x, y);
346
- if (heat < 0.08) return;
347
- const alpha = Math.min(0.52, heat * 0.58);
348
- if (heat > 0.7) {
349
- ctx.fillStyle = `rgba(255,50,0,${alpha})`; ctx.fillRect(px, py, CELL, CELL);
350
- } else if (heat > 0.45) {
351
- ctx.fillStyle = `rgba(255,120,20,${alpha})`; ctx.fillRect(px, py, CELL, CELL);
352
- } else {
353
- ctx.fillStyle = `rgba(255,190,40,${alpha * 0.7})`; ctx.fillRect(px, py, CELL, CELL);
354
- }
355
- };
356
-
357
- const drawFog = (px: number, py: number, x: number, y: number) => {
358
- const explored = new Set(map_state.visible_cells.map(([cx, cy]) => `${cx},${cy}`));
359
- if (explored.has(`${x},${y}`)) return;
360
- ctx.fillStyle = 'rgba(5,5,12,0.72)'; ctx.fillRect(px, py, CELL, CELL);
361
- ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
362
- ctx.fillStyle = 'rgba(80,80,120,0.5)';
363
- ctx.fillText('?', px + CELL / 2, py + CELL / 2); (ctx as any).textBaseline = 'alphabetic';
364
- };
365
-
366
- const drawFire = (px: number, py: number, intensity: number) => {
367
- if (intensity < 0.03) return;
368
- const s = CELL;
369
- // High base alpha so even smoldering (0.1) is visible
370
- const alpha = Math.min(1.0, 0.5 + intensity * 0.5);
371
- const g = ctx.createRadialGradient(px + s / 2, py + s / 2, 0, px + s / 2, py + s / 2, s * 0.72);
372
- g.addColorStop(0, `rgba(255, 255, 180, ${alpha})`);
373
- g.addColorStop(0.3, `rgba(255, 180, 0, ${alpha * 0.9})`);
374
- g.addColorStop(0.7, `rgba(255, 40, 0, ${alpha * 0.7})`);
375
- g.addColorStop(1, 'rgba(150, 0, 0, 0)');
376
- ctx.fillStyle = g; ctx.fillRect(px, py, s, s);
377
-
378
- if (intensity > 0.3) {
379
- ctx.fillStyle = `rgba(255, 255, 255, ${intensity * 0.6})`;
380
- [[s / 2 - 2, 4, s / 2 + 2, 4, s / 2, -2], [s * .28, 7, s * .48, 7, s * .38, 1], [s * .58, 6, s * .78, 6, s * .68, 0]].forEach(([x1, y1, x2, y2, x3, y3]) => {
381
- ctx.beginPath(); ctx.moveTo(px + x1, py + y1); ctx.lineTo(px + x2, py + y2); ctx.lineTo(px + x3, py + y3); ctx.closePath(); ctx.fill();
382
- });
383
- }
384
- if (intensity > 0.5) {
385
- ctx.font = `bold ${s * .65}px serif`; ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
386
- ctx.globalAlpha = Math.min(1, intensity * 1.5);
387
- ctx.fillText('🔥', px + s / 2, py + s / 2 + 2); ctx.globalAlpha = 1; (ctx as any).textBaseline = 'alphabetic';
388
- }
389
- };
390
-
391
- const drawSmoke = (px: number, py: number, density: number) => {
392
- if (density < 0.03) return;
393
- const s = CELL;
394
- const alpha = Math.min(0.8, 0.2 + density * 0.6);
395
- ctx.fillStyle = `rgba(80, 80, 100, ${alpha})`; ctx.fillRect(px, py, s, s);
396
- const g = ctx.createRadialGradient(px + s * .38, py + s * .28, 0, px + s / 2, py + s / 2, s * 0.62);
397
- g.addColorStop(0, `rgba(120, 120, 140, ${alpha * 0.3})`); g.addColorStop(1, 'rgba(50, 50, 70, 0)');
398
- ctx.fillStyle = g; ctx.fillRect(px, py, s, s);
399
- };
400
-
401
- const drawAgent = () => {
402
- const { agent_x: ax, agent_y: ay } = map_state;
403
- const px = ax * CELL, py = ay * CELL, s = CELL;
404
- const cx = px + s / 2, cy = py + s / 2;
405
-
406
- if (agentMoveFlash > 0) {
407
- const alpha = agentMoveFlash / 18;
408
- const radius = s * (0.6 + (1 - alpha) * 0.6);
409
- ctx.save();
410
- ctx.strokeStyle = `rgba(255, 230, 60, ${alpha})`;
411
- ctx.lineWidth = 3 + alpha * 4;
412
- ctx.shadowColor = `rgba(255, 200, 0, ${alpha})`;
413
- ctx.shadowBlur = 14;
414
- ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke();
415
- ctx.restore();
416
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
- const gl = ctx.createRadialGradient(cx, cy, 0, cx, cy, s * .65);
419
- gl.addColorStop(0, 'rgba(70,135,255,0.42)'); gl.addColorStop(1, 'rgba(30,70,200,0)');
420
- ctx.fillStyle = gl; ctx.fillRect(px, py, s, s);
421
- ctx.fillStyle = 'rgba(0,0,0,0.34)';
422
- ctx.beginPath(); ctx.ellipse(cx, cy + 11, 7, 2.5, 0, 0, Math.PI * 2); ctx.fill();
423
- ctx.fillStyle = '#180C04'; ctx.fillRect(cx - 6, cy + 8, 5, 4); ctx.fillRect(cx + 1, cy + 8, 5, 4);
424
- ctx.fillStyle = '#1C3080'; ctx.fillRect(cx - 6, cy + 1, 5, 8); ctx.fillRect(cx + 1, cy + 1, 5, 8);
425
- ctx.fillStyle = '#2840A8'; ctx.fillRect(cx - 6, cy + 1, 2, 8); ctx.fillRect(cx + 1, cy + 1, 2, 8);
426
- ctx.fillStyle = '#601808'; ctx.fillRect(cx - 7, cy - 1, 14, 2);
427
- ctx.fillStyle = '#E08020'; ctx.beginPath(); ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.fill();
428
- ctx.fillStyle = '#1A60D8'; ctx.fillRect(cx - 7, cy - 9, 14, 11);
429
- ctx.fillStyle = '#3080F8'; ctx.fillRect(cx - 7, cy - 9, 14, 3); ctx.fillRect(cx - 7, cy - 9, 3, 11);
430
- ctx.fillStyle = '#1A60D8'; ctx.fillRect(cx - 10, cy - 8, 4, 9); ctx.fillRect(cx + 6, cy - 8, 4, 9);
431
- ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 10, cy + 0, 4, 3); ctx.fillRect(cx + 6, cy + 0, 4, 3);
432
- ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 2, cy - 11, 4, 3);
433
- ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 5, cy - 19, 10, 9);
434
- ctx.fillStyle = '#F8D890'; ctx.fillRect(cx - 5, cy - 19, 10, 3); ctx.fillRect(cx - 5, cy - 19, 3, 9);
435
- ctx.fillStyle = '#4A2810'; ctx.fillRect(cx - 5, cy - 19, 10, 4);
436
- ctx.fillRect(cx - 6, cy - 17, 3, 3); ctx.fillRect(cx + 3, cy - 17, 3, 3);
437
- ctx.fillStyle = '#180C00'; ctx.fillRect(cx - 3, cy - 13, 2, 2); ctx.fillRect(cx + 1, cy - 13, 2, 2);
438
- ctx.fillStyle = '#FFFFFF'; ctx.fillRect(cx - 3, cy - 14, 1, 1); ctx.fillRect(cx + 2, cy - 14, 1, 1);
439
- ctx.font = 'bold 6px monospace'; ctx.textAlign = 'center';
440
- ctx.fillStyle = '#FFFFFF'; ctx.fillText('A', cx, py + 2);
441
- };
442
-
443
- const drawZoneLabels = () => {
444
- ctx.save();
445
- ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center';
446
- ctx.fillStyle = 'rgba(195,180,150,0.13)';
447
- ctx.fillText('NORTH OFFICES', CELL * 8, CELL * 2.6);
448
- ctx.fillText('MAIN CORRIDOR', CELL * 8, CELL * 7.4);
449
- ctx.fillText('SOUTH OFFICES', CELL * 8, CELL * 12.9);
450
- ctx.restore();
451
- };
452
-
453
- // Main Render Loop
454
- const render = () => {
455
- ctx.clearRect(0, 0, canvas.width, canvas.height);
456
-
457
- for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
458
- const ct = map_state.cell_grid[idx(x, y)], px = x * CELL, py = y * CELL;
459
- if (ct === WALL && WALL_ITEMS[`${x},${y}`]) { drawWallWithItem(px, py, WALL_ITEMS[`${x},${y}`]); continue; }
460
- switch (ct) {
461
- case FLOOR: getZone(x, y) === 'corridor' ? drawFloorCorridor(px, py) : drawFloorRoom(px, py); break;
462
- case WALL: isOuter(x, y) ? drawWallOuter(px, py) : drawWallInner(px, py); break;
463
- case DOOR_OPEN: drawDoorOpen(px, py); break;
464
- case DOOR_CLOSED: drawDoorClosed(px, py); break;
465
- case OBSTACLE: drawDoorFailed(px, py); break;
466
- case EXIT: drawExit(px, py); break;
467
- case DOOR_FAILED: drawDoorFailed(px, py); break;
468
  }
469
- }
470
 
471
- drawZoneLabels();
 
 
 
 
 
 
 
 
 
 
472
 
473
- for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
474
- const item = ITEMS[`${x},${y}`];
475
- if (item) drawItem(item, x * CELL, y * CELL);
476
- }
 
 
 
 
 
 
477
 
478
- for (let y = 0; y < H; y++) for (let x = 0; x < W; x++)
479
- drawFog(x * CELL, y * CELL, x, y);
480
-
481
- // Draw Hazards (Fire/Smoke) AFTER Fog so they pop as "Thermal" or "Known" data
482
- for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
483
- const px = x * CELL, py = y * CELL;
484
- const fire = map_state.fire_grid[idx(x, y)];
485
- const smoke = map_state.smoke_grid[idx(x, y)];
486
- const visible = map_state.visible_cells.some(([vx, vy]) => vx === x && vy === y);
487
-
488
- // Smoke only visible if explored/in-sight (it's hard to see in the dark)
489
- if (visible) {
490
- drawSmoke(px, py, smoke);
491
  }
492
 
493
- // Fire is drawn ALWAYS (thermal detection)
494
- if (fire > 0.03) {
495
- drawFire(px, py, fire);
496
- // Stronger heat glow for distant fires
497
- if (!visible) {
498
- ctx.fillStyle = `rgba(255, 100, 0, ${Math.min(0.2, fire * 0.5)})`;
499
- ctx.fillRect(px, py, CELL, CELL);
500
- }
501
  }
502
-
503
- // Temperature/Heat signature
504
- drawTemperature(px, py, x, y);
505
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
- drawAgent();
508
-
509
- const vig = ctx.createRadialGradient(canvas.width / 2, canvas.height / 2, canvas.width * .28, canvas.width / 2, canvas.height / 2, canvas.width * .84);
510
- vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(1, 'rgba(0,0,0,0.45)');
511
- ctx.fillStyle = vig; ctx.fillRect(0, 0, canvas.width, canvas.height);
512
- };
513
-
514
- render();
515
  }, [observation, agentMoveFlash]);
516
 
517
- const W = observation?.map_state.grid_w || 16;
518
- const H = observation?.map_state.grid_h || 16;
519
 
520
  return (
521
- <canvas
522
- ref={canvasRef}
523
- id="map-canvas"
524
- width={W * CELL}
525
- height={H * CELL}
526
  />
527
  );
528
  };
 
6
  agentMoveFlash: number;
7
  }
8
 
9
+ /* ── Cell type constants ── */
10
+ const WALL = 1;
11
+ const DOOR_OPEN = 2;
 
12
  const DOOR_CLOSED = 3;
13
+ const EXIT = 4;
14
+ const OBSTACLE = 5;
15
+
16
+ /* ── Wind direction vectors ── */
17
+ const WIND_DIRS: Record<string, [number, number]> = {
18
+ N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0],
19
+ NW: [-0.7, -0.7], NE: [0.7, -0.7], SW: [-0.7, 0.7], SE: [0.7, 0.7],
20
+ CALM: [0, 0],
21
+ };
22
 
23
+ /* ── Agent appearance per health tier ── */
24
+ const AGENT_THEMES = {
25
+ healthy: { body: '#3b82f6', dark: '#1d4ed8', arm: '#2563eb', ring: '#fbbf24', ringGlow: 'rgba(251,191,36,0.5)' },
26
+ moderate: { body: '#f97316', dark: '#c2410c', arm: '#ea580c', ring: '#fb923c', ringGlow: 'rgba(251,146,60,0.5)' },
27
+ low: { body: '#dc2626', dark: '#991b1b', arm: '#b91c1c', ring: '#f87171', ringGlow: 'rgba(248,113,113,0.5)' },
28
+ critical: { body: '#7c3aed', dark: '#5b21b6', arm: '#6d28d9', ring: '#c4b5fd', ringGlow: 'rgba(196,181,253,0.5)' },
29
  };
30
 
31
+ /* ── Ember particle ── */
32
+ class Ember {
33
+ x: number; y: number; vx: number; vy: number;
34
+ life: number; decay: number; size: number;
35
+ type: 'ember' | 'spark';
36
+
37
+ constructor(x: number, y: number, windX: number) {
38
+ const speed = 0.4 + Math.random() * 1.0;
39
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.6;
40
+ this.x = x + (Math.random() - 0.5) * 3;
41
+ this.y = y + (Math.random() - 0.5) * 3;
42
+ this.vx = Math.cos(angle) * speed + windX * 0.7;
43
+ this.vy = Math.sin(angle) * speed - 0.22;
44
+ this.life = 1.0;
45
+ this.decay = 0.012 + Math.random() * 0.015;
46
+ this.size = 1.2 + Math.random() * 2.2;
47
+ this.type = Math.random() > 0.4 ? 'ember' : 'spark';
48
+ }
49
+
50
+ update() {
51
+ this.x += this.vx;
52
+ this.y += this.vy;
53
+ this.vy -= 0.012;
54
+ this.vx *= 0.97;
55
+ this.life -= this.decay;
56
+ }
57
+ }
58
 
 
59
 
60
+ /* ── Minecraft pixel-art character ── */
61
+ function drawMinecraftAgent(
62
+ ctx: CanvasRenderingContext2D,
63
+ cx: number, cy: number, cs: number,
64
+ theme: typeof AGENT_THEMES.healthy
65
+ ) {
66
+ const u = cs / 18;
67
+ const left = cx - 5 * u;
68
+ const top = cy - 8.5 * u;
69
+
70
+ const px = (rx: number, ry: number, rw: number, rh: number, color: string) => {
71
+ ctx.fillStyle = color;
72
+ ctx.fillRect(left + rx * u, top + ry * u, rw * u, rh * u);
73
+ };
74
+
75
+ /* Helmet */
76
+ px(2, 0, 6, 1, '#5c4a3d');
77
+ /* Head */
78
+ px(2, 1, 6, 5, '#f5d5a0');
79
+ /* Face features */
80
+ px(3, 3, 1, 1, '#3d2b1a'); /* left eye */
81
+ px(6, 3, 1, 1, '#3d2b1a'); /* right eye */
82
+ px(4, 5, 2, 1, '#c8937a'); /* mouth */
83
+ /* Hair accent */
84
+ px(2, 1, 6, 1, '#7a5c3e');
85
+
86
+ /* Body */
87
+ px(3, 6, 4, 4, theme.body);
88
+ px(3, 6, 4, 1, theme.dark);
89
+
90
+ /* Arms */
91
+ px(1, 6, 2, 4, theme.arm);
92
+ px(7, 6, 2, 4, theme.arm);
93
+
94
+ /* Legs */
95
+ px(3, 10, 2, 4, '#1e40af');
96
+ px(5, 10, 2, 4, '#1e3a8a');
97
+
98
+ /* Boots */
99
+ px(3, 14, 2, 2, '#3a2e26');
100
+ px(5, 14, 2, 2, '#2e2420');
101
+ }
102
+
103
+ /* ── Main canvas component ── */
104
  const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => {
105
  const canvasRef = useRef<HTMLCanvasElement>(null);
106
+ const embersRef = useRef<Ember[]>([]);
107
+ const trailRef = useRef<{ x: number; y: number; t: number }[]>([]);
108
+ const timeRef = useRef(0);
109
+ const rafRef = useRef(0);
110
 
111
+ const CS = 40;
112
+
113
+ const animate = () => {
114
  const canvas = canvasRef.current;
115
+ if (!canvas || !observation) { rafRef.current = requestAnimationFrame(animate); return; }
116
  const ctx = canvas.getContext('2d');
117
  if (!ctx) return;
118
 
119
+ timeRef.current += 0.016;
120
+ const t = timeRef.current;
121
 
122
+ const { map_state, agent_health, wind_dir } = observation;
123
+ const { grid_w: W, grid_h: H, cell_grid, fire_grid, smoke_grid, agent_x, agent_y } = map_state;
124
+ const cs = CS;
125
+ const wv = WIND_DIRS[wind_dir] ?? [0, 0];
126
  const idx = (x: number, y: number) => y * W + x;
127
+ const visible = new Set(map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`));
128
+
129
+ /* ── Canvas bg ── */
130
+ ctx.fillStyle = '#c8b890';
131
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
132
+
133
+ /* ── Base layer ── */
134
+ for (let y = 0; y < H; y++) {
135
+ for (let x = 0; x < W; x++) {
136
+ const ct = cell_grid[idx(x, y)];
137
+ const px = x * cs, py = y * cs;
138
+
139
+ switch (ct) {
140
+ case WALL: {
141
+ ctx.fillStyle = '#5e5850';
142
+ ctx.fillRect(px, py, cs, cs);
143
+ ctx.fillStyle = 'rgba(255,255,255,0.28)';
144
+ ctx.fillRect(px, py, cs, 2);
145
+ ctx.fillRect(px, py + 2, 2, cs - 2);
146
+ ctx.fillStyle = 'rgba(0,0,0,0.45)';
147
+ ctx.fillRect(px, py + cs - 2, cs, 2);
148
+ ctx.fillRect(px + cs - 2, py, 2, cs - 2);
149
+ /* center mortar cross */
150
+ ctx.fillStyle = 'rgba(0,0,0,0.12)';
151
+ ctx.fillRect(px + cs / 2 - 1, py, 2, cs);
152
+ ctx.fillRect(px, py + cs / 2 - 1, cs, 2);
153
+ break;
154
+ }
155
+ case OBSTACLE: {
156
+ ctx.fillStyle = '#3a3530';
157
+ ctx.fillRect(px, py, cs, cs);
158
+ ctx.fillStyle = 'rgba(255,80,0,0.55)';
159
+ ctx.fillRect(px, py, cs, 3);
160
+ ctx.fillRect(px, py, 3, cs);
161
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
162
+ ctx.fillRect(px, py + cs - 3, cs, 3);
163
+ ctx.fillRect(px + cs - 3, py, 3, cs);
164
+ break;
165
+ }
166
+ default: {
167
+ /* Minecraft checkerboard floor */
168
+ ctx.fillStyle = (x + y) % 2 === 0 ? '#e8d8b8' : '#d0be98';
169
+ ctx.fillRect(px, py, cs, cs);
170
+ /* tile bevel */
171
+ ctx.fillStyle = 'rgba(255,255,255,0.20)';
172
+ ctx.fillRect(px, py, cs, 2);
173
+ ctx.fillRect(px, py + 2, 2, cs - 2);
174
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
175
+ ctx.fillRect(px, py + cs - 2, cs, 2);
176
+ ctx.fillRect(px + cs - 2, py, 2, cs - 2);
177
+ }
178
  }
179
  }
180
+ }
181
+
182
+ /* ── Fire ambient: multiply scorches the floor tiles ── */
183
+ ctx.save();
184
+ ctx.globalCompositeOperation = 'multiply';
185
+ for (let y = 0; y < H; y++) {
186
+ for (let x = 0; x < W; x++) {
187
+ const fire = fire_grid[idx(x, y)];
188
+ if (fire < 0.1) continue;
189
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
190
+ const radius = cs * (1.2 + fire * 1.8);
191
+ const a = Math.min(0.85, fire * 0.9);
192
+ const gr = ctx.createRadialGradient(px, py, 0, px, py, radius);
193
+ gr.addColorStop(0, `rgba(255,80,0,${a})`);
194
+ gr.addColorStop(0.4, `rgba(220,40,0,${a * 0.5})`);
195
+ gr.addColorStop(1, 'rgba(0,0,0,0)');
196
+ ctx.fillStyle = gr;
197
+ ctx.fillRect(px - radius, py - radius, radius * 2, radius * 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
+ }
200
+ ctx.restore();
201
+
202
+ /* ── Fire volumetric: vivid source-over layers (real-looking flames) ── */
203
+ for (let y = 0; y < H; y++) {
204
+ for (let x = 0; x < W; x++) {
205
+ const fire = fire_grid[idx(x, y)];
206
+ if (fire < 0.05) continue;
207
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
208
+ const flicker = 0.80 + 0.20 * Math.sin(t * 11.0 + x * 3.1 + y * 2.7);
209
+ const eff = Math.min(1, fire * flicker);
210
+
211
+ const windDx = wv[0] * cs * 0.25 * eff;
212
+ const windDy = wv[1] * cs * 0.25 * eff - cs * 0.06;
213
+
214
+ /* Outer dark-red base: anchors fire to cell */
215
+ {
216
+ const r = cs * 0.70 * (0.7 + eff * 0.3);
217
+ const cx2 = px + windDx * 0.5, cy2 = py + windDy * 0.5;
218
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
219
+ gr.addColorStop(0, `rgba(200,20,0,${eff * 0.65})`);
220
+ gr.addColorStop(0.55,`rgba(170,10,0,${eff * 0.35})`);
221
+ gr.addColorStop(1, 'rgba(80,0,0,0)');
222
+ ctx.fillStyle = gr;
223
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
224
+ }
225
 
226
+ /* Mid vivid-orange body */
227
+ {
228
+ const r = cs * 0.46 * (0.8 + eff * 0.2);
229
+ const cx2 = px + windDx * 0.35, cy2 = py + windDy * 0.35 - cs * 0.04 * eff;
230
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
231
+ gr.addColorStop(0, `rgba(255,110,0,${eff * 0.90})`);
232
+ gr.addColorStop(0.45,`rgba(255,60,0,${eff * 0.60})`);
233
+ gr.addColorStop(1, 'rgba(220,20,0,0)');
234
+ ctx.fillStyle = gr;
235
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  }
 
237
 
238
+ /* Inner bright-yellow core */
239
+ {
240
+ const r = cs * 0.26 * eff;
241
+ const cx2 = px + windDx * 0.15, cy2 = py + windDy * 0.15 - cs * 0.10 * eff;
242
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
243
+ gr.addColorStop(0, `rgba(255,230,80,${eff * 0.95})`);
244
+ gr.addColorStop(0.35,`rgba(255,170,20,${eff * 0.75})`);
245
+ gr.addColorStop(1, 'rgba(255,80,0,0)');
246
+ ctx.fillStyle = gr;
247
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
248
+ }
249
 
250
+ /* White-hot tip (only for intense fire) */
251
+ if (eff > 0.55) {
252
+ const r = cs * 0.13 * eff;
253
+ const cx2 = px + windDx * 0.1, cy2 = py + windDy * 0.1 - cs * 0.18 * eff;
254
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
255
+ gr.addColorStop(0, `rgba(255,255,220,${eff * 0.9})`);
256
+ gr.addColorStop(1, 'rgba(255,220,60,0)');
257
+ ctx.fillStyle = gr;
258
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
259
+ }
260
 
261
+ /* Wind-carried plume tip */
262
+ if (fire > 0.35) {
263
+ const r = cs * 0.28 * eff;
264
+ const cx2 = px + windDx, cy2 = py + windDy - cs * 0.22 * eff;
265
+ const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
266
+ gr.addColorStop(0, `rgba(255,160,0,${eff * 0.65})`);
267
+ gr.addColorStop(1, 'rgba(255,60,0,0)');
268
+ ctx.fillStyle = gr;
269
+ ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
 
 
 
 
270
  }
271
 
272
+ if (fire > 0.45 && Math.random() < 0.09 && embersRef.current.length < 120) {
273
+ embersRef.current.push(new Ember(px, py, wv[0]));
 
 
 
 
 
 
274
  }
 
 
 
275
  }
276
+ }
277
+
278
+ /* ── Smoke (dark on light bg) ── */
279
+ for (let y = 0; y < H; y++) {
280
+ for (let x = 0; x < W; x++) {
281
+ const smoke = smoke_grid[idx(x, y)];
282
+ if (smoke < 0.1) continue;
283
+ const px = x * cs + cs / 2, py = y * cs + cs / 2;
284
+ const offX = Math.sin(t * 0.5 + x) * 2;
285
+ const offY = Math.cos(t * 0.4 + y) * 2;
286
+ const alpha = Math.min(0.68, smoke * 0.8);
287
+ const gr = ctx.createRadialGradient(px + offX, py + offY, 0, px + offX, py + offY, cs * 0.82);
288
+ gr.addColorStop(0, `rgba(72,82,96,${alpha})`);
289
+ gr.addColorStop(1, 'rgba(72,82,96,0)');
290
+ ctx.fillStyle = gr;
291
+ ctx.beginPath(); ctx.arc(px + offX, py + offY, cs * 0.82, 0, Math.PI * 2); ctx.fill();
292
+ }
293
+ }
294
+
295
+ /* ── Exits & Doors ── */
296
+ for (let y = 0; y < H; y++) {
297
+ for (let x = 0; x < W; x++) {
298
+ const ct = cell_grid[idx(x, y)];
299
+ const px = x * cs, py = y * cs;
300
+ const pulse = 0.7 + 0.3 * Math.sin(t * 3);
301
+
302
+ if (ct === EXIT) {
303
+ ctx.fillStyle = '#e6f4ec';
304
+ ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4);
305
+ ctx.strokeStyle = `rgba(22,163,74,${0.7 + 0.3 * pulse})`;
306
+ ctx.lineWidth = 2 * pulse;
307
+ ctx.strokeRect(px + 5, py + 5, cs - 10, cs - 10);
308
+ /* EXIT symbol */
309
+ ctx.fillStyle = `rgba(22,163,74,${0.85 + 0.15 * pulse})`;
310
+ ctx.font = `bold ${cs * 0.26}px var(--mono, monospace)`;
311
+ ctx.textAlign = 'center';
312
+ ctx.textBaseline = 'middle';
313
+ ctx.fillText('EXIT', px + cs / 2, py + cs / 2);
314
+ } else if (ct === DOOR_CLOSED) {
315
+ ctx.fillStyle = '#7c5c3c';
316
+ ctx.fillRect(px + 4, py + 2, cs - 8, cs - 4);
317
+ ctx.fillStyle = '#4a3020';
318
+ ctx.fillRect(px + 2, py, cs - 4, 2);
319
+ ctx.fillRect(px + 2, py + cs - 2, cs - 4, 2);
320
+ /* handle */
321
+ ctx.fillStyle = '#f0b030';
322
+ ctx.beginPath();
323
+ ctx.arc(px + cs - 10, py + cs / 2, 2.5, 0, Math.PI * 2);
324
+ ctx.fill();
325
+ } else if (ct === DOOR_OPEN) {
326
+ ctx.fillStyle = '#4a3020';
327
+ ctx.fillRect(px + 2, py, 4, cs);
328
+ ctx.fillRect(px + cs - 6, py, 4, cs);
329
+ }
330
+ }
331
+ }
332
+
333
+ /* ── Fog of War (dim only — all map items still readable) ── */
334
+ for (let y = 0; y < H; y++) {
335
+ for (let x = 0; x < W; x++) {
336
+ const key = `${x},${y}`;
337
+ if (!visible.has(key)) {
338
+ ctx.fillStyle = 'rgba(140,134,126,0.55)';
339
+ ctx.fillRect(x * cs, y * cs, cs, cs);
340
+ }
341
+ }
342
+ }
343
+
344
+ /* ── Vision lantern glow around agent ── */
345
+ const apx = agent_x * cs + cs / 2, apy = agent_y * cs + cs / 2;
346
+ const lanternR = cs * 3.5;
347
+ const lanternGr = ctx.createRadialGradient(apx, apy, 0, apx, apy, lanternR);
348
+ lanternGr.addColorStop(0, 'rgba(255,240,190,0.18)');
349
+ lanternGr.addColorStop(0.5, 'rgba(255,220,140,0.08)');
350
+ lanternGr.addColorStop(1, 'rgba(0,0,0,0)');
351
+ ctx.fillStyle = lanternGr;
352
+ ctx.fillRect(apx - lanternR, apy - lanternR, lanternR * 2, lanternR * 2);
353
+
354
+ /* ── Agent trail ── */
355
+ const now = timeRef.current;
356
+ if (
357
+ trailRef.current.length === 0 ||
358
+ Math.abs(trailRef.current[0].x - apx) > 1 ||
359
+ Math.abs(trailRef.current[0].y - apy) > 1
360
+ ) {
361
+ trailRef.current.unshift({ x: apx, y: apy, t: now });
362
+ }
363
+ if (trailRef.current.length > 20) trailRef.current.pop();
364
+
365
+ trailRef.current.forEach((p, i) => {
366
+ const alpha = (1 - i / trailRef.current.length) * 0.70;
367
+ ctx.fillStyle = `rgba(2,132,199,${alpha})`;
368
+ ctx.beginPath();
369
+ ctx.arc(p.x, p.y, cs * 0.12 * (1 - i / 22), 0, Math.PI * 2);
370
+ ctx.fill();
371
+ });
372
+
373
+ /* ── Agent rendering ── */
374
+ const theme =
375
+ agent_health >= 60 ? AGENT_THEMES.healthy :
376
+ agent_health >= 30 ? AGENT_THEMES.moderate :
377
+ agent_health > 0 ? AGENT_THEMES.low :
378
+ AGENT_THEMES.critical;
379
+
380
+ const pulse = 0.85 + 0.15 * Math.sin(t * 4);
381
+ const ringR = cs * 0.48;
382
+
383
+ /* pulsing gold aura */
384
+ const auraR = ringR * (1.5 + 0.2 * pulse);
385
+ const auraGr = ctx.createRadialGradient(apx, apy, ringR * 0.7, apx, apy, auraR);
386
+ auraGr.addColorStop(0, `rgba(251,191,36,${0.28 * pulse})`);
387
+ auraGr.addColorStop(1, 'rgba(251,191,36,0)');
388
+ ctx.fillStyle = auraGr;
389
+ ctx.beginPath(); ctx.arc(apx, apy, auraR, 0, Math.PI * 2); ctx.fill();
390
+
391
+ /* ground shadow */
392
+ ctx.save();
393
+ ctx.globalAlpha = 0.22;
394
+ const shadowGr = ctx.createRadialGradient(apx, apy + cs * 0.32, 0, apx, apy + cs * 0.32, cs * 0.38);
395
+ shadowGr.addColorStop(0, 'rgba(0,0,0,0.6)');
396
+ shadowGr.addColorStop(1, 'rgba(0,0,0,0)');
397
+ ctx.fillStyle = shadowGr;
398
+ ctx.beginPath(); ctx.ellipse(apx, apy + cs * 0.32, cs * 0.38, cs * 0.14, 0, 0, Math.PI * 2); ctx.fill();
399
+ ctx.restore();
400
+
401
+ /* Minecraft character */
402
+ drawMinecraftAgent(ctx, apx, apy, cs, theme);
403
+
404
+ /* health arc ring — gold ring + colored fill */
405
+ const hRatio = Math.max(0, Math.min(1, agent_health / 100));
406
+ /* ring track */
407
+ ctx.beginPath();
408
+ ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
409
+ ctx.strokeStyle = 'rgba(0,0,0,0.12)';
410
+ ctx.lineWidth = 4;
411
+ ctx.lineCap = 'round';
412
+ ctx.stroke();
413
+ /* gold base ring */
414
+ ctx.beginPath();
415
+ ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
416
+ ctx.strokeStyle = 'rgba(251,191,36,0.25)';
417
+ ctx.lineWidth = 3.5;
418
+ ctx.stroke();
419
+ /* health fill */
420
+ ctx.beginPath();
421
+ ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
422
+ ctx.strokeStyle = theme.ring;
423
+ ctx.lineWidth = 3.5;
424
+ ctx.lineCap = 'round';
425
+ ctx.stroke();
426
+ /* ring glow */
427
+ ctx.beginPath();
428
+ ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
429
+ ctx.strokeStyle = theme.ringGlow;
430
+ ctx.lineWidth = 6;
431
+ ctx.stroke();
432
+
433
+ /* move flash */
434
+ if (agentMoveFlash > 0) {
435
+ const fa = agentMoveFlash / 18;
436
+ ctx.strokeStyle = `rgba(255,255,255,${fa * 0.8})`;
437
+ ctx.lineWidth = 3;
438
+ ctx.beginPath();
439
+ ctx.arc(apx, apy, ringR * (1.8 + (1 - fa) * 0.6), 0, Math.PI * 2);
440
+ ctx.stroke();
441
+ }
442
+
443
+ /* ── Embers ── */
444
+ for (let i = embersRef.current.length - 1; i >= 0; i--) {
445
+ const e = embersRef.current[i];
446
+ e.update();
447
+ if (e.life <= 0) { embersRef.current.splice(i, 1); continue; }
448
+ ctx.fillStyle = `rgba(255,${Math.floor(80 + 175 * e.life)},0,${e.life})`;
449
+ ctx.beginPath(); ctx.arc(e.x, e.y, e.size * e.life, 0, Math.PI * 2); ctx.fill();
450
+ }
451
+
452
+ rafRef.current = requestAnimationFrame(animate);
453
+ };
454
 
455
+ useEffect(() => {
456
+ rafRef.current = requestAnimationFrame(animate);
457
+ return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
458
+ // eslint-disable-next-line react-hooks/exhaustive-deps
 
 
 
 
459
  }, [observation, agentMoveFlash]);
460
 
461
+ const W = observation?.map_state.grid_w ?? 16;
462
+ const H = observation?.map_state.grid_h ?? 16;
463
 
464
  return (
465
+ <canvas
466
+ ref={canvasRef}
467
+ id="map-canvas"
468
+ width={W * CS}
469
+ height={H * CS}
470
  />
471
  );
472
  };
frontend_new_desgin/index.html ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
6
+ <title>Pyre — Crisis Navigation Environment</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ /* Surface */
14
+ --s0: #f5f2ed; /* page bg — warm off-white */
15
+ --s1: #ffffff; /* panel / card */
16
+ --s2: #faf9f6; /* subtle inset */
17
+ --s3: #f0ede8; /* hover */
18
+ --bd: #e4dfd7; /* border */
19
+ --bd2:#cdc7be; /* strong border */
20
+
21
+ /* Text */
22
+ --t1: #1c1712; /* primary */
23
+ --t2: #6b6460; /* secondary */
24
+ --t3: #a8a29e; /* muted */
25
+
26
+ /* Semantic */
27
+ --fire: #c2410c; /* deep ember */
28
+ --fire2: rgba(194,65,12,0.08);
29
+ --blue: #1d4ed8;
30
+ --blue2: rgba(29,78,216,0.08);
31
+ --green: #166534;
32
+ --green2:rgba(22,101,52,0.08);
33
+ --amber: #92400e;
34
+ --amber2:rgba(146,64,14,0.08);
35
+ --red: #b91c1c;
36
+ --red2: rgba(185,28,28,0.08);
37
+
38
+ --mono: 'DM Mono', 'JetBrains Mono', monospace;
39
+ --sans: 'DM Sans', 'Inter', system-ui, sans-serif;
40
+ --r: 6px;
41
+ }
42
+
43
+ html, body { height: 100%; background: var(--s0); color: var(--t1); font-family: var(--sans); overflow: hidden; font-size: 13px; }
44
+
45
+ /* ── Shell ── */
46
+ .shell {
47
+ display: grid;
48
+ grid-template-rows: 44px 1fr 48px;
49
+ height: 100vh;
50
+ min-width: 1280px;
51
+ }
52
+
53
+ /* ── Topbar ── */
54
+ .topbar {
55
+ background: var(--s1);
56
+ border-bottom: 1px solid var(--bd);
57
+ display: flex; align-items: center;
58
+ padding: 0 20px; gap: 16px;
59
+ }
60
+ .brand { display: flex; align-items: center; gap: 9px; }
61
+ .brand-icon {
62
+ width: 28px; height: 28px; border-radius: 6px;
63
+ background: var(--fire); display: flex; align-items: center; justify-content: center;
64
+ font-size: 14px; flex-shrink: 0;
65
+ }
66
+ .brand-name { font-family: var(--mono); font-size: 14px; font-weight: 500; color: var(--t1); letter-spacing: 0.05em; }
67
+ .brand-sep { width: 1px; height: 18px; background: var(--bd); }
68
+ .brand-sub { font-size: 11px; color: var(--t3); }
69
+
70
+ .topbar-divider { width: 1px; height: 20px; background: var(--bd); }
71
+ .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
72
+
73
+ /* Segment controls */
74
+ .seg {
75
+ display: flex; border: 1px solid var(--bd); border-radius: 6px; overflow: hidden;
76
+ background: var(--s2);
77
+ }
78
+ .seg button {
79
+ background: none; border: none; padding: 5px 11px;
80
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
81
+ color: var(--t2); cursor: pointer; letter-spacing: 0.03em;
82
+ transition: background .1s, color .1s;
83
+ }
84
+ .seg button:hover { background: var(--s3); color: var(--t1); }
85
+ .seg button.on { background: var(--s1); color: var(--t1); box-shadow: inset 0 1px 0 rgba(255,255,255,.8); }
86
+ .seg button.on.fire { color: var(--fire); }
87
+ .seg button + button { border-left: 1px solid var(--bd); }
88
+
89
+ .live-chip {
90
+ font-family: var(--mono); font-size: 10px; font-weight: 500;
91
+ padding: 3px 10px; border-radius: 12px; border: 1px solid;
92
+ letter-spacing: 0.05em;
93
+ }
94
+ .live-chip.demo { border-color: var(--bd2); color: var(--t2); }
95
+ .live-chip.online { border-color: rgba(22,101,52,.4); color: var(--green); background: var(--green2); }
96
+ .live-chip.offline{ border-color: rgba(185,28,28,.4); color: var(--red); }
97
+
98
+ /* ── Content ── */
99
+ .content { display: grid; grid-template-columns: 1fr 320px; overflow: hidden; min-height: 0; }
100
+
101
+ /* ── Canvas zone ── */
102
+ .canvas-zone {
103
+ display: flex; align-items: center; justify-content: center;
104
+ background: var(--s0); padding: 20px; position: relative;
105
+ overflow: hidden;
106
+ }
107
+ .canvas-frame {
108
+ position: relative;
109
+ border-radius: 8px; overflow: hidden;
110
+ box-shadow: 0 0 0 1px var(--bd2), 0 20px 60px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.10);
111
+ /* Scale to fill available space while keeping aspect ratio */
112
+ max-width: min(calc(100vh - 92px - 48px - 40px), calc(100vw - 340px - 40px));
113
+ max-height: calc(100vh - 92px);
114
+ aspect-ratio: 1;
115
+ }
116
+ #mainCanvas {
117
+ display: block;
118
+ width: 100%;
119
+ height: 100%;
120
+ }
121
+
122
+ /* HUD overlays — float above canvas */
123
+ .hud-tl, .hud-tr {
124
+ position: absolute; top: 12px;
125
+ pointer-events: none; z-index: 2;
126
+ }
127
+ .hud-tl { left: 12px; }
128
+ .hud-tr { right: 12px; }
129
+
130
+ .hud-card {
131
+ background: rgba(14,10,8,0.82);
132
+ border: 1px solid rgba(255,255,255,0.10);
133
+ border-radius: 6px; padding: 8px 11px;
134
+ backdrop-filter: blur(8px);
135
+ color: #e8e0d8;
136
+ }
137
+ .hud-r { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
138
+ .hud-r:last-child { margin-bottom: 0; }
139
+ .hlbl { font-family: var(--mono); font-size: 9px; color: rgba(168,162,158,0.8); letter-spacing: 0.1em; }
140
+ .hval { font-family: var(--mono); font-size: 12px; font-weight: 500; color: #f0e8e0; }
141
+ .hbar-bg { width: 80px; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; }
142
+ .hbar-fill { height: 100%; border-radius: 2px; transition: width .4s, background .4s; }
143
+ .hbar-fill.g { background: #4ade80; }
144
+ .hbar-fill.m { background: #fbbf24; }
145
+ .hbar-fill.c { background: #f87171; animation: hblink .5s infinite alternate; }
146
+ @keyframes hblink { to { opacity: .3 } }
147
+ .hstatus { font-family: var(--mono); font-size: 9px; }
148
+ .hstatus.good { color: #4ade80; }
149
+ .hstatus.moderate { color: #fbbf24; }
150
+ .hstatus.low { color: #fb923c; }
151
+ .hstatus.critical { color: #f87171; }
152
+
153
+ .step-val { font-family: var(--mono); font-size: 12px; font-weight: 500; color: #93c5fd; }
154
+ .sbar-bg { width: 90px; height: 3px; background: rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; margin-top:5px; }
155
+ .sbar-fill { height:100%; border-radius:2px; background:#60a5fa; transition:width .4s; }
156
+ .step-meta { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.6); margin-top:3px; }
157
+
158
+ /* Episode end */
159
+ #epBanner {
160
+ display: none; position: absolute;
161
+ bottom: 14px; left: 50%; transform: translateX(-50%);
162
+ font-family: var(--mono); font-size: 13px; font-weight: 500;
163
+ letter-spacing: 0.04em; padding: 10px 20px;
164
+ border-radius: 8px; backdrop-filter: blur(10px);
165
+ pointer-events: none; white-space: nowrap;
166
+ }
167
+ #epBanner.ok { background:rgba(21,128,61,0.80); border:1px solid rgba(74,222,128,.3); color:#dcfce7; }
168
+ #epBanner.bad { background:rgba(153,27,27,0.80); border:1px solid rgba(248,113,113,.3); color:#fee2e2; }
169
+ #epBanner.tmo { background:rgba(120,53,15,0.80); border:1px solid rgba(251,191,36,.3); color:#fef3c7; }
170
+
171
+ /* Wind */
172
+ .wind-wrap {
173
+ position: absolute; bottom: 10px; right: 10px;
174
+ background: rgba(14,10,8,0.80); border: 1px solid rgba(255,255,255,0.10);
175
+ border-radius: 50%; backdrop-filter: blur(6px);
176
+ }
177
+
178
+ /* ── Side panel ── */
179
+ .side {
180
+ background: var(--s1);
181
+ border-left: 1px solid var(--bd);
182
+ overflow-y: auto; overflow-x: hidden;
183
+ scrollbar-width: thin; scrollbar-color: var(--bd) transparent;
184
+ display: flex; flex-direction: column;
185
+ }
186
+ ::-webkit-scrollbar { width: 3px; }
187
+ ::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 2px; }
188
+
189
+ .side-sec { padding: 14px 16px; border-bottom: 1px solid var(--bd); }
190
+ .side-sec:last-child { border-bottom: none; flex: 1; min-height: 0; display: flex; flex-direction: column; }
191
+ .sec-hd {
192
+ font-family: var(--mono); font-size: 9px; font-weight: 500;
193
+ letter-spacing: 0.12em; text-transform: uppercase; color: var(--t3);
194
+ margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between;
195
+ }
196
+
197
+ /* Stats */
198
+ .sg { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
199
+ .sc {
200
+ background: var(--s2); border: 1px solid var(--bd);
201
+ border-radius: var(--r); padding: 8px 10px;
202
+ }
203
+ .sc-l { font-size: 10px; color: var(--t3); margin-bottom: 2px; }
204
+ .sc-v { font-family: var(--mono); font-size: 14px; font-weight: 500; color: var(--t1); }
205
+ .sc-v.fire { color: var(--fire); }
206
+ .sc-v.blue { color: var(--blue); }
207
+ .sc-v.amber { color: var(--amber); }
208
+
209
+ .meta-row {
210
+ display: flex; justify-content: space-between; align-items: center;
211
+ margin-top: 8px; padding-top: 9px; border-top: 1px solid var(--bd);
212
+ font-size: 11px;
213
+ }
214
+
215
+ /* Charts */
216
+ .chart-bg {
217
+ background: var(--s2); border: 1px solid var(--bd);
218
+ border-radius: var(--r); overflow: hidden; margin-top: 6px;
219
+ }
220
+ .chart-bg canvas { display: block; width: 100% !important; }
221
+
222
+ /* Event log */
223
+ .elog { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; flex: 1; }
224
+ .erow {
225
+ display: flex; align-items: baseline; gap: 7px;
226
+ padding: 3px 6px; border-radius: 4px;
227
+ font-family: var(--mono); font-size: 10px;
228
+ border-left: 2px solid transparent;
229
+ animation: ein .15s ease;
230
+ }
231
+ @keyframes ein { from { opacity:0; transform:translateY(-2px) } }
232
+ .erow:hover { background: var(--s3); }
233
+ .erow.alarm { border-left-color: var(--fire); background: var(--fire2); }
234
+ .estep { color: var(--t3); min-width: 22px; font-size: 9px; }
235
+ .etext { color: var(--t2); flex: 1; line-height: 1.4; }
236
+ .erwd { font-size: 9px; min-width: 32px; text-align: right; }
237
+ .erwd.p { color: var(--green); }
238
+ .erwd.n { color: var(--red); }
239
+
240
+ /* ── Bottom bar ── */
241
+ .botbar {
242
+ background: var(--s1); border-top: 1px solid var(--bd);
243
+ display: flex; align-items: center; padding: 0 16px; gap: 6px;
244
+ }
245
+ .bsep { width: 1px; height: 20px; background: var(--bd); margin: 0 4px; }
246
+
247
+ .icn-btn {
248
+ width: 30px; height: 30px;
249
+ display: flex; align-items: center; justify-content: center;
250
+ background: var(--s2); border: 1px solid var(--bd);
251
+ border-radius: var(--r); color: var(--t2);
252
+ font-size: 12px; cursor: pointer;
253
+ transition: background .1s, color .1s, border-color .1s;
254
+ }
255
+ .icn-btn:hover { background: var(--s3); color: var(--t1); border-color: var(--bd2); }
256
+ .icn-btn:active { transform: scale(0.94); }
257
+ .icn-btn.play {
258
+ background: var(--fire2); border-color: rgba(194,65,12,0.3); color: var(--fire);
259
+ }
260
+ .icn-btn.play:hover { background: rgba(194,65,12,0.14); }
261
+
262
+ .seed-wrap { display: flex; align-items: center; gap: 6px; }
263
+ .seed-lbl { font-size: 11px; color: var(--t3); }
264
+ #seedInput {
265
+ width: 64px; background: var(--s2); border: 1px solid var(--bd);
266
+ color: var(--t1); font-family: var(--mono); font-size: 11px;
267
+ padding: 4px 8px; border-radius: var(--r); outline: none;
268
+ }
269
+ #seedInput:focus { border-color: var(--bd2); }
270
+
271
+ .keys {
272
+ margin-left: auto; display: flex; gap: 12px; align-items: center;
273
+ font-size: 10px; color: var(--t3);
274
+ }
275
+ .keys kbd {
276
+ background: var(--s2); border: 1px solid var(--bd2);
277
+ border-radius: 3px; padding: 1px 5px;
278
+ font-family: var(--mono); font-size: 10px; color: var(--t2);
279
+ }
280
+
281
+ /* Toast */
282
+ #toast {
283
+ position: fixed; bottom: 56px; left: 50%;
284
+ transform: translateX(-50%) translateY(6px);
285
+ background: var(--t1); color: var(--s0);
286
+ font-family: var(--mono); font-size: 11px;
287
+ padding: 7px 16px; border-radius: 20px;
288
+ opacity: 0; pointer-events: none; z-index: 99;
289
+ transition: opacity .22s, transform .22s;
290
+ }
291
+ #toast.up { opacity: 1; transform: translateX(-50%) translateY(0); }
292
+ </style>
293
+ </head>
294
+ <body>
295
+ <div class="shell">
296
+
297
+ <!-- Topbar -->
298
+ <header class="topbar">
299
+ <div class="brand">
300
+ <div class="brand-icon">🔥</div>
301
+ <span class="brand-name">Pyre</span>
302
+ <div class="brand-sep"></div>
303
+ <span class="brand-sub">Crisis Navigation Environment</span>
304
+ </div>
305
+
306
+ <div class="topbar-divider"></div>
307
+
308
+ <div class="seg">
309
+ <button class="mode-btn on fire" data-mode="demo">Demo</button>
310
+ <button class="mode-btn" data-mode="live">Live</button>
311
+ </div>
312
+
313
+ <div class="seg">
314
+ <button class="diff-btn" data-diff="easy">Easy</button>
315
+ <button class="diff-btn on" data-diff="medium">Medium</button>
316
+ <button class="diff-btn" data-diff="hard">Hard</button>
317
+ </div>
318
+
319
+ <div class="topbar-right">
320
+ <span id="liveIndicator" class="live-chip demo">Demo</span>
321
+ </div>
322
+ </header>
323
+
324
+ <!-- Content -->
325
+ <div class="content">
326
+
327
+ <!-- Canvas zone -->
328
+ <div class="canvas-zone">
329
+ <div class="canvas-frame">
330
+ <canvas id="mainCanvas" width="576" height="576"></canvas>
331
+
332
+ <div class="hud-tl">
333
+ <div class="hud-card">
334
+ <div class="hud-r">
335
+ <span class="hlbl">HP</span>
336
+ <div class="hbar-bg"><div id="healthBar" class="hbar-fill g" style="width:100%"></div></div>
337
+ <span id="healthVal" class="hval">100</span>
338
+ </div>
339
+ <div class="hud-r" style="gap:10px">
340
+ <span id="healthStatus" class="hstatus good">Good</span>
341
+ <span id="smokeTag" style="font-family:var(--mono);font-size:9px;color:rgba(168,162,158,.6);margin-left:auto">smoke: clear</span>
342
+ </div>
343
+ </div>
344
+ </div>
345
+
346
+ <div class="hud-tr">
347
+ <div class="hud-card" style="text-align:right">
348
+ <div class="hud-r" style="justify-content:flex-end">
349
+ <span class="step-val" id="stepCount">0 / 150</span>
350
+ </div>
351
+ <div class="sbar-bg" style="margin-left:auto">
352
+ <div id="stepBar" class="sbar-fill" style="width:0%"></div>
353
+ </div>
354
+ <div class="step-meta" id="diffTag">medium · small_office</div>
355
+ </div>
356
+ </div>
357
+
358
+ <div id="epBanner"></div>
359
+
360
+ <div class="wind-wrap">
361
+ <canvas id="windCanvas" width="68" height="68"></canvas>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- Side -->
367
+ <aside class="side">
368
+
369
+ <div class="side-sec">
370
+ <div class="sec-hd">Episode</div>
371
+ <div class="sg">
372
+ <div class="sc"><div class="sc-l">Reward</div><div id="rewardVal" class="sc-v blue">0.00</div></div>
373
+ <div class="sc"><div class="sc-l">Burned</div><div id="burnPct" class="sc-v fire">0%</div></div>
374
+ <div class="sc"><div class="sc-l">Wind</div><div id="windVal" class="sc-v amber">—</div></div>
375
+ <div class="sc"><div class="sc-l">Humidity</div><div id="humidVal" class="sc-v">—%</div></div>
376
+ </div>
377
+ <div class="meta-row">
378
+ <span style="color:var(--t3)">spread ρ</span>
379
+ <span id="spreadVal" style="color:var(--fire);font-family:var(--mono)">0.25</span>
380
+ </div>
381
+ </div>
382
+
383
+ <div class="side-sec">
384
+ <div class="sec-hd">
385
+ Cumulative Reward
386
+ <span id="rewardLast" style="color:var(--t2);font-weight:400;letter-spacing:0;font-size:10px">—</span>
387
+ </div>
388
+ <div class="chart-bg" style="height:52px"><canvas id="rewardChart" height="52"></canvas></div>
389
+ </div>
390
+
391
+ <div class="side-sec">
392
+ <div class="sec-hd">
393
+ Active Fire Cells
394
+ <span id="fireLast" style="color:var(--t2);font-weight:400;letter-spacing:0;font-size:10px">0</span>
395
+ </div>
396
+ <div class="chart-bg" style="height:46px"><canvas id="fireSizeChart" height="46"></canvas></div>
397
+ </div>
398
+
399
+ <div class="side-sec">
400
+ <div class="sec-hd">Action Distribution</div>
401
+ <div class="chart-bg" style="height:68px"><canvas id="actionChart" height="68"></canvas></div>
402
+ </div>
403
+
404
+ <div class="side-sec">
405
+ <div class="sec-hd">Event Log</div>
406
+ <div id="eventLog" class="elog">
407
+ <div class="erow">
408
+ <span class="estep">000</span>
409
+ <span class="etext">Episode started. Assess your surroundings.</span>
410
+ </div>
411
+ </div>
412
+ </div>
413
+
414
+ </aside>
415
+ </div>
416
+
417
+ <!-- Bottom bar -->
418
+ <footer class="botbar">
419
+ <button id="prevBtn" class="icn-btn" title="Prev template">⏮</button>
420
+ <button id="playBtn" class="icn-btn play" title="Play (Space)">▶</button>
421
+ <button id="stepBtn" class="icn-btn" title="Step (→)">⏭</button>
422
+ <button id="resetBtn" class="icn-btn" title="Reset (R)">↺</button>
423
+ <div class="bsep"></div>
424
+ <div class="seg">
425
+ <button class="speed-btn" data-speed="0.5">½×</button>
426
+ <button class="speed-btn on" data-speed="1">1×</button>
427
+ <button class="speed-btn" data-speed="2">2×</button>
428
+ <button class="speed-btn" data-speed="4">4×</button>
429
+ <button class="speed-btn" data-speed="8">8×</button>
430
+ </div>
431
+ <div class="bsep"></div>
432
+ <div class="seed-wrap">
433
+ <span class="seed-lbl">Seed</span>
434
+ <input id="seedInput" type="number" value="42" min="0" max="9999">
435
+ </div>
436
+ <div class="keys">
437
+ <span><kbd>Space</kbd> play</span>
438
+ <span><kbd>→</kbd> step</span>
439
+ <span><kbd>R</kbd> reset</span>
440
+ </div>
441
+ </footer>
442
+ </div>
443
+
444
+ <div id="toast"></div>
445
+ <script src="js/sim.js?v=3"></script>
446
+ <script src="js/renderer.js?v=4"></script>
447
+ <script src="js/app.js?v=6"></script>
448
+ </body>
449
+ </html>
frontend_new_desgin/js/app.js ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * app.js — Pyre visualization controller
3
+ * Handles episode lifecycle, HUD updates, charts, live/demo modes.
4
+ */
5
+
6
+ // ─── Mini chart renderer ─────────────────────────────────────────────────────
7
+
8
+ class MiniChart {
9
+ constructor(canvas, opts={}) {
10
+ this.canvas = canvas;
11
+ this.ctx = canvas.getContext('2d');
12
+ this.color = opts.color || '#00e5ff';
13
+ this.fill = opts.fill || 'rgba(0,229,255,0.12)';
14
+ this.label = opts.label || '';
15
+ this.maxPoints = opts.maxPoints || 200;
16
+ this.yMin = opts.yMin !== undefined ? opts.yMin : null;
17
+ this.yMax = opts.yMax !== undefined ? opts.yMax : null;
18
+ this.data = [];
19
+ }
20
+
21
+ push(v) {
22
+ this.data.push(v);
23
+ if (this.data.length > this.maxPoints) this.data.shift();
24
+ }
25
+
26
+ draw() {
27
+ const {canvas, ctx, data, color, fill} = this;
28
+ const w=canvas.width, h=canvas.height;
29
+ ctx.fillStyle='#faf9f6'; ctx.fillRect(0,0,w,h);
30
+ if (data.length < 2) return;
31
+
32
+ const yMin = this.yMin !== null ? this.yMin : Math.min(...data);
33
+ const yMax = this.yMax !== null ? this.yMax : Math.max(...data);
34
+ const yRange = (yMax-yMin) || 1;
35
+ const pad = 4;
36
+
37
+ // Grid lines
38
+ ctx.strokeStyle='rgba(0,0,0,0.06)';
39
+ ctx.lineWidth=0.5;
40
+ for (let i=0;i<=3;i++) {
41
+ const gy=pad+(h-2*pad)*(i/3);
42
+ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(w,gy); ctx.stroke();
43
+ }
44
+
45
+ const toX = i => (i/(data.length-1))*w;
46
+ const toY = v => h-pad - ((v-yMin)/yRange)*(h-2*pad);
47
+
48
+ // Fill area
49
+ ctx.beginPath();
50
+ ctx.moveTo(toX(0), h);
51
+ for (let i=0;i<data.length;i++) ctx.lineTo(toX(i), toY(data[i]));
52
+ ctx.lineTo(toX(data.length-1), h);
53
+ ctx.closePath();
54
+ ctx.fillStyle=fill;
55
+ ctx.fill();
56
+
57
+ // Line
58
+ ctx.beginPath();
59
+ ctx.moveTo(toX(0), toY(data[0]));
60
+ for (let i=1;i<data.length;i++) ctx.lineTo(toX(i), toY(data[i]));
61
+ ctx.strokeStyle=color;
62
+ ctx.lineWidth=1.8;
63
+ ctx.stroke();
64
+
65
+ // Current value dot
66
+ const lastX=toX(data.length-1), lastY=toY(data[data.length-1]);
67
+ ctx.beginPath();
68
+ ctx.arc(lastX, lastY, 3, 0, Math.PI*2);
69
+ ctx.fillStyle=color;
70
+ ctx.fill();
71
+
72
+ // Current value label
73
+ ctx.fillStyle=color;
74
+ ctx.font='10px monospace';
75
+ ctx.textAlign='right';
76
+ ctx.fillText(data[data.length-1].toFixed(1), w-2, lastY-4);
77
+ }
78
+ }
79
+
80
+ // ─── Action histogram ────────────────────────────────────────────────────────
81
+
82
+ function drawActionHistogram(canvas, counts) {
83
+ const ctx = canvas.getContext('2d');
84
+ const w=canvas.width, h=canvas.height;
85
+ ctx.fillStyle='#faf9f6'; ctx.fillRect(0,0,w,h);
86
+
87
+ const entries = Object.entries(counts);
88
+ const total = entries.reduce((s,[,v])=>s+v,0)||1;
89
+ const colors = {move:'#1d4ed8', door:'#c2410c', wait:'#7c3aed', look:'#166534'};
90
+ const barH = Math.floor((h-8)/entries.length)-2;
91
+
92
+ entries.forEach(([k,v],i) => {
93
+ const ratio = v/total;
94
+ const bw = Math.max(2, ratio*(w-60));
95
+ const by = 4+i*(barH+2);
96
+ // Bar bg
97
+ ctx.fillStyle='rgba(0,0,0,0.05)';
98
+ ctx.fillRect(60,by,w-62,barH);
99
+ // Bar fill
100
+ ctx.fillStyle=colors[k]||'#888';
101
+ ctx.fillRect(60,by,bw,barH);
102
+ // Label
103
+ ctx.fillStyle='rgba(80,70,65,0.85)';
104
+ ctx.font=`${Math.min(11,barH-1)}px monospace`;
105
+ ctx.textAlign='right';
106
+ ctx.fillText(k, 54, by+barH-2);
107
+ // Count
108
+ ctx.fillStyle=colors[k]||'#888';
109
+ ctx.textAlign='left';
110
+ ctx.fillText(v, 64+bw, by+barH-2);
111
+ });
112
+ }
113
+
114
+ // ─── Live mode client ────────────────────────────────────────────────────────
115
+
116
+ class LiveClient {
117
+ constructor(baseUrl='https://krooz-pyre-env.hf.space') {
118
+ this.baseUrl=baseUrl;
119
+ this.connected=false;
120
+ }
121
+ async checkHealth() {
122
+ try {
123
+ const r=await fetch(`${this.baseUrl}/web/health`,{signal:AbortSignal.timeout(3000)});
124
+ this.connected=r.ok;
125
+ } catch { this.connected=false; }
126
+ return this.connected;
127
+ }
128
+ async reset(difficulty='medium', seed=null) {
129
+ const body={difficulty};
130
+ if(seed!==null) body.seed=parseInt(seed)||null;
131
+ const r=await fetch(`${this.baseUrl}/web/reset`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
132
+ return r.json();
133
+ }
134
+ async step(action) {
135
+ const r=await fetch(`${this.baseUrl}/web/step`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(action)});
136
+ return r.json();
137
+ }
138
+ async getScene() {
139
+ const r=await fetch(`${this.baseUrl}/web/scene`);
140
+ return r.json();
141
+ }
142
+ }
143
+
144
+ // ─── App controller ───────────────────────────────────────────────────────────
145
+
146
+ class PyreApp {
147
+ constructor() {
148
+ this.canvas = document.getElementById('mainCanvas');
149
+ this.windCanvas = document.getElementById('windCanvas');
150
+ this.renderer = new PyreRenderer(this.canvas);
151
+ this.episode = null;
152
+ this.agent = new DemoAgent();
153
+ this.mode = 'demo'; // 'demo' | 'live'
154
+ this.playing = false;
155
+ this.speed = 1; // steps per second
156
+ this.stepInterval = null;
157
+ this.animFrame = null;
158
+ this.templateIdx = 0;
159
+ this.difficulty = 'medium';
160
+ this.seed = 42;
161
+ this.liveClient = new LiveClient();
162
+ this.liveScene = null;
163
+
164
+ // Charts
165
+ this.rewardChart = new MiniChart(document.getElementById('rewardChart'),{
166
+ color:'#1d4ed8', fill:'rgba(29,78,216,0.08)', yMin:null, yMax:null
167
+ });
168
+ this.fireSizeChart = new MiniChart(document.getElementById('fireSizeChart'),{
169
+ color:'#c2410c', fill:'rgba(194,65,12,0.08)', yMin:0
170
+ });
171
+
172
+ this._bindControls();
173
+ this.newEpisode();
174
+ this._startRenderLoop();
175
+ }
176
+
177
+ newEpisode() {
178
+ this.playing = false;
179
+ this._clearStepInterval();
180
+ const seed = parseInt(document.getElementById('seedInput')?.value) || this.seed;
181
+ this.seed = seed;
182
+ this.episode = new PyreEpisode(this.templateIdx, seed, this.difficulty);
183
+ this.renderer.init(this.episode);
184
+ this.agent = new DemoAgent();
185
+ this._updateHUD();
186
+ this._clearCharts();
187
+ this._updateEventLog();
188
+ this._updatePlayBtn();
189
+ // Auto-play
190
+ setTimeout(()=>{this.playing=true;this._startStepInterval();this._updatePlayBtn();},300);
191
+ this.templateIdx = (this.templateIdx+1) % 3; // cycle templates
192
+ }
193
+
194
+ _clearCharts() {
195
+ this.rewardChart.data = [];
196
+ this.fireSizeChart.data = [];
197
+ this.rewardChart.draw();
198
+ this.fireSizeChart.draw();
199
+ const ac=document.getElementById('actionChart');
200
+ if(ac){const c=ac.getContext('2d');c.clearRect(0,0,ac.width,ac.height);}
201
+ }
202
+
203
+ step() {
204
+ if (!this.episode || this.episode.done) { this.playing=false; this._clearStepInterval(); this._updatePlayBtn(); return; }
205
+ const action = this.agent.chooseAction({
206
+ agentX:this.episode.agentX, agentY:this.episode.agentY,
207
+ cellGrid:this.episode.cellGrid, fireGrid:this.episode.fireGrid,
208
+ smokeGrid:this.episode.smokeGrid, exits:this.episode.exits,
209
+ w:this.episode.w, h:this.episode.h, doorRegistry:this.episode.doorRegistry
210
+ });
211
+ this.episode.step(action);
212
+ this._updateHUD();
213
+ this._updateCharts();
214
+ this._updateEventLog();
215
+
216
+ if (this.episode.done) {
217
+ this.playing=false;
218
+ this._clearStepInterval();
219
+ this._updatePlayBtn();
220
+ this._showEpisodeEnd();
221
+ }
222
+ }
223
+
224
+ togglePlay() {
225
+ this.playing = !this.playing;
226
+ if (this.playing) this._startStepInterval();
227
+ else this._clearStepInterval();
228
+ this._updatePlayBtn();
229
+ }
230
+
231
+ setSpeed(s) {
232
+ this.speed = s;
233
+ document.querySelectorAll('.speed-btn').forEach(b=>{
234
+ b.classList.toggle('on', parseFloat(b.dataset.speed)===s);
235
+ });
236
+ if (this.playing) { this._clearStepInterval(); this._startStepInterval(); }
237
+ }
238
+
239
+ _startStepInterval() {
240
+ this._clearStepInterval();
241
+ const ms = Math.max(50, 1000/this.speed);
242
+ this.stepInterval = setInterval(()=>this.step(), ms);
243
+ }
244
+
245
+ _clearStepInterval() {
246
+ if (this.stepInterval) { clearInterval(this.stepInterval); this.stepInterval=null; }
247
+ }
248
+
249
+ // ── HUD updates ────────────────────────────────────────────────────────────
250
+
251
+ _updateHUD() {
252
+ const ep = this.episode;
253
+ if (!ep) return;
254
+
255
+ // Health bar
256
+ const hPct = ep.agentHealth/100;
257
+ const hBar = document.getElementById('healthBar');
258
+ if (hBar) {
259
+ hBar.style.width=`${ep.agentHealth}%`;
260
+ hBar.className='hbar-fill ' + (hPct>0.6?'g':hPct>0.3?'m':'c');
261
+ }
262
+ const hVal = document.getElementById('healthVal');
263
+ if(hVal) hVal.textContent=`${Math.round(ep.agentHealth)}`;
264
+ const hStatus = document.getElementById('healthStatus');
265
+ if(hStatus) { hStatus.textContent=ep.healthStatus; hStatus.className='hstatus '+ep.healthStatus.toLowerCase(); }
266
+
267
+ // Step counter
268
+ const stepEl=document.getElementById('stepCount');
269
+ if(stepEl) stepEl.textContent=`${ep.stepCount} / ${ep.maxSteps}`;
270
+ const progBar=document.getElementById('stepBar');
271
+ if(progBar) progBar.style.width=`${100*ep.stepCount/ep.maxSteps}%`;
272
+
273
+ // Stats
274
+ setText('rewardVal', ep.totalReward.toFixed(2));
275
+ setText('burnPct', `${ep.percentBurned}%`);
276
+ setText('smokeVal', ep.smokeLevel);
277
+ const smokeTag=document.getElementById('smokeTag');
278
+ if(smokeTag) smokeTag.textContent=`smoke: ${ep.smokeLevel}`;
279
+ setText('windVal', ep.windDir);
280
+ setText('humidVal', `${Math.round(ep.humidity*100)}%`);
281
+ setText('spreadVal', ep.pSpread.toFixed(2));
282
+ const diffTag=document.getElementById('diffTag');
283
+ if(diffTag) {
284
+ let dt=`${ep.difficulty} · ${ep.templateName}`;
285
+ if(ep._fireDirection) dt+=` · fire ${ep._fireDirection}`;
286
+ diffTag.textContent=dt;
287
+ }
288
+
289
+ // Episode status banner
290
+ const banner=document.getElementById('epBanner');
291
+ if(banner){
292
+ if(ep.agentEvacuated){ banner.textContent='✓ EVACUATED'; banner.className='ok'; banner.style.display='flex'; }
293
+ else if(!ep.agentAlive){ banner.textContent='✗ INCAPACITATED'; banner.className='bad'; banner.style.display='flex'; }
294
+ else if(ep.stepCount>=ep.maxSteps){ banner.textContent='⏱ TIMEOUT'; banner.className='tmo'; banner.style.display='flex'; }
295
+ else banner.style.display='none';
296
+ }
297
+
298
+ // Smoke level indicator
299
+ const smokeInd=document.getElementById('smokeIndicator');
300
+ if(smokeInd){ smokeInd.className='smoke-indicator '+ep.smokeLevel; smokeInd.title=`Smoke: ${ep.smokeLevel}`; }
301
+
302
+ // Wind rose
303
+ if(this.windCanvas) this.renderer.drawWindRose(this.windCanvas, ep.windDir);
304
+ }
305
+
306
+ _updateCharts() {
307
+ const ep=this.episode;
308
+ if(!ep) return;
309
+ this.rewardChart.push(ep.totalReward);
310
+ setText('rewardLast', ep.lastReward>0?'+'+ep.lastReward.toFixed(2):ep.lastReward.toFixed(2));
311
+ this.rewardChart.draw();
312
+ const fs=ep.fireSizeHistory[ep.fireSizeHistory.length-1]||0;
313
+ this.fireSizeChart.push(fs);
314
+ setText('fireLast', fs);
315
+ this.fireSizeChart.draw();
316
+ const ac=document.getElementById('actionChart');
317
+ if(ac) drawActionHistogram(ac, ep.actionCounts);
318
+ }
319
+
320
+ _updateEventLog() {
321
+ const logEl=document.getElementById('eventLog');
322
+ if(!logEl||!this.episode) return;
323
+ const entries=this.episode.eventLog.slice(0,18);
324
+ logEl.innerHTML=entries.map(e=>`
325
+ <div class="erow${e.isAlert?' alarm':''}">
326
+ <span class="estep">${String(e.step).padStart(3,'0')}</span>
327
+ <span class="etext">${e.text}</span>
328
+ ${e.reward?`<span class="erwd ${e.reward>0?'p':'n'}">${e.reward>0?'+':''}${e.reward.toFixed(2)}</span>`:''}
329
+ </div>`).join('');
330
+ }
331
+
332
+ _showEpisodeEnd() {
333
+ const ep=this.episode;
334
+ const banner=document.getElementById('statusBanner');
335
+ if(!banner) return;
336
+ if(ep.agentEvacuated) {
337
+ banner.innerHTML=`✓ EVACUATED — ${ep.totalReward.toFixed(1)} pts in ${ep.stepCount} steps`;
338
+ banner.className='ok';
339
+ } else if(!ep.agentAlive) {
340
+ banner.innerHTML=`✗ INCAPACITATED — health depleted`;
341
+ banner.className='bad';
342
+ } else {
343
+ banner.innerHTML=`⏱ TIMEOUT — ${ep.stepCount} steps`;
344
+ banner.className='tmo';
345
+ }
346
+ banner.style.display='flex';
347
+ }
348
+
349
+ _updatePlayBtn() {
350
+ const btn=document.getElementById('playBtn');
351
+ if(btn){btn.textContent=this.playing?'⏸':'▶';btn.classList.toggle('play',!this.playing);}
352
+ }
353
+
354
+ // ── Render loop ────────────────────────────────────────────────────────────
355
+
356
+ _startRenderLoop() {
357
+ const loop=()=>{ this._render(); this.animFrame=requestAnimationFrame(loop); };
358
+ this.animFrame=requestAnimationFrame(loop);
359
+ }
360
+
361
+ _render() {
362
+ if (!this.episode) return;
363
+ this.renderer.frame(this.episode, 1);
364
+ // Periodic chart updates when paused (to keep time display fresh)
365
+ }
366
+
367
+ // ── Mode switching ─────────────────────────────────────────────────────────
368
+
369
+ async switchToLive() {
370
+ const ok=await this.liveClient.checkHealth();
371
+ const indicator=document.getElementById('liveIndicator');
372
+ if(!ok) {
373
+ if(indicator) { indicator.textContent='⚠ Server offline'; indicator.className='live-indicator offline'; }
374
+ showToast('Cannot reach server at localhost:8000 — staying in Demo mode');
375
+ return;
376
+ }
377
+ this.mode='live';
378
+ if(indicator){ indicator.textContent='● LIVE'; indicator.className='live-indicator online'; }
379
+ showToast('Connected to live server');
380
+ await this._resetLive();
381
+ this._startLiveLoop();
382
+ }
383
+
384
+ switchToDemo() {
385
+ this.mode='demo';
386
+ this._clearStepInterval();
387
+ const indicator=document.getElementById('liveIndicator');
388
+ if(indicator){ indicator.textContent='◉ DEMO'; indicator.className='live-indicator demo'; }
389
+ this.newEpisode();
390
+ }
391
+
392
+ async _resetLive() {
393
+ try {
394
+ const seed=parseInt(document.getElementById('seedInput')?.value)||null;
395
+ await this.liveClient.reset(this.difficulty, seed);
396
+ } catch(e){ console.warn('Live reset failed',e); }
397
+ }
398
+
399
+ async _startLiveLoop() {
400
+ const poll=async()=>{
401
+ if(this.mode!=='live') return;
402
+ try {
403
+ const scene=await this.liveClient.getScene();
404
+ this._applyLiveScene(scene);
405
+ } catch(e){ console.warn('Live poll failed',e); }
406
+ if(this.mode==='live') setTimeout(poll, 800);
407
+ };
408
+ poll();
409
+ }
410
+
411
+ _applyLiveScene(scene) {
412
+ if(!scene||!scene.graph) return;
413
+ const g=scene.graph, lb=scene.labels;
414
+ const w=g.width, h=g.height;
415
+ const n=w*h;
416
+ const cellGrid=new Array(n), fireGrid=new Float32Array(n), smokeGrid=new Float32Array(n);
417
+ const visibleCells=new Set();
418
+ for(let y=0;y<h;y++) for(let x=0;x<w;x++){
419
+ const cell=g.grid[y][x];
420
+ const i=y*w+x;
421
+ cellGrid[i]=cell[0]; fireGrid[i]=cell[1]; smokeGrid[i]=cell[2];
422
+ if(cell[4]>0.5) visibleCells.add(`${x},${y}`);
423
+ }
424
+ if(!this.episode||this.episode.w!==w){
425
+ this.episode=new PyreEpisode(0,42,'medium');
426
+ this.renderer.init(this.episode);
427
+ }
428
+ this.episode.cellGrid=cellGrid;
429
+ this.episode.fireGrid=fireGrid;
430
+ this.episode.smokeGrid=smokeGrid;
431
+ this.episode.visibleCells=visibleCells;
432
+ // Agent state
433
+ this.episode.agentX=lb.agent.x; this.episode.agentY=lb.agent.y;
434
+ this.episode.agentHealth=lb.agent.health;
435
+ this.episode.agentAlive=lb.agent.alive;
436
+ this.episode.agentEvacuated=lb.agent.evacuated;
437
+ // Episode metadata
438
+ this.episode.stepCount=lb.episode.step;
439
+ this.episode.maxSteps=lb.episode.max_steps;
440
+ this.episode.windDir=lb.episode.wind_dir;
441
+ this.episode.pSpread=lb.episode.fire_spread_rate;
442
+ this.episode.humidity=lb.episode.humidity;
443
+ this.episode.difficulty=lb.episode.difficulty||'medium';
444
+ this.episode.templateName=lb.episode.template||'—';
445
+ // Map
446
+ this.episode.exits=lb.map.exit_positions||this.episode.exits;
447
+ this.episode.doorRegistry=lb.map.door_registry||{};
448
+ // Narrative fields (agent perception)
449
+ this.episode._smokeLevel=lb.agent.smoke_level||'clear';
450
+ this.episode._fireDirection=lb.agent.fire_direction||null;
451
+ this.episode._locationLabel=lb.agent.location||'';
452
+ this.episode._healthStatus=lb.agent.health_status||'Good';
453
+ // Surroundings → inject into event log
454
+ if(lb.surroundings) {
455
+ const sur=lb.surroundings;
456
+ if(sur.audible_signals&&sur.audible_signals.length) {
457
+ const step=lb.episode.step;
458
+ const prev=this.episode.eventLog[0];
459
+ if(!prev||prev.step!==step||!prev._fromSurroundings) {
460
+ for(const sig of sur.audible_signals.slice(0,2)) {
461
+ this.episode.eventLog.unshift({step,text:`🔊 ${sig}`,reward:0,isAlert:true,_fromSurroundings:true});
462
+ }
463
+ if(this.episode.eventLog.length>50) this.episode.eventLog.length=50;
464
+ }
465
+ }
466
+ }
467
+ this.episode.w=w; this.episode.h=h;
468
+ this._updateHUD();
469
+ }
470
+
471
+ // ── Control binding ────────────────────────────────────────────────────────
472
+
473
+ _bindControls() {
474
+ document.getElementById('playBtn')?.addEventListener('click',()=>this.togglePlay());
475
+ document.getElementById('stepBtn')?.addEventListener('click',()=>this.step());
476
+ document.getElementById('resetBtn')?.addEventListener('click',()=>this.newEpisode());
477
+ document.getElementById('prevBtn')?.addEventListener('click',()=>{
478
+ this.templateIdx=((this.templateIdx-2)+3)%3;
479
+ this.newEpisode();
480
+ });
481
+
482
+ document.querySelectorAll('.speed-btn').forEach(b=>{
483
+ b.addEventListener('click',()=>this.setSpeed(parseFloat(b.dataset.speed)));
484
+ });
485
+
486
+ document.querySelectorAll('.diff-btn').forEach(b=>{
487
+ b.addEventListener('click',()=>{
488
+ this.difficulty=b.dataset.diff;
489
+ document.querySelectorAll('.diff-btn').forEach(x=>x.classList.toggle('active',x===b));
490
+ this.newEpisode();
491
+ });
492
+ });
493
+
494
+ document.querySelectorAll('.mode-btn').forEach(b=>{
495
+ b.addEventListener('click',()=>{
496
+ document.querySelectorAll('.mode-btn').forEach(x=>x.classList.remove('active','fire'));
497
+ b.classList.add('active');
498
+ if(b.dataset.mode==='live') this.switchToLive();
499
+ else this.switchToDemo();
500
+ });
501
+ });
502
+
503
+ document.getElementById('seedInput')?.addEventListener('change',()=>this.newEpisode());
504
+
505
+ // Keyboard shortcuts
506
+ window.addEventListener('keydown',e=>{
507
+ if(e.target.tagName==='INPUT') return;
508
+ if(e.code==='Space'){e.preventDefault();this.togglePlay();}
509
+ else if(e.code==='ArrowRight') this.step();
510
+ else if(e.code==='KeyR') this.newEpisode();
511
+ else if(e.code==='Digit1') this.setSpeed(0.5);
512
+ else if(e.code==='Digit2') this.setSpeed(1);
513
+ else if(e.code==='Digit3') this.setSpeed(2);
514
+ else if(e.code==='Digit4') this.setSpeed(4);
515
+ else if(e.code==='Digit5') this.setSpeed(8);
516
+ });
517
+ }
518
+ }
519
+
520
+ // ── Utilities ──────────────────────────────────────���───────────────────────
521
+
522
+ function setText(id, val) {
523
+ const el=document.getElementById(id);
524
+ if(el) el.textContent=val;
525
+ }
526
+
527
+ function showToast(msg) {
528
+ const t=document.getElementById('toast');
529
+ if(!t) return;
530
+ t.textContent=msg; t.classList.add('up');
531
+ setTimeout(()=>t.classList.remove('up'),3000);
532
+ }
533
+
534
+ // Boot
535
+ window.addEventListener('DOMContentLoaded',()=>{ window.app=new PyreApp(); });
frontend_new_desgin/js/renderer.js ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * renderer.js — Canvas2D rendering engine for Pyre visualization
3
+ * Overhauled: natural building materials, vibrant fire, rich atmospheric depth.
4
+ */
5
+
6
+ // ── Palette ──────────────────────────────────────────────────────────────────
7
+
8
+ const COLORS = {
9
+ bg: '#1e242c', // A dark slate/navy instead of pitch black
10
+ // Walls — warm concrete / painted interior
11
+ wallBase: '#4f443b',
12
+ wallHT: 'rgba(255,248,232,0.25)', // top catch-light
13
+ wallHL: 'rgba(255,248,232,0.15)', // left highlight
14
+ wallSB: 'rgba(0,0,0,0.60)', // bottom shadow
15
+ wallSR: 'rgba(0,0,0,0.45)', // right shadow
16
+ wallMortar: 'rgba(0,0,0,0.20)', // mortar / texture lines
17
+ // Floor — worn office tile
18
+ floorBase: '#261f1a',
19
+ floorVis: '#302620',
20
+ floorGrout: 'rgba(0,0,0,0.3)',
21
+ // Obstacle — charred ruin
22
+ obstBase: '#140d0a',
23
+ obstEmber: 'rgba(255,80,0,0.35)',
24
+ // Exit
25
+ exitBase: '#062210',
26
+ exitGlow: '#22c55e',
27
+ exitGlow2: '#4ade80',
28
+ // Door
29
+ doorWood: '#5c3621',
30
+ doorDark: '#331c0d',
31
+ doorFrame: '#291508',
32
+ doorPanel: 'rgba(0,0,0,0.35)',
33
+ doorGlow: '#7dd3fc',
34
+ // Agent
35
+ agentCore: '#0ea5e9',
36
+ agentGlow: '#38bdf8',
37
+ agentBright: '#e0f2fe',
38
+ // Fog
39
+ fogSeen: 'rgba(30,36,44,0.3)', // increased visibility
40
+ fogUnseen: 'rgba(20,24,30,0.95)', // darker, more contrast
41
+ // Smoke channels
42
+ smokeR: 200, smokeG: 210, smokeB: 220, // Bright, thick white/grey smoke
43
+ };
44
+
45
+ // Fire color ramp: black → deep red → orange → yellow → white
46
+ function fireColor(t, alpha=1) {
47
+ t = Math.min(1, Math.max(0, t));
48
+ let r,g,b;
49
+ if (t < 0.15) { const s=t/0.15; r=~~(98+88*s); g=~~(6*s); b=0; }
50
+ else if (t < 0.40) { const s=(t-0.15)/0.25; r=~~(186+60*s); g=~~(6+60*s); b=0; }
51
+ else if (t < 0.65) { const s=(t-0.40)/0.25; r=~~(246+9*s); g=~~(66+112*s); b=~~(9*s); }
52
+ else if (t < 0.85) { const s=(t-0.65)/0.20; r=255; g=~~(178+57*s); b=~~(9+32*s); }
53
+ else { const s=(t-0.85)/0.15; r=255; g=~~(235+20*s); b=~~(41+214*s); }
54
+ return `rgba(${r},${g},${b},${alpha})`;
55
+ }
56
+
57
+ function lerpColor(c1, c2, t) {
58
+ return [~~(c1[0]+(c2[0]-c1[0])*t), ~~(c1[1]+(c2[1]-c1[1])*t), ~~(c1[2]+(c2[2]-c1[2])*t)];
59
+ }
60
+
61
+ // ── Ember particle system ─────────────────────────────────────────────────────
62
+
63
+ class EmberSystem {
64
+ constructor(maxCount=220) {
65
+ this.particles = [];
66
+ this.maxCount = maxCount;
67
+ }
68
+
69
+ spawn(x, y, windX, windY, fireIntensity) {
70
+ if (this.particles.length >= this.maxCount) return;
71
+ if (Math.random() > fireIntensity * 0.48) return;
72
+ const speed = 0.4 + Math.random() * 1.0;
73
+ const angle = -Math.PI/2 + (Math.random()-0.5)*1.5;
74
+ this.particles.push({
75
+ x: x + (Math.random()-0.5)*3,
76
+ y: y + (Math.random()-0.5)*3,
77
+ vx: Math.cos(angle)*speed + windX*0.7,
78
+ vy: Math.sin(angle)*speed - 0.22,
79
+ life: 1.0,
80
+ decay: 0.010 + Math.random()*0.015,
81
+ size: 1.3 + Math.random()*2.4,
82
+ type: Math.random() > 0.4 ? 'ember' : 'spark',
83
+ });
84
+ }
85
+
86
+ update() {
87
+ for (let i=this.particles.length-1;i>=0;i--) {
88
+ const p=this.particles[i];
89
+ p.x += p.vx; p.y += p.vy;
90
+ p.vy -= 0.012;
91
+ p.vx *= 0.97;
92
+ p.life -= p.decay;
93
+ if (p.life <= 0) this.particles.splice(i,1);
94
+ }
95
+ }
96
+
97
+ draw(ctx) {
98
+ for (const p of this.particles) {
99
+ const a = p.life * 0.92;
100
+ const r = p.size * p.life;
101
+ ctx.beginPath();
102
+ ctx.arc(p.x, p.y, r, 0, Math.PI*2);
103
+ if (p.type === 'ember')
104
+ ctx.fillStyle=`rgba(255,${~~(80+100*p.life)},0,${a})`;
105
+ else
106
+ ctx.fillStyle=`rgba(255,${~~(210+45*p.life)},${~~(60*p.life)},${a})`;
107
+ ctx.fill();
108
+ }
109
+ }
110
+ }
111
+
112
+ // ── Smoke drift layer ─────────────────────────────────────────────────────────
113
+
114
+ class SmokeLayer {
115
+ constructor(w, h) {
116
+ this.w=w; this.h=h;
117
+ this.phases = new Float32Array(w*h).map(()=>Math.random()*Math.PI*2);
118
+ }
119
+ getOffset(x, y, time) {
120
+ const ph = this.phases[y*this.w+x];
121
+ return { dx: Math.sin(time*0.35+ph)*1.8, dy: Math.cos(time*0.28+ph+1.2)*1.8 };
122
+ }
123
+ }
124
+
125
+ // ── Main Renderer ─────────────────────────────────────────────────────────────
126
+
127
+ class PyreRenderer {
128
+ constructor(canvas) {
129
+ this.canvas = canvas;
130
+ this.ctx = canvas.getContext('2d');
131
+ this.time = 0;
132
+ this.embers = new EmberSystem(220);
133
+ this.agentTrail = [];
134
+ this.prevInterp = null;
135
+ this.smokeLayer = null;
136
+ this.exploreCanvas = null;
137
+ this.exploreCtx = null;
138
+ this._lastExploreSet = null;
139
+ this.cellSize = 0;
140
+ this.windVec = [0,0];
141
+ }
142
+
143
+ init(episode) {
144
+ const cs = Math.floor(this.canvas.width / episode.w);
145
+ this.cellSize = cs;
146
+ this.smokeLayer = new SmokeLayer(episode.w, episode.h);
147
+ this.exploreCanvas = document.createElement('canvas');
148
+ this.exploreCanvas.width = this.canvas.width;
149
+ this.exploreCanvas.height = this.canvas.height;
150
+ this.exploreCtx = this.exploreCanvas.getContext('2d');
151
+ this.exploreCtx.fillStyle = 'black';
152
+ this.exploreCtx.fillRect(0,0,this.exploreCanvas.width,this.exploreCanvas.height);
153
+ this.agentTrail = [];
154
+ this._lastExploreSet = new Set();
155
+ }
156
+
157
+ frame(episode, lerpT=1) {
158
+ this.time += 0.016;
159
+ const {w,h,cellGrid,fireGrid,smokeGrid,agentX,agentY,visibleCells,exploreSet} = episode;
160
+ const cs = this.cellSize;
161
+ this._w = w;
162
+ const ctx = this.ctx;
163
+ const wv = WIND_DIRS[episode.windDir]||[0,0];
164
+ this.windVec = wv;
165
+
166
+ // ── Update explore canvas ─────────────────────────────────────────────
167
+ if (exploreSet && exploreSet !== this._lastExploreSet) {
168
+ this._lastExploreSet = exploreSet;
169
+ for (const key of exploreSet) {
170
+ const [ex,ey] = key.split(',').map(Number);
171
+ this.exploreCtx.clearRect(ex*cs, ey*cs, cs, cs);
172
+ }
173
+ }
174
+
175
+ // ── Background ───────────────────────────────────────────────────────
176
+ ctx.fillStyle = COLORS.bg;
177
+ ctx.fillRect(0,0,this.canvas.width,this.canvas.height);
178
+
179
+ // ── Cell base layer ───────────────────────────────────────────────────
180
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
181
+ this._drawCellBase(ctx, x, y, cellGrid, cs);
182
+ }
183
+
184
+ // ── Fire ambient — warm glow radiating onto floor (normal blend) ──────
185
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
186
+ const i=y*w+x;
187
+ if (fireGrid[i] < 0.28) continue;
188
+ this._drawFireAmbient(ctx, x, y, fireGrid[i], cs);
189
+ }
190
+
191
+ // ── Fire glow (screen blend for natural layering) ─────────────────────
192
+ ctx.save();
193
+ ctx.globalCompositeOperation = 'screen';
194
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
195
+ const i=y*w+x;
196
+ const fire=fireGrid[i];
197
+ if (fire < 0.05) continue;
198
+ this._drawFireCell(ctx, x, y, fire, cs);
199
+ if (fire >= FIRE_BURNING && Math.random()<0.09) {
200
+ this.embers.spawn(x*cs+cs/2, y*cs+cs/2, wv[0], wv[1], fire);
201
+ }
202
+ }
203
+ ctx.restore();
204
+
205
+ // ── Smoke overlay ─────────────────────────────────────────────────────
206
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
207
+ const i=y*w+x;
208
+ const smoke=smokeGrid[i];
209
+ if (smoke < 0.08) continue;
210
+ this._drawSmoke(ctx, x, y, smoke, cs);
211
+ }
212
+
213
+ // ── Doors & exits ─────────────────────────────────────────────────────
214
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
215
+ const ct=cellGrid[y*w+x];
216
+ if (ct===EXIT) this._drawExit(ctx,x,y,cs,fireGrid[y*w+x]);
217
+ else if (ct===DOOR_OPEN||ct===DOOR_CLOSED) this._drawDoor(ctx,x,y,ct,cs);
218
+ }
219
+
220
+ // ── Fog of war ────────────────────────────────────────────────────────
221
+ if (visibleCells && episode.exploreSet) {
222
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
223
+ const key=`${x},${y}`;
224
+ if (visibleCells.has(key)) continue;
225
+ const explored = episode.exploreSet.has(key);
226
+ ctx.fillStyle = explored ? COLORS.fogSeen : COLORS.fogUnseen;
227
+ ctx.fillRect(x*cs, y*cs, cs, cs);
228
+ }
229
+ }
230
+
231
+ // ── Agent trail ───────────────────────────────────────────────────────
232
+ this._updateTrail(agentX, agentY, cs);
233
+ this._drawTrail(ctx, cs);
234
+
235
+ // ── Agent ─────────────────────────────────────────────────────────────
236
+ this._drawAgent(ctx, agentX, agentY, episode.agentHealth, episode.agentAlive, episode.agentEvacuated, cs);
237
+
238
+ // ── Embers (drawn above fog for dramatic effect) ──────────────────────
239
+ this.embers.update();
240
+ this.embers.draw(ctx);
241
+
242
+ // ── Tile grid overlay (very subtle) ───────────────────────────────────
243
+ ctx.strokeStyle='rgba(255,255,255,0.04)';
244
+ ctx.lineWidth=1;
245
+ for (let x=0;x<=w;x++) { ctx.beginPath(); ctx.moveTo(x*cs,0); ctx.lineTo(x*cs,h*cs); ctx.stroke(); }
246
+ for (let y=0;y<=h;y++) { ctx.beginPath(); ctx.moveTo(0,y*cs); ctx.lineTo(w*cs,y*cs); ctx.stroke(); }
247
+ }
248
+
249
+ // ── Cell base rendering ───────────────────────────────────────────────────
250
+
251
+ _drawCellBase(ctx, x, y, cellGrid, cs) {
252
+ const ct = cellGrid[y * (this._w||16) + x];
253
+ const px=x*cs, py=y*cs;
254
+
255
+ switch(ct) {
256
+ case WALL: {
257
+ const wallH = 8; // Extrude wall upward for 3D depth
258
+
259
+ // Front shadow face (gives depth)
260
+ ctx.fillStyle = COLORS.wallSB;
261
+ ctx.fillRect(px, py+cs-wallH, cs, wallH);
262
+
263
+ // Right side shadow face if applicable, but for simplicity, just a slight drop shadow
264
+ ctx.fillStyle = 'rgba(0,0,0,0.3)';
265
+ ctx.fillRect(px + cs, py - wallH + 4, 3, cs);
266
+
267
+ // Top face (shifted up by wallH)
268
+ ctx.fillStyle = COLORS.wallBase;
269
+ ctx.fillRect(px, py - wallH, cs, cs);
270
+
271
+ // Top face catch-light
272
+ ctx.fillStyle = COLORS.wallHT;
273
+ ctx.fillRect(px, py - wallH, cs, 3);
274
+
275
+ // Left highlight
276
+ ctx.fillStyle = COLORS.wallHL;
277
+ ctx.fillRect(px, py - wallH + 3, 2, cs - 3);
278
+
279
+ // Brick texture on the top face
280
+ ctx.fillStyle = COLORS.wallMortar;
281
+ ctx.fillRect(px+3, py - wallH + ~~(cs*0.34), cs-6, 1);
282
+ ctx.fillRect(px+3, py - wallH + ~~(cs*0.67), cs-6, 1);
283
+ ctx.fillRect(px+~~(cs*0.3), py - wallH + 3, 1, ~~(cs*0.34)-3);
284
+ ctx.fillRect(px+~~(cs*0.7), py - wallH + ~~(cs*0.34)+1, 1, ~~(cs*0.33));
285
+ ctx.fillRect(px+~~(cs*0.5), py - wallH + ~~(cs*0.67)+1, 1, ~~(cs*0.33)-3);
286
+ break;
287
+ }
288
+
289
+ case OBSTACLE: {
290
+ const obsH = 4; // Charred ruin slight 3D elevation
291
+
292
+ // Front shadow face
293
+ ctx.fillStyle = '#0a0705';
294
+ ctx.fillRect(px, py+cs-obsH, cs, obsH);
295
+
296
+ // Top face
297
+ ctx.fillStyle = COLORS.obstBase;
298
+ ctx.fillRect(px, py - obsH, cs, cs);
299
+
300
+ // Diagonal char texture
301
+ ctx.strokeStyle = 'rgba(0,0,0,0.6)';
302
+ ctx.lineWidth = 0.8;
303
+ for (let d=0; d<cs+cs; d+=5) {
304
+ ctx.beginPath();
305
+ ctx.moveTo(px+Math.max(0,d-cs), py - obsH + Math.min(d,cs));
306
+ ctx.lineTo(px+Math.min(d,cs), py - obsH + Math.max(0,d-cs));
307
+ ctx.stroke();
308
+ }
309
+
310
+ // Faint ember glow at edges
311
+ ctx.fillStyle = COLORS.obstEmber;
312
+ ctx.fillRect(px, py - obsH, cs, 1);
313
+ ctx.fillRect(px, py - obsH +cs-1, cs, 1);
314
+ ctx.fillRect(px, py - obsH +1, 1, cs-2);
315
+ ctx.fillRect(px+cs-1, py - obsH +1, 1, cs-2);
316
+ break;
317
+ }
318
+
319
+ case EXIT: {
320
+ ctx.fillStyle = COLORS.exitBase;
321
+ ctx.fillRect(px, py, cs, cs);
322
+ // Subtle green tint
323
+ ctx.fillStyle = 'rgba(34,197,94,0.07)';
324
+ ctx.fillRect(px+2, py+2, cs-4, cs-4);
325
+ break;
326
+ }
327
+
328
+ default: { // FLOOR, DOOR_OPEN, DOOR_CLOSED
329
+ ctx.fillStyle = COLORS.floorVis;
330
+ ctx.fillRect(px, py, cs, cs);
331
+
332
+ // Checkered tile effect
333
+ if ((x + y) % 2 === 0) {
334
+ ctx.fillStyle = 'rgba(255,248,230,0.035)';
335
+ ctx.fillRect(px, py, cs, cs);
336
+ }
337
+
338
+ // Tile grout lines (top + left edge per cell → creates tile grid)
339
+ ctx.fillStyle = COLORS.floorGrout;
340
+ ctx.fillRect(px, py, cs, 1);
341
+ ctx.fillRect(px, py+1, 1, cs-1);
342
+
343
+ // Inner tile details for realism
344
+ ctx.fillStyle = 'rgba(0,0,0,0.06)';
345
+ ctx.fillRect(px+2, py+2, cs-4, 2);
346
+ ctx.fillRect(px+2, py+2, 2, cs-4);
347
+ break;
348
+ }
349
+ }
350
+ }
351
+
352
+ // ── Fire rendering ────────────────────────────────────────────────────────
353
+
354
+ _drawFireAmbient(ctx, x, y, fire, cs) {
355
+ // Warm orange-red ambient glow radiating onto surrounding cells
356
+ const px=x*cs+cs/2, py=y*cs+cs/2;
357
+ const radius = cs * (2.8 + fire * 2.0); // much larger glow
358
+ const alpha = fire * 0.65; // much brighter glow
359
+ const gr = ctx.createRadialGradient(px,py,0,px,py,radius);
360
+ gr.addColorStop(0, `rgba(255,100,10,${alpha})`);
361
+ gr.addColorStop(0.45,`rgba(200,60,0,${alpha*0.5})`);
362
+ gr.addColorStop(1, 'rgba(0,0,0,0)');
363
+ ctx.fillStyle = gr;
364
+ ctx.fillRect(px-radius, py-radius, radius*2, radius*2);
365
+ }
366
+
367
+ _drawFireCell(ctx, x, y, fire, cs) {
368
+ const px=x*cs+cs/2, py=y*cs+cs/2;
369
+ const f1 = 0.82 + 0.18*Math.sin(this.time*9.5+x*2.7+y*3.14);
370
+ const eff = fire * f1;
371
+
372
+ // Wind offsets for volumetric flame leaning
373
+ const windDx = this.windVec ? this.windVec[0] * cs * 0.35 * eff : 0;
374
+ const windDy = this.windVec ? this.windVec[1] * cs * 0.35 * eff : -cs * 0.15; // default lean up
375
+
376
+ // Four layered volumetric gradients
377
+ const layers = [
378
+ {r:cs*0.35, aMax:0.98, ox: windDx*0.2, oy: windDy*0.2 - cs*0.1}, // bright core
379
+ {r:cs*0.65, aMax:0.75, ox: windDx*0.5, oy: windDy*0.5}, // inner flame
380
+ {r:cs*0.95, aMax:0.45, ox: windDx, oy: windDy}, // flame body
381
+ {r:cs*1.40, aMax:0.18, ox: windDx*1.5, oy: windDy*1.5}, // outer heat glow
382
+ ];
383
+ for (const {r,aMax,ox,oy} of layers) {
384
+ const cx = px + ox;
385
+ const cy = py + oy;
386
+ const gr = ctx.createRadialGradient(cx,cy,0,cx,cy,r);
387
+ gr.addColorStop(0, fireColor(eff, aMax));
388
+ gr.addColorStop(0.4, fireColor(eff*0.75, aMax*0.55));
389
+ gr.addColorStop(0.8, fireColor(eff*0.32, aMax*0.18));
390
+ gr.addColorStop(1, fireColor(eff*0.08, 0));
391
+ ctx.fillStyle = gr;
392
+ ctx.beginPath();
393
+ ctx.arc(cx, cy, r, 0, Math.PI*2);
394
+ ctx.fill();
395
+ }
396
+
397
+ // White-hot core for high intensity fire
398
+ if (eff > 0.48) {
399
+ const cx = px + windDx*0.1;
400
+ const cy = py + windDy*0.1 - cs*0.1;
401
+ const gr2 = ctx.createRadialGradient(cx,cy,0,cx,cy,cs*0.17);
402
+ gr2.addColorStop(0, `rgba(255,255,230,${(eff-0.48)*1.7})`);
403
+ gr2.addColorStop(1, 'rgba(255,210,50,0)');
404
+ ctx.fillStyle = gr2;
405
+ ctx.beginPath();
406
+ ctx.arc(cx, cy, cs*0.17, 0, Math.PI*2);
407
+ ctx.fill();
408
+ }
409
+ }
410
+
411
+ _drawSmoke(ctx, x, y, smoke, cs) {
412
+ const px=x*cs+cs/2, py=y*cs+cs/2;
413
+ const off = this.smokeLayer.getOffset(x,y,this.time);
414
+ const alpha = Math.min(0.85, smoke * 0.95);
415
+ const {smokeR:sr, smokeG:sg, smokeB:sb} = COLORS;
416
+
417
+ // Draw multiple overlapping soft circles to form a volumetric cloud
418
+ const cloudRadius = cs * 0.85; // overlaps neighboring cells
419
+
420
+ ctx.save();
421
+ // Shift slightly based on smoke layer offset for animation
422
+ ctx.translate(px + off.dx, py + off.dy);
423
+
424
+ // Base fluffy cloud layer
425
+ const gr = ctx.createRadialGradient(0, 0, 0, 0, 0, cloudRadius);
426
+ gr.addColorStop(0, `rgba(${sr},${sg},${sb},${alpha})`);
427
+ gr.addColorStop(0.5, `rgba(${sr},${sg},${sb},${alpha*0.75})`);
428
+ gr.addColorStop(1, `rgba(${sr},${sg},${sb},0)`);
429
+
430
+ ctx.fillStyle = gr;
431
+ ctx.beginPath();
432
+ ctx.arc(0, 0, cloudRadius, 0, Math.PI*2);
433
+ ctx.fill();
434
+
435
+ // Secondary offset puff for 3D depth and density
436
+ if (smoke > 0.3) {
437
+ const puffRadius = cs * 0.6;
438
+ const puffOffX = off.dy * 1.5; // orthogonal offset
439
+ const puffOffY = off.dx * 1.5;
440
+ const gr2 = ctx.createRadialGradient(puffOffX, puffOffY, 0, puffOffX, puffOffY, puffRadius);
441
+ gr2.addColorStop(0, `rgba(${sr+15},${sg+15},${sb+20},${alpha*0.5})`);
442
+ gr2.addColorStop(1, `rgba(${sr+15},${sg+15},${sb+20},0)`);
443
+
444
+ ctx.fillStyle = gr2;
445
+ ctx.beginPath();
446
+ ctx.arc(puffOffX, puffOffY, puffRadius, 0, Math.PI*2);
447
+ ctx.fill();
448
+ }
449
+
450
+ ctx.restore();
451
+ }
452
+
453
+ _drawExit(ctx, x, y, cs, fire) {
454
+ const px=x*cs, py=y*cs;
455
+ const pulse = 0.62 + 0.38*Math.sin(this.time*2.8 + x + y);
456
+ const blocked = fire >= EXIT_BLOCKED_FIRE_THRESHOLD;
457
+ const col1 = blocked ? '#ef4444' : COLORS.exitGlow;
458
+ const col2 = blocked ? '#dc2626' : COLORS.exitGlow2;
459
+
460
+ ctx.save();
461
+
462
+ // Outer glow ring
463
+ ctx.shadowBlur = cs * 1.1 * pulse;
464
+ ctx.shadowColor = col1;
465
+ ctx.strokeStyle = col1;
466
+ ctx.lineWidth = 2;
467
+ ctx.strokeRect(px+2, py+2, cs-4, cs-4);
468
+
469
+ // Interior tint
470
+ ctx.fillStyle = blocked ? 'rgba(220,38,38,0.11)' : 'rgba(34,197,94,0.11)';
471
+ ctx.fillRect(px+3, py+3, cs-6, cs-6);
472
+
473
+ // Center symbol
474
+ ctx.shadowBlur = 0;
475
+ const sa = 0.55 + 0.35*pulse;
476
+ ctx.fillStyle = blocked
477
+ ? `rgba(255,90,90,${sa})`
478
+ : `rgba(74,222,128,${sa})`;
479
+ ctx.font = `bold ${~~(cs*0.50)}px sans-serif`;
480
+ ctx.textAlign='center'; ctx.textBaseline='middle';
481
+ ctx.fillText(blocked ? '✕' : '⇥', px+cs/2, py+cs/2+1);
482
+
483
+ // Corner bracket accents
484
+ ctx.fillStyle = blocked ? 'rgba(248,113,113,0.75)' : 'rgba(74,222,128,0.75)';
485
+ const ca=5;
486
+ ctx.fillRect(px, py, ca, 1); ctx.fillRect(px, py, 1, ca);
487
+ ctx.fillRect(px+cs-ca,py, ca, 1); ctx.fillRect(px+cs-1,py,1,ca);
488
+ ctx.fillRect(px, py+cs-1, ca, 1); ctx.fillRect(px, py+cs-ca, 1, ca);
489
+ ctx.fillRect(px+cs-ca,py+cs-1, ca, 1); ctx.fillRect(px+cs-1,py+cs-ca,1,ca);
490
+
491
+ ctx.restore();
492
+ }
493
+
494
+ _drawDoor(ctx, x, y, ct, cs) {
495
+ const px=x*cs, py=y*cs;
496
+
497
+ if (ct === DOOR_OPEN) {
498
+ // Open doorway — dark passage with door pressed to side
499
+ ctx.fillStyle = 'rgba(0,0,0,0.38)';
500
+ ctx.fillRect(px+4, py, cs-6, cs);
501
+
502
+ // Door panel pushed to left wall
503
+ ctx.fillStyle = COLORS.doorWood;
504
+ ctx.fillRect(px, py, 4, cs);
505
+
506
+ // Door frame along top
507
+ ctx.fillStyle = COLORS.doorFrame;
508
+ ctx.fillRect(px, py, cs, 2);
509
+
510
+ // Faint blue passage glow
511
+ ctx.fillStyle = 'rgba(125,211,252,0.10)';
512
+ ctx.fillRect(px+5, py+2, cs-7, cs-4);
513
+ } else {
514
+ // Closed door — wood panel with visible detail
515
+ // Door body
516
+ ctx.fillStyle = COLORS.doorWood;
517
+ ctx.fillRect(px+2, py+1, cs-4, cs-2);
518
+
519
+ // Door frame
520
+ ctx.fillStyle = COLORS.doorFrame;
521
+ ctx.fillRect(px, py, cs, 2); // top rail
522
+ ctx.fillRect(px, py+cs-2, cs, 2); // bottom rail
523
+ ctx.fillRect(px, py+2, 2, cs-4); // left stile
524
+ ctx.fillRect(px+cs-2, py+2, 2, cs-4); // right stile
525
+
526
+ // Panel lines (3 horizontal panels)
527
+ ctx.fillStyle = COLORS.doorPanel;
528
+ ctx.fillRect(px+4, py+~~(cs*0.14), cs-8, 1);
529
+ ctx.fillRect(px+4, py+~~(cs*0.50), cs-8, 1);
530
+ ctx.fillRect(px+4, py+~~(cs*0.82), cs-8, 1);
531
+
532
+ // Panel inset shadow
533
+ ctx.fillStyle = 'rgba(0,0,0,0.13)';
534
+ ctx.fillRect(px+4, py+~~(cs*0.14)+2, cs-8, ~~(cs*0.34));
535
+ ctx.fillRect(px+4, py+~~(cs*0.50)+2, cs-8, ~~(cs*0.30));
536
+
537
+ // Doorknob — gold circle
538
+ ctx.beginPath();
539
+ ctx.arc(px+cs-8, py+~~(cs/2), 2.5, 0, Math.PI*2);
540
+ ctx.fillStyle = 'rgba(210,168,75,0.88)';
541
+ ctx.fill();
542
+ }
543
+ }
544
+
545
+ // ── Agent ─────────────────────────────────────────────────────────────────
546
+
547
+ _updateTrail(ax, ay, cs) {
548
+ const px=ax*cs+cs/2, py=ay*cs+cs/2;
549
+ if (!this.agentTrail.length
550
+ || Math.abs(this.agentTrail[0].px-px)>1
551
+ || Math.abs(this.agentTrail[0].py-py)>1) {
552
+ this.agentTrail.unshift({px,py,t:this.time});
553
+ }
554
+ while (this.agentTrail.length > 14) this.agentTrail.pop();
555
+ }
556
+
557
+ _drawTrail(ctx, cs) {
558
+ if (this.agentTrail.length < 2) return;
559
+ for (let i=1; i<this.agentTrail.length; i++) {
560
+ const alpha = (1-i/this.agentTrail.length)*0.42;
561
+ const r = Math.max(1.5, (1-i/this.agentTrail.length)*cs*0.22);
562
+ ctx.beginPath();
563
+ ctx.arc(this.agentTrail[i].px, this.agentTrail[i].py, r, 0, Math.PI*2);
564
+ ctx.fillStyle = `rgba(14,165,233,${alpha})`;
565
+ ctx.fill();
566
+ }
567
+ }
568
+
569
+ _drawAgent(ctx, ax, ay, health, alive, evacuated, cs) {
570
+ if (!alive && !evacuated) return;
571
+ const px=ax*cs+cs/2, py=ay*cs+cs/2;
572
+ const pulse = 0.82 + 0.18*Math.sin(this.time*3.6);
573
+ const pulse2 = 0.70 + 0.30*Math.sin(this.time*2.1+1.0);
574
+ const r = cs * 0.38 * pulse; // increased size for better visibility
575
+
576
+ const hRatio = Math.max(0, Math.min(1, health/100));
577
+ const agentColor = evacuated ? '#4ade80'
578
+ : hRatio > 0.6 ? COLORS.agentCore
579
+ : hRatio > 0.3 ? '#f59e0b'
580
+ : '#ef4444';
581
+
582
+ ctx.save();
583
+
584
+ // Outer aura pulse (very faint)
585
+ const auraR = cs * 0.74 * pulse2;
586
+ const auraGr = ctx.createRadialGradient(px,py,r*0.9,px,py,auraR);
587
+ auraGr.addColorStop(0, 'rgba(0,0,0,0)');
588
+ auraGr.addColorStop(0.55, evacuated ? 'rgba(74,222,128,0.09)' : 'rgba(56,189,248,0.09)');
589
+ auraGr.addColorStop(1, 'rgba(0,0,0,0)');
590
+ ctx.fillStyle = auraGr;
591
+ ctx.beginPath();
592
+ ctx.arc(px, py, auraR, 0, Math.PI*2);
593
+ ctx.fill();
594
+
595
+ // Drop shadow
596
+ ctx.fillStyle = 'rgba(0,0,0,0.32)';
597
+ ctx.beginPath();
598
+ ctx.ellipse(px+2, py+3, r*0.9, r*0.48, 0, 0, Math.PI*2);
599
+ ctx.fill();
600
+
601
+ // Glow
602
+ ctx.shadowBlur = cs * 0.90;
603
+ ctx.shadowColor = evacuated ? '#22c55e' : agentColor;
604
+
605
+ // Body — radial gradient for sphere illusion
606
+ ctx.beginPath();
607
+ ctx.arc(px, py, r, 0, Math.PI*2);
608
+ const bodyGr = ctx.createRadialGradient(px-r*0.28, py-r*0.28, 0, px, py, r);
609
+ bodyGr.addColorStop(0, '#ffffff');
610
+ bodyGr.addColorStop(0.32, COLORS.agentBright);
611
+ bodyGr.addColorStop(0.70, agentColor);
612
+ bodyGr.addColorStop(1, 'rgba(0,80,160,0.55)');
613
+ ctx.fillStyle = bodyGr;
614
+ ctx.fill();
615
+
616
+ // Outline
617
+ ctx.shadowBlur = 0;
618
+ ctx.strokeStyle = 'rgba(255,255,255,0.88)';
619
+ ctx.lineWidth = 1.5;
620
+ ctx.stroke();
621
+ ctx.restore();
622
+
623
+ // Health arc ring
624
+ ctx.save();
625
+ const arcR = r * 1.65;
626
+ // Track
627
+ ctx.beginPath();
628
+ ctx.arc(px, py, arcR, 0, Math.PI*2);
629
+ ctx.strokeStyle = 'rgba(255,255,255,0.07)';
630
+ ctx.lineWidth = 2.5;
631
+ ctx.stroke();
632
+ // Fill arc
633
+ if (hRatio > 0) {
634
+ ctx.beginPath();
635
+ ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + hRatio*Math.PI*2);
636
+ ctx.strokeStyle = hRatio>0.6?'#4ade80':hRatio>0.3?'#fbbf24':'#f87171'; // More vibrant health colors
637
+ ctx.lineWidth = 3.0; // Slightly thicker
638
+ ctx.lineCap = 'round';
639
+ ctx.stroke();
640
+
641
+ // Arc glow
642
+ ctx.shadowBlur = 8;
643
+ ctx.shadowColor = ctx.strokeStyle;
644
+ ctx.stroke();
645
+ }
646
+ ctx.restore();
647
+ }
648
+
649
+ // ── Wind rose ─────────────────────────────────────────────────────────────
650
+
651
+ drawWindRose(canvas2, windDir, speed=1) {
652
+ if (!canvas2) return;
653
+ const ctx=canvas2.getContext('2d');
654
+ const w=canvas2.width, h=canvas2.height, cx=w/2, cy=h/2;
655
+ ctx.clearRect(0,0,w,h);
656
+
657
+ const wv=WIND_DIRS[windDir]||[0,0];
658
+ const angle=Math.atan2(wv[1],wv[0]);
659
+ const isCalm=windDir==='CALM';
660
+
661
+ // Compass ring
662
+ ctx.beginPath();
663
+ ctx.arc(cx,cy,cx-4,0,Math.PI*2);
664
+ ctx.strokeStyle='rgba(120,140,220,0.28)';
665
+ ctx.lineWidth=1.5;
666
+ ctx.stroke();
667
+
668
+ // Cardinal labels
669
+ const labels=[['N',0,-1],['E',1,0],['S',0,1],['W',-1,0]];
670
+ ctx.font=`${w*0.16}px monospace`;
671
+ ctx.textAlign='center'; ctx.textBaseline='middle';
672
+ for (const [l,dx,dy] of labels) {
673
+ ctx.fillStyle='rgba(160,172,232,0.55)';
674
+ ctx.fillText(l, cx+dx*(cx-10), cy+dy*(cy-10));
675
+ }
676
+
677
+ if (!isCalm) {
678
+ const arrowLen=cx*0.60;
679
+ const ax2=cx+Math.cos(angle)*arrowLen, ay2=cy+Math.sin(angle)*arrowLen;
680
+ ctx.beginPath();
681
+ ctx.moveTo(cx,cy);
682
+ ctx.lineTo(ax2,ay2);
683
+ ctx.strokeStyle='rgba(251,191,36,0.95)';
684
+ ctx.lineWidth=2.5;
685
+ ctx.stroke();
686
+
687
+ const headLen=8, ha=0.40;
688
+ ctx.beginPath();
689
+ ctx.moveTo(ax2,ay2);
690
+ ctx.lineTo(ax2-headLen*Math.cos(angle-ha), ay2-headLen*Math.sin(angle-ha));
691
+ ctx.lineTo(ax2-headLen*Math.cos(angle+ha), ay2-headLen*Math.sin(angle+ha));
692
+ ctx.closePath();
693
+ ctx.fillStyle='rgba(251,191,36,0.95)';
694
+ ctx.fill();
695
+
696
+ ctx.font=`bold ${w*0.18}px monospace`;
697
+ ctx.fillStyle='rgba(255,210,80,0.95)';
698
+ ctx.textAlign='center'; ctx.textBaseline='middle';
699
+ ctx.fillText(windDir, cx, cy);
700
+ } else {
701
+ ctx.font=`${w*0.18}px monospace`;
702
+ ctx.fillStyle='rgba(160,182,232,0.75)';
703
+ ctx.textAlign='center'; ctx.textBaseline='middle';
704
+ ctx.fillText('CALM', cx, cy);
705
+ }
706
+ }
707
+ }
frontend_new_desgin/js/sim.js ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * sim.js — JavaScript port of pyre_env fire simulation
3
+ * Mirrors server/fire_sim.py and server/floor_plan.py exactly.
4
+ */
5
+
6
+ // Cell types
7
+ const FLOOR = 0, WALL = 1, DOOR_OPEN = 2, DOOR_CLOSED = 3, EXIT = 4, OBSTACLE = 5;
8
+
9
+ const FIRE_IGNITION = 0.1;
10
+ const FIRE_BURNING = 0.3;
11
+ const FIRE_INTENSITY_GAIN = 0.15;
12
+ const BURNOUT_TICKS = 5;
13
+ const DOOR_CLOSED_FIRE_FACTOR = 0.15;
14
+ const SMOKE_SPREAD_RATE = 0.20;
15
+ const SMOKE_DOOR_FACTOR = 0.4;
16
+ const SMOKE_DECAY = 0.02;
17
+ const EXIT_BLOCKED_FIRE_THRESHOLD = 0.5;
18
+
19
+ const WIND_DIRS = {
20
+ N:[0,-1], NE:[1,-1], E:[1,0], SE:[1,1],
21
+ S:[0,1], SW:[-1,1], W:[-1,0], NW:[-1,-1], CALM:[0,0]
22
+ };
23
+ const CARDINAL = [[0,-1],[0,1],[-1,0],[1,0]];
24
+
25
+ function windMult(dx, dy, wx, wy) {
26
+ if (wx===0&&wy===0) return 1.0;
27
+ const dot = dx*wx + dy*wy;
28
+ return dot>0 ? 2.0 : dot<0 ? 0.5 : 1.0;
29
+ }
30
+
31
+ // Seeded PRNG (mulberry32)
32
+ function makePRNG(seed) {
33
+ let s = seed >>> 0;
34
+ return function() {
35
+ s += 0x6D2B79F5;
36
+ let t = s;
37
+ t = Math.imul(t ^ t>>>15, t|1);
38
+ t ^= t + Math.imul(t^t>>>7, t|61);
39
+ return ((t^t>>>14)>>>0) / 4294967296;
40
+ };
41
+ }
42
+
43
+ class FireSim {
44
+ constructor(w, h, rng, pSpread=0.25, windDir='CALM', humidity=0.25, fuelMap=null, ventMap=null) {
45
+ this.w=w; this.h=h; this.rng=rng;
46
+ const wv = WIND_DIRS[windDir]||[0,0];
47
+ this.wx=wv[0]; this.wy=wv[1];
48
+ this.effectiveSpread = pSpread * Math.max(0, 1-humidity);
49
+ this.fuelMap=fuelMap; this.ventMap=ventMap;
50
+ }
51
+
52
+ step(cellGrid, fireGrid, smokeGrid, burnTimers) {
53
+ const {w, h} = this;
54
+ const burned = [];
55
+ const ignite = new Uint8Array(w*h);
56
+
57
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
58
+ const i=y*w+x;
59
+ if (fireGrid[i] < FIRE_BURNING) continue;
60
+ for (const [dx,dy] of CARDINAL) {
61
+ const nx=x+dx, ny=y+dy;
62
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
63
+ const ni=ny*w+nx, nct=cellGrid[ni];
64
+ if (nct===WALL||nct===OBSTACLE||fireGrid[ni]>0) continue;
65
+ let p = nct===DOOR_CLOSED ? this.effectiveSpread*DOOR_CLOSED_FIRE_FACTOR : this.effectiveSpread;
66
+ p *= windMult(dx,dy,this.wx,this.wy);
67
+ if (this.fuelMap) p *= this.fuelMap[ni];
68
+ if (this.rng() < Math.min(1,p)) ignite[ni]=1;
69
+ }
70
+ }
71
+
72
+ const newFire = fireGrid.slice();
73
+ const newTimers = burnTimers.slice();
74
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
75
+ const i=y*w+x, ct=cellGrid[i];
76
+ if (ct===WALL||ct===OBSTACLE) continue;
77
+ if (fireGrid[i]>0) {
78
+ let gain = FIRE_INTENSITY_GAIN;
79
+ if (this.fuelMap) gain*=this.fuelMap[i];
80
+ newFire[i] = Math.min(1, fireGrid[i]+gain);
81
+ if (fireGrid[i]>=FIRE_BURNING) newTimers[i]++;
82
+ if (newTimers[i]>=BURNOUT_TICKS && newFire[i]>=1.0) {
83
+ cellGrid[i]=OBSTACLE; newFire[i]=0; newTimers[i]=0;
84
+ burned.push([x,y]);
85
+ }
86
+ } else if (ignite[i]) {
87
+ newFire[i]=FIRE_IGNITION; newTimers[i]=0;
88
+ }
89
+ }
90
+ for (let i=0;i<fireGrid.length;i++){fireGrid[i]=newFire[i];burnTimers[i]=newTimers[i];}
91
+ this._spreadSmoke(cellGrid, fireGrid, smokeGrid);
92
+ return burned;
93
+ }
94
+
95
+ _spreadSmoke(cellGrid, fireGrid, smokeGrid) {
96
+ const {w,h}=this;
97
+ const ns=smokeGrid.slice();
98
+ for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
99
+ const i=y*w+x, ct=cellGrid[i];
100
+ if (ct===WALL||ct===OBSTACLE) continue;
101
+ if (fireGrid[i]>=FIRE_BURNING) ns[i]=Math.min(1, smokeGrid[i]+0.3);
102
+ for (const [dx,dy] of CARDINAL) {
103
+ const nx=x+dx, ny=y+dy;
104
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
105
+ const ni=ny*w+nx, nct=cellGrid[ni];
106
+ if (nct===WALL||nct===OBSTACLE) continue;
107
+ if (smokeGrid[i]>smokeGrid[ni]) {
108
+ const diff=smokeGrid[i]-smokeGrid[ni];
109
+ let rate=SMOKE_SPREAD_RATE;
110
+ if (nct===DOOR_CLOSED) rate*=SMOKE_DOOR_FACTOR;
111
+ ns[ni]=Math.min(1, ns[ni]+Math.min(diff*rate, diff*0.5));
112
+ }
113
+ }
114
+ const decay=this.ventMap?this.ventMap[i]:SMOKE_DECAY;
115
+ ns[i]=Math.max(0, ns[i]-decay);
116
+ }
117
+ for (let i=0;i<smokeGrid.length;i++) smokeGrid[i]=ns[i];
118
+ }
119
+ }
120
+
121
+ // ─── Floor plan templates (mirrors floor_plan.py) ───────────────────────────
122
+
123
+ function makeSmallOffice() {
124
+ const W=16, H=16;
125
+ const rows=[
126
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
127
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
128
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
129
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
130
+ [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
131
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
132
+ [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
133
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
134
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
135
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
136
+ [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
137
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
138
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
139
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
140
+ [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
141
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
142
+ ];
143
+ const grid=rows.flat();
144
+ return {
145
+ name:'small_office', w:W, h:H, grid,
146
+ exits:[[0,6],[15,8]],
147
+ doors:[[2,4],[6,4],[10,4],[14,4],[2,10],[6,10],[10,10],[14,10]],
148
+ agentSpawns: (() => {
149
+ const s=[];
150
+ for(let y=5;y<=9;y++) for(let x=1;x<=14;x++) if(grid[y*W+x]===0) s.push([x,y]);
151
+ return s;
152
+ })(),
153
+ zoneMap: buildZoneMap_SO(grid,W,H)
154
+ };
155
+ }
156
+
157
+ function buildZoneMap_SO(grid,W,H) {
158
+ const zm={};
159
+ for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
160
+ const ct=grid[y*W+x];
161
+ if(ct===0) zm[`${x},${y}`]=y<=4?'north_offices':y>=5&&y<=9?'main_corridor':'south_offices';
162
+ else if(ct===4) zm[`${x},${y}`]='exit';
163
+ }
164
+ return zm;
165
+ }
166
+
167
+ function makeOpenPlan() {
168
+ const W=16, H=16;
169
+ const rows=[
170
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
171
+ [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
172
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
173
+ [1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
174
+ [1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
175
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
176
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
177
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
178
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
179
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
180
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
181
+ [1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
182
+ [1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
183
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
184
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
185
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,4],
186
+ ];
187
+ const grid=rows.flat();
188
+ const agentSpawns=[];
189
+ for(let y=1;y<H-1;y++) for(let x=1;x<W-1;x++) if(grid[y*W+x]===FLOOR) agentSpawns.push([x,y]);
190
+ return {name:'open_plan',w:W,h:H,grid,exits:[[0,1],[15,15]],doors:[],agentSpawns};
191
+ }
192
+
193
+ function makeTCorridor() {
194
+ const W=16, H=16;
195
+ const rows=[
196
+ [1,1,1,1,1,1,1,4,1,1,1,1,1,1,1,1],
197
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
198
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
199
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
200
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
201
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
202
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
203
+ [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
204
+ [1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
205
+ [1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
206
+ [1,1,2,1,1,2,1,0,1,2,1,1,1,1,2,1],
207
+ [1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
208
+ [1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
209
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
210
+ [1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
211
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
212
+ ];
213
+ const grid=rows.flat();
214
+ const agentSpawns=[];
215
+ for(let x=1;x<W-1;x++) if(grid[7*W+x]===FLOOR) agentSpawns.push([x,7]);
216
+ for(let y=4;y<8;y++) if(grid[y*W+7]===FLOOR) agentSpawns.push([7,y]);
217
+ return {name:'t_corridor',w:W,h:H,grid,exits:[[7,0],[0,7],[15,7]],doors:[[2,10],[5,10],[9,10],[14,10]],agentSpawns};
218
+ }
219
+
220
+ const TEMPLATES = [makeSmallOffice(), makeOpenPlan(), makeTCorridor()];
221
+
222
+ // ─── BFS exit distance ──────────────────────────────────────────────────────
223
+
224
+ function bfsExitDist(ax, ay, exits, cellGrid, w, h) {
225
+ const blocked = exits.filter(([ex,ey])=>cellGrid[ey*w+ex]===OBSTACLE);
226
+ const targets = exits.filter(([ex,ey])=>cellGrid[ey*w+ex]!==OBSTACLE);
227
+ if (!targets.length) return 999;
228
+ const visited = new Set([`${ax},${ay}`]);
229
+ const queue = [[ax,ay,0]];
230
+ while (queue.length) {
231
+ const [x,y,d]=queue.shift();
232
+ for (const [dx,dy] of CARDINAL) {
233
+ const nx=x+dx, ny=y+dy;
234
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
235
+ const key=`${nx},${ny}`;
236
+ if (visited.has(key)) continue;
237
+ const ct=cellGrid[ny*w+nx];
238
+ if (ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
239
+ if (targets.some(([ex,ey])=>ex===nx&&ey===ny)) return d+1;
240
+ visited.add(key);
241
+ queue.push([nx,ny,d+1]);
242
+ }
243
+ }
244
+ return 999;
245
+ }
246
+
247
+ // ─── Fog of war (BFS, visibility radius shrinks under smoke) ────────────────
248
+
249
+ function computeVisible(ax, ay, cellGrid, smokeGrid, w, h) {
250
+ const smokeDensity = smokeGrid[ay*w+ax];
251
+ const radius = smokeDensity>0.5 ? 2 : smokeDensity>0.2 ? 3 : 5;
252
+ const visible = new Set([`${ax},${ay}`]);
253
+ const queue = [[ax,ay,0]];
254
+ const visited = new Set([`${ax},${ay}`]);
255
+ while (queue.length) {
256
+ const [x,y,d]=queue.shift();
257
+ if (d>=radius) continue;
258
+ for (const [dx,dy] of CARDINAL) {
259
+ const nx=x+dx, ny=y+dy;
260
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
261
+ const key=`${nx},${ny}`;
262
+ if (visited.has(key)) continue;
263
+ visited.add(key);
264
+ const ct=cellGrid[ny*w+nx];
265
+ if (ct===WALL) continue;
266
+ visible.add(key);
267
+ queue.push([nx,ny,d+1]);
268
+ }
269
+ }
270
+ return visible;
271
+ }
272
+
273
+ // ─── Simple BFS demo agent ──────────────────────────────────────────────────
274
+
275
+ class DemoAgent {
276
+ constructor() { this.lastDir = null; this.stuckCount = 0; }
277
+
278
+ chooseAction(state) {
279
+ const { agentX:ax, agentY:ay, cellGrid, fireGrid, smokeGrid, exits, w, h } = state;
280
+ const dirs = ['north','south','east','west'];
281
+ const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
282
+
283
+ // Find best direction toward nearest exit
284
+ let bestDir = null, bestDist = Infinity;
285
+ for (const dir of dirs) {
286
+ const [dx,dy]=deltas[dir];
287
+ const nx=ax+dx, ny=ay+dy;
288
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
289
+ const ct=cellGrid[ny*w+nx];
290
+ if (ct===WALL||ct===OBSTACLE) continue;
291
+ if (ct===DOOR_CLOSED) {
292
+ // Try to open door if fire is not adjacent
293
+ return {action:'door', target_id:this._findDoor(state,nx,ny), door_state:'open'};
294
+ }
295
+ const fire=fireGrid[ny*w+nx];
296
+ if (fire>0.4) continue; // avoid fire
297
+ const d = bfsExitDist(nx,ny,exits,cellGrid,w,h);
298
+ if (d<bestDist) { bestDist=d; bestDir=dir; }
299
+ }
300
+
301
+ if (!bestDir || Math.random()<0.15) {
302
+ // Random valid move
303
+ const shuffled=[...dirs].sort(()=>Math.random()-0.5);
304
+ for (const dir of shuffled) {
305
+ const [dx,dy]=deltas[dir];
306
+ const nx=ax+dx, ny=ay+dy;
307
+ if (nx<0||nx>=w||ny<0||ny>=h) continue;
308
+ const ct=cellGrid[ny*w+nx];
309
+ if (ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED&&fireGrid[ny*w+nx]<0.4) {
310
+ return {action:'move',direction:dir};
311
+ }
312
+ }
313
+ }
314
+ return bestDir ? {action:'move',direction:bestDir} : {action:'wait'};
315
+ }
316
+
317
+ _findDoor(state, dx, dy) {
318
+ for (const [id,[x,y]] of Object.entries(state.doorRegistry||{})) {
319
+ if (x===dx&&y===dy) return id;
320
+ }
321
+ return `door_1`;
322
+ }
323
+ }
324
+
325
+ // ─── Episode state machine ───────────────────────────────────────────────────
326
+
327
+ class PyreEpisode {
328
+ constructor(templateIdx=0, seed=42, difficulty='medium') {
329
+ const presets = {
330
+ easy: {nSources:[1,1], pSpread:[0.10,0.20], humidity:[0.30,0.50], winds:['CALM'], maxSteps:200},
331
+ medium: {nSources:[2,4], pSpread:[0.15,0.40], humidity:[0.10,0.45], winds:Object.keys(WIND_DIRS), maxSteps:150},
332
+ hard: {nSources:[3,5], pSpread:[0.30,0.55], humidity:[0.05,0.20], winds:Object.keys(WIND_DIRS).filter(d=>d!=='CALM'), maxSteps:100},
333
+ };
334
+ const p = presets[difficulty]||presets.medium;
335
+ const rng = makePRNG(seed);
336
+ const tpl = TEMPLATES[templateIdx % TEMPLATES.length];
337
+
338
+ // Deep-copy grid
339
+ this.cellGrid = tpl.grid.slice();
340
+ this.w = tpl.w; this.h = tpl.h;
341
+ this.exits = tpl.exits.map(e=>[...e]);
342
+ this.doors = (tpl.doors||[]).map(d=>[...d]);
343
+ this.doorRegistry = {};
344
+ for (let j=0;j<this.doors.length;j++) {
345
+ this.doorRegistry[`door_${j+1}`]=this.doors[j];
346
+ if (rng()<0.3) this.cellGrid[this.doors[j][1]*this.w+this.doors[j][0]]=DOOR_CLOSED;
347
+ }
348
+
349
+ // Fire params
350
+ const [ns0,ns1]=p.nSources, [sp0,sp1]=p.pSpread, [hm0,hm1]=p.humidity;
351
+ const nSrc = Math.round(ns0 + rng()*(ns1-ns0));
352
+ const pSpread = sp0 + rng()*(sp1-sp0);
353
+ const humidity = hm0 + rng()*(hm1-hm0);
354
+ const windDir = p.winds[Math.floor(rng()*p.winds.length)];
355
+
356
+ this.pSpread = pSpread;
357
+ this.humidity = humidity;
358
+ this.windDir = windDir;
359
+ this.maxSteps = p.maxSteps;
360
+ this.templateName = tpl.name;
361
+ this.difficulty = difficulty;
362
+ this.seed = seed;
363
+
364
+ // Fire grids
365
+ this.fireGrid = new Float32Array(this.w*this.h);
366
+ this.smokeGrid = new Float32Array(this.w*this.h);
367
+ this.burnTimers = new Int32Array(this.w*this.h);
368
+
369
+ // Place fire sources
370
+ const floorCells = [];
371
+ for (let y=0;y<this.h;y++) for (let x=0;x<this.w;x++) {
372
+ if (this.cellGrid[y*this.w+x]===FLOOR) {
373
+ const farFromExit = this.exits.every(([ex,ey])=>Math.abs(x-ex)+Math.abs(y-ey)>=5);
374
+ if (farFromExit) floorCells.push([x,y]);
375
+ }
376
+ }
377
+ floorCells.sort(()=>rng()-0.5);
378
+
379
+ // Agent spawn
380
+ const spawn = tpl.agentSpawns[Math.floor(rng()*tpl.agentSpawns.length)];
381
+ this.agentX = spawn[0]; this.agentY = spawn[1];
382
+ this.agentHealth = 100.0;
383
+ this.agentAlive = true;
384
+ this.agentEvacuated = false;
385
+ this.stepCount = 0;
386
+
387
+ // Place fires far from agent
388
+ const fireSources = floorCells.filter(([fx,fy])=>Math.abs(fx-this.agentX)+Math.abs(fy-this.agentY)>=4).slice(0,nSrc);
389
+ for (const [fx,fy] of fireSources) this.fireGrid[fy*this.w+fx]=FIRE_IGNITION;
390
+
391
+ // Fire sim
392
+ this.sim = new FireSim(this.w, this.h, makePRNG(seed+1), pSpread, windDir, humidity);
393
+
394
+ // Tracking
395
+ this.visibleCells = new Set();
396
+ this.exploreSet = new Set();
397
+ this._updateVisibility();
398
+
399
+ // Reward tracking
400
+ this.totalReward = 0;
401
+ this.rewardHistory = [];
402
+ this.fireSizeHistory = [];
403
+ this.actionCounts = {move:0, door:0, look:0, wait:0};
404
+ this.eventLog = [];
405
+ this.lastAction = null;
406
+ this.lastReward = 0;
407
+ }
408
+
409
+ _updateVisibility() {
410
+ this.visibleCells = computeVisible(this.agentX, this.agentY, this.cellGrid, this.smokeGrid, this.w, this.h);
411
+ if (!this.exploreSet) this.exploreSet = new Set();
412
+ for (const k of this.visibleCells) this.exploreSet.add(k);
413
+ }
414
+
415
+ _fireSize() {
416
+ let count=0;
417
+ for (let i=0;i<this.fireGrid.length;i++) if(this.fireGrid[i]>=FIRE_BURNING) count++;
418
+ return count;
419
+ }
420
+
421
+ step(action) {
422
+ if (!this.agentAlive || this.agentEvacuated || this.stepCount>=this.maxSteps) return false;
423
+
424
+ const prevX=this.agentX, prevY=this.agentY;
425
+ let feedback = '';
426
+
427
+ // Execute action
428
+ const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
429
+ if (action.action==='move') {
430
+ const [dx,dy]=deltas[action.direction]||[0,0];
431
+ const nx=this.agentX+dx, ny=this.agentY+dy;
432
+ if (nx>=0&&nx<this.w&&ny>=0&&ny<this.h) {
433
+ const ct=this.cellGrid[ny*this.w+nx];
434
+ if (ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED) {
435
+ this.agentX=nx; this.agentY=ny;
436
+ feedback=`Moved ${action.direction}`;
437
+ } else feedback=`Blocked: ${ct===DOOR_CLOSED?'door closed':'wall'}`;
438
+ }
439
+ this.actionCounts.move++;
440
+ } else if (action.action==='door') {
441
+ const doorPos = this.doorRegistry[action.target_id];
442
+ if (doorPos) {
443
+ const [dx,dy]=doorPos;
444
+ if (Math.abs(dx-this.agentX)+Math.abs(dy-this.agentY)<=2) {
445
+ const cur=this.cellGrid[dy*this.w+dx];
446
+ if (action.door_state==='open'&&cur===DOOR_CLOSED) {
447
+ this.cellGrid[dy*this.w+dx]=DOOR_OPEN;
448
+ feedback=`Opened ${action.target_id}`;
449
+ } else if (action.door_state==='close'&&cur===DOOR_OPEN) {
450
+ this.cellGrid[dy*this.w+dx]=DOOR_CLOSED;
451
+ feedback=`Closed ${action.target_id} — slowing fire`;
452
+ }
453
+ }
454
+ }
455
+ this.actionCounts.door++;
456
+ } else if (action.action==='wait') {
457
+ feedback='Waiting...'; this.actionCounts.wait++;
458
+ } else if (action.action==='look') {
459
+ feedback=`Looking ${action.direction||''}`; this.actionCounts.look++;
460
+ }
461
+
462
+ // Check evacuation
463
+ const agentCell=this.cellGrid[this.agentY*this.w+this.agentX];
464
+ if (agentCell===EXIT && this.fireGrid[this.agentY*this.w+this.agentX]<EXIT_BLOCKED_FIRE_THRESHOLD) {
465
+ this.agentEvacuated=true;
466
+ feedback='EVACUATED — reached safety!';
467
+ }
468
+
469
+ // Advance fire
470
+ const burned = this.sim.step(this.cellGrid, this.fireGrid, this.smokeGrid, this.burnTimers);
471
+
472
+ // Health damage
473
+ const ai=this.agentY*this.w+this.agentX;
474
+ const smoke=this.smokeGrid[ai], fire=this.fireGrid[ai];
475
+ let dmg=0;
476
+ if (smoke>=0.8) dmg+=5; else if (smoke>=0.5) dmg+=2; else if (smoke>=0.2) dmg+=0.5;
477
+ if (fire>=FIRE_BURNING) dmg+=10;
478
+ this.agentHealth=Math.max(0, this.agentHealth-dmg);
479
+ if (this.agentHealth<=0) { this.agentAlive=false; feedback='Incapacitated by fire and smoke!'; }
480
+
481
+ this.stepCount++;
482
+ this._updateVisibility();
483
+
484
+ // Reward (simplified rubric)
485
+ const prevDist=bfsExitDist(prevX,prevY,this.exits,this.cellGrid,this.w,this.h);
486
+ const curDist=bfsExitDist(this.agentX,this.agentY,this.exits,this.cellGrid,this.w,this.h);
487
+ let reward=-0.01;
488
+ if (curDist<prevDist) reward+=0.1;
489
+ if (smoke>=0.5||fire>=FIRE_BURNING) reward-=0.5;
490
+ reward-=0.02*dmg;
491
+ if (this.agentEvacuated) reward+=5.0+(0.05*Math.max(0,this.maxSteps-this.stepCount));
492
+ if (!this.agentAlive) reward-=10.0;
493
+ if (action.action==='door'&&action.door_state==='close') reward+=0.5;
494
+
495
+ this.lastReward=Math.round(reward*1000)/1000;
496
+ this.totalReward=Math.round((this.totalReward+reward)*1000)/1000;
497
+ this.rewardHistory.push(this.totalReward);
498
+ this.fireSizeHistory.push(this._fireSize());
499
+
500
+ if (feedback) this.eventLog.unshift({step:this.stepCount, text:feedback, reward:this.lastReward});
501
+ if (this.eventLog.length>50) this.eventLog.pop();
502
+ if (burned.length) this.eventLog.unshift({step:this.stepCount, text:`${burned.length} cell(s) burned out`, reward:0, isAlert:true});
503
+
504
+ this.lastAction = action;
505
+ return !this.agentEvacuated && this.agentAlive && this.stepCount<this.maxSteps;
506
+ }
507
+
508
+ get done() { return !this.agentAlive||this.agentEvacuated||this.stepCount>=this.maxSteps; }
509
+ get percentBurned() { return Math.round(100*Array.from(this.fireGrid).filter(f=>f>0).length/(this.w*this.h)); }
510
+ get exitBlocked() { return this.exits.filter(([ex,ey])=>this.fireGrid[ey*this.w+ex]>=EXIT_BLOCKED_FIRE_THRESHOLD); }
511
+ get smokeLevel() {
512
+ const s=this.smokeGrid[this.agentY*this.w+this.agentX];
513
+ return s>=0.8?'heavy':s>=0.5?'moderate':s>=0.2?'light':'clear';
514
+ }
515
+ get healthStatus() {
516
+ if(this.agentHealth>75) return 'Good';
517
+ if(this.agentHealth>50) return 'Moderate';
518
+ if(this.agentHealth>25) return 'Low';
519
+ return 'Critical';
520
+ }
521
+ }
openenv_pyre_env.egg-info/PKG-INFO CHANGED
@@ -13,3 +13,15 @@ Requires-Dist: langchain-openai>=1.2.1
13
  Provides-Extra: dev
14
  Requires-Dist: pytest>=8.0.0; extra == "dev"
15
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  Provides-Extra: dev
14
  Requires-Dist: pytest>=8.0.0; extra == "dev"
15
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
16
+ Provides-Extra: train
17
+ Requires-Dist: datasets>=4.8.4; extra == "train"
18
+ Requires-Dist: peft>=0.15.2; extra == "train"
19
+ Requires-Dist: tensorboard>=2.20.0; extra == "train"
20
+ Requires-Dist: torch>=2.9.0; extra == "train"
21
+ Requires-Dist: transformers>=4.57.6; extra == "train"
22
+ Requires-Dist: trl>=1.2.0; extra == "train"
23
+ Requires-Dist: tornado>=6.5.5; extra == "train"
24
+ Requires-Dist: vllm<=0.18.0,>=0.11.0; extra == "train"
25
+ Requires-Dist: flashinfer-python==0.6.3; extra == "train"
26
+ Requires-Dist: flashinfer-cubin==0.6.3; extra == "train"
27
+ Requires-Dist: jupyter>=1.1.1; extra == "train"
openenv_pyre_env.egg-info/SOURCES.txt CHANGED
@@ -1,8 +1,10 @@
1
  README.md
2
  __init__.py
3
  client.py
 
4
  models.py
5
  pyproject.toml
 
6
  ./__init__.py
7
  ./client.py
8
  ./evals.py
 
1
  README.md
2
  __init__.py
3
  client.py
4
+ evals.py
5
  models.py
6
  pyproject.toml
7
+ train_grpo_openenv.py
8
  ./__init__.py
9
  ./client.py
10
  ./evals.py
openenv_pyre_env.egg-info/requires.txt CHANGED
@@ -9,3 +9,16 @@ langchain-openai>=1.2.1
9
  [dev]
10
  pytest>=8.0.0
11
  pytest-cov>=4.0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  [dev]
10
  pytest>=8.0.0
11
  pytest-cov>=4.0.0
12
+
13
+ [train]
14
+ datasets>=4.8.4
15
+ peft>=0.15.2
16
+ tensorboard>=2.20.0
17
+ torch>=2.9.0
18
+ transformers>=4.57.6
19
+ trl>=1.2.0
20
+ tornado>=6.5.5
21
+ vllm<=0.18.0,>=0.11.0
22
+ flashinfer-python==0.6.3
23
+ flashinfer-cubin==0.6.3
24
+ jupyter>=1.1.1
pyproject.toml CHANGED
@@ -28,6 +28,19 @@ dev = [
28
  "pytest>=8.0.0",
29
  "pytest-cov>=4.0.0",
30
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  [project.scripts]
33
  # Server entry point - enables running via: uv run --project . server
 
28
  "pytest>=8.0.0",
29
  "pytest-cov>=4.0.0",
30
  ]
31
+ train = [
32
+ "datasets>=4.8.4",
33
+ "peft>=0.15.2",
34
+ "tensorboard>=2.20.0",
35
+ "torch>=2.9.0",
36
+ "transformers>=4.57.6",
37
+ "trl>=1.2.0",
38
+ "tornado>=6.5.5",
39
+ "vllm>=0.11.0,<=0.18.0",
40
+ "flashinfer-python==0.6.3",
41
+ "flashinfer-cubin==0.6.3",
42
+ "jupyter>=1.1.1",
43
+ ]
44
 
45
  [project.scripts]
46
  # Server entry point - enables running via: uv run --project . server
server/static/assets/index-8TVqpTW-.css ADDED
@@ -0,0 +1 @@
 
 
1
+ *,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#f5f2ed;--s1:#fff;--s2:#faf9f6;--s3:#f0ede8;--bd:#e4dfd7;--bd2:#cdc7be;--t1:#1c1712;--t2:#6b6460;--t3:#a8a29e;--fire:#c2410c;--fire2:#c2410c14;--blue:#1d4ed8;--blue2:#1d4ed814;--green:#166534;--green2:#16653414;--amber:#92400e;--amber2:#92400e14;--red:#b91c1c;--red2:#b91c1c14;--gold:#d97706;--gold2:#d977061f;--mono:"DM Mono", "JetBrains Mono", monospace;--sans:"DM Sans", "Inter", system-ui, sans-serif;--r:8px}body{background:var(--bg);color:var(--t1);font-family:var(--sans);font-size:13px;line-height:1.5;overflow:hidden}.shell{grid-template-rows:48px 1fr;height:100vh;display:grid;overflow:hidden}.topbar{background:var(--s1);border-bottom:1px solid var(--bd);z-index:20;align-items:center;gap:16px;padding:0 20px;display:flex;box-shadow:0 1px 3px #0000000a}.brand{align-items:center;gap:10px;display:flex}.brand-icon{background:linear-gradient(135deg,#c2410c,#f97316);border-radius:6px;justify-content:center;align-items:center;width:28px;height:28px;font-size:14px;display:flex}.brand-name{font-family:var(--mono);color:var(--t1);letter-spacing:.06em;text-transform:uppercase;font-size:15px;font-weight:500}.brand-sep{color:var(--bd2);margin:0 2px}.brand-sub{color:var(--t3);letter-spacing:.08em;font-size:11px;font-family:var(--mono)}.topbar-right{align-items:center;gap:12px;margin-left:auto;display:flex}.live-chip{font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--bd);color:var(--t3);background:var(--s2);border-radius:20px;align-items:center;gap:5px;padding:4px 10px;font-size:10px;font-weight:500;display:flex}.live-chip:before{content:"";background:var(--bd2);border-radius:50%;width:6px;height:6px}.live-chip.online{color:var(--green);background:var(--green2);border-color:#16653433}.live-chip.online:before{background:#22c55e;animation:1.2s infinite blink}.live-chip.offline{color:var(--t3);background:var(--s2)}.live-chip.error{color:var(--red);background:var(--red2);border-color:#b91c1c33}.live-chip.error:before{background:var(--red)}@keyframes blink{0%,to{opacity:1}50%{opacity:.3}}.topbar-ep{font-family:var(--mono);color:var(--t3);align-items:center;gap:6px;font-size:10px;display:flex}.topbar-ep .ep-id{color:var(--blue);font-weight:500}.content{grid-template-columns:1fr 300px;height:100%;display:grid;overflow:hidden}.canvas-zone{background:var(--bg);flex-direction:column;justify-content:flex-start;align-items:center;gap:16px;padding:20px;display:flex;overflow-y:auto}.canvas-frame{box-shadow:0 0 0 1px var(--bd2), 0 4px 24px #0000001a, 0 1px 4px #0000000f;background:#d4c9a8;border-radius:10px;flex-shrink:0;line-height:0;position:relative;overflow:hidden}#map-canvas{max-width:100%;max-height:calc(100vh - 168px);image-rendering:pixelated;display:block}.hud-overlay{pointer-events:none;z-index:10;justify-content:space-between;gap:8px;padding:10px;display:flex;position:absolute;top:0;left:0;right:0}.hud-card{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#ede6dc;background:#0c0805d1;border:1px solid #ffffff1a;border-radius:6px;flex-direction:column;gap:3px;min-width:130px;padding:7px 10px;display:flex}.hud-card-center{border-color:#fbbf242e;min-width:160px}.hud-r{align-items:center;gap:6px;display:flex}.hlbl{font-family:var(--mono);color:#a8a29eb3;letter-spacing:.1em;text-transform:uppercase;flex-shrink:0;font-size:9px}.hval{font-family:var(--mono);color:#f0e8e0;text-align:right;min-width:26px;margin-left:auto;font-size:13px;font-weight:500}.hbar-bg{background:#ffffff1a;border-radius:3px;flex:1;height:5px;overflow:hidden}.hbar-fill{border-radius:3px;height:100%;transition:width .35s}.hbar-fill.g{background:linear-gradient(90deg,#22c55e,#4ade80)}.hbar-fill.m{background:linear-gradient(90deg,#f59e0b,#fbbf24)}.hbar-fill.c{background:linear-gradient(90deg,#ef4444,#f87171)}.hstatus{font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;border-radius:3px;padding:2px 6px;font-size:9px;font-weight:500}.hstatus.good{color:#4ade80;background:#4ade801f}.hstatus.moderate{color:#fbbf24;background:#fbbf241f}.hstatus.low{color:#fb923c;background:#fb923c1f}.hstatus.critical{color:#f87171;background:#f871711f;animation:1s infinite pulse-red}@keyframes pulse-red{0%,to{opacity:1}50%{opacity:.5}}.step-val{font-family:var(--mono);color:#f0e8e0;font-size:12px;font-weight:500}.sbar-bg{background:#ffffff1a;border-radius:2px;width:100%;height:3px;margin-top:1px;overflow:hidden}.sbar-fill{background:linear-gradient(90deg, var(--blue), #60a5fa);border-radius:2px;height:100%;transition:width .35s}.step-meta{font-family:var(--mono);color:#a8a29e8c;letter-spacing:.06em;text-transform:uppercase;margin-top:1px;font-size:8px}.legend{background:var(--s1);border:1px solid var(--bd);border-radius:6px;flex-wrap:wrap;align-items:center;gap:14px;width:100%;max-width:640px;padding:8px 12px;display:flex}.legend-item{font-family:var(--mono);color:var(--t2);letter-spacing:.05em;text-transform:uppercase;align-items:center;gap:5px;font-size:9px;display:flex}.leg-swatch{border-radius:2px;flex-shrink:0;width:12px;height:12px}.dialog{background:var(--s1);border:1px solid var(--bd);border-left:3px solid var(--fire);border-radius:var(--r);font-family:var(--mono);color:var(--t2);width:100%;max-width:640px;padding:10px 14px;font-size:12px;line-height:1.5}.dialog-who{color:var(--t3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:3px;font-size:9px;display:block}.side{background:var(--s1);border-left:1px solid var(--bd);flex-direction:column;display:flex;overflow:hidden auto}.side-sec{border-bottom:1px solid var(--bd);padding:14px 16px}.sec-hd{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.12em;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:9px;font-weight:600;display:flex}.sg{grid-template-columns:1fr 1fr;gap:6px;display:grid}.sc{background:var(--s2);border:1px solid var(--bd);border-radius:6px;padding:8px 10px}.sc-l{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:2px;font-size:9px}.sc-v{font-family:var(--mono);color:var(--t1);font-size:14px;font-weight:500}.sc-v.fire{color:var(--fire)}.sc-v.blue{color:var(--blue)}.sc-v.green{color:var(--green)}.sc-v.amber{color:var(--amber)}.sc-v.gold{color:var(--gold)}.srow{border-bottom:1px solid #00000008;justify-content:space-between;align-items:center;padding:5px 0;font-size:12px;display:flex}.srow:last-child{border-bottom:none}.sv{font-family:var(--mono);font-weight:500}.sv.blue{color:var(--blue)}.sv.green{color:var(--green)}.sv.hot{color:var(--fire)}.sv.danger{color:var(--red)}.sv.warn{color:var(--amber)}.sv.gold{color:var(--gold)}.bar-w{margin-top:10px}.bar-lbl{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.06em;justify-content:space-between;margin-bottom:5px;font-size:9px;display:flex}.bar-bg{background:var(--s2);border:1px solid var(--bd);border-radius:4px;height:6px;overflow:hidden}.bar-fill{border-radius:4px;height:100%;transition:width .4s cubic-bezier(.4,0,.2,1)}.elog{flex-direction:column;gap:2px;max-height:160px;display:flex;overflow-y:auto}.erow{background:0 0;border-radius:4px;align-items:flex-start;gap:6px;padding:4px 6px;font-size:11px;display:flex}.erow.alarm{background:var(--red2);border-left:2px solid var(--red)}.erow.warn-row{background:var(--amber2);border-left:2px solid var(--amber)}.estep{font-family:var(--mono);color:var(--t3);flex-shrink:0;min-width:26px;padding-top:1px;font-size:9px}.etext{color:var(--t2);flex:1;font-size:11px;line-height:1.4}.erwd{font-family:var(--mono);flex-shrink:0;padding-top:1px;font-size:9px;font-weight:500}.erwd.p{color:var(--green)}.erwd.n{color:var(--red)}.dpad{grid-template-columns:repeat(3,1fr);gap:5px;max-width:140px;margin:0 auto;display:grid}.dpad-center{justify-content:center;align-items:center;display:flex}.ctrl-grid{grid-template-columns:repeat(3,1fr);gap:5px;display:grid}.ctrl-row{gap:5px;margin-top:8px;display:flex}.ctrl-btn{background:var(--s2);color:var(--t2);border:1px solid var(--bd);font-family:var(--mono);letter-spacing:.04em;cursor:pointer;text-align:center;border-radius:5px;flex:1;padding:7px 6px;font-size:10px;font-weight:500;transition:all .1s}.ctrl-btn:hover:not(:disabled){background:var(--s3);color:var(--t1);border-color:var(--bd2)}.ctrl-btn:disabled{opacity:.4;cursor:not-allowed}.ctrl-btn.accent{background:var(--fire2);color:var(--fire);border-color:#c2410c40}.ctrl-btn.accent:hover{color:var(--fire);background:#c2410c26}.ctrl-btn.active{background:var(--blue2);color:var(--blue);border-color:#1d4ed840}.ctrl-btn.active:hover{background:#1d4ed826}.ctrl-btn.play{color:#fff;background:linear-gradient(135deg,#c2410c,#f97316);border:none;font-size:11px;box-shadow:0 2px 8px #c2410c4d}.ctrl-btn.play:hover{filter:brightness(1.05);box-shadow:0 3px 12px #c2410c73}.ctrl-status{font-family:var(--mono);color:var(--t3);background:var(--s2);border:1px solid var(--bd);border-left:3px solid var(--bd2);border-radius:5px;margin-top:10px;padding:7px 10px;font-size:10px}.ctrl-status.error{color:var(--red);background:var(--red2);border-left-color:var(--red)}.door-grid{grid-template-columns:1fr 1fr;gap:4px;display:grid}.door-btn{background:var(--s2);border:1px solid var(--bd);font-family:var(--mono);cursor:pointer;color:var(--t2);text-align:center;border-radius:5px;padding:5px 4px;font-size:9px;transition:all .1s}.door-btn:hover{background:var(--s3);color:var(--t1)}.door-btn.open{color:var(--green);background:var(--green2);border-color:#16653440}.door-btn.closed{color:var(--amber);background:var(--amber2);border-color:#92400e40}.door-btn.failed{color:var(--red);background:var(--red2);border-color:#b91c1c40}.report-box{background:var(--s2);border:1px solid var(--bd);max-height:160px;font-family:var(--mono);color:var(--t2);white-space:pre-wrap;border-radius:5px;padding:10px;font-size:9px;line-height:1.5;overflow-y:auto}::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
server/static/assets/index-CWSK8ZuR.js ADDED
The diff for this file is too large to render. See raw diff
 
server/static/index.html CHANGED
@@ -1,14 +1,17 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
8
- <script type="module" crossorigin src="/assets/index-DmWZ9HHA.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-S-HKcJzd.css">
10
- </head>
11
- <body>
12
- <div id="root"></div>
13
- </body>
14
- </html>
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Pyre — Crisis Navigation</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ <script type="module" crossorigin src="/assets/index-CWSK8ZuR.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-8TVqpTW-.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>
train_grpo_openenv.py CHANGED
@@ -1,13 +1,13 @@
1
  """
2
  train_grpo_openenv.py — GRPO + LoRA training for Pyre
3
- (PyreEnv WebSocket client, OpenEnv notebook-style pipeline)
4
 
5
  Mirrors train_grpo_openenv.py from the reference as closely as possible.
6
  Differences vs. the reference are intentional and limited to:
7
 
8
- 1. Environment is the Pyre fire-evacuation server (WebSocket via PyreEnv.sync()).
9
- The reference calls WorldState in-process; Pyre has no in-process equivalent,
10
- so a running `uv run server` is required before launching training.
11
 
12
  2. Pyre is a single-environment, difficulty-parametrised task. For this initial
13
  training run we fix difficulty to "easy" only (1 fire source, slow spread,
@@ -31,10 +31,6 @@ Differences vs. the reference are intentional and limited to:
31
 
32
  Usage
33
  ─────
34
- # 1. Start the Pyre server (from pyre_env/):
35
- # uv run server
36
- #
37
- # 2. Run training (from pyre_env/):
38
  python train_grpo_openenv.py
39
  python train_grpo_openenv.py --model-id Qwen/Qwen3-1.7B --dataset-size 1000
40
 
@@ -77,7 +73,8 @@ _PROJECT_ROOT = Path(__file__).resolve().parent
77
  if str(_PROJECT_ROOT) not in sys.path:
78
  sys.path.insert(0, str(_PROJECT_ROOT))
79
 
80
- from pyre_env import PyreEnv, PyreAction # noqa: E402
 
81
 
82
  logging.basicConfig(
83
  level=logging.INFO,
@@ -86,9 +83,6 @@ logging.basicConfig(
86
  )
87
  log = logging.getLogger("grpo_openenv")
88
 
89
- # Server URL — set PYRE_SERVER_URL in pyre_env/.env to override
90
- ENV_URL = os.getenv("PYRE_SERVER_URL", "http://localhost:8000")
91
-
92
 
93
  # ─────────────────────────────────────────────────────────────────────────────
94
  # 1. Difficulty registry + stratified mix (notebook "cell-1" analogue)
@@ -322,81 +316,79 @@ def rollout_once(
322
  steps_taken: int = 0
323
  think_steps: int = 0
324
 
325
- MAX_TOK_ACCUM = 4096
326
 
327
  try:
328
- with PyreEnv(base_url=ENV_URL).sync() as env:
329
- result = env.reset(difficulty=difficulty, seed=seed)
330
- obs = result.observation
331
- done = result.done
332
-
333
- for _turn in range(max_turns):
334
- if done or len(completion_ids) >= MAX_TOK_ACCUM:
335
- break
336
-
337
- obs_dict = obs.model_dump()
338
- user_prompt = make_user_prompt(obs_dict, history)
339
- messages = [
340
- {"role": "system", "content": system_prompt},
341
- {"role": "user", "content": user_prompt},
342
- ]
343
-
344
- try:
345
- prompt_text = tokenizer.apply_chat_template(
346
- messages,
347
- add_generation_prompt=True,
348
- tokenize=False,
349
- enable_thinking=True,
350
- )
351
- except TypeError:
352
- prompt_text = tokenizer.apply_chat_template(
353
- messages,
354
- add_generation_prompt=True,
355
- tokenize=False,
356
- )
357
-
358
- log.debug(
359
- "\n[difficulty=%s seed=%d step=%d] ── PROMPT ──────────────────────────\n%s\n────────────────────────────────────",
360
- difficulty, seed, _turn + 1, prompt_text,
361
  )
362
-
363
- rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0]
364
- prompt_ids.extend(rollout_outputs["prompt_ids"])
365
- completion_ids.extend(rollout_outputs["completion_ids"])
366
- logprobs.extend(rollout_outputs.get("logprobs") or [])
367
- completion_text = rollout_outputs.get("text") or tokenizer.decode(
368
- rollout_outputs["completion_ids"], skip_special_tokens=False
369
  )
370
 
371
- log.debug(
372
- "\n[difficulty=%s seed=%d step=%d] ── COMPLETION ──────────────────────\n%s\n────────────────────────────────────",
373
- difficulty, seed, _turn + 1, completion_text,
374
- )
375
 
376
- if "<think>" in completion_text and "</think>" in completion_text:
377
- think_steps += 1
378
- steps_taken += 1
 
 
 
 
379
 
380
- action_dict, fmt_score = parse_action(completion_text)
381
- fmt_penalty = (1.0 - fmt_score) * -0.10
 
 
382
 
383
- result = env.step(PyreAction(**action_dict))
384
- obs = result.observation
385
- step_reward = float(result.reward or 0.0)
386
- done = result.done
387
 
388
- step_rewards.append(step_reward + fmt_penalty)
 
389
 
390
- feedback = obs.last_action_feedback or ""
391
- # Reward is intentionally excluded from the history entry.
392
- history.append(
393
- f"Step {_turn + 1}: {json.dumps(action_dict)}"
394
- + (f"\n → {feedback}" if feedback else "")
395
- + f"\n health: {obs.agent_health:.1f}"
396
- )
397
 
398
- agent_evacuated = obs.agent_evacuated
399
- final_health = float(obs.agent_health)
 
 
 
 
 
 
 
 
 
 
400
 
401
  except Exception as exc:
402
  log.error("Episode failed (difficulty=%s seed=%d): %s", difficulty, seed, exc)
@@ -568,17 +560,6 @@ if __name__ == "__main__":
568
  logging.getLogger().setLevel(logging.DEBUG)
569
  log.debug("Debug logging enabled — full prompt/completion will be printed each step.")
570
 
571
- # ── Server health check ─────────────────────────────────────────────────
572
- import requests as _requests
573
- try:
574
- _requests.get(f"{ENV_URL}/health", timeout=5).raise_for_status()
575
- log.info("Pyre server: healthy ✓ (%s)", ENV_URL)
576
- except Exception as exc:
577
- raise SystemExit(
578
- f"Pyre server not reachable at {ENV_URL}: {exc}\n"
579
- "Start it with: uv run server"
580
- )
581
-
582
  # ── Init tokenizer ──────────────────────────────────────────────────────
583
  tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=True)
584
  if tokenizer.pad_token is None:
@@ -595,22 +576,26 @@ if __name__ == "__main__":
595
  #
596
  # Matching the reference notebook, with one Pyre-specific override:
597
  #
598
- # max_completion_length 1024512 (<think> + small JSON action)
599
  #
600
  # Everything else — lr, grad_accum, num_generations, vLLM colocate,
601
  # gradient checkpointing, push_to_hub — is identical to the reference.
602
  grpo_config = GRPOConfig(
 
 
 
 
603
  num_train_epochs=1,
604
  learning_rate=5e-6,
605
  gradient_accumulation_steps=64,
606
  per_device_train_batch_size=1,
607
  warmup_steps=20,
608
  num_generations=2,
609
- max_completion_length=512,
610
  use_vllm=True,
611
  vllm_mode="colocate",
612
  vllm_gpu_memory_utilization=0.3,
613
- vllm_max_model_len=8192,
614
  output_dir=args.output_dir,
615
  report_to=args.report_to,
616
  trackio_space_id=args.output_dir if args.report_to == "trackio" else None,
@@ -625,10 +610,6 @@ if __name__ == "__main__":
625
  # ── Create trainer ──────────────────────────────────────────────────────
626
  trainer = GRPOTrainer(
627
  model=args.model_id,
628
- model_init_kwargs={
629
- "torch_dtype": torch.bfloat16,
630
- "attn_implementation": "eager",
631
- },
632
  processing_class=tokenizer,
633
  reward_funcs=[
634
  reward_evacuated,
 
1
  """
2
  train_grpo_openenv.py — GRPO + LoRA training for Pyre
3
+ (PyreEnvironment in-process, OpenEnv notebook-style pipeline)
4
 
5
  Mirrors train_grpo_openenv.py from the reference as closely as possible.
6
  Differences vs. the reference are intentional and limited to:
7
 
8
+ 1. Environment is PyreEnvironment, called in-process (no server needed).
9
+ The reference calls WorldState in-process; Pyre uses the same approach —
10
+ PyreEnvironment is instantiated directly, so no `uv run server` is required.
11
 
12
  2. Pyre is a single-environment, difficulty-parametrised task. For this initial
13
  training run we fix difficulty to "easy" only (1 fire source, slow spread,
 
31
 
32
  Usage
33
  ─────
 
 
 
 
34
  python train_grpo_openenv.py
35
  python train_grpo_openenv.py --model-id Qwen/Qwen3-1.7B --dataset-size 1000
36
 
 
73
  if str(_PROJECT_ROOT) not in sys.path:
74
  sys.path.insert(0, str(_PROJECT_ROOT))
75
 
76
+ from pyre_env import PyreAction # noqa: E402
77
+ from pyre_env.server.pyre_env_environment import PyreEnvironment # noqa: E402
78
 
79
  logging.basicConfig(
80
  level=logging.INFO,
 
83
  )
84
  log = logging.getLogger("grpo_openenv")
85
 
 
 
 
86
 
87
  # ─────────────────────────────────────────────────────────────────────────────
88
  # 1. Difficulty registry + stratified mix (notebook "cell-1" analogue)
 
316
  steps_taken: int = 0
317
  think_steps: int = 0
318
 
319
+ MAX_TOK_ACCUM = 8192
320
 
321
  try:
322
+ env = PyreEnvironment()
323
+ obs = env.reset(difficulty=difficulty, seed=seed)
324
+ done = obs.done
325
+
326
+ for _turn in range(max_turns):
327
+ if done or len(completion_ids) >= MAX_TOK_ACCUM:
328
+ break
329
+
330
+ obs_dict = obs.model_dump()
331
+ user_prompt = make_user_prompt(obs_dict, history)
332
+ messages = [
333
+ {"role": "system", "content": system_prompt},
334
+ {"role": "user", "content": user_prompt},
335
+ ]
336
+
337
+ try:
338
+ prompt_text = tokenizer.apply_chat_template(
339
+ messages,
340
+ add_generation_prompt=True,
341
+ tokenize=False,
342
+ enable_thinking=True,
 
 
 
 
 
 
 
 
 
 
 
 
343
  )
344
+ except TypeError:
345
+ prompt_text = tokenizer.apply_chat_template(
346
+ messages,
347
+ add_generation_prompt=True,
348
+ tokenize=False,
 
 
349
  )
350
 
351
+ log.debug(
352
+ "\n[difficulty=%s seed=%d step=%d] ── PROMPT ──────────────────────────\n%s\n────────────────────────────────────",
353
+ difficulty, seed, _turn + 1, prompt_text,
354
+ )
355
 
356
+ rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0]
357
+ prompt_ids.extend(rollout_outputs["prompt_ids"])
358
+ completion_ids.extend(rollout_outputs["completion_ids"])
359
+ logprobs.extend(rollout_outputs.get("logprobs") or [])
360
+ completion_text = rollout_outputs.get("text") or tokenizer.decode(
361
+ rollout_outputs["completion_ids"], skip_special_tokens=False
362
+ )
363
 
364
+ log.debug(
365
+ "\n[difficulty=%s seed=%d step=%d] ── COMPLETION ──────────────────────\n%s\n────────────────────────────────────",
366
+ difficulty, seed, _turn + 1, completion_text,
367
+ )
368
 
369
+ if "<think>" in completion_text and "</think>" in completion_text:
370
+ think_steps += 1
371
+ steps_taken += 1
 
372
 
373
+ action_dict, fmt_score = parse_action(completion_text)
374
+ fmt_penalty = (1.0 - fmt_score) * -0.10
375
 
376
+ obs = env.step(PyreAction(**action_dict))
377
+ step_reward = float(obs.reward or 0.0)
378
+ done = obs.done
 
 
 
 
379
 
380
+ step_rewards.append(step_reward + fmt_penalty)
381
+
382
+ feedback = obs.last_action_feedback or ""
383
+ # Reward is intentionally excluded from the history entry.
384
+ history.append(
385
+ f"Step {_turn + 1}: {json.dumps(action_dict)}"
386
+ + (f"\n → {feedback}" if feedback else "")
387
+ + f"\n health: {obs.agent_health:.1f}"
388
+ )
389
+
390
+ agent_evacuated = obs.agent_evacuated
391
+ final_health = float(obs.agent_health)
392
 
393
  except Exception as exc:
394
  log.error("Episode failed (difficulty=%s seed=%d): %s", difficulty, seed, exc)
 
560
  logging.getLogger().setLevel(logging.DEBUG)
561
  log.debug("Debug logging enabled — full prompt/completion will be printed each step.")
562
 
 
 
 
 
 
 
 
 
 
 
 
563
  # ── Init tokenizer ──────────────────────────────────────────────────────
564
  tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=True)
565
  if tokenizer.pad_token is None:
 
576
  #
577
  # Matching the reference notebook, with one Pyre-specific override:
578
  #
579
+ # max_completion_length 5121024 (safe margin for Qwen3 think + JSON action)
580
  #
581
  # Everything else — lr, grad_accum, num_generations, vLLM colocate,
582
  # gradient checkpointing, push_to_hub — is identical to the reference.
583
  grpo_config = GRPOConfig(
584
+ model_init_kwargs={
585
+ "torch_dtype": "bfloat16",
586
+ "attn_implementation": "eager",
587
+ },
588
  num_train_epochs=1,
589
  learning_rate=5e-6,
590
  gradient_accumulation_steps=64,
591
  per_device_train_batch_size=1,
592
  warmup_steps=20,
593
  num_generations=2,
594
+ max_completion_length=1024,
595
  use_vllm=True,
596
  vllm_mode="colocate",
597
  vllm_gpu_memory_utilization=0.3,
598
+ vllm_max_model_length=8192,
599
  output_dir=args.output_dir,
600
  report_to=args.report_to,
601
  trackio_space_id=args.output_dir if args.report_to == "trackio" else None,
 
610
  # ── Create trainer ──────────────────────────────────────────────────────
611
  trainer = GRPOTrainer(
612
  model=args.model_id,
 
 
 
 
613
  processing_class=tokenizer,
614
  reward_funcs=[
615
  reward_evacuated,
uv.lock CHANGED
The diff for this file is too large to render. See raw diff