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>
- src/soci/api/routes.py +31 -0
- src/soci/api/server.py +7 -1
- 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">⏯</button>
|
| 161 |
+
<button class="ctrl-btn" id="btn-fast" onclick="setSpeed(0.25)" title="Fast">⏩</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">🐢</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>
|