DeltaZN commited on
Commit ·
86758a1
1
Parent(s): a3fd468
feat: Implement deterministic runtime configuration & small visual fixes
Browse files- README.md +18 -8
- frontend/src/scene/BeastActor.tsx +18 -8
- frontend/src/scene/NpcActor.tsx +5 -1
- frontend/src/scene/WorldView.tsx +25 -12
- frontend/src/scene/useCompletePreviousTickPosition.ts +40 -0
- scripts/playtest_agent.py +9 -5
- src/god_simulator/__main__.py +4 -3
- src/god_simulator/api/gradio_app.py +2 -2
- src/god_simulator/config.py +26 -0
- tests/test_config.py +40 -1
- tests/test_gradio_app.py +54 -0
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.
|
| 96 |
-
calls,
|
|
|
|
| 97 |
|
| 98 |
```powershell
|
| 99 |
-
|
|
|
|
| 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.
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 169 |
focusTarget: readonly [number, number, number] | null,
|
| 170 |
moveDirection: Vector3,
|
| 171 |
delta: number,
|
| 172 |
): number {
|
| 173 |
-
group.position.y = MathUtils.damp(group.position.y,
|
| 174 |
|
| 175 |
-
const deltaX =
|
| 176 |
-
const deltaZ =
|
| 177 |
const distanceToTarget = Math.hypot(deltaX, deltaZ);
|
| 178 |
if (distanceToTarget <= 0.025) {
|
| 179 |
-
group.position.x =
|
| 180 |
-
group.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 -
|
| 293 |
-
const z = (row + 0.5) * VOXEL_SIZE -
|
| 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,
|
| 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={[
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|