ddd-team1 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +30 -0
  2. package/package.json +35 -0
  3. package/src/application/AuthService.ts +3 -0
  4. package/src/application/GameRepository.ts +11 -0
  5. package/src/application/GetGameStateQuery.ts +46 -0
  6. package/src/application/MakeMoveUseCase.ts +22 -0
  7. package/src/application/OnlineGameService.ts +17 -0
  8. package/src/domain/Command.ts +12 -0
  9. package/src/domain/DomainEvent.ts +20 -0
  10. package/src/domain/Game.ts +72 -0
  11. package/src/domain/shared/AggregateRoot.ts +21 -0
  12. package/src/domain/shared/BusinessRules/BusinessRule.ts +16 -0
  13. package/src/domain/shared/BusinessRules/BusinessRuleViolation.ts +7 -0
  14. package/src/domain/shared/BusinessRules/BusinessRuleViolationError.ts +10 -0
  15. package/src/domain/shared/Entity.ts +7 -0
  16. package/src/domain/shared/ValueObject.ts +20 -0
  17. package/src/domain/shared/index.ts +6 -0
  18. package/src/domain/value-objects/Board.ts +197 -0
  19. package/src/domain/value-objects/Piece.ts +91 -0
  20. package/src/domain/value-objects/PieceV2.ts +79 -0
  21. package/src/domain/value-objects/Position.ts +80 -0
  22. package/src/domain/value-objects/StandardAlgebraicNotationMove.ts +168 -0
  23. package/src/domain/value-objects/pieces/Rook.ts +31 -0
  24. package/src/infrastructure/FileBasedGameRepository.ts +50 -0
  25. package/src/infrastructure/InMemoryGameRepository.ts +29 -0
  26. package/src/infrastructure/firebase/FirebaseAuthService.ts +15 -0
  27. package/src/infrastructure/firebase/FirestoreGameRepository.ts +67 -0
  28. package/src/infrastructure/firebase/FirestoreOnlineGameService.ts +117 -0
  29. package/src/infrastructure/firebase/configStore.ts +85 -0
  30. package/src/infrastructure/firebase/filePersistence.ts +30 -0
  31. package/src/infrastructure/firebase/firebaseConfig.ts +41 -0
  32. package/src/infrastructure/tui/App.tsx +159 -0
  33. package/src/infrastructure/tui/GameRepositoryContext.tsx +48 -0
  34. package/src/infrastructure/tui/components/BoardView.tsx +46 -0
  35. package/src/infrastructure/tui/components/GameMenu.tsx +171 -0
  36. package/src/infrastructure/tui/components/GameScreen.tsx +184 -0
  37. package/src/infrastructure/tui/components/HostGameScreen.tsx +83 -0
  38. package/src/infrastructure/tui/components/JoinGameScreen.tsx +84 -0
  39. package/src/infrastructure/tui/components/LogMessages.tsx +51 -0
  40. package/src/infrastructure/tui/components/MoveInput.tsx +140 -0
  41. package/src/infrastructure/tui/components/SetupScreen.tsx +85 -0
  42. package/src/infrastructure/tui/index.tsx +70 -0
