Skip to content

Commit 821b163

Browse files
committed
feat: complete migration to 3D use of loc end-to-end
1 parent 709a023 commit 821b163

128 files changed

Lines changed: 16081 additions & 1182 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
![Zero Dependencies](https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg)
55
![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
66
![CI](https://github.com/tmcarmichael/compass-core/actions/workflows/ci.yml/badge.svg)
7-
![Coverage](<https://img.shields.io/badge/Coverage-78%25_(20.5K_measured)-blue.svg>)
7+
![Coverage](<https://img.shields.io/badge/Coverage-88%25_(20.5K_measured)-blue.svg>)
88

99
A layered decision architecture for intelligent behavior in a real-time 3D world.
1010

@@ -18,7 +18,7 @@ Decision-making is layered by design. Priority rules enforce the safety envelope
1818

1919
A legacy 3D MMORPG emulator provided the demanding sandbox that forced this architecture to evolve: partial observability, noisy state, spatial hazard, resource pressure, and constant interruption. What began as reactive rules and state-machine routines grew into a layered system that preserves survival guarantees while adding learned adaptation and anticipatory planning. The result is an architecture shaped by the demands of the world it was built in.
2020

21-
The system operates autonomously in multi-hour sessions. In [recorded sessions](docs/samples/learned-encounter-data.md), average fight duration for a given entity type dropped from 29.5s to 15.9s as encounter history accumulated, session grades improved from B to A, and operational parameters auto-tuned from defaults. See [docs/samples/](docs/samples/) for full session telemetry: [decision traces](docs/samples/decision-trace.md), [GOAP plans with cost tracking](docs/samples/goap-planner.md), [forensic ring buffer dumps](docs/samples/forensics-ring-buffer.md), and [4-tier log output](docs/samples/session-tiers.md) from live sessions.
21+
> **Scope.** This is a cleaned extraction from a private working repo, published as an architecture reference. Live runtime config, environment assets, and operational glue are intentionally omitted. See [docs/samples/](docs/samples/) for real session output.
2222
2323
---
2424

@@ -219,7 +219,7 @@ GOAP Goal planner, spawn prediction, trajectory forecasting
219219

220220
---
221221

222-
## Why a Legacy 3D MMORPG Emulator
222+
## Why an MMORPG Sandbox
223223

224224
A legacy 3D MMORPG emulator concentrates most of the hard problems in autonomous agent design into a single domain:
225225

@@ -236,7 +236,7 @@ A legacy 3D MMORPG emulator concentrates most of the hard problems in autonomous
236236

237237
## Reading the Code
238238

239-
Start with [`perception/state.py`](src/perception/state.py): the frozen `GameState` and `SpawnData` dataclasses that every layer depends on. Then [`brain/decision.py`](src/brain/decision.py): the coordinator that evaluates rules and manages routine lifecycles. Pick any rule module in [`brain/rules/`](src/brain/rules/) to see how conditions and score functions are written. The GOAP planner lives in [`brain/goap/planner.py`](src/brain/goap/planner.py). Combat strategies are in [`routines/strategies/`](src/routines/strategies/).
239+
After the top-level overview, read by subsystem. Start with any rule module in [`brain/rules/`](src/brain/rules/) to see how conditions and score functions are written. Then inspect [`brain/goap/planner.py`](src/brain/goap/planner.py) for goal-directed sequencing, [`brain/world/model.py`](src/brain/world/model.py) for derived world intelligence, and [`brain/runner/loop.py`](src/brain/runner/loop.py) for the 10 Hz execution path. Combat strategies live in [`routines/strategies/`](src/routines/strategies/).
240240

241241
For architecture details beyond the README, see [`docs/architecture.md`](docs/architecture.md). For design rationale, [`docs/design-decisions.md`](docs/design-decisions.md). For the full evolutionary arc, [`docs/evolution.md`](docs/evolution.md).
242242

@@ -268,10 +268,6 @@ docs/ Architecture, design decisions, evolution history, retr
268268

269269
</details>
270270

271-
> **Note:** This repository is an architecture and implementation reference. Running the full system requires the target environment and supporting assets described in the docs. This is a cleaned extraction from a private working repo; the evolution described in [docs/evolution.md](docs/evolution.md) reflects the actual development arc, published here as a single snapshot.
272-
273-
---
274-
275271
Built with Python 3.14, zero runtime dependencies, and the standard library. See [docs/testing.md](docs/testing.md) for the test strategy and coverage philosophy.
276272

277273
---

docs/retrospective.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,6 @@ The emulator was the concrete proof point, but this architecture applies to any
9797

9898
- **Other game environments**: any application with readable process memory can be perceived the same way. The perception layer is the only part that changes.
9999

100-
- **Robotics control loops**: perception -> decision -> action -> motor is the standard robotics architecture. The 10 Hz priority-rule engine, the non-blocking tick contract, and the hysteresis patterns apply directly. GOAP planning with learned cost functions is directly applicable to warehouse robots, delivery drones, and autonomous vehicles.
101-
102100
- **Autonomous testing agents**: navigating complex UIs, handling error states, recovering from unexpected conditions. The routine state machine pattern handles multi-step UI flows cleanly.
103101

104102
- **Any long-running autonomous system**: the 4-tier logging pipeline, the forensic buffer, and the scorecard system are useful wherever an agent operates unattended and needs post-hoc debugging.

docs/samples/learned-encounter-data.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"scorecard":{"pathing":0,"defeat_rate":100,"pull_success":100,"targeting":100,"survival":100,"mana_efficiency":83,"uptime":100,"overall":88,"grade":"B"},
1313
"mob_stats":{"a_tree_snake":{"avg_dur":29.5,"avg_mana":23,"pet_death_rate":0.07,"danger":0.04}}}
1414

15-
{"session":"session_20260327_213213","version":"v1.37.2","duration_min":18.0,"defeats":24,"dph":77.8,
15+
{"session":"session_20260327_213213","version":"v1.33.2","duration_min":18.0,"defeats":24,"dph":77.8,
1616
"scorecard":{"pathing":100,"defeat_rate":100,"pull_success":95,"targeting":91,"survival":100,"mana_efficiency":100,"uptime":100,"overall":98,"grade":"A"},
1717
"mob_stats":{"a_tree_snake":{"avg_dur":15.9,"avg_mana":3,"pet_death_rate":0.0,"danger":0.0}}}
1818

@@ -25,7 +25,7 @@
2525

2626
```json
2727
// data/memory/nektulos_tuning.json
28-
{"v":1,"roam_radius_mult":1.2999999999999998,"social_add_limit":5,"mana_conserve_level":0}
28+
{ "v": 1, "roam_radius_mult": 1.2999999999999998, "social_add_limit": 5, "mana_conserve_level": 0 }
2929
```
3030

3131
Defaults: `roam_radius_mult=1.0`, `social_add_limit=3`, `mana_conserve_level=0`.

docs/testing.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<!-- last_modified: 2026-03-30 -->
2+
13
# Testing
24

35
How the test suite is structured, what it covers, and what it deliberately omits.
@@ -15,7 +17,7 @@ Hypothesis profiles: `dev` (50 examples, fast) and `ci` (200 examples, thorough)
1517

1618
## Coverage strategy
1719

18-
Coverage is measured in CI with `pytest-cov` and enforced with a `fail_under` floor in `pyproject.toml`. The measured surface is ~17.7K of ~22K total source lines (80% of the codebase). Only 7 files are omitted; all require a live game client or game asset files.
20+
Coverage is measured in CI with `pytest-cov` and enforced with a `fail_under` floor in `pyproject.toml`. The measured surface is the full 20.5K source lines with zero omissions.
1921

2022
### What is covered
2123

@@ -48,21 +50,17 @@ set_backend(recorder)
4850

4951
This makes every routine testable without mocking, monkeypatching, or environment access. Tests assert on `recorder.actions` to verify motor commands.
5052

51-
### What is omitted (and why)
53+
### Previously omitted modules
5254

53-
The `[tool.coverage.run] omit` list in `pyproject.toml` excludes only modules that require external hardware or binary assets:
55+
Seven modules that were previously excluded from coverage measurement are now fully measured and tested via mock-based testing:
5456

55-
| Omitted path | Reason |
56-
|---|---|
57-
| `src/perception/*` | Win32 `ReadProcessMemory`: requires a live game client process |
58-
| `src/eq/s3d.py`, `wld.py`, `zone_chr.py` | Binary asset parsers: require S3D/WLD game files |
59-
| `src/runtime/orchestrator.py` | Session lifecycle: thread coordination, logging wiring |
60-
| `src/runtime/server.py` | Web server: HTTP/WebSocket infrastructure |
61-
| `src/runtime/agent.py` | Process management: Win32 admin check, process spawning |
57+
- **Perception layer** (`src/perception/*`): Win32 `ReadProcessMemory` calls are tested through mock readers that return crafted SPAWNINFO buffers. `read_state()` assembly, profile chain resolution, struct validation, log parsing, and all sub-readers (char, spawn, inventory) are covered.
58+
- **Binary asset parsers** (`src/eq/s3d.py`, `wld.py`, `zone_chr.py`): tested with synthetic binary fixtures built via `struct.pack`.
59+
- **Runtime orchestration** (`src/runtime/orchestrator.py`, `agent.py`): config building, feature toggles, log buffer, session pruning tested with mock dependencies.
6260

63-
These 7 files (~4K lines) are validated through runtime invariants, the forensics ring buffer, and session-level observability.
61+
### Compensating controls for deeply-coupled code
6462

65-
### Compensating controls for omitted code
63+
The remaining uncovered lines are concentrated in multi-phase routine tick handlers (motor-coupled state machines) and the 10 Hz brain runner loop (threading, I/O). These are validated through:
6664

6765
1. **Runtime invariants** (`src/util/invariants.py`): assertions that fire during operation and flush diagnostics on violation
6866
2. **Forensics buffer** (`src/util/forensics.py`): 300-tick ring buffer that dumps to disk on death or crash, providing post-hoc debugging for the perception/routine layers

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"hypothesis>=6.100",
2828
"pre-commit>=4.0",
2929
"pytest-benchmark>=5.0",
30+
"pytest-xdist>=3.5",
3031
]
3132

3233
# ── Tool configuration ───────────────────────────────────────────────
@@ -99,6 +100,6 @@ markers = [
99100
source = ["src"]
100101

101102
[tool.coverage.report]
102-
fail_under = 78
103+
fail_under = 91
103104
show_missing = true
104105
skip_empty = true

src/brain/context.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from brain.state.threat import ThreatState
3838
from brain.state.zone import ZoneState
3939
from core.constants import XP_SCALE_MAX
40-
from nav.geometry import distance_2d
40+
from core.types import Point
4141
from perception.state import GameState
4242
from util.thread_guard import assert_brain_thread
4343

@@ -283,16 +283,16 @@ def has_unlootable_corpse(self, state: GameState, max_dist: float = 120.0) -> bo
283283
for spawn in state.spawns:
284284
if not spawn.is_corpse:
285285
continue
286-
dist = distance_2d(state.x, state.y, spawn.x, spawn.y)
286+
dist = state.pos.dist_to(spawn.pos)
287287
if dist > max_dist:
288288
continue
289-
defeat = self.find_unlootable_kill(spawn.name, spawn.x, spawn.y, corpse_spawn_id=spawn.spawn_id)
289+
defeat = self.find_unlootable_kill(spawn.name, spawn.pos, corpse_spawn_id=spawn.spawn_id)
290290
if defeat:
291291
return True
292292
# Log WHY matching failed
293293
for k in self.defeat_tracker.defeat_history:
294294
if not k.looted and time.time() - k.time < 300:
295-
kdist = distance_2d(spawn.x, spawn.y, k.x, k.y)
295+
kdist = spawn.pos.dist_to(k.pos)
296296
name_match = k.name in spawn.name or spawn.name.startswith(k.name)
297297
log.debug(
298298
"Loot match: corpse='%s'@(%.0f,%.0f) vs defeat='%s'@(%.0f,%.0f) "
@@ -318,25 +318,29 @@ def clear_recent_kills(self) -> None:
318318
(sid, t) for sid, t in self.defeat_tracker.recent_kills if now - t < 60
319319
]
320320

321-
def record_kill(self, spawn_id: int, name: str = "", x: float = 0.0, y: float = 0.0) -> None:
321+
_ORIGIN = Point(0.0, 0.0, 0.0)
322+
323+
def record_kill(self, spawn_id: int, name: str = "",
324+
pos: Point = _ORIGIN) -> None:
322325
"""Delegate to defeat_tracker."""
323326
assert_brain_thread("record_kill")
324-
self.defeat_tracker.record_kill(spawn_id, name=name, x=x, y=y)
327+
self.defeat_tracker.record_kill(spawn_id, name=name, pos=pos)
325328

326-
def update_stationary_kills(self, player_x: float, player_y: float) -> None:
329+
def update_stationary_kills(self, pos: Point) -> None:
327330
assert_brain_thread("update_stationary_kills")
328331
if self.metrics.last_kill_x == 0 and self.metrics.last_kill_y == 0:
329-
self.metrics.last_kill_x = player_x
330-
self.metrics.last_kill_y = player_y
332+
self.metrics.last_kill_x = pos.x
333+
self.metrics.last_kill_y = pos.y
331334
self.metrics.stationary_kills = 1
332335
return
333-
dist = distance_2d(player_x, player_y, self.metrics.last_kill_x, self.metrics.last_kill_y)
336+
last_kill_pos = Point(self.metrics.last_kill_x, self.metrics.last_kill_y, 0.0)
337+
dist = pos.dist_to(last_kill_pos)
334338
if dist < 30:
335339
self.metrics.stationary_kills += 1
336340
else:
337341
self.metrics.stationary_kills = 1
338-
self.metrics.last_kill_x = player_x
339-
self.metrics.last_kill_y = player_y
342+
self.metrics.last_kill_x = pos.x
343+
self.metrics.last_kill_y = pos.y
340344

341345
def should_reposition(self) -> bool:
342346
if self.metrics.stationary_kills < 2:
@@ -351,14 +355,13 @@ def should_reposition(self) -> bool:
351355
def find_unlootable_kill(
352356
self,
353357
corpse_name: str,
354-
corpse_x: float,
355-
corpse_y: float,
358+
pos: Point,
356359
max_dist: float = 60.0,
357360
corpse_spawn_id: int = 0,
358361
) -> DefeatInfo | None:
359362
"""Delegate to defeat_tracker."""
360363
return self.defeat_tracker.find_unlootable_kill(
361-
corpse_name, corpse_x, corpse_y, corpse_spawn_id=corpse_spawn_id, max_dist=max_dist
364+
corpse_name, pos, corpse_spawn_id=corpse_spawn_id, max_dist=max_dist
362365
)
363366

364367
def defeats_in_window(self, window_seconds: float) -> int:
@@ -424,7 +427,7 @@ def nearest_player_dist(self, state: GameState) -> float:
424427
best = 9999.0
425428
for spawn in state.spawns:
426429
if spawn.spawn_type == 0 and spawn.name != state.name:
427-
dist = distance_2d(state.x, state.y, spawn.x, spawn.y)
430+
dist = state.pos.dist_to(spawn.pos)
428431
if dist < best:
429432
best = dist
430433
return best
@@ -433,7 +436,7 @@ def nearby_player_count(self, state: GameState, radius: float = 200.0) -> int:
433436
count = 0
434437
for spawn in state.spawns:
435438
if spawn.spawn_type == 0 and spawn.name != state.name:
436-
dist = distance_2d(state.x, state.y, spawn.x, spawn.y)
439+
dist = state.pos.dist_to(spawn.pos)
437440
if dist <= radius:
438441
count += 1
439442
return count

src/brain/goap/spawn_predictor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _cell_center(cx: int, cy: int) -> Point:
3838
return Point(
3939
x=(cx + 0.5) * CELL_SIZE,
4040
y=(cy + 0.5) * CELL_SIZE,
41+
z=0.0,
4142
)
4243

4344

src/brain/goap/world_state.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ def build_world_state(state: GameState, ctx: AgentContext) -> PlanWorldState:
7272
# At camp
7373
at_camp = True
7474
if ctx.camp.roam_radius > 0:
75-
from nav.geometry import distance_2d
75+
from core.types import Point
7676

77-
d = distance_2d(state.x, state.y, ctx.camp.camp_x, ctx.camp.camp_y)
77+
d = state.pos.dist_to(Point(ctx.camp.camp_x, ctx.camp.camp_y, 0.0))
7878
at_camp = d <= ctx.camp.roam_radius * 1.2 # slight buffer
7979

8080
return PlanWorldState(

0 commit comments

Comments
 (0)