RayMelius Claude Opus 4.6 commited on
Commit
fb40e92
·
1 Parent(s): da24623

Add simulation speed controls: pause/resume and speed adjustment

Browse files

- Add pause/resume and speed control (0.25x to 5x) to API server
- New endpoints: GET/POST /api/controls, /controls/pause, /controls/resume, /controls/speed
- Web UI header now has play/pause button, fast/normal/slow speed buttons
- Speed label shows current state (PAUSED, 4x, 2x, 1x, 0.3x)
- Controls state persisted across page refreshes via API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. src/soci/api/routes.py +31 -0
  2. src/soci/api/server.py +7 -1
  3. web/index.html +74 -0
src/soci/api/routes.py CHANGED
@@ -300,6 +300,37 @@ async def get_events(limit: int = 50):
300
  return {"events": events}
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  @router.post("/save")
304
  async def save_state(name: str = "manual_save"):
305
  """Manually save the simulation state."""
 
300
  return {"events": events}
301
 
302
 
303
+ @router.get("/controls")
304
+ async def get_controls():
305
+ """Get current simulation control state."""
306
+ from soci.api.server import _sim_paused, _sim_speed
307
+ return {"paused": _sim_paused, "speed": _sim_speed}
308
+
309
+
310
+ @router.post("/controls/pause")
311
+ async def pause_simulation():
312
+ """Pause the simulation."""
313
+ import soci.api.server as srv
314
+ srv._sim_paused = True
315
+ return {"paused": True}
316
+
317
+
318
+ @router.post("/controls/resume")
319
+ async def resume_simulation():
320
+ """Resume the simulation."""
321
+ import soci.api.server as srv
322
+ srv._sim_paused = False
323
+ return {"paused": False}
324
+
325
+
326
+ @router.post("/controls/speed")
327
+ async def set_speed(multiplier: float = 1.0):
328
+ """Set simulation speed. 0.25=fast, 1.0=normal, 3.0=slow."""
329
+ import soci.api.server as srv
330
+ srv._sim_speed = max(0.1, min(5.0, multiplier))
331
+ return {"speed": srv._sim_speed}
332
+
333
+
334
  @router.post("/save")
335
  async def save_state(name: str = "manual_save"):
336
  """Manually save the simulation state."""
src/soci/api/server.py CHANGED
@@ -28,6 +28,8 @@ logger = logging.getLogger(__name__)
28
  _simulation: Optional[Simulation] = None
29
  _database: Optional[Database] = None
30
  _sim_task: Optional[asyncio.Task] = None
 
 
31
 
32
 
33
  def get_simulation() -> Simulation:
@@ -42,13 +44,17 @@ def get_database() -> Database:
42
 
43
  async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0) -> None:
44
  """Background task that runs the simulation continuously."""
 
45
  while True:
46
  try:
 
 
 
47
  await sim.tick()
48
  # Auto-save every 24 ticks
49
  if sim.clock.total_ticks % 24 == 0:
50
  await save_simulation(sim, db, "autosave")
51
- await asyncio.sleep(tick_delay)
52
  except asyncio.CancelledError:
53
  logger.info("Simulation loop cancelled")
54
  await save_simulation(sim, db, "autosave")
 
28
  _simulation: Optional[Simulation] = None
29
  _database: Optional[Database] = None
30
  _sim_task: Optional[asyncio.Task] = None
31
+ _sim_paused: bool = False
32
+ _sim_speed: float = 1.0 # 1.0 = normal, 0.5 = fast, 2.0 = slow
33
 
34
 
35
  def get_simulation() -> Simulation:
 
44
 
45
  async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0) -> None:
46
  """Background task that runs the simulation continuously."""
47
+ global _sim_paused, _sim_speed
48
  while True:
49
  try:
50
+ if _sim_paused:
51
+ await asyncio.sleep(0.5)
52
+ continue
53
  await sim.tick()
54
  # Auto-save every 24 ticks
55
  if sim.clock.total_ticks % 24 == 0:
56
  await save_simulation(sim, db, "autosave")
57
+ await asyncio.sleep(tick_delay * _sim_speed)
58
  except asyncio.CancelledError:
59
  logger.info("Simulation loop cancelled")
60
  await save_simulation(sim, db, "autosave")
web/index.html CHANGED
@@ -131,6 +131,18 @@
131
  .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
132
  .rel-mini-fill { height: 100%; border-radius: 2px; }
133
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  ::-webkit-scrollbar { width: 6px; }
135
  ::-webkit-scrollbar-track { background: #1a1a2e; }
136
  ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
@@ -144,6 +156,13 @@
144
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
145
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
146
  <span id="conv-count">0 convos</span>
 
 
 
 
 
 
 
147
  <span id="api-calls">API: 0</span>
148
  <span id="cost">$0.00</span>
149
  <span id="status"><span class="dot yellow"></span> Connecting...</span>
@@ -1108,12 +1127,67 @@ async function fetchState() {
1108
  }
1109
  }
