Skip to content

Sistema Event Shoot - "Atire na Caixa"

Visao Geral

O "Atire na Caixa" e um mini-game vinculado ao sistema de eventos do ProCases. O jogador escolhe quantos tickets de evento deseja gastar (entre 250 e 5000) e recebe um item aleatorio. Quanto mais tickets gastos, maior a faixa de valor dos itens disputados: o pool e montado dinamicamente a partir da tabela Item no momento do tiro (nao ha pool fixo). O resultado e determinado pelo sistema Provably Fair com range de 1 a 10.000.000.

Alem do item ganho, o backend retorna 2 itens alternativos para a animacao do frontend (os "quase acertei"), garantindo que nunca sejam o mesmo item ganho nem duplicados entre si.


Arquitetura de Arquivos

CamadaArquivoResponsabilidade
Presentationsrc/presentation/controllers/event-shoot.controller.tsController HTTP, rota e guards
Presentationsrc/presentation/modules/event-shoot.module.tsModulo NestJS, DI providers
Applicationsrc/application/use-cases/event-shoot/perform-event-shoot.use-case.tsLogica de negocio completa
Applicationsrc/config/business-limits.config.tsEVENT_LIMITS (custo min/max, multiplicadores de valor)
Domainsrc/domain/repositories/event-shoot.repository.interface.tsInterface do repositorio
Domainsrc/domain/repositories/item.repository.interface.tsfindAll filtrado por valueCentsBrl (fonte do pool dinamico)
Infrastructuresrc/infrastructure/database/repositories/event-shoot.repository.tsImplementacao Prisma
Frontendclient/app/evento/page.tsxPagina do evento

Constantes (EVENT_LIMITS)

Arquivo: src/config/business-limits.config.ts

typescript
export const EVENT_LIMITS = {
  MIN_SHOOT_COST: 250,        // custo minimo por tiro (tickets)
  MAX_SHOOT_COST: 5000,       // custo maximo por tiro (tickets)
  VALUE_MIN_MULTIPLIER: 30,   // piso da faixa BRL = ratio * 30 (centavos)
  VALUE_MAX_MULTIPLIER: 300,  // teto da faixa BRL = ratio * 300 (centavos)
};

O ratio e definido como ticketsToSpend / MIN_SHOOT_COST (i.e. ticketsToSpend / 250).


Endpoint

POST /event-shoot/shoot

Autenticacao: Requer AuthGuard (session ID via cookie).

Body:

typescript
{
  ticketsToSpend: number;  // inteiro entre 250 e 5000
  clientSeed?: string;     // seed customizado do usuario (opcional)
}

Custo: variavel, definido por ticketsToSpend (250 a 5000 tickets de evento).

Validacoes:

  1. ticketsToSpend e inteiro no intervalo [250, 5000]
  2. Usuario existe no banco
  3. Usuario possui >= ticketsToSpend de eventTickets
  4. Pool dinamico construido para o valor gasto possui >= 3 itens

Response (200):

typescript
{
  openingId: string;        // Snowflake ID da abertura
  wonItem: {
    id: string;             // ID do item ganho
    name: string;           // Nome do item
    imageUrl: string;       // URL da imagem
    rarity: string;         // Raridade (CONSUMER, MIL_SPEC, etc.)
    valueCents: string;     // Valor em centavos BRL (valueCentsBrl)
  };
  alt1: {                   // Primeiro item alternativo (animacao)
    id: string;
    name: string;
    imageUrl: string;
    rarity: string;
    valueCents: string;
  };
  alt2: {                   // Segundo item alternativo (animacao)
    id: string;
    name: string;
    imageUrl: string;
    rarity: string;
    valueCents: string;
  };
  serverSeedHash: string;   // Hash do server seed (verificacao)
  clientSeed: string;       // Client seed usado
  nonce: number;            // Nonce calculado
  roll: number;             // Resultado do roll (1-10.000.000)
  ticketsSpent: number;     // Tickets gastos (= ticketsToSpend)
  remainingTickets: number; // Tickets restantes do usuario
}

