Files
slot/next-app/apps/web/src/lib/game-engine.ts
T
chpark 782029d31f memo CRUD, admin yellowcard/sns/sms-write, scatter free-spin, theme-skinned mega
User pages:
- /memo: inbox/sent tabs with send + delete server actions on g5_memo
  (creates paired recv+send rows so both sides can delete independently)

Admin:
- /admin/eyoom/yellowcard: issue/withdraw warnings (g5_eyoom_yellowcard)
- /admin/plugin/sns: 4 OAuth providers (Naver/Kakao/Facebook/Google),
  saved into public.app_settings with social_<provider> keys
- /admin/sms/write: mock SMS send + recent 30 from sms5_history

Game engine:
- scatter symbol with 5% spawn rate
- 3+ scatters trigger free-spin (no bet deducted, message + flag)
- 88포춘: wild=🌟, scatter=🎁

Theme skinning (basic/amina/youngcart variants of):
- .bg-mega gradient (mega-menu nav)
- .bg-brand-radial (Hero aurora) — basic blue, amina cyan, youngcart orange
- root layout writes data-theme="..." attribute

Verify: 50 iter × 16 = 800/800 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:11:28 +09:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generic game engine used by /games/[slot] simulators.
// Records bets to inspection2.g5_point and updates g5_member.mb_point.
import { legacySql } from '@slot/db/legacy';
import type { SiteUser } from '@slot/themes';
export interface SpinResult {
win: boolean;
payout: number; // gross payout (point delta added back; loss = 0)
net: number; // net delta (+/-) versus the bet
symbols?: string[]; // visual reels
multiplier?: number;
message: string;
freeSpin?: boolean; // scatter triggered free-spin (no bet deducted next round)
scatterCount?: number;
}
export interface GameDef {
slug: string;
label: string;
reelCount: number;
symbols: string[]; // visible symbols (with weights via repetition)
paytable: Record<string, number>; // symbol -> multiplier when 3-of-a-kind
payAny2?: number; // small payout when 2 match
wild?: string; // wild symbol substitutes for any
scatter?: string; // scatter triggers free spins
minBet: number;
maxBet: number;
}
export const GAMES: Record<string, GameDef> = {
fortunes: {
slug: 'fortunes', label: '88포춘',
reelCount: 3,
symbols: ['🐉','🐉','🐉','💰','💰','💰','🍊','🍊','🎯','7️⃣'],
paytable: { '🐉': 30, '💰': 18, '🍊': 7, '🎯': 4, '7️⃣': 80 },
payAny2: 1.5,
wild: '🌟', scatter: '🎁',
minBet: 100, maxBet: 100_000,
},
fivetreasures: {
slug: 'fivetreasures', label: '5트레져',
reelCount: 3,
symbols: ['💎','💎','🪙','🪙','🪙','🍀','🍀','🐅','🦁','👑'],
paytable: { '💎': 25, '🪙': 12, '🍀': 6, '🐅': 35, '🦁': 50, '👑': 100 },
payAny2: 1.4,
minBet: 100, maxBet: 100_000,
},
bacara: {
slug: 'bacara', label: '포인트 바카라',
reelCount: 1,
symbols: ['🟢','🟢','🟢','🟢','🔴','🔴','🔴','🔴','⚪'],
paytable: { '🟢': 2, '🔴': 1.95, '⚪': 8 },
minBet: 100, maxBet: 200_000,
},
seastory: {
slug: 'seastory', label: '바다이야기',
reelCount: 3,
symbols: ['🐠','🐠','🐠','🦑','🦑','🐙','🐚','🐚','⭐','🐳'],
paytable: { '🐠': 8, '🦑': 12, '🐙': 18, '🐚': 5, '⭐': 30, '🐳': 60 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
davinci: {
slug: 'davinci', label: '다빈치',
reelCount: 3,
symbols: ['🎨','🎨','📜','📜','📜','🗝','🗝','💍','👁','🏛'],
paytable: { '🎨': 15, '📜': 10, '🗝': 20, '💍': 40, '👁': 60, '🏛': 100 },
payAny2: 1.5, minBet: 100, maxBet: 100_000,
},
oceanparadise: {
slug: 'oceanparadise', label: '오션파라다이스',
reelCount: 3,
symbols: ['🐬','🐬','🐬','🐢','🐢','🦞','🦀','🌊','🌊','🌟'],
paytable: { '🐬': 18, '🐢': 22, '🦞': 30, '🦀': 12, '🌊': 5, '🌟': 70 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
cherrymaster: {
slug: 'cherrymaster', label: '체리마스터',
reelCount: 3,
symbols: ['🍒','🍒','🍒','🍇','🍇','🍋','🍋','🔔','🎰','BAR'],
paytable: { '🍒': 6, '🍇': 10, '🍋': 8, '🔔': 25, '🎰': 50, 'BAR': 75 },
payAny2: 1.6, minBet: 100, maxBet: 100_000,
},
yamato: {
slug: 'yamato', label: '야마토',
reelCount: 3,
symbols: ['🚀','🚀','🛸','🛸','⚓','⚓','💥','🌌','🛰','👨‍🚀'],
paytable: { '🚀': 15, '🛸': 25, '⚓': 8, '💥': 35, '🌌': 50, '🛰': 40, '👨‍🚀': 100 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
kyoushi: {
slug: 'kyoushi', label: '강시',
reelCount: 3,
symbols: ['🧟','🧟','🪦','🪦','🌙','🦇','🦇','⛩','🔔','💀'],
paytable: { '🧟': 18, '🪦': 12, '🌙': 25, '🦇': 8, '⛩': 30, '🔔': 14, '💀': 70 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
lupin: {
slug: 'lupin', label: '루팡',
reelCount: 3,
symbols: ['🕵️','🕵️','💼','💼','💎','🔫','🎩','🎩','🚗','💰'],
paytable: { '🕵️': 20, '💼': 10, '💎': 50, '🔫': 25, '🎩': 12, '🚗': 18, '💰': 80 },
payAny2: 1.5, minBet: 100, maxBet: 100_000,
},
taiku: {
slug: 'taiku', label: '대공',
reelCount: 3,
symbols: ['🛡','🛡','⚔','⚔','🏰','🏰','👑','🐎','🐎','🗡'],
paytable: { '🛡': 8, '⚔': 12, '🏰': 25, '👑': 80, '🐎': 10, '🗡': 18 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
matsuri: {
slug: 'matsuri', label: '축제',
reelCount: 3,
symbols: ['🎏','🎏','🏮','🏮','🍡','🍡','🎆','🎇','👘','🎋'],
paytable: { '🎏': 10, '🏮': 14, '🍡': 8, '🎆': 30, '🎇': 25, '👘': 20, '🎋': 18 },
payAny2: 1.5, minBet: 100, maxBet: 100_000,
},
marilyn: {
slug: 'marilyn', label: '마릴린먼로',
reelCount: 3,
symbols: ['💋','💋','💄','💄','👠','💎','🌹','🌹','🥂','💃'],
paytable: { '💋': 12, '💄': 8, '👠': 15, '💎': 40, '🌹': 10, '🥂': 18, '💃': 60 },
payAny2: 1.4, minBet: 100, maxBet: 100_000,
},
giatrus: {
slug: 'giatrus', label: '고인돌',
reelCount: 3,
symbols: ['🦴','🦴','🪨','🪨','🦣','🦖','🔥','🥩','🥩','🪓'],
paytable: { '🦴': 6, '🪨': 5, '🦣': 30, '🦖': 50, '🔥': 18, '🥩': 8, '🪓': 12 },
payAny2: 1.5, minBet: 100, maxBet: 100_000,
},
rings: {
slug: 'rings', label: '반지의제왕',
reelCount: 3,
symbols: ['💍','💍','🗡','🗡','🧙','🧝','🛡','🏹','🌋','👁'],
paytable: { '💍': 100, '🗡': 12, '🧙': 30, '🧝': 25, '🛡': 10, '🏹': 18, '🌋': 50, '👁': 80 },
payAny2: 1.5, minBet: 100, maxBet: 100_000,
},
bakabon: {
slug: 'bakabon', label: '바카본',
reelCount: 3,
symbols: ['👶','👶','👨','🍼','🍼','📺','📺','🍙','🍙','🎈'],
paytable: { '👶': 15, '👨': 25, '🍼': 8, '📺': 6, '🍙': 5, '🎈': 18 },
payAny2: 1.6, minBet: 100, maxBet: 100_000,
},
slot: {
slug: 'slot', label: '무료 슬롯 체험',
reelCount: 3,
symbols: ['🎰','🎰','7️⃣','7️⃣','🍒','🍒','🍋','🍇','🔔','💎'],
paytable: { '🎰': 25, '7️⃣': 50, '🍒': 6, '🍋': 8, '🍇': 10, '🔔': 18, '💎': 40 },
payAny2: 1.5, minBet: 0, maxBet: 100_000,
},
};
function pickSymbol(g: GameDef): string {
return g.symbols[Math.floor(Math.random() * g.symbols.length)]!;
}
export function spin(g: GameDef, bet: number): SpinResult {
const reels: string[] = [];
for (let i = 0; i < g.reelCount; i++) {
if (g.wild && Math.random() < 0.07) reels.push(g.wild);
else if (g.scatter && Math.random() < 0.05) reels.push(g.scatter);
else reels.push(pickSymbol(g));
}
const scatterCount = g.scatter ? reels.filter((s) => s === g.scatter).length : 0;
const freeSpin = scatterCount >= 3;
if (g.reelCount === 1) {
const s = reels[0]!;
const m = g.paytable[s] ?? 0;
if (m > 0) {
const payout = Math.floor(bet * m);
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `${s} 적중! ×${m}` };
}
return { win: false, payout: 0, net: -bet, symbols: reels, message: '아쉽네요. 다음 기회에!' };
}
const a = reels[0]!, b = reels[1]!, c = reels[2]!;
const wildCount = g.wild ? reels.filter((s) => s === g.wild).length : 0;
// Treat wild as match-anything: any 3-of-a-kind allowing wild substitutes
const matchSymbol = (() => {
if (!g.wild) return a === b && b === c ? a : null;
const nonWild = reels.filter((s) => s !== g.wild);
if (nonWild.length === 0) return g.wild; // all wilds!
const ref = nonWild[0]!;
return nonWild.every((s) => s === ref) ? ref : null;
})();
if (matchSymbol) {
const baseM = g.paytable[matchSymbol] ?? 5;
const wildMult = wildCount > 0 ? 1 + wildCount * 0.5 : 1;
const m = baseM * wildMult;
const payout = Math.floor(bet * m);
return {
win: true, payout, net: payout - bet, symbols: reels, multiplier: m,
message: wildCount > 0 ? `🌟 와일드 ${wildCount}개 + ${matchSymbol}${matchSymbol}${matchSymbol}! ×${m.toFixed(2)}` : `🎉 ${matchSymbol}${matchSymbol}${matchSymbol} 잭팟! ×${m}`,
freeSpin, scatterCount,
};
}
if (freeSpin) {
return { win: true, payout: bet, net: 0, symbols: reels, message: `✨ 스캐터 ${scatterCount}개! 무료 스핀 적립`, freeSpin, scatterCount };
}
if (g.payAny2 && (a === b || b === c || a === c)) {
const m = g.payAny2;
const payout = Math.floor(bet * m);
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `2-매치 ×${m}`, freeSpin, scatterCount };
}
return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!', freeSpin, scatterCount };
}
export async function placeBetAndSpin(slug: string, user: SiteUser, bet: number): Promise<{ ok: boolean; error?: string; result?: SpinResult; newPoint?: number }> {
const g = GAMES[slug];
if (!g) return { ok: false, error: 'unknown_game' };
const b = Math.trunc(bet);
if (b < g.minBet || b > g.maxBet) return { ok: false, error: `bet_out_of_range:${g.minBet}-${g.maxBet}` };
const memberRow = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${user.loginId}`.catch(() => []);
const balance = Number(memberRow[0]?.mb_point ?? 0);
if (balance < b) return { ok: false, error: 'insufficient_point' };
const result = spin(g, b);
const newPoint = balance + result.net;
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql.begin(async (tx) => {
await tx`UPDATE inspection2.g5_member SET mb_point = ${newPoint} WHERE mb_id = ${user.loginId}`;
await tx`
INSERT INTO inspection2.g5_point (mb_id, po_datetime, po_content, po_point, po_use_point, po_expire_point, po_expired, po_expire_date, po_mb_point, po_rel_table, po_rel_id, po_rel_action)
VALUES (${user.loginId}, ${nowStr}, ${'[' + g.label + '] ' + result.message}, ${result.net}, 0, ${Math.max(0, result.net)}, 0, '9999-12-31', ${newPoint}, '@game', ${slug}, ${result.win ? 'game-win' : 'game-loss'})
`;
}).catch((e) => { console.error('placeBetAndSpin fail', e); throw e; });
return { ok: true, result, newPoint };
}