Tres ofertas en las que todos los servidores tienen que estar de acuerdo.
Advertencia
Sword Masters quedó abandonado en octubre de 2024, y la mayoría de sus animaciones, sonidos e imágenes son del grupo dueño del juego, así que no se puede correr como antes y este sistema no se puede mostrar en video.
Resumen. La tienda de habilidades de Sword Masters no vende todo el catálogo a la vez. Muestra tres ofertas sorteadas por rareza, y esas tres cambian según un reloj fijo, con una cuenta regresiva en pantalla. El sorteo y el bucle del reloj llegaron con el código que heredé, y el bucle a su vez partió de un tutorial del DevForum para tiendas diarias. Yo lo adapté al juego nuevo y agregué la parte que mantiene a todos los servidores en la misma oferta: la oferta actual se guarda en un DataStore, y cuando un servidor la rota, anuncia la nueva por MessagingService. Este artículo recorre el sorteo y sus probabilidades reales (la Legendary sale tan seguido como la Rare), cómo se ponen de acuerdo los servidores y dónde pueden quedar en desacuerdo, el reloj y un desajuste entre cada cuánto cambia la oferta y lo que dice la cuenta regresiva, y lo que la tienda nunca llegó a hacer.
1. El problema
Se pueden comprar cinco habilidades: Superjump, Stomp, Burger, Fireball y Laser beam, una por rareza de Common a Legendary. La tienda muestra tres. Un juego de Roblox corre en muchos servidores a la vez que no comparten memoria, y los jugadores pasan de uno a otro: si la tienda muestra una cosa en un servidor y otra en otro, el jugador lo nota enseguida. Cuando la oferta cambia, tiene que cambiar en todos los servidores al mismo tiempo.
2. El sorteo
2.1 La rareza
La oferta se sortea en dos pasos: primero una rareza y después una habilidad de esa rareza. Los pesos están en Settings.Abilities.SpecialOffersChances:
| Rareza | Peso |
|---|---|
| Common | 40 |
| Uncommon | 20 |
| Rare | 10 |
| Epic | 10 |
| Legendary | 10 |
El selector, Randomizer.Pick, saca un entero uniforme entre 1 y (el factor de un millón está para admitir pesos con hasta seis decimales) y recorre los elementos restando cada peso hasta que cae dentro de uno. Asume que los pesos suman 100. Estos suman 90, así que en el 10% de los sorteos no cae en ningún elemento y la función devuelve nil. Quien la llama repite hasta conseguir una rareza:
repeat
rar = Randomizer.Pick(ServerSettings.Abilities.SpecialOffersChances)
until rar ~= nil
Eso es muestreo por rechazo [1], y renormaliza los pesos: la probabilidad de cada rareza en un sorteo válido es .
| Rareza | Habilidad | |
|---|---|---|
| Common | Superjump | 44.4% |
| Uncommon | Stomp | 22.2% |
| Rare | Burger | 11.1% |
| Epic | Fireball | 11.1% |
| Legendary | Laser beam | 11.1% |
El orden en que pairs recorre la tabla decide qué intervalo le toca a cada rareza, pero no su tamaño, así que no cambia las probabilidades.
2.2 Tres distintas
Cada rareza tiene exactamente una habilidad en la lista de la tienda, así que la rareza decide la habilidad. Para llenar tres espacios, el código sortea y descarta cualquier habilidad que ya esté en la oferta, hasta tener tres distintas. Rechazar los repetidos es lo mismo que sortear sin reemplazo en proporción a los pesos. Sumando sobre los órdenes en que pueden salir tres rarezas distintas, la probabilidad de que cada habilidad aparezca en una oferta es
| Habilidad | |
|---|---|
| Superjump | |
| Stomp | |
| Burger | |
| Fireball | |
| Laser beam |
El número interesante es el último. Laser beam, la Legendary y la más cara, sale en casi la mitad de las rotaciones, exactamente tan seguido como Burger, que es Rare. En esta tienda la rareza es solo un peso, y Rare, Epic y Legendary pesan lo mismo. Superjump y Stomp aparecen juntas en el 64% de las ofertas, y el tercer espacio es un sorteo parejo entre las otras tres.
El costo de los dos rechazos es pequeño. Una oferta necesita en promedio 4.725 sorteos válidos (contando los repetidos que se descartan), y con el 10% de nil, llamadas al selector.
3. Una oferta para todos los servidores
3.1 El estado guardado
La oferta actual es una tabla pequeña, {Time, abilities}, guardada en el DataStore AbilitiesOffer bajo una sola clave. Cuando un servidor arranca, la lee, con un reintento si la primera lectura falla. Si no hay nada guardado (la primera vez que el juego corre), sortea una oferta y la guarda. El cliente pide la oferta al entrar y otra vez cada vez que cambia su inventario, para que se actualicen las marcas de “comprada”.
3.2 Rotar
Cuando un servidor entra a un período nuevo, hace esto:
- Lee la oferta guardada.
- Si contiene alguna habilidad que no está en su propia oferta, asume que otro servidor ya rotó y la adopta.
- Si no, sortea una nueva, la guarda con
SetAsyncy la publica en el temaabsOfferconMessagingService.
Todos los servidores están suscritos a ese tema, y cuando llega un mensaje adoptan la oferta y se la mandan a sus clientes. La idea es que el primer servidor que cruza el límite rote, y los demás encuentren el trabajo hecho al leer.
3.3 Dónde quedan en desacuerdo
El paso 1 y el paso 3 no son atómicos. Si dos servidores leen antes de que cualquiera de los dos escriba, los dos ven la oferta vieja, los dos sortean, los dos guardan y los dos publican. Cada servidor recibe los dos mensajes, el suyo incluido, y se queda con el último que le llega. MessagingService no garantiza el orden de entrega [3], y los mensajes de servidores distintos no tienen un orden global de todas formas [4], así que dos servidores pueden quedar con ofertas distintas hasta la siguiente rotación.
Lo que lo empeora es que no es un caso raro. Todos los servidores calculan el límite con el mismo reloj sincronizado (sección 4), así que todos lo cruzan con diferencia de más o menos un segundo, y con varios servidores corriendo la colisión es el caso normal.
Hay dos arreglos, y el segundo es el mejor. El primero es hacer la lectura y la escritura en un solo UpdateAsync [2], cuya función de transformación solo sortea si la oferta guardada es de un período anterior. El DataStore vuelve a correr la transformación cuando otro servidor escribió en el medio, así que sobrevive exactamente una oferta por período. El segundo es no coordinar nada: si la oferta se sortea con un Random sembrado con el índice del período, cada servidor calcula la misma oferta por su cuenta y no hace falta guardar ni anunciar nada. La versión anterior del juego hacía justo eso en su tienda de espadas y skins, con Random.new(day).
La revisión de adopción también tiene una rareza. Compara por pertenencia, así que si un sorteo nuevo da justo las mismas tres habilidades que la oferta anterior, el servidor no puede distinguir “alguien rotó a lo mismo” de “nadie rotó” y vuelve a rotar. Pasa más de lo que parece: la probabilidad de que dos ofertas seguidas sean el mismo conjunto es .
4. El reloj
4.1 De dónde sale la hora
Los servidores no confían en su propio reloj. TimeSync le hace una petición a google.com, lee el encabezado Date y corrige la latencia suponiendo que la respuesta tardó la mitad del viaje de ida y vuelta en llegar. Es el algoritmo de Cristian [5], con resolución de un segundo, porque esa es la precisión del encabezado. Si la petición falla, usa os.time().
4.2 El período
El bucle corre cada segundo y calcula dos cosas a partir de la hora : el índice del período actual y la cuenta regresiva.
local day = math.floor((syncedtime.time() + offset) / (3600)) * 4
local t = (math.floor(syncedtime.time()) / 4) - offset
local daypass = (t) % (3600)
local timeleft = (3600 * 4) - (daypass * 4)
Con offset = 0, la cuenta regresiva se simplifica a
una cuenta de cuatro horas, que coincide con la configuración (ResetInterval = 4 y la lista de horas UTC de renovación 0, 4, 8, 12, 16, 20). Pero el índice es
que cambia cada hora. La oferta, entonces, rota cada hora, mientras la cuenta en pantalla dice que va a rotar en hasta cuatro, y solo llega a cero una de cada cuatro rotaciones. El factor 4 terminó multiplicando al índice en vez de dividir al tiempo. El índice correcto es . El ResetTimeUTC = -2 configurado tampoco se lee, así que los períodos empiezan en horas UTC.
4.3 La cuenta en pantalla
El servidor escribe la cuenta regresiva en formato HH:MM:SS en un StringValue replicado, y el cliente la copia a la etiqueta cada segundo, mostrando “Refreshing…” cuando lee 00:00:00. Es una forma barata de que todos los jugadores del servidor vean la misma cuenta, sin un reloj en cada cliente.
5. Lo que la tienda nunca llegó a hacer
Comprar no funciona en el servidor. El cliente pide confirmación, compara el precio contra una estadística de monedas del formato de datos viejo, dispara el remote Buy y muestra “comprada” o “no te alcanzan las monedas” según su propia comparación, pero el handler Buy del servidor está vacío, así que no se cobra nada ni se agrega nada al inventario. La tienda de espadas y skins de la versión anterior tampoco se portó: el ShopService de la reescritura tiene un RandomSkin vacío. Y hay una pequeña fuga: cada rotación crea tres StringValue en una carpeta y limpia otra distinta, así que la primera carpeta crece tres entradas cada hora.
6. Lo que me dejó
Lo que aprendí aquí sirve para cualquier sistema con muchos servidores: antes de coordinar, revisar si el resultado se puede derivar. Una oferta que es función pura del índice del período no necesita un DataStore, ni mensajes, ni una revisión de “quién rotó primero”, y no puede estar en desacuerdo consigo misma. Cuando coordinar es inevitable, la lectura y la escritura tienen que ser una sola operación. Y el bug del reloj me enseñó a escribir los períodos con sus unidades en el nombre, PERIOD_SECONDS = 4 * 3600, para que el 4 no pueda terminar del lado equivocado de una división.
Referencias
- J. von Neumann. “Various Techniques Used in Connection with Random Digits”. National Bureau of Standards Applied Mathematics Series, 12, 1951.
- Roblox Creator Documentation. “Data stores” (
GlobalDataStore:UpdateAsync). - Roblox Creator Documentation. “Cross-server messaging” (
MessagingService). - L. Lamport. “Time, Clocks, and the Ordering of Events in a Distributed System”. Communications of the ACM, 21(7), 1978.
- F. Cristian. “Probabilistic Clock Synchronization”. Distributed Computing, 3(3), 1989.
Este texto de Mastedore. Todos los derechos reservados.