Pular para o conteúdo principal

Primeira batalha

Três chamadas: ler o catálogo, abrir um lobby, preencher. A terceira é a que inicia a batalha.

lib/slotbattle.ts
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á:

app/page.tsx
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

app/api/battles/route.ts
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:

lib/seat-layout.ts
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:

lib/creator-session.ts
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.

A emissão é uma conveniência de avaliação, não o formato de produção

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 de placeholder falha tarde e caro

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_RESOLVEDseat_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 vivo

Aceita 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

app/_components/open-battle-form.tsx
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.

Este passo precisa de credenciais reais de provedor

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

app/_components/open-battle-form.tsx
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.

Assista ao vivo