Erros:

StatusMensagemCausa
400Tickets must be an integer between 250 and 5000ticketsToSpend invalido ou fora do intervalo
400User not founduserId invalido
400Insufficient event ticketsuser.eventTickets < ticketsToSpend
400Not enough items available for this ticket amountPool dinamico com < 3 itens (mesmo apos alargamento)

Fluxo de Execucao Completo

Todo o fluxo roda dentro de um lock por usuario (withLock('user:event-shoot:${userId}')), impedindo dois tiros simultaneos do mesmo jogador.

1. Validacao de Entrada

typescript
if (
  !Number.isInteger(ticketsToSpend) ||
  ticketsToSpend < EVENT_LIMITS.MIN_SHOOT_COST ||
  ticketsToSpend > EVENT_LIMITS.MAX_SHOOT_COST
) {
  throw new BadRequestException(
    `Tickets must be an integer between ${EVENT_LIMITS.MIN_SHOOT_COST} and ${EVENT_LIMITS.MAX_SHOOT_COST}`,
  );
}

2. Validacao de Usuario e Pool

typescript
const user = await this.userRepository.findById(userId);
if (!user) throw new BadRequestException('User not found');

if (user.eventTickets < ticketsToSpend) {
  throw new BadRequestException('Insufficient event tickets');
}

const weightedItems = await this.buildDynamicPool(ticketsToSpend);
if (weightedItems.length < 3) {
  throw new BadRequestException('Not enough items available for this ticket amount');
}

3. Construcao do Pool Dinamico

O pool nao vem de uma tabela fixa: e montado a cada tiro a partir da tabela Item, com base no valor gasto.

typescript
private async buildDynamicPool(ticketsToSpend: number): Promise<WeightedItem[]> {
  const ratio = ticketsToSpend / EVENT_LIMITS.MIN_SHOOT_COST;
  let minValueCentsBrl = BigInt(Math.floor(ratio * EVENT_LIMITS.VALUE_MIN_MULTIPLIER));
  let maxValueCentsBrl = BigInt(Math.floor(ratio * EVENT_LIMITS.VALUE_MAX_MULTIPLIER));

  // itemRepository.findAll filtra por valueCentsBrl (BRL)
  let items = await this.itemRepository.findAll({
    minValueCents: minValueCentsBrl,
    maxValueCents: maxValueCentsBrl,
  });

  // Fallback: se ha menos de 3 itens, alarga a faixa (min/2, max*2) e tenta de novo
  if (items.length < 3) {
    minValueCentsBrl = minValueCentsBrl / 2n;
    maxValueCentsBrl = maxValueCentsBrl * 2n;
    items = await this.itemRepository.findAll({
      minValueCents: minValueCentsBrl,
      maxValueCents: maxValueCentsBrl,
    });
  }

  if (items.length < 3) return [];

  const sorted = [...items].sort(
    (a, b) => Number(a.valueCentsBrl) - Number(b.valueCentsBrl),
  );

  return this.assignWeightsAndRanges(sorted);
}

Faixa de valor por tickets (BRL, em centavos):

TicketsratioFaixa min (ratio*30)Faixa max (ratio*300)
250130 (R$0,30)300 (R$3,00)
500260 (R$0,60)600 (R$6,00)
10004120 (R$1,20)1200 (R$12,00)
250010300 (R$3,00)3000 (R$30,00)
500020600 (R$6,00)6000 (R$60,00)

Nota: o campo do filtro se chama minValueCents/maxValueCents na interface do repositorio, mas a implementacao aplica o filtro sobre valueCentsBrl (BRL). Os multiplicadores produzem centavos BRL.

4. Distribuicao de Pesos e Ranges

Os itens do pool (ordenados por valueCentsBrl ASC) sao distribuidos em 5 bandas de peso por percentil. Itens mais baratos ficam nas bandas de maior peso (maior chance).

