Merge branch 'm8/mafia' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/server/src/rooms/room.ts # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/engine/src/types.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { MafiaState, MafiaView } from '@bg/games/mafia';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(players: string[]) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const games = { mafia: GAMES.mafia! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'mafia',
|
||||
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
|
||||
for (const p of players) {
|
||||
const msgs: ServerMessage[] = [];
|
||||
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
const state = () => room.game!.state as MafiaState;
|
||||
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
|
||||
return { room, clock, conns, state, lastView };
|
||||
}
|
||||
|
||||
describe('마피아 room flow', () => {
|
||||
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
|
||||
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
|
||||
expect(room.chat('A', '대기실 인사')).toBeNull();
|
||||
expect(room.start('A')).toBeNull();
|
||||
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
|
||||
// Role reveal → night on the server clock.
|
||||
clock.advance(8_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('night');
|
||||
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
|
||||
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
|
||||
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
|
||||
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
|
||||
for (const t of town) {
|
||||
const all = JSON.stringify(conns[t]!.msgs);
|
||||
expect(all).not.toContain('ROOM-MAFIA-SECRET');
|
||||
expect(all).not.toContain(`"role":"mafia"`);
|
||||
}
|
||||
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
|
||||
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
|
||||
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
|
||||
room.act(town[0]!, 3, { type: 'phaseTimeout' });
|
||||
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
|
||||
expect(state().phase.kind).toBe('night');
|
||||
clock.advance(30_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('discussion');
|
||||
expect(state().day).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -564,6 +564,7 @@ export class Room {
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
|
||||
this.lastChatAt.set(userId, now);
|
||||
@@ -581,8 +582,13 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
|
||||
private gameChatOnly(): boolean {
|
||||
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
|
||||
}
|
||||
|
||||
emote(userId: string, id: string): void {
|
||||
if (!this.isParticipant(userId)) return;
|
||||
if (!this.isParticipant(userId) || this.gameChatOnly()) return;
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
|
||||
this.lastChatAt.set(`e:${userId}`, now);
|
||||
|
||||
1030
apps/web/src/games/mafia/index.tsx
Normal file
1030
apps/web/src/games/mafia/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
'ninety-nine': () => import('./ninety-nine'),
|
||||
dobble: () => import('./dobble'),
|
||||
catchmind: () => import('./catchmind'),
|
||||
mafia: () => import('./mafia'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -27,4 +27,6 @@ export interface GameUI<V = any, O = any> {
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
optionsSummary(options: O): string;
|
||||
Rules: ComponentType<{ options?: O }>;
|
||||
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
||||
ownChat?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export const GAME_ICON: Record<string, string> = {
|
||||
'ninety-nine': '💯',
|
||||
dobble: '👀',
|
||||
catchmind: '🖍️',
|
||||
mafia: '🌙',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['도블', '그림 맞히기', '마피아'];
|
||||
export const UPCOMING: string[] = [];
|
||||
|
||||
@@ -9,10 +9,12 @@ export default function GamesPage() {
|
||||
<main className="page">
|
||||
<h1>게임 목록</h1>
|
||||
<GameGrid games={catalog.filter((g) => g.available)} />
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
{UPCOMING.length > 0 && (
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Home() {
|
||||
<section className="stack" style={{ gap: 16 }}>
|
||||
<h2>게임 고르기</h2>
|
||||
<GameGrid games={available} />
|
||||
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>
|
||||
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
|
||||
</section>
|
||||
<aside className="stack" style={{ gap: 12 }}>
|
||||
<h2>지금 열린 방</h2>
|
||||
|
||||
@@ -136,6 +136,8 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
|
||||
const g = { nameKo: gameName(room.gameId) };
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
|
||||
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
socket.leave();
|
||||
@@ -157,9 +159,11 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
</strong>
|
||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
</span>
|
||||
{!roomChatClosed && (
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
</span>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙
|
||||
</button>
|
||||
@@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
)}
|
||||
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
|
||||
<EmoteBubbles room={room} />
|
||||
{chatOpen && (
|
||||
{chatOpen && !roomChatClosed && (
|
||||
<Sheet onClose={() => setChatOpen(false)} label="채팅">
|
||||
<ChatPanel room={room} me={me} />
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
|
||||
@@ -549,7 +553,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
|
||||
<aside className="game-side" aria-label="게임 정보">
|
||||
{wide && status}
|
||||
<ui.Side {...props} />
|
||||
{wide && <ChatPanel room={room} me={me} title={false} />}
|
||||
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user