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-coreno 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.protocoles la única frontera cliente-servidor. Nada serializa a mano fuera de él.unsafeprohibido ensim-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
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-datavalida 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
- Drenar canal de entrada (comandos ya validados por
net, eventos de otros workers, órdenes de admin). - Aplicar comandos contra
sim-core(cada uno chequea su intervalo/reglas conahora_ms). - 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).
- Recolectar eventos de salida → interest management (§5.1) → encolar a
net. - Marcar entidades dirty → encolar snapshots a
persistsegún política (§8.1). - 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
netacumula 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
TimeSynccada 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_versionse bumpea en cada cambio incompatible. El servidor nunca soporta más de una versión: ante mismatch respondeHelloReject(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_hashata 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) oResume { 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-deflatedeshabilitado: 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).
TimeSyncde 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_seqvarint) 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) yEntityLeave(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_cazaen 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
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
- Al autenticar, el server emite
resume_token(128 bits aleatorios; se guarda hasheado). El cliente lo retiene en memoria +localStorage. - 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).
Hello+Resume { token }→ el server re-ata el socket a la sesión viva, rota el token, respondeResumeOk { estado_dc: {fase, restante_ms}, snapshot_de_área }y el jugador sigue donde estaba, sub-segundo en el caso típico.- Si el personaje ya fue persistido (pasó todo el timer):
ResumeExpired→ login normal → spawn en el mismo tile. - 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. - 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,
ResumeOktrae 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. - 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_seqmonó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ó. Comosim-corees 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_iden 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_escrowy 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". goldes columna del personaje (fiel a AO) pero cada delta pasa porledger_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 versionadoNNNN_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+ unEXPLAINautomá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(debin/tools): invariantes del ledger (Σ=0 por tx), sin instancias duplicadas, sin huérfanos de FK, conteos plausibles, carga de 100 personajes al azar porsim-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)
- Jugar como invitado al primer click: el cliente genera una
device_key(aleatoria, en localStorage),POST /auth/guestcreaaccounts(status=guest)ligada al device → elige nombre y raza → está jugando. Sin email, sin captcha (con las defensas de §9.4). - 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
activeconservando TODO. - 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).
- ⚠️ 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 suresume_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 |
| 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-coreaplica. - Toda sanción exige
case_code+rule_codede 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 →
appealsconsla_due_atcalculado. - 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_overduetiene 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:
- 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).
- Repartir zone workers entre más cores (ya diseñado, §1.2) e instancias a workers dedicados.
- Separar Postgres a su caja + réplica streaming (lecturas de API/panel a la réplica; el juego sigue escribiendo al primario).
- Separar login/api-admin del proceso de juego (son stateless, trivial).
- 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 contrace_idpor 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 deccu_samplesreales; 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 enps). - Postgres: roles de mínimo privilegio (
game_rwsin DDL ni DELETE en tablas append-only;admin_apicon lo suyo;backupsolo 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-auditen 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
- Workspace compila con
clippy -D warnings,cargo-denyycargo-auditen verde;sim-coresinunsafe, sin tokio, sin SQL (verificado por CI con lints de dependencia). packets.tomlcubre 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.- 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
WalkC→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 }conkind ∈ {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_atcalculado 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-coreaplica ×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):
- 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. - DC sin combate reciente → LogoutSeguro (
T_safe) → despawn + save. Igual que §6.1. - 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=restitutiony 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 desdeseasons.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_charactersen la principal guarda el snapshot de cierre, como ya define el schema §8.2).
Cierre y merge de trofeos al main:
- Fin anunciado → el server de temporada entra en solo-lectura (sin trade/mercado) durante la ventana de cierre.
- Job transaccional de cierre: computa puntos finales por cuenta → escribe
season_characters.character_snapshot+season_rewardsen la DB principal (transacción con idempotencia por(season_id, account_id): el job se puede relanzar). - 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-equivalentekind=season_reward. - La DB de temporada se archiva (dump frío
pg_dump -Fcal 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
mailcon los ítems en escrow. Badge en el HUD (S11) vía eventoMailBadge { 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 ygm_actionscomo siempre). - Expiración:
expires_atpor 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_codeyrule_codecomo 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 humana ≤ 72 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 ensim-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 escribeledger 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 (
GETpuro). - 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,appealsypoll_ballotsjamás se borran — se seudonimizan al ejecutar un borrado de cuenta (07 R5 / 01 R5): purga de PII deaccounts/account_sessions/devices, personajes renombrados aBorrado-####, 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.kindsumaclan_deposityclan_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:
reportsganatarget_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 engm_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 comosystem_noticeal casillero (R5) con link al detalle, y las restauraciones de ítems salen comogm_grantadjunto en ese mensaje — auditadas en ledger ygm_actionscomo 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).