Conversaciones como datos, recorridas dos veces.
Resumen. Rod and Reef tiene 117 archivos de diálogo: 80 conversaciones base y 37 capas que les agregan las líneas de las quests secundarias y de la historia. Además de hablar, los NPCs ofrecen quests, las revisan, las pagan, abren tiendas, ponen cinemáticas y responden distinto según el progreso del jugador. Este artículo describe cómo está hecho eso. Cada conversación es una tabla plana de Luau con nodos por nombre, que un deserializador de dos pasadas convierte en un grafo dirigido. Las capas se combinan con una unión de mapas en la que gana la última. El nodo de entrada no es fijo, porque lo decide un resolvedor a partir de las quests del jugador. El cliente recorre el grafo para mostrar la conversación, con un efecto de máquina de escribir que cuenta grafemas visibles y se sincroniza con la voz grabada cuando existe. El servidor recorre el mismo grafo en paralelo y solo acepta movimientos y acciones que la conversación de verdad le puso enfrente al jugador, con el mismo payload con el que se escribieron.
1. El problema
Un NPC de Rod and Reef puede hacer muchas cosas en una conversación. Puede ofrecer una quest y aceptarla si el jugador dice que sí, revisar si ya se cumplió y saltar a una línea u otra según el resultado, pagar la recompensa, abrir la tienda de cañas o el menú de venta, poner una cinemática de la historia o mandar al jugador a elegir un bote para un viaje. Y el mismo NPC dice cosas distintas según dónde va el jugador: lo saluda la primera vez, le recuerda la quest que tiene a medias, le ofrece la siguiente, o le dice que ya no tiene nada más.
Eso deja tres requisitos. El contenido tiene que ser datos, porque hay cientos de líneas escritas por gente que no debería tocar código y buena parte se genera con herramientas a partir de los documentos de diseño. El formato tiene que permitir ramas, ciclos y acciones. Y como algunas de esas acciones pagan monedas y experiencia, el servidor no puede creerle al cliente cuando dice “el jugador eligió esto”.
2. El formato
Una conversación es una tabla de Luau donde cada clave es el nombre de un nodo:
MarksColdCall_Greet = {
Text = "Word is a Winterspire angler landed something rare. ...",
Responses = {
{
Text = "Let's go see it.",
Actions = { { Type = "AcceptQuest", QuestId = "MarksColdCall" } },
NextNode = "MarksColdCall_Accepted",
},
{ Text = "Maybe later.", NextNode = "MarksColdCall_Declined" },
},
},
MarksColdCall_Accepted = {
Text = "Good. Pick a boat with room for two. A decent one.",
Actions = { { Type = "ChooseRideBoat", QuestId = "MarksColdCall" } },
Close = true,
},
Un nodo tiene un texto y, como mucho, una de tres salidas: Responses si el jugador elige, NextNode si la conversación sigue sola, o Close si termina. Un nodo sin ninguna de las tres también termina. Actions puede ir en un nodo, y se ejecuta cuando ese nodo se muestra, o en una respuesta, y se ejecuta cuando el jugador avanza después de elegirla. Dos claves reservadas, FirstNode y Speaker, dicen dónde empieza la conversación por defecto y quién habla.
Que la tabla sea plana, con referencias por nombre y no un árbol anidado, es lo que permite que un nodo se alcance desde varias respuestas y que haya ciclos. El caso típico es la revisión de una quest a medias, que vuelve al mismo nodo cada vez que el jugador habla con el NPC. También hace que los archivos generados se lean bien en un diff. Para que el analizador de Luau en modo --!strict acepte estas tablas hubo que anotarlas con un tipo explícito, RawDialogueTable, porque Luau infiere el tipo de los elementos de un arreglo a partir del primero, y una lista de respuestas donde solo la primera tiene Actions falla en todas las demás.
3. Del texto al grafo
DialogueTree.FromTable convierte la tabla en un grafo dirigido . Los vértices son de seis tipos (Root, Prompt, Response, Branch, Condition y Action), aunque las tablas solo producen los tres primeros.
La conversión se hace en dos pasadas, como un ensamblador que resuelve saltos hacia adelante [1]. En la primera se crea un vértice Prompt por cada clave de la tabla. En la segunda se conectan: la raíz apunta a FirstNode, cada respuesta se vuelve un vértice Response con el nombre <clave>_r<i> y la prioridad , y se agregan las aristas de cada nodo a sus respuestas y de cada respuesta o nodo lineal a su NextNode. Hace falta separar las pasadas porque un NextNode puede nombrar un nodo que aparece más abajo en la tabla, y además el orden de iteración de una tabla de Luau no está definido, así que no hay un “más abajo” en el que confiar. Las dos pasadas cuestan . Un nombre que no existe genera una advertencia y la arista se omite, sin romper la conversación.
Las salidas de cada vértice se devuelven ordenadas por prioridad, y eso decide el orden de los botones de respuesta. Un vértice Branch es transparente: tanto el cliente como el servidor lo atraviesan y siguen con sus hijos.
Hay un segundo deserializador, FromConfiguration, que construye el mismo grafo a partir de instancias Configuration de Studio, el formato de un editor visual, con referencias por ObjectValue y búsqueda por nombre como respaldo. Lo importante es que las dos fuentes terminan en el mismo grafo, así que el resto del sistema no sabe de dónde salió una conversación.
4. Capas
La conversación base de un NPC la genera una herramienta a partir de los documentos de diseño, y las quests secundarias y de la historia agregan nodos encima. Si esos nodos se escribieran dentro del archivo generado, volver a generarlo los borraría. Por eso viven en carpetas aparte (SideQuestDialogue y StoryQuestDialogue) y se combinan al cargar.
La combinación es una unión de mapas finitos en la que gana el de la derecha:
La operación es asociativa pero no conmutativa, así que el orden está fijo: base, luego quests secundarias, luego historia, y al final los nodos de bloqueo del tutorial, que se agregan a todos los NPCs:
La historia va después de las secundarias para ganar cualquier choque de nombres, aunque en la práctica no los hay, porque cada capa usa el nombre de la quest como prefijo (MarksColdCall_Greet). La unión es por nodo y no por campo, así que una capa reemplaza nodos enteros. Y se hace sobre una copia, porque la tabla que devuelve require está en caché y la comparten todos los que la piden, y escribir los nodos del tutorial directamente en ella los dejaría pegados para siempre.
5. Dónde empieza la conversación
FirstNode es solo el valor por defecto. Para los NPCs que dan quests, el nodo de entrada lo decide QuestlineResolver.ResolveStartNode a partir del perfil del jugador, en este orden:
- Si el jugador tiene una quest activa de este NPC, la conversación empieza en el nodo de revisión de esa quest. La historia se revisa antes que el resto.
- Si el jugador está siguiendo una quest en el diario y este NPC la ofrece y el jugador ya la puede tomar, empieza en el saludo de esa quest.
- Si no, empieza en el saludo de la primera quest disponible.
- Si el NPC ya no tiene nada que ofrecer, empieza en
AllDone. - Si el NPC no tiene quests, se usa
FirstNode.
Hay dos casos que pasan antes de esa resolución. Durante la introducción del juego, todos los NPCs menos el que el tutorial está señalando responden con una línea corta que depende de la etapa, y el NPC señalado se calcula con la misma función con la que el cliente decide qué prompt mostrar, así que las dos cosas no se pueden desincronizar. El otro caso son las quests de historia que son cinemáticas. Si la oferta actual del NPC tiene una escena asociada, acercarse a él no abre ningún diálogo: la quest se acepta, la escena se reproduce y la recompensa llega cuando el mundo vuelve. El servidor sale antes de mandarle al cliente la orden de abrir la conversación, así que el cliente nunca abre algo que tendría que cerrar de inmediato.
6. El cliente
6.1 Recorrido
El cliente recorre el grafo con una regla simple. Dadas las salidas del vértice actual, si alguna es una respuesta las muestra como botones, si no muestra la primera línea del NPC por prioridad, y si es una rama la atraviesa. Una línea sin respuestas espera a que el jugador avance, y al avanzar el cliente le avisa al servidor qué línea está dejando.
Las acciones tienen dos momentos. Las de un nodo se mandan cuando el nodo aparece en pantalla, que es lo que necesita, por ejemplo, la línea de “gracias, aquí tienes tu pago”. Las de una respuesta se guardan y se mandan cuando el jugador avanza más allá de la respuesta del NPC. Ese orden importa: la herramienta que genera los diálogos de la historia mete un nodo intermedio entre aceptar y completar una quest de conversación, porque sin él la orden de completar llegaba antes que la de aceptar y la quest quedaba activa para siempre y sin pagar.
Una respuesta que revisa una quest es un caso especial. Esa respuesta tiene dos salidas posibles (cumplida o no cumplida) y el cliente no sabe cuál es la correcta, porque eso lo decide el servidor revisando los objetivos. Así que el cliente no recorre el NextNode de esa respuesta y espera a que el servidor le diga a dónde saltar.
6.2 La máquina de escribir
El texto aparece letra por letra cambiando MaxVisibleGraphemes, que indexa grafemas renderizados. Con RichText activado, el largo del texto no es la cantidad de grafemas: <font color="#fff">hi</font> tiene 28 caracteres y dibuja 2. La primera versión iteraba sobre el largo crudo, así que en una línea con color el texto terminaba de aparecer y el bucle seguía corriendo, y sonando, 26 veces más. Ahora el conteo quita las etiquetas, cuenta cada entidad (&) como un solo carácter y después cuenta grafemas con utf8.graphemes, que agrupa un emoji o una letra con tilde combinada como uno solo [2].
Si la línea tiene voz grabada, que se busca por convención en SoundService.Dialogues.VoiceLines.<NPC>.<Nodo>, la velocidad se ajusta para que el texto termine con el audio:
Si no hay voz, cada letra dispara un sonido corto con tono y volumen ligeramente aleatorios, con uniforme en , donde y son atributos de cada NPC. Así cada personaje tiene una voz reconocible sin haber grabado nada.
La máquina de escribir corre en su propio hilo y escribe sobre una etiqueta que comparten todas las líneas de la conversación. Si una línea empezaba mientras la anterior seguía apareciendo, los dos bucles se peleaban la etiqueta: el viejo indexaba un texto que ya no era el suyo, y en pantalla la línea nueva aparecía cortada a la mitad. Peor aún, al terminar, el viejo volvía a procesar las salidas de su nodo, disparaba otra vez sus acciones y mostraba sus respuestas encima de la línea que lo había reemplazado. Ahora cada línea nueva incrementa un contador de generación, cada hilo guarda el valor con el que empezó y se detiene en cuanto deja de coincidir, sin escribir nada y sin completar nada.
7. El servidor también recorre el grafo
7.1 El agujero
La primera versión del servidor aceptaba cualquier nodo y cualquier acción con cualquier payload mientras hubiera alguna conversación abierta. Hablar con el NPC más cercano bastaba para mandar AdvanceObjective y CompleteQuest de cualquier quest activa y cobrar la recompensa. Es el problema de siempre de confiar en el cliente [3], y la solución fue que el servidor recorra el mismo grafo que el cliente, en paralelo.
7.2 Nodos alcanzables
Por cada conversación abierta, el servidor guarda un conjunto de nodos alcanzables. Empieza con el nodo de entrada y crece con las mismas reglas que usa el cliente para mostrar cosas. Mostrar una línea del NPC la agrega a junto con todas sus respuestas, porque aparecen en pantalla con ella. Una rama se atraviesa y se procesan sus hijos.
Con eso, los mensajes del cliente se validan así:
- Avanzar desde un nodo solo vale si y es una línea del NPC. Si vale, sus salidas se agregan a .
- Elegir una respuesta solo vale si y es una respuesta. Si vale, se arman sus acciones y se agregan sus salidas.
- Un salto decidido por el servidor, como el resultado de revisar una quest, pasa por una función
jumpToque agrega el destino a antes de mandarle al cliente la orden de saltar. Sin eso, el recorrido del servidor rechazaría todos los pasos que vienen después del salto.
7.3 Acciones armadas
Las acciones se controlan con contadores. Para cada nodo y cada acción escrita en él, cuenta cuántas veces puede ejecutarse todavía. Mostrar un nodo o elegir una respuesta suma uno, con un tope de 2, y ejecutar la acción resta uno. Una acción de tipo y payload que llega del cliente se acepta solo si
donde construye el payload canónico de la acción escrita en el nodo. El tope existe porque una conversación legítima gasta cada armado casi de inmediato, y un ciclo (una revisión fallida que vuelve al mismo nodo) puede armar la misma acción más de una vez. Con el tope, recorrer un ciclo muchas veces no acumula ejecuciones.
está en un módulo compartido, ActionPayload, y no copiado en el cliente y en el servidor, porque la comparación solo sirve si los dos lados arman el mismo texto. Revisar una quest se codifica como questId|nodoÉxito|nodoFallo. Avanzar un objetivo se codifica como questId|objectiveId, con la cantidad y la isla opcionales al final. Como los campos son posicionales, nombrar una isla obliga a escribir la cantidad aunque sea 1, en vez de inventar un valor que signifique “sin cantidad”.
El resultado es una invariante: toda acción que el servidor ejecuta corresponde a una acción escrita en un nodo que al jugador de verdad se le mostró, con el payload exacto con el que se escribió, y como mucho tantas veces como se armó. El cliente todavía elige entre las opciones legítimas, porque elegir una respuesta es justamente su trabajo, pero ya no puede inventar opciones.
7.4 Quién es el NPC
La conversación se abre solo si el jugador está a menos de studs del NPC (el rango de interacción más una tolerancia por lag), y un jugador sin HumanoidRootPart no puede hablar con nadie, porque si no bastaría con no tener cuerpo para saltarse la distancia. Entre una conversación y la siguiente hay 1.5 s de espera.
Los NPCs se identifican por nombre, y los nombres no son únicos: en los muelles hay cuatro encargados de botes llamados igual, uno por isla. Buscar por nombre devolvía el primero que encontraba la iteración de la tabla, que estaba en otra isla, y la verificación de distancia fallaba en silencio. Era el reporte “aprieto E con el encargado y no pasa nada”. Ahora el cliente manda el modelo además del nombre, y el servidor lo usa solo si está en su registro de NPCs, así que es una pista que se verifica y no algo que se cree.
8. Contenido
Los diálogos base de los NPCs con quests de pesca salen de una herramienta en Python que lee transcripciones por NPC en JSON y genera, en una sola corrida, las definiciones de quests, el diálogo base y la línea de quests de cada uno. Los diálogos de la historia salen de otra herramienta que distingue dos formas de nodo: las quests con trabajo de verdad (pescar algo, buscar algo), que siguen el flujo de ofrecer, aceptar, revisar y completar, y las quests que son la conversación misma, donde aceptar ya cumple el único objetivo. Escribir contenido nuevo es, casi siempre, agregar datos.
9. Limitaciones
Los nodos guardan condiciones (Conditions) que todavía no se evalúan. El enrutamiento condicional que existe vive en el resolvedor del nodo de entrada y en los saltos decididos por el servidor, no dentro del grafo, así que una rama que dependa del estado a mitad de una conversación todavía necesita una acción del servidor.
La unión de capas es por nodo entero, y una capa no puede cambiar solo el texto de un nodo de la base. El formato de payload con barras verticales es ad hoc: un identificador que contenga una barra lo rompería, y nada lo impide más allá de la convención de nombres. Y el tope de dos ejecuciones por acción armada es un número elegido a mano, suficiente para los ciclos que hay hoy.
10. Lo que me dejó
Validar en el servidor no tuvo que ser un sistema aparte con sus propias reglas. Bastó con que el servidor hiciera el mismo recorrido que el cliente, sobre el mismo grafo y con el mismo código para construir los payloads, y el resto salió de comparar. Lo otro que me llevo es lo de las capas: separar lo generado de lo escrito a mano y combinarlos al cargar fue lo que permitió volver a generar contenido sin miedo a perder nada.
Referencias
- A. V. Aho, M. S. Lam, R. Sethi y J. D. Ullman. Compilers: Principles, Techniques, and Tools, 2.ª ed. Addison-Wesley, 2006.
- Unicode Consortium. “Unicode Standard Annex #29: Unicode Text Segmentation”.
- G. Gambetta. “Fast-Paced Multiplayer (Part I): Client-Server Game Architecture”. gabrielgambetta.com.
Este texto de Mastedore. Todos los derechos reservados.