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>
This commit is contained in:
chpark
2026-04-28 18:11:28 +09:00
parent 0d248eb6ae
commit 782029d31f
6 changed files with 356 additions and 14 deletions
@@ -0,0 +1,77 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface Row { id: number; mb_id: string; reason: string; datetime: Date }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function issueCard(formData: FormData) {
'use server';
await requireAdmin();
const mb_id = String(formData.get('mb_id') ?? '').slice(0, 30);
const reason = String(formData.get('reason') ?? '').slice(0, 250);
if (!mb_id || !reason) return;
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql`INSERT INTO inspection2.g5_eyoom_yellowcard (mb_id, reason, datetime) VALUES (${mb_id}, ${reason}, ${now})`.catch(() => {});
revalidatePath('/admin/eyoom/yellowcard');
}
async function deleteCard(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_eyoom_yellowcard WHERE id = ${id}`.catch(() => {});
revalidatePath('/admin/eyoom/yellowcard');
}
export default async function YellowcardAdmin() {
await requireAdmin();
const rows = await legacySql<Row[]>`SELECT id, mb_id, reason, datetime FROM inspection2.g5_eyoom_yellowcard ORDER BY id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> () </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> /. .</p>
</header>
<form action={issueCard} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[160px_1fr_auto]">
<input name="mb_id" required placeholder="회원 아이디" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="reason" required placeholder="경고 사유" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-amber-600 px-4 py-2 text-[13px] font-bold text-white"> </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.id}</td>
<td className="px-3 py-2">{r.mb_id}</td>
<td className="px-3 py-2">{r.reason}</td>
<td className="px-3 py-2 text-[11px]">{r.datetime && new Date(r.datetime).toISOString().slice(0,16).replace('T',' ')}</td>
<td className="px-3 py-2 text-center">
<form action={deleteCard} className="inline">
<input type="hidden" name="id" value={r.id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={5} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,73 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
const PROVIDERS = [
{ id: 'naver', label: 'Naver', color: 'bg-emerald-600' },
{ id: 'kakao', label: 'Kakao', color: 'bg-yellow-500' },
{ id: 'facebook', label: 'Facebook', color: 'bg-blue-600' },
{ id: 'google', label: 'Google', color: 'bg-rose-600' },
];
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveSocial(formData: FormData) {
'use server';
await requireAdmin();
const provider = String(formData.get('provider') ?? '').slice(0, 20);
const clientId = String(formData.get('client_id') ?? '').slice(0, 200);
const clientSecret = String(formData.get('client_secret') ?? '').slice(0, 200);
const enabled = formData.get('enabled') ? 1 : 0;
if (!provider) return;
await legacySql`
INSERT INTO public.app_settings (key, value)
VALUES (${'social_' + provider}, ${JSON.stringify({ clientId, clientSecret, enabled })}::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`.catch(() => {});
revalidatePath('/admin/plugin/sns');
}
export default async function SnsAdmin() {
await requireAdmin();
const rows = await legacySql<{ key: string; value: { clientId?: string; clientSecret?: string; enabled?: number } }[]>`
SELECT key, value FROM public.app_settings WHERE key LIKE 'social_%'
`.catch(() => []);
const map = new Map(rows.map((r) => [r.key, r.value]));
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft">Naver/Kakao/Facebook/Google OAuth . M9에서 OAuth flow와 .</p>
</header>
<div className="grid gap-3 sm:grid-cols-2">
{PROVIDERS.map((p) => {
const cur = map.get('social_' + p.id) ?? {};
return (
<form key={p.id} action={saveSocial} className="rounded-2xl bg-white p-4 ring-1 ring-neutral-100">
<div className="mb-3 flex items-center gap-2">
<span className={`grid h-9 w-9 place-items-center rounded-full ${p.color} text-white text-[14px] font-bold`}>{p.label[0]}</span>
<h3 className="m-0 text-[15px] font-bold">{p.label}</h3>
</div>
<input type="hidden" name="provider" value={p.id} />
<label className="block text-[11px] font-bold text-neutral-700">Client ID</label>
<input name="client_id" defaultValue={cur.clientId ?? ''} className="mb-2 mt-1 w-full rounded border border-neutral-200 px-2 py-1.5 text-[12px]" />
<label className="block text-[11px] font-bold text-neutral-700">Client Secret</label>
<input name="client_secret" type="password" defaultValue={cur.clientSecret ?? ''} className="mb-2 mt-1 w-full rounded border border-neutral-200 px-2 py-1.5 text-[12px]" />
<label className="flex items-center gap-2 text-[11.5px]"><input type="checkbox" name="enabled" defaultChecked={Boolean(cur.enabled)} /> </label>
<button type="submit" className="mt-3 w-full rounded-lg bg-brand-600 py-1.5 text-[12px] font-bold text-white"></button>
</form>
);
})}
</div>
</article>
);
}
@@ -0,0 +1,72 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface HistoryRow { id: number; send_hp: string; send_msg: string; send_state: string | null; send_date: Date }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function sendSms(formData: FormData) {
'use server';
await requireAdmin();
const to = String(formData.get('to') ?? '').slice(0, 20);
const msg = String(formData.get('msg') ?? '').slice(0, 500);
if (!to || !msg) return;
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql`
INSERT INTO inspection2.sms5_history (send_hp, send_msg, send_state, send_date)
VALUES (${to}, ${msg}, ${'mock-sent'}, ${now})
`.catch(() => {});
revalidatePath('/admin/sms/write');
redirect('/admin/sms/write?status=ok');
}
export default async function SmsWriteAdmin({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
await requireAdmin();
const sp = await searchParams;
const recent = await legacySql<HistoryRow[]>`
SELECT id, send_hp, send_msg, send_state, send_date
FROM inspection2.sms5_history ORDER BY id DESC LIMIT 30
`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">SMS </div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft">Mock 발송: sms5_history에 ( M9).</p>
</header>
{sp.status === 'ok' && <div className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-[13px] text-emerald-700"> .</div>}
<form action={sendSms} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[180px_1fr_auto]">
<input name="to" required placeholder="수신 번호 (010...)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="msg" required placeholder="메시지" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" maxLength={500} />
<button type="submit" className="rounded-lg bg-emerald-600 px-4 py-2 text-[13px] font-bold text-white">📨 </button>
</form>
<h2 className="mb-2 text-[14px] font-bold text-neutral-700"> 30</h2>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-left"></th></tr>
</thead>
<tbody>
{recent.map((r) => (
<tr key={r.id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.send_hp}</td>
<td className="px-3 py-2">{r.send_msg}</td>
<td className="px-3 py-2 text-center text-[10px]"><span className="rounded-full bg-emerald-50 px-2 py-0.5 font-bold text-emerald-700">{r.send_state ?? 'queued'}</span></td>
<td className="px-3 py-2 text-[11px]">{r.send_date && new Date(r.send_date).toISOString().slice(0,16).replace('T',' ')}</td>
</tr>
))}
{recent.length === 0 && <tr><td colSpan={4} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
+31
View File
@@ -124,6 +124,37 @@ details > summary::-webkit-details-marker { display: none; }
linear-gradient(90deg, #6c4cd1 0%, #8a5cd6 50%, #a47adf 100%);
}
/* Theme-aware mega menu (skinned by data-theme on <html>) */
html[data-theme="basic"] .bg-mega {
background: linear-gradient(90deg, #1d4ed8 0%, #2563eb 50%, #3b82f6 100%);
}
html[data-theme="amina"] .bg-mega {
background: linear-gradient(90deg, #0369a1 0%, #0891b2 50%, #06b6d4 100%);
}
html[data-theme="youngcart"] .bg-mega {
background: linear-gradient(90deg, #c2410c 0%, #ea580c 50%, #fb923c 100%);
}
html[data-theme="basic"] .bg-brand-radial {
background:
radial-gradient(ellipse 800px 400px at 15% -10%, #7dd3fcaa 0%, transparent 55%),
radial-gradient(ellipse 700px 500px at 90% 20%, #c4b5fdaa 0%, transparent 55%),
radial-gradient(ellipse 900px 500px at 50% 110%, #93c5fdaa 0%, transparent 50%),
linear-gradient(135deg, #0c1c3a 0%, #0a1530 35%, #050a18 100%);
}
html[data-theme="amina"] .bg-brand-radial {
background:
radial-gradient(ellipse 800px 400px at 15% -10%, #67e8f9aa 0%, transparent 55%),
radial-gradient(ellipse 700px 500px at 90% 20%, #7dd3fcaa 0%, transparent 55%),
linear-gradient(135deg, #0c2030 0%, #06141f 100%);
}
html[data-theme="youngcart"] .bg-brand-radial {
background:
radial-gradient(ellipse 800px 400px at 15% -10%, #fed7aaaa 0%, transparent 55%),
radial-gradient(ellipse 700px 500px at 90% 20%, #fdba74aa 0%, transparent 55%),
radial-gradient(ellipse 900px 500px at 50% 110%, #fb923caa 0%, transparent 50%),
linear-gradient(135deg, #2d1505 0%, #1a0a02 100%);
}
/* Card link */
.card-link { text-decoration: none; color: inherit; }
+91 -11
View File
@@ -1,18 +1,98 @@
import { StubPage } from '@/lib/page-shells';
import { getCurrentSiteUser } from '@/lib/page-data';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
export default async function MemoPage() {
interface MemoRow { me_id: number; me_recv_mb_id: string; me_send_mb_id: string; me_send_datetime: Date; me_read_datetime: Date | null; me_memo: string; me_type: string }
export default async function MemoPage({ searchParams }: { searchParams: Promise<{ tab?: string; status?: string }> }) {
const sp = await searchParams;
const tab = sp.tab === 'sent' ? 'sent' : 'recv';
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/memo');
const rows = tab === 'recv'
? await legacySql<MemoRow[]>`
SELECT me_id, me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type
FROM inspection2.g5_memo WHERE me_recv_mb_id = ${user.loginId} AND me_type = 'recv'
ORDER BY me_id DESC LIMIT 100
`.catch(() => [])
: await legacySql<MemoRow[]>`
SELECT me_id, me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type
FROM inspection2.g5_memo WHERE me_send_mb_id = ${user.loginId} AND me_type = 'send'
ORDER BY me_id DESC LIMIT 100
`.catch(() => []);
async function sendMemo(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const to = String(formData.get('to') ?? '').trim().slice(0, 30);
const text = String(formData.get('memo') ?? '').slice(0, 1000);
if (!to || !text) return;
const target = await legacySql<{ mb_id: string }[]>`SELECT mb_id FROM inspection2.g5_member WHERE mb_id = ${to} OR mb_nick = ${to} LIMIT 1`.catch(() => []);
if (!target[0]) return;
const recvId = target[0]!.mb_id;
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql.begin(async (tx) => {
await tx`INSERT INTO inspection2.g5_memo (me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type) VALUES (${recvId}, ${u.loginId}, ${now}, NULL, ${text}, 'recv')`;
await tx`INSERT INTO inspection2.g5_memo (me_recv_mb_id, me_send_mb_id, me_send_datetime, me_read_datetime, me_memo, me_type) VALUES (${recvId}, ${u.loginId}, ${now}, NULL, ${text}, 'send')`;
}).catch((e) => { console.error('memo send fail', e); });
revalidatePath('/memo');
}
async function deleteMemo(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const id = Number(formData.get('me_id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_memo WHERE me_id = ${id} AND (me_recv_mb_id = ${u.loginId} OR me_send_mb_id = ${u.loginId})`.catch(() => {});
revalidatePath('/memo');
}
return (
<StubPage title="쪽지함" lead="다른 회원으로부터 받은 쪽지를 확인할 수 있습니다.">
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<a href="/memo" style={{ background: 'var(--color-primary)', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none' }}> </a>
<a href="/memo?box=sent" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', color: 'var(--color-text)' }}> </a>
<a href="/memo/write" style={{ background: '#16a34a', color: '#fff', padding: '6px 14px', borderRadius: 4, textDecoration: 'none', marginLeft: 'auto' }}> </a>
</div>
<p style={{ color: 'var(--color-textMuted)', textAlign: 'center', padding: 40 }}> .</p>
</StubPage>
<article className="flex flex-col gap-4">
<header className="rounded-3xl bg-gradient-to-br from-pink-600 to-rose-700 p-6 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">MEMO</div>
<h1 className="mt-1 text-[26px] font-extrabold"> </h1>
</header>
<nav className="flex gap-2">
<Link href="/memo?tab=recv" className={`rounded-full px-4 py-1.5 text-[12.5px] font-bold ${tab === 'recv' ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}> </Link>
<Link href="/memo?tab=sent" className={`rounded-full px-4 py-1.5 text-[12.5px] font-bold ${tab === 'sent' ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}> </Link>
</nav>
<form action={sendMemo} className="grid gap-2 rounded-2xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[160px_1fr_auto]">
<input name="to" placeholder="받는 사람 (아이디/닉네임)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" required />
<input name="memo" placeholder="쪽지 내용" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" required />
<button type="submit" className="rounded-lg bg-pink-600 px-4 py-2 text-[13px] font-bold text-white hover:bg-pink-700"></button>
</form>
{rows.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft">{tab === 'recv' ? '받은 쪽지가 없습니다.' : '보낸 쪽지가 없습니다.'}</p>
) : (
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
{rows.map((m) => (
<li key={m.me_id} className="flex items-start gap-3 px-4 py-3">
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-[12px] ${m.me_read_datetime ? 'bg-neutral-100 text-neutral-600' : 'bg-rose-100 text-rose-600 font-bold'}`}>{tab === 'recv' ? '📥' : '📤'}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2 text-[12px] text-neutral-text-soft">
<strong className="text-neutral-800">{tab === 'recv' ? `${m.me_send_mb_id}` : `${m.me_recv_mb_id}`}</strong>
<span>{m.me_send_datetime && new Date(m.me_send_datetime).toLocaleString('ko-KR')}</span>
</div>
<p className="m-0 mt-1 text-[13.5px] text-neutral-800">{m.me_memo}</p>
</div>
<form action={deleteMemo}>
<input type="hidden" name="me_id" value={m.me_id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600 hover:bg-rose-100"></button>
</form>
</li>
))}
</ul>
)}
</article>
);
}
+12 -3
View File
@@ -11,6 +11,8 @@ export interface SpinResult {
symbols?: string[]; // visual reels
multiplier?: number;
message: string;
freeSpin?: boolean; // scatter triggered free-spin (no bet deducted next round)
scatterCount?: number;
}
export interface GameDef {
@@ -33,6 +35,7 @@ export const GAMES: Record<string, GameDef> = {
symbols: ['🐉','🐉','🐉','💰','💰','💰','🍊','🍊','🎯','7️⃣'],
paytable: { '🐉': 30, '💰': 18, '🍊': 7, '🎯': 4, '7️⃣': 80 },
payAny2: 1.5,
wild: '🌟', scatter: '🎁',
minBet: 100, maxBet: 100_000,
},
fivetreasures: {
@@ -155,12 +158,14 @@ function pickSymbol(g: GameDef): string {
}
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 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;
@@ -188,14 +193,18 @@ export function spin(g: GameDef, bet: number): SpinResult {
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}` };
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: '꽝!' };
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 }> {