ZENLLC commited on
Commit
fac4ca2
·
verified ·
1 Parent(s): 3f1437f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +905 -0
app.py ADDED
@@ -0,0 +1,905 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import math
3
+ import time
4
+ from dataclasses import dataclass, asdict
5
+ from typing import Dict, List, Tuple, Optional
6
+
7
+ import numpy as np
8
+ from PIL import Image, ImageDraw, ImageFont
9
+
10
+ import gradio as gr
11
+
12
+ # ============================================================
13
+ # ChronoSandbox — Agent Timeline Lab (Deterministic, Inspectable)
14
+ # - Multi-agent gridworld
15
+ # - First-person pseudo-3D raycast view for selected agent
16
+ # - Global truth map + per-agent belief maps (fog-of-war memory)
17
+ # - AutoRun animation, time dilation, rewind scrubber
18
+ # - Branching timelines (fork from any previous step)
19
+ # - Click-to-edit map tiles
20
+ #
21
+ # Minimal philosophy: explicit rules, no hidden weights, replayable.
22
+ # ============================================================
23
+
24
+ # -----------------------------
25
+ # World / render config
26
+ # -----------------------------
27
+ GRID_W, GRID_H = 21, 15
28
+ TILE = 22 # top-down pixels per tile
29
+
30
+ VIEW_W, VIEW_H = 640, 360
31
+ RAY_W = 320
32
+ FOV_DEG = 78
33
+ MAX_DEPTH = 20
34
+
35
+ # 0=E,1=S,2=W,3=N
36
+ DIRS = [(1, 0), (0, 1), (-1, 0), (0, -1)]
37
+ ORI_DEG = [0, 90, 180, 270]
38
+
39
+ # Tile types
40
+ EMPTY = 0
41
+ WALL = 1
42
+ FOOD = 2
43
+ NOISE = 3
44
+ DOOR = 4
45
+ TELE = 5
46
+
47
+ TILE_NAMES = {
48
+ EMPTY: "Empty",
49
+ WALL: "Wall",
50
+ FOOD: "Food",
51
+ NOISE: "Noise",
52
+ DOOR: "Door",
53
+ TELE: "Teleporter",
54
+ }
55
+
56
+ # Palette (kept simple; inspectable)
57
+ SKY = np.array([14, 16, 26], dtype=np.uint8)
58
+ FLOOR_NEAR = np.array([24, 26, 40], dtype=np.uint8)
59
+ FLOOR_FAR = np.array([10, 11, 18], dtype=np.uint8)
60
+ WALL_BASE = np.array([210, 210, 225], dtype=np.uint8)
61
+ WALL_SIDE = np.array([150, 150, 170], dtype=np.uint8)
62
+
63
+ AGENT_COLORS = {
64
+ "Predator": (255, 120, 90),
65
+ "Prey": (120, 255, 160),
66
+ "Scout": (120, 190, 255),
67
+ }
68
+
69
+ # -----------------------------
70
+ # Deterministic RNG helper
71
+ # -----------------------------
72
+ def rng_for(seed: int, step: int, stream: int = 0) -> np.random.Generator:
73
+ # Stable stream keyed by (seed, step, stream)
74
+ # Using PCG64 bitgen for reproducibility.
75
+ mix = (seed * 1_000_003) ^ (step * 9_999_937) ^ (stream * 97_531)
76
+ return np.random.default_rng(mix & 0xFFFFFFFFFFFFFFFF)
77
+
78
+ # -----------------------------
79
+ # State definitions
80
+ # -----------------------------
81
+ @dataclass
82
+ class Agent:
83
+ name: str
84
+ x: int
85
+ y: int
86
+ ori: int # 0..3
87
+ energy: int = 100 # mainly for prey, food, etc.
88
+
89
+ @dataclass
90
+ class WorldState:
91
+ seed: int
92
+ step: int
93
+ grid: List[List[int]] # ints
94
+ agents: Dict[str, Agent]
95
+ controlled: str # which agent receives manual control
96
+ pov: str # which agent camera is showing
97
+ autorun: bool
98
+ speed_hz: float
99
+ overlay: bool
100
+ event_log: List[str]
101
+ caught: bool
102
+ branches: Dict[str, int] # branch_name -> step_index in history
103
+
104
+ @dataclass
105
+ class Snapshot:
106
+ step: int
107
+ agents: Dict[str, Dict]
108
+ grid: List[List[int]]
109
+ event_log_tail: List[str]
110
+ caught: bool
111
+
112
+ def default_grid() -> List[List[int]]:
113
+ g = [[EMPTY for _ in range(GRID_W)] for _ in range(GRID_H)]
114
+ # Border walls
115
+ for x in range(GRID_W):
116
+ g[0][x] = WALL
117
+ g[GRID_H - 1][x] = WALL
118
+ for y in range(GRID_H):
119
+ g[y][0] = WALL
120
+ g[y][GRID_W - 1] = WALL
121
+
122
+ # Some interior structure
123
+ for x in range(4, 17):
124
+ g[7][x] = WALL
125
+ g[7][10] = DOOR # a door gap
126
+
127
+ # Toys
128
+ g[3][4] = FOOD
129
+ g[11][15] = FOOD
130
+ g[4][14] = NOISE
131
+ g[12][5] = NOISE
132
+ g[2][18] = TELE
133
+ g[13][2] = TELE
134
+ return g
135
+
136
+ def init_state(seed: int) -> WorldState:
137
+ agents = {
138
+ "Predator": Agent("Predator", 2, 2, 0, 100),
139
+ "Prey": Agent("Prey", 18, 12, 2, 100),
140
+ "Scout": Agent("Scout", 10, 3, 1, 100),
141
+ }
142
+ return WorldState(
143
+ seed=seed,
144
+ step=0,
145
+ grid=default_grid(),
146
+ agents=agents,
147
+ controlled="Predator",
148
+ pov="Predator",
149
+ autorun=False,
150
+ speed_hz=8.0,
151
+ overlay=False,
152
+ event_log=["Initialized world."],
153
+ caught=False,
154
+ branches={"main": 0},
155
+ )
156
+
157
+ # -----------------------------
158
+ # Per-agent belief memory
159
+ # -----------------------------
160
+ def init_belief() -> Dict[str, np.ndarray]:
161
+ # -1 unknown, else tile id
162
+ b = {}
163
+ for name in ["Predator", "Prey", "Scout"]:
164
+ b[name] = -1 * np.ones((GRID_H, GRID_W), dtype=np.int16)
165
+ return b
166
+
167
+ # -----------------------------
168
+ # Utility: movement + collision
169
+ # -----------------------------
170
+ def in_bounds(x: int, y: int) -> bool:
171
+ return 0 <= x < GRID_W and 0 <= y < GRID_H
172
+
173
+ def is_blocking(tile: int) -> bool:
174
+ # door is passable (for drama); wall blocks; tele is passable
175
+ return tile == WALL
176
+
177
+ def move_forward(state: WorldState, a: Agent) -> None:
178
+ dx, dy = DIRS[a.ori]
179
+ nx, ny = a.x + dx, a.y + dy
180
+ if not in_bounds(nx, ny):
181
+ return
182
+ if is_blocking(state.grid[ny][nx]):
183
+ return
184
+ # Door toggle mechanic: if you step onto a door, it becomes empty (door opens)
185
+ if state.grid[ny][nx] == DOOR:
186
+ state.grid[ny][nx] = EMPTY
187
+ state.event_log.append(f"t={state.step}: {a.name} opened a door.")
188
+ a.x, a.y = nx, ny
189
+
190
+ # Teleporter: stepping onto TELE sends you to the other TELE (deterministically)
191
+ if state.grid[ny][nx] == TELE:
192
+ teles = [(x, y) for y in range(GRID_H) for x in range(GRID_W) if state.grid[y][x] == TELE]
193
+ if len(teles) >= 2:
194
+ # choose destination as "the other tele" based on sorted list
195
+ teles_sorted = sorted(teles)
196
+ idx = teles_sorted.index((nx, ny))
197
+ dest = teles_sorted[(idx + 1) % len(teles_sorted)]
198
+ a.x, a.y = dest
199
+ state.event_log.append(f"t={state.step}: {a.name} teleported.")
200
+
201
+ def turn_left(a: Agent) -> None:
202
+ a.ori = (a.ori - 1) % 4
203
+
204
+ def turn_right(a: Agent) -> None:
205
+ a.ori = (a.ori + 1) % 4
206
+
207
+ # -----------------------------
208
+ # Perception: LOS + FOV on grid
209
+ # -----------------------------
210
+ def los_clear(grid: List[List[int]], x0: int, y0: int, x1: int, y1: int) -> bool:
211
+ # Bresenham line-of-sight; walls block
212
+ dx = abs(x1 - x0)
213
+ dy = abs(y1 - y0)
214
+ sx = 1 if x0 < x1 else -1
215
+ sy = 1 if y0 < y1 else -1
216
+ err = dx - dy
217
+ x, y = x0, y0
218
+ while True:
219
+ if (x, y) != (x0, y0) and (x, y) != (x1, y1):
220
+ if grid[y][x] == WALL:
221
+ return False
222
+ if x == x1 and y == y1:
223
+ return True
224
+ e2 = 2 * err
225
+ if e2 > -dy:
226
+ err -= dy
227
+ x += sx
228
+ if e2 < dx:
229
+ err += dx
230
+ y += sy
231
+
232
+ def within_fov(observer: Agent, tx: int, ty: int, fov_deg: float = 78.0) -> bool:
233
+ # vector from observer to target in observer's local frame
234
+ dx = tx - observer.x
235
+ dy = ty - observer.y
236
+ if dx == 0 and dy == 0:
237
+ return True
238
+ # absolute angle of target
239
+ angle = math.degrees(math.atan2(dy, dx)) % 360
240
+ facing = ORI_DEG[observer.ori]
241
+ # smallest signed difference
242
+ diff = (angle - facing + 540) % 360 - 180
243
+ return abs(diff) <= (fov_deg / 2)
244
+
245
+ def visible(observer: Agent, target: Agent, grid: List[List[int]]) -> bool:
246
+ return within_fov(observer, target.x, target.y, FOV_DEG) and los_clear(grid, observer.x, observer.y, target.x, target.y)
247
+
248
+ # -----------------------------
249
+ # Raycast pseudo-3D render
250
+ # -----------------------------
251
+ def raycast_view(state: WorldState, observer: Agent, belief: Optional[np.ndarray] = None) -> np.ndarray:
252
+ # Returns RGB uint8 image
253
+ img = np.zeros((VIEW_H, VIEW_W, 3), dtype=np.uint8)
254
+ img[:, :] = SKY
255
+
256
+ # floor gradient
257
+ for y in range(VIEW_H // 2, VIEW_H):
258
+ t = (y - VIEW_H // 2) / (VIEW_H // 2 + 1e-6)
259
+ col = (1 - t) * FLOOR_NEAR + t * FLOOR_FAR
260
+ img[y, :] = col.astype(np.uint8)
261
+
262
+ # ray setup
263
+ fov = math.radians(FOV_DEG)
264
+ half_fov = fov / 2
265
+ for rx in range(RAY_W):
266
+ # camera plane: [-1, 1]
267
+ cam_x = (2 * rx / (RAY_W - 1)) - 1
268
+ ray_ang = math.radians(ORI_DEG[observer.ori]) + cam_x * half_fov
269
+
270
+ # DDA-like stepping
271
+ ox, oy = observer.x + 0.5, observer.y + 0.5
272
+ sin_a = math.sin(ray_ang)
273
+ cos_a = math.cos(ray_ang)
274
+ depth = 0.0
275
+ hit_side = 0
276
+
277
+ while depth < MAX_DEPTH:
278
+ depth += 0.05
279
+ tx = int(ox + cos_a * depth)
280
+ ty = int(oy + sin_a * depth)
281
+ if not in_bounds(tx, ty):
282
+ break
283
+
284
+ tile = state.grid[ty][tx]
285
+ if tile == WALL:
286
+ # side shading based on ray direction
287
+ # crude: if abs(cos)>abs(sin) consider "vertical" else "horizontal"
288
+ hit_side = 1 if abs(cos_a) > abs(sin_a) else 0
289
+ break
290
+ if tile == DOOR:
291
+ # door is semi-visible in world; render thinner by treating as a hit but less dark
292
+ hit_side = 2
293
+ break
294
+
295
+ # project wall slice
296
+ if depth >= MAX_DEPTH:
297
+ continue
298
+ # fish-eye correction
299
+ depth *= math.cos(ray_ang - math.radians(ORI_DEG[observer.ori]))
300
+ depth = max(depth, 0.001)
301
+
302
+ proj_h = int((VIEW_H * 0.9) / depth)
303
+ y0 = max(0, VIEW_H // 2 - proj_h // 2)
304
+ y1 = min(VIEW_H - 1, VIEW_H // 2 + proj_h // 2)
305
+
306
+ if hit_side == 0:
307
+ col = WALL_BASE.copy()
308
+ elif hit_side == 1:
309
+ col = WALL_SIDE.copy()
310
+ else:
311
+ # door slice
312
+ col = np.array([180, 210, 255], dtype=np.uint8)
313
+
314
+ # slight depth dim
315
+ dim = max(0.25, 1.0 - (depth / MAX_DEPTH))
316
+ col = (col * dim).astype(np.uint8)
317
+
318
+ # draw slice (scaled to full VIEW_W)
319
+ x0 = int(rx * (VIEW_W / RAY_W))
320
+ x1 = int((rx + 1) * (VIEW_W / RAY_W))
321
+ img[y0:y1, x0:x1] = col
322
+
323
+ # overlay: draw "billboards" for visible agents
324
+ for other_name, other in state.agents.items():
325
+ if other_name == observer.name:
326
+ continue
327
+ if visible(observer, other, state.grid):
328
+ # place billboard at its relative angle
329
+ dx = other.x - observer.x
330
+ dy = other.y - observer.y
331
+ ang = (math.degrees(math.atan2(dy, dx)) % 360)
332
+ facing = ORI_DEG[observer.ori]
333
+ diff = (ang - facing + 540) % 360 - 180
334
+ # map diff to screen x
335
+ sx = int((diff / (FOV_DEG / 2)) * (VIEW_W / 2) + (VIEW_W / 2))
336
+ dist = math.sqrt(dx * dx + dy * dy)
337
+ h = int((VIEW_H * 0.65) / max(dist, 0.75))
338
+ w = max(10, h // 3)
339
+ y_mid = VIEW_H // 2
340
+ y0 = max(0, y_mid - h // 2)
341
+ y1 = min(VIEW_H - 1, y_mid + h // 2)
342
+ x0 = max(0, sx - w // 2)
343
+ x1 = min(VIEW_W - 1, sx + w // 2)
344
+ col = AGENT_COLORS.get(other_name, (255, 200, 120))
345
+ img[y0:y1, x0:x1] = np.array(col, dtype=np.uint8)
346
+
347
+ if state.overlay:
348
+ # reticle
349
+ cx, cy = VIEW_W // 2, VIEW_H // 2
350
+ img[cy - 1:cy + 2, cx - 10:cx + 10] = np.array([120, 190, 255], dtype=np.uint8)
351
+ img[cy - 10:cy + 10, cx - 1:cx + 2] = np.array([120, 190, 255], dtype=np.uint8)
352
+
353
+ return img
354
+
355
+ # -----------------------------
356
+ # Top-down map render (truth or belief)
357
+ # -----------------------------
358
+ def render_topdown(grid: np.ndarray, agents: Dict[str, Agent], title: str, show_agents: bool = True) -> Image.Image:
359
+ w = grid.shape[1] * TILE
360
+ h = grid.shape[0] * TILE
361
+ im = Image.new("RGB", (w, h + 28), (10, 12, 18))
362
+ draw = ImageDraw.Draw(im)
363
+
364
+ # tiles
365
+ for y in range(grid.shape[0]):
366
+ for x in range(grid.shape[1]):
367
+ t = int(grid[y, x])
368
+ if t == -1:
369
+ col = (18, 20, 32) # unknown
370
+ elif t == EMPTY:
371
+ col = (26, 30, 44)
372
+ elif t == WALL:
373
+ col = (190, 190, 210)
374
+ elif t == FOOD:
375
+ col = (255, 210, 120)
376
+ elif t == NOISE:
377
+ col = (255, 120, 220)
378
+ elif t == DOOR:
379
+ col = (140, 210, 255)
380
+ elif t == TELE:
381
+ col = (120, 190, 255)
382
+ else:
383
+ col = (80, 80, 90)
384
+
385
+ x0, y0 = x * TILE, y * TILE + 28
386
+ draw.rectangle([x0, y0, x0 + TILE - 1, y0 + TILE - 1], fill=col)
387
+
388
+ # grid lines
389
+ for x in range(grid.shape[1] + 1):
390
+ xx = x * TILE
391
+ draw.line([xx, 28, xx, h + 28], fill=(12, 14, 22))
392
+ for y in range(grid.shape[0] + 1):
393
+ yy = y * TILE + 28
394
+ draw.line([0, yy, w, yy], fill=(12, 14, 22))
395
+
396
+ # agents
397
+ if show_agents:
398
+ for name, a in agents.items():
399
+ cx = a.x * TILE + TILE // 2
400
+ cy = a.y * TILE + 28 + TILE // 2
401
+ col = AGENT_COLORS.get(name, (220, 220, 220))
402
+ r = TILE // 3
403
+ draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=col)
404
+ # heading tick
405
+ dx, dy = DIRS[a.ori]
406
+ draw.line([cx, cy, cx + dx * r, cy + dy * r], fill=(10, 10, 10), width=3)
407
+
408
+ # title bar
409
+ draw.rectangle([0, 0, w, 28], fill=(14, 16, 26))
410
+ draw.text((8, 6), title, fill=(230, 230, 240))
411
+
412
+ return im
413
+
414
+ # -----------------------------
415
+ # Autonomy policies (explicit rules)
416
+ # -----------------------------
417
+ def predator_policy(state: WorldState, step: int) -> str:
418
+ pred = state.agents["Predator"]
419
+ prey = state.agents["Prey"]
420
+ # If prey visible, chase: turn toward prey then forward
421
+ if visible(pred, prey, state.grid):
422
+ dx = prey.x - pred.x
423
+ dy = prey.y - pred.y
424
+ ang = (math.degrees(math.atan2(dy, dx)) % 360)
425
+ facing = ORI_DEG[pred.ori]
426
+ diff = (ang - facing + 540) % 360 - 180
427
+ if diff < -10:
428
+ return "L"
429
+ if diff > 10:
430
+ return "R"
431
+ return "F"
432
+ # else wander deterministically
433
+ r = rng_for(state.seed, step, stream=1)
434
+ return r.choice(["F", "L", "R", "F", "F"])
435
+
436
+ def prey_policy(state: WorldState, step: int) -> str:
437
+ prey = state.agents["Prey"]
438
+ pred = state.agents["Predator"]
439
+ # If predator visible, flee: turn away then forward
440
+ if visible(prey, pred, state.grid):
441
+ dx = pred.x - prey.x
442
+ dy = pred.y - prey.y
443
+ ang = (math.degrees(math.atan2(dy, dx)) % 360)
444
+ facing = ORI_DEG[prey.ori]
445
+ diff = (ang - facing + 540) % 360 - 180
446
+ # want to face opposite direction: add 180
447
+ diff_away = ((diff + 180) + 540) % 360 - 180
448
+ if diff_away < -10:
449
+ return "L"
450
+ if diff_away > 10:
451
+ return "R"
452
+ return "F"
453
+ # else seek food if adjacent, else wander
454
+ for turn in [0, -1, 1, 2]:
455
+ ori = (prey.ori + turn) % 4
456
+ dx, dy = DIRS[ori]
457
+ nx, ny = prey.x + dx, prey.y + dy
458
+ if in_bounds(nx, ny) and state.grid[ny][nx] == FOOD:
459
+ if turn == 0:
460
+ return "F"
461
+ if turn == -1:
462
+ return "L"
463
+ if turn == 1:
464
+ return "R"
465
+ return "R" # 180 via two rights across ticks; keep simple
466
+ r = rng_for(state.seed, step, stream=2)
467
+ return r.choice(["F", "L", "R", "F"])
468
+
469
+ def scout_policy(state: WorldState, step: int) -> str:
470
+ # Scout tries to keep line-of-sight on predator without colliding
471
+ scout = state.agents["Scout"]
472
+ pred = state.agents["Predator"]
473
+ if los_clear(state.grid, scout.x, scout.y, pred.x, pred.y):
474
+ # orbit-ish: if too close, turn away; else meander
475
+ dist = abs(scout.x - pred.x) + abs(scout.y - pred.y)
476
+ if dist <= 3:
477
+ return "R"
478
+ r = rng_for(state.seed, step, stream=3)
479
+ return r.choice(["F", "L", "R", "F"])
480
+ else:
481
+ # seek predator direction
482
+ dx = pred.x - scout.x
483
+ dy = pred.y - scout.y
484
+ ang = (math.degrees(math.atan2(dy, dx)) % 360)
485
+ facing = ORI_DEG[scout.ori]
486
+ diff = (ang - facing + 540) % 360 - 180
487
+ if diff < -10:
488
+ return "L"
489
+ if diff > 10:
490
+ return "R"
491
+ return "F"
492
+
493
+ # -----------------------------
494
+ # Step simulation
495
+ # -----------------------------
496
+ def apply_action(state: WorldState, agent_name: str, action: str) -> None:
497
+ a = state.agents[agent_name]
498
+ if action == "L":
499
+ turn_left(a)
500
+ elif action == "R":
501
+ turn_right(a)
502
+ elif action == "F":
503
+ move_forward(state, a)
504
+
505
+ def consume_tiles(state: WorldState) -> None:
506
+ prey = state.agents["Prey"]
507
+ tile = state.grid[prey.y][prey.x]
508
+ if tile == FOOD:
509
+ prey.energy = min(200, prey.energy + 35)
510
+ state.grid[prey.y][prey.x] = EMPTY
511
+ state.event_log.append(f"t={state.step}: Prey ate food (+energy).")
512
+
513
+ def check_catch(state: WorldState) -> None:
514
+ pred = state.agents["Predator"]
515
+ prey = state.agents["Prey"]
516
+ if pred.x == prey.x and pred.y == prey.y:
517
+ state.caught = True
518
+ state.event_log.append(f"t={state.step}: CAUGHT.")
519
+
520
+ def tick(state: WorldState, manual_action: Optional[str] = None) -> None:
521
+ if state.caught:
522
+ return
523
+
524
+ # Manual action applies to controlled agent first (if provided)
525
+ if manual_action:
526
+ apply_action(state, state.controlled, manual_action)
527
+
528
+ # Autonomy for the others (and for controlled if autorun)
529
+ step = state.step
530
+ # Controlled agent: if autorun and no manual action this tick, autopilot it
531
+ if state.autorun and not manual_action:
532
+ if state.controlled == "Predator":
533
+ act = predator_policy(state, step)
534
+ elif state.controlled == "Prey":
535
+ act = prey_policy(state, step)
536
+ else:
537
+ act = scout_policy(state, step)
538
+ apply_action(state, state.controlled, act)
539
+
540
+ # Non-controlled always run their policy each tick
541
+ for name in ["Predator", "Prey", "Scout"]:
542
+ if name == state.controlled:
543
+ continue
544
+ if name == "Predator":
545
+ act = predator_policy(state, step)
546
+ elif name == "Prey":
547
+ act = prey_policy(state, step)
548
+ else:
549
+ act = scout_policy(state, step)
550
+ apply_action(state, name, act)
551
+
552
+ consume_tiles(state)
553
+ check_catch(state)
554
+ state.step += 1
555
+
556
+ # -----------------------------
557
+ # History + branching
558
+ # -----------------------------
559
+ MAX_HISTORY = 3000 # keeps rewind practical on Spaces
560
+
561
+ def snapshot_of(state: WorldState) -> Snapshot:
562
+ return Snapshot(
563
+ step=state.step,
564
+ agents={k: asdict(v) for k, v in state.agents.items()},
565
+ grid=[row[:] for row in state.grid],
566
+ event_log_tail=state.event_log[-12:],
567
+ caught=state.caught,
568
+ )
569
+
570
+ def restore_into(state: WorldState, snap: Snapshot) -> None:
571
+ state.step = snap.step
572
+ state.grid = [row[:] for row in snap.grid]
573
+ for k, d in snap.agents.items():
574
+ state.agents[k] = Agent(**d)
575
+ state.caught = snap.caught
576
+ # preserve full log, but annotate jump
577
+ state.event_log.append(f"Jumped to t={snap.step} (rewind).")
578
+
579
+ # -----------------------------
580
+ # Belief updates
581
+ # -----------------------------
582
+ def update_belief_for_agent(state: WorldState, belief: np.ndarray, agent: Agent) -> None:
583
+ # Reveal tiles in a cone up to MAX_DEPTH using simple ray sampling
584
+ # plus always reveal own tile
585
+ belief[agent.y, agent.x] = state.grid[agent.y][agent.x]
586
+
587
+ base = math.radians(ORI_DEG[agent.ori])
588
+ half = math.radians(FOV_DEG / 2)
589
+ rays = 33 if agent.name != "Scout" else 45
590
+
591
+ for i in range(rays):
592
+ t = i / (rays - 1)
593
+ ang = base + (t * 2 - 1) * half
594
+ sin_a, cos_a = math.sin(ang), math.cos(ang)
595
+ ox, oy = agent.x + 0.5, agent.y + 0.5
596
+ depth = 0.0
597
+ while depth < MAX_DEPTH:
598
+ depth += 0.2
599
+ tx = int(ox + cos_a * depth)
600
+ ty = int(oy + sin_a * depth)
601
+ if not in_bounds(tx, ty):
602
+ break
603
+ belief[ty, tx] = state.grid[ty][tx]
604
+ if state.grid[ty][tx] == WALL:
605
+ break
606
+
607
+ # -----------------------------
608
+ # UI orchestration
609
+ # -----------------------------
610
+ def build_views(state: WorldState, beliefs: Dict[str, np.ndarray]) -> Tuple[np.ndarray, Image.Image, Image.Image, Image.Image, str, str]:
611
+ pov_agent = state.agents[state.pov]
612
+
613
+ # Update beliefs each frame (deterministic, based on current truth)
614
+ for name, a in state.agents.items():
615
+ update_belief_for_agent(state, beliefs[name], a)
616
+
617
+ # POV raycast
618
+ pov_img = raycast_view(state, pov_agent)
619
+
620
+ # Truth map
621
+ truth_np = np.array(state.grid, dtype=np.int16)
622
+ truth_img = render_topdown(truth_np, state.agents, f"Truth Map — t={state.step} seed={state.seed}", show_agents=True)
623
+
624
+ # Belief maps (two most interesting: controlled + other)
625
+ ctrl = state.controlled
626
+ other = "Prey" if ctrl == "Predator" else "Predator"
627
+ ctrl_img = render_topdown(beliefs[ctrl], state.agents, f"{ctrl} Belief (Fog-of-War)", show_agents=True)
628
+ other_img = render_topdown(beliefs[other], state.agents, f"{other} Belief (Fog-of-War)", show_agents=True)
629
+
630
+ # Status + log
631
+ pred = state.agents["Predator"]
632
+ prey = state.agents["Prey"]
633
+ scout = state.agents["Scout"]
634
+
635
+ status = (
636
+ f"Controlled: {state.controlled} | POV: {state.pov} | "
637
+ f"AutoRun: {state.autorun} @ {state.speed_hz:.2f} Hz | "
638
+ f"Caught: {state.caught}\n"
639
+ f"Pred({pred.x},{pred.y}) ori={pred.ori} | "
640
+ f"Prey({prey.x},{prey.y}) ori={prey.ori} energy={prey.energy} | "
641
+ f"Scout({scout.x},{scout.y}) ori={scout.ori}"
642
+ )
643
+ log = "\n".join(state.event_log[-14:])
644
+ return pov_img, truth_img, ctrl_img, other_img, status, log
645
+
646
+ def grid_click_to_tile(evt: gr.SelectData, selected_tile: int, state: WorldState) -> WorldState:
647
+ # evt.index is pixel coords (x,y) on truth image; our truth image has 28px title bar
648
+ x_px, y_px = evt.index
649
+ y_px = y_px - 28
650
+ if y_px < 0:
651
+ return state
652
+ gx = int(x_px // TILE)
653
+ gy = int(y_px // TILE)
654
+ if not in_bounds(gx, gy):
655
+ return state
656
+
657
+ # Protect borders from accidental deletion (optional)
658
+ if gx == 0 or gy == 0 or gx == GRID_W - 1 or gy == GRID_H - 1:
659
+ return state
660
+
661
+ state.grid[gy][gx] = selected_tile
662
+ state.event_log.append(f"t={state.step}: Edited tile ({gx},{gy}) -> {TILE_NAMES.get(selected_tile, selected_tile)}.")
663
+ return state
664
+
665
+ def export_run(state: WorldState, history: List[Snapshot]) -> str:
666
+ payload = {
667
+ "seed": state.seed,
668
+ "current_step": state.step,
669
+ "controlled": state.controlled,
670
+ "pov": state.pov,
671
+ "autorun": state.autorun,
672
+ "speed_hz": state.speed_hz,
673
+ "overlay": state.overlay,
674
+ "branches": state.branches,
675
+ "history": [asdict(s) for s in history],
676
+ }
677
+ return json.dumps(payload, indent=2)
678
+
679
+ def import_run(txt: str) -> Tuple[WorldState, List[Snapshot], Dict[str, np.ndarray], int]:
680
+ data = json.loads(txt)
681
+ st = init_state(int(data["seed"]))
682
+ st.controlled = data.get("controlled", "Predator")
683
+ st.pov = data.get("pov", st.controlled)
684
+ st.autorun = bool(data.get("autorun", False))
685
+ st.speed_hz = float(data.get("speed_hz", 8.0))
686
+ st.overlay = bool(data.get("overlay", False))
687
+ st.branches = dict(data.get("branches", {"main": 0}))
688
+
689
+ history = []
690
+ for s in data.get("history", []):
691
+ history.append(Snapshot(**s))
692
+
693
+ beliefs = init_belief()
694
+ rewind_idx = min(len(history) - 1, len(history) - 1 if history else 0)
695
+
696
+ if history:
697
+ restore_into(st, history[-1])
698
+
699
+ st.event_log.append("Imported run.")
700
+ return st, history, beliefs, rewind_idx
701
+
702
+ # -----------------------------
703
+ # Gradio app
704
+ # -----------------------------
705
+ with gr.Blocks(title="ChronoSandbox — Agent Timeline Lab") as demo:
706
+ gr.Markdown(
707
+ "## ChronoSandbox — Agent Timeline Lab\n"
708
+ "Deterministic multi-agent POV sandbox with **time dilation, rewind, and branching timelines**.\n"
709
+ "Everything is explicit: no hidden weights, no magic state."
710
+ )
711
+
712
+ # Persistent state
713
+ st = gr.State(init_state(seed=1337))
714
+ history = gr.State([snapshot_of(init_state(seed=1337))]) # start with step 0
715
+ beliefs = gr.State(init_belief())
716
+ rewind_index = gr.State(0)
717
+
718
+ with gr.Row():
719
+ pov_img = gr.Image(label="First-Person POV (Pseudo-3D)", type="numpy", width=VIEW_W, height=VIEW_H)
720
+ with gr.Column():
721
+ status = gr.Textbox(label="Status", lines=3)
722
+ log = gr.Textbox(label="Event Log", lines=14)
723
+
724
+ with gr.Row():
725
+ truth = gr.Image(label="Truth Map (click to edit tiles)", type="pil")
726
+ belief_a = gr.Image(label="Belief A", type="pil")
727
+ belief_b = gr.Image(label="Belief B", type="pil")
728
+
729
+ with gr.Row():
730
+ with gr.Column(scale=2):
731
+ gr.Markdown("### Controls")
732
+ with gr.Row():
733
+ btn_L = gr.Button("Turn Left (L)")
734
+ btn_F = gr.Button("Forward (F)")
735
+ btn_R = gr.Button("Turn Right (R)")
736
+ with gr.Row():
737
+ toggle_control = gr.Button("Toggle Controlled Agent")
738
+ toggle_pov = gr.Button("Toggle POV Camera")
739
+ btn_step = gr.Button("Tick (Single Step)")
740
+ with gr.Row():
741
+ autorun = gr.Checkbox(False, label="AutoRun")
742
+ overlay = gr.Checkbox(False, label="Overlay (reticle)")
743
+ speed = gr.Slider(0.25, 32.0, value=8.0, step=0.25, label="Speed (Hz) — time dilation")
744
+ tile_pick = gr.Radio(
745
+ choices=[(TILE_NAMES[k], k) for k in [EMPTY, WALL, FOOD, NOISE, DOOR, TELE]],
746
+ value=WALL,
747
+ label="Click-edit tile type"
748
+ )
749
+ with gr.Column(scale=2):
750
+ gr.Markdown("### Time Travel")
751
+ rewind = gr.Slider(0, 0, value=0, step=1, label="Rewind Scrubber (history index)")
752
+ btn_jump = gr.Button("Jump to Rewind Index")
753
+ btn_branch = gr.Button("Branch From Current (fork timeline)")
754
+ branch_name = gr.Textbox(value="branch_1", label="Branch name")
755
+ gr.Markdown("### Import / Export")
756
+ export_box = gr.Textbox(label="Export JSON", lines=10)
757
+ btn_export = gr.Button("Export Run")
758
+ import_box = gr.Textbox(label="Import JSON", lines=10)
759
+ btn_import = gr.Button("Import Run")
760
+
761
+ timer = gr.Timer(0.12) # base UI refresh; actual tick rate controlled by speed_hz + autorun gating
762
+
763
+ def refresh(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int):
764
+ # clamp rewind slider max
765
+ r_max = max(0, len(hist) - 1)
766
+ r_idx = max(0, min(r_idx, r_max))
767
+ pov_np, truth_im, a_im, b_im, stxt, ltxt = build_views(state, bel)
768
+ return (
769
+ pov_np,
770
+ truth_im,
771
+ a_im,
772
+ b_im,
773
+ stxt,
774
+ ltxt,
775
+ gr.update(maximum=r_max, value=r_idx),
776
+ r_idx
777
+ )
778
+
779
+ def do_action(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int, act: str):
780
+ tick(state, manual_action=act)
781
+ hist.append(snapshot_of(state))
782
+ if len(hist) > MAX_HISTORY:
783
+ hist.pop(0)
784
+ r_idx = len(hist) - 1
785
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
786
+
787
+ def do_tick(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int):
788
+ tick(state, manual_action=None)
789
+ hist.append(snapshot_of(state))
790
+ if len(hist) > MAX_HISTORY:
791
+ hist.pop(0)
792
+ r_idx = len(hist) - 1
793
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
794
+
795
+ def set_toggles(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int, ar: bool, sp: float, ov: bool):
796
+ state.autorun = bool(ar)
797
+ state.speed_hz = float(sp)
798
+ state.overlay = bool(ov)
799
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
800
+
801
+ def toggle_control_fn(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int):
802
+ order = ["Predator", "Prey", "Scout"]
803
+ i = order.index(state.controlled)
804
+ state.controlled = order[(i + 1) % len(order)]
805
+ state.event_log.append(f"t={state.step}: Controlled -> {state.controlled}.")
806
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
807
+
808
+ def toggle_pov_fn(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int):
809
+ order = ["Predator", "Prey", "Scout"]
810
+ i = order.index(state.pov)
811
+ state.pov = order[(i + 1) % len(order)]
812
+ state.event_log.append(f"t={state.step}: POV -> {state.pov}.")
813
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
814
+
815
+ def jump_fn(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int, idx: int):
816
+ if not hist:
817
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
818
+ idx = int(idx)
819
+ idx = max(0, min(idx, len(hist) - 1))
820
+ restore_into(state, hist[idx])
821
+ r_idx = idx
822
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
823
+
824
+ def branch_fn(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int, name: str):
825
+ nm = (name or "").strip() or f"branch_{len(state.branches)+1}"
826
+ state.branches[nm] = r_idx
827
+ state.event_log.append(f"t={state.step}: Branched timeline '{nm}' at history idx={r_idx}.")
828
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
829
+
830
+ def truth_click(evt: gr.SelectData, tile: int, state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int):
831
+ # apply edit, snapshot after edit
832
+ state = grid_click_to_tile(evt, int(tile), state)
833
+ hist.append(snapshot_of(state))
834
+ if len(hist) > MAX_HISTORY:
835
+ hist.pop(0)
836
+ r_idx = len(hist) - 1
837
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
838
+
839
+ def export_fn(state: WorldState, hist: List[Snapshot]):
840
+ return export_run(state, hist)
841
+
842
+ def import_fn(txt: str):
843
+ state, hist, bel, r_idx = import_run(txt)
844
+ # refresh outputs + return states
845
+ pov_np, truth_im, a_im, b_im, stxt, ltxt = build_views(state, bel)
846
+ r_max = max(0, len(hist) - 1)
847
+ return (
848
+ pov_np, truth_im, a_im, b_im, stxt, ltxt,
849
+ gr.update(maximum=r_max, value=r_idx),
850
+ state, hist, bel, r_idx
851
+ )
852
+
853
+ # Buttons
854
+ btn_L.click(do_action, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], api_name=False, queue=True, fn_kwargs={"act": "L"})
855
+ btn_F.click(do_action, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], api_name=False, queue=True, fn_kwargs={"act": "F"})
856
+ btn_R.click(do_action, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], api_name=False, queue=True, fn_kwargs={"act": "R"})
857
+
858
+ btn_step.click(do_tick, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
859
+
860
+ toggle_control.click(toggle_control_fn, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
861
+ toggle_pov.click(toggle_pov_fn, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
862
+
863
+ autorun.change(set_toggles, inputs=[st, history, beliefs, rewind_index, autorun, speed, overlay], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
864
+ speed.change(set_toggles, inputs=[st, history, beliefs, rewind_index, autorun, speed, overlay], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
865
+ overlay.change(set_toggles, inputs=[st, history, beliefs, rewind_index, autorun, speed, overlay], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
866
+
867
+ btn_jump.click(jump_fn, inputs=[st, history, beliefs, rewind_index, rewind], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
868
+ btn_branch.click(branch_fn, inputs=[st, history, beliefs, rewind_index, branch_name], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
869
+
870
+ truth.select(truth_click, inputs=[tile_pick, st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index], queue=True)
871
+
872
+ btn_export.click(export_fn, inputs=[st, history], outputs=[export_box], queue=True)
873
+ btn_import.click(import_fn, inputs=[import_box], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, st, history, beliefs, rewind_index], queue=True)
874
+
875
+ # Timer-driven autorun
876
+ def timer_fn(state: WorldState, hist: List[Snapshot], bel: Dict[str, np.ndarray], r_idx: int, ar: bool, sp: float):
877
+ state.autorun = bool(ar)
878
+ state.speed_hz = float(sp)
879
+
880
+ if not state.autorun or state.caught:
881
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
882
+
883
+ # How many sim ticks per UI frame?
884
+ # timer runs ~8.33 Hz (0.12s). We convert desired Hz to ticks per frame.
885
+ ticks_per_frame = max(1, int(round(state.speed_hz * 0.12)))
886
+ for _ in range(ticks_per_frame):
887
+ tick(state, manual_action=None)
888
+ hist.append(snapshot_of(state))
889
+ if len(hist) > MAX_HISTORY:
890
+ hist.pop(0)
891
+
892
+ r_idx = len(hist) - 1
893
+ return refresh(state, hist, bel, r_idx) + (state, hist, bel, r_idx)
894
+
895
+ timer.tick(
896
+ timer_fn,
897
+ inputs=[st, history, beliefs, rewind_index, autorun, speed],
898
+ outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index, st, history, beliefs, rewind_index],
899
+ queue=True
900
+ )
901
+
902
+ # Initial paint
903
+ demo.load(refresh, inputs=[st, history, beliefs, rewind_index], outputs=[pov_img, truth, belief_a, belief_b, status, log, rewind, rewind_index], queue=True)
904
+
905
+ demo.queue().launch()