Objetivo: saber en qué máquina corre cada línea de tu mod, mover información entre cliente y servidor sin abrir agujeros, y probarlo de verdad con dos jugadores en tu computador.
1. Tres lugares donde corre tu código
| Situación | isClient() | isServer() | Qué carpetas de Lua cargan |
|---|---|---|---|
| Un jugador | false | false | shared, client, server (todo, en la misma máquina) |
| Cliente conectado a un servidor | true | false | shared, client |
| Servidor dedicado | false | true | shared, server |
La trampa está en la primera fila: en un jugador, isClient() y isServer() son los dos falsos. Un if isServer() then no corre en un jugador. Por eso el juego usa tanto if not isClient() then (para "lo que decide la autoridad": un jugador o el servidor). Hay 620 isClient() y 279 isServer() en el Lua del juego.
Regla de diseño:
- El servidor decide (inventario, mundo, daño, XP). El cliente pide.
- El cliente muestra (UI, sonidos, efectos). El servidor no tiene pantalla.
- Lo que tienen que conocer los dos (definiciones, clases de acciones, validaciones) va en
shared/.
2. Comandos: el canal de tu mod
Son mensajes con nombre: un módulo (el nombre de tu mod, para no chocar), un comando y una tabla de argumentos.
-- cliente → servidor
sendClientCommand(player, "CursoLab", "pedirSopaipilla", { cantidad = 2 })
-- servidor: recibirlo
Events.OnClientCommand.Add(function(module, command, player, args) ... end)
-- servidor → un cliente / → todos
sendServerCommand(player, "CursoLab", "teToco", { item = "CursoLab.Sopaipilla" })
sendServerCommand("CursoLab", "aviso", { texto = "..." })
-- cliente: recibirlo
Events.OnServerCommand.Add(function(module, command, args) ... end)Lo que hacen por dentro (y sus trampas)
Esto es lo que dice el bytecode de LuaManager$GlobalObject:
sendClientCommand
- En un cliente, manda el paquete por la red (
GameClient.sendClientCommand). - En el servidor, lanza un error:
IllegalStateException("can't call this function on the server"). - En un jugador, pasa por
zombie.spnetwork.SinglePlayerClient: el mensaje llega a tuOnClientCommanden la misma máquina. Tu código cliente/servidor funciona igual en un jugador.
sendServerCommand
0: getstatic GameServer.server
3: ifeq 12 // si NO soy servidor, salto al return
... // GameServer.sendServerCommand(...)
12: return- En un jugador no hace nada. Si tu código de
server/responde consendServerCommand, en un jugador esa respuesta se pierde en silencio. En Finish Him y Batalla de Aura, dos de nuestros mods, lo resolvemos con "en partida local llamo la función directo".
La tabla args se serializa con zombie.network.TableNetworkUtils. Solo viajan:
- claves: texto o número;
- valores: texto, número, booleano, tablas (anidadas), ítems de inventario (
InventoryItem), direcciones (IsoDirections) y cadáveres (IsoDeadBody).
Cualquier otra cosa (un IsoPlayer, un IsoObject, una función) se descarta: GameClient escribe una línea en el log y no la manda. Para referirte a un objeto del mundo, manda sus coordenadas (x, y, z) y un índice o tipo, y búscalo del otro lado.
El patrón que usamos en Wanaco
local RATE_MS = { toggleCannon = 300, aim = 100 }
local lastCmd = {}
local function allowed(player, command) -- límite de frecuencia por jugador y comando
local key = tostring(player:getUsername() or player:getOnlineID()) .. ":" .. command
local now = getTimestampMs()
if lastCmd[key] and now - lastCmd[key] < (RATE_MS[command] or 200) then return false end
lastCmd[key] = now
return true
end
local Commands = {}
function Commands.toggleCannon(player, args)
local vehicle = canOperate(player) -- vuelve a validar TODO del lado servidor
if not vehicle then return end
...
end
Events.OnClientCommand.Add(function(module, command, player, args)
if module ~= "Wanaco" or not Commands[command] or not player then return end
if type(args) ~= "table" then args = {} end
if not allowed(player, command) then return end
local ok, err = pcall(Commands[command], player, args) -- un error no tumba el listener
if not ok then print("[Wanaco] error en comando " .. tostring(command) .. ": " .. tostring(err)) end
end)Las cuatro defensas: filtrar por módulo, tabla de comandos permitidos (nada de _G[command]), límite de frecuencia y pcall. Y la quinta, la más importante: validar en el servidor como si el cliente mintiera, porque puede hacerlo. Cualquiera puede mandar sendClientCommand desde la consola de Lua.
3. Sincronizar estado
Cambiar algo en el servidor no lo cambia en los clientes. Hay que avisar:
| Qué cambiaste | Cómo se avisa | Usos en el juego |
|---|---|---|
| ModData de un objeto del mundo | obj:transmitModData() | 37 |
| ModData de una parte de vehículo | vehicle:transmitPartModData(part) (Wanaco) | |
| Inventario | sendAddItemToContainer(cont, item), sendRemoveItemFromContainer(cont, item) (Pumpkin Z DIY, módulo 11) | |
| Campos de un ítem | item:syncItemFields(), sendItemStats(item) | |
| Datos globales del mod | ModData.transmit("CursoLab") (sección 4) |
Mutar la tabla de ModData sin transmitirla es el bug más común de multijugador: en tu partida local funciona perfecto (todo es la misma máquina) y en el servidor nadie ve el cambio.
4. ModData global
Una tabla con nombre que se guarda con la partida (no pertenece a ningún objeto). API (zombie.world.moddata.ModData): getOrCreate(tag), get, exists, add(tag, tabla), remove, transmit(tag), request(tag), getTableNames().
Lo que hace transmit, leído en GlobalModData.transmit:
- en un cliente, manda la tabla al servidor;
- en el servidor, la manda a todos los clientes conectados;
- en un jugador, no hace nada (no hay a quién).
Y lo que pasa al recibirla (GlobalModDataPacket.parse): se dispara OnReceiveGlobalModData(tag, tabla) con una tabla nueva. No se guarda sola. Tú decides:
Events.OnReceiveGlobalModData.Add(function(tag, data)
if tag ~= "CursoLab" or type(data) ~= "table" then return end
ModData.add("CursoLab", data) -- reemplaza la copia local
end)Consecuencia de seguridad: en el servidor, ese mismo evento salta cuando un cliente hace ModData.transmit. El paquete solo exige que el jugador haya iniciado sesión (requiredCapability = Capability.LoginOnServer). Si tu código del servidor hace ModData.add con lo que llega sin mirar, cualquier jugador puede reescribir los datos globales de tu mod. En el servidor, o ignoras el evento o validas como con un comando.
5. Probar con dos jugadores en tu computador
Nosotros usamos un script propio que levanta un servidor local sin Steam y dos clientes, con RCON para teletransportar, dar ítems y dar admin. Lo esencial:
- Los mods se copian a cada instancia (los enlaces simbólicos no funcionan): después de editar, reinicia el servidor y vuelve a abrir los clientes.
- El log de cada lado:
server-console.txten la instancia del servidor yconsole.txten cada cliente. Unprinten código deserver/sale en el del servidor, no en tu ventana. Map=Muldraugh, KYyPauseEmpty=falseen el.ini(si no, mundo de puro bosque o mundo pausado).
Ejercicios
Ejercicio 12.1 · ¿Dónde corre?
Agrega a CursoLab un archivo en cada carpeta (shared, client, server) que imprima [CursoLab] cargó <carpeta> client=<isClient()> server=<isServer()>. Revisa los logs en un jugador y en el servidor local con un cliente.
Listo cuando: tienes la tabla de la sección 1 llenada con tus propios logs.
Ejercicio 12.2 · Pedir una sopaipilla
Un comando pedirSopaipilla { cantidad = n }: el servidor valida (máximo 3, máximo una vez cada 10 segundos por jugador, solo si el jugador tiene harina) y agrega las sopaipillas al inventario, sincronizando. El cliente lo pide desde el menú contextual (módulo 10).
Listo cuando: funciona en un jugador y en el servidor local, y pedir 50 desde la consola Lua del cliente no te da 50.
Ejercicio 12.3 · La respuesta perdida
Haz que el servidor responda con sendServerCommand(player, "CursoLab", "listo", {...}) y que el cliente muestre un Say. Pruébalo en un jugador: ¿llega? Explica por qué con el bytecode de la sección 2, y arréglalo para que funcione en los dos modos sin duplicar la lógica.
Ejercicio 12.4 · Qué viaja
Manda desde el cliente un comando con esta tabla y mira qué llega al servidor (imprime todas las claves):
{ n = 1, s = "a", b = true, t = { x = 1 }, item = unItem, player = getPlayer(), f = function() end, [true] = 1 }Compara con la lista de tipos de TableNetworkUtils y busca la línea que deja el cliente en su log.
Ejercicio 12.5 · Un contador global seguro
Un contador de "sopaipillas comidas en el servidor" guardado en ModData global, visible para todos los clientes en tu ventana del módulo 10.
- Solo el servidor lo modifica (al recibir el evento del 9.1 por comando).
- El servidor lo transmite después de cada cambio.
- Los clientes lo guardan al recibirlo.
- El servidor ignora transmisiones de clientes.
Listo cuando: con dos clientes, uno come y el otro ve el número subir, y un ModData.transmit("CursoLab") hecho desde la consola de un cliente con un número falso no cambia nada.
Desafío 12
Escribe CursoLab.Net: una pequeña biblioteca reutilizable que haga, en una sola llamada, "pedir al servidor y recibir respuesta" (CursoLab.Net.call("comando", args, callback)), con límite de frecuencia, pcall, validación por comando y que funcione igual en un jugador y en un servidor. Mira shared/Util/LuaNet.lua del juego antes de diseñarla.
Anterior: 11 · Timed actions · Siguiente: 13 · Loot, sandbox y traducciones