feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive LED effects through the existing color strip and value source pipelines. Core: - GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary - GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven) - Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook - Community YAML adapters: Minecraft, Valorant, Rocket League - GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing) - GameEventValueSource with EMA smoothing and timeout - 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert) - Auto-setup for Valve GSI games (Steam path detection, cfg file writing) - Demo capture engine exposed to non-demo mode Frontend: - Game tab in Streams tree navigation with integration cards - Game integration editor modal with adapter picker, config fields, event mappings - game_event source type in CSS and ValueSource editors - Setup instructions overlay (markdown rendered) - Live event monitor and connection test API: - Full CRUD for game integrations - Event ingestion endpoint (adapter-level auth) - Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""Tests for CS2 Game State Integration adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.cs2_adapter import CS2Adapter
|
||||
|
||||
|
||||
# ── Realistic CS2 GSI payload samples ────────────────────────────────────
|
||||
|
||||
|
||||
def _make_cs2_payload(
|
||||
*,
|
||||
health: int = 100,
|
||||
armor: int = 100,
|
||||
money: int = 800,
|
||||
kills: int = 0,
|
||||
deaths: int = 0,
|
||||
round_phase: str | None = None,
|
||||
bomb: str | None = None,
|
||||
flashed: int = 0,
|
||||
team: str = "CT",
|
||||
ammo_clip: int | None = None,
|
||||
ammo_clip_max: int | None = None,
|
||||
auth_token: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a realistic CS2 GSI payload."""
|
||||
payload: dict = {
|
||||
"player": {
|
||||
"steamid": "76561198012345678",
|
||||
"name": "TestPlayer",
|
||||
"team": team,
|
||||
"state": {
|
||||
"health": health,
|
||||
"armor": armor,
|
||||
"helmet": True,
|
||||
"flashed": flashed,
|
||||
"smoked": 0,
|
||||
"burning": 0,
|
||||
"money": money,
|
||||
"round_kills": 0,
|
||||
"round_killhs": 0,
|
||||
"equip_value": 4400,
|
||||
},
|
||||
"match_stats": {
|
||||
"kills": kills,
|
||||
"assists": 0,
|
||||
"deaths": deaths,
|
||||
"mvps": 0,
|
||||
"score": kills * 2,
|
||||
},
|
||||
"weapons": {},
|
||||
},
|
||||
}
|
||||
|
||||
if ammo_clip is not None and ammo_clip_max is not None:
|
||||
payload["player"]["weapons"]["weapon_0"] = {
|
||||
"name": "weapon_ak47",
|
||||
"paintkit": "default",
|
||||
"type": "Rifle",
|
||||
"ammo_clip": ammo_clip,
|
||||
"ammo_clip_max": ammo_clip_max,
|
||||
"ammo_reserve": 90,
|
||||
"state": "active",
|
||||
}
|
||||
|
||||
if round_phase is not None:
|
||||
payload["round"] = {"phase": round_phase}
|
||||
|
||||
if bomb is not None:
|
||||
payload.setdefault("round", {})["bomb"] = bomb
|
||||
|
||||
if auth_token is not None:
|
||||
payload["auth"] = {"token": auth_token}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ── Health/Armor/Money tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2ContinuousEvents:
|
||||
def test_health_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(health=75)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
health_events = [e for e in events if e.event_type == "health"]
|
||||
assert len(health_events) == 1
|
||||
assert health_events[0].value == pytest.approx(0.75)
|
||||
|
||||
def test_health_zero(self) -> None:
|
||||
payload = _make_cs2_payload(health=0)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
health_events = [e for e in events if e.event_type == "health"]
|
||||
assert health_events[0].value == 0.0
|
||||
|
||||
def test_armor_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(armor=50)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
armor_events = [e for e in events if e.event_type == "armor"]
|
||||
assert len(armor_events) == 1
|
||||
assert armor_events[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_money_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(money=8000)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
gold_events = [e for e in events if e.event_type == "gold"]
|
||||
assert len(gold_events) == 1
|
||||
assert gold_events[0].value == pytest.approx(8000.0 / 16000.0)
|
||||
|
||||
def test_ammo_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(ammo_clip=15, ammo_clip_max=30)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
ammo_events = [e for e in events if e.event_type == "ammo"]
|
||||
assert len(ammo_events) == 1
|
||||
assert ammo_events[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_adapter_id_passed_through(self) -> None:
|
||||
payload = _make_cs2_payload(health=100)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "my_cs2"}, {})
|
||||
assert all(e.adapter_id == "my_cs2" for e in events)
|
||||
|
||||
|
||||
# ── Kill/Death diff detection tests ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2DiffDetection:
|
||||
def test_kill_detected_on_increase(self) -> None:
|
||||
payload1 = _make_cs2_payload(kills=3)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(kills=4)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 1
|
||||
|
||||
def test_no_kill_on_first_payload(self) -> None:
|
||||
payload = _make_cs2_payload(kills=5)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 0
|
||||
|
||||
def test_multiple_kills_detected(self) -> None:
|
||||
payload1 = _make_cs2_payload(kills=2)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(kills=5)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 3
|
||||
|
||||
def test_death_detected(self) -> None:
|
||||
payload1 = _make_cs2_payload(deaths=0)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(deaths=1)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
death_events = [e for e in events if e.event_type == "death"]
|
||||
assert len(death_events) == 1
|
||||
|
||||
def test_no_death_on_same_count(self) -> None:
|
||||
payload1 = _make_cs2_payload(deaths=2)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(deaths=2)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
death_events = [e for e in events if e.event_type == "death"]
|
||||
assert len(death_events) == 0
|
||||
|
||||
|
||||
# ── Round/Bomb trigger tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Triggers:
|
||||
def test_round_start(self) -> None:
|
||||
payload = _make_cs2_payload(round_phase="live")
|
||||
events, state = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
rs = [e for e in events if e.event_type == "round_start"]
|
||||
assert len(rs) == 1
|
||||
assert state["round_phase"] == "live"
|
||||
|
||||
def test_round_end(self) -> None:
|
||||
prev = {"round_phase": "live"}
|
||||
payload = _make_cs2_payload(round_phase="over")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
re = [e for e in events if e.event_type == "round_end"]
|
||||
assert len(re) == 1
|
||||
|
||||
def test_no_round_event_on_same_phase(self) -> None:
|
||||
prev = {"round_phase": "live"}
|
||||
payload = _make_cs2_payload(round_phase="live")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
round_events = [e for e in events if e.event_type in ("round_start", "round_end")]
|
||||
assert len(round_events) == 0
|
||||
|
||||
def test_bomb_planted(self) -> None:
|
||||
payload = _make_cs2_payload(round_phase="live", bomb="planted")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
bomb = [e for e in events if e.event_type == "objective_captured"]
|
||||
assert len(bomb) == 1
|
||||
|
||||
def test_bomb_defused(self) -> None:
|
||||
prev = {"bomb": "planted"}
|
||||
payload = _make_cs2_payload(round_phase="live", bomb="defused")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
defuse = [e for e in events if e.event_type == "objective_lost"]
|
||||
assert len(defuse) == 1
|
||||
|
||||
def test_flashbang_detected(self) -> None:
|
||||
payload = _make_cs2_payload(flashed=200)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {"flashed": 0})
|
||||
flash = [e for e in events if e.event_type == "blinded"]
|
||||
assert len(flash) == 1
|
||||
assert flash[0].value == pytest.approx(200.0 / 255.0)
|
||||
|
||||
def test_no_flashbang_when_already_flashed(self) -> None:
|
||||
prev = {"flashed": 200}
|
||||
payload = _make_cs2_payload(flashed=150)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
flash = [e for e in events if e.event_type == "blinded"]
|
||||
# Already flashed (prev > 0), so no new flash event
|
||||
assert len(flash) == 0
|
||||
|
||||
def test_team_ct(self) -> None:
|
||||
payload = _make_cs2_payload(team="CT")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
team = [e for e in events if e.event_type == "team_a"]
|
||||
assert len(team) == 1
|
||||
|
||||
def test_team_t(self) -> None:
|
||||
payload = _make_cs2_payload(team="T")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
team = [e for e in events if e.event_type == "team_b"]
|
||||
assert len(team) == 1
|
||||
|
||||
|
||||
# ── Auth validation tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Auth:
|
||||
def test_auth_valid(self) -> None:
|
||||
payload = _make_cs2_payload(auth_token="mysecret")
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is True
|
||||
|
||||
def test_auth_invalid(self) -> None:
|
||||
payload = _make_cs2_payload(auth_token="wrong")
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is False
|
||||
|
||||
def test_auth_no_token_configured_accepts_all(self) -> None:
|
||||
payload = _make_cs2_payload()
|
||||
result = CS2Adapter.validate_auth({}, payload, {})
|
||||
assert result is True
|
||||
|
||||
def test_auth_missing_payload_token(self) -> None:
|
||||
payload = _make_cs2_payload() # no auth in payload
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── Metadata tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Metadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert CS2Adapter.ADAPTER_TYPE == "cs2"
|
||||
|
||||
def test_supported_events(self) -> None:
|
||||
assert "health" in CS2Adapter.SUPPORTED_EVENTS
|
||||
assert "kill" in CS2Adapter.SUPPORTED_EVENTS
|
||||
assert "death" in CS2Adapter.SUPPORTED_EVENTS
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = CS2Adapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = CS2Adapter.get_setup_instructions()
|
||||
assert "gamestate_integration" in instructions
|
||||
assert "CS2" in instructions
|
||||
Reference in New Issue
Block a user