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,204 @@
|
||||
"""Tests for Dota 2 Game State Integration adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.dota2_adapter import Dota2Adapter
|
||||
|
||||
|
||||
def _make_dota2_payload(
|
||||
*,
|
||||
health: int = 1000,
|
||||
max_health: int = 1000,
|
||||
mana: float = 500.0,
|
||||
max_mana: float = 500.0,
|
||||
gold: int = 625,
|
||||
kills: int = 0,
|
||||
deaths: int = 0,
|
||||
game_state: str | None = None,
|
||||
auth_token: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a realistic Dota 2 GSI payload."""
|
||||
payload: dict = {
|
||||
"hero": {
|
||||
"xpos": -6624,
|
||||
"ypos": -6592,
|
||||
"id": 1,
|
||||
"name": "npc_dota_hero_antimage",
|
||||
"level": 12,
|
||||
"alive": True,
|
||||
"respawn_seconds": 0,
|
||||
"buyback_cost": 874,
|
||||
"buyback_cooldown": 0,
|
||||
"health": health,
|
||||
"max_health": max_health,
|
||||
"health_percent": int(100.0 * health / max_health) if max_health else 0,
|
||||
"mana": mana,
|
||||
"max_mana": max_mana,
|
||||
"mana_percent": int(100.0 * mana / max_mana) if max_mana else 0,
|
||||
},
|
||||
"player": {
|
||||
"steamid": "76561198012345678",
|
||||
"name": "TestPlayer",
|
||||
"activity": "playing",
|
||||
"kills": kills,
|
||||
"deaths": deaths,
|
||||
"assists": 0,
|
||||
"last_hits": 120,
|
||||
"denies": 10,
|
||||
"kill_streak": 0,
|
||||
"gold": gold,
|
||||
"gold_reliable": 200,
|
||||
"gold_unreliable": gold - 200,
|
||||
"gpm": 450,
|
||||
"xpm": 520,
|
||||
},
|
||||
}
|
||||
|
||||
if game_state is not None:
|
||||
payload["map"] = {
|
||||
"name": "start",
|
||||
"matchid": "12345",
|
||||
"game_time": 1200,
|
||||
"clock_time": 1200,
|
||||
"daytime": True,
|
||||
"game_state": game_state,
|
||||
"win_team": "none",
|
||||
}
|
||||
|
||||
if auth_token is not None:
|
||||
payload["auth"] = {"token": auth_token}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class TestDota2ContinuousEvents:
|
||||
def test_health_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(health=750, max_health=1500)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
hp = [e for e in events if e.event_type == "health"]
|
||||
assert len(hp) == 1
|
||||
assert hp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_mana_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(mana=300.0, max_mana=600.0)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
mp = [e for e in events if e.event_type == "mana"]
|
||||
assert len(mp) == 1
|
||||
assert mp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_gold_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(gold=5000)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
g = [e for e in events if e.event_type == "gold"]
|
||||
assert len(g) == 1
|
||||
assert g[0].value == pytest.approx(5000.0 / 99999.0)
|
||||
|
||||
def test_gold_custom_max(self) -> None:
|
||||
payload = _make_dota2_payload(gold=5000)
|
||||
events, _ = Dota2Adapter.parse_payload(
|
||||
payload,
|
||||
{"adapter_id": "d2", "max_gold": 10000},
|
||||
{},
|
||||
)
|
||||
g = [e for e in events if e.event_type == "gold"]
|
||||
assert g[0].value == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestDota2DiffDetection:
|
||||
def test_kill_detected(self) -> None:
|
||||
p1 = _make_dota2_payload(kills=3)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(kills=4)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 1
|
||||
|
||||
def test_no_kill_on_first_payload(self) -> None:
|
||||
p = _make_dota2_payload(kills=5)
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 0
|
||||
|
||||
def test_death_detected(self) -> None:
|
||||
p1 = _make_dota2_payload(deaths=1)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(deaths=2)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
deaths = [e for e in events if e.event_type == "death"]
|
||||
assert len(deaths) == 1
|
||||
|
||||
def test_multiple_kills(self) -> None:
|
||||
p1 = _make_dota2_payload(kills=0)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(kills=3)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 3
|
||||
|
||||
|
||||
class TestDota2MatchFlow:
|
||||
def test_match_start_pre_game(self) -> None:
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_PRE_GAME")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
|
||||
ms = [e for e in events if e.event_type == "match_start"]
|
||||
assert len(ms) == 1
|
||||
|
||||
def test_match_start_in_progress(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_PRE_GAME"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
ms = [e for e in events if e.event_type == "match_start"]
|
||||
assert len(ms) == 1
|
||||
|
||||
def test_match_end(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_POST_GAME")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
me = [e for e in events if e.event_type == "match_end"]
|
||||
assert len(me) == 1
|
||||
|
||||
def test_no_event_on_same_state(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
flow = [e for e in events if e.event_type in ("match_start", "match_end")]
|
||||
assert len(flow) == 0
|
||||
|
||||
|
||||
class TestDota2Auth:
|
||||
def test_auth_valid(self) -> None:
|
||||
p = _make_dota2_payload(auth_token="secret")
|
||||
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
|
||||
assert result is True
|
||||
|
||||
def test_auth_invalid(self) -> None:
|
||||
p = _make_dota2_payload(auth_token="wrong")
|
||||
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
|
||||
assert result is False
|
||||
|
||||
def test_auth_no_config_accepts_all(self) -> None:
|
||||
p = _make_dota2_payload()
|
||||
result = Dota2Adapter.validate_auth({}, p, {})
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestDota2Metadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert Dota2Adapter.ADAPTER_TYPE == "dota2"
|
||||
|
||||
def test_supported_events(self) -> None:
|
||||
assert "health" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
assert "mana" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
assert "gold" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = Dota2Adapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
assert "max_gold" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = Dota2Adapter.get_setup_instructions()
|
||||
assert "Dota 2" in instructions
|
||||
Reference in New Issue
Block a user