extends RefCounted ## Integration tests for RaidServer + SyncProtocol + ClientView. ## Simulates 4 fake clients driving a full fight through the message pipeline. var _test_failures := 0 func _make_encounter(timeline: Array = [], enrage_type: String = "hard", enrage_time: float = 999.0) -> Dictionary: return { "id": "test_boss", "boss_hp": 1000.0, "enrage_type": enrage_type, "enrage_start_time": enrage_time, "timeline": timeline } ## Builds a server with real class data (data/classes.json) and 4 joined players at level 5. func _make_server(encounter: Dictionary = {}) -> RaidServer: if encounter.is_empty(): encounter = _make_encounter() var server := RaidServer.new(encounter, {}) server.join("Tank", "warden", "tank", 5) server.join("Heal", "luminary", "healer", 5) server.join("DPS1", "blade", "dps", 5) server.join("DPS2", "arcanist", "dps", 5) return server func _drain(server: RaidServer) -> Array[Dictionary]: var out: Array[Dictionary] = [] for encoded in server.get_outgoing(): out.append(SyncProtocol.decode(encoded)) return out func _find(msgs: Array, msg_type: String) -> Dictionary: for m in msgs: if str(m.get("type", "")) == msg_type: return m return {} # --- Lobby / join ----------------------------------------------------------- func test_join_assigns_unique_ids_and_broadcasts() -> void: var server := RaidServer.new(_make_encounter(), {}) var id_a := server.join("A", "warden", "tank") var id_b := server.join("B", "luminary", "healer") assert_true(id_a != id_b, "Ids should be unique") var msgs := _drain(server) # There are two "joined" messages (one per player); find B's specifically. var joined := {} for m in msgs: if str(m.get("type", "")) == "joined" and int(m.get("player_id", -1)) == id_b: joined = m assert_false(joined.is_empty(), "Should broadcast a joined message for player B") func test_join_message_routes_through_handle_client_message() -> void: var server := RaidServer.new(_make_encounter(), {}) server.handle_client_message(-1, { "type": "join", "name": "ViaMessage", "class_id": "blade", "role": "dps", "level": 3 }) assert_eq(server.sim.raid_state.players.size(), 1, "Join via message should register a player") assert_eq_str(server.sim.raid_state.players[0].name, "ViaMessage", "Name should come from the message") # --- Fight start ------------------------------------------------------------ func test_start_fight_sets_hp_and_broadcasts() -> void: var server := _make_server() server.start_fight() var msgs := _drain(server) assert_false(_find(msgs, "fight_started").is_empty(), "Should broadcast fight_started") for p in server.sim.raid_state.players: assert_almost(p.max_hp, 1800.0, "Level 5 max HP = 1000 * (1 + 0.2*4)") assert_almost(p.hp, 1800.0, "Players start at full HP") # --- Combat pipeline -------------------------------------------------------- func test_combo_attack_deals_damage_to_boss() -> void: var server := _make_server() server.start_fight() _drain(server) # Warden hit 1 requires FRONT (positional 1), damage_mult 0.8. # Level 5 base = 100 * (1 + 0.15*4) = 160 -> 160 * 0.8 = 128. server.handle_client_message(0, {"type": "input_ability", "ability_id": "combo_attack", "positional": 1}) var msgs := _drain(server) var dmg_msg := _find(msgs, "event_damage") assert_false(dmg_msg.is_empty(), "Should broadcast event_damage") assert_almost(float(dmg_msg.get("amount", -1.0)), 128.0, "Hit 1 damage = 160 * 0.8") assert_almost(server.sim.raid_state.boss_hp, 872.0, "Boss HP should drop by 128") func test_gcd_blocks_immediate_combo_reuse() -> void: var server := _make_server() server.start_fight() _drain(server) server.handle_client_message(0, {"type": "input_ability", "ability_id": "combo_attack", "positional": 1}) _drain(server) server.handle_client_message(0, {"type": "input_ability", "ability_id": "combo_attack", "positional": 2}) var msgs := _drain(server) var rejected := _find(msgs, "input_rejected") assert_false(rejected.is_empty(), "Second immediate combo should be rejected") assert_eq_str(str(rejected.get("reason", "")), "gcd_active", "Rejection reason should be gcd_active") func test_combo_break_on_wrong_positional() -> void: var server := _make_server() server.start_fight() _drain(server) server.tick(2.6) # Let the GCD recover from a prior (none here, just pacing) _drain(server) # Warden hit 1 needs FRONT; send BACK -> combo break, no damage. server.handle_client_message(0, {"type": "input_ability", "ability_id": "combo_attack", "positional": 2}) var msgs := _drain(server) assert_false(_find(msgs, "event_combo_broken").is_empty(), "Should broadcast combo break") assert_almost(server.sim.raid_state.boss_hp, 1000.0, "No damage on a broken hit") func test_dead_player_inputs_rejected() -> void: var server := _make_server() server.start_fight() _drain(server) server.sim.raid_state.deal_damage_to_player(2, 99999.0) # Kill DPS1 _drain(server) server.handle_client_message(2, {"type": "input_ability", "ability_id": "combo_attack", "positional": 1}) var msgs := _drain(server) assert_true(server.sim.raid_state.boss_hp == 1000.0, "Dead player should deal no damage") func test_off_gcd_ability_broadcasts_use() -> void: var server := _make_server() server.start_fight() _drain(server) # War Cry: off-GCD, 45s cooldown (Warden). server.handle_client_message(0, {"type": "input_ability", "ability_id": "war_cry"}) var msgs := _drain(server) assert_false(_find(msgs, "event_ability_used").is_empty(), "Should broadcast ability use") # Immediately again -> on cooldown. server.handle_client_message(0, {"type": "input_ability", "ability_id": "war_cry"}) msgs = _drain(server) var rejected := _find(msgs, "input_rejected") assert_eq_str(str(rejected.get("reason", "")), "on_cooldown", "Second use should be on cooldown") # --- Events / snapshots ----------------------------------------------------- func test_mechanic_events_broadcast() -> void: var enc := _make_encounter([{"time": 2.0, "event_type": "aoe_spread", "params": {"damage": 100.0}}]) var server := _make_server(enc) server.start_fight() _drain(server) for i in 3: server.tick(1.0) var msgs := _drain(server) var mech := _find(msgs, "event_mechanic") assert_false(mech.is_empty(), "Should broadcast the aoe mechanic") assert_eq_str(str(mech.get("event_type", "")), "aoe_spread", "Mechanic type should be preserved") func test_snapshot_round_trip_through_client_view() -> void: var server := _make_server() server.start_fight() _drain(server) server.handle_client_message(0, {"type": "input_ability", "ability_id": "combo_attack", "positional": 1}) server.tick(2.6) # Crosses the 0.1s snapshot interval var view := ClientView.new() for encoded in server.get_outgoing(): view.apply_message(SyncProtocol.decode(encoded)) assert_eq(view.fight_phase, RaidState.FightPhase.ACTIVE, "View should be ACTIVE") assert_almost(view.boss_hp, 872.0, "View boss HP should reflect the hit") assert_eq(int(view.players.size()), 4, "View should have all 4 players") var tank := view.get_player(0) assert_true(float(tank.get("hp", -1.0)) > 0.0, "Tank entry should have HP data") func test_hard_enrage_wipe_broadcasts_end() -> void: var enc := _make_encounter([], "hard", 10.0) var server := _make_server(enc) server.start_fight() for i in 13: server.tick(1.0) var msgs := _drain(server) var ended := _find(msgs, "fight_ended") assert_false(ended.is_empty(), "Should broadcast fight_ended") assert_eq_str(str(ended.get("result", "")), "wiped", "Result should be wiped") # --- Full fake-client fight ------------------------------------------------- ## A simple bot: attacks whenever its GCD is ready, cycling the Warden's ## positional sequence (FRONT, BACK, any). Runs until the fight ends. func _bot_attack(server: RaidServer, player_id: int) -> void: var p := server.sim.raid_state.players[player_id] if not p.gcd.is_ready(): return var positionals: Array[int] = [1, 2, 0] # FRONT, BACK, NONE (hit 3 accepts any) server.handle_client_message(player_id, { "type": "input_ability", "ability_id": "combo_attack", "positional": positionals[p.combo.current_hit % positionals.size()] }) func test_full_fight_to_kill() -> void: var server := _make_server() server.start_fight() var ended := {} for i in 60: server.tick(2.6) _bot_attack(server, 0) var msgs := _drain(server) ended = _find(msgs, "fight_ended") if not ended.is_empty(): break assert_false(ended.is_empty(), "Bot should have killed the boss within 60 iterations") assert_eq_str(str(ended.get("result", "")), "complete", "Result should be complete") assert_eq_str(str(ended.get("reason", "")), "boss_dead", "Reason should be boss_dead") func test_revive_potion_flow() -> void: var server := _make_server() server.start_fight() _drain(server) server.sim.raid_state.deal_damage_to_player(1, 99999.0) # Kill the healer _drain(server) server.tick(0.2) var down_msgs := _drain(server) assert_false(_find(down_msgs, "event_player_down").is_empty(), "Should broadcast player down") # Tank (id 0) uses a Revive Potion on the healer (id 1). server.handle_client_message(0, {"type": "input_consumable", "consumable_id": "revive_potion", "target_id": 1}) var msgs := _drain(server) assert_false(_find(msgs, "event_player_up").is_empty(), "Should broadcast player up") var healer := server.sim.raid_state.players[1] assert_false(healer.is_dead, "Healer should be alive") assert_almost(healer.hp, 540.0, "Revive at 30% of 1800 max HP") assert_almost(healer.weakness_timer, 90.0, "Weakness debuff applied") # Second potion use by the same player should be rejected (max_per_raid = 1). server.sim.raid_state.deal_damage_to_player(2, 99999.0) _drain(server) server.tick(0.2) _drain(server) server.handle_client_message(0, {"type": "input_consumable", "consumable_id": "revive_potion", "target_id": 2}) msgs = _drain(server) var rejected := _find(msgs, "input_rejected") assert_eq_str(str(rejected.get("reason", "")), "no_potions", "Second potion should be rejected") # --- Assertions ------------------------------------------------------------- func assert_true(condition: bool, msg: String = "") -> void: if not condition: _test_failures += 1 push_error("ASSERT FAILED [true]: " + msg) func assert_false(condition: bool, msg: String = "") -> void: if condition: _test_failures += 1 push_error("ASSERT FAILED [false]: " + msg) func assert_eq(value: int, expected: int, msg: String = "") -> void: if value != expected: _test_failures += 1 push_error("ASSERT FAILED [%d != %d]: %s" % [value, expected, msg]) func assert_eq_str(value: String, expected: String, msg: String = "") -> void: if value != expected: _test_failures += 1 push_error("ASSERT FAILED ['%s' != '%s']: %s" % [value, expected, msg]) func assert_almost(value: float, expected: float, msg: String = "", tolerance: float = 0.5) -> void: if abs(value - expected) > tolerance: _test_failures += 1 push_error("ASSERT FAILED [%f != %f (tol %f)]: %s" % [value, expected, tolerance, msg])