0d248eb6ae
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>
222 lines
9.6 KiB
TypeScript
222 lines
9.6 KiB
TypeScript
// 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 };
|
||
}
|