Skip to content

Commit d8c312f

Browse files
committed
Extract lobby UI view and update documentation
Lobby extraction: - Extract lobby/menu rendering from ui.ts into lobby-view.ts - Add lobby-view tests Documentation updates: - Fix stale LOC counts in shared module inventory - Update client module inventory (file counts, LOC, descriptions) to reflect recent decomposition work - Mark Phases 0-2 as done in backlog - Update reactive signals note with known limitations - Fix command dispatch references (now command-router.ts) - Update reusability table and deferred-item stats
1 parent ca71bf7 commit d8c312f

File tree

3 files changed

+318
-149
lines changed

3 files changed

+318
-149
lines changed

src/client/ui/lobby-view.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { LobbyView } from './lobby-view';
5+
6+
const installFixture = () => {
7+
document.body.innerHTML = `
8+
<button id="createBtn">Create Game</button>
9+
<button id="singlePlayerBtn">Single Player</button>
10+
<button id="backBtn">Back</button>
11+
<div id="scenarioList"></div>
12+
<button class="btn-difficulty" data-difficulty="easy">Easy</button>
13+
<button class="btn-difficulty active" data-difficulty="normal">Normal</button>
14+
<button class="btn-difficulty" data-difficulty="hard">Hard</button>
15+
<button id="joinBtn">Join</button>
16+
<input id="codeInput" />
17+
<button id="copyBtn">Copy Link</button>
18+
<div id="gameCode"></div>
19+
<div id="waitingStatus"></div>
20+
`;
21+
};
22+
23+
describe('LobbyView', () => {
24+
beforeEach(() => {
25+
installFixture();
26+
vi.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
vi.useRealTimers();
31+
});
32+
33+
it('emits multiplayer and single-player scenario events', () => {
34+
const emit = vi.fn();
35+
const showMenu = vi.fn();
36+
const showScenarioSelect = vi.fn();
37+
new LobbyView({
38+
emit,
39+
showMenu,
40+
showScenarioSelect,
41+
});
42+
43+
document.getElementById('createBtn')?.click();
44+
expect(showScenarioSelect).toHaveBeenCalledTimes(1);
45+
46+
const scenarioButtons = Array.from(
47+
document.querySelectorAll<HTMLElement>('#scenarioList .btn-scenario'),
48+
);
49+
scenarioButtons[0]?.click();
50+
expect(emit).toHaveBeenCalledWith({
51+
type: 'selectScenario',
52+
scenario: scenarioButtons[0]?.dataset.scenario,
53+
});
54+
55+
document.getElementById('singlePlayerBtn')?.click();
56+
(
57+
document.querySelector('[data-difficulty="hard"]') as HTMLElement
58+
)?.click();
59+
scenarioButtons[1]?.click();
60+
expect(emit).toHaveBeenCalledWith({
61+
type: 'startSinglePlayer',
62+
scenario: scenarioButtons[1]?.dataset.scenario,
63+
difficulty: 'hard',
64+
});
65+
});
66+
67+
it('parses join input and back navigation', () => {
68+
const emit = vi.fn();
69+
const showMenu = vi.fn();
70+
new LobbyView({
71+
emit,
72+
showMenu,
73+
showScenarioSelect: vi.fn(),
74+
});
75+
76+
const input = document.getElementById('codeInput') as HTMLInputElement;
77+
input.value = 'https://example.test/?code=abcde&playerToken=tok';
78+
document.getElementById('joinBtn')?.click();
79+
80+
expect(emit).toHaveBeenCalledWith({
81+
type: 'join',
82+
code: 'ABCDE',
83+
playerToken: 'tok',
84+
});
85+
86+
document.getElementById('backBtn')?.click();
87+
expect(emit).toHaveBeenCalledWith({ type: 'backToMenu' });
88+
expect(showMenu).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it('updates waiting copy, menu loading, and copy-link feedback', async () => {
92+
const copyText = vi
93+
.fn<(text: string) => Promise<void>>()
94+
.mockResolvedValue();
95+
const view = new LobbyView({
96+
emit: vi.fn(),
97+
showMenu: vi.fn(),
98+
showScenarioSelect: vi.fn(),
99+
copyText,
100+
});
101+
102+
view.setMenuLoading(true);
103+
expect(
104+
(document.getElementById('createBtn') as HTMLButtonElement).disabled,
105+
).toBe(true);
106+
expect(document.getElementById('createBtn')?.textContent).toBe(
107+
'CREATING...',
108+
);
109+
110+
view.showWaiting('ABCDE');
111+
expect(document.getElementById('gameCode')?.textContent).toBe('ABCDE');
112+
expect(document.getElementById('waitingStatus')?.textContent).toBe(
113+
'Waiting for opponent...',
114+
);
115+
116+
view.showConnecting();
117+
expect(document.getElementById('gameCode')?.textContent).toBe('...');
118+
expect(document.getElementById('waitingStatus')?.textContent).toBe(
119+
'Connecting...',
120+
);
121+
122+
const gameCode = document.getElementById('gameCode');
123+
expect(gameCode).not.toBeNull();
124+
if (!gameCode) {
125+
throw new Error('Expected #gameCode');
126+
}
127+
gameCode.textContent = 'ABCDE';
128+
document.getElementById('copyBtn')?.click();
129+
await Promise.resolve();
130+
131+
expect(copyText).toHaveBeenCalledWith('http://localhost:3000/?code=ABCDE');
132+
expect(document.getElementById('copyBtn')?.textContent).toBe('Copied!');
133+
134+
vi.advanceTimersByTime(2000);
135+
expect(document.getElementById('copyBtn')?.textContent).toBe('Copy Link');
136+
});
137+
});

src/client/ui/lobby-view.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { CODE_LENGTH } from '../../shared/constants';
2+
import { SCENARIOS } from '../../shared/map-data';
3+
import { byId } from '../dom';
4+
import type { AIDifficulty, UIEvent } from './events';
5+
import { parseJoinInput } from './formatters';
6+
import { buildWaitingScreenCopy } from './screens';
7+
8+
export interface LobbyViewDeps {
9+
emit: (event: UIEvent) => void;
10+
showMenu: () => void;
11+
showScenarioSelect: () => void;
12+
copyText?: (text: string) => Promise<void> | undefined;
13+
}
14+
15+
export class LobbyView {
16+
private aiDifficulty: AIDifficulty = 'normal';
17+
private pendingAIGame = false;
18+
19+
constructor(private readonly deps: LobbyViewDeps) {
20+
this.bindMenuControls();
21+
this.bindDifficultyButtons();
22+
this.buildScenarioList();
23+
this.bindJoinControls();
24+
this.bindCopyButton();
25+
}
26+
27+
onMenuShown(): void {
28+
this.pendingAIGame = false;
29+
}
30+
31+
setMenuLoading(loading: boolean): void {
32+
const btn = byId<HTMLButtonElement>('createBtn');
33+
34+
btn.disabled = loading;
35+
btn.textContent = loading ? 'CREATING...' : 'Create Game';
36+
}
37+
38+
showWaiting(code: string): void {
39+
const copy = buildWaitingScreenCopy(code, false);
40+
byId('gameCode').textContent = copy.codeText;
41+
byId('waitingStatus').textContent = copy.statusText;
42+
}
43+
44+
showConnecting(): void {
45+
const copy = buildWaitingScreenCopy('', true);
46+
byId('gameCode').textContent = copy.codeText;
47+
byId('waitingStatus').textContent = copy.statusText;
48+
}
49+
50+
private bindMenuControls(): void {
51+
byId('createBtn').addEventListener('click', () => {
52+
this.deps.showScenarioSelect();
53+
});
54+
55+
byId('singlePlayerBtn').addEventListener('click', () => {
56+
this.pendingAIGame = true;
57+
this.deps.showScenarioSelect();
58+
});
59+
60+
byId('backBtn').addEventListener('click', () => {
61+
this.deps.emit({ type: 'backToMenu' });
62+
this.deps.showMenu();
63+
});
64+
}
65+
66+
private bindDifficultyButtons(): void {
67+
const buttons = Array.from(
68+
document.querySelectorAll<HTMLElement>('.btn-difficulty'),
69+
);
70+
71+
for (const btn of buttons) {
72+
btn.addEventListener('click', (event: Event) => {
73+
event.stopPropagation();
74+
75+
this.aiDifficulty = btn.dataset.difficulty as AIDifficulty;
76+
77+
for (const button of buttons) {
78+
button.classList.remove('active');
79+
}
80+
81+
btn.classList.add('active');
82+
});
83+
}
84+
}
85+
86+
private submitJoin(rawValue: string): void {
87+
const parsed = parseJoinInput(rawValue, CODE_LENGTH);
88+
if (!parsed) {
89+
return;
90+
}
91+
92+
this.deps.emit({
93+
type: 'join',
94+
code: parsed.code,
95+
playerToken: parsed.playerToken,
96+
});
97+
}
98+
99+
private bindJoinControls(): void {
100+
byId('joinBtn').addEventListener('click', () => {
101+
this.submitJoin(byId<HTMLInputElement>('codeInput').value);
102+
});
103+
104+
byId('codeInput').addEventListener('keydown', (event) => {
105+
if (event.key === 'Enter') {
106+
this.submitJoin((event.target as HTMLInputElement).value);
107+
}
108+
});
109+
}
110+
111+
private bindCopyButton(): void {
112+
byId('copyBtn').addEventListener('click', () => {
113+
const code = byId('gameCode').textContent ?? '';
114+
const url = `${window.location.origin}/?code=${code}`;
115+
const copyText =
116+
this.deps.copyText ??
117+
((text: string) => navigator.clipboard?.writeText(text));
118+
const copyPromise = copyText(url);
119+
120+
copyPromise
121+
?.then(() => {
122+
byId('copyBtn').textContent = 'Copied!';
123+
124+
setTimeout(() => {
125+
byId('copyBtn').textContent = 'Copy Link';
126+
}, 2000);
127+
})
128+
.catch(() => {});
129+
});
130+
}
131+
132+
private buildScenarioList(): void {
133+
const container = byId('scenarioList');
134+
135+
for (const [key, def] of Object.entries(SCENARIOS)) {
136+
const btn = document.createElement('button');
137+
btn.className = 'btn btn-scenario';
138+
btn.dataset.scenario = key;
139+
140+
const tags = (def.tags ?? [])
141+
.map((tag) => `<span class="scenario-tag">${tag}</span>`)
142+
.join('');
143+
144+
btn.innerHTML =
145+
`<div class="scenario-name">${def.name}${tags}</div>` +
146+
`<div class="scenario-desc">${def.description}</div>`;
147+
148+
btn.addEventListener('click', () => {
149+
if (this.pendingAIGame) {
150+
this.pendingAIGame = false;
151+
this.deps.emit({
152+
type: 'startSinglePlayer',
153+
scenario: key,
154+
difficulty: this.aiDifficulty,
155+
});
156+
return;
157+
}
158+
159+
this.deps.emit({
160+
type: 'selectScenario',
161+
scenario: key,
162+
});
163+
});
164+
165+
container.appendChild(btn);
166+
}
167+
}
168+
}

0 commit comments

Comments
 (0)