Ir al contenido
AURUM Documentación
/

Plan maestro · 05

41 min de lectura 8.981 palabras 85 secciones

Servidor Rust, netcode y persistencia

Este documento define la arquitectura completa del servidor autoritativo de Argentum Web (provisional): proceso Rust, protocolo binario sobre WebSocket, reconexión con gracia, anti-cheat, persistencia Postgres, cuentas, moderación transparente, escala y operación. Todo lo que el cliente muestra, acá se decide; el cliente jamás decide nada. Las decisiones abiertas están marcadas con "⚠️ DECISIÓN PENDIENTE DE PABLO" y numeradas D1–D10.


0. Principios rectores (evidencia → requisito de servidor)

Cada principio sale de la investigación (research/01, 03, 04, 05, 06). El resto del documento los implementa; si una decisión futura contradice esta tabla, se discute contra la evidencia, no contra gustos.

# Evidencia Requisito de servidor
P1 Queja nº1 del cluster móvil: "desconexión → el personaje queda logueado → te matan → perdés todo" (77 votos, 6 años sin arreglar — research/05) Reconexión con gracia como feature de primera clase. La muerte JAMÁS por desconexión (§6).
P2 Herida nº1 del AO oficial: "staff corrupto, moderación arbitraria" (56+13 menciones, top negativo — research/05); "operador no confiable" = causa nº1 de éxodo en los 6 comparables (research/06) Toda acción de GM deja rastro público. Registro público de sanciones + apelación con SLA + acciones imposibles de ejecutar sin log (§10).
P3 Los juegos de grindeo son imanes de bots por construcción; a Tibia le costó ⅔ de su población (research/04) Server-authoritative total + rate-limit por paquete + detección estadística + ledger de economía desde el día 1 (§7, §8).
P4 Pedido nº1 de Tibia: instancias/canales para spots; cultura del "dominando" (research/06) Canales dinámicos por zona de caza + spawns que escalan, como infraestructura nativa (§5).
P5 Población mal dimensionada mata lanzamientos (Ravendawn 3×; privados de AO dispersos — research/04) Un megaservidor LatAm, capacidad 3–5× el objetivo, login queue digna para la apertura ritual (§12, §16).
P6 "Publicar números reales; los claims inflados se auditan solos" (regla de oro — research/06) Contador de jugadores online público, real, con API (§13).
P7 El feel exacto (caminar 210 ms/tile, cooldowns) es sagrado; "el riesgo nº1 es que no se sienta como AO" (research/05) Los intervalos heredados se aplican en milisegundos exactos server-side, nunca cuantizados al tick (§3).
P8 Exploits de economía al lanzar = portada (dupe de Ravendawn a las 5 semanas — research/04) Inventarios transaccionales + ledger de doble entrada + reconciliación nocturna ANTES del launch (§8).
P9 Rewrites totales de AO fracasaron (C#/Unity abandonados); lo que funcionó fue ingeniería incremental con datos heredados (research/01) El servidor consume los datos convertidos de AO20 (mapas .csm, obj.dat, npcs.dat, Balance.dat, intervalos) como fuente canónica de contenido y balance.
P10 El navegador se actualiza solo — ventaja estructural vs clientes descargables Protocolo versionado con handshake estricto: nunca mantener compatibilidad con clientes viejos; se fuerza refresh (§4).

1. Arquitectura del proceso servidor

1.1 Workspace de crates (patrón Veloren)

Veloren demostró que un workspace con lógica compartida en crates chicos mantiene los tiempos de compilación sanos y la lógica testeable sin arrancar el mundo entero (research/03). Estructura:

argentum-server/            (workspace raíz)
├── crates/
│   ├── protocol/           Esquema de paquetes + codegen (fuente única Rust↔TS) — §4
│   ├── sim-core/           TODAS las reglas del juego: movimiento, combate, skills,
│   │                       fórmulas de Balance.dat, estados, triggers. PURO:
│   │                       sin tokio, sin sockets, sin SQL, sin reloj de pared
│   │                       (el tiempo entra como parámetro). Determinista y testeable.
│   ├── world-data/         Carga del contenido convertido (mapas, obj, npcs, hechizos,
│   │                       balance) con validación de checksums; hot-reload en dev.
│   ├── net/                Listener WSS, sesiones, framing, rate-limit por paquete,
│   │                       tokens de resume, batching por tick.
│   ├── persist/            sqlx + migraciones + actores de persistencia + ledger.
│   ├── anticheat/          Validadores, heurísticas estadísticas, replay logger.
│   ├── admin-api/          REST (axum) para el panel admin + auth de staff.
│   └── i18n/               IDs de mensajes + catálogos ES/EN/RU del lado servidor
│                           (solo para email/panel; el juego manda IDs, ver §4.6).
├── bin/
│   ├── server/             El binario: compone todo lo anterior.
│   ├── bots/               Clientes headless para carga y chaos testing — §14.
│   └── tools/              Inspectores: dump de mapas, visor de replays, verify-world.

Reglas duras del workspace:

  • sim-core no conoce red ni base de datos. Recibe (estado, comando, ahora_ms) y devuelve (estado', eventos). Esto habilita: tests de mesa con vectores dorados del Balance.dat, replay determinista de sesiones sospechosas, bots que reutilizan las mismas reglas, y la opción futura (no compromiso) de compilar predicción exacta a WASM para el cliente.
  • protocol es la única frontera cliente-servidor. Nada serializa a mano fuera de él.
  • unsafe prohibido en sim-core, protocol, net (lint #![forbid(unsafe_code)]); excepciones puntuales solo en crates de infraestructura con justificación en comentario.
  • CI corre cargo clippy -D warnings, cargo deny (licencias/vulns), cargo audit.

1.2 Modelo de ejecución: IO async, simulación en hilos dedicados

Diagrama mermaid — se muestra como código fuente
flowchart LR
    subgraph tokio [Runtime tokio - IO]
        WS[Listener WSS<br/>tareas por conexión] --> RL[Rate-limit +<br/>decode protocol]
        API[admin-api axum]
        PA[Actores de persistencia<br/>sqlx pool]
    end
    subgraph sim [Hilos dedicados - Simulación]
        Z1[Zone worker 1<br/>mapas 1..N]
        Z2[Zone worker 2<br/>mapas N+1..M]
        ZI[Instance worker<br/>dungeons/arenas]
        GS[Servicios globales:<br/>chat, mercado, clanes,<br/>votos, matchmaker]
    end
    RL -- canales mpsc --> Z1 & Z2 & ZI & GS
    Z1 & Z2 & ZI & GS -- eventos --> WS
    Z1 & Z2 & ZI & GS -- writes --> PA
  • tokio (multi-thread) maneja sockets, TLS-passthrough, admin API y la cola hacia Postgres. Una tarea liviana por conexión: decodifica, valida rate, y encola comandos tipados hacia el zone worker que posee al personaje.
  • Zone workers: hilos dedicados (no tokio) con loop de tick fijo. Cada worker posee en exclusiva un conjunto de mapas y todas sus entidades: cero locks en el hot path, comunicación solo por canales (flume/crossbeam). Viajar entre mapas de workers distintos = handoff por mensaje (el personaje se serializa, se transfiere, se confirma; durante el handoff no recibe daño — dura <1 tick).
  • Instance workers: mismos zone workers pero para mapas efímeros (dungeon del MVP, arena PvP, battlegrounds futuros). Se crean y destruyen a demanda.
  • Servicios globales (chat, mercado, clanes, votos, lista de amigos): actores propios fuera del tick de zonas, porque su latencia tolerable es mayor y su estado es transversal.
  • En el MVP alcanza con 1 zone worker + 1 instance worker (AO20 sostiene su pico histórico de 678 CCU en VB6 mono-hilo — research/05); la arquitectura permite subir a N workers repartiendo mapas sin rediseño. El reparto de mapas a workers es configuración, con rebalanceo en mantenimiento (no en caliente en MVP).
  • Sin Redis, sin Kafka, sin microservicios en MVP. Todo en un proceso. Cada pieza de infraestructura extra es un modo de fallo nuevo; se agregan cuando una métrica lo exija (§12.4).

1.3 Estado en RAM, Postgres como durabilidad

El mundo autoritativo vive en memoria de los workers. Postgres no está en el hot path del tick. La frontera exacta (qué se escribe cuándo y qué es transaccional) está en §8.1. Presupuesto de memoria: una entidad personaje completa (stats, inventario, cooldowns, buffs) ronda 2–4 KB; 2.500 CCU + 20.000 NPCs activos caben holgados en <1 GB. La RAM del servidor la dominan los buffers de red y el page cache de Postgres, no el mundo.


2. Datos del mundo: contrato con el conversor

El servidor carga en el arranque un bundle de mundo versionado producido por el conversor (mapas .csm decodificados + .dat parseados — research/01):

  • Formato: binario propio (mismo codegen del protocolo) o flatbuffers de archivo; incluye world_version (semver) + hash SHA-256 por archivo.
  • Contiene por mapa: grilla 100×100 con Bloqueados con bitmask direccional por lado (fiel al .csm), capas lógicas relevantes al server (agua/lava por rango de GrhIndex, techos para "bajo techo"), los 40 tipos de triggers por tile (zona segura, cárcel, autoresu…), TileExits, spawns de NPC, flags de mapa (Seguro, restricciones, DropItems, clima) y metadata (nombre, música — el ID, no el archivo).
  • Contiene globales: obj.dat (4.996 ítems), npcs.dat (1.404), Hechizos.dat (293), Balance.dat, Ciudades.Dat, intervalos exactos (210/1165/1230/800 ms), recetas, quests del slice.
  • El cliente recibe el hash del bundle en el handshake (§4.2): si el server y el cliente no ven la misma colisión/triggers, se rechaza la conexión. Un desync de colisión entre cliente y server es la fuente clásica de "falsos speedhacks" y de bugs de "me atasqué"; el hash lo hace imposible.
  • world-data valida el bundle al cargar (toda TileExit apunta a mapa existente, todo spawn referencia NPC existente, etc.) y se niega a arrancar con un bundle roto. En dev: hot-reload por archivo.

El diseño interno del conversor es de otra sección; acá queda fijado el contrato: bundle versionado + checksums compartidos + validación al arranque (pregunta abierta Q3 al final).


3. Modelo de tick y tiempo

3.1 La regla que protege el feel: intervalos en milisegundos, nunca en ticks

Ningún intervalo sagrado de AO es múltiplo limpio de ningún tick razonable — ni siquiera de la unidad nominal de 40 ms del propio AO20 (210/40 = 5,25). AO20 ya valida cooldowns contra reloj, no contra tick (research/01). Hacemos lo mismo:

Evento Intervalo AO (ms) En ticks de 50 ms Cómo se aplica
Caminar (por tile) 210 4,2 Timestamp ms por entidad (next_walk_at)
Golpe melee 1.165 23,3 Timestamp ms
Lanzar hechizo 1.230 24,6 Timestamp ms
Combo golpe-hechizo 800 16,0 Timestamp ms
AI de NPC 150 3,0 Cada 3 ticks exactos
Regen/hambre/sed 1.000 / 60.000 20 / 1.200 Timers programados

Si cuantizáramos el caminar al tick, saldría 200 o 250 ms: un error de ±5–19 % en la cadencia más sagrada del juego ("el laneo en grilla" — research/05). Por eso: el tick es la frecuencia de procesamiento; las reglas se validan contra reloj monotónico en ms. El reloj lo pone siempre el servidor; los timestamps del cliente jamás se usan para reglas (§7.1).

3.2 Tick elegido: 20 Hz (50 ms), constante configurable

Justificación:

  • La cadencia perceptible más rápida del juego es caminar: 4,76 pasos/s. 20 Hz la muestrea >4×; la latencia extra máxima que agrega el batching por tick (≤50 ms) es imperceptible en un juego por casillas con interpolación de 210 ms por tile (research/03: 10–20 Hz sobra para MMO por casillas; WoW arrancó a 4 Hz).
  • Deja 50 ms de presupuesto de cómputo por tick. Nuestra simulación (lógica de grilla 2D) va a consumir milisegundos: el margen es enorme y medible (métrica tick_duration, alerta al 60 % del presupuesto — §13).
  • Si alguna vez se quisiera igualar el 25 Hz nominal de AO20, es cambiar una constante: nada del diseño depende del valor.

3.3 Anatomía del tick de un zone worker

  1. Drenar canal de entrada (comandos ya validados por net, eventos de otros workers, órdenes de admin).
  2. Aplicar comandos contra sim-core (cada uno chequea su intervalo/reglas con ahora_ms).
  3. Sub-sistemas por cadencia: AI de NPCs (cada 3 ticks, repartida en cohortes para aplanar carga), proyectiles/efectos (cada tick), regen (1 s), hambre/sed/clima (60 s), respawns (curva por canal — §5.3).
  4. Recolectar eventos de salida → interest management (§5.1) → encolar a net.
  5. Marcar entidades dirty → encolar snapshots a persist según política (§8.1).
  6. Dormir hasta el próximo tick (deadline absoluto; si un tick se pasa, se loguea y el siguiente se acorta — jamás "espiral de muerte": si 3 ticks seguidos exceden presupuesto, se activa shed de carga: primero AI de mapas vacíos, después broadcast de clima/decorativo).

3.4 Redes y tiempo del cliente

  • net acumula todos los eventos de una conexión durante el tick y los despacha en un solo frame WS binario (amortiza overhead de frame y syscalls).
  • Cliente y servidor sincronizan reloj con TimeSync cada 10 s (RTT + offset EMA): habilita barras de cooldown exactas y la interpolación del cliente. La sincronización es informativa; nunca autoritativa.

4. Protocolo binario versionado sobre WebSocket

4.1 Transporte

  • WSS (WebSocket sobre TLS, puerto 443): >99 % de soporte, atraviesa cualquier red corporativa/móvil, es lo que usa todo juego browser de producción (research/03). TCP ordenado y confiable: correcto para un juego por casillas donde cada evento importa (no hay "snapshots descartables" estilo shooter).
  • WebTransport queda como mejora progresiva futura (baseline recién marzo 2026): la capa de paquetes (§4.3) se diseña independiente del framing, así el día que convenga, los eventos de movimiento pueden viajar por datagramas sin tocar el esquema. No es decisión de hoy.
  • El protocolo es event-driven, no snapshot-driven, fiel al modelo AO (research/01: ~204 tipos S→C / ~313 C→S): cada acción produce paquetes a los interesados. Es el modelo natural de un mundo por casillas determinista y el que menos ancho de banda gasta en un mundo mayormente quieto.

4.2 Handshake y versionado

Primer mensaje del cliente (nunca datos en la URL — los query strings terminan en logs):

C→S Hello { protocol_version: u16, world_hash: [u8;32], client_build: u32 }
S→C HelloOk { server_time_ms, motd_id } | HelloReject { reason: VERSION_MISMATCH | WORLD_MISMATCH | ... }
  • protocol_version se bumpea en cada cambio incompatible. El servidor nunca soporta más de una versión: ante mismatch responde HelloReject(VERSION_MISMATCH) y el cliente (PWA) fuerza refresh y reintenta. El navegador se actualiza solo: esta es una ventaja estructural que ningún cliente descargable tiene (P10) — la explotamos en lugar de cargar con compatibilidad multi-versión.
  • world_hash ata cliente y servidor al mismo bundle de mundo (§2).
  • Después del Hello: Auth { ticket } (ticket de un solo uso emitido por el login HTTP — §9.3) o Resume { token } (§6.3).

4.3 Esquema de paquetes: codegen propio (elegido) — fundamentos

Requisitos reales: dos lenguajes (Rust y TypeScript); paquetes chicos dominados por campos fijos (un Walk son ~6 bytes); versión clavada por handshake (no necesitamos evolución de esquema en caliente); input hostil (el decoder es superficie de ataque); presión de GC mínima en el cliente.

Opción Veredicto Por qué
bincode / postcard / bitcode Los más densos del ecosistema Rust, pero serde-céntricos: no existe decoder TS — habría que espejarlo a mano y el drift entre ambos lados es el bug clásico.
FlatBuffers ✗ (fallback) Codegen Rust+TS ✓, pero el overhead de vtables/offsets puede duplicar un paquete de 6–15 bytes; su gran feature (evolución de esquema con compatibilidad) resuelve un problema que el handshake estricto ya eliminó; la API TS generada aloca objetos por lectura.
Protobuf Cross-lang maduro, pero paga 1 byte de tag por campo, semántica "todo opcional" que invita a mensajes parciales, y churn de allocations en decode.
Codegen propio: packets.toml → structs Rust (read/write sobre bytes) + clases TS (sobre DataView) ✓ ELEGIDO Fuente única de verdad; bytes exactos (layout fijo little-endian, varint solo donde suma); cero allocations extra; tests dorados bidireccionales generados junto con el código; fuzzing del decoder Rust con cargo-fuzz. Es exactamente el modelo de AO20 (writer/reader binario generados; su generador es privado — el nuestro es nuestro) y está probado que TS puede hablar un binario custom de AO: LambdaClass lo hizo con el protocolo AO20 real (research/01).

Detalles del esquema:

  • IDs de paquete: varint u16, registro con IDs estables en packets.toml; un ID retirado jamás se reusa.
  • Tipos primitivos: u8/u16/u32 LE, varint, str8/str16 (largo + UTF-8, con tope por campo), arrays con largo topeado. Todo campo de largo variable declara tope en el esquema y el decoder lo aplica (anti-DoS).
  • Coordenadas: mapa u16, x/y u8 (mapas 100×100), heading u8. IDs de entidad: varint (índice por mapa, <16k).
  • El frame WS es la concatenación de paquetes del tick; no hay prefijo de largo por paquete (el layout es fijo por versión) — bytes que no se gastan en 500 CCU × 20 Hz.
  • Alcance MVP: subconjunto de 100–150 tipos de los ~200/300 de AO20 (la matriz feature→paquete la define la sección de contenido — pregunta abierta Q3).

4.4 Compresión

  • permessage-deflate deshabilitado: CPU por frame en miles de frames chicos por segundo, ventana de memoria por conexión, y beneficio casi nulo en paquetes de <20 bytes.
  • Compresión explícita solo S→C y solo para frames grandes: prefijo de 1 byte (0 = raw, 1 = deflate-raw). Se comprime si el frame supera 512 bytes: sync inicial de área (~2–10 KB), inventario completo, lista de mercado. Cliente: DecompressionStream('deflate-raw') nativo del navegador; servidor: flate2.
  • C→S jamás comprimido (paquetes diminutos + descomprimir input hostil es un vector de DoS).

4.5 Keep-alive y detección de muerte del socket

  • Ping WS protocolar del servidor cada 20 s; 2 pongs perdidos → socket muerto → arranca la máquina de estados de desconexión (§6.2).
  • TimeSync de aplicación cada 10 s (cliente→servidor) hace de heartbeat fino y mide RTT (métrica por conexión, visible para el jugador — la transparencia de "es tu red, no el server" ahorra tickets).
  • Presupuesto: keep-alives ≈ 4 bytes/s por conexión. Irrelevante.

4.6 Reglas transversales del protocolo

  • El servidor nunca manda prosa de juego: manda IDs de mensaje + parámetros (MsgId::NoTenesMana, {spell_id}) y el cliente traduce (ES/EN/RU desde el día 1). Excepciones: chat de jugadores y broadcasts de GM (texto plano UTF-8 topeado y sanitizado). Esto congela i18n en el protocolo y evita re-deploys de servidor por textos.
  • Números de secuencia C→S (client_seq varint) en comandos con efectos de inventario/economía: el servidor guarda el último procesado por sesión y descarta duplicados → exactamente-una-vez a través de reconexiones (el retry tras resume es el vector clásico de dupes; acá muere — P8).
  • Presupuesto de ancho de banda (dimensionamiento, no promesa): evento de movimiento ≈ 6 bytes; plaza llena con 50 personajes caminando sin parar ≈ 50 × 4,76 × 6 ≈ 1,4 KB/s + combate/chat → presupuesto de diseño 4 KB/s promedio, 32 KB/s p99 (arena/evento) por cliente. 500 CCU ≈ 2 MB/s egress ≈ 5 TB/mes (§17).

5. Interés por áreas, canales e instancias

5.1 Interest management: grilla espacial (el ModAreas de AO, modernizado)

AO20 ya funciona por áreas (research/01); Mirror documenta que la grilla espacial es ~30× más barata que chequear distancias por par (research/03). Diseño:

  • Cada mapa 100×100 se divide en celdas de 12×12 tiles. Cada entidad pertenece a una celda; cada conexión está suscripta al bloque 3×3 de celdas alrededor de su personaje (36×36 tiles: cubre el viewport isométrico con zoom máximo, con margen para la rotación de cámara de 90°).
  • Al cruzar un borde de celda: se emiten EntityEnter (estado completo de las entidades nuevas del anillo entrante) y EntityLeave (IDs del anillo saliente). Dentro de la suscripción, solo deltas/eventos.
  • Costo: O(entidades del bloque) por evento, sin pares cuadráticos. Un evento de área se serializa una sola vez y se encola a los N suscriptores (broadcast por referencia contada).
  • Casos especiales heredados: invisibilidad/ocultarse filtra por suscriptor (el paquete de un oculto solo va a quienes lo pueden ver — el cliente de un rival JAMÁS recibe la posición de un oculto: el wallhack se previene en el server, no se "oculta" en el cliente); "bajo techo" filtra lluvia; minimapa recibe una proyección de baja frecuencia (1 Hz).

5.2 Canales de zonas de caza (la respuesta al "dominando")

El pedido nº1 de la comunidad de Tibia son instancias/canales para spots, y la cultura de "dominando" (clanes privatizando spawns y extorsionando) es su top thread con 460 pts (research/06). AO tiene la misma dinámica en menor escala. Infraestructura nativa:

  • Un mapa marcado zona_de_caza en el bundle puede existir en N canales (réplicas runtime del mismo mapa, cada una con sus NPCs y su loot). Mapa 34 [Canal 2] visible en el cliente.
  • Auto-escala: se abre el canal N+1 cuando la ocupación del canal más lleno supera un umbral sostenido (config por mapa, p. ej. >12 cazadores durante 5 min); se drena y cierra cuando baja (aviso de 10 min a los presentes, luego merge suave al canal 1 conservando posición).
  • Reglas anti-abuso (el canal no puede ser herramienta de escape ni de evasión de PvP): cambiar de canal requiere estar fuera de combate (mismo timer que el linger de §6), tiene casteo visible de 5 s y cooldown de 60 s; el estado criminal/alineación viaja con el personaje; los canales comparten economía y respawn budget global (no se multiplica el oro del mundo por canal — el loot total del mapa se reparte entre canales, ver §5.3).
  • Jamás se canaliza: ciudades, mapas sociales, mapas de "agite" (hotspots PvP declarados en el bundle) ni el dungeon/arena del MVP. La densidad es el producto (Outlands — research/06); los canales existen solo donde la escasez de spawn expulsa gente del juego.
  • La party entra junta: al cruzar el TileExit hacia un mapa canalizado, el grupo cae al mismo canal (afinidad por party > balanceo).

5.3 Spawns que escalan

Innegociable del proyecto. La infraestructura (la curva exacta es de la sección de diseño — Q1):

  • Cada área de spawn del bundle define población base y timer de respawn base. El zone worker modula el timer con la cantidad de cazadores presentes en el área (curva con tope, p. ej. hasta 2,5× la tasa base), por canal.
  • Presupuesto global anti-inflación: la suma de spawns de todos los canales de un mapa respeta un cap de "riqueza generada por hora" del mapa; los canales reparten ese presupuesto. La economía no se multiplica por abrir canales; solo se reparte la cola.
  • Métricas por área: cazadores, kills/min, tiempo-medio-hasta-spawn (la métrica de la queja "estoy esperando para jugar" — se le pone alerta, §13).

5.4 Instancias (dungeon del MVP, arena PvP)

  • Una instancia = mapa efímero con dueño (party o matchmaker), TTL, y reglas propias (sin drop en arena, etc.), corriendo en el instance worker. Mismo código de zona; distinto ciclo de vida.
  • Arena PvP: matchmaker global (cola por modo; el diseño de modos es de otra sección), resultados persistidos (§8.2 arena_matches), espectador vía suscripción de solo-lectura al área (base del "espectador clickeable por browser" — research/04 §c9).
  • Límite de instancias concurrentes por tipo (config) + backpressure con cola visible ("tu dungeon abre en ~2 min") para que un evento no funda el worker.

6. Reconexión con gracia (feature de primera clase)

La queja más votada de todo el cluster móvil (P1). Objetivo de diseño en una frase: la muerte jamás puede ser causada por la desconexión, y la desconexión jamás puede ser un arma. Esos dos filos definen todo; Tibia resolvió el segundo e ignoró el primero — nosotros resolvemos ambos.

6.1 Máquina de estados

Diagrama mermaid — se muestra como código fuente
stateDiagram-v2
    [*] --> Conectado
    Conectado --> Linger: socket cae con combate reciente (<15 s)
    Conectado --> LogoutSeguro: socket cae sin combate reciente
    Linger --> Conectado: Resume OK (token)
    Linger --> Muerto: lo matan durante el linger (cuenta normal)
    Linger --> LogoutSeguro: sobrevive T_linger
    LogoutSeguro --> Conectado: Resume OK
    LogoutSeguro --> Persistido: pasa T_safe (10 s) → despawn + save
    Persistido --> Conectado: login normal, spawn en el mismo tile
  • Combate reciente = atacó o fue atacado (jugador o NPC) en los últimos 15 s (T_combat, mismo timer que bloquea el cambio de canal — una sola definición de "en combate" en todo el juego).
  • Linger (en combate): el personaje queda en el mundo, atacable con sus stats normales (ni invulnerable ni bonificado: cualquier bonus convierte el cable-pull en herramienta), deja de atacar y castear (nadie puede "pelear lageado por las dudas"), y mantiene su defensa/evasión normales. Duración T_linger: 45 s si el combate reciente fue PvP, 20 s si fue solo PvE (valores propuestos — ver D5). Si muere durante el linger, la muerte vale con todas sus consecuencias: esto cierra el exploit de desenchufar para escapar (la regla de Tibia, que es correcta en este filo). Si sobrevive, pasa a logout seguro.
  • Logout seguro (sin combate): 10 s parado (T_safe) y despawn con save. Cubre el caso masivo real del cluster móvil: se te cae el 4G caminando o cazando entre packs → no te pasa nada.
  • Caída del servidor: por construcción no mata a nadie — si el proceso muere, TODA la simulación se detiene; al volver, el mundo carga del último save (§8.1) y nadie estuvo expuesto mientras tanto. El rollback máximo de estado no-económico es ≤30 s y la política es pública (los rollbacks silenciosos de Tibia son leyenda negra).

6.2 Contra el abuso (diseñado contra el catálogo Tibia)

Intento de abuso Respuesta del diseño
Desenchufar para escapar de un PK El personaje queda 45 s atacable sin defenderse mejor que antes. Escapar así es peor que correr.
"DC falso" para ganar invulnerabilidad No existe estado invulnerable. Nunca.
Reconectar-desconectar en loop para confundir Resume tiene rate-limit (3/min) y el linger no se resetea por resume fallido.
Fingir lag para que no te sancionen por macro El estado de conexión no altera la detección estadística (§7.4); los inputs llegaron o no llegaron.
Robo de sesión para "resucitar" al personaje de otro Token de resume rotativo de un solo uso, ligado a cuenta+personaje, hasheado en server; un login completo desde otro dispositivo invalida el token y notifica (§9.3).
Abusar del logout seguro para farmear sin riesgo (alt-F4 al ver un PK en pantalla) El logout seguro exige 15 s sin combate y el despawn tarda 10 s más: un PK que llegó a verte casi siempre llega a taggearte. Es la misma ventana de riesgo que el logout voluntario.

6.3 Protocolo de resume

  1. Al autenticar, el server emite resume_token (128 bits aleatorios; se guarda hasheado). El cliente lo retiene en memoria + localStorage.
  2. Cae el socket → cliente entra en auto-reconexión: backoff 0,25 s → 0,5 → 1 → 2 → … (tope 5 s), contra el mismo endpoint (o el siguiente de la lista de endpoints del handshake).
  3. Hello + Resume { token } → el server re-ata el socket a la sesión viva, rota el token, responde ResumeOk { estado_dc: {fase, restante_ms}, snapshot_de_área } y el jugador sigue donde estaba, sub-segundo en el caso típico.
  4. Si el personaje ya fue persistido (pasó todo el timer): ResumeExpired → login normal → spawn en el mismo tile.
  5. Si murió durante el linger: ResumeOk + pantalla de muerte con relato de lo ocurrido offline (quién te pegó, cuándo, con qué — sale del log de eventos §7.5). La transparencia convierte la peor experiencia del juego en una historia contable en el grupo de WhatsApp, no en un ticket de soporte.
  6. Timers visibles: el cliente muestra "Reconectando — tu personaje sigue en el mundo (~38 s)" usando la última sincronización de reloj + las constantes públicas del sistema; al reconectar, ResumeOk trae el valor exacto. Las constantes (T_combat, T_linger, T_safe) son públicas en la wiki y en la API de status: reglas conocidas = confianza.
  7. Un login completo (no resume) con la sesión viva en otro socket: se corta el socket viejo con SessionTakenOver, se notifica por el canal nuevo. Única sesión de juego simultánea por personaje; hasta 1 personaje conectado por cuenta (los 3 slots son alternos, no multibox — antimacro y cultura de trabajadores se resuelve en diseño de juego, no acá).

⚠️ DECISIÓN PENDIENTE DE PABLO (D5): números finales del sistema de gracia y visibilidad del estado. Propuesta: T_combat=15 s, T_linger=45 s PvP / 20 s PvE, T_safe=10 s, sin marcador visual sobre el personaje desconectado (un ícono "zZz" es información gratis para el PK y bait para mind-games; la alternativa con ícono es más "honesta" pero cambia el meta). Opciones: (a) propuesta tal cual, (b) con ícono visible tras 5 s, (c) linger PvP más corto (30 s). Coordina con el especialista de diseño de combate (Q1).


7. Anti-cheat: el cliente jamás decide

Contexto honesto: en browser no existe anti-cheat de cliente (no hay EAC posible, el código es legible, cualquiera puede hablar el protocolo con un script — research/01 muestra que hasta AO20 con EasyAntiCheat sufre "CARCEL POR CHEAT... Y NO BAN"). Por eso el 100 % de la seguridad es server-side: validación total + límites de tasa + estadística + economía auditable + humanos con herramientas. El objetivo no es "imposible hacer trampa" (nadie lo logra); es "la trampa no rinde, se detecta rápido y la sanción es pública y justa" (P2, P3).

7.1 Validación server-authoritative total

El cliente manda intenciones; el servidor las valida contra el mismo dato que renderiza el cliente (bundle compartido, §2):

Dominio Validaciones (todas server-side)
Movimiento Tile destino dentro del mapa; bitmask direccional de Bloqueados del .csm; agua solo con barca (rango GrhIndex); TileExits aplicados por el server; intervalo 210 ms contra reloj del server; sin diagonales; una casilla por comando.
Combate Cooldowns 1.165/1.230/800 ms; rango y línea de visión por Bresenham en la grilla; target válido y visible PARA EL SERVER (ocultos: §5.1); flags de mapa (Seguro, SinMagia…); triggers de tile; estados (paralizado no camina, etc.); fórmulas de Balance.dat solo en sim-core.
Inventario/economía Atomicidad total (§8); peso/slots; requisitos de clase/raza/nivel del obj.dat; oro nunca negativo; topes de stack; máquina de estados estricta del trade seguro (ofertar→confirmar→ejecutar, cualquier cambio resetea confirmaciones — el scam clásico de AO muere acá); mercado por escrow (§8.3).
Social Longitudes topeadas, rate de chat, targets de whisper existentes, permisos de clan verificados por rol.

Presupuesto de confianza del input del cliente: cero. Los timestamps del cliente no existen para las reglas. Tolerancia de jitter legítimo: el server acepta 1 comando de movimiento encolado (el cliente puede mandar el paso siguiente hasta 100 ms antes de que venza el intervalo; se aplica exactamente al vencer). Esto hace que caminar con 200 ms de RTT se sienta continuo sin regalarle ni un milisegundo al speedhack: la cadencia ejecutada nunca baja de 210 ms por tile.

7.2 Rate-limit por tipo de paquete (heredando PacketRatePolicy de AO20)

AO20 trae rate-limit por paquete de serie (research/01). Nuestro net aplica, antes de decodificar el payload completo, una política por tipo con token-bucket:

Paquete (ejemplos) Tasa sostenida Burst Al exceder
Walk 6/s 8 Descartar en silencio + contador
Attack 1 por 1.100 ms 2 Descartar + contador
CastSpell 1 por 1.150 ms 2 Descartar + contador
UseItem / Drop / PickUp 3/s 5 Descartar + contador
Talk 2/s, 512 B 4 Descartar + mute progresivo
Whisper 5 por 10 s 8 Descartar + contador
TradeOp / BankOp / MarketOp 5/s 8 Descartar + contador
TimeSync 1 por 5 s 2 Descartar
Resume 3/min 3 Cerrar socket
Cualquier paquete malformado Cerrar socket + flag (un cliente legítimo jamás manda bytes inválidos)
  • Los buckets son levemente más permisivos que el intervalo de la regla (la regla de juego valida después, exacto): el rate-limit es la primera muralla anti-flood, no la regla.
  • Contador de violaciones por sesión con decay: umbral A → warning interno; umbral B → kick; N kicks/24 h → tempban corto automático + flag a revisión. Los valores viven en config caliente (§11) para tunear sin deploy.
  • Presupuesto global por IP (conexiones nuevas/min, bytes/s pre-auth) en el edge y en net (§15).

7.3 Consistencia de sesión

  • Un socket = una cuenta = un personaje en juego.
  • client_seq monótono; retrocesos = flag.
  • Tamaño máximo de frame C→S: 2 KB (nada legítimo lo supera); pre-auth: 256 B.

7.4 Detección estadística de macros y bots

Los rates y la validación paran el cheat grosero; el bot "educado" que juega a velocidad legal se caza con estadística sobre los logs de input (todo server-side; nada del cliente):

  • Regularidad inhumana: coeficiente de variación de los intervalos entre acciones del mismo tipo (humano: CV alto y con deriva por fatiga; macro: CV cercano a 0 sostenido). Ventanas de 10 min, umbrales por acción.
  • Reacción imposible: tiempo respuesta a estímulos no anunciados (te paralizan → tomás poción): consistentemente <100 ms = flag.
  • Patrones de ruta: secuencias de tiles repetidas k veces exactas (el server ve el path completo); loops de farmeo idénticos por horas.
  • Curva de sesión: sesiones >16 h sin degradación de precisión/tempo; actividad 24/7 con microcortes regulares.
  • Grafo económico: cuentas nuevas que bombean riqueza a una cuenta colectora (anti-RMT y anti-granja, sobre el ledger §8.3).
  • Salida: score compuesto → cola de revisión con evidencia adjunta (gráficos + replay §7.5). Jamás autoban estadístico: los bans kafkianos son odiados (Tibia) y las oleadas de OSRS se celebran porque son certeras (research/06). Autoban inmediato SOLO por firmas deterministas: paquete malformado, imposibilidad física (teleport sin TileExit, intervalo ejecutado <210 ms — que por construcción no puede pasar, así que su presencia = bug o ataque al proceso).
  • Fricción progresiva para sospechosos en vez de captcha in-game (odiado): límites de transferencia de riqueza, prioridad de revisión, susurro de GM de verificación en casos altos.

7.5 Logs de auditoría y replay de sesiones sospechosas

  • Ring buffer en memoria por sesión: últimos 15 min de comandos validados C→S + eventos relevantes S→C (≈5 eventos/s × ~10 B = trivial). Se descarta al cerrar sesión limpia… salvo flag, reporte de jugador, o muerte con implicancia PvP: entonces se vuelca a almacenamiento frío (comprimido, ~50–100 KB por volcado).
  • Visor de replay (en tools/ + panel admin): reproduce la sesión sobre el mapa tick a tick — qué vio, qué mandó, qué le pasó. Como sim-core es determinista y puro, un replay de inputs re-simulado reproduce el estado exactamente: la herramienta definitiva para dirimir "fue macro / no fue macro" y para post-mortems de bugs de combate.
  • Todo evento económico va al ledger permanente (§8.3) independientemente del ring buffer.
  • Retención de volcados de replay: propuesta 90 días flags / 30 días reportes ordinarios, luego borrado automático. ⚠️ DECISIÓN PENDIENTE DE PABLO (D8): política de retención y privacidad de logs/replays (30/90/180 días; qué se le muestra al jugador sancionado — propuesta: su propio replay siempre, el del rival jamás; y qué dice la política de privacidad pública).

8. Persistencia Postgres

8.1 Frontera RAM ↔ DB (la regla de oro anti-dupe y anti-rollback)

Clase de estado Escritura Racional
Posición, HP/maná, XP, skills, buffs Write-behind: cada 30 s si dirty + inmediato en eventos clave (level up, muerte, cambio de mapa, logout/despawn) Perder ≤30 s de esto en un crash es aceptable y público (§6.1).
Todo movimiento de valor multi-parte: trade seguro, mercado, banco, drop/pickup de ítem valioso, tesorería de clan, cofres Síncrono y transaccional: la operación no se confirma al jugador hasta que la transacción SQL commiteó (estado de ambas partes + filas de ledger en UNA transacción) Un crash jamás puede dejar un ítem duplicado o desaparecido a medias. El dupe de Ravendawn fue portada (P8).
Configuración runtime, sanciones, votos, cuentas Síncrono (es tráfico admin, no de juego) Bajo volumen, integridad ante todo.
  • Toda escritura de personaje pasa por un actor de persistencia con cola serializada por personaje (orden garantizado, sin races entre snapshot y trade). Los commits económicos usan la misma cola de las dos partes con un two-phase simple interno (lock lógico de ambos personajes durante el commit — en RAM el trade ya validó todo; la DB solo puede fallar por caída, y entonces el trade entero no ocurrió: consistente).
  • Volumen a 500 CCU: ~17 snapshots/s + economía → decenas de escrituras/s. Postgres en NVMe ni se despeina; el diseño escala 20× antes de pensar en nada exótico.
  • Latencia de trade percibida: un commit local son 1–5 ms; el jugador ve "Comercio concluido" en el mismo tick o el siguiente.

8.2 Esquema (tablas núcleo)

Convenciones: bigint generado para PKs internas; nombres de jugador en citext; timestamptz siempre; soft-delete solo donde se indica; particiones mensuales en tablas de flujo. Sketch de columnas clave (el DDL final vive en crates/persist/migrations/):

-- Identidad
accounts(id, email citext UNIQUE NULL,           -- NULL mientras es invitado
         password_hash text NULL,                -- argon2id; NULL si solo OAuth
         oauth_provider text NULL, oauth_subject text NULL,
         status enum(guest,active,locked,banned),
         totp_secret bytea NULL, locale char(2), created_at, last_login_at,
         UNIQUE(oauth_provider, oauth_subject))
account_sessions(id, account_id, token_hash bytea UNIQUE, kind enum(web,game),
         created_at, expires_at, ip inet, user_agent, revoked_at NULL)
devices(id, account_id, device_key_hash, first_seen, last_seen)   -- invitado→claim

-- Personajes (3 slots gratis: innegociable)
characters(id, account_id, slot smallint CHECK (slot BETWEEN 1 AND 3),
         name citext UNIQUE, race, class, level, exp bigint,
         map smallint, x smallint, y smallint, heading,
         hp, mana, stamina, hunger, thirst, gold bigint CHECK (gold >= 0),
         alignment, faction, flags jsonb, deleted_at NULL,      -- nombre se libera tras cuarentena
         UNIQUE(account_id, slot) WHERE deleted_at IS NULL)
character_skills(character_id, skill_id, value, exp, PRIMARY KEY(character_id, skill_id))
character_spells(character_id, spell_id, slot)
quest_progress(character_id, quest_id, state jsonb, updated_at)
achievements(character_id, achievement_id, earned_at)

-- Ítems: contenedor genérico + instancias para lo valioso
containers(id, kind enum(inventory,bank,trade_escrow,market_escrow,clan_vault,corpse,mail),
         owner_kind enum(character,account,clan,system), owner_id)
container_slots(container_id, slot smallint, template_id int,     -- obj.dat id
         quantity int CHECK (quantity > 0), instance_id NULL,
         PRIMARY KEY(container_id, slot))
item_instances(id uuid, template_id, crafted_by NULL, props jsonb, created_at)
         -- solo ítems únicos/valiosos llevan instancia; commodities van por template+qty.
         -- Un instance_id vive en UN solo slot: índice UNIQUE parcial sobre
         -- container_slots(instance_id) → el dupe de instancia es IMPOSIBLE a nivel DB.

-- Ledger de doble entrada (anti-dupe, auditoría, forensics)
ledger_tx(id, kind enum(trade,market_sale,bank_move,drop,pickup,npc_buy,npc_sell,
         craft,loot,quest_reward,gm_grant,fee,destroy,...),
         actor_character NULL, counterparty NULL, created_at, meta jsonb)
ledger_entries(tx_id, seq, container_from NULL, container_to NULL,
         template_id, quantity, instance_id NULL, gold_delta bigint)
         -- invariante por tx: lo que sale = lo que entra (o va a/viene de un
         -- contenedor 'system': spawns, sinks, fees). CHECK + job de reconciliación.
         -- Particionado mensual. Nunca UPDATE ni DELETE (grants lo impiden).

-- Mercado (con escrow — nunca "de mano a mano")
market_orders(id, seller_character, template_id, instance_id NULL, quantity,
         unit_price, escrow_container, status enum(open,filled,cancelled,expired),
         created_at, expires_at)
market_trades(id, order_id, buyer_character, quantity, unit_price,
         fee_gold, tx_id → ledger, created_at)

-- Social y gobernanza
clans(id, name citext UNIQUE, tag, leader_character, vault_container, created_at)
clan_members(clan_id, character_id UNIQUE, role, joined_at)
clan_relations(clan_a, clan_b, kind enum(war,alliance), since)
polls(id, title_msgid, body_msgid, options jsonb, opens_at, closes_at,
         threshold numeric DEFAULT 0.70, eligibility jsonb,   -- snapshot de reglas
         status, results jsonb NULL)                          -- resultados crudos públicos
poll_ballots(poll_id, account_id, option, cast_at, PRIMARY KEY(poll_id, account_id))

-- Moderación (P2: nada de esto se borra)
reports(id, reporter_character, target_character, category, text, created_at,
         status, dedup_key)
sanctions(id, case_code text UNIQUE,             -- "2026-0041", el ID público
         account_id, character_id NULL, rule_code,       -- código del reglamento público
         kind enum(warn,mute,jail,tempban,permban,rollback),
         public_reason_msgid, internal_notes, evidence_refs jsonb,
         issued_by_staff, issued_at, expires_at NULL,
         status enum(active,expired,overturned), overturned_by NULL, overturned_at NULL)
appeals(id, sanction_id, text, filed_at, sla_due_at, resolved_by NULL,
         resolution enum(upheld,reduced,overturned) NULL, resolved_at NULL, public_note)
gm_actions(id, staff_account, action, target_kind, target_id, args jsonb,
         performed_at)                            -- append-only, trigger prohíbe UPDATE/DELETE

-- Temporadas (alimentan al personaje permanente; jamás wipes desnudos)
seasons(id, name, ruleset jsonb, starts_at, ends_at, status)
season_characters(season_id, account_id, character_snapshot jsonb, points,
         transferred_at NULL)                     -- job transaccional de cierre → rewards
season_rewards(season_id, account_id, reward_kind, reward_ref, granted_tx NULL)

-- Operación
ccu_samples(sampled_at, online int, per_map jsonb)         -- alimenta el contador público
anticheat_flags(id, account_id, character_id, kind, score, evidence jsonb,
         created_at, reviewed_by NULL, outcome NULL)

Notas de diseño:

  • El dupe muere en tres capas: (1) operación atómica en RAM en un solo worker, (2) transacción SQL única para las dos partes + ledger, (3) índice UNIQUE de instance_id en slots + reconciliación nocturna (§8.4). Ravendawn no tenía ninguna de las tres.
  • Trade por escrow siempre: confirmar un trade mueve ambos lados a trade_escrow y de ahí cruzado a los inventarios, en una transacción. El mercado igual (orden publica → escrow; compra → cruce + fee al sink). No existe el "te lo tiro al piso y me pagás".
  • gold es columna del personaje (fiel a AO) pero cada delta pasa por ledger_entries.gold_delta: el balance es derivable y auditable; discrepancia = alarma.
  • ⚠️ DECISIÓN PENDIENTE DE PABLO (D6): banco fiel a AO (bóveda por personaje) vs bóveda por personaje + pestaña compartida de cuenta (QoL moderna; los 3 slots gratis son "cultura de trabajadores" y hoy se pasan ítems por trade entre alts con mula o amigo — la pestaña compartida elimina ese ritual riesgoso). Opciones: (a) fiel puro, (b) pestaña compartida desde el día 1, (c) fiel al lanzar y pestaña compartida a votación de la comunidad (70 %). Recomendación: (c) — coherente con la gobernanza.

8.3 El ledger como producto, no solo como tabla

  • Job nocturno de reconciliación: Σ oro creado (spawns/quests/NPC-sell) − Σ destruido (fees/sinks/NPC-buy) = Δ oro total de personajes+bancos+escrows; ídem por template de ítem valioso; toda divergencia = alerta P1 y freeze opcional del mercado (config).
  • Dashboards de economía en el panel admin (§11): creación/destrucción por fuente por día, top riqueza, velocidad del oro, grafo de transferencias de cuentas jóvenes (anti-RMT §7.4).
  • El ledger es también el respaldo de futuras decisiones de monetización por trading taxeado (modelo Tibia Coins/Char Bazaar — research/04): si algún día se vota, la infraestructura contable ya existe. No es alcance del MVP.

8.4 Migraciones

  • sqlx migrate: SQL plano versionado NNNN_descripcion.sql, checksums embebidos en el binario; el server verifica al arrancar que la DB está en la migración esperada y se niega a arrancar ante drift. Prior art: AO20 lleva 50 migraciones SQL (research/01).
  • Política: forward-only (sin down-migrations en prod); patrón expand→migrate→contract para cambios con el server vivo (post-MVP; en MVP hay ventana de mantenimiento con countdown in-game, §16).
  • CI: cada PR aplica todas las migraciones sobre una DB efímera + corre los tests de persist + un EXPLAIN automático de las queries calientes marca regresiones de plan.

8.5 Backups: PITR + prueba de restore automatizada

Un backup que nunca se restauró no existe. Esquema:

  • pgBackRest: full semanal + diferencial diario + WAL continuo (archive_timeout 60 s) → bucket S3-compatible en un proveedor DISTINTO al del VPS (sobrevivir al cierre de cuenta del proveedor es parte de la amenaza). Cifrado de backup (cipher AES-256 de pgBackRest). RPO ≤ 60 s; RTO objetivo < 30 min.
  • Dump lógico diario (pg_dump -Fc) como cinturón extra y para clonar staging.
  • Drill de restore automatizado, mensual y alarmado: job que levanta un contenedor limpio, restaura el último punto, y corre verify-world (de bin/tools): invariantes del ledger (Σ=0 por tx), sin instancias duplicadas, sin huérfanos de FK, conteos plausibles, carga de 100 personajes al azar por sim-core, y un bot que loguea contra un server efímero apuntado a esa DB. Si el drill no corrió o falló → alerta roja. El resultado del último drill es visible en el panel admin.
  • Retención: WAL para PITR de 14 días; diarios 30 días; semanales 6 meses. (Recomendación firme; se ajusta con el costo real de storage, ~centavos — §17.)
  • ⚠️ DECISIÓN PENDIENTE DE PABLO (D2): Postgres self-hosted en la misma máquina del juego (latencia local, $0 extra, pgBackRest ya diseñado; recomendado para MVP) vs managed (RDS/Cloud SQL/DO Managed: failover y PITR "de fábrica", +40–100 USD/mes, latencia de red, menos control). Recomendación: self-hosted en MVP con este diseño de backups; revisar managed o réplica streaming al crecer (§12.4).

9. Cuentas, sesiones y registro

9.1 Invitado → claim (entrada sin fricción, innegociable de research/05)

  1. Jugar como invitado al primer click: el cliente genera una device_key (aleatoria, en localStorage), POST /auth/guest crea accounts(status=guest) ligada al device → elige nombre y raza → está jugando. Sin email, sin captcha (con las defensas de §9.4).
  2. Claim: en cualquier momento (y empujado por UX en momentos de valor: primer level up importante, primer ítem valioso): asociar email (OTP de 6 dígitos por mail) o Google OAuth (PKCE). La cuenta pasa a active conservando TODO.
  3. Un invitado sin claim: puede jugar, no puede usar mercado, trade con otros jugadores ni sacar del banco hacia otros (cuarentena económica: mata la granja de cuentas descartables sin molestar al nostálgico que entró a probar — §7.4).
  4. ⚠️ DECISIÓN PENDIENTE DE PABLO (D10): expiración de invitados no reclamados. Propuesta: tras 30 días de inactividad se recicla el NOMBRE (no la cuenta; si vuelve con su device_key y el nombre sigue libre, lo recupera; si no, elige otro). Opciones: (a) 30 días, (b) 90 días, (c) nunca reciclar nombres de invitados. El nombre-squatting de la apertura ritual es un problema real (miles de "Legolas" el día 1).

9.2 Autenticación

  • Password: argon2id (parámetros memory-hard revisados por año), política de largo mínimo sin reglas arbitrarias, chequeo k-anonymity contra corpus de passwords filtradas (HIBP-style, offline).
  • OAuth: Google día 1 (es el ecosistema del público objetivo Android/TWA). ⚠️ DECISIÓN PENDIENTE DE PABLO (D9): ¿sumar Discord (el hábitat social del nicho AO — research/05) y/o Apple (obligatorio solo si algún día hay app iOS nativa) desde el día 1, o solo Google? Recomendación: Google + Discord.
  • TOTP 2FA opcional para jugadores (con códigos de recuperación de un solo uso), obligatorio para todo staff (§10). Verificación por OTP de email ante login desde dispositivo nuevo (activable por cuenta).
  • Emails transaccionales (OTP, claim, avisos de seguridad, apelaciones) vía proveedor transaccional con dominio dedicado; plantillas trilingües desde i18n.

9.3 Sesiones y tickets de juego

  • Sesión web/API: token opaco 256 bits, almacenado hasheado, expiración deslizante, revocable (tabla account_sessions); lista de sesiones activas visible para el usuario ("cerrar todas" incluida — higiene que OSRS enseñó a los grinders a exigir).
  • Conexión de juego: el login HTTP emite un ticket de un solo uso, TTL 30 s, que viaja en el primer mensaje WS (Auth{ticket}) — jamás en la URL. El WS obtiene entonces su resume_token (§6.3).
  • Cambio de password / logout-all / sanción → revoca sesiones y tickets y desconecta sockets vivos de la cuenta.

9.4 Anti-bot de registro (sin castigar cibers ni CGNAT)

Realidad LatAm: CGNAT masivo en móviles y la cultura AO de cibers/LAN (research/05) hacen que los límites duros por IP castiguen inocentes. Defensa en capas, cada una barata para humanos y cara para granjas:

Capa Regla Nota
Edge (§15) Rate por IP con burst generoso: guest 10/h, register 5/h por IP; ventanas cortas Suave a propósito: CGNAT
Proof-of-work invisible El cliente resuelve un challenge de ~300 ms de CPU al crear cuenta/invitado Invisible para humanos; multiplica el costo de una granja de miles
Email Verificación OTP para claim; blocklist de dominios descartables; sin email no hay economía (§9.1) El bot puede jugar, no puede monetizar
Device device_key + señales pasivas mínimas (sin fingerprinting invasivo) Correlación de granjas, no bloqueo
Escalada Solo ante ataque activo: Turnstile en el FORMULARIO WEB de registro (jamás captcha in-game) El captcha in-game está documentado como odiado
Nombres Normalización Unicode anti-confusables; bloqueo de prefijos de staff ("GM", "Admin", "Soporte") e impersonación También anti-scam

Rate-limits de auth: login 5 intentos/15 min por cuenta + curva por IP; reset de password 3/día; enumeración de emails imposible (respuestas idénticas).


10. GM, moderación y transparencia (diseñar contra la arbitrariedad)

La herida nº1 del AO oficial es el staff (P2). El principio de diseño: ninguna acción de poder puede ejecutarse sin dejar rastro, y el rastro relevante es público por defecto. No es una promesa de conducta: es que el sistema no ofrece el botón silencioso.

10.1 Roles y matriz de permisos

Capacidad Guía Moderador GM Admin Owner
Responder dudas, canal de ayuda
Mute / jail (con caso)
Teleport propio, espectar (logueado)
Tempban (con caso)
Permban, rollback dirigido
Spawn/give-item (broadcast a canal interno de auditoría, ledger gm_grant)
Config runtime, feature flags, rates de evento
Gestión de staff, borrar NADA ✔*

* Ni el Owner puede borrar gm_actions, sanctions ni ledger: los grants de Postgres no lo permiten y hay triggers que rechazan UPDATE/DELETE. La única vía de corrección es el overturn, que es en sí un registro nuevo y público.

Reglas duras adicionales, codificadas (no "políticas"):

  • Los personajes de staff no existen en la economía ni en el PvP: no tradean, no dropean, no entran al mercado, no pegan (fuera de eventos anunciados con flag de evento). Es un flag de cuenta que sim-core aplica.
  • Toda sanción exige case_code + rule_code de un reglamento público versionado (el reglamento lo redacta la sección de comunidad/gobernanza — Q1 no, esto es transversal; queda como dependencia declarada).
  • Espectar jugadores queda logueado en gm_actions (visible en auditoría interna; no público, para no filtrar investigaciones en curso).

10.2 Registro público de sanciones

Página web pública (y API), actualizada en tiempo real desde sanctions:

  • Por sanción: case_code, fecha, nombre del personaje, regla violada (código + texto del reglamento), tipo y duración, estado (activa/expirada/revocada), staff como rol+número ("GM-03", jamás el nombre real — la transparencia es sobre el poder, no un directorio para acosar staff), resultado de apelación si la hubo.
  • Las revocaciones se muestran, no se esconden: un overturn público duele menos que un rumor de corrupción.
  • Reporte mensual de transparencia autogenerado: sanciones por tipo, tiempos de apelación, tasa de overturn, bans de bots (celebrarlos funciona: OSRS — research/06).
  • ⚠️ DECISIÓN PENDIENTE DE PABLO (D7): parámetros de la política. (1) SLA de apelación: propuesta 72 h para primera respuesta humana (Tibia es "kafkiano sin apelación"; la diferencia se construye acá) — ¿48/72/96 h? (2) ¿El registro público muestra nombre de personaje siempre, o anonimiza sanciones menores (mute) y publica solo tempban+? (3) Regla de four-eyes: propuesta: permban SIEMPRE requiere segundo staff distinto del emisor; ¿extenderla a tempban >7 días? (4) Dotación mínima de staff para sostener el SLA (esto ata con presupuesto/operación).

10.3 Apelaciones

  • Botón "Apelar" en la pantalla de sanción y en la web (logueado): texto libre → appeals con sla_due_at calculado.
  • La apelación la resuelve un staff distinto del emisor (four-eyes; el sistema no asigna al emisor, no es costumbre: es constraint).
  • El apelante ve el estado y la resolución con nota pública; el replay propio (§7.5) se le puede mostrar como evidencia.
  • SLA vencido sin respuesta → escalado automático a Admin + contador visible en el panel (la métrica appeals_overdue tiene alerta — un SLA sin alarma es decorativo).

11. Panel admin web

Superficie: admin-api (axum, JSON) + frontend server-rendered con HTMX (mínima superficie de ataque, sin CORS, CSP estricta; un SPA acá no aporta nada). Acceso: subdominio propio detrás de Cloudflare Access (o allowlist), login de staff con 2FA obligatorio, RBAC de §10.1, toda llamada logueada en gm_actions.

Módulos:

Módulo Contenido
Vivo CCU ahora y por mapa/canal, tick p99 por worker, mapa de calor de población, últimos logins, estado de colas
Jugador Ficha: personajes, sesiones e IPs (correlación de cuentas), economía resumida (del ledger), sanciones e historial, flags anti-cheat, botón replay
Moderación Cola de reportes con dedup, workflow de caso (evidencia → acción → publicación automática al registro), apelaciones con SLA y semáforo
Anti-cheat Cola de flags por score, visor de replay, gráficos de intervalos, decisión con evidencia adjunta
Economía Dashboards del ledger (§8.3): creación/destrucción por fuente, sinks, top riqueza, alertas de reconciliación, freeze de mercado
Mundo Config runtime y feature flags (rates de evento, toggles de features, umbrales de canales), broadcast global (msgid o texto, trilingüe), control de instancias
Gobernanza Crear/gestionar polls (umbral 70 % por defecto), resultados crudos, publicación
Temporadas Alta de temporada (ruleset), estado, job de cierre y transferencia de recompensas
Operación Estado de backups y del último drill de restore (§8.5), migraciones aplicadas, versión de bundle de mundo, shutdown graceful con countdown

12. Escala y topología

12.1 Dimensionamiento del MVP (500 CCU objetivo, margen 3–5×)

Diseñamos para 2.500 CCU en una caja y declaramos éxito operativo a 500 (P5):

  • CPU: 500 jugadores ≈ 2.500 comandos/s + AI de ~3–5 k NPCs activos a 150 ms (≈25 k evaluaciones/s de máquinas de estado triviales) + serialización de broadcast. Es lógica de grilla 2D: presupuesto real estimado en fracciones de core; un 8 vCPU moderno queda mayormente ocioso a 2.500 CCU. La referencia dura: AO20 aguanta ~680 CCU en VB6 mono-hilo (research/05); Rust multi-hilo con este diseño tiene más de un orden de magnitud de margen sobre eso.
  • Red: presupuesto §4.6 → 500 CCU ≈ 2 MB/s (~16 Mbps) egress promedio, picos 5× en eventos. Trivial para cualquier puerto de 1 Gbps; ~5 TB/mes (cabe en las cuotas típicas; §17).
  • RAM: mundo <1 GB (§1.3) + Postgres (shared_buffers 4–8 GB) + buffers → caja de 32 GB holgada.
  • El cuello real no es cómputo: son los picos de login de la apertura (§16) y la DB en eventos económicos masivos — ambos con mitigación diseñada.

12.2 Megaservidor LatAm: región y proveedor

Un solo mundo denso por región; jamás fragmentar (P5, research/06 "piden #8"). Dónde ponerlo:

Región RTT AR RTT CL/UY RTT BR RTT MX RTT ES Mercado de hosting
Buenos Aires 2–10 ms 20–30 30–40 ~170 ~230 Más chico: metal vía Latitude.sh, EdgeUno, locales; sin hyperscalers baratos
São Paulo 30–40 ms 40–50 2–15 ~150 ~200 El hub LatAm: Vultr, AWS/GCP/Azure, Latitude.sh metal, mejor conectividad internacional
Miami (descartar) ~130 ~120 ~110 60–90 ~110 Enorme, pero castiga al núcleo AR/CL/UY

Con caminar a 210 ms/tile + predicción de movimiento en cliente, cualquier RTT <80 ms es indistinguible de local y <150 ms es cómodo. La diferencia BsAs vs SP es identitaria y de mercado más que técnica.

⚠️ DECISIÓN PENDIENTE DE PABLO (D1): región + proveedor del megaservidor. Opciones: (a) Buenos Aires (Latitude.sh metal o EdgeUno): el núcleo AR/UY con ping de LAN — coherente con "megaservidor LatAm de corazón argentino"; (b) São Paulo (Vultr High Performance o Latitude.sh metal): mejor equilibrio si Brasil entra en el mapa (el precedente Tibia BR es fuerte — research/04) y mercado de hosting más profundo. Recomendación: (a) si el día 1 es 90 % hispanohablante, con migración a SP posible (es mover un binario + restore de PG + DNS; la arquitectura no cambia). Nota: detrás de Cloudflare el origen es migrable sin cambiar IPs públicas.

12.3 Nodo EU (después): proxy de borde vs mundo separado

⚠️ DECISIÓN PENDIENTE DE PABLO (D4) — análisis:

(A) Proxy de borde en Madrid (B) Mundo EU separado (cuentas compartidas)
Qué es Terminación WSS en un VPS EU; backhaul TCP optimizado al sim LatAm Segundo proceso servidor con su propia DB de personajes/economía
Población Un solo mundo (la densidad se preserva) Se fragmenta (la enfermedad de la escena AO — research/05)
Ping EU RTT total sigue ~180–220 ms, pero con menos jitter/pérdida (TCP termina cerca; el trecho largo va por backbone estable). Jugable en juego por casillas; los veteranos EU de AO ya juegan así hoy Ping local <30 ms
Costo ~5–15 USD/mes (VPS chico Hetzner/OVH) Duplica infra + operación + divide el contador público
Riesgo La mejora percibida es real pero modesta Mundos muertos en horas valle EU; "¿en cuál juego con mis amigos?"
Recomendación Primero esto, medido (métrica de RTT/abandono por región ya existe — §13) Solo si (1) CCU EU sostenido supera un umbral y (2) la comunidad lo vota (70 %)

La lista de endpoints del handshake (§6.3) ya contempla múltiples puntos de entrada: agregar el proxy EU no toca el protocolo.

12.4 Camino a miles (sin re-arquitectura)

Orden de palancas, cada una gatillada por métrica y no por ansiedad:

  1. Vertical: de VPS 8 vCPU a metal 16–32 cores (la palanca más barata en $/CCU; cubre hasta varios miles con este diseño).
  2. Repartir zone workers entre más cores (ya diseñado, §1.2) e instancias a workers dedicados.
  3. Separar Postgres a su caja + réplica streaming (lecturas de API/panel a la réplica; el juego sigue escribiendo al primario).
  4. Separar login/api-admin del proceso de juego (son stateless, trivial).
  5. Recién si algún día un mapa-evento concentra a TODOS (torneo global): sharding de proceso por grupos de mapas con handoff inter-proceso — el mismo contrato de mensajes de §1.2, cruzando la red. No se construye hasta que la métrica lo pida.

13. Observabilidad y contador público

13.1 Métricas (Prometheus + Grafana)

Endpoint /metrics (privado). Núcleo del catálogo:

Métrica Tipo Alerta
tick_duration_seconds{worker} histogram p99 > 30 ms (60 % del presupuesto)
entities{worker,kind} / players_online{map,channel} gauge
net_packets_total{dir,type} / net_bytes_total{dir} counter Anomalía de tasa (ataque/bug)
ws_connections / resume_total{outcome} gauge/counter resume_fail > 5 % en 5 min
persist_queue_depth / persist_write_seconds gauge/histogram Cola > 1.000 o p99 > 500 ms
economy_gold_created_total{source} / destroyed_total{sink} counter Desvío >3σ vs baseline horaria
ledger_reconciliation_diff gauge ≠ 0 → P1
anticheat_violations_total{type} / flags_open counter/gauge Pico súbito
appeals_overdue gauge > 0 (el SLA con dientes — §10.3)
spawn_wait_seconds{map} histogram p95 > umbral ("esperar para jugar")
backup_last_success_age / restore_drill_age gauge > 26 h / > 35 días
logins_total / login_queue_depth counter/gauge Cola > capacidad (§16)
ccu_drop_ratio_5m gauge Caída >30 % en 5 min = detector de crash/red
  • Stack: Prometheus + Grafana self-hosted (o Grafana Cloud free al principio); tracing → JSON → Loki para logs estructurados con trace_id por comando (correlacionar "el trade 8812 tardó" de punta a punta); panics a Sentry (sentry-rust).
  • Telemetría de cliente (FPS, RTT, motivo de desconexión, plataforma) llega muestreada a un endpoint de ingesta propio → mismas dashboards: la queja "se me cortó" se responde con datos por región/ISP.

13.2 Contador público real (innegociable)

  • GET /api/v1/status (público, cache 30 s): { online, record, record_at, uptime, seasons_active, constants: {t_combat, t_linger_pvp, ...} }. Alimenta la web, el widget del launcher y el contador in-game. Sale de ccu_samples reales; no existe código para inflarlo — los claims inflados se auditan solos (P6).
  • Página de status/uptime pública en infraestructura SEPARADA (un VPS mínimo en otro proveedor con Gatus, o un SaaS de status): cuando el server está caído, la página que lo dice tiene que estar arriba.
  • Historia pública de CCU (gráfico 30 días) en la web: la apertura ritual y los eventos se ven; la transparencia es marketing.

14. Bots de carga y verificación continua

bin/bots: clientes headless que hablan el protocolo REAL vía el mismo codegen (§4.3) — no un mock. Perfiles de comportamiento scriptados: caminante aleatorio, cazador (busca área, mata, lootea, vende), chatero, comerciante (mercado), party que entra al dungeon, PvP en arena, y "nostálgico" (loguea, camina por la ciudad, se va).

Suite Qué corre Presupuesto de aprobación
PR smoke 50 bots, 3 min, en el runner de CI Sin errores; tick p99 < 30 ms
Nocturna 500 CCU Staging (docker compose: server+PG) + rampa 0→500 en 10 min + 30 min sostenidos Tick p99 < 30 ms; RSS sin pendiente (leak); 0 desyncs de posición; 0 violaciones de ledger; resume_fail = 0
Apertura (thundering herd) 2.000 logins en 60 s contra login+queue (§16) Cola justa FIFO, sin timeouts, sin caída de tick del mundo ya poblado
Chaos de reconexión Matar el 50 % de los sockets a la vez; todos resumen 100 % ResumeOk; ningún personaje muerto por el corte; ledger intacto
Red móvil (netem) Perfiles 150–300 ms RTT, 1–3 % loss, jitter Cadencia de caminata servida estable; sin kicks falsos por rate-limit
Soak de fin de semana 300 CCU × 24 h+ Sin leaks, sin drift de reloj, reconciliación nocturna en verde
Determinismo Replays dorados de sim-core (vectores de combate derivados de Balance.dat) Bit-exacto entre corridas y entre plataformas
Fuzzing cargo-fuzz sobre decoder de protocolo y parser de bundle Corpus creciente; 0 crashes

Los reportes de la nocturna (flamegraph + métricas + diffs) quedan como artefactos de CI. La nocturna en rojo bloquea el deploy siguiente: es el contrato de que "capacidad 3–5×" no es una frase.


15. Seguridad de infraestructura: DDoS, TLS, secrets

15.1 DDoS y exposición

  • Recomendado: todo detrás de Cloudflare — la web, la API y el WSS del juego (WebSocket se proxya en todos los planes; somos WSS/443 estándar, no hace falta Spectrum, que es para TCP/UDP crudo). CF tiene PoPs en Buenos Aires y São Paulo: la latencia agregada típica es de un dígito de ms.
  • Origen blindado: firewall que solo acepta 443 desde rangos de Cloudflare + Authenticated Origin Pulls; SSH por WireGuard/tailnet + llaves (jamás password); el puerto del juego NO existe público fuera de CF.
  • Edge: rate-limits en login/registro, reglas WAF para la API, Turnstile solo en formularios web (§9.4). El L7 del juego se defiende en net (§7.2) y el volumétrico lo absorbe CF.
  • Plan B documentado (runbook): si CF degrada el WSS (inspección/buffering anómalo), endpoint directo de emergencia en el proveedor con su mitigación nativa, conmutable por la lista de endpoints del handshake sin tocar cliente.
  • ⚠️ DECISIÓN PENDIENTE DE PABLO (D3): postura DDoS. Opciones: (a) Cloudflare proxy total (recomendada: gratis/Pro ~20 USD, oculta el origen, PoPs LatAm) — con la nota de ToS: el tráfico WS de juego es aceptable hoy para juegos no-video, revisar términos al contratar; (b) conexión directa + proveedor con mitigación incluida (Latitude/Vultr/OVH la incluyen básica) — menos capas, más IP expuesta; (c) híbrido: web/API tras CF, juego directo. La (a) simplifica también la migración de región (D1).

15.2 TLS

  • CF Full (strict) con certificado de origen, o Let's Encrypt + caddy/nginx si conexión directa. TLS 1.3, HSTS, OCSP stapling. El proceso Rust puede terminar TLS él mismo (rustls) si se elimina el reverse proxy local — decisión de implementación, no de plan.

15.3 Secrets

  • Repo: sops + age (secrets cifrados versionados; la llave age vive en el servidor y en el password manager de Pablo — nunca en el repo).
  • Runtime: systemd LoadCredential (no env vars en texto plano en unit files ni en ps).
  • Postgres: roles de mínimo privilegio (game_rw sin DDL ni DELETE en tablas append-only; admin_api con lo suyo; backup solo replication). Passwords rotables con runbook escrito.
  • CI: secrets de deploy con OIDC o deploy key de un solo uso; nada de tokens de larga vida en logs.
  • Higiene de dependencias: cargo-deny + cargo-audit en CI; lockfile commiteado; sin dependencias de git sin pin.

16. Deploys, mantenimiento y la apertura como evento ritual

16.1 Deploys

  • Binario único + bundle de mundo versionado; deploy = subir, migrar, reiniciar. En MVP: ventana de mantenimiento con countdown in-game (broadcast trilingüe automático 15/5/1 min, force-save de todos, cierre limpio) — la cultura AO entiende y hasta celebra el mantenimiento anunciado; lo que no perdona es el silencio (research/06, regla 6).
  • Shutdown graceful = nadie muere: al iniciar el cierre se detiene el combate nuevo, se fuerza logout seguro de todos (sin linger: es server-initiated), se persiste todo, recién entonces sale el proceso. El crash-kill está cubierto por §6.1/§8.1.
  • Config caliente (§11 Mundo) para todo lo tuneable sin deploy: rates de evento, umbrales de canales, límites de rate-limit, feature flags (heredando la idea de feature flags de AO20 — research/01).
  • Staging permanente (mismo compose que la nocturna de bots) donde TODO deploy pasa antes que prod.

16.2 La apertura (thundering herd por diseño)

La apertura de server es EL evento social del año en la escena AO: fecha y hora anunciadas, cuenta regresiva, picada y vicio (research/05). Eso es un thundering herd autoinfligido y se lo trata como tal:

  • Login queue justa (FIFO por llegada, sin colados) con posición y ETA visibles y actualizados por WS liviano; la cola aguanta aunque el mundo esté lleno. Cap de CCU configurable por encima del objetivo (margen 3–5× — P5); la cola solo aparece si se supera.
  • La página de countdown y el cliente estático viven en CDN (Cloudflare Pages/estáticos cacheados): el origen no sirve ni un byte estático ese día.
  • Registro y creación de personaje ABIERTOS DÍAS ANTES de la apertura (con reserva de nombre): parte del pico se adelanta y el nombre-squatting se maneja con la política D10.
  • Ensayo general obligatorio: la suite "Apertura" de §14 (2.000 logins/60 s) en verde la semana previa + un stress público anunciado ("stress test abierto, entrá a romperlo") que además es marketing.
  • Runbook de apertura: dashboards en pantalla, umbrales de shed de carga (§3.3), decisión pre-acordada de subir el cap o activar cola, y quién comunica qué si algo se degrada (el silencio es el único pecado imperdonable).

17. Costos de hosting estimados (USD/mes, por fase)

Aproximaciones a precios de lista jul-2026; verificar al contratar. Sin tiempos: las fases son estados de carga, no calendario.

Rubro Dev/Staging MVP (≤500 CCU, margen 3–5×) Crecimiento (2–5k CCU) Miles+ (5–10k)
Servidor de juego (+PG en MVP) VPS 4 vCPU/8 GB — 10–25 VPS 8 vCPU/32 GB SP/BsAs (Vultr HP / Latitude) — 100–200 Metal 16–32 cores — 250–400 Metal ×2 (juego + spare/split) — 500–800
Postgres (mismo VPS) — 0 (mismo host, self-hosted) — 0 Caja propia + réplica — 80–150 Caja + réplica + standby — 150–300
Backups PITR (B2/R2, otro proveedor) 1–3 3–10 10–25 25–50
Cloudflare Free — 0 Free o Pro — 0–20 Pro — 20 Pro/Business según ToS — 20–200
Nodo proxy EU (post-MVP, D4) VPS chico — 5–15 10–30
Observabilidad (Grafana Cloud free / self-host en staging) 0 0–20 20–50 50–100
Sentry / emails transaccionales / dominio+status externo 0–10 15–40 40–80 80–150
Ancho de banda extra (≈5 TB/mes en MVP suele estar incluido) 0 0–20 20–60 60–150
Total estimado ≈ 15–40 ≈ 120–310 ≈ 450–800 ≈ 900–1.800

Lecturas: (1) el MVP entero cuesta menos que un sueldo junior por AÑO — el costo del proyecto es tiempo de desarrollo, no fierro; (2) la palanca de costo dominante es la elección de D1/D2/D3; (3) Tibia factura €24M/año con ~14k CCU promedio (research/04): el fierro de "miles+" de esta tabla sigue siendo ruido contra cualquier monetización sana.


18. Riesgos propios del área y mitigaciones

Riesgo Prob. Impacto Mitigación (ya en el diseño)
Bots headless hablando el protocolo (browser = cliente hostil) Alta Economía/rates §7 completo: estadística + cuarentena económica + ledger + revisión humana con replay; se asume carrera perpetua, se gana en costo/beneficio
Dupe no previsto Media Portada (Ravendawn) 3 capas §8.2 + reconciliación §8.3 + freeze de mercado + PITR para cirugía
Pico de apertura por encima del margen Media Reputación día 1 Cola justa §16.2 + cap configurable + ensayo 2.000/60 s + registro anticipado
CF degrada WSS o cambia ToS Baja Latencia/corte Plan B runbook §15.1 + lista de endpoints en handshake
Pérdida del VPS/proveedor Baja Total Backups en proveedor distinto + drill mensual + runbook de restore a proveedor alternativo (RTO <30 min)
Desync colisión cliente/server Media "Falsos speedhacks", frustración Bundle único con hash en handshake §2
Deriva del protocolo Rust↔TS Media Bugs sutiles Codegen único + tests dorados bidireccionales + fuzzing §4.3
Staff insuficiente para el SLA de apelación Media La herida nº1 reabierta appeals_overdue con alerta + escalado automático + D7 dimensiona dotación ANTES del launch

Criterios de terminado

Esta área está completa cuando TODO lo siguiente es verificable (no "está codeado": se puede demostrar en vivo):

Arquitectura y protocolo

  1. Workspace compila con clippy -D warnings, cargo-deny y cargo-audit en verde; sim-core sin unsafe, sin tokio, sin SQL (verificado por CI con lints de dependencia).
  2. packets.toml cubre la matriz de features del MVP; el codegen emite Rust+TS con tests dorados bidireccionales en verde; el fuzzer del decoder corrió ≥24 h acumuladas sin crashes.
  3. Handshake rechaza versión/bundle incorrectos y el cliente PWA se auto-refresca y reconecta (demostrado con un deploy real de bump de versión).

Fidelidad y simulación 4. Los intervalos 210/1.165/1.230/800 ms se validan contra reloj server-side con test de regresión que falla si se cuantizan al tick; los vectores dorados de combate (Balance.dat) pasan bit-exacto. 5. Interest management: un bot fuera del bloque 3×3 no recibe NINGÚN paquete de un oculto/entidad lejana (test automatizado de no-filtración). 6. Canales: auto-apertura y drenado funcionan con bots; el presupuesto económico global por mapa se respeta (test de reconciliación con 3 canales activos).

Reconexión 7. Chaos test §14 en verde: matar 50 % de sockets → 100 % resume sub-segundo, cero muertes causadas por el corte, ledger intacto. Kill -9 del proceso con 300 bots → restart → rollback ≤30 s, ningún personaje muerto por la caída. 8. Los timers de gracia (D5 decididos) son públicos en la API de status y la pantalla de muerte offline muestra el relato del linger.

Anti-cheat y auditoría 9. Rate-limit por paquete activo con tabla en config caliente; paquete malformado = desconexión + flag (test con cliente malicioso de juguete). 10. El pipeline estadístico produce flags con evidencia y el visor de replay reproduce una sesión real tick a tick; existe al menos un caso de prueba de macro detectado end-to-end en staging.

Persistencia 11. Migraciones aplican de cero en CI; el server se niega a arrancar con drift de esquema. 12. Trade/mercado/banco son transaccionales: el test que mata el proceso a mitad de 1.000 trades concurrentes termina con ledger Σ=0 y cero ítems duplicados o perdidos. 13. PITR operativo y drill de restore automatizado ejecutado con éxito al menos dos veces, con verify-world en verde y resultado visible en el panel.

Cuentas y moderación 14. Flujo invitado→claim (email OTP y Google) funciona en producción de staging, con cuarentena económica activa para no-verificados. 15. Registro público de sanciones y apelaciones con SLA (D7) operativos end-to-end: caso de prueba sancionado → publicado → apelado → resuelto por segundo staff → overturn visible. 16. gm_actions, sanctions y ledger rechazan UPDATE/DELETE incluso para el rol owner (test de permisos SQL).

Escala y operación 17. Nocturna de 500 bots en verde 7 corridas seguidas (tick p99 <30 ms, sin leaks, resume_fail=0); suite de apertura (2.000 logins/60 s) en verde. 18. Dashboards Grafana + alertas del catálogo §13.1 activas y probadas (cada alerta disparada al menos una vez a propósito); contador público consumible y honesto. 19. Runbooks escritos y ensayados: apertura, caída total + restore, plan B de Cloudflare, rotación de secrets, deploy con ventana. 20. Costos reales del MVP contratados y documentados contra la tabla §17; D1–D10 decididas y registradas en este documento.


Reconciliación (2026-07-26)

Cierra los huecos y contradicciones del informe del crítico (90-huecos-y-decisiones.md) que tocan al servidor: H1/C9 (sprint), H2/C1/C2/C3/C15 (gracia y restitución), H14 (temporadas), regla de oro nº2 (anti-blob), H7/H9/H10/H11 (correo, AFK, TTL de loot, API pública) y H3 (staffing). Donde dice «reemplaza a §X», el texto viejo queda como contexto histórico y ESTA sección manda. Criterio general: opción más conservadora y reversible; las decisiones genuinas de Pablo quedan marcadas, las técnicas se resuelven acá con su porqué.

R1. Sprint server-side (cierra H1 y C9; implementa 02 adenda A)

La spec de juego es 02 adenda A y no se toca; esto es su bajada a protocolo, validación y rate-limit — lo que a 05 le faltaba.

Protocolo (extiende §4.3):

  • El paquete Walk C→S suma un flag de modo en el byte de heading (bit alto: 0 = caminar, 1 = correr). Sin paquete nuevo: el layout sigue fijo y el costo es 0 bytes.
  • El evento de movimiento S→C emite el mismo flag a los suscriptores del área: todos ven la animación de correr (tell diegético de 02 A.1). Nuevo evento discreto StaminaTell { entity, kind } con kind ∈ {exhausto_inicio, exhausto_fin} para la animación de jadeo al llegar a 0.
  • La stamina ajena jamás se emite: ni valor ni porcentaje viaja a otros clientes (misma arquitectura que invisibilidad §5.1). Lo único visible del cansancio del rival es POSTURA: clip de correr y clip de jadeo. Esto resuelve la visibilidad de stamina rival como postura/animación, no barra numérica — coherente con 01 y con la recomendación (a) de N1 en 02 adenda A: los tells diegéticos SON la animación; anti-cheat por construcción porque el dato no existe en la memoria del cliente rival. N1 queda cerrada así (era resolución técnica: cualquier variante numérica exigiría emitir el dato y regalarlo a clientes modificados).

Validación server-side por modo (reemplaza la fila «Movimiento» de §7.1 en lo que toca a intervalos):

  • Caminar: intervalo sostenido ≥ 205 ms/tile; correr: ≥ 145 ms/tile (objetivos 210/150, margen de jitter 5 ms — números de 02 A.1). La validación es por timestamp server-side next_walk_at calculado según el modo del paso ANTERIOR aplicado; el flag del cliente es intención, el server decide qué modo se ejecutó.
  • Paso con flag «corriendo» sin condiciones (stamina ≤ 0, arranque con ≤ 10 %, candado de 1 s post-hostil activo, parálisis/inmovilidad, fantasma): se degrada a caminar en silencio (se procesa como paso normal a 210 ms) + contador de violación. Degradar en vez de descartar es lo conservador: un cliente con estado desincronizado no se traba ni se kickea, y el speedhack no gana nada.
  • El resto de 02 A.6 se aplica en sim-core: correr bloquea golpear/castear/proyectiles/comercio/banco (pociones sí), candado de arranque 1.000 ms tras acción hostil propia, parálisis corta el sprint, NPCs y fantasmas no corren.

Stamina autoritativa (extiende §3.3 y §7.1):

  • Consumo: acumulador fraccional server-side de 1 % de stamina máxima por tile corrido (02 A.2), descontado al aplicar el paso. Sin redondeos explotables: el acumulador es el estado, la barra es la vista.
  • Regen: en el tick de regen de 1 s (§3.3), tasa por postura según 02 A.3 (corriendo 0 %, caminando 1 %, parado 2 %, sentado 5 %); hambre o sed en 0 → regen 0. Todo server-side; el cliente solo predice su propia barra y se corrige con el snapshot.
  • Fatiga (< 20 %): sim-core aplica ×0,85 a PoderAtaque/PoderAtaqueProyectiles en la fórmula de §7.1-Combate. El estado «fatigado» viaja solo al dueño (su HUD), jamás al rival.
  • Sprint gratis en ZONA_SEGURA si N2 se ratifica en (b): flag del trigger de tile, costo 0 en el acumulador; la validación de intervalo no cambia.

Rate-limit (reemplaza la fila Walk de la tabla §7.2):

Paquete Tasa sostenida Burst Al exceder
Walk 8/s 10 Descartar en silencio + contador

Porqué: corriendo legítimo son 6,67 pasos/s (150 ms/tile); la fila vieja de 6/s kickeaba el sprint legal (C9). El bucket queda por encima del máximo legítimo y por debajo de cualquier flood útil; la regla exacta (145/205 ms) la valida sim-core después, como manda §7.2 («el rate-limit es la primera muralla, no la regla»).

Tests (extienden §14): el perfil de bot «cazador» y el chaos de red móvil corren también en modo sprint; suite nueva de speedhack sintético por modo (pasos a 140 ms con flag correr, pasos a 200 ms sin flag) que debe terminar en degradación/rubber-band sin kick falso.

R2. Spec única de gracia de reconexión y restitución (reemplaza a §6 de este documento Y a 02 §22 — cierra H2, C1, C2, C3, C15)

La promesa manda: la muerte causada por desconexión se restituye. Esta sección es LA spec que citan cliente (04 S40), party UI, wiki y API; 02 §22 y §8.5 pasan a referenciarla. Síntesis: la máquina de estados y el anti-abuso de 05 §6 + el clasificador y la restitución de 02 §22. Filosofía en dos filos: la muerte jamás por desconexión, la desconexión jamás como arma — y cuando los filos chocan, decide el clasificador, con default pro-jugador.

Constantes únicas (reemplazan TODA cifra previa de 02 §22, 04 §5.6 y 05 §4.5/§6; públicas en GET /api/v1/status):

Constante Valor Nota
Heartbeat C→S (TimeSync) cada 5 s Sube de 10 s: habilita detección fina. Costo ~2 bytes/s, irrelevante
T_detect (detección de DC) cierre de socket = inmediato; silencio total = 12 s Reemplaza los «2 pongs de 20 s» de §4.5 como detector primario (el ping WS queda como capa de transporte). Resuelve C2: los 5 s de 02 eran irrealizables sin falsos positivos en 4G
T_combat 15 s ÚNICA definición de «en combate» en todo el juego (linger, cambio de canal, logout). Reemplaza los 30 s de 02 §22.1.3. Resuelve C3: 15 s alcanza para negar el escape y castiga menos al que ya se desenganchó
T_linger 45 s si el combate reciente fue PvP / 20 s solo PvE Los valores propuestos de 05 D5 pasan a spec; D5 queda reducida a ratificarlos o ajustarlos
T_safe (logout seguro) 10 s parado Reemplaza los 5 s de 02 §22.3
Ventana de reconexión post-muerte para restitución 30 min De 02 §22.5
Backoff de reconexión del cliente 0,25 → 0,5 → 1 → 2 s… tope 5 s Canónico (C16); 04 hereda
Token de resume en cliente memoria + localStorage con TTL de sesión Resuelve C15 a favor de localStorage: el caso real nº1 del cluster móvil es el navegador matando la pestaña — sessionStorage muere con ella y rompe la promesa. Mitigación: token rotativo de un solo uso, hasheado server-side, invalidado por login completo (§9.3). Reemplaza a 04 §5.7

Máquina de estados (la de §6.1, con estos cambios):

  1. DC detectado con flag de combate reciente PvP o PvE (ventana T_combat) → Linger en modo defensa automático: el personaje queda en el mundo, atacable, con defensa/evasión/armadura normales, deja de atacar y castear. Además (heredado de 02 §22.4, era lo que a 05 §6 le faltaba): los NPCs se desagrean a los 5 s de linger, y contra jugadores solo pueden seguir dañándolo quienes ya estaban en combate con él al momento del DC (nadie «se suma» a matar a un desconectado). Sin auto-pociones ni ninguna acción automática ofensiva o de consumo: cualquier automatismo de ese tipo convierte el cable-pull en herramienta.
  2. DC sin combate reciente → LogoutSeguro (T_safe) → despawn + save. Igual que §6.1.
  3. Muere durante el linger → la muerte OCURRE siempre (fantasma, respawn, relato offline de §6.3.5). Lo que decide el clasificador es la restitución, no la muerte: así el filo anti-combat-log de Tibia y la promesa conviven sin contradicción.

Restitución (integra 02 §22.5–22.7):

  • Si el DC se clasifica genuino y el jugador reconecta dentro de los 30 min: restitución automática del 100 % de lo dropeado (ítems y oro). Los ítems ya levantados por otros se clonan-restituyen (el que los levantó los conserva: quitárselos genera su propia injusticia); cada clonación queda en el ledger como kind=restitution y la reconciliación nocturna (§8.3) la descuenta como emisión conocida — la inflación por restitución es medible y tiene alerta.
  • Si se clasifica combat-log deliberado: la muerte vale con todas sus consecuencias, sin restitución.
  • Entrega vía correo in-game (R5): la restitución llega al casillero del personaje, no aparece mágicamente en el inventario (evita conflictos de peso/slots y deja la entrega auditada y visible). La pantalla de reconexión lo anuncia.
  • Muerte por lag del SERVIDOR (tick degradado reconocido por monitoreo §13): restitución automática sin clasificador ni pedido, con disculpa en el log personal (02 §22.7, intacto).

Clasificador de combat-log (server-side, señales): cierre limpio de socket (close frame WS) vs timeout; historial de RTT/pérdida de la conexión en los 60 s previos (la telemetría de §13.1 ya lo mide); correlación con otros jugadores del mismo AS/ISP con problemas simultáneos (evidencia pro-jugador); densidad de DCs «oportunos» de la cuenta (DC ≤ 5 s después de ser taggeado en PvP) en 30 días. Default pro-jugador: ante la duda, restituye (la restitución es barata; la muerte injusta cuesta un jugador — 02 §22 y decisión nº40 del informe). Umbral revisable con datos al mes.

Anti-abuso de la restitución:

  • Cooldown: máximo 2 restituciones automáticas por cuenta por ventana móvil de 7 días; la 3ª no se pierde: entra a cola de revisión manual con el replay adjunto (§7.5). Números en config caliente.
  • El logout voluntario jamás da gracia ni restitución: comando de salir o cierre explícito de sesión = logout normal (con su regla de combate vigente). El cierre «limpio» de pestaña durante combate PvP cuenta como señal fuerte de combat-log para el clasificador.
  • Reincidentes: pierden el beneficio de restitución por un período visible en su perfil (transparencia > castigo oculto — 02 §22.6).
  • Logs: toda muerte-en-linger vuelca replay automáticamente (extiende el gatillo de §7.5); toda restitución = fila de ledger + entrada en el panel admin con el veredicto del clasificador y sus señales; patrón anómalo (misma dupla matador/muerto, restituciones cruzadas) → flag anti-cheat.

D5 queda así: la estructura y las reglas de esta sección son spec cerrada; a Pablo le queda ratificar los VALORES de la tabla de constantes y la visibilidad del marcador (§6.4 opción sin ícono sigue recomendada). ⚠️ DECISIÓN PENDIENTE DE PABLO (D5, reducida): ¿constantes tal cual, o ajustes finos (p. ej. linger PvP 30 s)?

R3. Infraestructura de temporadas (cierra H14; implementa 02 §19)

Topología — mismo binario, mundo aparte, base separada:

  • El servidor de temporada es el mismo binario con --season <id>: carga el ruleset desde seasons.ruleset (rates, restricciones, reliquias, zona habilitada) como config, no como fork. Un solo código que mantener; el ruleset es data.
  • Base de datos separada: database propia aurum_season_<id> (misma instancia Postgres en MVP; caja aparte si la carga lo pide). Cero riesgo de contaminar el mundo permanente: no hay JOIN posible entre economías. pgBackRest ya respalda el cluster completo (§8.5), la temporada queda cubierta sin trabajo extra.
  • Proceso: en el mismo host si el headroom del MVP lo permite (a 500 CCU sobra — §12.1), o VPS propio 4–8 vCPU/16 GB si la temporada trae su propio pico. Puerto/endpoint propio en la lista del handshake; el cliente elige mundo en la selección de personaje (S07).
  • Cuentas compartidas, personajes separados: el login HTTP es único (DB principal) y emite tickets válidos para cualquiera de los dos mundos; los personajes de temporada viven SOLO en la DB de temporada (season_characters en la principal guarda el snapshot de cierre, como ya define el schema §8.2).

Cierre y merge de trofeos al main:

  1. Fin anunciado → el server de temporada entra en solo-lectura (sin trade/mercado) durante la ventana de cierre.
  2. Job transaccional de cierre: computa puntos finales por cuenta → escribe season_characters.character_snapshot + season_rewards en la DB principal (transacción con idempotencia por (season_id, account_id): el job se puede relanzar).
  3. Las recompensas (SOLO cosméticas/prestigio — 02 §19, constitucional) se entregan por correo in-game (R5) en el mundo permanente, con fila de ledger gm_grant-equivalente kind=season_reward.
  4. La DB de temporada se archiva (dump frío pg_dump -Fc al bucket de backups) y el proceso se apaga. El dump frío permite auditar disputas de la edición sin mantener nada corriendo.

Costo (fila nueva de la tabla §17; 07 §1.2 debe reflejarla — ver nota de sync):

Rubro MVP Crecimiento
Servidor de temporada (solo mientras corre una edición) mismo host — 0 (headroom) o VPS 4–8 vCPU/16 GB — 25–60 VPS/metal chico — 60–120
Storage de archivo de ediciones cerradas ~1–3 3–10

Conservador a propósito: la primera temporada arranca con el permanente estable (02 §19) y en el mismo fierro; se le compra caja propia recién cuando una métrica (tick p99 del host compartido) lo pida.

R4. Anti-blob general: retornos decrecientes en gank N≥3 contra 1 (cierra la regla de oro nº2, §3 del informe; extiende 02 §9.2)

La regla de oro nº2 (research/06) pedía anti-blob para TODO PvP, no solo faccionario. Regla server-side, sin importar facción, alineación ni zona:

  • Contribuyentes de un kill: jugadores que dañaron a la víctima en los 30 s previos a la muerte, MÁS quienes curaron/bufaron a esos atacantes en la misma ventana (el soporte del blob cuenta como blob — si no, el gank se «lava» con un healer).
  • Condición gank: N ≥ 3 contribuyentes contra víctima sola (sin aliados que la hayan curado/asistido en la ventana).
  • Retornos decrecientes de recompensa sistémica (puntos de facción, honor, progreso de logros PvP, rating): multiplicador ×1 con N ≤ 2, ×0,4 con N = 3, ×0,15 con N = 4, ×0 con N ≥ 5. Valores en config caliente, votables post-beta.
  • Rankings: un kill en condición gank no suma a killboard/rankings/rachas de ninguno de los participantes (queda registrado en el historial del killboard, marcado «gank» — visible, no premiado; el dato público es en sí el disuasivo).
  • El loot NO se toca: el drop sigue las reglas de zona de 02 §8. Tocar el botín según headcount genera metas raros (dejar morir al 3º para cobrar); el disuasivo es de gloria, no de botín.
  • Implementación: la tabla de daño reciente por entidad ya existe para XP de party (02 §17); esto es un fold más sobre la misma estructura al resolver la muerte, en sim-core. Costo cero en el hot path.

Porqué así: es la opción mínima y reversible — no prohíbe nada (el 5v1 sigue siendo posible: a veces es legítimo — guerra de clanes, caza de un PK marcado), solo le saca el premio. Nota: la caza de un jugador con bounty activo (02 §9) paga su recompensa de bounty normalmente aunque sea en grupo — el bounty existe justamente para organizar cacerías; lo que decae es el honor/rating, no el cobro del contrato.

R5. Correo/casillero in-game (cierra H7; usa containers.kind=mail de §8.2)

Alcance MVP: casillero de solo-recepción del sistema. Sin correo jugador-a-jugador (evita spam, scam y superficie de moderación — se revisa post-MVP por poll si hay demanda).

Schema (extiende §8.2):

mail_messages(id, character_id, kind enum(market, restitution, season_reward,
         event_reward, gm_grant, system_notice, order_refund),
         subject_msgid, body_msgid, params jsonb,      -- i18n por IDs, §4.6
         attachment_container NULL,                     -- containers(kind=mail)
         gold_attached bigint DEFAULT 0,
         created_at, read_at NULL, claimed_at NULL, expires_at NULL)
  • Entrega: el sistema crea el mensaje + (si hay adjuntos) un container mail con los ítems en escrow. Badge en el HUD (S11) vía evento MailBadge { unread }; el contenido se pide on-demand (no viaja en el sync de área).
  • Claim: transacción única mail-escrow → inventario + fila de ledger (mismo patrón que mercado §8.2). Si no hay espacio/peso, claim parcial permitido; el resto queda en el casillero.
  • Quién lo usa: retiro del mercado y devolución de órdenes expiradas (S28 «casillero»), restituciones por DC (R2), recompensas de temporada (R3) y de eventos offline, entregas de GM (gm_grant, auditado en ledger y gm_actions como siempre).
  • Expiración: expires_at por tipo — mercado/devoluciones 90 días (3 avisos previos por el propio casillero), recompensas de evento 30 días, restituciones y season_rewards NO expiran (expirar una restitución re-rompería la promesa que la creó). Al expirar: ítems/oro → ledger kind=destroy (sink conocido y logueado).
  • Sin rate-limit propio: solo escribe el sistema; no hay vector de spam.

R6. Staffing realista «1 humano + IA» (cierra H3; ajusta §10; sincroniza con 06 §7.3 y 07 §11.2)

Con 1 humano, el four-eyes de §10.2-D7(3) tal como estaba es matemáticamente imposible (informe H3). La moderación se rediseña por capas para que el humano sea la ÚLTIMA instancia, no la primera:

Capa 0 — automática (sin humano, jamás «modera»): filtros multilingües con lista blanca cultural (06 §7.3), rate-limits y mute progresivo por flood (§7.2), firmas deterministas (§7.4). Son reglas del juego, no juicios: se aplican solas y se apelan como cualquier sanción.

Capa 1 — reportes con evidencia automática: todo reporte de jugador adjunta SOLO evidencia server-side generada por el sistema (volcado de replay §7.5 + contexto de chat + ficha resumida). El reportante no aporta «pruebas»: el server ya las tiene. El triage IA clasifica por severidad/idioma, deduplica y arma el expediente con acción propuesta. La IA jamás sanciona por encima de la capa 0.

Capa 2 — moderadores comunitarios voluntarios por idioma (rol Moderador de §10.1, con estas restricciones explícitas):

  • Poderes LIMITADOS: mute temporal ≤ 24 h, jail ≤ 1 h, escalar el caso con el expediente. Sin bans, sin toques económicos, sin teleport, sin espectar.
  • TODO auditado públicamente: sus acciones aparecen en el registro público de sanciones igual que las de staff, como rol+número («MOD-ES-01»), con case_code y rule_code como cualquier sanción. La transparencia es la correa: un mod voluntario con historial público no puede ser el «GM corrupto» de la herida nº1.
  • Reclutamiento: ≥ 1 por idioma al launch (objetivo 2), de la beta cerrada del Consejo de Veteranos (07 §8.1); revocación inmediata por Pablo; TOTP obligatorio como todo staff (§9.2).
  • Cumple el requisito de 06 §7.3 («ningún jugador moderado por quien no lee su idioma») estructuralmente; la vista MT interna etiquetada queda solo para severidad baja, como 06 ya permite.

Capa 3 — Pablo (decisión final) con permban en dos tiempos (reemplaza el four-eyes de §10.2-D7(3) mientras el staff humano sea 1):

  • Permban = suspensión inmediata de 7 días + ventana de revisión de 72 h + conversión a permanente, con registro público motivado obligatorio: el caso se publica completo (case_code, regla, evidencia citada, veredicto) ANTES de la conversión. El «segundo par de ojos» del MVP es el público + la ventana: reversible por construcción.
  • Excepción: firmas deterministas de bot/ataque (§7.4) convierten sin ventana (no hay juicio que errar), igualmente publicadas.
  • La apelación de un permban la revisa Pablo con el expediente re-generado desde cero por el tooling (fresh-eyes procesal: la IA no le muestra su decisión anterior).
  • Cuando exista un 2º staff humano de confianza, el four-eyes real de §10.2 se activa y esta regla de dos tiempos se retira (el constraint queda codificado con un flag single_staff_mode).

SLA realista (ajusta §10.3 y la propuesta de D7(1)): primera respuesta automática con estado y evidencia < 1 h (es tooling); primera respuesta humana72 h para tempban y superiores, ≤ 7 días para sanciones menores (mute/jail); resolución ≤ 7 días. appeals_overdue y el reporte mensual de transparencia (§10.2) publican el cumplimiento real: un SLA incumplido en silencio es peor que uno laxo cumplido (07 §11.2).

Qué exige humano vs qué hace la IA (resumen operable, alinea 07 §0.3): IA/automatización = triage, evidencia, dedup, expedientes, filtros, firmas, métricas, publicación al registro. Humano (Pablo o mod voluntario según capa) = toda sanción no determinista, toda apelación, todo permban, y el evento GM semanal en su formato semi-automatizado (N4-b).

D7 queda así: (1) SLA = los números de arriba; (3) four-eyes = dos tiempos en single_staff_mode, four-eyes real con ≥ 2 staff; (4) dotación mínima = beta: Pablo + ≥ 2 voluntarios de confianza; launch: ≥ 1 mod voluntario por idioma (objetivo 2) — coincide con el parche del informe H3. ⚠️ DECISIÓN PENDIENTE DE PABLO (D7, reducida): (2) anonimización de sanciones menores en el registro público — sigue abierta tal como estaba.

R7. Política AFK/idle (cierra H9; responde la delegación de 01 §6.11)

  • Timeout de inactividad: 20 min sin input (ningún paquete C→S salvo heartbeat) → aviso in-game a los 18 min → logout seguro server-initiated (sin linger: es voluntario por omisión). En combate no aplica por definición (estar en combate ES actividad; y un AFK en zona hostil se muere solo, que es la regla del mundo).
  • Excepción meditar con presencia: meditar no dispara el timeout mientras el jugador demuestre presencia — cada 30 min de meditación continua sin otro input, un prompt discreto de un solo click («¿Seguís ahí?», sin puzzle, jamás captcha — está documentado como odiado, §9.4); sin respuesta en 5 min → logout seguro. La meditación con presencia cuenta como sesión; la meditación que termina en logout por no-presencia no cuenta para racha ni misión diaria (02 adenda B).
  • Con cola de login activa (§16.2): el timeout baja a 10 min y el prompt de meditación a cada 15 min — el asiento ocupado por un ausente es exactamente lo que la cola justa no tolera.
  • Enforcement en el servicio de sesiones (net), no en sim-core: es política de conexión, no regla de juego. Valores en config caliente.

R8. TTL del loot en el piso (cierra H10; propuesta para ratificar con Economía en 02 §8)

Tipo Zona segura/ciudad Verde/amarilla Roja/dungeon
Ítem tirado a mano / oro suelto 3 min 5 min 5 min
Drop de muerte (cadáver con lista visible) 10 min 10 min 10 min
  • El cadáver de 10 min es transversal a propósito: «volver por tus cosas» es gameplay y es el relato de la pantalla de muerte (S22); acortarlo en roja castigaría doble al muerto, alargarlo acumularía botín sin dueño.
  • Ítems con instance_id (valiosos): mismo TTL, pero la expiración escribe ledger kind=destroy (el sink queda auditado; un valioso jamás desaparece sin fila).
  • Cap de memoria anti-DoS: máximo de ítems en piso por tile y por mapa (config); al exceder, colapsa el más viejo sin instancia. Tirar basura infinita no infla la RAM del worker.
  • Los TTL restantes viajan en el sync de área (el cliente puede mostrar el desvanecimiento); valores en config caliente, ratificables por Economía sin deploy.

R9. API pública read-only para fan-tools (cierra H11; extiende §13.2)

La cultura AO vive de fan-sites, atlas y planillas (research/05); una API oficial es marketing de confianza y mata el scraping salvaje. Todo bajo GET /api/v1/…, versionado, JSON, solo lectura:

Endpoint Contenido Cache
/status Ya existe (§13.2): online, récord, constantes públicas de R2 30 s
/rankings/{tipo} Los rankings públicos de 02 §21.2 (con su ventana móvil; sin ranking de riqueza — decisión nº39) 5 min
/characters/{name} Perfil público: nivel, clase, clan, logros visibles, killboard propio. Sin riqueza, sin cuenta, sin última conexión. Opt-out de perfil en Opciones 5 min
/killboard/recent Últimos kills públicos con flags (gank marcado — R4) 1 min
/polls + /polls/{id} Polls de gobernanza con resultados crudos (§8.2) 5 min
/seasons Ediciones, estado, top 100 cosmético de cada una 5 min
/sanctions El registro público de §10.2 (ya prometido como API) 5 min
/balance/winrates Matriz de winrates de arena por clase (alimenta el tablero público de balance — H6; el dato sale de arena_matches) 1 h
  • Autenticación: nada para leer — solo rate-limit por IP (60 req/min, burst 120) servido desde el edge (Cloudflare) con cache; API keys gratuitas registradas (por cuenta) suben a 600 req/min para herramientas serias. CORS abierto (GET puro).
  • Términos de uso publicados junto a la API: atribución, prohibido el perfilado masivo de jugadores (el opt-out se respeta también en dumps), sin garantía de SLA pero con changelog de versionado (v1 estable; los breaking changes son v2).
  • Implementación: mismos handlers del admin-api (axum) con vistas públicas filtradas + cache HTTP agresivo; el costo marginal es ~0 porque todo dato ya existe para las superficies propias (killboard, registro de sanciones, contador).

Barrido final (2026-07-26)

Última pasada contra el «Estado post-reconciliación» de 90-huecos-y-decisiones.md: los residuos que R1–R9 dejaron en este archivo (C7, el bucket de Resume de C16, el enum de R5, la seudonimización de §8.2) más el lado servidor de C23 (tesorería), H18 (cola UGC) y H13 (tickets). Donde dice «reemplaza a §X», esta sección manda.

B1. Línea de visión — C7 cerrada (reemplaza «línea de visión por Bresenham» en la fila Combate de §7.1)

04 R5 ya lo había adoptado y lo derivaba acá; se cierra: la validación de combate usa la LoS laxa fiel de 02 §4.5 («se castea a lo que se ve en pantalla, con esquinas permisivas» — no se altera el meta del agite; es la recomendación de la decisión nº24 del 90). La fila Combate de §7.1 se lee: «rango y visibilidad dentro del área replicada del atacante (la laxitud de 02 §4.5); target válido y visible PARA EL SERVER». Bresenham queda implementado pero apagado detrás de flag (config caliente): se enciende solo si el playtest activa la cláusula de escape de 02 (abusos nuevos por la cámara 3D). El cliente no cambia en ningún caso (04 R5).

B2. Bucket de Resume — el residuo de C16 (reemplaza la fila Resume 3/min de la tabla §7.2)

04 R7 tenía razón: el backoff canónico de R2 (0,25 s → tope 5 s) produce ~12 intentos/min sostenidos, y la fila vieja cerraba el socket al 4º — el propio cliente legítimo moría en la reconexión que la promesa nº1 protege. Fila nueva:

Paquete Tasa sostenida Burst Al exceder
Resume 12/min 6 Cerrar socket

Sigue siendo inútil para flood (el token es de un solo uso y se invalida al primer resume exitoso o al login completo); la separación transporte/Resume que 04 R7 implementó es compatible y se mantiene como buena práctica.

B3. Enum del casillero — referral_reward (residuo nº3; extiende el schema de R5)

El enum mail_messages.kind de R5 suma referral_reward (02 R-D lo requiere: la recompensa de referidos llega por casillero). Sin expiración especial: 30 días como las recompensas de evento.

B4. Seudonimización sobre append-only (residuo nº2; nota para §8.2 — la spec canónica es 07 R5 y esta nota la implementa)

  • §8.2 gana la regla explícita que le faltaba: ledger, sanctions, gm_actions, appeals y poll_ballots jamás se borran — se seudonimizan al ejecutar un borrado de cuenta (07 R5 / 01 R5): purga de PII de accounts/account_sessions/devices, personajes renombrados a Borrado-####, IDs internos intactos (no son PII). Killboard, rankings y la API de R9 se re-renderizan con el nombre seudonimizado.
  • Tabla nueva en §8.2: erasure_log(id, account_ref, email_hash, executed_at) — sin PII reversible; el runbook DR re-aplica las purgas posteriores al punto de restore (07 R5.4). Se agrega al drill mensual de §8.5.

B5. Tesorería de clan — lado servidor (cierra C23 junto con 02-barrido B1; el schema de §8.2 ya estaba listo)

clans.vault_container y containers.kind=clan_vault existen desde el schema original; lo que faltaba era la regla (02-barrido B1) y estas bajadas:

  • ledger_tx.kind suma clan_deposit y clan_withdraw; todo movimiento de bóveda es transacción SQL única con filas de ledger (ya obligatorio por §8.1: la tesorería está listada en «todo movimiento de valor multi-parte»).
  • Límite diario de retiro por rango: config del clan (jsonb en clans), validado server-side contra el acumulado móvil de 24 h del ledger — sin contador paralelo que desincronizar.
  • El log interno del clan es una vista del ledger filtrada por vault_container, servida a miembros vía el servicio global de clanes (§3.1) — cero tabla nueva.
  • Disolución del clan → job transaccional: bóveda a ledger kind=destroy (02-barrido B1).

B6. Cola de UGC visual y tickets de soporte (cierra H18 y H13 lado servidor; superficie en 01-barrido B1/B3)

  • H18: reports gana target_kind enum(character, clan) — el reporte de emblema/descripción ofensivos targetea al clan; el expediente de la capa 1 (R6) adjunta render del emblema + descripción + tag. Entra a la MISMA cola de moderación por severidad/idioma (nada nuevo que operar); la resolución (forzar a default, sanción al editor) es acción de mod/Pablo según capas de R6, auditada en gm_actions.
  • H13: tabla nueva support_tickets(id, account_id, category enum(bug, account, restoration, payment, other), locale, text, status enum(received, in_review, waiting_user, resolved), context jsonb, created_at, resolved_at NULL). El triage es el MISMO pipeline de la capa 1 de R6 (clasificación por categoría/idioma, expediente automático, dedup); la respuesta se entrega como system_notice al casillero (R5) con link al detalle, y las restauraciones de ítems salen como gm_grant adjunto en ese mensaje — auditadas en ledger y gm_actions como cualquier entrega de GM. SLA y dotación: los de R6/07 R1 (los tickets comparten la cola humana con la moderación; el SLA publicado es uno solo por idioma).