1110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1111
  // ============================================================
1112
  // INIT
1113
  // ============================================================
1114
  initCanvas();
1115
  showDefaultDetail();
1116
  fetchState();
 
1117
  setInterval(fetchState, POLL_INTERVAL);
1118
  </script>
1119
  </body>
 
131
  .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
132
  .rel-mini-fill { height: 100%; border-radius: 2px; }
133
 
134
+ /* CONTROLS */
135
+ .controls { display: flex; align-items: center; gap: 4px; }
136
+ .ctrl-btn {
137
+ background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0;
138
+ padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px;
139
+ transition: all 0.15s;
140
+ }
141
+ .ctrl-btn:hover { background: #1a4a80; color: #fff; }
142
+ .ctrl-btn.active { background: #4ecca3; color: #1a1a2e; border-color: #4ecca3; }
143
+ .ctrl-btn.paused { background: #e94560; color: #fff; border-color: #e94560; }
144
+ .speed-label { font-size: 10px; color: #666; margin-left: 2px; }
145
+
146
  ::-webkit-scrollbar { width: 6px; }
147
  ::-webkit-scrollbar-track { background: #1a1a2e; }
148
  ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
 
156
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
157
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
158
  <span id="conv-count">0 convos</span>
159
+ <span class="controls">
160
+ <button class="ctrl-btn" id="btn-pause" onclick="togglePause()" title="Pause/Resume">&#x23EF;</button>
161
+ <button class="ctrl-btn" id="btn-fast" onclick="setSpeed(0.25)" title="Fast">&#x23E9;</button>
162
+ <button class="ctrl-btn active" id="btn-normal" onclick="setSpeed(1.0)" title="Normal">1x</button>
163
+ <button class="ctrl-btn" id="btn-slow" onclick="setSpeed(3.0)" title="Slow">&#x1F422;</button>
164
+ <span class="speed-label" id="speed-label">1x</span>
165
+ </span>
166
  <span id="api-calls">API: 0</span>
167
  <span id="cost">$0.00</span>
168
  <span id="status"><span class="dot yellow"></span> Connecting...</span>
 
1127
  }
1128
  }
1129
 
1130
+ // ============================================================
1131
+ // CONTROLS
1132
+ // ============================================================
1133
+ let simPaused = false;
1134
+ let simSpeed = 1.0;
1135
+
1136
+ async function togglePause() {
1137
+ try {
1138
+ const endpoint = simPaused ? 'resume' : 'pause';
1139
+ const res = await fetch(`${API_BASE}/controls/${endpoint}`, { method: 'POST' });
1140
+ if (res.ok) {
1141
+ const data = await res.json();
1142
+ simPaused = data.paused;
1143
+ updateControlsUI();
1144
+ }
1145
+ } catch(e) {}
1146
+ }
1147
+
1148
+ async function setSpeed(mult) {
1149
+ try {
1150
+ const res = await fetch(`${API_BASE}/controls/speed?multiplier=${mult}`, { method: 'POST' });
1151
+ if (res.ok) {
1152
+ const data = await res.json();
1153
+ simSpeed = data.speed;
1154
+ updateControlsUI();
1155
+ }
1156
+ } catch(e) {}
1157
+ }
1158
+
1159
+ function updateControlsUI() {
1160
+ const pauseBtn = document.getElementById('btn-pause');
1161
+ pauseBtn.className = 'ctrl-btn' + (simPaused ? ' paused' : '');
1162
+ pauseBtn.title = simPaused ? 'Resume' : 'Pause';
1163
+
1164
+ document.getElementById('btn-fast').className = 'ctrl-btn' + (simSpeed <= 0.3 ? ' active' : '');
1165
+ document.getElementById('btn-normal').className = 'ctrl-btn' + (simSpeed > 0.3 && simSpeed <= 1.5 ? ' active' : '');
1166
+ document.getElementById('btn-slow').className = 'ctrl-btn' + (simSpeed > 1.5 ? ' active' : '');
1167
+
1168
+ const label = simPaused ? 'PAUSED' : (simSpeed <= 0.3 ? '4x' : (simSpeed <= 0.6 ? '2x' : (simSpeed <= 1.5 ? '1x' : '0.3x')));
1169
+ document.getElementById('speed-label').textContent = label;
1170
+ }
1171
+
1172
+ async function fetchControls() {
1173
+ try {
1174
+ const res = await fetch(`${API_BASE}/controls`);
1175
+ if (res.ok) {
1176
+ const data = await res.json();
1177
+ simPaused = data.paused;
1178
+ simSpeed = data.speed;
1179
+ updateControlsUI();
1180
+ }
1181
+ } catch(e) {}
1182
+ }
1183
+
1184
  // ============================================================
1185
  // INIT
1186
  // ============================================================
1187
  initCanvas();
1188
  showDefaultDetail();
1189
  fetchState();
1190
+ fetchControls();
1191
  setInterval(fetchState, POLL_INTERVAL);
1192
  </script>
1193
  </body>