DeltaZN commited on
Commit
86758a1
·
1 Parent(s): a3fd468

feat: Implement deterministic runtime configuration & small visual fixes

Browse files
README.md CHANGED
@@ -92,13 +92,19 @@ other than `config/game.modal.local.json`.
92
  ## LLM Connector
93
 
94
  The default runtime config is `config/game.modal.local.json`, which uses the
95
- vLLM Modal endpoints for NPC decisions and God Console. To run without model
96
- calls, start the deterministic offline config explicitly:
 
97
 
98
  ```powershell
99
- uv run python -m god_simulator run --config config/game.local.json
 
100
  ```
101
 
 
 
 
 
102
  The world is a 20 by 20 grid rendered as an 80 by 80 terrain, so one block is 4
103
  world units. NPCs start with 100 health and a deterministic base attack value
104
  between 5 and 15. The LLM connector uses the official `openai` Python client
@@ -157,14 +163,18 @@ by `connector.max_parallel_npc_calls`.
157
 
158
  The connector choice is binary: `connector.type: "deterministic"` makes every
159
  tick deterministic, while `connector.type: "openai_compatible"` asks the model
160
- on every tick.
 
 
161
 
162
  When the UI first loads, it fetches `/claw3d/state?warmup=1`. That tells the
163
  backend to start fire-and-forget health warmups for configured Modal model
164
- endpoints. Each resolved `.modal.run/health` URL is pinged in a daemon thread
165
- without a request timeout. Normal UI refreshes only read the latest model
166
- statuses. The UI shows each model as `checking`, `warmup` if no response has
167
- arrived after two seconds, or `ready` after HTTP 200.
 
 
168
 
169
  For Nemotron 3 Nano tool calling, the Modal configs use reasoning-off chat
170
  template kwargs and NVIDIA's recommended tool-call sampling values:
 
92
  ## LLM Connector
93
 
94
  The default runtime config is `config/game.modal.local.json`, which uses the
95
+ vLLM Modal endpoints for NPC decisions and God Console. For a temporary local
96
+ run without model calls, force deterministic runtime
97
+ resolvers:
98
 
99
  ```powershell
100
+ $env:GOD_SIMULATOR_FORCE_DETERMINISTIC="1"
101
+ uv run python -m god_simulator run
102
  ```
103
 
104
+ Unset `GOD_SIMULATOR_FORCE_DETERMINISTIC` to return to the configured model
105
+ connectors. You can also start the deterministic offline config explicitly with
106
+ `--config config/game.local.json`.
107
+
108
  The world is a 20 by 20 grid rendered as an 80 by 80 terrain, so one block is 4
109
  world units. NPCs start with 100 health and a deterministic base attack value
110
  between 5 and 15. The LLM connector uses the official `openai` Python client
 
163
 
164
  The connector choice is binary: `connector.type: "deterministic"` makes every
165
  tick deterministic, while `connector.type: "openai_compatible"` asks the model
166
+ on every tick. `GOD_SIMULATOR_FORCE_DETERMINISTIC=1` overrides the active
167
+ runtime to deterministic NPC ticks, disables model-backed God Console, and
168
+ removes Modal warmup targets for that process.
169
 
170
  When the UI first loads, it fetches `/claw3d/state?warmup=1`. That tells the
171
  backend to start fire-and-forget health warmups for configured Modal model
172
+ endpoints. With `GOD_SIMULATOR_FORCE_DETERMINISTIC=1` or
173
+ `config/game.local.json`, there are no Modal targets to warm. Each resolved
174
+ `.modal.run/health` URL is pinged in a daemon thread without a request timeout.
175
+ Normal UI refreshes only read the latest model statuses. The UI shows each model
176
+ as `checking`, `warmup` if no response has arrived after two seconds, or `ready`
177
+ after HTTP 200.
178
 
179
  For Nemotron 3 Nano tool calling, the Modal configs use reasoning-off chat
180
  template kwargs and NVIDIA's recommended tool-call sampling values:
frontend/src/scene/BeastActor.tsx CHANGED
@@ -6,9 +6,11 @@ import { MathUtils, Vector3 } from "three";
6
 
7
  import type { BeastSnapshot } from "../types";
8
  import { angularDistance, dampAngle, hashToUnit } from "./characterUtils";
 
9
 