typescript
const HASH_MAX = 10000000;
const WEIGHT_BANDS = [
  { percentile: 0.40, weight: 40 },
  { percentile: 0.30, weight: 30 },
  { percentile: 0.15, weight: 15 },
  { percentile: 0.10, weight: 10 },
  { percentile: 0.05, weight: 5 },
];
  • Cada banda recebe round(total * percentil) itens (minimo 1); a ultima banda absorve o restante.
  • Os hash ranges sao cumulativos e proporcionais ao peso: rangeSize = floor((weight / totalWeight) * HASH_MAX).
  • O ultimo item sempre termina em HASH_MAX (10.000.000), cobrindo o range completo sem gaps.
BandaPercentilWeightChance relativa
1 (mais barata)40% dos itens40maior
230% dos itens30alta
315% dos itens15media
410% dos itens10baixa
5 (mais cara)5% dos itens5menor

5. Debito de Tickets

typescript
await this.userRepository.decrementEventTickets(userId, ticketsToSpend);

Os tickets sao debitados antes do calculo do roll. Se houver erro no calculo, os tickets ja foram consumidos. Essa decisao e intencional para prevenir exploits de retry.

6. Geracao Provably Fair

typescript
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
const nonce = await this.nonceService.getNextNonce(userId);

Nonce: obtido via NonceService.getNextNonce(userId), que faz redis.incr('nonce:{userId}') — um contador monotonico global por usuario, compartilhado com os demais jogos (case, battle, upgrade). Isso garante nonce unico e crescente por tiro, sem colisao.

7. Calculo do Roll Principal

typescript
const roll = this.provablyFairService.calculateRoll(
  serverSeed, finalClientSeed, nonce
);

O roll retorna um valor entre 1 e 10.000.000. O item ganho e determinado pelo hash range em que o roll cai:

typescript
let wonIndex = -1;
for (let i = 0; i < weightedItems.length; i++) {
  if (roll >= weightedItems[i].hashRangeStart && roll <= weightedItems[i].hashRangeEnd) {
    wonIndex = i;
    break;
  }
}

if (wonIndex === -1) {
  wonIndex = weightedItems.length - 1; // fallback: ultimo item
}

Os itens do pool ja estao ordenados por valueCentsBrl ASC (ranges baixos = itens mais baratos).

8. Selecao de Alternativas (Animacao)

Os 2 itens alternativos sao calculados usando nonces incrementais para manter a deterministic nature do Provably Fair:

Alternativa 1 (nonce + 1):

typescript
const alt1Roll = this.provablyFairService.calculateRoll(
  serverSeed, finalClientSeed, nonce + 1
);
let alt1Index = weightedItems.findIndex(
  (it) => alt1Roll >= it.hashRangeStart && alt1Roll <= it.hashRangeEnd,
);
if (alt1Index === -1) alt1Index = 0;

// Evita colisao com o item ganho
if (alt1Index === wonIndex) {
  alt1Index = (alt1Index + 1) % weightedItems.length;
}

Alternativa 2 (nonce + 2):

typescript
const alt2Roll = this.provablyFairService.calculateRoll(
  serverSeed, finalClientSeed, nonce + 2
);
let alt2Index = weightedItems.findIndex(
  (it) => alt2Roll >= it.hashRangeStart && alt2Roll <= it.hashRangeEnd,
);
if (alt2Index === -1) alt2Index = weightedItems.length - 1;

// Evita colisao com item ganho E com alt1
if (alt2Index === wonIndex || alt2Index === alt1Index) {
  const used = new Set([wonIndex, alt1Index]);
  for (let j = 0; j < weightedItems.length; j++) {
    if (!used.has(j)) {
      alt2Index = j;
      break;
    }
  }
}

Logica de Colisao:

  • Se alt1 cair no mesmo item que o ganho, desloca para o proximo item (circular)
  • Se alt2 cair no mesmo item que o ganho OU alt1, busca o primeiro item livre do pool
  • E por isso que o pool precisa de no minimo 3 itens

