class_name RaidServer extends RefCounted ## Authoritative server logic for one raid instance (up to 4 clients + boss). ## Pure logic, headless-testable: no sockets here. The transport layer feeds ## client messages into handle_client_message() and drains get_outgoing(). ## ## Responsibilities: ## - lobby joins (dynamic player creation + combat system setup from data) ## - input validation (InputValidator) before anything touches FightSim ## - ability resolution -> damage pipeline for combo attacks ## - draining FightSim timeline events into client broadcasts ## - periodic full snapshots (resync after packet loss) ## ## TODO (stat table milestone): per-ability damage numbers, class gimmick ## gauge wiring in the damage pipeline, server-side positional derivation ## from arena geometry (clients currently report their facing). const SNAPSHOT_INTERVAL: float = 0.1 # 10 Hz full snapshots const BASE_ABILITY_DAMAGE: float = 100.0 const WEAKNESS_REDUCTION: float = 0.25 # post-revive debuff (90s) const DEFAULT_ARENA_RADIUS: float = 30.0 var encounter_id: String = "" var sim: FightSim var _class_defs: Dictionary = {} var _outgoing: Array[String] = [] var _last_event_index: int = 0 var _snapshot_accumulator: float = 0.0 var _prev_alive: Dictionary = {} # player_id -> bool var _potion_counts: Dictionary = {} # player_id -> revive potions left var _next_player_id: int = 0 var _fight_ended_broadcasted: bool = false func _init(encounter_data: Dictionary, class_defs: Dictionary = {}) -> void: encounter_id = str(encounter_data.get("id", "")) sim = FightSim.new(encounter_data, []) _class_defs = class_defs if not class_defs.is_empty() else load_class_defs() ## Loads class definitions (combos + abilities) from data/classes.json. static func load_class_defs(path: String = "res://data/classes.json") -> Dictionary: var f := FileAccess.open(path, FileAccess.READ) if f == null: push_error("RaidServer: cannot open " + path) return {} var parsed = JSON.parse_string(f.get_as_text()) if typeof(parsed) != TYPE_DICTIONARY: push_error("RaidServer: invalid JSON in " + path) return {} return parsed as Dictionary ## Registers a client in the lobby. Returns their assigned player_id. func join(name: String, class_id: String, role: String, level: int = 1) -> int: var id := _next_player_id _next_player_id += 1 var p := sim.raid_state.create_player(id, name, class_id, role, level) _setup_combat_systems(p) _potion_counts[id] = 1 _broadcast({ "type": "joined", "player_id": id, "players": _roster() }) return id ## Routes a decoded client message. player_id is -1 for pre-join messages. func handle_client_message(player_id: int, msg: Dictionary) -> void: match str(msg.get("type", "")): "join": if player_id == -1: join( str(msg.get("name", "Player")), str(msg.get("class_id", "warden")), str(msg.get("role", "dps")), int(msg.get("level", 1)) ) "start_fight": start_fight() "input_ability": if sim.raid_state.fight_phase == RaidState.FightPhase.ACTIVE: _handle_input_ability( player_id, str(msg.get("ability_id", "")), int(msg.get("positional", -1)) ) "input_move": if sim.raid_state.fight_phase == RaidState.FightPhase.ACTIVE: _handle_input_move(player_id, msg) "input_consumable": if sim.raid_state.fight_phase == RaidState.FightPhase.ACTIVE: _handle_input_consumable( player_id, str(msg.get("consumable_id", "")), int(msg.get("target_id", -1)) ) ## Starts the fight (lobby -> active). Sets player HP from level. func start_fight() -> void: if sim.raid_state.fight_phase != RaidState.FightPhase.LOBBY: return sim.start() for p in sim.raid_state.players: p.max_hp = _max_hp_for_level(p.level) p.hp = p.max_hp _prev_alive[p.player_id] = true _broadcast({ "type": "fight_started", "encounter_id": encounter_id, "boss_max_hp": sim.raid_state.boss_max_hp }) ## Advances the fight. Call once per server tick (e.g., 20 Hz). func tick(delta_time: float) -> void: if sim.raid_state.fight_phase == RaidState.FightPhase.ACTIVE: sim.tick(delta_time) _drain_sim_events() _detect_player_state_changes() _check_fight_end_broadcast() if sim.raid_state.fight_phase != RaidState.FightPhase.ACTIVE: return _snapshot_accumulator += delta_time if _snapshot_accumulator >= SNAPSHOT_INTERVAL: _snapshot_accumulator = 0.0 _broadcast(SyncProtocol.build_snapshot(sim)) ## Broadcasts fight_ended exactly once when the fight leaves ACTIVE — from a ## tick (enrage wipe) or from input handling (boss kill), whichever happens first. func _check_fight_end_broadcast() -> void: var phase := sim.raid_state.fight_phase if (phase == RaidState.FightPhase.WIPED or phase == RaidState.FightPhase.COMPLETE) \ and not _fight_ended_broadcasted: _fight_ended_broadcasted = true _broadcast({ "type": "fight_ended", "result": "wiped" if phase == RaidState.FightPhase.WIPED else "complete", "reason": sim.raid_state.end_reason }) ## Returns encoded outgoing messages and clears the queue. The transport ## layer sends each string to all connected clients. ## (duplicate(): Arrays are reference types — assigning would alias the queue.) func get_outgoing() -> Array[String]: var out: Array[String] = _outgoing.duplicate() _outgoing.clear() return out # --- Internals ------------------------------------------------------------- func _setup_combat_systems(p: RaidState.PlayerState) -> void: var classes: Dictionary = _class_defs.get("classes", {}) var cdef = classes.get(p.class_id, {}) if typeof(cdef) != TYPE_DICTIONARY: push_error("RaidServer: unknown class %s for player %d" % [p.class_id, p.player_id]) return cdef = cdef as Dictionary var hits: Array[Dictionary] = [] for h in cdef.get("combo", []): if typeof(h) == TYPE_DICTIONARY: hits.append(h) p.combo = ComboState.new(hits) var defs: Array[AbilityResolver.AbilityDef] = [] for a in cdef.get("abilities", []): if typeof(a) != TYPE_DICTIONARY: continue a = a as Dictionary var type_int := AbilityResolver.AbilityType.OFF_GCD if str(a.get("type", "off_gcd")) == "on_gcd": type_int = AbilityResolver.AbilityType.ON_GCD var def := AbilityResolver.AbilityDef.new( str(a.get("id", "")), str(a.get("name", "")), type_int, float(a.get("cooldown", 0.0)) ) def.positional_bonus = int(a.get("positional_bonus", -1)) def.positional_bonus_mult = float(a.get("positional_bonus_mult", 1.0)) defs.append(def) p.abilities = AbilityResolver.new(defs, p.gcd) func _handle_input_ability(player_id: int, ability_id: String, positional: int) -> void: var p := _get_player(player_id) if p == null or p.is_dead or p.is_disconnected: return if ability_id == "combo_attack": if not p.gcd.consume(): _reject(player_id, "gcd_active") return var result := p.combo.advance(positional) if bool(result["broke"]): # GCD is consumed on a break (FFXIV-style), no damage dealt. _broadcast({"type": "event_combo_broken", "player_id": player_id}) return var dmg := _roll_combo_damage(p, result["hit_data"]) sim.deal_damage_to_boss(dmg) _drain_sim_events() _broadcast({ "type": "event_damage", "target": "boss", "source_id": player_id, "amount": dmg }) _check_fight_end_broadcast() return # Non-combo abilities: validate + resolve cooldowns/GCD via the resolver. var verdict := InputValidator.validate_ability(p, ability_id) if verdict.verdict != InputValidator.Verdict.VALID: _reject(player_id, verdict.reason) return var res := p.abilities.try_use_ability(ability_id, positional) if not bool(res["success"]): _reject(player_id, str(res["reason"])) return _broadcast({ "type": "event_ability_used", "player_id": player_id, "ability_id": ability_id, "positional_bonus": bool(res["positional_bonus_applied"]) }) # TODO: per-ability damage/heal numbers once the stat table lands. func _handle_input_move(player_id: int, msg: Dictionary) -> void: var p := _get_player(player_id) if p == null: return var raw = msg.get("position", []) if typeof(raw) != TYPE_ARRAY or (raw as Array).size() < 3: return raw = raw as Array var target_position := Vector3( float(raw[0]), float(raw[1]), float(raw[2]) ) var arena_radius := sim.get_arena_radius(DEFAULT_ARENA_RADIUS) var verdict := InputValidator.validate_move(p, target_position, Vector2(arena_radius, arena_radius)) if verdict.verdict != InputValidator.Verdict.VALID: return # Silently drop illegal moves (no feedback spam). p.position = target_position func _handle_input_consumable(player_id: int, consumable_id: String, target_id: int) -> void: var p := _get_player(player_id) if p == null: return var verdict := InputValidator.validate_consumable(p, consumable_id, target_id) if verdict.verdict != InputValidator.Verdict.VALID: _reject(player_id, verdict.reason) return if consumable_id == "revive_potion": var potions_left := int(_potion_counts.get(player_id, 0)) if potions_left <= 0: _reject(player_id, "no_potions") return if sim.raid_state.revive_player(target_id): _potion_counts[player_id] = potions_left - 1 var revived := _get_player(target_id) _broadcast({ "type": "event_player_up", "player_id": target_id, "hp_pct": 0.3 if revived == null else revived.hp / maxf(revived.max_hp, 1.0) }) else: # TODO: other consumable effects (potions, tonics) once the # progression/inventory layer exists. pass ## Converts new FightSim timeline events into client broadcasts. func _drain_sim_events() -> void: while _last_event_index < sim.event_log.size(): var e: Dictionary = sim.event_log[_last_event_index] match str(e.get("type", "")): "dialog": _broadcast({"type": "event_dialog", "line": str((e["params"] as Dictionary).get("line", ""))}) "phase_shift": _broadcast({"type": "event_phase", "phase": int((e["params"] as Dictionary).get("new_phase", 1))}) "enrage_soft_start": _broadcast({"type": "event_enrage", "kind": "soft"}) "aoe_spread", "frontal_cone", "stack_debuff", "boss_attack": _broadcast({ "type": "event_mechanic", "event_type": e["type"], "params": e["params"], "time": e["time"] }) _last_event_index += 1 ## Broadcasts player death / revive transitions (diffed against last tick). func _detect_player_state_changes() -> void: for p in sim.raid_state.players: var was_alive: bool = _prev_alive.get(p.player_id, true) var is_alive := not p.is_dead and not p.is_disconnected if was_alive and not is_alive: _broadcast({"type": "event_player_down", "player_id": p.player_id}) elif not was_alive and is_alive: _broadcast({ "type": "event_player_up", "player_id": p.player_id, "hp_pct": p.hp / maxf(p.max_hp, 1.0) }) _prev_alive[p.player_id] = is_alive func _roll_combo_damage(p: RaidState.PlayerState, hit_data: Dictionary) -> float: var base := BASE_ABILITY_DAMAGE * (1.0 + 0.15 * float(p.level - 1)) var dmg := base * float(hit_data.get("damage_mult", 1.0)) if p.weakness_timer > 0.0: dmg = DamageCalculator.apply_weakness(dmg, WEAKNESS_REDUCTION) return dmg func _max_hp_for_level(level: int) -> float: return 1000.0 * (1.0 + 0.2 * float(level - 1)) func _get_player(player_id: int) -> RaidState.PlayerState: for p in sim.raid_state.players: if p.player_id == player_id: return p return null func _roster() -> Array[Dictionary]: var roster: Array[Dictionary] = [] for p in sim.raid_state.players: roster.append({"id": p.player_id, "name": p.name, "class_id": p.class_id, "role": p.role}) return roster func _broadcast(msg: Dictionary) -> void: _outgoing.append(SyncProtocol.encode(msg)) func _reject(player_id: int, reason: String) -> void: _broadcast({"type": "input_rejected", "player_id": player_id, "reason": reason})