실시간 마을 채팅 티커
- 오른쪽 상단에 한 줄씩 뜨는 반투명 채팅, 20초 후 페이드아웃 (최대 6줄) - 💬 버튼으로 입력창 토글, Enter 전송 / ESC 닫기 - SSE 스트림(/api/chat/stream): 접속 즉시 연결, 최근 20개 백로그는 비동기 전송, 25초 하트비트로 프록시 유휴 타임아웃 방지 - 전송(/api/chat/send): 닉네임+토큰 검증(사칭 방지), 1.5초 도배 제한, 120자 제한 - chat_messages 테이블 자동 생성, 인프로세스 EventEmitter 브로드캐스트 (단일 컨테이너) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof setInterval> | 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<ChatMessage[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const seen = useRef<Set<number>>(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 (
|
||||||
|
<div className="pointer-events-none absolute right-3 top-15 z-30 flex w-64 flex-col items-end gap-1">
|
||||||
|
{visible.map((m) => {
|
||||||
|
const age = now - m.at;
|
||||||
|
const fading = age > SHOW_MS - 4000;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="max-w-full truncate rounded-lg bg-[rgba(43,29,12,.62)] px-2.5 py-1 text-[11px] leading-snug text-[#f3e6c8] shadow-sm transition-opacity duration-1000"
|
||||||
|
style={{ opacity: fading ? 0.25 : 0.92 }}
|
||||||
|
title={`${m.nickname}: ${m.text}`}
|
||||||
|
>
|
||||||
|
<span className="font-jua text-[#ffd97a]">{m.nickname}</span>
|
||||||
|
<span className="mx-1 text-[#c9b088]">·</span>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="pointer-events-auto mt-1 flex w-full items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void send()}
|
||||||
|
disabled={sending || !draft.trim()}
|
||||||
|
className="wood-pill shrink-0 px-2.5 py-1.5 text-xs hover:brightness-125 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
📨
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="wood-pill shrink-0 px-2 py-1.5 text-xs hover:brightness-125"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="wood-pill pointer-events-auto mt-1 px-2.5 py-1 text-xs hover:brightness-125"
|
||||||
|
title="마을 채팅"
|
||||||
|
>
|
||||||
|
💬 채팅
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useGame } from "@/hooks/useGame";
|
|||||||
import VillageView, { type PlacingState } from "@/components/game/VillageView";
|
import VillageView, { type PlacingState } from "@/components/game/VillageView";
|
||||||
import DbStatus from "@/components/DbStatus";
|
import DbStatus from "@/components/DbStatus";
|
||||||
import NicknameModal from "@/components/game/NicknameModal";
|
import NicknameModal from "@/components/game/NicknameModal";
|
||||||
|
import ChatTicker from "@/components/game/ChatTicker";
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
effectiveRate,
|
effectiveRate,
|
||||||
@@ -232,6 +233,11 @@ export default function Game() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── 실시간 마을 채팅 (닉네임 확정 후) ── */}
|
||||||
|
{state.nickname && state.syncToken && (
|
||||||
|
<ChatTicker nickname={state.nickname} token={state.syncToken} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
|
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
|
||||||
{!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && (
|
{!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && (
|
||||||
<NicknameModal onDone={() => dispatch(() => undefined)} />
|
<NicknameModal onDone={() => dispatch(() => undefined)} />
|
||||||
|
|||||||
+94
@@ -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<string, unknown>).__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<string, number>();
|
||||||
|
const MIN_INTERVAL_MS = 1500;
|
||||||
|
|
||||||
|
export const CHAT_MAX_LEN = 120;
|
||||||
|
|
||||||
|
/** 토큰 검증 후 메시지 저장·브로드캐스트. 실패 사유 문자열 또는 null */
|
||||||
|
export async function sendChat(
|
||||||
|
nickname: string,
|
||||||
|
token: string,
|
||||||
|
rawText: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<ChatMessage[]> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
새 이슈에서 참조
사용자 차단