9. Persistencia

typescript
const openingId = this.snowflakeService.generate();

await this.eventShootRepository.createOpening({
  id: openingId,
  userId,
  eventShootItemId: null,        // pool dinamico: nao ha FK para EventShootItem
  itemId: wonItem.item.id,
  ticketsSpent: ticketsToSpend,
  itemValueCents: wonItem.item.valueCentsBrl,
  serverSeed,
  serverSeedHash,
  clientSeed: finalClientSeed,
  nonce,
  roll,
  alt1ItemId: alt1Item.item.id,
  alt2ItemId: alt2Item.item.id,
});

O serverSeed e salvo em texto plano no banco. O usuario recebe apenas o serverSeedHash na response. Apos o tiro, o usuario pode verificar a integridade comparando o hash.

Como o pool e dinamico (vem da tabela Item, nao de EventShootItem), o campo eventShootItemId e gravado como null.

10. Inventario

typescript
await this.userInventoryRepository.create({
  userId,
  itemId: wonItem.item.id,
  status: 'AVAILABLE',
  acquiredFrom: 'Evento - Atire na Caixa',
  acquiredValueCents: wonItem.item.valueCentsBrl,
});

O item e adicionado ao inventario com status AVAILABLE, acquiredFrom marcado como 'Evento - Atire na Caixa' (rastreabilidade) e acquiredValueCents = valueCentsBrl (snapshot do valor de venda, em BRL).

11. Registro Financeiro (logOnly)

typescript
await this.transactionService.logOnly(
  userId,
  wonItem.item.valueCentsBrl,
  `Evento: Ganhou ${wonItem.item.name} (${ticketsToSpend} tickets)`,
  {
    action: 'EVENT_SHOOT',
    ticketsSpent: ticketsToSpend,
    wonItemId: wonItem.item.id.toString(),
    wonItemName: wonItem.item.name,
    wonItemRarity: wonItem.item.rarity,
    wonItemValueCents: wonItem.item.valueCentsBrl.toString(),
  },
);

logOnly cria uma transacao de valor 0 guardando o valor real do item em metadatanao move saldo (o premio e um item, nao dinheiro). Serve apenas para auditoria/analytics do valor ganho.

12. WebSocket Events

Event Tickets Update (imediato):

typescript
const updatedUser = await this.userRepository.findById(userId);
this.webSocketGateway.emitEventTicketsUpdate(
  userId,
  updatedUser?.eventTickets ?? 0
);

Emite o evento eventTickets:updated para a sala privada do usuario (user:{userId}) com o saldo atualizado de tickets.

Live Drop (3 segundos de delay):

typescript
const dropChance =
  (wonItem.weight / weightedItems.reduce((sum, i) => sum + i.weight, 0)) * 100;

setTimeout(() => {
  this.webSocketGateway.emitLiveDrop({
    userName: user.username,
    userAvatar: user.avatarUrl || undefined,
    itemName: wonItem.item.name,
    itemImage: wonItem.item.imageUrl,
    itemValueCents: wonItem.item.valueCentsBrl,
    rarity: wonItem.item.rarity,
    source: 'event',
    timestamp: new Date(),
    dropChance,
  });
}, 3000);

O delay de 3 segundos existe para sincronizar com a animacao do frontend. O campo source: 'event' permite ao frontend diferenciar drops do evento de drops de caixas normais. O dropChance e calculado a partir do peso do item ganho no pool dinamico.

13. Auditoria

typescript
await this.auditService.logUserAction(
  userId, 'EVENT_SHOOT', 'EventShoot', openingId.toString(),
  {
    itemWon: wonItem.item.name,
    roll,
    ticketsSpent: ticketsToSpend,
    itemValueCents: wonItem.item.valueCentsBrl.toString(),
  },
);

Modelos de Banco de Dados

EventShootItem (legado)