10
  type BeastActorProps = {
11
  beast: BeastSnapshot;
 
12
  focusTarget: readonly [number, number, number] | null;
13
  isSelected: boolean;
14
  onSelect: () => void;
@@ -41,7 +43,7 @@ const DEAD_PALETTE: BeastPalette = {
41
  eyes: "#1f1b19",
42
  };
43
 
44
- export function BeastActor({ beast, focusTarget, isSelected, onSelect }: BeastActorProps) {
45
  const groupRef = useRef<Group>(null);
46
  const pulseRef = useRef<Mesh>(null);
47
  const bodyRef = useRef<Group>(null);
@@ -59,10 +61,16 @@ export function BeastActor({ beast, focusTarget, isSelected, onSelect }: BeastAc
59
  beast.position.y,
60
  beast.position.z,
61
  ]);
 
 
 
 
62
 
63
  const isDead = beast.state === "dead";
64
  const palette = isDead ? DEAD_PALETTE : LIVING_PALETTE;
65
 
 
 
66
  useFrame((state, delta) => {
67
  const group = groupRef.current;
68
  if (!group) {
@@ -70,7 +78,9 @@ export function BeastActor({ beast, focusTarget, isSelected, onSelect }: BeastAc
70
  }
71
 
72
  const time = state.clock.elapsedTime + animationPhase;
73
- const walkTarget = isDead ? 0 : updatePosition(group, beast, focusTarget, moveDirection, delta);
 
 
74
  walkBlendRef.current = MathUtils.damp(walkBlendRef.current, walkTarget, 7, delta);
75
 
76
  if (pulseRef.current) {
@@ -165,19 +175,19 @@ export function BeastActor({ beast, focusTarget, isSelected, onSelect }: BeastAc
165
 
166
  function updatePosition(
167
  group: Group,
168
- beast: BeastSnapshot,
169
  focusTarget: readonly [number, number, number] | null,
170
  moveDirection: Vector3,
171
  delta: number,
172
  ): number {
173
- group.position.y = MathUtils.damp(group.position.y, beast.position.y, 8, delta);
174
 
175
- const deltaX = beast.position.x - group.position.x;
176
- const deltaZ = beast.position.z - group.position.z;
177
  const distanceToTarget = Math.hypot(deltaX, deltaZ);
178
  if (distanceToTarget <= 0.025) {
179
- group.position.x = beast.position.x;
180
- group.position.z = beast.position.z;
181
  if (focusTarget) {
182
  const focusDeltaX = focusTarget[0] - group.position.x;
183
  const focusDeltaZ = focusTarget[2] - group.position.z;
 
6
 
7
  import type { BeastSnapshot } from "../types";
8
  import { angularDistance, dampAngle, hashToUnit } from "./characterUtils";
9
+ import { useCompletePreviousTickPosition } from "./useCompletePreviousTickPosition";
10
 
11
  type BeastActorProps = {
12
  beast: BeastSnapshot;
13
+ tick: number;
14
  focusTarget: readonly [number, number, number] | null;
15
  isSelected: boolean;
16
  onSelect: () => void;
 
43
  eyes: "#1f1b19",
44
  };
45
 
46
+ export function BeastActor({ beast, tick, focusTarget, isSelected, onSelect }: BeastActorProps) {
47
  const groupRef = useRef<Group>(null);
48
  const pulseRef = useRef<Mesh>(null);
49
  const bodyRef = useRef<Group>(null);
 
61
  beast.position.y,
62
  beast.position.z,
63
  ]);
64
+ const target = useMemo(
65
+ () => [beast.position.x, beast.position.y, beast.position.z] as const,
66
+ [beast.position.x, beast.position.y, beast.position.z],
67
+ );
68
 
69
  const isDead = beast.state === "dead";
70
  const palette = isDead ? DEAD_PALETTE : LIVING_PALETTE;
71
 
72
+ useCompletePreviousTickPosition({ groupRef, tick, target });
73
+
74
  useFrame((state, delta) => {
75
  const group = groupRef.current;
76
  if (!group) {
 
78
  }
79
 
80
  const time = state.clock.elapsedTime + animationPhase;
81
+ const walkTarget = isDead
82
+ ? 0
83
+ : updatePosition(group, target, focusTarget, moveDirection, delta);
84
  walkBlendRef.current = MathUtils.damp(walkBlendRef.current, walkTarget, 7, delta);
85
 
86
  if (pulseRef.current) {
 
175
 
176
  function updatePosition(
177
  group: Group,
178
+ target: readonly [number, number, number],
179
  focusTarget: readonly [number, number, number] | null,
180
  moveDirection: Vector3,
181
  delta: number,
182
  ): number {
183
+ group.position.y = MathUtils.damp(group.position.y, target[1], 8, delta);
184
 
185
+ const deltaX = target[0] - group.position.x;
186
+ const deltaZ = target[2] - group.position.z;
187
  const distanceToTarget = Math.hypot(deltaX, deltaZ);
188
  if (distanceToTarget <= 0.025) {
189
+ group.position.x = target[0];
190
+ group.position.z = target[2];
191
  if (focusTarget) {
192
  const focusDeltaX = focusTarget[0] - group.position.x;
193
  const focusDeltaZ = focusTarget[2] - group.position.z;
frontend/src/scene/NpcActor.tsx CHANGED
@@ -7,16 +7,18 @@ import type { EntitySnapshot } from "../types";
7
  import { faceVariant, hashToUnit, rolePalette } from "./characterUtils";
8
  import { NpcBody } from "./NpcBody";
9
  import { NpcLabelButton } from "./NpcLabelButton";
 
10
  import { useNpcActorAnimation } from "./useNpcActorAnimation";
11
 
12
  type NpcActorProps = {
13
  entity: EntitySnapshot;
 
14
  focusTarget: readonly [number, number, number] | null;
15
  isSelected: boolean;
16
  onSelect: () => void;
17
  };
18
 
19
- export function NpcActor({ entity, focusTarget, isSelected, onSelect }: NpcActorProps) {
20
  const groupRef = useRef<Group>(null);
21
  const pulseRef = useRef<Mesh>(null);
22
  const characterRef = useRef<Group>(null);
@@ -52,6 +54,8 @@ export function NpcActor({ entity, focusTarget, isSelected, onSelect }: NpcActor
52
  rightLegRef,
53
  };
54
 
 
 
55
  useNpcActorAnimation({
56
  entity,
57
  isSelected,
 
7
  import { faceVariant, hashToUnit, rolePalette } from "./characterUtils";
8
  import { NpcBody } from "./NpcBody";
9
  import { NpcLabelButton } from "./NpcLabelButton";
10
+ import { useCompletePreviousTickPosition } from "./useCompletePreviousTickPosition";
11
  import { useNpcActorAnimation } from "./useNpcActorAnimation";
12
 
13
  type NpcActorProps = {
14
  entity: EntitySnapshot;
15
+ tick: number;
16
  focusTarget: readonly [number, number, number] | null;
17
  isSelected: boolean;
18
  onSelect: () => void;
19
  };
20
 
21
+ export function NpcActor({ entity, tick, focusTarget, isSelected, onSelect }: NpcActorProps) {
22
  const groupRef = useRef<Group>(null);
23
  const pulseRef = useRef<Mesh>(null);
24
  const characterRef = useRef<Group>(null);
 
54
  rightLegRef,
55
  };
56
 
57
+ useCompletePreviousTickPosition({ groupRef, tick, target });
58
+
59
  useNpcActorAnimation({
60
  entity,
61
  isSelected,
frontend/src/scene/WorldView.tsx CHANGED
@@ -42,6 +42,7 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
42
  <NpcActor
43
  key={entity.id}
44
  entity={entity}
 
45
  focusTarget={
46
  entity.state.focus_target_id
47
  ? (positionsById.get(entity.state.focus_target_id) ?? null)
@@ -71,6 +72,7 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
71
  <BeastActor
72
  key={beast.id}
73
  beast={beast}
 
74
  focusTarget={
75
  beast.state === "attacking"
76
  ? (positionsById.get(beast.target_npc_id ?? beast.target_house_id ?? "") ?? null)
@@ -115,7 +117,10 @@ function HouseBuilding({ house, isSelected, onSelect }: HouseBuildingProps) {
115
  return (
116
  <group position={[house.position.x, house.position.y, house.position.z]} onClick={handleSelect}>
117
  {isComplete ? (
118
- <>
 
 
 
119
  <mesh castShadow receiveShadow position={[0, 1.0, 0]}>
120
  <boxGeometry args={[3.2, 2.0, 3.2]} />
121
  <meshStandardMaterial map={textures.wall} roughness={0.85} />
@@ -136,9 +141,9 @@ function HouseBuilding({ house, isSelected, onSelect }: HouseBuildingProps) {
136
  <meshStandardMaterial map={textures.window} roughness={0.4} />
137
  </mesh>
138
  ))}
139
- </>
140
  ) : isDestroyed ? (
141
- <>
142
  {/* Charred wall stumps where the house stood. */}
143
  <mesh castShadow receiveShadow position={[-0.95, 0.34, -1.05]} rotation={[0, 0.12, 0]}>
144
  <boxGeometry args={[1.3, 0.68, 1.1]} />
@@ -180,9 +185,9 @@ function HouseBuilding({ house, isSelected, onSelect }: HouseBuildingProps) {
180
  <meshStandardMaterial color="#6b5d51" roughness={0.95} />
181
  </mesh>
182
  ))}
183
- </>
184
  ) : (
185
- <>
186
  {/* Frame rising with build progress. */}
187
  <mesh castShadow receiveShadow position={[0, Math.max(0.15, buildRatio), 0]}>
188
  <boxGeometry args={[3.2, Math.max(0.3, buildRatio * 2.0), 3.2]} />
@@ -202,7 +207,7 @@ function HouseBuilding({ house, isSelected, onSelect }: HouseBuildingProps) {
202
  </mesh>
203
  )),
204
  )}
205
- </>
206
  )}
207
 
208
  <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.035, 0]}>
@@ -270,12 +275,20 @@ type TerrainProps = {
270
 
271
  const VOXEL_SIZE = 1;
272
  const VOXEL_HEIGHT = 0.6;
 
 
 
 
273
 
274
  function Terrain({ width, depth, color }: TerrainProps) {
275
  const meshRef = useRef<InstancedMesh>(null);
276
- const cols = Math.max(1, Math.round(width / VOXEL_SIZE));
277
- const rows = Math.max(1, Math.round(depth / VOXEL_SIZE));
278
  const count = cols * rows;
 
 
 
 
279
 
280
  useLayoutEffect(() => {
281
  const mesh = meshRef.current;
@@ -289,8 +302,8 @@ function Terrain({ width, depth, color }: TerrainProps) {
289
  let index = 0;
290
  for (let col = 0; col < cols; col += 1) {
291
  for (let row = 0; row < rows; row += 1) {
292
- const x = (col + 0.5) * VOXEL_SIZE - width / 2;
293
- const z = (row + 0.5) * VOXEL_SIZE - depth / 2;
294
  const heightRoll = tileNoise(col, row, 1);
295
  const tintRoll = tileNoise(col, row, 2);
296
  // Subtle steps only — keep the surface readable as flat ground.
@@ -310,7 +323,7 @@ function Terrain({ width, depth, color }: TerrainProps) {
310
  if (mesh.instanceColor) {
311
  mesh.instanceColor.needsUpdate = true;
312
  }
313
- }, [cols, rows, width, depth, color]);
314
 
315
  return (
316
  <group>
@@ -320,7 +333,7 @@ function Terrain({ width, depth, color }: TerrainProps) {
320
  </instancedMesh>
321
  {/* dirt slab so the world edge reads as a floating voxel block */}
322
  <mesh position={[0, -VOXEL_HEIGHT - 0.7, 0]}>
323
- <boxGeometry args={[width, 1.6, depth]} />
324
  <meshStandardMaterial color="#6b4a2c" roughness={0.95} />
325
  </mesh>
326
  </group>
 
42
  <NpcActor
43
  key={entity.id}
44
  entity={entity}
45
+ tick={snapshot.tick}
46
  focusTarget={
47
  entity.state.focus_target_id
48
  ? (positionsById.get(entity.state.focus_target_id) ?? null)
 
72
  <BeastActor
73
  key={beast.id}
74
  beast={beast}
75
+ tick={snapshot.tick}
76
  focusTarget={
77
  beast.state === "attacking"
78
  ? (positionsById.get(beast.target_npc_id ?? beast.target_house_id ?? "") ?? null)
 
117
  return (
118
  <group position={[house.position.x, house.position.y, house.position.z]} onClick={handleSelect}>
119
  {isComplete ? (
120
+ // Keyed so a house transitioning building -> completed remounts these
121
+ // meshes instead of reusing the frame's material (which would leak its
122
+ // transparent/opacity/color onto the wall and make it render wrong).
123
+ <group key="completed">
124
  <mesh castShadow receiveShadow position={[0, 1.0, 0]}>
125
  <boxGeometry args={[3.2, 2.0, 3.2]} />
126
  <meshStandardMaterial map={textures.wall} roughness={0.85} />
 
141
  <meshStandardMaterial map={textures.window} roughness={0.4} />
142
  </mesh>
143
  ))}
144
+ </group>
145
  ) : isDestroyed ? (
146
+ <group key="destroyed">
147
  {/* Charred wall stumps where the house stood. */}
148
  <mesh castShadow receiveShadow position={[-0.95, 0.34, -1.05]} rotation={[0, 0.12, 0]}>
149
  <boxGeometry args={[1.3, 0.68, 1.1]} />
 
185
  <meshStandardMaterial color="#6b5d51" roughness={0.95} />
186
  </mesh>
187
  ))}
188
+ </group>
189
  ) : (
190
+ <group key="building">
191
  {/* Frame rising with build progress. */}
192
  <mesh castShadow receiveShadow position={[0, Math.max(0.15, buildRatio), 0]}>
193
  <boxGeometry args={[3.2, Math.max(0.3, buildRatio * 2.0), 3.2]} />
 
207
  </mesh>
208
  )),
209
  )}
210
+ </group>
211
  )}
212
 
213
  <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.035, 0]}>
 
275
 
276
  const VOXEL_SIZE = 1;
277
  const VOXEL_HEIGHT = 0.6;
278
+ // Purely visual border (in tiles per side) so structures placed on the
279
+ // playable edge still rest on ground instead of hanging into the void.
280
+ // Gameplay bounds are unchanged — the backend never places anything here.
281
+ const EDGE_MARGIN = 3;
282
 
283
  function Terrain({ width, depth, color }: TerrainProps) {
284
  const meshRef = useRef<InstancedMesh>(null);
285
+ const cols = Math.max(1, Math.round(width / VOXEL_SIZE)) + EDGE_MARGIN * 2;
286
+ const rows = Math.max(1, Math.round(depth / VOXEL_SIZE)) + EDGE_MARGIN * 2;
287
  const count = cols * rows;
288
+ // Rendered span including the border; keeps the grid centered on the origin
289
+ // so playable coordinates from the backend still line up.
290
+ const renderWidth = cols * VOXEL_SIZE;
291
+ const renderDepth = rows * VOXEL_SIZE;
292
 
293
  useLayoutEffect(() => {
294
  const mesh = meshRef.current;
 
302
  let index = 0;
303
  for (let col = 0; col < cols; col += 1) {
304
  for (let row = 0; row < rows; row += 1) {
305
+ const x = (col + 0.5) * VOXEL_SIZE - renderWidth / 2;
306
+ const z = (row + 0.5) * VOXEL_SIZE - renderDepth / 2;
307
  const heightRoll = tileNoise(col, row, 1);
308
  const tintRoll = tileNoise(col, row, 2);
309
  // Subtle steps only — keep the surface readable as flat ground.
 
323
  if (mesh.instanceColor) {
324
  mesh.instanceColor.needsUpdate = true;
325
  }
326
+ }, [cols, rows, renderWidth, renderDepth, color]);
327
 
328
  return (
329
  <group>
 
333
  </instancedMesh>
334
  {/* dirt slab so the world edge reads as a floating voxel block */}
335
  <mesh position={[0, -VOXEL_HEIGHT - 0.7, 0]}>
336
+ <boxGeometry args={[renderWidth, 1.6, renderDepth]} />
337
  <meshStandardMaterial color="#6b4a2c" roughness={0.95} />
338
  </mesh>
339
  </group>
frontend/src/scene/useCompletePreviousTickPosition.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import type { RefObject } from "react";
3
+ import type { Group } from "three";
4
+
5
+ type ActorPosition = readonly [number, number, number];
6
+
7
+ type CompletePreviousTickPositionOptions = {
8
+ groupRef: RefObject<Group | null>;
9
+ tick: number;
10
+ target: ActorPosition;
11
+ };
12
+
13
+ const SETTLED_DISTANCE = 0.025;
14
+
15
+ export function useCompletePreviousTickPosition({
16
+ groupRef,
17
+ tick,
18
+ target,
19
+ }: CompletePreviousTickPositionOptions) {
20
+ const previousTickRef = useRef(tick);
21
+ const previousTargetRef = useRef<ActorPosition>(target);
22
+
23
+ useLayoutEffect(() => {
24
+ const group = groupRef.current;
25
+ const previousTarget = previousTargetRef.current;
26
+ if (group && tick !== previousTickRef.current && !isAtPosition(group, previousTarget)) {
27
+ group.position.set(previousTarget[0], previousTarget[1], previousTarget[2]);
28
+ }
29
+
30
+ previousTickRef.current = tick;
31
+ previousTargetRef.current = target;
32
+ }, [groupRef, target, tick]);
33
+ }
34
+
35
+ function isAtPosition(group: Group, target: ActorPosition): boolean {
36
+ return (
37
+ Math.abs(group.position.y - target[1]) <= SETTLED_DISTANCE &&
38
+ Math.hypot(group.position.x - target[0], group.position.z - target[2]) <= SETTLED_DISTANCE
39
+ );
40
+ }
scripts/playtest_agent.py CHANGED
@@ -3,11 +3,11 @@ from __future__ import annotations
3
  import argparse
4
  import json
5
  import os
 
 
6
  from collections import Counter
7
  from dataclasses import dataclass, field
8
  from pathlib import Path
9
- import sys
10
- import time
11
  from typing import Any
12
  from urllib.error import URLError
13
  from urllib.parse import urlparse
@@ -26,6 +26,7 @@ from god_simulator.config import ( # noqa: E402
26
  ServerConfig,
27
  SimulationConfig,
28
  WorldConfig,
 
29
  load_game_config,
30
  )
31
  from god_simulator.domain import WorldLogEvent, WorldState # noqa: E402
@@ -47,7 +48,6 @@ from god_simulator.simulation.roles import normalize_role # noqa: E402
47
  from god_simulator.simulation.spawning import create_world # noqa: E402
48
  from god_simulator.simulation.tick import advance_world # noqa: E402
49
 
50
-
51
  CHAOS_SCHEDULE: dict[int, list[str]] = {
52
  50: ["spawn_beast"],
53
  120: ["famine"],
@@ -135,7 +135,7 @@ def main() -> None:
135
  args = _parse_args()
136
  started = time.perf_counter()
137
 
138
- config = _load_or_default_config(args.config)
139
  config = _with_playtest_overrides(config, seed=args.seed, npc_count=args.npcs)
140
  world = create_world(config)
141
  simulator, simulator_label = _build_simulator(config, args.use_llm)
@@ -197,7 +197,11 @@ def _parse_args() -> argparse.Namespace:
197
  parser = argparse.ArgumentParser(
198
  description="Run the scheduled-chaos playtest and write playtest_report.md."
199
  )
200
- parser.add_argument("--config", type=Path, default=Path("config/game.modal.local.json"))
 
 
 
 
201
  parser.add_argument("--report", type=Path, default=Path("playtest_report.md"))
202
  parser.add_argument("--ticks", type=int, default=400)
203
  parser.add_argument("--seed", type=int, default=None)
 
3
  import argparse
4
  import json
5
  import os
6
+ import sys
7
+ import time
8
  from collections import Counter
9
  from dataclasses import dataclass, field
10
  from pathlib import Path
 
 
11
  from typing import Any
12
  from urllib.error import URLError
13
  from urllib.parse import urlparse
 
26
  ServerConfig,
27
  SimulationConfig,
28
  WorldConfig,
29
+ apply_runtime_env_overrides,
30
  load_game_config,
31
  )
32
  from god_simulator.domain import WorldLogEvent, WorldState # noqa: E402
 
48
  from god_simulator.simulation.spawning import create_world # noqa: E402
49
  from god_simulator.simulation.tick import advance_world # noqa: E402
50
 
 
51
  CHAOS_SCHEDULE: dict[int, list[str]] = {
52
  50: ["spawn_beast"],
53
  120: ["famine"],
 
135
  args = _parse_args()
136
  started = time.perf_counter()
137
 
138
+ config = apply_runtime_env_overrides(_load_or_default_config(args.config))
139
  config = _with_playtest_overrides(config, seed=args.seed, npc_count=args.npcs)
140
  world = create_world(config)
141
  simulator, simulator_label = _build_simulator(config, args.use_llm)
 
197
  parser = argparse.ArgumentParser(
198
  description="Run the scheduled-chaos playtest and write playtest_report.md."
199
  )
200
+ parser.add_argument(
201
+ "--config",
202
+ type=Path,
203
+ default=Path(os.getenv("GOD_SIMULATOR_CONFIG", "config/game.modal.local.json")),
204
+ )
205
  parser.add_argument("--report", type=Path, default=Path("playtest_report.md"))
206
  parser.add_argument("--ticks", type=int, default=400)
207
  parser.add_argument("--seed", type=int, default=None)
src/god_simulator/__main__.py CHANGED
@@ -1,13 +1,14 @@
1
  from __future__ import annotations
2
 
3
  import argparse
 
4
  from pathlib import Path
5
 
6
  from god_simulator.api.server import run_server
7
- from god_simulator.config import load_game_config
8
  from god_simulator.simulation.spawning import create_world
9
 
10
- DEFAULT_CONFIG = Path("config/game.modal.local.json")
11
 
12
 
13
  def build_parser() -> argparse.ArgumentParser:
@@ -37,7 +38,7 @@ def main() -> None:
37
  parser = build_parser()
38
  args = parser.parse_args()
39
 
40
- config = load_game_config(args.config)
41
  world = create_world(config)
42
 
43
  if args.command == "inspect":
 
1
  from __future__ import annotations
2
 
3
  import argparse
4
+ import os
5
  from pathlib import Path
6
 
7
  from god_simulator.api.server import run_server
8
+ from god_simulator.config import apply_runtime_env_overrides, load_game_config
9
  from god_simulator.simulation.spawning import create_world
10
 
11
+ DEFAULT_CONFIG = Path(os.getenv("GOD_SIMULATOR_CONFIG", "config/game.modal.local.json"))
12
 
13
 
14
  def build_parser() -> argparse.ArgumentParser:
 
38
  parser = build_parser()
39
  args = parser.parse_args()
40
 
41
+ config = apply_runtime_env_overrides(load_game_config(args.config))
42
  world = create_world(config)
43
 
44
  if args.command == "inspect":
src/god_simulator/api/gradio_app.py CHANGED
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
12
  from pydantic import BaseModel
13
 
14
  from god_simulator.api.runtime import GameRuntime, create_game_runtime
15
- from god_simulator.config import load_game_config
16
  from god_simulator.simulation.spawning import create_world
17
 
18
 
@@ -29,7 +29,7 @@ def create_gradio_app(
29
  config_path: Path,
30
  static_dir: Path = Path("dist/frontend"),
31
  ) -> gr.Server:
32
- config = load_game_config(config_path)
33
  runtime = create_game_runtime(world=create_world(config), config=config)
34
  app = gr.Server(title="God Simulator")
35
 
 
12
  from pydantic import BaseModel
13
 
14
  from god_simulator.api.runtime import GameRuntime, create_game_runtime
15
+ from god_simulator.config import apply_runtime_env_overrides, load_game_config
16
  from god_simulator.simulation.spawning import create_world
17
 
18
 
 
29
  config_path: Path,
30
  static_dir: Path = Path("dist/frontend"),
31
  ) -> gr.Server:
32
+ config = apply_runtime_env_overrides(load_game_config(config_path))
33
  runtime = create_game_runtime(world=create_world(config), config=config)
34
  app = gr.Server(title="God Simulator")
35
 
src/god_simulator/config.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any, Literal, cast
9
  TerrainKind = Literal["plain_green"]
10
  ConnectorKind = Literal["deterministic", "openai_compatible"]
11
  OverseerMode = Literal["off", "advisor", "autopilot"]
 
12
 
13
 
14
  @dataclass(frozen=True, slots=True)
@@ -83,6 +84,27 @@ def load_game_config(path: Path) -> GameConfig:
83
  return parse_game_config(raw)
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def parse_game_config(raw: dict[str, Any]) -> GameConfig:
87
  world = _required_mapping(raw, "world")
88
  npcs = _required_mapping(raw, "npcs")
@@ -345,3 +367,7 @@ def _dotenv_value(raw: str) -> str:
345
  if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
346
  return value[1:-1]
347
  return value
 
 
 
 
 
9
  TerrainKind = Literal["plain_green"]
10
  ConnectorKind = Literal["deterministic", "openai_compatible"]
11
  OverseerMode = Literal["off", "advisor", "autopilot"]
12
+ FORCE_DETERMINISTIC_ENV = "GOD_SIMULATOR_FORCE_DETERMINISTIC"
13
 
14
 
15
  @dataclass(frozen=True, slots=True)
 
84
  return parse_game_config(raw)
85
 
86
 
87
+ def apply_runtime_env_overrides(config: GameConfig) -> GameConfig:
88
+ if not _truthy_env(os.getenv(FORCE_DETERMINISTIC_ENV)):
89
+ return config
90
+
91
+ deterministic_connector = ConnectorConfig(type="deterministic")
92
+ return GameConfig(
93
+ world=config.world,
94
+ npcs=config.npcs,
95
+ simulation=config.simulation,
96
+ server=config.server,
97
+ connector=deterministic_connector,
98
+ god_console=None,
99
+ overseer=OverseerConfig(
100
+ mode=config.overseer.mode,
101
+ cycle_ticks=config.overseer.cycle_ticks,
102
+ max_directives=config.overseer.max_directives,
103
+ connector=deterministic_connector,
104
+ ),
105
+ )
106
+
107
+
108
  def parse_game_config(raw: dict[str, Any]) -> GameConfig:
109
  world = _required_mapping(raw, "world")
110
  npcs = _required_mapping(raw, "npcs")
 
367
  if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
368
  return value[1:-1]
369
  return value
370
+
371
+
372
+ def _truthy_env(value: str | None) -> bool:
373
+ return value is not None and value.strip().lower() in ("1", "true", "yes", "on")
tests/test_config.py CHANGED
@@ -4,7 +4,7 @@ import json
4
 
5
  import pytest
6
 
7
- from god_simulator.config import load_game_config, parse_game_config
8
 
9
 
10
  def test_parse_game_config() -> None:
@@ -152,3 +152,42 @@ def test_load_game_config_does_not_override_existing_env(
152
  config = load_game_config(config_path)
153
 
154
  assert config.connector.base_url == "https://already-set.example/v1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import pytest
6
 
7
+ from god_simulator.config import apply_runtime_env_overrides, load_game_config, parse_game_config
8
 
9
 
10
  def test_parse_game_config() -> None:
 
152
  config = load_game_config(config_path)
153
 
154
  assert config.connector.base_url == "https://already-set.example/v1"
155
+
156
+
157
+ def test_force_deterministic_env_overrides_runtime_connectors(
158
+ monkeypatch: pytest.MonkeyPatch,
159
+ ) -> None:
160
+ monkeypatch.setenv("GOD_SIMULATOR_FORCE_DETERMINISTIC", "1")
161
+ config = parse_game_config(
162
+ {
163
+ "world": {"width": 80, "depth": 60, "terrain": "plain_green", "seed": 7},
164
+ "npcs": {"count": 3},
165
+ "simulation": {"tick_ms": 500},
166
+ "server": {"host": "127.0.0.1", "port": 8000},
167
+ "connector": {
168
+ "type": "openai_compatible",
169
+ "base_url": "https://workspace--npc-serve.modal.run/v1",
170
+ "model": "npc-model",
171
+ },
172
+ "god_console": {
173
+ "type": "openai_compatible",
174
+ "base_url": "https://workspace--god-serve.modal.run/v1",
175
+ "model": "god-model",
176
+ },
177
+ "overseer": {
178
+ "mode": "autopilot",
179
+ "connector": {
180
+ "type": "openai_compatible",
181
+ "base_url": "https://workspace--overseer-serve.modal.run/v1",
182
+ "model": "overseer-model",
183
+ },
184
+ },
185
+ }
186
+ )
187
+
188
+ config = apply_runtime_env_overrides(config)
189
+
190
+ assert config.connector.type == "deterministic"
191
+ assert config.god_console is None
192
+ assert config.overseer.mode == "autopilot"
193
+ assert config.overseer.connector.type == "deterministic"
tests/test_gradio_app.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  from pathlib import Path
5
 
 
6
  from fastapi.testclient import TestClient
7
 
8
  from god_simulator.api.gradio_app import create_gradio_app
@@ -37,6 +38,28 @@ def test_gradio_app_serves_missing_frontend_page(tmp_path: Path) -> None:
37
  assert "frontend is not built" in response.text
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def _write_config(tmp_path: Path) -> Path:
41
  config_path = tmp_path / "game.json"
42
  config_path.write_text(
@@ -56,3 +79,34 @@ def _write_config(tmp_path: Path) -> Path:
56
  encoding="utf-8",
57
  )
58
  return config_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import json
4
  from pathlib import Path
5
 
6
+ import pytest
7
  from fastapi.testclient import TestClient
8
 
9
  from god_simulator.api.gradio_app import create_gradio_app
 
38
  assert "frontend is not built" in response.text
39
 
40
 
41
+ def test_gradio_app_force_deterministic_disables_modal_runtime(
42
+ tmp_path: Path,
43
+ monkeypatch: pytest.MonkeyPatch,
44
+ ) -> None:
45
+ monkeypatch.setenv("GOD_SIMULATOR_FORCE_DETERMINISTIC", "1")
46
+ config_path = _write_modal_config(tmp_path)
47
+ app = create_gradio_app(config_path=config_path, static_dir=tmp_path / "dist")
48
+ client = TestClient(app)
49
+
50
+ health = client.get("/health")
51
+ assert health.status_code == 200
52
+ assert health.json()["simulator"] == "deterministic"
53
+
54
+ snapshot = client.get("/claw3d/state?warmup=1")
55
+ assert snapshot.status_code == 200
56
+ assert snapshot.json()["simulation"]["models"] == []
57
+
58
+ god_command = client.post("/god-command", json={"command": "make Ada happy"})
59
+ assert god_command.status_code == 400
60
+ assert god_command.json()["error"] == "god_console_unavailable"
61
+
62
+
63
  def _write_config(tmp_path: Path) -> Path:
64
  config_path = tmp_path / "game.json"
65
  config_path.write_text(
 
79
  encoding="utf-8",
80
  )
81
  return config_path
82
+
83
+
84
+ def _write_modal_config(tmp_path: Path) -> Path:
85
+ config_path = tmp_path / "game.modal.json"
86
+ config_path.write_text(
87
+ json.dumps(
88
+ {
89
+ "world": {
90
+ "width": 80,
91
+ "depth": 80,
92
+ "terrain": "plain_green",
93
+ "seed": 42,
94
+ },
95
+ "npcs": {"count": 2},
96
+ "simulation": {"tick_ms": 500},
97
+ "server": {"host": "127.0.0.1", "port": 8000},
98
+ "connector": {
99
+ "type": "openai_compatible",
100
+ "base_url": "https://workspace--npc-serve.modal.run/v1",
101
+ "model": "npc-model",
102
+ },
103
+ "god_console": {
104
+ "type": "openai_compatible",
105
+ "base_url": "https://workspace--god-serve.modal.run/v1",
106
+ "model": "god-model",
107
+ },
108
+ }
109
+ ),
110
+ encoding="utf-8",
111
+ )
112
+ return config_path