mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-09 17:51:24 -10:00
- vtt-server: new POST /api/v1/actions/{save,concentration,death-save}
handlers backed by vtt-core RulesEvaluator; /actions/check now honors
advantage/disadvantage
- orchestrator: routing/engine_client.py + /api/v1/engine/* proxy routes
(session, attack, check, save, concentration, death-save, map/generate)
with 502 on engine-down; ENGINE_API_URL env-configured
- client: App.tsx attack/spell/check/macro rolls now resolve server-side
via api/rules_engine.ts (local dice fallback offline); WfcStudioView
hits the real engine WFC route and maps its Vec<Vec<u8>> tile grid
- tests: 8 proxy tests (live-engine integration + 502 contract), all
suites green: 41 pytest pass engine-up / 34+7 skip engine-down,
cargo workspace clean, client builds in ~1s, MCR 100% / HCI 1.0
- fix misleading "ALL PHASES 1-7" benchmark banner
Co-Authored-By: Claude <[email protected]>
115 lines
4.0 KiB
Python
115 lines
4.0 KiB
Python
"""Tests for the authoritative rules engine proxy (/api/v1/engine/*).
|
|
|
|
Engine-up cases run against a live vtt-server (crates/vtt-server) when one is
|
|
reachable on ENGINE_API_URL; they skip otherwise so CI without the Rust binary
|
|
still passes. The engine-down case always runs and asserts the 502 contract.
|
|
"""
|
|
|
|
import os
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from vtt_orchestrator.routing import engine_client
|
|
from vtt_orchestrator.server import app
|
|
|
|
client = TestClient(app)
|
|
|
|
ENGINE_URL = os.environ.get("ENGINE_API_URL", "http://localhost:8088")
|
|
|
|
|
|
def _engine_up() -> bool:
|
|
try:
|
|
httpx.get(f"{ENGINE_URL}/health", timeout=1.0)
|
|
return True
|
|
except httpx.HTTPError:
|
|
return False
|
|
|
|
|
|
@pytest.fixture()
|
|
def live_engine():
|
|
if not _engine_up():
|
|
pytest.skip("vtt-server engine not running")
|
|
return None
|
|
|
|
|
|
class TestEngineDown:
|
|
def test_unreachable_engine_returns_502(self, monkeypatch):
|
|
monkeypatch.setattr(engine_client, "ENGINE_API_URL", "http://localhost:59999")
|
|
response = client.post("/api/v1/engine/check", json={"modifier": 3, "dc": 12})
|
|
assert response.status_code == 502
|
|
assert "unreachable" in response.json()["detail"].lower()
|
|
|
|
|
|
class TestEngineProxy:
|
|
def test_create_session(self, live_engine):
|
|
resp = client.post("/api/v1/engine/session", json={"session_name": "pytest"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["session_id"]
|
|
|
|
def test_attack_resolution_contract(self, live_engine):
|
|
session_id = client.post("/api/v1/engine/session", json={}).json()["session_id"]
|
|
resp = client.post(
|
|
"/api/v1/engine/attack",
|
|
json={
|
|
"session_id": session_id,
|
|
"attacker_id": "thorin",
|
|
"target_id": "orc-warlord",
|
|
"attack_bonus": 7,
|
|
"target_ac": 15,
|
|
"damage_expression": "1d12+3",
|
|
"damage_type": "slashing",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert 1 <= body["natural_roll"] <= 20
|
|
assert isinstance(body["is_hit"], bool)
|
|
if body["is_critical_hit"]:
|
|
# Critical hits double damage dice; minimum expression is still respected.
|
|
assert body["total_damage"] >= 2
|
|
elif not body["is_hit"]:
|
|
assert body["total_damage"] == 0
|
|
|
|
def test_check_with_advantage_stays_bounded(self, live_engine):
|
|
resp = client.post(
|
|
"/api/v1/engine/check", json={"modifier": 5, "dc": 13, "advantage": True}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert 1 <= resp.json()["roll"] <= 20
|
|
|
|
def test_save_normalizes_ability_casing(self, live_engine):
|
|
resp = client.post(
|
|
"/api/v1/engine/save", json={"save_modifier": 2, "dc": 10, "ability": "wisdom"}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["ability"] == "WISDOM"
|
|
|
|
def test_concentration_dc_is_max_of_half_damage_or_ten(self, live_engine):
|
|
resp = client.post(
|
|
"/api/v1/engine/concentration", json={"con_modifier": 0, "damage_taken": 30}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["dc"] == 15
|
|
|
|
def test_death_save_state_machine_tracks_failures(self, live_engine):
|
|
resp = client.post(
|
|
"/api/v1/engine/death-save",
|
|
json={"successes": 0, "failures": 0, "natural_roll": 1},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["failures"] == 2, "a natural 1 counts as two failures"
|
|
|
|
def test_map_generation_returns_wall_grid(self, live_engine):
|
|
resp = client.post(
|
|
"/api/v1/engine/map/generate", json={"width": 16, "height": 12, "seed": 42}
|
|
)
|
|
assert resp.status_code == 200
|
|
tiles = resp.json()["tiles"]
|
|
assert len(tiles) == 12 and len(tiles[0]) == 16
|
|
# Perimeter must be sealed.
|
|
assert all(cell == 1 for cell in tiles[0])
|
|
assert any(1 in row for row in tiles)
|