Tabela historica de pool fixo. Nao e mais a fonte do pool do tiro — o PerformEventShootUseCase monta o pool dinamicamente a partir da tabela Item. O modelo permanece no schema para compatibilidade (e ainda e populavel via scripts/seed-event-shoot-items.ts), mas o fluxo de tiro atual nao o le.

prisma
model EventShootItem {
  id              BigInt   @id
  itemId          BigInt   @map("item_id")
  weight          Int      @default(1)
  hashRangeStart  Int      @map("hash_range_start")
  hashRangeEnd    Int      @map("hash_range_end")
  dropChance      Float    @default(0) @map("drop_chance")

  item            Item     @relation(fields: [itemId], references: [id])
  openings        EventShootOpening[]

  @@map("event_shoot_items")
  @@index([itemId])
}

EventShootOpening

Tabela de historico de todas as aberturas (tiros) realizados.

prisma
model EventShootOpening {
  id                BigInt   @id
  userId            BigInt   @map("user_id")
  eventShootItemId  BigInt?  @map("event_shoot_item_id")
  itemId            BigInt   @map("item_id")
  ticketsSpent      Int      @map("tickets_spent")
  itemValueCents    BigInt   @map("item_value_cents")

  serverSeed        String   @map("server_seed")
  serverSeedHash    String   @map("server_seed_hash")
  clientSeed        String   @map("client_seed")
  nonce             Int
  roll              Int

  alt1ItemId        BigInt   @map("alt1_item_id")
  alt2ItemId        BigInt   @map("alt2_item_id")

  createdAt         DateTime @default(now()) @map("created_at")

  user              User             @relation(fields: [userId], references: [id])
  eventShootItem    EventShootItem?  @relation(fields: [eventShootItemId], references: [id], onDelete: Restrict)
  item              Item             @relation("EventShootWonItem", fields: [itemId], references: [id])
  alt1Item          Item             @relation("EventShootAlt1Item", fields: [alt1ItemId], references: [id])
  alt2Item          Item             @relation("EventShootAlt2Item", fields: [alt2ItemId], references: [id])

  @@map("event_shoot_openings")
  @@index([userId])
  @@index([createdAt(sort: Desc)])
}

Campos:

CampoTipoDescricao
idBigInt (PK)Snowflake ID
userIdBigInt (FK)Usuario que atirou
eventShootItemIdBigInt? (FK, nullable)Sempre null no fluxo dinamico atual (legado)
itemIdBigInt (FK)Item ganho (relation EventShootWonItem)
ticketsSpentIntTickets gastos (250 a 5000)
itemValueCentsBigIntValor do item em centavos BRL
serverSeedStringServer seed completo (para verificacao posterior)
serverSeedHashStringHash do server seed (commitment)
clientSeedStringClient seed usado no calculo
nonceIntNonce calculado (contador Redis por usuario)
rollIntResultado do roll (1-10.000.000)
alt1ItemIdBigInt (FK)Primeiro item alternativo (relation EventShootAlt1Item)
alt2ItemIdBigInt (FK)Segundo item alternativo (relation EventShootAlt2Item)
createdAtDateTimeTimestamp da abertura

Repository Interface

typescript
export interface IEventShootRepository {
  // Legado: retorna o pool fixo de EventShootItem. Nao e usado pelo fluxo de tiro atual,
  // que monta o pool dinamicamente via IItemRepository.findAll.
  findAllItems(): Promise<(EventShootItem & {
    item: {
      id: bigint;
      name: string;
      imageUrl: string;
      rarity: string;
      valueCentsBrl: bigint;
    }
  })[]>;

  createOpening(data: CreateEventShootOpeningData): Promise<EventShootOpening>;
}

export interface CreateEventShootOpeningData {
  id: bigint;
  userId: bigint;
  eventShootItemId: bigint | null;   // null no fluxo dinamico
  itemId: bigint;
  ticketsSpent: number;
  itemValueCents: bigint;
  serverSeed: string;
  serverSeedHash: string;
  clientSeed: string;
  nonce: number;
  roll: number;
  alt1ItemId: bigint;
  alt2ItemId: bigint;
}

