[#_lesson_28_online] == Lección 28: En línea image::learn/28-online.png[La lección terminada, 480] En la lección doce guardaste un récord. Sigue ahí a la mañana siguiente, que es casi todo para lo que sirve un récord, pero es un récord de una sola máquina. Esta lección pone una puntuación en una tabla de récords que otras personas pueden ver. También es la lección sobre los fallos. Todo lo demás que has escrito falla por motivos que puedes encontrar: falta un archivo, un número está mal, una función está mal escrita. La red falla por motivos que nadie en la habitación puede ver. Alguien desenchufó un cable en un edificio en el que nunca has estado, y tu juego tiene que seguir funcionando. Casi todo lo que viene ahora trata de eso. [#_what_the_service_is] === Qué es el servicio Singe puede hablar con un *servicio maestro*, un servidor que guarda tres cosas: * una *cuenta*, que pertenece a la máquina y no a ningún juego en concreto; * un *catálogo* de juegos para descargar y actualizar; * *tablas de récords*, una o más por juego. El servicio por defecto es `master.singeengine.com`, y un operador que tenga el suyo propio apunta la máquina a otro sitio. La sección del manual sobre el servicio en línea describe las dos partes. Ahora la parte que la gente se salta. El servicio *no* es multijugador: no hay forma de que dos jugadores estén en la misma partida. No es una nube de partidas guardadas; la partida guardada de la lección doce se queda en la máquina. No es una protección anticopia, y nada en él impide que nadie juegue a tu juego. No es obligatorio: un juego que nunca inicia sesión funciona exactamente igual que siempre, que es el único valor por defecto sensato para algo que necesita una conexión que funcione. Así que la tabla de récords es un extra. Conserva el récord local de la lección doce, sigue mostrándolo y deja que la tabla en línea sea eso que a veces está. [#_signing_in_happens_once_and_not_in_your_game] === La sesión se inicia una vez, y no en tu juego La cuenta pertenece a la máquina. Se crea, y se inicia sesión con ella, desde el menú incluido, detrás de la tecla asignada a `INPUT_SERVICE`, en la herramienta llamada *Online Account*. El token que obtiene se escribe en `master.dat` en la raíz de datos, un nivel por encima de la carpeta de datos de cada juego, así que todos los juegos de la máquina publican sus puntuaciones con la misma sesión. Tu juego nunca pide una dirección de correo electrónico y nunca pide una contraseña. No hay ninguna función para hacerlo, y es a propósito: un jugador que escribe una contraseña en un juego que ha descargado no tiene forma de saber adónde va. Lo que aparece de un jugador en una tabla de récords es un *alias*: de tres a veinte caracteres entre letras, dígitos, guiones y guiones bajos, empezando y terminando con una letra o un dígito. Sin espacios, sin puntos y sin `@`, así que un alias ni siquiera puede parecer una dirección de correo electrónico. Se guarda aparte de la cuenta, es lo único de un jugador que ven los demás jugadores, y se elige una vez -- un operador puede cambiarlo, un jugador no, porque una tabla de récords cuyos nombres no paran de cambiar no es realmente la historia de nadie. Hasta que se ha elegido un alias, las puntuaciones se rechazan, ya que una puntuación sin el nombre de nadie no está en una tabla de récords en ningún sentido útil. La herramienta Online Account muestra el nombre con el que publica esta máquina, y tu juego lo lee con `scorePlayerName()`, que responde `nil` cuando no hay ninguno. Conviene saberlo antes de que te desconcierte: una puntuación enviada sin alias detrás la rechaza el *servicio*, no `scoreSubmit`, así que a tu juego no se le dice nada. Si `scorePlayerName()` es `nil`, dilo en pantalla mientras todavía haya alguien delante para leerlo. [#_your_game_needs_an_id] === Tu juego necesita un identificador Una tabla de récords tiene que guardarse bajo algo. Ese algo es `GAME_ID` en la entrada de tu juego en `games.dat`, el archivo que hiciste en la lección trece: [source,lua] ---- GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10" ---- Es un UUID, exactamente con esa forma: ocho dígitos hexadecimales, tres grupos de cuatro y luego doce, separados por guiones. Genera uno como quieras y pégalo; no importa cuál te salga, solo que no cambie nunca más. La entrada de la referencia para `singeGetGameId` explica por qué no se acepta nada más. `singeGetGameId()` lo devuelve, o `nil` cuando la entrada no tiene ninguno. Un juego sin identificador se puede jugar pero no puede entrar en ninguna clasificación, y preguntar primero es la forma de no ofrecer una tabla de récords que no puede funcionar nunca: [source,lua] ---- if singeGetGameId() then -- showLeaderboardButton es una función tuya, no una de las de Singe. showLeaderboardButton() end ---- [#_the_six_calls] === Las seis llamadas Todo lo que hace un juego con el servicio son seis funciones, y vienen de `Singe/Framework.singe`, así que aquí el `dofile` del principio de tu script no es opcional: [source,lua] ---- dofile("Singe/Framework.singe") ---- `scoreBegin()` dice que empieza una partida. `scoreSubmit(value)` pone una puntuación en la cola. `scoreBoard(onDone)` trae la tabla de récords. `scorePlayerName()` es el alias. `scoreWaiting()` es cuántas puntuaciones no han salido todavía. Y `scoreUpdate()` tiene que ejecutarse una vez por fotograma, desde `onOverlayUpdate`, o ninguna de las demás termina nunca. Esa última es todo el truco de esta lección, así que se dice dos veces. Nada de esto habla con la red cuando lo llamas. `scoreSubmit` escribe en una cola y vuelve enseguida. `scoreBoard` recuerda lo que quieres y vuelve enseguida. El trabajo de verdad se hace a trocitos dentro de `scoreUpdate`, entre tus fotogramas, y por eso tu juego nunca da tirones mientras la conexión va lenta. Quita `scoreUpdate` y nada se rompe, nada da error, y nunca pasa nada. [source,lua] ---- function onOverlayUpdate() scoreUpdate() overlayClear() return OVERLAY_UPDATED end ---- [#_posting_a_score] === Publicar una puntuación Al final de una partida: [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- `scoreSubmit` responde `false` cuando no hay cuenta o el juego no tiene `GAME_ID`, y `true` cuando la puntuación ha entrado en la cola. Fíjate en lo que *no* hace el `else`: no tira la puntuación, no se deshace en disculpas y no impide que el jugador empiece otra partida. Dice una línea corta, porque el jugador no ha hecho nada mal y no puede arreglarlo desde aquí. La cola es la razón de que esto sea seguro. Una puntuación en la cola se escribe en disco, así que sobrevive a que se apague la máquina, y se entrega al servicio la próxima vez que haya conexión -- una hora después, o el martes que viene. Por eso una recreativa en un local con un wifi malo acaba igualmente en la tabla de récords. Sé sincero contigo mismo sobre el límite de esa promesa: la cola guarda puntuaciones de una máquina que *tiene* cuenta. Si nadie inició sesión nunca, `scoreSubmit` responde `false` y la puntuación no se guarda para más tarde, porque no hay nadie para quien guardarla. `scoreWaiting()` te dice cuántas quedan en la cola, y mostrar ese número en algún sitio es un detalle: es la diferencia entre «el servicio está roto» y «tres de tus puntuaciones están esperando a que vuelva internet». `scoreBegin()` merece un párrafo propio. Llámala cuando empiece una partida, y el servicio apunta el momento según su propio reloj. La puntuación que venga después lleva cuánto duró la partida, medido en el servidor, que es el único número de un envío que no ha pasado por la máquina del jugador. No se rechaza nada por él: se le muestra a quien gestiona el servicio, junto a la puntuación, y decide una persona. Una máquina que estaba sin conexión cuando empezó la partida no registra ningún tiempo, lo cual es sincero, no un fallo. Y ya que estamos siendo sinceros: una puntuación enviada es una *afirmación*. El juego se ejecuta en el ordenador del jugador, así que nada que pueda hacer el cliente la convierte en algo más, y una clave secreta incluida en tu juego solo aparentaría seguridad. El servicio registra quién afirmó qué, limita lo deprisa que pueden llegar las puntuaciones y marca las disparatadas para que las mire una persona. Diseña tu tabla de récords sabiéndolo. [#_reading_the_board_back] === Leer la tabla de récords `scoreBoard` es la primera función de este libro que recibe una función como argumento. [source,lua] ---- scoreBoard(function(ok, result) ... end) ---- Escribiste funciones en la lección cinco y les diste nombre. Esta no tiene nombre: se escribe donde se usa, se le pasa directamente a `scoreBoard` y se llama más tarde -- quizá segundos más tarde -- cuando llega la respuesta. Eso es otra vez un *callback*, la misma idea que `onOverlayUpdate`, salvo que esta vez eliges tú quién lo llama. `ok` es `true` o `false`. Cuando es `false`, `result` es una cadena corta que dice por qué. Cuando es `true`, `result` es una tabla con dos partes: * `result.top`, una lista de filas, cada una con un `name` y un `value`; * `result.standing`, el `value` y el `rank` propios de este jugador y el número de `players` de la tabla de récords -- que puede faltar, si este jugador no tiene nada en ella. Entre la llamada y el callback tu juego sigue funcionando y dibujando. Ese hueco es real y el jugador lo ve, así que pon algo en él. El script de abajo pone una línea de texto en `"Asking the service..."` antes de llamar, y el callback sustituye esa línea por la tabla de récords o por el motivo de que no la haya. Una pantalla que no dice nada durante cuatro segundos parece rota; una pantalla que dice lo que está haciendo solo es lenta. [#_the_catalogue] === El catálogo Lo tercero que hace el servicio es listar juegos. La herramienta *Get Games* del menú muestra todo lo que ofrece el servicio, marca lo que ya está instalado en esta máquina y lo descarga, lo actualiza o lo quita. Una descarga se comprueba con el hash que publicó el catálogo antes de sustituir nada, así que una transferencia que llega dañada falla en lugar de instalar un juego que no funcionará. Un juego que el servicio ha retirado sigue funcionando y aparece como instalado pero ya no ofrecido: lo único que deja de pasar es que reciba actualizaciones, porque quien tiene un juego lo tiene. Tu juego no llama a nada de esto, y no debería. El catálogo te importa como autor de un juego más que como escritor de un script: es adonde va tu archivo `.game` cuando lo publicas, que es la lección treinta, y es otra razón por la que tu `games.dat` necesita un `GAME_ID` que no cambie nunca. Si alguna vez quieres la lista desde dentro de un script, `Singe/Master.singe` es el módulo que hay debajo de todo esto y merece la pena leerlo una vez. [#_what_the_service_knows_about_a_player] === Lo que el servicio sabe de un jugador Si publicas un juego que envía puntuaciones, le estás pasando datos de otra persona a un servidor, y eso es responsabilidad tuya aunque no hayas escrito el servidor. Así que dilo claramente, en la pantalla de información o en el léeme de tu propio juego: * El servicio guarda una *dirección de correo electrónico* y una contraseña para la cuenta. La dirección sirve para recuperar una contraseña perdida y para nada más. Nunca se muestra a otros jugadores y no se puede cambiar después. * El *alias* es lo que aparece en las tablas de récords. Se mantiene aparte de la cuenta a propósito para que una dirección no sea nunca lo que ven los demás jugadores, y se comprueba contra una lista de nombres que nadie debería reclamar y de palabras que no pintan nada en una pantalla en un local público. * Una *puntuación* lleva su valor, la tabla de récords en la que está, la hora que registró el servicio y el alias. Nada más. * El catálogo sabe *qué juegos están instalados* en la máquina, y qué versión, porque así es como te ofrece una actualización. Lo que no hace nada de esto es seguir a un jugador de un juego a otro, vender nada ni meterse en la máquina a buscar algo que tú no hayas enviado. Tu juego no debería añadir nada a la lista. Si adjuntas información extra a una puntuación -- `scoreSubmit` acepta un tercer argumento opcional para eso -- que trate del juego. [#_the_script] === El script Una prueba de reflejos: espera a la palabra, pulsa el botón y puntúa lo que quede de mil. Es un juego pequeño, y el resto es la parte en línea. Crea una carpeta llamada `reaction` y pon esto en `reaction.singe`, dentro de ella. Dale a la carpeta un `games.dat` como el de la lección trece, que nombre `reaction/reaction.singe`, con la línea `GAME_ID` de antes en su entrada. [source,lua] ---- dofile("Singe/Framework.singe") local WAIT_MIN = 1000 local WAIT_MAX = 3000 local PERFECT = 1000 local state = "attract" local goAt = 0 local score = 0 local best = saveGet("best", 0) local saying = "Button 1 to play." local top = nil local standing = nil local function whyNotPosted() if not singeGetGameId() then return "this game has no GAME_ID." end return "this machine is not signed in." end local function fetchBoard() top = nil standing = nil saying = "Asking the service..." scoreBoard(function(ok, result) if not ok then saying = "No board: " .. tostring(result) return end top = result.top standing = result.standing if #top == 0 then saying = "The board is empty. You could be first." else saying = "" end end) end local function finish(reaction) score = PERFECT - reaction if score < 0 then score = 0 end if score > best then best = score saveSet("best", best) end state = "result" if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end end local function play() state = "waiting" goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX) score = 0 top = nil standing = nil saying = "" scoreBegin() end function onInputPressed(what) if what ~= SWITCH_BUTTON1 then return end if state == "waiting" then state = "result" score = 0 saying = "Too soon. Nothing posted." elseif state == "go" then finish(singeGetTicks() - goAt) else play() end end function onOverlayUpdate() scoreUpdate() if state == "waiting" and singeGetTicks() >= goAt then state = "go" end overlayClear() overlayPrint(2, 0, "REACTION TEST") if state == "attract" then overlayPrint(2, 2, "Button 1 to play.") elseif state == "waiting" then overlayPrint(2, 2, "Wait for it...") elseif state == "go" then overlayPrint(2, 2, "NOW!") else overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.") end overlayPrint(2, 4, "Best on this machine: " .. best) overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet")) overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) overlayPrint(2, 8, saying) if top then for place, row in ipairs(top) do if place > 5 then break end overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value) end end if standing then overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".") end return OVERLAY_UPDATED end ---- Ejecútalo con `Singe -R reaction`. En una máquina que nunca ha iniciado sesión, se juega, guarda tu mejor puntuación y dice `Kept here only: this machine is not signed in.` Esa es la ejecución importante, y es la que tendrán la mayoría de tus jugadores. [#_what_just_happened_23] === Qué acaba de pasar [source,lua] ---- local top = nil local standing = nil ---- Dos variables que son `nil` casi todo el tiempo. `nil` significa «aquí no hay nada», y es lo que contiene una variable antes de que se meta nada en ella. Estas guardan la tabla de récords cuando la hay, y `nil` cuando no: mientras la petición está en camino, después de un fallo y antes de la primera partida. El código de dibujo pregunta `if top then` y no dibuja nada cuando no hay nada, que es la forma de casi cualquier trozo de código de red que escribas. [source,lua] ---- saying = "Asking the service..." scoreBoard(function(ok, result) ---- El orden importa. La línea de texto se pone *antes* de la llamada, porque la llamada vuelve enseguida y el callback puede tardar segundos en ejecutarse. Si pones el texto solo dentro del callback, la pantalla muestra el mensaje de la partida anterior hasta que llega la respuesta, que es exactamente el mensaje equivocado en exactamente el momento equivocado. [source,lua] ---- if not ok then saying = "No board: " .. tostring(result) return end ---- El fallo va primero, y es corto. `tostring` convierte lo que haya vuelto en texto para que unirlo con `..` no pueda fallar; `result` es una cadena cuando `ok` es false, pero una línea de código de dibujo que se fía del tipo de algo que le ha llegado de un servidor es una línea esperando a romperse. El jugador ve una frase y el juego sigue. [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- Que `scoreSubmit` responda `false` no es un error. Es un hecho sobre esta máquina, y merece la pena distinguir los dos hechos que puede significar, que es lo que hace `whyNotPosted`: que falte `GAME_ID` es error *tuyo*, y una máquina sin sesión iniciada no es error de nadie. La mejor puntuación local ya se guardó, dos líneas más arriba, antes de intentar nada de esto. Primero guarda, luego publica, siempre. [source,lua] ---- overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) ---- La cola, en pantalla. En una máquina con conexión esto marca `0` para siempre y nadie se fija. En una máquina sin ella sube, y el jugador sabe que el juego le está guardando las puntuaciones en lugar de comérselas. [source,lua] ---- function onOverlayUpdate() scoreUpdate() ---- La primera línea del fotograma, antes que nada. Ponla en algún sitio donde un `if` no pueda saltársela, porque un `scoreUpdate` que solo se ejecuta en la pantalla de fin de partida es una cola que solo se vacía en la pantalla de fin de partida. [#_try_it_28] === Pruébalo . *Desenchúfalo.* Desconecta la máquina de la red, juega unas cuantas partidas y mira el número `Waiting to send`. Vuelve a conectarla y mira cómo baja sin que hagas nada. . *Borra el identificador.* Quita `GAME_ID` de tu `games.dat` y vuelve a ejecutarlo. El mensaje cambia. Vuelve a ponerlo. . *Muestra más de la tabla de récords.* El bucle se para en cinco filas. El overlay mide dieciocho filas de caracteres de alto por defecto, así que calcula cuántas te caben de verdad, y mueve la línea de `standing` si hace falta. . *Ponle nombre a la tabla.* `scoreSubmit(score, "reaction")` y `scoreBoard(onDone, "reaction")` usan una tabla de récords con ese nombre en lugar de la que lleva el nombre de tu script. Publica en dos tablas desde el mismo juego -- por ejemplo, una para la puntuación y otra para la reacción más rápida -- y deja que el botón 2 cambie cuál se muestra. . *Haz visible la espera.* Entre la llamada a `scoreBoard` y la ejecución del callback, dibuja un punto que se mueva. Luego averigua cuánto dura eso de verdad con tu conexión, y si merecía la pena dibujarlo. [#_break_it_on_purpose_27] === Rómpelo a propósito Quita la primera línea de `onOverlayUpdate`, para que desaparezca la llamada a `scoreUpdate()`, y juega una partida. No pasa nada. Ni error, ni mensaje, ni tabla de récords, y el número `Waiting to send` sube y se queda arriba para siempre. El juego funciona perfectamente. Este es el peor tipo de fallo y merece la pena encontrárselo una vez a propósito: un paso que falta y que produce silencio en lugar de una queja. No hay número de línea que mirar, porque no ha ido nada mal -- pediste trabajo y luego nunca le diste ocasión de hacerse. Cuando algo de Singe que recibe un callback no te devuelve nunca la llamada, la primera pregunta es siempre si lo que lo mueve se está ejecutando en cada fotograma. El otro que te encontrarás hace más ruido. Vuelve a poner `scoreUpdate()`, borra la línea `dofile` del principio y ejecútalo: ---- Error executing function 'onOverlayUpdate': reaction/reaction.singe:89: attempt to call a nil value (global 'scoreUpdate') stack traceback: reaction/reaction.singe:89: in function 'onOverlayUpdate' ---- La línea 89 es el `scoreUpdate()` que volviste a poner, una línea más arriba ahora que el `dofile` ya no está. Lee la primera línea como siempre: la función a la que estaba llamando el motor, el archivo, la línea y la queja. `nil` otra vez -- el nombre `scoreUpdate` no tiene nada dentro, porque el archivo que lo define nunca se cargó. Las seis llamadas viven en `Singe/Framework.singe`. [#_what_you_learned_28] === Lo que has aprendido * El servicio guarda una cuenta, un catálogo y tablas de récords. No es multijugador, no es una nube de partidas guardadas y nunca es obligatorio. * La cuenta pertenece a la máquina y se inicia sesión con ella desde las herramientas de servicio del menú. Un juego nunca pide una contraseña. * Una tabla de récords se guarda bajo el `GAME_ID` de `games.dat`, un UUID que no debe cambiar nunca. `singeGetGameId()` lo lee. * `scoreSubmit` pone una puntuación en la cola en disco y vuelve enseguida; la cola sobrevive a que se apague la máquina. * `scoreUpdate()` tiene que ejecutarse en cada fotograma o nada de lo que hace el servicio termina nunca. * `scoreBoard` le da su respuesta a una función que escribes tú, que se ejecuta más tarde. Dibuja algo mientras tanto. * Cualquier llamada de red puede fallar, y fallar no es un error. Di una línea corta y sigue jugando. * Una puntuación publicada es una afirmación, y el servicio la trata como tal. * Conserva el récord local. Es el que siempre funciona. [#_next_time_28] === La próxima vez Un récord es un número, y la partida guardada de la lección doce es el sitio adecuado para él. La lección veintinueve es para todo lo demás que un juego quiere guardar: cada ronda que juega cualquiera, las fases que incluyes en el juego y las preguntas que quieres hacerles a las dos cosas. Coge esta prueba de reflejos y le da una base de datos.