class_name InputValidator extends RefCounted ## Validates client inputs before they touch authoritative raid state. ## The server NEVER trusts the client: every action packet is checked here ## for legality (cooldowns, GCD, range, alive status) before resolution. enum Verdict { VALID, REJECTED } ## Result of a validation check. class ValidationResult: var verdict: int = Verdict.VALID # Verdict enum var reason: String = "" # Machine-readable rejection reason (for debug/network logs) func _init(verdict: int = Verdict.VALID, reason: String = ""): self.verdict = verdict self.reason = reason ## Validates an "use ability" input from a client. ## player: The authoritative PlayerState for this player. ## ability_id: The ability the client claims to have used. ## Returns a ValidationResult. static func validate_ability(player, ability_id: String) -> ValidationResult: if player == null: return ValidationResult.new(Verdict.REJECTED, "no_player") if player.is_dead: return ValidationResult.new(Verdict.REJECTED, "player_dead") if player.is_disconnected: return ValidationResult.new(Verdict.REJECTED, "player_disconnected") var resolver = player.abilities if resolver == null: return ValidationResult.new(Verdict.REJECTED, "no_resolver") # Delegate the detailed checks (GCD state / cooldown) to the resolver's # dry-run view so we don't consume anything during validation. if not resolver._abilities.has(ability_id): return ValidationResult.new(Verdict.REJECTED, "unknown_ability") var state = resolver._abilities[ability_id] var ability_type := AbilityResolver.AbilityType.ON_GCD # default assumption guard if state.def.type == AbilityResolver.AbilityType.OFF_GCD: if not state.is_ready(): return ValidationResult.new(Verdict.REJECTED, "on_cooldown") elif not resolver.gcd.is_ready() and not resolver.gcd.is_in_queue_window(): return ValidationResult.new(Verdict.REJECTED, "gcd_active") return ValidationResult.new(Verdict.VALID) ## Validates a "move" input. Movement is clamped to arena bounds; the client ## sends its intended position and we reject anything outside the arena. static func validate_move(player, target_position: Vector3, arena_bounds: Vector2) -> ValidationResult: if player == null: return ValidationResult.new(Verdict.REJECTED, "no_player") if player.is_dead or player.is_disconnected: return ValidationResult.new(Verdict.REJECTED, "not_controllable") var x := absf(target_position.x) var z := absf(target_position.z) if x > arena_bounds.x or z > arena_bounds.y: return ValidationResult.new(Verdict.REJECTED, "out_of_arena") return ValidationResult.new(Verdict.VALID) ## Validates a "use consumable" input. ## player: The authoritative PlayerState. ## consumable_id: e.g., "revive_potion". ## target_player_id: For targeted consumables (e.g., revive). -1 for self-cast. static func validate_consumable(player, consumable_id: String, target_player_id: int = -1) -> ValidationResult: if player == null: return ValidationResult.new(Verdict.REJECTED, "no_player") if player.is_dead and consumable_id != "revive_potion": # A dead player can only be the *target* of a revive, not the user. return ValidationResult.new(Verdict.REJECTED, "player_dead") if target_player_id == -1: # Self-cast: must be alive and able to act. if player.is_disconnected: return ValidationResult.new(Verdict.REJECTED, "player_disconnected") else: # Targeted (e.g., revive): the user must be alive; target rules are # checked by the raid state when the effect resolves. pass return ValidationResult.new(Verdict.VALID) ## Validates a "target change" input — the target id must exist in the raid. static func validate_target_change(player, target_id: int, known_ids: Array[int]) -> ValidationResult: if player == null: return ValidationResult.new(Verdict.REJECTED, "no_player") if not known_ids.has(target_id): return ValidationResult.new(Verdict.REJECTED, "unknown_target") return ValidationResult.new(Verdict.VALID)