O pool do tiro vem de IItemRepository.findAll({ minValueCents, maxValueCents }) (filtrado por valueCentsBrl), nao de findAllItems().


Implementacao do Repository

typescript
@Injectable()
export class EventShootRepository implements IEventShootRepository {
  constructor(
    private prisma: PrismaService,
    private snowflake: SnowflakeService,
  ) {}

  async findAllItems() {
    return this.prisma.eventShootItem.findMany({
      include: {
        item: {
          select: {
            id: true,
            name: true,
            imageUrl: true,
            rarity: true,
            valueCentsBrl: true,
          },
        },
      },
      orderBy: { hashRangeStart: 'asc' },
    });
  }

  async createOpening(data: CreateEventShootOpeningData): Promise<EventShootOpening> {
    return this.prisma.eventShootOpening.create({
      data: {
        id: data.id,
        userId: data.userId,
        eventShootItemId: data.eventShootItemId,
        itemId: data.itemId,
        ticketsSpent: data.ticketsSpent,
        itemValueCents: data.itemValueCents,
        serverSeed: data.serverSeed,
        serverSeedHash: data.serverSeedHash,
        clientSeed: data.clientSeed,
        nonce: data.nonce,
        roll: data.roll,
        alt1ItemId: data.alt1ItemId,
        alt2ItemId: data.alt2ItemId,
      },
    });
  }
}

Seed Script (legado)

Arquivo: scripts/seed-event-shoot-items.ts

Legado: este script popula a tabela EventShootItem (pool fixo). O fluxo de tiro atual nao usa essa tabela — o pool e montado dinamicamente a partir de Item. O script permanece util apenas se/quando o pool fixo for reativado; para o comportamento atual, ele nao e necessario.

O script popula EventShootItem com itens reais do banco, distribuidos em 5 faixas de preco com pesos diferentes.

Faixas de Preco

typescript
const HASH_MAX = 10000000;

const PRICE_RANGES: PriceRange[] = [
  { minCents: 3,  maxCents: 6,   weight: 40, count: 10 }, // R$0,03-R$0,06
  { minCents: 7,  maxCents: 15,  weight: 30, count: 8  }, // R$0,07-R$0,15
  { minCents: 16, maxCents: 30,  weight: 15, count: 6  }, // R$0,16-R$0,30
  { minCents: 31, maxCents: 60,  weight: 10, count: 5  }, // R$0,31-R$0,60
  { minCents: 61, maxCents: 100, weight: 5,  count: 4  }, // R$0,61-R$1,00
];

Algoritmo de Distribuicao de Ranges

typescript
const totalWeight = allItems.reduce((sum, i) => sum + i.weight, 0);
let currentStart = 1;

const records = allItems.map((item, index) => {
  const dropChance = (item.weight / totalWeight) * 100;
  const rangeSize = Math.floor((item.weight / totalWeight) * HASH_MAX);
  const hashRangeStart = currentStart;
  const hashRangeEnd = index === allItems.length - 1
    ? HASH_MAX
    : currentStart + rangeSize - 1;

  currentStart = hashRangeEnd + 1;

  return {
    id: item.id,
    itemId: item.itemId,
    weight: item.weight,
    hashRangeStart,
    hashRangeEnd,
    dropChance: parseFloat(dropChance.toFixed(4)),
  };
});

Execucao

bash
npx ts-node scripts/seed-event-shoot-items.ts

Limpeza (para re-seed)

sql
DELETE FROM event_shoot_items;

Modulo NestJS

typescript
@Module({
  imports: [AuthModule, WebSocketModule],
  controllers: [EventShootController],
  providers: [
    { provide: 'IUserRepository', useClass: UserRepository },
    { provide: 'IEventShootRepository', useClass: EventShootRepository },
    { provide: 'IUserInventoryRepository', useClass: UserInventoryRepository },
    { provide: 'IItemRepository', useClass: ItemRepository },
    UserRepository,
    EventShootRepository,
    UserInventoryRepository,
    ItemRepository,
    ProvablyFairService,
    NonceService,
    TransactionService,
    TransactionRepository,
    PerformEventShootUseCase,
  ],
})
export class EventShootModule {}

