Primeira batalha
Três chamadas: ler o catálogo, abrir um lobby, preencher. A terceira é a que inicia a batalha.
1. O catálogo
export async function listGames(): Promise<Game[]> {
const { games } = await call<{ games: Game[] }>('/games')
return games
}
Renderizado no servidor, então a chave não sai de lá:
export default async function LobbyPage() {
const [games, openBattles] = await Promise.all([listGames(), listBattles('OPEN')])
// …
}
Dois campos exigem cuidado na renderização:
{game.rtp > 0 ? `RTP ${game.rtp}%` : 'RTP unknown'}
rtp: 0 significa que o jogo não tem perfil, não um RTP de zero. Renderize como desconhecido.
status: "planned" significa que existe receita de gravação mas o jogo não foi verificado
ponta a ponta. Ele aparece no catálogo para um operador ver que está vindo; trate como não
pronto para vender.
2. Abra um lobby
const battle = await createBattle({
gameId,
seatsTotal,
entryAmount: '25.50',
currency: 'BRL',
creatorPlayerRef: playerRef,
creatorGameUrl: creatorGameUrl(body.gameUrl, playerRef),
})
Quatro detalhes dessa chamada importam.
entryAmount é string. Representar dinheiro como float introduz erro de arredondamento. O
SlotBattle normaliza o valor, então "25.50" volta como "25.5"; compare valores fazendo
parse como decimal, nunca por igualdade de string.
Não existe tenant_id. Ele vem da chave de API. Enviar um é 400, porque o decoder recusa
campos desconhecidos em vez de ignorá-los.
seatsTotal é de 2 a 8. Qualquer outro valor é 400 com seats_total must be 2..8. A
demo oferece os tamanhos que os jogadores pedem, nomeados por jogadores por lado, então um
2×2 são quatro assentos:
export const SEAT_PRESETS = [
{ label: '1×1', seats: 2, hint: 'Head to head' },
{ label: '2×2', seats: 4, hint: 'Two a side' },
{ label: '3×3', seats: 6, hint: 'Three a side' },
{ label: '4×4', seats: 8, hint: 'Full table — the maximum' },
]
creatorGameUrl decide se a batalha pode começar. A demo resolve isso em três passos,
todos antes de a batalha existir:
export async function resolveCreatorGameUrl(input: {
fromForm?: string
configured?: string
mint: () => Promise<string>
}): Promise<string> {
const typed = input.fromForm?.trim()
if (typed) return typed
const configured = input.configured?.trim()
if (configured) return configured
const minted = (await input.mint()).trim()
if (!minted) {
throw new Error('The demo-session mint returned an empty launch URL.')
}
return minted
}
Informar a URL deixa o assento 0 READY. Omitir deixa o assento 0 FILLED, o que parece
correto mas significa que a mesa nunca fica toda READY, então a batalha nunca começa. A demo
sempre informa uma: o campo Sua URL de jogo do formulário vence, depois
DEMO_CREATOR_GAME_URL, e faltando os dois ela emite uma sessão com
POST /games/{id}/demo-sessions.
Esse último passo é a mesma emissão de provedor que o SlotBattle já faz para assentos de bot,
exposta por HTTP. Custa um escopo próprio, demo:mint.
Em produção a sessão de um jogador humano pertence ao casino: ela sai do mesmo login que
permitiu ao jogador depositar. Bots recebem a deles emitida a partir das credenciais de
provedor do casino porque bot não tem login. O assento 0 é uma pessoa, e é por isso que esta
demo pede uma URL real primeiro e só emite como último recurso, e por isso que a rota fica
atrás do próprio escopo em vez de embutida em battles:write.
Uma chave emitida antes de essa rota existir não carrega demo:mint. Se a emissão responder
403 nomeando o escopo, a chave precisa ser reemitida, pelo console ou pelo seu host.
A demo não tem fallback de placeholder. Quando nenhuma sessão pode ser obtida, ela se recusa a abrir a batalha, com um código de erro próprio:
return Response.json(
{
code: 'creator_session_unavailable',
message: `No game session for your seat: ${detail}`,
},
{ status },
)
Ela não repassa o código do próprio SlotBattle, porque o mesmo status significaria duas coisas
diferentes: unavailable vindo de POST /battles significa que a instância está no limite e
retentar é o conselho certo, enquanto uma emissão que falhou é configuração do lado do host que
esperar não resolve. Mesmo status, código diferente, para o formulário dar o conselho certo.
Uma URL inalcançável, como um placeholder .invalid, não parece falha até ficar cara. A
batalha abre, todo assento de bot grava normalmente, e cerca de um minuto depois a batalha
inteira termina FAILED: o assento 0 não carregou nada (net::ERR_NAME_NOT_RESOLVED →
seat_browser_failed), e a barreira de início exige todos os assentos.
O SlotBattle aponta um browser headless de verdade para qualquer URL que receba. Recusar antes de a batalha existir custa um erro de formulário em vez de uma gravação desperdiçada.
game_url é uma URL de sessão ao vivoAceita na entrada, nunca devolvida. O SlotBattle a criptografa em repouso e nunca a deixa cruzar uma fronteira de processo: nem em resposta, nem em log, nem em evento, nem em webhook. Não a logue do seu lado também.
A resposta, para um 2×2 e portanto quatro assentos:
{
"id": "btl_ClzIJ4ONnmwci6D87J9O",
"status": "OPEN",
"entry_amount": "25.5",
"ws_url": "ws://127.0.0.1:8071/ws?battle_id=btl_ClzIJ4ONnmwci6D87J9O",
"seats": [
{ "seat_ref": "usr_s0", "player_ref": "alice", "status": "READY" },
{ "seat_ref": "usr_s1", "player_ref": "", "status": "EMPTY" },
{ "seat_ref": "usr_s2", "player_ref": "", "status": "EMPTY" },
{ "seat_ref": "usr_s3", "player_ref": "", "status": "EMPTY" }
]
}
O assento 0 está READY porque informamos a URL. Os outros estão EMPTY.
Repare na porta do ws_url. O feed ao vivo roda num listener próprio (8071 por padrão), e
não na porta REST para a qual o seu SLOTBATTLE_BASE_URL aponta. Uma instância sem
SLOTBATTLE_PUBLIC_WS_BASE_URL configurada deriva o ws_url do host da requisição e devolve a
porta REST, onde nada aceita o upgrade. Todo cliente então recebe uma URL que se recusa a
conectar, e nada loga erro. Use o ws_url que a API retorna em vez de montar um.
3. Encha a mesa
const battle = await (await fetch('/api/battles', { /* … */ })).json()
// Fill the rest of the table. With every seat READY, the battle starts by
// itself — there is no start endpoint.
await fetch(`/api/battles/${battle.id}/bot-seats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerRef }),
})
router.push(`/battles/${battle.id}`)
Duas chamadas em vez de uma, porque são dois atos com dois escopos diferentes: battles:write
abre o lobby, bots:write senta os bots.
Um casino real deixaria jogadores entrarem entre essas duas chamadas, que é para isso que um lobby serve. A demo pula direto para os bots para algo acontecer na hora.
Bots recebem sessões demo emitidas pelo backend do provedor, então bot-seats precisa de
credenciais SoftSwiss reais, e não há sandbox. Sem elas essa chamada falha e a batalha fica
OPEN até a janela do lobby expirar.
Todo o resto do tutorial funciona mesmo assim.
Trate os erros que vão realmente acontecer
function explain(code: string, message: string): string {
switch (code) {
case 'game_not_allowed':
return "That game is not in this casino's allowlist. If you just allowed it, wait up to 30 seconds for the cache."
case 'conflict':
return 'That player is already in another active battle. One active battle per player, per casino.'
case 'unavailable':
return 'The instance is at capacity. Try again in a moment.'
case 'forbidden':
return `Refused: ${message}. Your API key may be missing a scope.`
default:
return `${code}: ${message}`
}
}
Ramifique pelo code, nunca pela message. Códigos são estáveis; mensagens podem ser
reescritas.
Desses, só unavailable vale retentar. Os outros são determinísticos: retentar um conflict
produz outro conflict.