WENOPAL

12 · Multijugador

En qué máquina corre cada línea de un mod, cómo mover datos entre cliente y servidor sin abrir agujeros y cómo probarlo con dos jugadores.

Curso técnico · Build 42 · Actualizado el

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ónisClient()isServer()Qué carpetas de Lua cargan
Un jugadorfalsefalseshared, client, server (todo, en la misma máquina)
Cliente conectado a un servidortruefalseshared, client
Servidor dedicadofalsetrueshared, 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.

lua
-- 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 tu OnClientCommand en 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 con sendServerCommand, 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

lua
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é cambiasteCómo se avisaUsos en el juego
ModData de un objeto del mundoobj:transmitModData()37
ModData de una parte de vehículovehicle:transmitPartModData(part) (Wanaco)
InventariosendAddItemToContainer(cont, item), sendRemoveItemFromContainer(cont, item) (Pumpkin Z DIY, módulo 11)
Campos de un ítemitem:syncItemFields(), sendItemStats(item)
Datos globales del modModData.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:

lua
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.txt en la instancia del servidor y console.txt en cada cliente. Un print en código de server/ sale en el del servidor, no en tu ventana.
  • Map=Muldraugh, KY y PauseEmpty=false en 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):

lua
{ 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.

  1. Solo el servidor lo modifica (al recibir el evento del 9.1 por comando).
  2. El servidor lo transmite después de cada cambio.
  3. Los clientes lo guardan al recibirlo.
  4. 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