// gacha.ts — authoritative pull execution // // All pull outcomes are resolved server-side. The client sends a banner ID and // pull count; this module resolves rarity, determines the result from the // banner pool, updates pity counters, deducts currency, and records pull // history. Nothing here is computed client-side. // // Pity model (PoC defaults — all values should be content-driven): // - Soft pity begins at 70 pulls (increasing rate toward hard pity) // - Hard pity at 90 pulls guarantees SSR // - Featured SSR rate-up: 50% chance when SSR hits // - Pity carries over between sessions but NOT between different banners // unless the banner explicitly declares carry-over. export interface BannerDefinition { id: string; name: string; type: "standard" | "featured" | "event"; pull_cost_currency: string; pull_cost_amount: number; multi_cost_amount: number; // cost for 10-pull; usually 10x single minus one pool: BannerPoolEntry[]; soft_pity_start: number; hard_pity: number; featured_character_ids: string[]; active_from: number; // unix ms active_until: number | null; // null = permanent } export interface BannerPoolEntry { character_id: string; rarity: "R" | "SR" | "SSR" | "UR"; base_weight: number; } export interface PullRecord { banner_id: string; character_id: string; rarity: string; pulled_at: number; pity_count_at_pull: number; was_soft_pity: boolean; was_hard_pity: boolean; } // ── RPCs ────────────────────────────────────────────────────────────────── export function rpcGachaPull( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { // TODO PR 5: implement full pull resolution // Stub returns a placeholder response so the endpoint is registered and // callable from the Godot client in PoC step 1. logger.info(`gacha/pull stub called by ${ctx.userId}`); const req = JSON.parse(payload || "{}"); const bannerId: string = req.banner_id ?? "standard"; const count: number = req.count ?? 1; if (count !== 1 && count !== 10) { throw new Error("Pull count must be 1 or 10"); } return JSON.stringify({ ok: false, error: "not_implemented", message: "Gacha pull system is stubbed. Implement in PR 5.", banner_id: bannerId, count, }); } export function rpcListBanners( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, _payload: string ): string { // TODO PR 5: load active banners from content storage logger.info(`gacha/banners stub called by ${ctx.userId}`); return JSON.stringify({ banners: [], message: "Banner list is stubbed. Implement in PR 5." }); } export function rpcGetPity( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { // TODO PR 5: read pity counters from player storage const req = JSON.parse(payload || "{}"); return JSON.stringify({ banner_id: req.banner_id ?? "standard", current_pity: 0, soft_pity_start: 70, hard_pity: 90, message: "Pity counter is stubbed. Implement in PR 5.", }); } export function rpcGetPullHistory( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, _payload: string ): string { // TODO PR 5: load pull history from player storage return JSON.stringify({ history: [], message: "Pull history is stubbed. Implement in PR 5." }); }