Files
slot/next-app/apps/web/src/lib/game-engine.ts
T
chpark 0d248eb6ae React standalone on :8088, mypage tabs, sms config, wild symbols, cross-verify x16
Standalone deployment:
- React Next.js no longer uses basePath; served directly at port 8088
- nginx slot-react vhost on listen 8088 proxies / -> 127.0.0.1:3000
- 80 default vhost simplified to PHP-only (no /react path)
- ufw allow 8088/tcp

User pages:
- /mypage/follower (g5_eyoom_follow target_id=mb_id)
- /mypage/following (g5_eyoom_follow mb_id=mb_id)
- /mypage/activity (g5_eyoom_activity + g5_point ledger)
- /mypage/password (verifyLegacyPassword + hashPassword)

Admin:
- /admin/sms/config (sms5_config single-row CRUD)

Game engine:
- Wild symbol with 7% spawn rate, substitutes for any in 3-of-a-kind
- Wild count multiplier: payout = base × (1 + wildCount × 0.5)

Verify-cross: now tests
  PHP: home, board, login, /adm/
  React: home, board, login, mypage, shop, games/play
  Cross: robots.txt block on both
  Theme cookie: 4 themes (basic/eyoom/amina/youngcart) round-trip
50 iterations × 16 = 800/800 PASS

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

222 lines
9.6 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;
}
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,
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 {
// 5% chance of inserting wild ⭐ — boosts excitement on slots that defined it.
const reels: string[] = [];
for (let i = 0; i < g.reelCount; i++) {
if (g.wild && Math.random() < 0.07) reels.push(g.wild);
else reels.push(pickSymbol(g));
}
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}`,
};
}
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}` };
}
return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!' };
}
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 };
}