diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts new file mode 100644 index 0000000..4e2d658 --- /dev/null +++ b/src/app/api/chat/send/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { sendChat, CHAT_MAX_LEN } from "@/lib/chat"; +import { validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const { nickname, token, text } = await req.json().catch(() => ({})); + if ( + !validNickname(nickname) || + typeof token !== "string" || + typeof text !== "string" || + text.length > CHAT_MAX_LEN * 4 + ) + return NextResponse.json({ ok: false }, { status: 400 }); + try { + const err = await sendChat(nickname, token, text); + if (err === "auth") return NextResponse.json({ ok: false }, { status: 403 }); + if (err === "too_fast") + return NextResponse.json({ ok: false, reason: err }, { status: 429 }); + return NextResponse.json({ ok: !err }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/app/api/chat/stream/route.ts b/src/app/api/chat/stream/route.ts new file mode 100644 index 0000000..4e2081e --- /dev/null +++ b/src/app/api/chat/stream/route.ts @@ -0,0 +1,61 @@ +import { onChat, recentChat, type ChatMessage } from "@/lib/chat"; + +export const dynamic = "force-dynamic"; + +/** SSE: 접속 시 최근 20개 → 이후 새 메시지 실시간 푸시 */ +export async function GET(req: Request) { + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; + + const stream = new ReadableStream({ + async start(controller) { + const push = (msg: ChatMessage) => { + try { + controller.enqueue( + encoder.encode(`id: ${msg.id}\ndata: ${JSON.stringify(msg)}\n\n`), + ); + } catch { + /* 이미 닫힘 */ + } + }; + // 실시간 구독을 먼저 연결 (백로그 DB 조회가 느려도 스트림은 즉시 열림) + unsubscribe = onChat(push); + controller.enqueue(encoder.encode(": connected\n\n")); + void recentChat(20) + .then((backlog) => backlog.forEach(push)) + .catch(() => { + /* DB 불가 시 백로그 생략, 실시간만 */ + }); + // Traefik/프록시 유휴 타임아웃 방지 + heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + /* ignore */ + } + }, 25000); + req.signal.addEventListener("abort", () => { + unsubscribe?.(); + if (heartbeat) clearInterval(heartbeat); + try { + controller.close(); + } catch { + /* ignore */ + } + }); + }, + cancel() { + unsubscribe?.(); + if (heartbeat) clearInterval(heartbeat); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/src/components/game/ChatTicker.tsx b/src/components/game/ChatTicker.tsx new file mode 100644 index 0000000..462fdeb --- /dev/null +++ b/src/components/game/ChatTicker.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface ChatMessage { + id: number; + nickname: string; + text: string; + at: number; +} + +const SHOW_MS = 20000; // 한 줄이 머무는 시간 +const MAX_LINES = 6; + +/** 오른쪽 상단 실시간 마을 채팅 티커 */ +export default function ChatTicker({ + nickname, + token, +}: { + nickname: string; + token: string; +}) { + const [messages, setMessages] = useState([]); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const seen = useRef>(new Set()); + const boot = useRef(Date.now()); + + // SSE 구독 (EventSource가 끊기면 자동 재접속) + useEffect(() => { + const es = new EventSource("/api/chat/stream"); + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) as ChatMessage; + if (seen.current.has(msg.id)) return; + seen.current.add(msg.id); + // 접속 시점 백로그는 "이미 지난 대화" — 은은하게 잠깐만 보여주도록 시각 보정 + if (msg.at < boot.current - SHOW_MS) msg.at = boot.current - SHOW_MS * 0.7; + // 백로그가 실시간보다 늦게 도착할 수 있어 id 순으로 정렬 유지 + setMessages((prev) => + [...prev, msg].sort((a, b) => a.id - b.id).slice(-50), + ); + } catch { + /* ignore */ + } + }; + return () => es.close(); + }, []); + + // 오래된 줄 페이드아웃용 시계 + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const visible = messages.filter((m) => now - m.at < SHOW_MS).slice(-MAX_LINES); + + const send = async () => { + const text = draft.trim(); + if (!text || sending) return; + setSending(true); + try { + await fetch("/api/chat/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname, token, text }), + }); + setDraft(""); + } finally { + setSending(false); + } + }; + + return ( +
+ {visible.map((m) => { + const age = now - m.at; + const fading = age > SHOW_MS - 4000; + return ( +
+ {m.nickname} + · + {m.text} +
+ ); + })} + + {open ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) void send(); + if (e.key === "Escape") setOpen(false); + }} + maxLength={120} + autoFocus + placeholder="마을 사람들에게 한마디…" + className="w-full rounded-lg border-2 border-[rgba(126,95,48,.4)] bg-[rgba(255,250,238,.95)] px-2.5 py-1.5 text-xs text-[#3f2c14] outline-none focus:border-[#7fb539]" + /> + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 77b83c0..02bb401 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -5,6 +5,7 @@ import { useGame } from "@/hooks/useGame"; import VillageView, { type PlacingState } from "@/components/game/VillageView"; import DbStatus from "@/components/DbStatus"; import NicknameModal from "@/components/game/NicknameModal"; +import ChatTicker from "@/components/game/ChatTicker"; import { engine, effectiveRate, @@ -232,6 +233,11 @@ export default function Game() { } /> + {/* ── 실시간 마을 채팅 (닉네임 확정 후) ── */} + {state.nickname && state.syncToken && ( + + )} + {/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */} {!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && ( dispatch(() => undefined)} /> diff --git a/src/lib/chat.ts b/src/lib/chat.ts new file mode 100644 index 0000000..b2c0a66 --- /dev/null +++ b/src/lib/chat.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from "node:events"; +import { db } from "@/lib/db"; +import { ensureTable as ensureVillages } from "@/lib/villages"; + +let tableReady = false; + +export interface ChatMessage { + id: number; + nickname: string; + text: string; + at: number; // epoch ms +} + +/** chat_messages 테이블 보장 (최초 1회) */ +export async function ensureChatTable() { + if (tableReady) return; + await db.query(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGSERIAL PRIMARY KEY, + nickname TEXT NOT NULL, + text TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() + ) + `); + tableReady = true; +} + +// 단일 컨테이너 전제의 인프로세스 브로드캐스트 (HMR에도 살아남도록 globalThis에 보관) +const bus: EventEmitter = ((globalThis as Record).__chatBus ??= + new EventEmitter()) as EventEmitter; +bus.setMaxListeners(500); + +export function onChat(fn: (msg: ChatMessage) => void): () => void { + bus.on("msg", fn); + return () => bus.off("msg", fn); +} + +// 도배 방지: 닉네임당 최소 전송 간격 +const lastSent = new Map(); +const MIN_INTERVAL_MS = 1500; + +export const CHAT_MAX_LEN = 120; + +/** 토큰 검증 후 메시지 저장·브로드캐스트. 실패 사유 문자열 또는 null */ +export async function sendChat( + nickname: string, + token: string, + rawText: string, +): Promise { + const text = rawText.replace(/\s+/g, " ").trim().slice(0, CHAT_MAX_LEN); + if (!text) return "empty"; + const now = Date.now(); + if (now - (lastSent.get(nickname) ?? 0) < MIN_INTERVAL_MS) return "too_fast"; + + await ensureVillages(); + const owner = await db.query( + "SELECT 1 FROM villages WHERE nickname = $1 AND token = $2", + [nickname, token], + ); + if (owner.rowCount === 0) return "auth"; + + await ensureChatTable(); + const r = await db.query( + "INSERT INTO chat_messages (nickname, text) VALUES ($1, $2) RETURNING id, extract(epoch from created_at) * 1000 AS at", + [nickname, text], + ); + lastSent.set(nickname, now); + const msg: ChatMessage = { + id: Number(r.rows[0].id), + nickname, + text, + at: Math.round(Number(r.rows[0].at)), + }; + bus.emit("msg", msg); + return null; +} + +/** 최근 메시지 (오래된 것 → 최신 순) */ +export async function recentChat(limit = 20): Promise { + await ensureChatTable(); + const r = await db.query( + `SELECT id, nickname, text, extract(epoch from created_at) * 1000 AS at + FROM chat_messages ORDER BY id DESC LIMIT $1`, + [limit], + ); + return r.rows + .map((row) => ({ + id: Number(row.id), + nickname: row.nickname as string, + text: row.text as string, + at: Math.round(Number(row.at)), + })) + .reverse(); +}