@@ -0,0 +1,184 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import type { UUID } from 'crypto';
4
+ import type { EventDTO, PieceDTO } from '../../../application/GetGameStateQuery';
5
+ import { GetGameStateQuery } from '../../../application/GetGameStateQuery';
6
+ import { MakeMoveUseCase } from '../../../application/MakeMoveUseCase';
7
+ import { useGameRepository } from '../GameRepositoryContext';
8
+ import { BoardView } from './BoardView';
9
+ import { type LogMessage, LogMessages } from './LogMessages';
10
+ import { MoveInput } from './MoveInput';
11
+
12
+ const MAX_LOG_MESSAGES = 8;
13
+
14
+ function eventsToLogMessages(newEvents: EventDTO[], allEvents: EventDTO[]): LogMessage[] {
15
+ if (newEvents.length === 0) return [];
16
+ const maxNameLen = Math.max(...allEvents.map((e) => e.name.length));
17
+ return newEvents.map((e) => ({
18
+ text: e.payload
19
+ ? `${e.name.padEnd(maxNameLen)} ${e.payload}`
20
+ : e.name,
21
+ type: 'info' as const,
22
+ }));
23
+ }
24
+
25
+ interface GameScreenProps {
26
+ gameId: UUID;
27
+ gameNumber: number;
28
+ onBack: () => void;
29
+ onExit: () => void;
30
+ mode?: 'local' | 'online';
31
+ myColor?: 'white' | 'black';
32
+ }
33
+
34
+ export function GameScreen({ gameId, gameNumber, onBack, onExit, mode = 'local', myColor }: GameScreenProps) {
35
+ const { gameStateQuery, makeMoveUseCase, onlineGameService, createOnlineRepo } = useGameRepository();
36
+ const [boardState, setBoardState] = useState<(PieceDTO | null)[][] | null>(null);
37
+ const [currentPlayer, setCurrentPlayer] = useState<'white' | 'black'>('white');
38
+ const [logMessages, setLogMessages] = useState<LogMessage[]>([]);
39
+ const [hasGuest, setHasGuest] = useState(false);
40
+ const [logExpanded, setLogExpanded] = useState(false);
41
+ const loggedEventCount = useRef(0);
42
+
43
+ const onlineRepo = useMemo(() => {
44
+ if (mode === 'online') return createOnlineRepo(gameId);
45
+ return null;
46
+ }, [mode, gameId]);
47
+
48
+ const onlineMoveUseCase = useMemo(() => {
49
+ if (onlineRepo) return new MakeMoveUseCase(onlineRepo);
50
+ return null;
51
+ }, [onlineRepo]);
52
+
53
+ const onlineStateQuery = useMemo(() => {
54
+ if (onlineRepo) return new GetGameStateQuery(onlineRepo);
55
+ return null;
56
+ }, [onlineRepo]);
57
+
58
+ function applyState(board: (PieceDTO | null)[][], player: 'white' | 'black', events: EventDTO[]) {
59
+ setBoardState(board);
60
+ setCurrentPlayer(player);
61
+
62
+ const newEvents = events.slice(loggedEventCount.current);
63
+ if (newEvents.length > 0) {
64
+ setLogMessages((msgs) => [...msgs, ...eventsToLogMessages(newEvents, events)]);
65
+ }
66
+ loggedEventCount.current = events.length;
67
+ }
68
+
69
+ // Local mode: load initial state
70
+ useEffect(() => {
71
+ if (mode !== 'local') return;
72
+ gameStateQuery.execute(gameId).then((state) => {
73
+ applyState(state.board, state.currentPlayer, state.events);
74
+ });
75
+ }, [gameId, mode]);
76
+
77
+ // Online mode: track game info (guest joining)
78
+ useEffect(() => {
79
+ if (mode !== 'online' || !onlineGameService) return;
80
+
81
+ const unsubscribe = onlineGameService.onGameInfoChanged(gameId, (info) => {
82
+ setHasGuest(info.guestUid !== null);
83
+ });
84
+
85
+ return unsubscribe;
86
+ }, [gameId, mode, onlineGameService]);
87
+
88
+ // Online mode: subscribe to real-time events, rebuild state via query
89
+ useEffect(() => {
90
+ if (mode !== 'online' || !onlineGameService || !onlineStateQuery) return;
91
+
92
+ const unsubscribe = onlineGameService.onEventsChanged(gameId, () => {
93
+ onlineStateQuery.execute(gameId).then((state) => {
94
+ applyState(state.board, state.currentPlayer, state.events);
95
+ });
96
+ });
97
+
98
+ return unsubscribe;
99
+ }, [gameId, mode, onlineGameService, onlineStateQuery]);
100
+
101
+ const addLog = useCallback((text: string, type: LogMessage['type']) => {
102
+ setLogMessages((prev) => [...prev, { text, type }]);
103
+ }, []);
104
+
105
+ const handleMove = useCallback(async (standardAlgebraicNotation: string) => {
106
+ if (mode === 'online') {
107
+ if (!onlineMoveUseCase) return;
108
+ try {
109
+ await onlineMoveUseCase.execute(gameId, standardAlgebraicNotation);
110
+ } catch (e) {
111
+ const message = e instanceof Error ? e.message : String(e);
112
+ addLog(`${standardAlgebraicNotation} — ${message}`, 'error');
113
+ }
114
+ } else {
115
+ try {
116
+ await makeMoveUseCase.execute(gameId, standardAlgebraicNotation);
117
+ const updatedState = await gameStateQuery.execute(gameId);
118
+ applyState(updatedState.board, updatedState.currentPlayer, updatedState.events);
119
+ } catch (e) {
120
+ const message = e instanceof Error ? e.message : String(e);
121
+ addLog(`${standardAlgebraicNotation} — ${message}`, 'error');
122
+ }
123
+ }
124
+ }, [gameId, mode]);
125
+
126
+ useInput((_ch, key) => {
127
+ if (key.ctrl && _ch === 'o') {
128
+ setLogExpanded((prev) => !prev);
129
+ }
130
+ });
131
+
132
+ const handleCommand = useCallback((command: string) => {
133
+ switch (command) {
134
+ case '/menu':
135
+ onBack();
136
+ break;
137
+ case '/exit':
138
+ onExit();
139
+ break;
140
+ }
141
+ }, [onBack, onExit]);
142
+
143
+ if (!boardState) {
144
+ return <Text>Loading...</Text>;
145
+ }
146
+
147
+ const isMyTurn = mode === 'local' || (hasGuest && currentPlayer === myColor);
148
+ const disabledMessage = !isMyTurn
149
+ ? (mode === 'online' && !hasGuest
150
+ ? `Waiting for invitation to be accepted... Invite code: ${gameId}`
151
+ : `Waiting for opponent (${currentPlayer})...`)
152
+ : undefined;
153
+
154
+ return (
155
+ <Box flexDirection="column" padding={1}>
156
+ <Box>
157
+ <Text bold>Chess - DDD</Text>
158
+ <Text dimColor> — </Text>
159
+ {mode === 'online' ? (
160
+ <Text bold color="magenta">Online ({gameId.slice(0, 8)}) — you are {myColor === 'white' ? '♔' : '♚'} {myColor}</Text>
161
+ ) : (
162
+ <Text bold color="cyan">Game #{gameNumber} ({gameId})</Text>
163
+ )}
164
+ </Box>
165
+ <Box marginTop={1}>
166
+ <BoardView board={boardState} />
167
+ </Box>
168
+ <MoveInput
169
+ onMove={handleMove}
170
+ onCommand={handleCommand}
171
+ currentPlayer={currentPlayer}
172
+ onEscape={onBack}
173
+ disabled={!isMyTurn}
174
+ disabledMessage={disabledMessage}
175
+ />
176
+ <LogMessages
177
+ messages={logExpanded ? logMessages : logMessages.slice(-MAX_LOG_MESSAGES)}
178
+ totalCount={logMessages.length}
179
+ expanded={logExpanded}
180
+ />
181
+ <Text dimColor>Esc to go back to menu — type / for commands</Text>
182
+ </Box>
183
+ );
184
+ }
@@ -0,0 +1,83 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useEffect, useState } from 'react';
3
+ import type { GameInfo } from '../../../application/OnlineGameService';
4
+ import { useGameRepository } from '../GameRepositoryContext';
5
+
6
+ interface HostGameScreenProps {
7
+ onGameReady: (gameId: string, myColor: 'white' | 'black') => void;
8
+ onBack: () => void;
9
+ }
10
+
11
+ export function HostGameScreen({ onGameReady, onBack }: HostGameScreenProps) {
12
+ const { onlineGameService, playerUid } = useGameRepository();
13
+ const [gameInfo, setGameInfo] = useState<GameInfo | null>(null);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ useInput((_ch, key) => {
17
+ if (key.escape) onBack();
18
+ });
19
+
20
+ useEffect(() => {
21
+ if (!onlineGameService || !playerUid) return;
22
+
23
+ onlineGameService.createGame(playerUid).then((info) => {
24
+ setGameInfo(info);
25
+ }).catch((err) => {
26
+ setError(err.message);
27
+ });
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ if (!gameInfo || !onlineGameService) return;
32
+
33
+ const unsubscribe = onlineGameService.onGameInfoChanged(gameInfo.gameId, (info) => {
34
+ if (info.status === 'active') {
35
+ unsubscribe();
36
+ onGameReady(info.gameId, info.hostColor);
37
+ }
38
+ });
39
+
40
+ return unsubscribe;
41
+ }, [gameInfo]);
42
+
43
+ if (error) {
44
+ return (
45
+ <Box flexDirection="column" padding={1}>
46
+ <Text color="red">Failed to create game: {error}</Text>
47
+ <Text dimColor>Press Esc to go back</Text>
48
+ </Box>
49
+ );
50
+ }
51
+
52
+ if (!gameInfo) {
53
+ return (
54
+ <Box padding={1}>
55
+ <Text>Creating game...</Text>
56
+ </Box>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <Box flexDirection="column" padding={1}>
62
+ <Text bold>Host Game</Text>
63
+ <Box marginTop={1} flexDirection="column">
64
+ <Text>Invite code:</Text>
65
+ <Box marginTop={1}>
66
+ <Text bold color="green">{gameInfo.gameId}</Text>
67
+ </Box>
68
+ <Box marginTop={1}>
69
+ <Text dimColor>Share this code with your opponent.</Text>
70
+ </Box>
71
+ <Box marginTop={1}>
72
+ <Text>You play as: <Text bold color={gameInfo.hostColor === 'white' ? '#fff' : '#aaa'}>{gameInfo.hostColor === 'white' ? '♔' : '♚'} {gameInfo.hostColor}</Text></Text>
73
+ </Box>
74
+ </Box>
75
+ <Box marginTop={1}>
76
+ <Text color="yellow">Waiting for opponent to join...</Text>
77
+ </Box>
78
+ <Box marginTop={1}>
79
+ <Text dimColor>Esc to go back</Text>
80
+ </Box>
81
+ </Box>
82
+ );
83
+ }
@@ -0,0 +1,84 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useState } from 'react';
3
+ import { useGameRepository } from '../GameRepositoryContext';
4
+
5
+ interface JoinGameScreenProps {
6
+ onGameJoined: (gameId: string, myColor: 'white' | 'black') => void;
7
+ onBack: () => void;
8
+ }
9
+
10
+ export function JoinGameScreen({ onGameJoined, onBack }: JoinGameScreenProps) {
11
+ const { onlineGameService, playerUid } = useGameRepository();
12
+ const [input, setInput] = useState('');
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [joining, setJoining] = useState(false);
15
+
16
+ useInput((ch, key) => {
17
+ if (joining) return;
18
+
19
+ if (key.escape) {
20
+ if (input) {
21
+ setInput('');
22
+ setError(null);
23
+ } else {
24
+ onBack();
25
+ }
26
+ return;
27
+ }
28
+
29
+ if (key.return && input.trim()) {
30
+ if (!onlineGameService || !playerUid) return;
31
+
32
+ setJoining(true);
33
+ setError(null);
34
+
35
+ onlineGameService.joinGame(input.trim(), playerUid).then((info) => {
36
+ const myColor = info.hostColor === 'white' ? 'black' : 'white';
37
+ onGameJoined(info.gameId, myColor);
38
+ }).catch((err) => {
39
+ setError(err.message);
40
+ setJoining(false);
41
+ });
42
+ return;
43
+ }
44
+
45
+ if (key.backspace || key.delete) {
46
+ setInput((prev) => prev.slice(0, -1));
47
+ setError(null);
48
+ return;
49
+ }
50
+
51
+ if (ch) {
52
+ setInput((prev) => prev + ch);
53
+ setError(null);
54
+ }
55
+ });
56
+
57
+ return (
58
+ <Box flexDirection="column" padding={1}>
59
+ <Text bold>Join Game</Text>
60
+ <Box marginTop={1} flexDirection="column">
61
+ <Text dimColor>Enter the invite code:</Text>
62
+ <Box marginTop={1}>
63
+ <Text color="yellow">{'> '}</Text>
64
+ <Text bold color="cyan">{input}</Text>
65
+ {!input && <Text dimColor>(paste invite code)</Text>}
66
+ {input && <Text color="gray">█</Text>}
67
+ </Box>
68
+ {error && (
69
+ <Box marginTop={1}>
70
+ <Text color="red">{error}</Text>
71
+ </Box>
72
+ )}
73
+ {joining && (
74
+ <Box marginTop={1}>
75
+ <Text color="yellow">Joining game...</Text>
76
+ </Box>
77
+ )}
78
+ </Box>
79
+ <Box marginTop={1}>
80
+ <Text dimColor>Enter to join, Esc to go back</Text>
81
+ </Box>
82
+ </Box>
83
+ );
84
+ }
@@ -0,0 +1,51 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ export interface LogMessage {
4
+ text: string;
5
+ type: 'info' | 'error' | 'success';
6
+ }
7
+
8
+ interface LogMessagesProps {
9
+ messages: LogMessage[];
10
+ totalCount?: number;
11
+ expanded?: boolean;
12
+ }
13
+
14
+ const COLOR_MAP: Record<LogMessage['type'], string> = {
15
+ info: 'blue',
16
+ error: 'red',
17
+ success: 'green',
18
+ };
19
+
20
+ const PREFIX_MAP: Record<LogMessage['type'], string> = {
21
+ info: 'ℹ',
22
+ error: '✗',
23
+ success: '✓',
24
+ };
25
+
26
+ export function LogMessages({ messages, totalCount = 0, expanded = false }: LogMessagesProps) {
27
+ const hidden = totalCount - messages.length;
28
+
29
+ return (
30
+ <Box flexDirection="column" marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
31
+ <Box>
32
+ <Text bold color="gray">Log</Text>
33
+ {hidden > 0 && !expanded && (
34
+ <Text dimColor> ({hidden} hidden — Ctrl+O to expand)</Text>
35
+ )}
36
+ {expanded && totalCount > 0 && (
37
+ <Text dimColor> (expanded — Ctrl+O to collapse)</Text>
38
+ )}
39
+ </Box>
40
+ {messages.length === 0 ? (
41
+ <Text color="gray" dimColor>No messages yet</Text>
42
+ ) : (
43
+ messages.map((msg, i) => (
44
+ <Text key={i} color={COLOR_MAP[msg.type]}>
45
+ {PREFIX_MAP[msg.type]} {msg.text}
46
+ </Text>
47
+ ))
48
+ )}
49
+ </Box>
50
+ );
51
+ }
@@ -0,0 +1,140 @@
1
+ import { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+
4
+ interface Command {
5
+ name: string;
6
+ description: string;
7
+ }
8
+
9
+ const COMMANDS: Command[] = [
10
+ { name: '/menu', description: 'Go back to main menu' },
11
+ { name: '/exit', description: 'Exit' },
12
+ ];
13
+
14
+ interface MoveInputProps {
15
+ onMove: (standardAlgebraicNotation: string) => void;
16
+ onCommand: (command: string) => void;
17
+ currentPlayer: 'white' | 'black';
18
+ onEscape?: () => void;
19
+ disabled?: boolean;
20
+ disabledMessage?: string;
21
+ }
22
+
23
+ const PIECE_ICON: Record<string, string> = {
24
+ white: '♔',
25
+ black: '♚',
26
+ };
27
+
28
+ export function MoveInput({ onMove, onCommand, currentPlayer, onEscape, disabled, disabledMessage }: MoveInputProps) {
29
+ const [input, setInput] = useState('');
30
+ const [selectedIndex, setSelectedIndex] = useState(0);
31
+
32
+ const isCommandMode = input.startsWith('/');
33
+ const matchingCommands = isCommandMode
34
+ ? COMMANDS.filter((cmd) => cmd.name.startsWith(input.toLowerCase()))
35
+ : [];
36
+
37
+ useInput((ch, key) => {
38
+ // Always allow escape and commands, even when disabled
39
+ if (key.escape) {
40
+ if (input) {
41
+ setInput('');
42
+ setSelectedIndex(0);
43
+ } else if (onEscape) {
44
+ onEscape();
45
+ }
46
+ return;
47
+ }
48
+
49
+ if (key.tab && isCommandMode && matchingCommands.length > 0) {
50
+ setInput(matchingCommands[selectedIndex]!.name);
51
+ return;
52
+ }
53
+
54
+ if (isCommandMode && key.upArrow) {
55
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
56
+ return;
57
+ }
58
+
59
+ if (isCommandMode && key.downArrow) {
60
+ setSelectedIndex((prev) => Math.min(matchingCommands.length - 1, prev + 1));
61
+ return;
62
+ }
63
+
64
+ if (key.return) {
65
+ const trimmed = input.trim();
66
+ if (isCommandMode) {
67
+ const exact = COMMANDS.find((cmd) => cmd.name === trimmed);
68
+ if (exact) {
69
+ onCommand(exact.name);
70
+ setInput('');
71
+ setSelectedIndex(0);
72
+ } else if (matchingCommands.length === 1) {
73
+ onCommand(matchingCommands[0]!.name);
74
+ setInput('');
75
+ setSelectedIndex(0);
76
+ }
77
+ } else if (trimmed && !disabled) {
78
+ onMove(trimmed);
79
+ setInput('');
80
+ }
81
+ return;
82
+ }
83
+
84
+ if (key.backspace || key.delete) {
85
+ setInput((prev) => {
86
+ const next = prev.slice(0, -1);
87
+ setSelectedIndex(0);
88
+ return next;
89
+ });
90
+ return;
91
+ }
92
+
93
+ if (ch && !key.ctrl) {
94
+ setInput((prev) => {
95
+ const next = prev + ch;
96
+ setSelectedIndex(0);
97
+ return next;
98
+ });
99
+ }
100
+ });
101
+
102
+ const playerColor = currentPlayer === 'white' ? '#fff' : '#aaa';
103
+
104
+ return (
105
+ <Box marginTop={1} flexDirection="column">
106
+ <Box>
107
+ <Text color={playerColor}>{PIECE_ICON[currentPlayer]} </Text>
108
+ <Text bold color={playerColor}>{currentPlayer}</Text>
109
+ <Text dimColor> to move</Text>
110
+ </Box>
111
+ {disabled && disabledMessage ? (
112
+ <Box>
113
+ <Text color="yellow">{disabledMessage}</Text>
114
+ </Box>
115
+ ) : (
116
+ <Box>
117
+ <Text color="yellow">{'> '}</Text>
118
+ <Text bold color={isCommandMode ? 'cyan' : 'green'}>{input}</Text>
119
+ {!input && <Text dimColor>(e.g. Nf3, Bxe5, O-O — type / for commands)</Text>}
120
+ {input && <Text color="gray">█</Text>}
121
+ </Box>
122
+ )}
123
+ {isCommandMode && matchingCommands.length > 0 && (
124
+ <Box flexDirection="column" marginLeft={2}>
125
+ {matchingCommands.map((cmd, idx) => (
126
+ <Box key={cmd.name}>
127
+ <Text color={idx === selectedIndex ? 'yellow' : 'gray'}>
128
+ {idx === selectedIndex ? '> ' : ' '}
129
+ </Text>
130
+ <Text bold={idx === selectedIndex} color={idx === selectedIndex ? 'cyan' : undefined}>
131
+ {cmd.name}
132
+ </Text>
133
+ <Text dimColor> — {cmd.description}</Text>
134
+ </Box>
135
+ ))}
136
+ </Box>
137
+ )}
138
+ </Box>
139
+ );
140
+ }
@@ -0,0 +1,85 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useState } from 'react';
3
+ import { decodeConnectionString, saveConfig } from '../../firebase/configStore';
4
+
5
+ interface SetupScreenProps {
6
+ onComplete: () => void;
7
+ onBack: () => void;
8
+ }
9
+
10
+ export function SetupScreen({ onComplete, onBack }: SetupScreenProps) {
11
+ const [input, setInput] = useState('');
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [success, setSuccess] = useState(false);
14
+
15
+ useInput((ch, key) => {
16
+ if (success) return;
17
+
18
+ if (key.escape) {
19
+ if (input) {
20
+ setInput('');
21
+ setError(null);
22
+ } else {
23
+ onBack();
24
+ }
25
+ return;
26
+ }
27
+
28
+ if (key.return && input.trim()) {
29
+ try {
30
+ const config = decodeConnectionString(input.trim());
31
+ saveConfig(config);
32
+ setSuccess(true);
33
+ setError(null);
34
+ setTimeout(() => onComplete(), 500);
35
+ } catch (e) {
36
+ setError(e instanceof Error ? e.message : String(e));
37
+ }
38
+ return;
39
+ }
40
+
41
+ if (key.backspace || key.delete) {
42
+ setInput((prev) => prev.slice(0, -1));
43
+ setError(null);
44
+ return;
45
+ }
46
+
47
+ if (ch) {
48
+ setInput((prev) => prev + ch);
49
+ setError(null);
50
+ }
51
+ });
52
+
53
+ return (
54
+ <Box flexDirection="column" padding={1}>
55
+ <Text bold>Online Setup</Text>
56
+ <Box marginTop={1} flexDirection="column">
57
+ <Text>Paste the connection string to connect to a game server.</Text>
58
+ <Text dimColor>Ask the host for this string, or generate one from your Firebase project:</Text>
59
+ <Text dimColor> echo -n '{`'{"apiKey":"...","projectId":"..."}'`}' | base64</Text>
60
+ </Box>
61
+ <Box marginTop={1} flexDirection="column">
62
+ <Text dimColor>Connection string:</Text>
63
+ <Box>
64
+ <Text color="yellow">{'> '}</Text>
65
+ <Text bold color="cyan">{input.length > 60 ? `...${input.slice(-57)}` : input}</Text>
66
+ {!input && <Text dimColor>(paste base64 string)</Text>}
67
+ {input && <Text color="gray">|</Text>}
68
+ </Box>
69
+ </Box>
70
+ {error && (
71
+ <Box marginTop={1}>
72
+ <Text color="red">{error}</Text>
73
+ </Box>
74
+ )}
75
+ {success && (
76
+ <Box marginTop={1}>
77
+ <Text color="green">Connected! Redirecting...</Text>
78
+ </Box>
79
+ )}
80
+ <Box marginTop={1}>
81
+ <Text dimColor>Enter to connect, Esc to go back</Text>
82
+ </Box>
83
+ </Box>
84
+ );
85
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bun
2
+ import { render } from 'ink';
3
+ import { FileBasedGameRepository } from '../FileBasedGameRepository.ts';
4
+ import { loadConfig, loadUid } from '../firebase/configStore.ts';
5
+ import { initFirebase, isFirebaseInitialized } from '../firebase/firebaseConfig.ts';
6
+ import { FirebaseAuthService } from '../firebase/FirebaseAuthService.ts';
7
+ import { FirestoreOnlineGameService } from '../firebase/FirestoreOnlineGameService.ts';
8
+ import { App } from './App';
9
+ import { GameRepositoryProvider } from './GameRepositoryContext.tsx';
10
+
11
+ const repo = new FileBasedGameRepository();
12
+
13
+ let onlineGameService: FirestoreOnlineGameService | null = null;
14
+ let playerUid: string | null = null;
15
+
16
+ async function authenticateAndSetup(): Promise<void> {
17
+ const config = loadConfig();
18
+ if (!config) return;
19
+
20
+ initFirebase(config);
21
+ const authService = new FirebaseAuthService();
22
+ await authService.authenticate();
23
+
24
+ playerUid = authService.getUid();
25
+ onlineGameService = new FirestoreOnlineGameService();
26
+ }
27
+
28
+ function tryInitFromSavedConfig() {
29
+ const config = loadConfig();
30
+ if (!config) return;
31
+
32
+ // If we already have a uid, set it immediately so the menu can show games
33
+ const savedUid = loadUid();
34
+ if (savedUid) {
35
+ playerUid = savedUid;
36
+ }
37
+
38
+ authenticateAndSetup().then(() => {
39
+ rerender();
40
+ }).catch((err) => {
41
+ console.error('Firebase auth failed:', err.message);
42
+ rerender();
43
+ });
44
+ }
45
+
46
+ async function initOnline(): Promise<void> {
47
+ await authenticateAndSetup();
48
+ rerender();
49
+ }
50
+
51
+ // Try loading saved config on startup
52
+ tryInitFromSavedConfig();
53
+
54
+ function doRender() {
55
+ return render(
56
+ <GameRepositoryProvider repo={repo} onlineGameService={onlineGameService} playerUid={playerUid} initOnline={initOnline}>
57
+ <App onExit={() => { instance.unmount(); process.exit(0); }} />
58
+ </GameRepositoryProvider>
59
+ );
60
+ }
61
+
62
+ let instance = doRender();
63
+
64
+ function rerender() {
65
+ instance.rerender(
66
+ <GameRepositoryProvider repo={repo} onlineGameService={onlineGameService} playerUid={playerUid} initOnline={initOnline}>
67
+ <App onExit={() => { instance.unmount(); process.exit(0); }} />
68
+ </GameRepositoryProvider>
69
+ );
70
+ }