Files
tactical-shooter/server/scripts/anti_cheat.gd
T
shawn e7299b17e9 Phase 7: netfox + godot-jolt stack upgrade
Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
2026-07-02 17:39:22 -04:00

678 lines
24 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node
class_name AntiCheat
# ═════════════════════════════════════════════════════════
# Server-authoritative anti-cheat validation coordinator
#
# Per-player state tracking and input validation pipeline.
# Called from the server's _physics_process per player per tick.
#
# Validation pipeline order:
# 1. Input sequence (timestamp monotonic, seq# dedup)
# 2. Movement (speed limit, teleport, multi-jump)
# 3. Aim (max snap rotation per tick)
# 4. Fire rate (interval between shots, ammo consistency)
# 5. Command rate (commands/second limit)
# 6. Enforcement (log / correct / warn / kick)
#
# Designed to be loaded once on the server and called per tick
# from the game's player state update loop.
# ═════════════════════════════════════════════════════════
# ─── Enforcement Levels ──────────────────────────────────
enum AcLevel {
OFF = 0, # Disabled — no validation performed
LOG = 1, # Log violations to server console only
CORRECT = 2, # Correct invalid input + warn player (default)
KICK = 3, # Kick player on threshold breach
}
# ─── Violation Categories ────────────────────────────────
enum ViolationCategory {
INPUT_SEQUENCE, # Bad sequence / replay / timestamp
MOVEMENT_SPEED, # Exceeded speed limit
MOVEMENT_TELEPORT, # Instant position change
MOVEMENT_MULTI_JUMP, # Jump without ground contact
AIM_SNAP, # View-angle delta exceeds limit
FIRE_RATE_INTERVAL, # Shots too close together
FIRE_RATE_AMMO, # Ammo delta inconsistent with shot
COMMAND_RATE, # > max_commands/sec from client
}
# ─── Cvars (exported for inspector; override via config system) ──
## Enforcement level (0=off, 1=log, 2=correct, 3=kick)
@export_range(0, 3, 1) var sv_ac_level: int = 2
## Speed multiplier before flagging (1.2 = 20% over max)
@export_range(1.0, 3.0, 0.05) var sv_ac_speed_tolerance: float = 1.2
## Max view-angle delta in degrees per tick
@export_range(10.0, 360.0, 1.0) var sv_ac_max_aim_snap: float = 180.0
## Fire interval tolerance in ms above weapon's fire rate
@export_range(0, 100, 1) var sv_ac_fire_tolerance_ms: int = 10
## Max commands from one client per second
@export_range(64, 254, 1) var sv_ac_command_rate_max: int = 128
## Max distance units per tick before flagged as teleport
@export_range(10.0, 500.0, 1.0) var sv_ac_teleport_threshold: float = 100.0
## Violations before kick (only at AcLevel.KICK)
@export_range(1, 50, 1) var sv_ac_kick_threshold: int = 5
## Enable/disable multi-jump check
@export var sv_ac_check_multi_jump: bool = true
## Enable/disable ammo consistency check
@export var sv_ac_check_ammo: bool = true
# ─── Signals ─────────────────────────────────────────────
## Emitted when a violation triggers a kick decision.
signal player_kicked(peer_id: int, reason: String, violation_count: int)
## Emitted on every validated violation (at all enforcement levels).
signal violation_recorded(peer_id: int, category: int, details: String)
## Emitted when the anti-cheat corrects an input field.
signal input_corrected(peer_id: int, field: String, original_value, clamped_value)
# ─── Per-Player Tracked State ────────────────────────────
class PlayerState:
var peer_id: int
var last_seq: int = -1
var last_timestamp: float = 0.0
var last_position: Vector3 = Vector3.ZERO
var last_view_angles: Vector2 = Vector2.ZERO
var last_fire_time: float = 0.0
var last_ammo: int = -1
var on_ground: bool = true # server-authoritative (set via set_ground_contact)
var jump_count: int = 0 # consecutive airborne jumps
var last_fire_pressed: bool = false
var _aim_initialized: bool = false # has aim been recorded at least once?
var command_times: Array[float] = []
var violation_count: int = 0
var violations_log: Array[Dictionary] = []
var connected_at: float = 0.0
func _init(id: int, time: float):
peer_id = id
connected_at = time
func reset(time: float) -> void:
last_seq = -1
last_timestamp = 0.0
last_position = Vector3.ZERO
last_view_angles = Vector2.ZERO
last_fire_time = 0.0
last_ammo = -1
on_ground = true
jump_count = 0
last_fire_pressed = false
_aim_initialized = false
command_times.clear()
violation_count = 0
violations_log.clear()
connected_at = time
# ─── Internal State ──────────────────────────────────────
var _player_states: Dictionary = {} # peer_id → PlayerState
# ─── Public API ──────────────────────────────────────────
func register_player(peer_id: int) -> void:
"""Register a new player for anti-cheat tracking.
Call when a player fully connects to the server.
The player's state is initialized fresh.
"""
var state = PlayerState.new(peer_id, Time.get_ticks_usec() / 1_000_000.0)
_player_states[peer_id] = state
func unregister_player(peer_id: int) -> void:
"""Remove a player from anti-cheat tracking.
Call on player disconnect to clean up state.
"""
_player_states.erase(peer_id)
func is_tracking(peer_id: int) -> bool:
"""Returns true if peer_id is being tracked."""
return _player_states.has(peer_id)
func validate_input(peer_id: int, input_packet: Dictionary, delta: float) -> Dictionary:
"""Run full validation pipeline on a player's input packet.
Parameters:
peer_id: Network peer ID of the player
input_packet: Dict with keys:
seq (int) — monotonic sequence number
time (float) — client timestamp (seconds)
movement (Vector3) — movement direction
view_angles (Vector2) — camera pitch/yaw
buttons (int) — bitmask: 1=jump, 2=fire, 4=crouch, 8=use
ammo (int) — current weapon ammo (-1 if unknown/melee)
position (Vector3) — player world position
max_speed (float, optional) — current max player speed
weapon_fire_rate_ms (float, optional) — current fire interval
burst_count (int, optional) — ammo per burst (default: 1)
delta: Physics tick delta (e.g. 1/128 ≈ 0.0078)
Integration note:
After calling validate_input() and applying corrected input
to physics, call set_ground_contact(peer_id, on_ground) to
furnish server-authoritative ground-state for the next tick's
multi-jump validation.
Returns:
Dict with:
corrected (Dictionary) — cleaned input packet (may have clamped fields)
violations (Array[Dictionary]) — violations detected this tick
action_taken (String) — "" / "warned" / "kicked"
"""
var result = {
"corrected": input_packet.duplicate(),
"violations": [],
"action_taken": "",
}
if sv_ac_level == AcLevel.OFF or not _player_states.has(peer_id):
return result
var state: PlayerState = _player_states[peer_id]
# ── Pipeline ──
# 1. Input sequence validation (always runs)
_result_seq(_validate_sequence(input_packet, state, result["corrected"]), peer_id, state, result)
# 2. Movement validation
_result_mv(_validate_movement(input_packet, state, delta, result["corrected"]), peer_id, state, result)
# 3. Aim validation
_result_aim(_validate_aim(input_packet, state, result["corrected"]), peer_id, state, result)
# 4. Fire rate validation
_result_fire(_validate_fire_rate(input_packet, state, result["corrected"]), peer_id, state, result)
# 5. Command rate validation
_result_cmd(_validate_command_rate(input_packet, state), peer_id, state, result)
# 6. Ammo validation
if sv_ac_check_ammo:
_result_ammo(_validate_ammo(input_packet, state, result["corrected"]), peer_id, state, result)
# ── Enforcement (post-pipeline) ──
if state.violation_count > 0 and result["violations"].size() > 0:
# Set warned flag at any level above OFF
if sv_ac_level >= AcLevel.CORRECT:
result["action_taken"] = "warned"
# Kick decision — at KICK level when threshold breached
if sv_ac_level >= AcLevel.KICK and state.violation_count >= sv_ac_kick_threshold:
var reason: String = "Anti-cheat violation threshold reached (%d/%d)" % [
state.violation_count, sv_ac_kick_threshold
]
player_kicked.emit(peer_id, reason, state.violation_count)
result["action_taken"] = "kicked"
# ── Post-validation bookkeeping ──
if input_packet.has("position") and typeof(input_packet.position) == TYPE_VECTOR3:
state.last_position = result["corrected"].get("position", input_packet.position)
if input_packet.has("time"):
state.last_timestamp = input_packet.time
if input_packet.has("view_angles"):
state.last_view_angles = result["corrected"].get("view_angles", input_packet.view_angles)
return result
func get_player_violations(peer_id: int) -> Array:
"""Return a copy of the violation log for a player."""
if not _player_states.has(peer_id):
return []
return _player_states[peer_id].violations_log.duplicate()
func get_player_violation_count(peer_id: int) -> int:
"""Return the total violation count for a player."""
if not _player_states.has(peer_id):
return 0
return _player_states[peer_id].violation_count
func reset_player_violations(peer_id: int) -> void:
"""Reset violation counter for a player (e.g. after admin pardon)."""
if _player_states.has(peer_id):
var s: PlayerState = _player_states[peer_id]
s.violation_count = 0
s.violations_log.clear()
func clear_all() -> void:
"""Remove all tracked players."""
_player_states.clear()
func set_ground_contact(peer_id: int, on_ground: bool) -> void:
"""Set the server-authoritative ground-contact state for a player.
Must be called by the game server AFTER physics simulation each tick,
so the anti-cheat uses authoritative ground state for multi-jump
detection on the next tick.
"""
if _player_states.has(peer_id):
_player_states[peer_id].on_ground = on_ground
# ─── Individual Validators ───────────────────────────────
func _validate_sequence(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
"""Check sequence monotonicity and timestamp monotonicity.
Returns violation Dictionaries or null.
"""
var violations: Array[Dictionary] = []
var seq: int = packet.get("seq", -1)
var time: float = packet.get("time", 0.0)
# First packet always accepted
if state.last_seq == -1:
state.last_seq = seq
state.last_timestamp = time
return {"violations": violations, "invalid": false}
var invalid := false
# Sequence must increase monotonically
if seq <= state.last_seq:
violations.append(_make_violation(
ViolationCategory.INPUT_SEQUENCE,
"Non-monotonic sequence: %d%d" % [state.last_seq, seq],
))
invalid = true
# Timestamp must increase monotonically
if time < state.last_timestamp:
violations.append(_make_violation(
ViolationCategory.INPUT_SEQUENCE,
"Timestamp regression: %.4f%.4f" % [state.last_timestamp, time],
))
invalid = true
# Update state on valid (or lightly correct)
if not invalid:
state.last_seq = seq
state.last_timestamp = time
else:
# Correct: bump seq forward so we don't accumulate stale state
if seq <= state.last_seq:
corrected["seq"] = state.last_seq + 1
return {"violations": violations, "invalid": invalid}
func _validate_movement(packet: Dictionary, state: PlayerState, delta: float, corrected: Dictionary) -> Dictionary:
"""Check speed limit, teleport, and multi-jump.
The player's max speed is defined by the movement controller.
We use a configurable tolerance multiplier.
"""
var violations: Array[Dictionary] = []
if not packet.has("position") or typeof(packet.position) != TYPE_VECTOR3:
return {"violations": violations, "corrected": false}
var pos: Vector3 = packet.position
var prev_pos: Vector3 = state.last_position
var displacement: Vector3 = pos - prev_pos
var distance: float = displacement.length()
# ── Teleport check ──
if distance > sv_ac_teleport_threshold and state.last_seq != -1:
violations.append(_make_violation(
ViolationCategory.MOVEMENT_TELEPORT,
"Teleport: moved %.1f units in one tick (threshold %.1f)" % [distance, sv_ac_teleport_threshold],
{"distance": distance, "threshold": sv_ac_teleport_threshold},
))
# Correct: reset position to previous
corrected["position"] = prev_pos
return {"violations": violations, "corrected": true}
# ── Speed check ──
# Max speed varies by weapon/game state; use a configurable tolerance
# over the assumed base speed. The actual game should pass its max_speed
# to validate_input(...) or we use a cvars-driven estimate.
var max_speed_units_per_sec: float = _resolve_max_speed(packet)
var max_speed_this_tick: float = max_speed_units_per_sec * sv_ac_speed_tolerance * delta
var speed: float = distance / max(delta, 0.001)
if distance > max_speed_this_tick and state.last_seq != -1:
violations.append(_make_violation(
ViolationCategory.MOVEMENT_SPEED,
"Speed: %.1f u/s (max %.1f u/s × %.1f tolerance)" % [speed, max_speed_units_per_sec, sv_ac_speed_tolerance],
{"speed": speed, "max_allowed": max_speed_this_tick / delta},
))
# Correct: clamp displacement to max permitted
if distance > 0.001:
var clamped_displacement: Vector3 = displacement.normalized() * min(distance, max_speed_this_tick)
corrected["position"] = prev_pos + clamped_displacement
return {"violations": violations, "corrected": true}
# ── Multi-jump check ──
# jump_count only resets on ground contact, NOT on button release,
# to prevent tap-jump bypass (pressing jump repeatedly while airborne).
if sv_ac_check_multi_jump:
var jump_pressed: bool = (packet.get("buttons", 0) & 1) != 0
var was_on_ground: bool = state.on_ground
if was_on_ground:
state.jump_count = 0
elif jump_pressed:
state.jump_count += 1
var max_jumps: int = 1 # only one jump allowed without ground contact
if not was_on_ground and state.jump_count > max_jumps:
violations.append(_make_violation(
ViolationCategory.MOVEMENT_MULTI_JUMP,
"Multi-jump: %d jumps without ground contact" % state.jump_count,
{"jump_count": state.jump_count, "max_jumps": max_jumps},
))
# Correct: suppress the jump flag
corrected["buttons"] = packet.get("buttons", 0) & ~1
return {"violations": violations, "corrected": true}
return {"violations": violations, "corrected": false}
func _validate_aim(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
"""Check view-angle delta per tick doesn't exceed max snap.
view_angles is Vector2(pitch, yaw) in degrees.
"""
var violations: Array[Dictionary] = []
if not packet.has("view_angles"):
return {"violations": violations, "corrected": false}
var angles: Vector2 = packet.view_angles
# First tick — just record (no aim-initialized state yet)
if not state._aim_initialized:
state._aim_initialized = true
return {"violations": violations, "corrected": false}
# Compute shortest angular distance per axis, handling yaw wraparound
var delta_angles := Vector2()
for axis in [0, 1]:
var raw_delta: float = angles[axis] - state.last_view_angles[axis]
var wrapped_delta: float = fmod(raw_delta, 360.0)
if wrapped_delta > 180.0:
wrapped_delta -= 360.0
elif wrapped_delta < -180.0:
wrapped_delta += 360.0
delta_angles[axis] = abs(wrapped_delta)
var max_delta: float = max(delta_angles.x, delta_angles.y)
if max_delta > sv_ac_max_aim_snap and state.last_seq != -1:
violations.append(_make_violation(
ViolationCategory.AIM_SNAP,
"Aim snap: %.1f° in one tick (max %.1f°)" % [max_delta, sv_ac_max_aim_snap],
{"delta": max_delta, "max_allowed": sv_ac_max_aim_snap},
))
# Correct: clamp delta per axis, handling yaw wraparound
var clamped_angles := state.last_view_angles
for axis in [0, 1]:
var diff: float = angles[axis] - state.last_view_angles[axis]
# Shortest-path diff, handling wraparound
var wrapped_diff: float = fmod(diff, 360.0)
if wrapped_diff > 180.0:
wrapped_diff -= 360.0
elif wrapped_diff < -180.0:
wrapped_diff += 360.0
# If the shortest path is within snap limit, allow it; otherwise clamp raw diff
if abs(wrapped_diff) > sv_ac_max_aim_snap:
var clamped_diff: float = clamp(diff, -sv_ac_max_aim_snap, sv_ac_max_aim_snap)
clamped_angles[axis] = state.last_view_angles[axis] + clamped_diff
corrected["view_angles"] = clamped_angles
return {"violations": violations, "corrected": true}
return {"violations": violations, "corrected": false}
func _validate_fire_rate(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
"""Check minimum interval between shots.
Weapon fire rate is represented as min_interval_ms.
If the game provides it in the packet, great; otherwise use a
reasonable minimum (based on fastest weapon in the game: ~100ms = 600rpm).
"""
var violations: Array[Dictionary] = []
var buttons: int = packet.get("buttons", 0)
var fire_pressed: bool = (buttons & 2) != 0
if not fire_pressed:
state.last_fire_pressed = false
return {"violations": violations, "corrected": false}
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
var elapsed_ms: float = (now - state.last_fire_time) * 1000.0
# If weapon provides its fire interval, use it; else use a reasonable min
var weapon_min_interval_ms: float = packet.get("weapon_fire_rate_ms", 80.0)
var allowed_interval_ms: float = weapon_min_interval_ms - sv_ac_fire_tolerance_ms
# First fire always accepted
if state.last_fire_time <= 0.0:
state.last_fire_time = now
state.last_fire_pressed = true
return {"violations": violations, "corrected": false}
if elapsed_ms < allowed_interval_ms:
violations.append(_make_violation(
ViolationCategory.FIRE_RATE_INTERVAL,
"Fire rate: %.1fms interval (min allowed %.1fms)" % [elapsed_ms, allowed_interval_ms],
{"elapsed_ms": elapsed_ms, "min_allowed_ms": allowed_interval_ms},
))
# Correct: suppress fire button
corrected["buttons"] = buttons & ~2
state.last_fire_pressed = false
return {"violations": violations, "corrected": true}
state.last_fire_time = now
state.last_fire_pressed = true
return {"violations": violations, "corrected": false}
func _validate_command_rate(packet: Dictionary, state: PlayerState) -> Dictionary:
"""Limit commands per second from a client.
Commands include any client→server packet. We track a sliding
window of recent command timestamps.
"""
var violations: Array[Dictionary] = []
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
var window_start: float = now - 1.0 # 1 second window
# Prune old entries
var i: int = 0
while i < state.command_times.size():
if state.command_times[i] < window_start:
state.command_times.remove_at(i)
else:
i += 1
state.command_times.append(now)
# Allow a small burst at the start of a connection
var is_new_connection: bool = (now - state.connected_at) < 3.0
var burst_allowed: int = 4
var limit: int = sv_ac_command_rate_max + (burst_allowed if is_new_connection else 0)
if state.command_times.size() > limit:
# Log one violation per excess over the limit
var excess: int = state.command_times.size() - limit
if excess > 0:
violations.append(_make_violation(
ViolationCategory.COMMAND_RATE,
"Command rate: %d/s (max %d/s)" % [state.command_times.size(), sv_ac_command_rate_max],
{"actual_rate": state.command_times.size(), "max_rate": sv_ac_command_rate_max},
))
# Keep window bounded — single cleanup pass
while state.command_times.size() > limit + 8:
state.command_times.remove_at(0)
return {"violations": violations}
func _validate_ammo(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
"""Check ammo delta consistency.
If the player fired last tick, ammo should decrease by at most 1
(or by burst count if provided).
"""
var violations: Array[Dictionary] = []
if not packet.has("ammo"):
return {"violations": violations, "corrected": false}
var ammo: int = packet.ammo
if state.last_ammo == -1:
state.last_ammo = ammo
return {"violations": violations, "corrected": false}
var buttons: int = packet.get("buttons", 0)
var fire_this: bool = (buttons & 2) != 0
var delta_ammo: int = ammo - state.last_ammo
# Expected: negative if fired, 0 if not fired, positive if picking up ammo
if state.last_fire_time > 0 and fire_this:
# Should have decreased by 1 (or burst count)
var expected_delta: int = -packet.get("burst_count", 1)
if delta_ammo > 0:
violations.append(_make_violation(
ViolationCategory.FIRE_RATE_AMMO,
"Ammo increased while firing: %d%d" % [state.last_ammo, ammo],
{"last_ammo": state.last_ammo, "new_ammo": ammo},
))
# Correct: keep old ammo value
corrected["ammo"] = state.last_ammo
return {"violations": violations, "corrected": true}
elif delta_ammo > expected_delta:
# Less ammo used than expected (e.g., 0 instead of -1)
violations.append(_make_violation(
ViolationCategory.FIRE_RATE_AMMO,
"Ammo mismatch while firing: delta %d (expected %d)" % [delta_ammo, expected_delta],
{"delta": delta_ammo, "expected": expected_delta},
))
corrected["ammo"] = max(0, state.last_ammo + expected_delta)
return {"violations": violations, "corrected": true}
state.last_ammo = ammo
return {"violations": violations, "corrected": false}
# ─── Enforcement Helpers ────────────────────────────────
func _make_violation(category: int, details: String, extra: Dictionary = {}) -> Dictionary:
var v := {
"category": category,
"category_name": ViolationCategory.keys()[category],
"details": details,
"time": Time.get_ticks_usec() / 1_000_000.0,
"extra": extra,
}
return v
func _record_violation(state: PlayerState, v: Dictionary) -> void:
state.violation_count += 1
state.violations_log.append(v)
# Keep log bounded
if state.violations_log.size() > 100:
state.violations_log.pop_front()
func _resolve_max_speed(packet: Dictionary) -> float:
"""Get max player speed from packet or default.
The game should set 'max_speed' in the input packet.
If not provided, assume a standard FPS walking speed.
"""
if packet.has("max_speed"):
return packet.max_speed
return 320.0 # Default: CS-style max speed
# ─── Pipeline Result Handlers ──────────────────────────
func _result_seq(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
func _result_mv(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
if r.get("corrected", false):
input_corrected.emit(peer_id, "position", null, null)
func _result_aim(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
if r.get("corrected", false):
input_corrected.emit(peer_id, "view_angles", null, null)
func _result_fire(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
if r.get("corrected", false):
input_corrected.emit(peer_id, "fire", null, null)
func _result_cmd(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
func _result_ammo(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
for v in r.get("violations", []):
_record_violation(state, v)
result["violations"].append(v)
violation_recorded.emit(peer_id, v.category, v.details)
if r.get("corrected", false):
input_corrected.emit(peer_id, "ammo", null, null)