# Recetas: Ficha en cada tecnología

Código mínimo y completo para los casos más comunes. En todas: `FICHA_URL`, `FICHA_SECRETO` y `FICHA_JUEGO`
vienen del entorno, los montos van en céntimos y el resultado lo decide el servidor. Mientras pruebas, usa
`FICHA_URL=https://ficha-pruebas.bezenti.com`.

Los SDK se descargan de `https://ficha.bezenti.com/sdk/<lenguaje>/` (un archivo cada uno).

## Índice

- [Servidor Node (web, con redirección)](#servidor-node-web-con-redirección)
- [Servidor que verifica una vinculación por código](#servidor-que-verifica-una-vinculación-por-código) (lo usan Unity, Godot y demás)
- [Unity (cliente)](#unity-cliente)
- [Godot 4 (cliente)](#godot-4-cliente)
- [Python (servidor)](#python-servidor)
- [Roblox (script de servidor)](#roblox-script-de-servidor)
- [Bot de Discord (discord.js)](#bot-de-discord-discordjs)
- [Servidor serverless (Cloudflare Workers)](#servidor-serverless-cloudflare-workers)
- [Cobrar, jugar y pagar sin perder dinero](#cobrar-jugar-y-pagar-sin-perder-dinero)

---

## Servidor Node (web, con redirección)

Express y el SDK de JS (`ficha.js`). Registra `https://tujuego.com/ficha/callback` como dirección de regreso.

```js
import express from "express";
import session from "express-session";
import crypto from "node:crypto";
import { Ficha, FichaError } from "./ficha.js";

const { FICHA_URL, FICHA_SECRETO, FICHA_JUEGO, PUBLIC_URL, SESSION_SECRET } = process.env;
const ficha = new Ficha(FICHA_SECRETO, FICHA_URL);
const REGRESO = `${PUBLIC_URL}/ficha/callback`;

const app = express();
app.use(express.json());
app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false }));

// 1. Mandar al jugador a conectar su cuenta.
app.get("/ficha/conectar", (req, res) => {
  req.session.estadoFicha = crypto.randomBytes(16).toString("hex");
  res.redirect(ficha.urlAutorizacion(FICHA_JUEGO, REGRESO, req.session.estadoFicha, 2000));
});

// 2. Vuelve con ?codigo=…&estado=… (o ?error=rechazado).
app.get("/ficha/callback", async (req, res) => {
  if (req.query.error) return res.redirect("/?ficha=rechazado");
  if (!req.query.estado || req.query.estado !== req.session.estadoFicha) return res.status(400).send("Estado inválido");
  delete req.session.estadoFicha;
  const jugador = await ficha.canjearCodigo(String(req.query.codigo), REGRESO);
  req.session.jugadorFicha = jugador.id; // guárdalo también en tu base, junto a tu usuario
  res.redirect("/");
});

// 3. Una partida: cobra la entrada, el servidor decide, paga el premio.
app.post("/partidas", async (req, res) => {
  const jugador = req.session.jugadorFicha;
  if (!jugador) return res.status(401).json({ error: "Conecta tu cuenta de Ficha" });
  const partida = crypto.randomUUID(); // en un juego real: el id de la partida en tu base
  try {
    await ficha.cobrar(jugador, 50, "Entrada a la partida", `mi-juego-${partida}-entrada`);
  } catch (e) {
    if (e instanceof FichaError) return res.status(402).json({ codigo: e.codigo, mensaje: mensajeParaJugador(e) });
    throw e;
  }
  const gano = Math.random() < 0.4; // ← tu lógica real, en el servidor
  if (gano) await ficha.pagar(jugador, 100, "Premio", `mi-juego-${partida}-premio`);
  res.json({ gano });
});

function mensajeParaJugador(e) {
  switch (e.codigo) {
    case "saldo_insuficiente": return "No te alcanza. Recarga en https://ficha.bezenti.com/billetera/recargar";
    case "limite_excedido": return `Llegaste a tu límite diario con este juego (te quedan S/ ${(e.disponible_hoy / 100).toFixed(2)}).`;
    case "jugador_no_vinculado":
    case "autorizacion_revocada": return "Vuelve a conectar tu cuenta de Ficha.";
    default: return e.mensaje;
  }
}

app.listen(3000);
```

Ejemplo completo y probado, sin Express: `https://ficha.bezenti.com/sdk/ejemplos/reflejos/servidor.mjs`.

---

## Servidor que verifica una vinculación por código

Para juegos que no son web. El cliente pide el código (sin secreto), lo muestra, y cuando el jugador lo
aprueba le manda a tu servidor el `codigo_dispositivo`. El servidor **lo consulta él mismo**: así nadie puede
hacerse pasar por otro jugador.

```js
// POST /ficha/vincular   { "codigo_dispositivo": "fdc_…" }   (con la sesión de tu juego)
app.post("/ficha/vincular", async (req, res) => {
  const e = await ficha.estadoVinculacion(String(req.body.codigo_dispositivo));
  if (e.estado !== "aprobada") return res.status(409).json({ estado: e.estado });
  if (e.juego !== FICHA_JUEGO) return res.status(400).json({ error: "Ese código es de otro juego" });
  await db.usuarios.update(req.usuario.id, { jugadorFicha: e.jugador.id });
  res.json({ jugador: e.jugador });
});
```

---

## Unity (cliente)

El cliente **no** lleva el secreto. Pide el código con `UnityWebRequest` (funciona también en WebGL), lo
muestra y se lo pasa a tu servidor.

```csharp
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class ConectarFicha : MonoBehaviour
{
    const string FichaUrl = "https://ficha-pruebas.bezenti.com"; // en producción: https://ficha.bezenti.com
    const string Juego = "mi-juego";
    public TMPro.TMP_Text texto;

    [System.Serializable] class Vinculacion { public string codigo, codigo_dispositivo, url; public int intervalo; }
    [System.Serializable] class Estado { public string estado; }

    public void Conectar() => StartCoroutine(Flujo());

    IEnumerator Flujo()
    {
        using var pedir = UnityWebRequest.Post($"{FichaUrl}/api/v1/vinculaciones",
            "{\"juego\":\"" + Juego + "\",\"limite_diario\":2000}", "application/json"); // Unity 2022.2+
        yield return pedir.SendWebRequest();
        if (pedir.result != UnityWebRequest.Result.Success) { texto.text = "No se pudo conectar con Ficha"; yield break; }
        var v = JsonUtility.FromJson<Vinculacion>(pedir.downloadHandler.text);
        texto.text = $"Entra en {v.url}\ny escribe {v.codigo}";
        Application.OpenURL($"{v.url}?codigo={v.codigo}");

        while (true)
        {
            yield return new WaitForSeconds(v.intervalo);
            using var consulta = UnityWebRequest.Get($"{FichaUrl}/api/v1/vinculaciones/{v.codigo_dispositivo}");
            yield return consulta.SendWebRequest();
            if (consulta.result != UnityWebRequest.Result.Success) continue;
            var e = JsonUtility.FromJson<Estado>(consulta.downloadHandler.text);
            if (e.estado == "pendiente") continue;
            if (e.estado != "aprobada") { texto.text = "Conexión " + e.estado; yield break; }
            // Tu servidor verifica y guarda el jugador (receta anterior). MiServidor es tu propio cliente HTTP.
            yield return MiServidor.Post("/ficha/vincular", "{\"codigo_dispositivo\":\"" + v.codigo_dispositivo + "\"}");
            texto.text = "¡Cuenta conectada!";
            yield break;
        }
    }
}
```

En Unity anterior a 2022.2, crea la petición con `new UnityWebRequest(url, "POST")`, un `UploadHandlerRaw`
con el JSON en UTF-8 y la cabecera `Content-Type: application/json`. Cobros y pagos: en tu servidor (un
backend con el SDK de C#/JS/Python, o un build *Dedicated Server* con `Ficha.cs`).

---

## Godot 4 (cliente)

Con `ficha.gd` (nodo `Ficha`), sin secreto.

```gdscript
extends Control

const FICHA_URL := "https://ficha-pruebas.bezenti.com"
@onready var etiqueta: Label = $Codigo

func conectar() -> void:
	var ficha := Ficha.new("", FICHA_URL)
	add_child(ficha)
	var v := await ficha.vincular("mi-juego", 2000)
	if not v.ok:
		etiqueta.text = "Ficha no responde (%s)" % v.codigo
		return
	etiqueta.text = "Entra en %s y escribe %s" % [v.datos.url, v.datos.codigo]
	OS.shell_open(v.datos.url_directa)
	var j := await ficha.esperar_vinculacion(v.datos.codigo_dispositivo)
	if j.ok:
		# Tu servidor verifica el codigo_dispositivo y guarda el jugador.
		await mi_servidor.post("/ficha/vincular", {"codigo_dispositivo": v.datos.codigo_dispositivo})
		etiqueta.text = "¡Hola, @%s!" % j.datos.usuario
	else:
		etiqueta.text = "No se conectó: %s" % j.codigo
```

Para un servidor dedicado (`godot --headless`): `Ficha.new(OS.get_environment("FICHA_SECRETO"), FICHA_URL)` y
`await ficha.cobrar(id, 50, "Entrada", "mi-juego-%s-entrada" % partida)`. Revisa `r.ok` y `r.codigo`.

---

## Python (servidor)

Con `ficha.py` (solo biblioteca estándar). Ejemplo con Flask:

```python
import os, uuid
from flask import Flask, jsonify, request, session
from ficha import Ficha, FichaError

ficha = Ficha(os.environ["FICHA_SECRETO"], url=os.environ.get("FICHA_URL", "https://ficha-pruebas.bezenti.com"))
JUEGO = os.environ["FICHA_JUEGO"]
app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]

@app.post("/ficha/vincular")
def vincular():
    e = ficha.estado_vinculacion(request.json["codigo_dispositivo"])
    if e["estado"] != "aprobada" or e["juego"] != JUEGO:
        return jsonify(estado=e["estado"]), 409
    session["jugador_ficha"] = e["jugador"]["id"]   # y guárdalo en tu base
    return jsonify(jugador=e["jugador"])

@app.post("/partidas")
def partida():
    jugador = session.get("jugador_ficha")
    if not jugador:
        return jsonify(error="Conecta tu cuenta de Ficha"), 401
    partida_id = uuid.uuid4().hex                     # en un juego real: el id de la partida en tu base
    try:
        ficha.cobrar(jugador, 50, "Entrada a la partida", f"mi-juego-{partida_id}-entrada")
    except FichaError as e:
        return jsonify(codigo=e.codigo, mensaje=e.mensaje), 402
    gano = decidir_resultado(request.json)            # ← tu lógica, en el servidor
    if gano:
        ficha.pagar(jugador, 100, "Premio", f"mi-juego-{partida_id}-premio")
    return jsonify(gano=gano)
```

---

## Roblox (script de servidor)

`HttpService` solo funciona en el servidor. En la configuración de la experiencia: activa **Allow HTTP
Requests** y crea el secreto `ficha` en **Secrets** (con dominio permitido `ficha.bezenti.com`).

```lua
-- ServerScriptService/Ficha.server.lua
local HttpService = game:GetService("HttpService")
local FICHA_URL = "https://ficha.bezenti.com"
local JUEGO = "mi-juego"

local function pedir(metodo, ruta, cuerpo, conSecreto)
	local cabeceras = { ["Content-Type"] = "application/json" }
	if conSecreto then
		cabeceras["Authorization"] = HttpService:GetSecret("ficha"):AddPrefix("Bearer ")
	end
	local r = HttpService:RequestAsync({
		Url = FICHA_URL .. ruta,
		Method = metodo,
		Headers = cabeceras,
		Body = cuerpo and HttpService:JSONEncode(cuerpo) or nil,
	})
	local datos = HttpService:JSONDecode(r.Body)
	if not r.Success then
		return nil, datos.error
	end
	return datos
end

-- Vincular: muestra v.codigo en una GUI del jugador y consulta hasta «aprobada».
local function vincular(jugadorRoblox)
	local v = assert(pedir("POST", "/api/v1/vinculaciones", { juego = JUEGO, limite_diario = 2000 }, false))
	-- (envía v.codigo y v.url al cliente con un RemoteEvent para mostrarlos)
	for _ = 1, 200 do
		task.wait(v.intervalo)
		local e = pedir("GET", "/api/v1/vinculaciones/" .. v.codigo_dispositivo, nil, false)
		if e and e.estado == "aprobada" and e.juego == JUEGO then
			return e.jugador.id -- guárdalo con DataStoreService junto al UserId
		elseif e and e.estado ~= "pendiente" then
			return nil
		end
	end
end

local function cobrar(idFicha, monto, concepto, referencia)
	return pedir("POST", "/api/v1/cobros", { jugador = idFicha, monto = monto, concepto = concepto, referencia = referencia }, true)
end

local function pagar(idFicha, monto, concepto, referencia)
	return pedir("POST", "/api/v1/pagos", { jugador = idFicha, monto = monto, concepto = concepto, referencia = referencia }, true)
end
```

Si `RequestAsync` lanza un error (red caída), repite con la misma referencia.

---

## Bot de Discord (discord.js)

El bot es el servidor: guarda el secreto en su entorno.

```js
import { Client, GatewayIntentBits, MessageFlags } from "discord.js";
import { Ficha } from "./ficha.js";

const ficha = new Ficha(process.env.FICHA_SECRETO, process.env.FICHA_URL);
const JUEGO = process.env.FICHA_JUEGO;
const cliente = new Client({ intents: [GatewayIntentBits.Guilds] });

cliente.on("interactionCreate", async (i) => {
  if (!i.isChatInputCommand()) return;
  if (i.commandName === "conectar") {
    const v = await ficha.vincular(JUEGO, 2000);
    await i.reply({ content: `Entra en ${v.url_directa} para conectar tu cuenta (código ${v.codigo}).`, flags: MessageFlags.Ephemeral });
    try {
      const jugador = await ficha.esperarVinculacion(v.codigo_dispositivo);
      await guardar(i.user.id, jugador.id); // discord_id → id de Ficha
      await i.followUp({ content: `Listo, @${jugador.usuario}.`, flags: MessageFlags.Ephemeral });
    } catch (e) {
      await i.followUp({ content: `No se conectó (${e.codigo}).`, flags: MessageFlags.Ephemeral });
    }
  }
  if (i.commandName === "apostar") {
    const id = await leer(i.user.id);
    const ronda = `${i.id}`; // el id de la interacción es único
    await ficha.cobrar(id, 100, "Ronda de dados", `dados-${ronda}-entrada`);
    // … el bot decide el resultado …
  }
});

cliente.login(process.env.DISCORD_TOKEN);
```

---

## Servidor serverless (Cloudflare Workers)

Para juegos sin backend propio. Guarda el secreto con `wrangler secret put FICHA_SECRETO`. Dos rutas: verificar
la vinculación y jugar una partida. Adapta `decidirResultado` a tu juego (y autentica al jugador con un token
tuyo; aquí se usa un ejemplo simple).

```js
export default {
  async fetch(req, env) {
    const url = new URL(req.url);
    const ficha = (metodo, ruta, cuerpo) =>
      fetch(`${env.FICHA_URL}${ruta}`, {
        method: metodo,
        headers: { Authorization: `Bearer ${env.FICHA_SECRETO}`, "Content-Type": "application/json" },
        body: cuerpo ? JSON.stringify(cuerpo) : undefined,
      }).then(async (r) => ({ ok: r.ok, datos: await r.json() }));

    if (req.method === "POST" && url.pathname === "/vincular") {
      const { codigo_dispositivo, usuario } = await req.json();
      const r = await fetch(`${env.FICHA_URL}/api/v1/vinculaciones/${codigo_dispositivo}`).then((x) => x.json());
      if (r.estado !== "aprobada" || r.juego !== env.FICHA_JUEGO) return Response.json({ estado: r.estado }, { status: 409 });
      await env.KV.put(`jugador:${usuario}`, String(r.jugador.id));
      return Response.json({ jugador: r.jugador });
    }

    if (req.method === "POST" && url.pathname === "/partida") {
      const { usuario, jugada, partida } = await req.json();
      const id = Number(await env.KV.get(`jugador:${usuario}`));
      if (!id) return Response.json({ error: "Conecta tu cuenta de Ficha" }, { status: 401 });
      const cobro = await ficha("POST", "/api/v1/cobros", { jugador: id, monto: 50, concepto: "Entrada", referencia: `mi-juego-${partida}-entrada` });
      if (!cobro.ok) return Response.json(cobro.datos, { status: 402 });
      const gano = decidirResultado(jugada);
      if (gano) await ficha("POST", "/api/v1/pagos", { jugador: id, monto: 100, concepto: "Premio", referencia: `mi-juego-${partida}-premio` });
      return Response.json({ gano });
    }
    return new Response("No encontrado", { status: 404 });
  },
};
```

---

## Cobrar, jugar y pagar sin perder dinero

Patrón para que ningún corte deje a alguien sin su premio ni cobre dos veces:

```js
// 1. Registrar la partida ANTES de tocar dinero.
const partida = await db.partidas.crear({ jugador, estado: "cobrando" });
// 2. Cobrar. Si se corta, repetir con la misma referencia es seguro.
await ficha.cobrar(jugador, ENTRADA, "Entrada", `mi-juego-${partida.id}-entrada`);
await db.partidas.actualizar(partida.id, { estado: "jugando" });
// 3. Jugar y decidir en el servidor.
const premio = calcularPremio(partida);
await db.partidas.actualizar(partida.id, { estado: premio ? "pagando" : "terminada", premio });
// 4. Pagar. Si falla, una tarea periódica reintenta las partidas en «pagando».
if (premio) {
  await ficha.pagar(jugador, premio, "Premio", `mi-juego-${partida.id}-premio`);
  await db.partidas.actualizar(partida.id, { estado: "terminada" });
}

// Tarea cada minuto: termina lo que quedó a medias.
for (const p of await db.partidas.buscar({ estado: "pagando" })) {
  try {
    await ficha.pagar(p.jugador, p.premio, "Premio", `mi-juego-${p.id}-premio`);
    await db.partidas.actualizar(p.id, { estado: "terminada" });
  } catch (e) {
    if (e.codigo !== "banca_insuficiente" && e.codigo !== "error_red") throw e; // los demás son bugs
  }
}
```
