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
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Presentation | src/presentation/controllers/event-shoot.controller.ts | Controller HTTP, rota e guards |
| Presentation | src/presentation/modules/event-shoot.module.ts | Modulo NestJS, DI providers |
| Application | src/application/use-cases/event-shoot/perform-event-shoot.use-case.ts | Logica de negocio completa |
| Application | src/config/business-limits.config.ts | EVENT_LIMITS (custo min/max, multiplicadores de valor) |
| Domain | src/domain/repositories/event-shoot.repository.interface.ts | Interface do repositorio |
| Domain | src/domain/repositories/item.repository.interface.ts | findAll filtrado por valueCentsBrl (fonte do pool dinamico) |
| Infrastructure | src/infrastructure/database/repositories/event-shoot.repository.ts | Implementacao Prisma |
| Frontend | client/app/evento/page.tsx | Pagina do evento |
Constantes (EVENT_LIMITS)
Arquivo: src/config/business-limits.config.ts
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:
{
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:
ticketsToSpende inteiro no intervalo[250, 5000]- Usuario existe no banco
- Usuario possui >=
ticketsToSpenddeeventTickets - Pool dinamico construido para o valor gasto possui >= 3 itens
Response (200):
{
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:
| Status | Mensagem | Causa |
|---|---|---|
| 400 | Tickets must be an integer between 250 and 5000 | ticketsToSpend invalido ou fora do intervalo |
| 400 | User not found | userId invalido |
| 400 | Insufficient event tickets | user.eventTickets < ticketsToSpend |
| 400 | Not enough items available for this ticket amount | Pool 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
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
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.
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):
| Tickets | ratio | Faixa min (ratio*30) | Faixa max (ratio*300) |
|---|---|---|---|
| 250 | 1 | 30 (R$0,30) | 300 (R$3,00) |
| 500 | 2 | 60 (R$0,60) | 600 (R$6,00) |
| 1000 | 4 | 120 (R$1,20) | 1200 (R$12,00) |
| 2500 | 10 | 300 (R$3,00) | 3000 (R$30,00) |
| 5000 | 20 | 600 (R$6,00) | 6000 (R$60,00) |
Nota: o campo do filtro se chama
minValueCents/maxValueCentsna interface do repositorio, mas a implementacao aplica o filtro sobrevalueCentsBrl(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).
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.
| Banda | Percentil | Weight | Chance relativa |
|---|---|---|---|
| 1 (mais barata) | 40% dos itens | 40 | maior |
| 2 | 30% dos itens | 30 | alta |
| 3 | 15% dos itens | 15 | media |
| 4 | 10% dos itens | 10 | baixa |
| 5 (mais cara) | 5% dos itens | 5 | menor |
5. Debito de Tickets
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
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
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:
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):
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):
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
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
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)
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 metadata — nao move saldo (o premio e um item, nao dinheiro). Serve apenas para auditoria/analytics do valor ganho.
12. WebSocket Events
Event Tickets Update (imediato):
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):
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
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.
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.
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:
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt (PK) | Snowflake ID |
userId | BigInt (FK) | Usuario que atirou |
eventShootItemId | BigInt? (FK, nullable) | Sempre null no fluxo dinamico atual (legado) |
itemId | BigInt (FK) | Item ganho (relation EventShootWonItem) |
ticketsSpent | Int | Tickets gastos (250 a 5000) |
itemValueCents | BigInt | Valor do item em centavos BRL |
serverSeed | String | Server seed completo (para verificacao posterior) |
serverSeedHash | String | Hash do server seed (commitment) |
clientSeed | String | Client seed usado no calculo |
nonce | Int | Nonce calculado (contador Redis por usuario) |
roll | Int | Resultado do roll (1-10.000.000) |
alt1ItemId | BigInt (FK) | Primeiro item alternativo (relation EventShootAlt1Item) |
alt2ItemId | BigInt (FK) | Segundo item alternativo (relation EventShootAlt2Item) |
createdAt | DateTime | Timestamp da abertura |
Repository Interface
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
@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 deItem. 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
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
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
npx ts-node scripts/seed-event-shoot-items.tsLimpeza (para re-seed)
DELETE FROM event_shoot_items;Modulo NestJS
@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).
// 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 retornadoO 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
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
| Problema | Causa | Solucao |
|---|---|---|
| "Tickets must be an integer between 250 and 5000" | ticketsToSpend invalido/fora do intervalo | Enviar inteiro entre 250 e 5000 |
| "Not enough items available for this ticket amount" | Faixa de valor (mesmo alargada) tem < 3 itens na tabela Item | Importar/atualizar itens naquela faixa de valueCentsBrl |
| Item ganho sempre o mesmo | Poucos itens na faixa (pool minimo) | Aumentar catalogo de itens naquela faixa de preco |
| Tickets nao atualizam no frontend | WebSocket desconectado | Verificar conexao WebSocket e sala do usuario |
| Live drop nao aparece | Delay de 3s | Aguardar 3 segundos apos o tiro |
Regras Importantes
- O custo e variavel entre 250 e 5000 tickets (
EVENT_LIMITS.MIN/MAX_SHOOT_COST), escolhido pelo jogador no body da request - O pool e dinamico, montado a cada tiro a partir da tabela
Itemfiltrada porvalueCentsBrl(faixaratio*30aratio*300centavos); a tabelaEventShootIteme legado - 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 - Precos dos itens usam
valueCentsBrl(BRL), nuncavalueCents(USD) - Os 3 itens retornados (ganho + 2 alts) sao sempre diferentes entre si
- O nonce vem do
NonceService(redis.incr('nonce:{userId}')) — contador monotonico por usuario, compartilhado com os demais jogos - O
acquiredFromdo inventario e sempre'Evento - Atire na Caixa', comacquiredValueCents = valueCentsBrl - O live drop tem delay de 3 segundos e source
'event' - Tickets sao debitados antes do calculo (prevencao de exploit)
- O premio e sempre um item —
transactionService.logOnlyregistra o valor sem mover saldo (nuncacredit) - Todo o fluxo roda sob lock por usuario (
user:event-shoot:${userId}) e e auditado (EVENT_SHOOT)