LockService, AuditService e o SnowflakeService sao injetados no use case a partir dos modulos globais correspondentes.


Verificacao Provably Fair

O usuario pode verificar o resultado apos o tiro usando os dados retornados. O roll usa o mesmo algoritmo do restante da plataforma: HMAC-SHA256 com o serverSeed como chave e ${clientSeed}:${nonce} como mensagem, extraindo os primeiros 13 hex (52 bits).

typescript
// O usuario recebe:
// - serverSeedHash (commitment, incluso na response)
// - clientSeed
// - nonce
// - roll

// Apos revelar o serverSeed (disponivel no historico):
const hmac = crypto.createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const decimal = parseInt(hash.substring(0, 13), 16); // 52 bits
const calculatedRoll = (decimal % 10000000) + 1;

// calculatedRoll deve ser igual ao roll retornado

O serverSeedHash e o commitment do server seed (hashServerSeed(serverSeed)), revelado antes do resultado; apos o tiro, o serverSeed original permite recomputar e conferir o hash e o roll.


Debugging

Backend

typescript
console.log('[EventShoot] User:', userId);
console.log('[EventShoot] Tickets to spend:', ticketsToSpend);
console.log('[EventShoot] Tickets before:', user.eventTickets);
console.log('[EventShoot] Pool size (dinamico):', weightedItems.length);
console.log('[EventShoot] Roll:', roll);
console.log('[EventShoot] Won item:', wonItem.item.name);
console.log('[EventShoot] Won index:', wonIndex);
console.log('[EventShoot] Alt1 index:', alt1Index);
console.log('[EventShoot] Alt2 index:', alt2Index);

Problemas Comuns

ProblemaCausaSolucao
"Tickets must be an integer between 250 and 5000"ticketsToSpend invalido/fora do intervaloEnviar inteiro entre 250 e 5000
"Not enough items available for this ticket amount"Faixa de valor (mesmo alargada) tem < 3 itens na tabela ItemImportar/atualizar itens naquela faixa de valueCentsBrl
Item ganho sempre o mesmoPoucos itens na faixa (pool minimo)Aumentar catalogo de itens naquela faixa de preco
Tickets nao atualizam no frontendWebSocket desconectadoVerificar conexao WebSocket e sala do usuario
Live drop nao apareceDelay de 3sAguardar 3 segundos apos o tiro

Regras Importantes

  1. O custo e variavel entre 250 e 5000 tickets (EVENT_LIMITS.MIN/MAX_SHOOT_COST), escolhido pelo jogador no body da request
  2. O pool e dinamico, montado a cada tiro a partir da tabela Item filtrada por valueCentsBrl (faixa ratio*30 a ratio*300 centavos); a tabela EventShootItem e legado
  3. O pool precisa de no minimo 3 itens (para garantir 1 ganho + 2 alternativas); se necessario, a faixa e alargada (min/2, max*2) antes de falhar
  4. Precos dos itens usam valueCentsBrl (BRL), nunca valueCents (USD)
  5. Os 3 itens retornados (ganho + 2 alts) sao sempre diferentes entre si
  6. O nonce vem do NonceService (redis.incr('nonce:{userId}')) — contador monotonico por usuario, compartilhado com os demais jogos
  7. O acquiredFrom do inventario e sempre 'Evento - Atire na Caixa', com acquiredValueCents = valueCentsBrl
  8. O live drop tem delay de 3 segundos e source 'event'
  9. Tickets sao debitados antes do calculo (prevencao de exploit)
  10. O premio e sempre um itemtransactionService.logOnly registra o valor sem mover saldo (nunca credit)
  11. Todo o fluxo roda sob lock por usuario (user:event-shoot:${userId}) e e auditado (EVENT_SHOOT)

Documentação Técnica - ProCases