diff --git a/AGENTS.md b/AGENTS.md index a17bd50..d965c79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,12 @@ # 프로젝트 작업 규칙 +## 작업 완료 후 커밋 + +- 매 작업을 완료할 때 관련 검증을 수행한 뒤 해당 작업의 변경 사항을 반드시 커밋한다. +- 별도의 커밋 요청이나 확인을 기다리지 않는다. 사용자가 명시적으로 커밋하지 말라고 요청한 경우는 예외로 한다. +- 해당 작업과 무관한 기존 변경 사항은 커밋에 포함하지 않는다. +- 완료 보고에 커밋 해시와 검증 결과를 명시한다. 푸시는 사용자가 요청한 경우에 수행한다. + ## UI 문구 최소화 화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다. diff --git a/src/client/main.ts b/src/client/main.ts index bb67055..a3fc0ca 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -181,6 +181,9 @@ async function acceptRoom(room: PeerRoom) { waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; }); waitingSocket.connect(); } +async function joinRoom(code: string) { + await acceptRoom(await api.request('/api/rooms/join', 'POST', { code: code.trim().toUpperCase(), requestId: crypto.randomUUID() })); +} async function rematch() { if (!currentMatch?.finished?.saved) return; if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; } @@ -257,7 +260,7 @@ async function boot() { const active = api.session.activeMatch; try { if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; } - } catch (error) { errorNotice(error); } + } catch (error) { errorNotice(error); return; } if (active) { if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) { await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); @@ -292,7 +295,14 @@ async function boot() { } else if (currentRoom) { try { await acceptRoom(await api.request('/api/rooms/' + currentRoom.id)); } catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); } - } else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home'); + } else { + setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home'); + if (pendingInvitation) { + const code = pendingInvitation; pendingInvitation = undefined; + analytics.click('room_join'); + try { await joinRoom(code); } catch (error) { errorNotice(error); } + } + } ready = true; if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); } } catch (error) { errorNotice(error); } @@ -302,7 +312,7 @@ el('bot-start').onclick = () => { void perform(() => launch('bot'), 'bot_start') el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); }; el('join-open').onclick = () => { setScreen('join'); el('room-code').focus(); }; el('join-close').onclick = () => setScreen('home'); -el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request('/api/rooms/join', 'POST', { code: el('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); }; +el('join-form').onsubmit = e => { e.preventDefault(); void perform(() => joinRoom(el('room-code').value), 'room_join'); }; el('waiting-leave').onclick = () => { void perform(leave); }; el('reconnect').onclick = () => { if (controller?.view().phase === 'finished') { void controller.retrySave(); return; } @@ -318,7 +328,8 @@ async function copy(link: boolean) { el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); }; el('rules-list').innerHTML = ACTION_IDS.map(id => '
' + ACTIONS[id].name + '기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '
').join(''); const invitation = new URLSearchParams(location.search).get('room'); -if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el('room-code').value = invitation.toUpperCase(); +let pendingInvitation = invitation && /^[A-Z0-9]{6}$/i.test(invitation) ? invitation.toUpperCase() : undefined; +if (pendingInvitation) el('room-code').value = pendingInvitation; lobby = createLobbyFeatures({ api, onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; }, diff --git a/tests/main-recovery.test.ts b/tests/main-recovery.test.ts index 9bb305c..3346948 100644 --- a/tests/main-recovery.test.ts +++ b/tests/main-recovery.test.ts @@ -12,18 +12,26 @@ import { emptyPlayerStats } from '../src/shared/p2p-types.js'; const source = readFileSync('src/client/main.ts', 'utf8').replace(/^import .*;\r?\n/gm, ''); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText; const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); }; -async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') { +async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result' | 'invitation' | 'invitation-expired') { + const invited = kind.startsWith('invitation'); + let saveNickname!: () => void; + const nicknameSaved = new Promise(resolve => { saveNickname = resolve; }); const mem = new Map(), calls: string[] = [], polls = new Map void>(), launched: string[] = []; const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' }; const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) }; const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] }; let open = true, pollId = 0; - mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room })); - if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: { + if (!invited) mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room })); + if (!invited && kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: { matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved', } } })); const send: typeof fetch = async (input, init) => { const path = String(input); calls.push(path); let data: any = session; + if (path === '/api/rooms/join') { + assert.equal(JSON.parse(String(init?.body)).code, 'ABCDEF'); + if (kind === 'invitation-expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: '방이 만료되었습니다.' }), { status: 410 }); + data = { ...room, selfIndex: 1 }; + } if (path === '/api/rooms/r' || path.endsWith('/leave')) { if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 }); if (path.endsWith('/leave')) open = false; @@ -46,13 +54,33 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') { createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), - createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }), + createLobbyFeatures: () => ({ initialize: async () => { if (invited) await nicknameSaved; return false; }, setReady() {}, active: false }), createMatchController: (config: any) => { launched.push(config.activeMatch.matchId); return { subscribe() {}, start: async () => {}, view: () => ({ matchId: config.activeMatch.matchId }), destroy() {} }; }, setTimeout(fn: () => void) { polls.set(++pollId, fn); return pollId; }, clearTimeout(id: number) { polls.delete(id); }, }; + if (invited) ctx.location.search = '?room=abcdef'; vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom,currentMatch})};', ctx); await flush(); - return { ctx, calls, polls, room, mem, launched, open: () => open }; + return { ctx, calls, polls, room, mem, launched, saveNickname, nodes, open: () => open }; } + +test('an invitation joins the host room immediately after first-time nickname registration', async () => { + const h = await setup('invitation'); + assert.equal(h.calls.includes('/api/rooms/join'), false, 'wait for nickname registration'); + h.saveNickname(); await flush(); + assert.equal(h.calls.filter(path => path === '/api/rooms/join').length, 1); + assert.deepEqual(h.launched, ['m']); + assert.equal(h.ctx.review.inspect().currentRoom.selfIndex, 1); +}); + +test('an expired invitation shows the join form and error after nickname registration', async () => { + const h = await setup('invitation-expired'); + h.saveNickname(); await flush(); + assert.equal(h.calls.filter(path => path === '/api/rooms/join').length, 1); + assert.equal(h.ctx.review.inspect().currentScreen, 'join'); + assert.equal(h.ctx.review.inspect().ready, true); + assert.equal(h.nodes.get('notice').textContent, '방이 만료되었습니다.'); + assert.deepEqual(h.launched, []); +}); test('already-expired room does not trap a restored result on exit', async () => { const h = await setup('expired'); await h.ctx.review.leave(); assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false);