dorkfuncli 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 (110) hide show
  1. package/.env.example +6 -0
  2. package/dist/commands/agent.d.ts +2 -0
  3. package/dist/commands/agent.js +163 -0
  4. package/dist/commands/agent.js.map +1 -0
  5. package/dist/commands/config.d.ts +3 -0
  6. package/dist/commands/config.js +124 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/config/configFile.d.ts +6 -0
  9. package/dist/config/configFile.js +43 -0
  10. package/dist/config/configFile.js.map +1 -0
  11. package/dist/config/defaults.d.ts +10 -0
  12. package/dist/config/defaults.js +19 -0
  13. package/dist/config/defaults.js.map +1 -0
  14. package/dist/config/index.d.ts +4 -0
  15. package/dist/config/index.js +5 -0
  16. package/dist/config/index.js.map +1 -0
  17. package/dist/config/resolve.d.ts +3 -0
  18. package/dist/config/resolve.js +24 -0
  19. package/dist/config/resolve.js.map +1 -0
  20. package/dist/config/runtime.d.ts +3 -0
  21. package/dist/config/runtime.js +13 -0
  22. package/dist/config/runtime.js.map +1 -0
  23. package/dist/config.d.ts +6 -0
  24. package/dist/config.js +6 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +230 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/transport/httpClient.d.ts +13 -0
  30. package/dist/transport/httpClient.js +91 -0
  31. package/dist/transport/httpClient.js.map +1 -0
  32. package/dist/transport/wsClient.d.ts +30 -0
  33. package/dist/transport/wsClient.js +196 -0
  34. package/dist/transport/wsClient.js.map +1 -0
  35. package/dist/tui/App.d.ts +6 -0
  36. package/dist/tui/App.js +40 -0
  37. package/dist/tui/App.js.map +1 -0
  38. package/dist/tui/components/ChatPanel.d.ts +12 -0
  39. package/dist/tui/components/ChatPanel.js +18 -0
  40. package/dist/tui/components/ChatPanel.js.map +1 -0
  41. package/dist/tui/components/ColoredBoard.d.ts +7 -0
  42. package/dist/tui/components/ColoredBoard.js +73 -0
  43. package/dist/tui/components/ColoredBoard.js.map +1 -0
  44. package/dist/tui/components/PlayerInfo.d.ts +10 -0
  45. package/dist/tui/components/PlayerInfo.js +10 -0
  46. package/dist/tui/components/PlayerInfo.js.map +1 -0
  47. package/dist/tui/components/StatusBar.d.ts +7 -0
  48. package/dist/tui/components/StatusBar.js +13 -0
  49. package/dist/tui/components/StatusBar.js.map +1 -0
  50. package/dist/tui/components/TicTacToeBoard.d.ts +6 -0
  51. package/dist/tui/components/TicTacToeBoard.js +19 -0
  52. package/dist/tui/components/TicTacToeBoard.js.map +1 -0
  53. package/dist/tui/hooks/useEnsNames.d.ts +5 -0
  54. package/dist/tui/hooks/useEnsNames.js +33 -0
  55. package/dist/tui/hooks/useEnsNames.js.map +1 -0
  56. package/dist/tui/screens/GameBoard.d.ts +10 -0
  57. package/dist/tui/screens/GameBoard.js +245 -0
  58. package/dist/tui/screens/GameBoard.js.map +1 -0
  59. package/dist/tui/screens/GameOver.d.ts +10 -0
  60. package/dist/tui/screens/GameOver.js +21 -0
  61. package/dist/tui/screens/GameOver.js.map +1 -0
  62. package/dist/tui/screens/Leaderboard.d.ts +5 -0
  63. package/dist/tui/screens/Leaderboard.js +102 -0
  64. package/dist/tui/screens/Leaderboard.js.map +1 -0
  65. package/dist/tui/screens/Lobby.d.ts +8 -0
  66. package/dist/tui/screens/Lobby.js +113 -0
  67. package/dist/tui/screens/Lobby.js.map +1 -0
  68. package/dist/tui/screens/Matchmaking.d.ts +9 -0
  69. package/dist/tui/screens/Matchmaking.js +66 -0
  70. package/dist/tui/screens/Matchmaking.js.map +1 -0
  71. package/dist/tui/screens/WatchGame.d.ts +7 -0
  72. package/dist/tui/screens/WatchGame.js +99 -0
  73. package/dist/tui/screens/WatchGame.js.map +1 -0
  74. package/dist/tui/screens/WatchList.d.ts +6 -0
  75. package/dist/tui/screens/WatchList.js +49 -0
  76. package/dist/tui/screens/WatchList.js.map +1 -0
  77. package/dist/tui/theme.d.ts +30 -0
  78. package/dist/tui/theme.js +31 -0
  79. package/dist/tui/theme.js.map +1 -0
  80. package/dist/wallet/signer.d.ts +13 -0
  81. package/dist/wallet/signer.js +41 -0
  82. package/dist/wallet/signer.js.map +1 -0
  83. package/package.json +43 -0
  84. package/play-agents.cjs +444 -0
  85. package/src/commands/agent.ts +175 -0
  86. package/src/commands/config.ts +162 -0
  87. package/src/config/configFile.ts +55 -0
  88. package/src/config/defaults.ts +28 -0
  89. package/src/config/index.ts +15 -0
  90. package/src/config/resolve.ts +33 -0
  91. package/src/config/runtime.ts +18 -0
  92. package/src/index.ts +237 -0
  93. package/src/transport/httpClient.ts +104 -0
  94. package/src/transport/wsClient.ts +214 -0
  95. package/src/tui/App.tsx +130 -0
  96. package/src/tui/components/ChatPanel.tsx +53 -0
  97. package/src/tui/components/ColoredBoard.tsx +98 -0
  98. package/src/tui/components/PlayerInfo.tsx +31 -0
  99. package/src/tui/components/StatusBar.tsx +35 -0
  100. package/src/tui/hooks/useEnsNames.ts +35 -0
  101. package/src/tui/screens/GameBoard.tsx +434 -0
  102. package/src/tui/screens/GameOver.tsx +83 -0
  103. package/src/tui/screens/Leaderboard.tsx +196 -0
  104. package/src/tui/screens/Lobby.tsx +197 -0
  105. package/src/tui/screens/Matchmaking.tsx +107 -0
  106. package/src/tui/screens/WatchGame.tsx +182 -0
  107. package/src/tui/screens/WatchList.tsx +99 -0
  108. package/src/tui/theme.ts +31 -0
  109. package/src/wallet/signer.ts +54 -0
  110. package/tsconfig.json +17 -0
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { formatAddress } from "@dorkfun/core";
4
+ import { colors } from "../theme.js";
5
+
6
+ export interface ChatMessage {
7
+ sender: string;
8
+ message: string;
9
+ timestamp?: number;
10
+ }
11
+
12
+ interface ChatPanelProps {
13
+ messages: ChatMessage[];
14
+ maxLines?: number;
15
+ players?: string[];
16
+ }
17
+
18
+ export function ChatPanel({ messages, maxLines = 8, players }: ChatPanelProps) {
19
+ const visible = messages.slice(-maxLines);
20
+
21
+ return (
22
+ <Box
23
+ flexDirection="column"
24
+ borderStyle="single"
25
+ borderColor={colors.border}
26
+ paddingX={1}
27
+ width={30}
28
+ >
29
+ <Text color={colors.dimmed} bold>
30
+ CHAT
31
+ </Text>
32
+ {visible.length === 0 ? (
33
+ <Text color={colors.dimmed}>No messages yet</Text>
34
+ ) : (
35
+ visible.map((msg, i) => {
36
+ const senderColor = players
37
+ ? msg.sender === players[0]
38
+ ? colors.cyan
39
+ : msg.sender === players[1]
40
+ ? colors.secondary
41
+ : colors.dimmed
42
+ : colors.secondary;
43
+ return (
44
+ <Text key={i}>
45
+ <Text color={senderColor}>{msg.sender.startsWith("0x") ? formatAddress(msg.sender) : msg.sender}: </Text>
46
+ <Text color={colors.white}>{msg.message}</Text>
47
+ </Text>
48
+ );
49
+ })
50
+ )}
51
+ </Box>
52
+ );
53
+ }
@@ -0,0 +1,98 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ /**
5
+ * Maps CSS classes from game UI renderBoard() output to terminal colors.
6
+ * These match the definitions in apps/web/src/styles/terminal.css.
7
+ */
8
+ const CLASS_STYLES: Record<string, { color?: string; bold?: boolean }> = {
9
+ // Chess
10
+ "chess-white": { color: "#00ffff" },
11
+ "chess-black": { color: "#ffb000" },
12
+ "chess-last-move": { bold: true },
13
+ // Tic-tac-toe
14
+ "ttt-x": { color: "#00ffff" },
15
+ "ttt-o": { color: "#ffb000" },
16
+ // Connect Four
17
+ "c4-r": { color: "#00ffff" },
18
+ "c4-y": { color: "#ffb000" },
19
+ // Othello
20
+ "oth-b": { color: "#00ffff" },
21
+ "oth-w": { color: "#ffb000" },
22
+ // Hex
23
+ "hex-r": { color: "#00ffff" },
24
+ "hex-b": { color: "#ffb000" },
25
+ // Checkers
26
+ "ck-black": { color: "#00ffff" },
27
+ "ck-white": { color: "#ffb000" },
28
+ // Sudoku
29
+ "sudoku-clue": { color: "#00ff41", bold: true },
30
+ "sudoku-player": { color: "#00ffff" },
31
+ "sudoku-error": { color: "#ff3333" },
32
+ };
33
+
34
+ interface Segment {
35
+ text: string;
36
+ color?: string;
37
+ bold?: boolean;
38
+ }
39
+
40
+ function parseSegments(line: string): Segment[] {
41
+ const segments: Segment[] = [];
42
+ const regex = /<span class="([^"]*)">(.*?)<\/span>/g;
43
+ let lastIndex = 0;
44
+ let match;
45
+
46
+ while ((match = regex.exec(line)) !== null) {
47
+ if (match.index > lastIndex) {
48
+ segments.push({ text: line.slice(lastIndex, match.index) });
49
+ }
50
+
51
+ const classes = match[1].split(/\s+/);
52
+ const content = match[2];
53
+ let color: string | undefined;
54
+ let bold: boolean | undefined;
55
+
56
+ for (const cls of classes) {
57
+ const style = CLASS_STYLES[cls];
58
+ if (style) {
59
+ if (style.color) color = style.color;
60
+ if (style.bold) bold = true;
61
+ }
62
+ }
63
+
64
+ segments.push({ text: content, color, bold });
65
+ lastIndex = match.index + match[0].length;
66
+ }
67
+
68
+ if (lastIndex < line.length) {
69
+ segments.push({ text: line.slice(lastIndex) });
70
+ }
71
+
72
+ return segments;
73
+ }
74
+
75
+ /**
76
+ * Renders board HTML (with <span class="..."> tags) as colored Ink text.
77
+ * Falls back to plain text for any content without spans.
78
+ */
79
+ export function ColoredBoard({ html }: { html: string }) {
80
+ const lines = html.split("\n");
81
+
82
+ return (
83
+ <Box flexDirection="column">
84
+ {lines.map((line, i) => {
85
+ const segments = parseSegments(line);
86
+ return (
87
+ <Text key={i}>
88
+ {segments.map((seg, j) => (
89
+ <Text key={j} color={seg.color} bold={seg.bold}>
90
+ {seg.text}
91
+ </Text>
92
+ ))}
93
+ </Text>
94
+ );
95
+ })}
96
+ </Box>
97
+ );
98
+ }
@@ -0,0 +1,31 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { formatAddress } from "@dorkfun/core";
4
+ import { colors } from "../theme.js";
5
+
6
+ interface PlayerInfoProps {
7
+ address: string;
8
+ ensName?: string | null;
9
+ label: string;
10
+ playerIndex: number;
11
+ isCurrentTurn: boolean;
12
+ isYou?: boolean;
13
+ }
14
+
15
+ export function PlayerInfo({ address, ensName, label, playerIndex, isCurrentTurn, isYou }: PlayerInfoProps) {
16
+ const display = formatAddress(address, ensName);
17
+ const labelColor = playerIndex === 0 ? colors.cyan : colors.secondary;
18
+
19
+ return (
20
+ <Box flexDirection="row" gap={1}>
21
+ <Text color={labelColor} bold>
22
+ [{label}]
23
+ </Text>
24
+ <Text color={isCurrentTurn ? colors.primary : colors.dimmed}>
25
+ {display}
26
+ {isYou ? " (you)" : ""}
27
+ {isCurrentTurn ? " ◀" : ""}
28
+ </Text>
29
+ </Box>
30
+ );
31
+ }
@@ -0,0 +1,35 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { formatAddress } from "@dorkfun/core";
4
+ import { colors } from "../theme.js";
5
+ import { useEnsNames } from "../hooks/useEnsNames.js";
6
+
7
+ interface StatusBarProps {
8
+ playerAddress: string;
9
+ serverUrl: string;
10
+ connected: boolean;
11
+ }
12
+
13
+ export function StatusBar({ playerAddress, serverUrl, connected }: StatusBarProps) {
14
+ const ensNames = useEnsNames(playerAddress ? [playerAddress] : []);
15
+ const shortAddr = playerAddress
16
+ ? formatAddress(playerAddress, ensNames[playerAddress])
17
+ : "Not connected";
18
+
19
+ return (
20
+ <Box
21
+ borderStyle="single"
22
+ borderColor={colors.border}
23
+ paddingX={1}
24
+ flexDirection="row"
25
+ justifyContent="space-between"
26
+ >
27
+ <Text color={colors.primary} bold>
28
+ DORK.FUN v0.1.0
29
+ </Text>
30
+ <Text color={colors.dimmed}>
31
+ {connected ? "●" : "○"} {shortAddr} | {serverUrl}
32
+ </Text>
33
+ </Box>
34
+ );
35
+ }
@@ -0,0 +1,35 @@
1
+ import { useState, useEffect } from "react";
2
+ import * as api from "../../transport/httpClient.js";
3
+
4
+ const cache = new Map<string, string | null>();
5
+
6
+ /**
7
+ * React hook that resolves ENS names for a list of addresses via the server API.
8
+ * Caches results in memory across hook instances.
9
+ */
10
+ export function useEnsNames(addresses: string[]): Record<string, string | null> {
11
+ const [names, setNames] = useState<Record<string, string | null>>(() => {
12
+ const initial: Record<string, string | null> = {};
13
+ for (const a of addresses) {
14
+ if (cache.has(a)) initial[a] = cache.get(a)!;
15
+ }
16
+ return initial;
17
+ });
18
+
19
+ useEffect(() => {
20
+ const unresolved = addresses.filter((a) => a && !cache.has(a));
21
+ if (unresolved.length === 0) return;
22
+
23
+ api
24
+ .resolveEns(unresolved)
25
+ .then((result) => {
26
+ for (const [addr, name] of Object.entries(result)) {
27
+ cache.set(addr, name);
28
+ }
29
+ setNames((prev) => ({ ...prev, ...result }));
30
+ })
31
+ .catch(() => {});
32
+ }, [addresses.join(",")]);
33
+
34
+ return names;
35
+ }
@@ -0,0 +1,434 @@
1
+ import React, { useEffect, useRef, useState, useCallback } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { formatEther } from "ethers";
4
+ import Spinner from "ink-spinner";
5
+ import { WsMessage, Observation } from "@dorkfun/core";
6
+ import { getGameUI } from "@dorkfun/game-ui";
7
+ import { colors } from "../theme.js";
8
+ import { ChatPanel, ChatMessage } from "../components/ChatPanel.js";
9
+ import { ColoredBoard } from "../components/ColoredBoard.js";
10
+ import { PlayerInfo } from "../components/PlayerInfo.js";
11
+ import { useEnsNames } from "../hooks/useEnsNames.js";
12
+ import { GameWebSocket } from "../../transport/wsClient.js";
13
+ import { sendEscrowDeposit } from "../../wallet/signer.js";
14
+
15
+ interface GameBoardProps {
16
+ matchId: string;
17
+ wsToken: string;
18
+ playerId: string;
19
+ gameId: string;
20
+ stakeWei?: string;
21
+ onGameOver: (winner: string | null, reason: string) => void;
22
+ }
23
+
24
+ type InputMode = "game" | "chat";
25
+ type DepositPhase = "none" | "confirm" | "depositing" | "waiting" | "confirmed";
26
+
27
+ export function GameBoard({ matchId, wsToken, playerId, gameId, stakeWei, onGameOver }: GameBoardProps) {
28
+ const [publicData, setPublicData] = useState<Record<string, unknown>>({});
29
+ const [currentPlayer, setCurrentPlayer] = useState("");
30
+ const [isMyTurn, setIsMyTurn] = useState(false);
31
+ const [players, setPlayers] = useState<string[]>([]);
32
+ const ensNames = useEnsNames(players);
33
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
34
+ const [error, setError] = useState("");
35
+ const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">("connecting");
36
+ const [gameReady, setGameReady] = useState(false);
37
+ const [inputMode, setInputMode] = useState<InputMode>("game");
38
+ const [inputBuffer, setInputBuffer] = useState("");
39
+ const [chatInput, setChatInput] = useState("");
40
+ const [wsRef] = useState(() => new GameWebSocket());
41
+
42
+ // Deposit state
43
+ const [depositPhase, setDepositPhase] = useState<DepositPhase>("none");
44
+ const [depositTxHash, setDepositTxHash] = useState("");
45
+ const [escrowPayload, setEscrowPayload] = useState<{
46
+ escrowAddress: string;
47
+ stakeWei: string;
48
+ matchIdBytes32: string;
49
+ } | null>(null);
50
+ const depositInitiated = useRef(false);
51
+
52
+ const ui = getGameUI(gameId);
53
+
54
+ // Ref to avoid stale closures in the sync interval
55
+ const isMyTurnRef = useRef(false);
56
+ useEffect(() => {
57
+ isMyTurnRef.current = isMyTurn;
58
+ }, [isMyTurn]);
59
+
60
+ useEffect(() => {
61
+ const connect = async () => {
62
+ try {
63
+ await wsRef.connect(matchId);
64
+ setConnectionStatus("connected");
65
+ wsRef.sendHello(wsToken, playerId);
66
+
67
+ wsRef.on("GAME_STATE", (msg: WsMessage) => {
68
+ const payload = msg.payload as any;
69
+ if (payload.observation) {
70
+ const obs = payload.observation as Observation;
71
+ if (obs.publicData) setPublicData(obs.publicData);
72
+ setCurrentPlayer(obs.currentPlayer);
73
+ setPlayers(obs.players);
74
+ setGameReady(true);
75
+ }
76
+ if (payload.yourTurn !== undefined) {
77
+ setIsMyTurn(payload.yourTurn);
78
+ }
79
+ if (payload.event === "player_disconnected") {
80
+ setChatMessages((prev) => [...prev, {
81
+ sender: "system",
82
+ message: payload.message,
83
+ timestamp: Date.now(),
84
+ }]);
85
+ }
86
+ });
87
+
88
+ wsRef.on("STEP_RESULT", (msg: WsMessage) => {
89
+ const payload = msg.payload as any;
90
+ if (payload.observation?.publicData) {
91
+ setPublicData(payload.observation.publicData);
92
+ }
93
+ });
94
+
95
+ wsRef.on("GAME_OVER", (msg: WsMessage) => {
96
+ const payload = msg.payload as { winner: string | null; reason: string };
97
+ onGameOver(payload.winner, payload.reason);
98
+ });
99
+
100
+ wsRef.on("CHAT", (msg: WsMessage) => {
101
+ const payload = msg.payload as ChatMessage;
102
+ setChatMessages((prev) => [...prev, payload]);
103
+ });
104
+
105
+ wsRef.on("ERROR", (msg: WsMessage) => {
106
+ setError((msg.payload as { error: string }).error);
107
+ setIsMyTurn(true);
108
+ });
109
+
110
+ // Handle deposit gating for staked matches - show confirmation first
111
+ wsRef.on("DEPOSIT_REQUIRED", (msg: WsMessage) => {
112
+ const payload = msg.payload as {
113
+ escrowAddress: string;
114
+ stakeWei: string;
115
+ matchIdBytes32: string;
116
+ };
117
+ setEscrowPayload(payload);
118
+ setDepositPhase("confirm");
119
+ });
120
+
121
+ wsRef.on("DEPOSITS_CONFIRMED", () => {
122
+ setDepositPhase("confirmed");
123
+ });
124
+
125
+ wsRef.on("SYNC_RESPONSE", (msg: WsMessage) => {
126
+ const payload = msg.payload as {
127
+ yourTurn: boolean;
128
+ currentPlayer: string;
129
+ matchStatus: string;
130
+ };
131
+ if (payload.matchStatus === "completed") return;
132
+
133
+ setIsMyTurn((current) => {
134
+ if (current !== payload.yourTurn) {
135
+ setCurrentPlayer(payload.currentPlayer);
136
+ if (payload.yourTurn) setError("");
137
+ return payload.yourTurn;
138
+ }
139
+ return current;
140
+ });
141
+ });
142
+
143
+ wsRef.on("reconnecting", () => {
144
+ setConnectionStatus("reconnecting");
145
+ });
146
+
147
+ wsRef.on("close", () => {
148
+ setConnectionStatus("disconnected");
149
+ });
150
+ } catch (err: any) {
151
+ setError(`Connection failed: ${err.message}`);
152
+ setConnectionStatus("disconnected");
153
+ }
154
+ };
155
+
156
+ connect();
157
+
158
+ return () => wsRef.close();
159
+ }, [matchId, wsToken, playerId]);
160
+
161
+ // Periodic state sync to recover from stuck turn states
162
+ useEffect(() => {
163
+ if (!gameReady || connectionStatus !== "connected") return;
164
+
165
+ const syncIntervalId = setInterval(() => {
166
+ if (wsRef.isConnected) {
167
+ wsRef.sendSyncRequest(matchId, isMyTurnRef.current);
168
+ }
169
+ }, 8000);
170
+
171
+ return () => clearInterval(syncIntervalId);
172
+ }, [gameReady, connectionStatus, matchId]);
173
+
174
+ const handleSubmitMove = useCallback(() => {
175
+ if (!isMyTurn || !ui || !inputBuffer.trim()) return;
176
+
177
+ const action = ui.parseInput(inputBuffer.trim(), publicData);
178
+ if (action) {
179
+ wsRef.sendAction(matchId, action);
180
+ setIsMyTurn(false);
181
+ setError("");
182
+ setInputBuffer("");
183
+ } else {
184
+ setError("Invalid move. " + ui.inputHint);
185
+ setInputBuffer("");
186
+ }
187
+ }, [isMyTurn, inputBuffer, publicData, matchId, ui]);
188
+
189
+ const handleSendChat = useCallback(() => {
190
+ if (chatInput.trim()) {
191
+ wsRef.sendChat(matchId, chatInput.trim());
192
+ setChatInput("");
193
+ }
194
+ setInputMode("game");
195
+ }, [chatInput, matchId]);
196
+
197
+ const handleDepositConfirm = useCallback(() => {
198
+ if (!escrowPayload || depositInitiated.current) return;
199
+ depositInitiated.current = true;
200
+
201
+ setDepositPhase("depositing");
202
+ sendEscrowDeposit({
203
+ address: escrowPayload.escrowAddress,
204
+ stakeWei: escrowPayload.stakeWei,
205
+ matchIdBytes32: escrowPayload.matchIdBytes32,
206
+ })
207
+ .then((txHash) => {
208
+ setDepositTxHash(txHash);
209
+ setDepositPhase("waiting");
210
+ })
211
+ .catch((err: Error) => {
212
+ setError(`Deposit failed: ${err.message}`);
213
+ setDepositPhase("none");
214
+ depositInitiated.current = false;
215
+ });
216
+ }, [escrowPayload]);
217
+
218
+ const handleDepositDecline = useCallback(() => {
219
+ wsRef.close();
220
+ onGameOver(null, "Deposit declined");
221
+ }, [onGameOver]);
222
+
223
+ useInput((input, key) => {
224
+ // Deposit confirmation input
225
+ if (depositPhase === "confirm") {
226
+ if (input === "y" || input === "Y") {
227
+ handleDepositConfirm();
228
+ } else if (input === "n" || input === "N") {
229
+ handleDepositDecline();
230
+ }
231
+ return;
232
+ }
233
+
234
+ if (inputMode === "chat") {
235
+ if (key.return) {
236
+ handleSendChat();
237
+ } else if (key.escape) {
238
+ setChatInput("");
239
+ setInputMode("game");
240
+ } else if (key.backspace || key.delete) {
241
+ setChatInput((prev) => prev.slice(0, -1));
242
+ } else if (input && !key.ctrl && !key.meta) {
243
+ setChatInput((prev) => prev + input);
244
+ }
245
+ return;
246
+ }
247
+
248
+ // Game mode
249
+ if (input === "/" && !inputBuffer) {
250
+ setInputMode("chat");
251
+ return;
252
+ }
253
+
254
+ if (key.return) {
255
+ handleSubmitMove();
256
+ return;
257
+ }
258
+
259
+ if (key.escape) {
260
+ setInputBuffer("");
261
+ setError("");
262
+ return;
263
+ }
264
+
265
+ if (key.backspace || key.delete) {
266
+ setInputBuffer((prev) => prev.slice(0, -1));
267
+ return;
268
+ }
269
+
270
+ if (input && !key.ctrl && !key.meta) {
271
+ setInputBuffer((prev) => prev + input);
272
+ if (error) setError("");
273
+ }
274
+ });
275
+
276
+ // Deposit confirmation UI - user must explicitly approve before funds leave wallet
277
+ if (depositPhase === "confirm") {
278
+ const stakeDisplay = escrowPayload ? formatEther(escrowPayload.stakeWei) : (stakeWei ? formatEther(stakeWei) : "?");
279
+ return (
280
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
281
+ <Text color={colors.primary} bold>
282
+ Match: {matchId.slice(0, 8)} - Stake Deposit Required
283
+ </Text>
284
+ <Text>{""}</Text>
285
+ <Text color={colors.warning} bold>
286
+ This match requires a stake of {stakeDisplay} ETH
287
+ </Text>
288
+ <Text>{""}</Text>
289
+ <Text color={colors.white}>
290
+ Your stake will be deposited to the on-chain escrow contract.
291
+ </Text>
292
+ <Text color={colors.error}>
293
+ If you lose, you will lose your stake.
294
+ </Text>
295
+ <Text color={colors.white}>
296
+ If you win, you receive your stake plus your opponent{"'"}s.
297
+ </Text>
298
+ <Text>{""}</Text>
299
+ <Text color={colors.primary} bold>
300
+ Press [Y] to deposit and play, [N] to decline and forfeit
301
+ </Text>
302
+ </Box>
303
+ );
304
+ }
305
+
306
+ // Deposit phase UI (depositing / waiting for opponent)
307
+ if (depositPhase !== "none" && depositPhase !== "confirmed") {
308
+ const stakeDisplay = stakeWei ? formatEther(stakeWei) : "?";
309
+ return (
310
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
311
+ <Text color={colors.primary} bold>
312
+ Match: {matchId.slice(0, 8)} - Escrow Deposit
313
+ </Text>
314
+ <Text>{""}</Text>
315
+
316
+ {depositPhase === "depositing" && (
317
+ <Box>
318
+ <Text color={colors.secondary}>
319
+ <Spinner type="dots" />
320
+ </Text>
321
+ <Text color={colors.white}> Depositing {stakeDisplay} ETH to escrow...</Text>
322
+ </Box>
323
+ )}
324
+
325
+ {depositPhase === "waiting" && (
326
+ <Box flexDirection="column">
327
+ <Text color={colors.primary}>Deposit confirmed on-chain!</Text>
328
+ <Text color={colors.dimmed}>TX: {depositTxHash.slice(0, 16)}...</Text>
329
+ <Text>{""}</Text>
330
+ <Box>
331
+ <Text color={colors.secondary}>
332
+ <Spinner type="dots" />
333
+ </Text>
334
+ <Text color={colors.white}> Waiting for opponent deposit...</Text>
335
+ </Box>
336
+ </Box>
337
+ )}
338
+
339
+ {error && (
340
+ <>
341
+ <Text>{""}</Text>
342
+ <Text color={colors.error}>{error}</Text>
343
+ </>
344
+ )}
345
+
346
+ <Text>{""}</Text>
347
+ <Text color={colors.dimmed}>Stake: {stakeDisplay} ETH</Text>
348
+ </Box>
349
+ );
350
+ }
351
+
352
+ const myLabel = gameReady ? (ui?.getPlayerLabel(playerId, publicData) || "?") : "?";
353
+ const boardHtml = gameReady ? (ui?.renderBoard(publicData) || `[No renderer for ${gameId}]`) : "Waiting for game state...";
354
+ const statusStr = gameReady ? ui?.renderStatus(publicData) : null;
355
+
356
+ return (
357
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
358
+ <Box flexDirection="row" justifyContent="space-between">
359
+ <Text color={colors.primary} bold>
360
+ Match: {matchId.slice(0, 8)}
361
+ {stakeWei ? ` [${formatEther(stakeWei)} ETH]` : ""}
362
+ </Text>
363
+ <Box>
364
+ {connectionStatus === "reconnecting" && (
365
+ <Text color={colors.warning}>Reconnecting...</Text>
366
+ )}
367
+ {connectionStatus === "disconnected" && (
368
+ <Text color={colors.error}>Disconnected</Text>
369
+ )}
370
+ <Text color={colors.dimmed}> You: {myLabel}</Text>
371
+ </Box>
372
+ </Box>
373
+
374
+ <Text>{""}</Text>
375
+
376
+ <Box flexDirection="row">
377
+ <Box flexDirection="column" marginRight={2}>
378
+ {players.map((p, i) => (
379
+ <PlayerInfo
380
+ key={p}
381
+ address={p}
382
+ ensName={ensNames[p]}
383
+ label={ui?.getPlayerLabel(p, publicData) || "?"}
384
+ playerIndex={i}
385
+ isCurrentTurn={p === currentPlayer}
386
+ isYou={p === playerId}
387
+ />
388
+ ))}
389
+
390
+ <Text>{""}</Text>
391
+
392
+ <ColoredBoard html={boardHtml} />
393
+
394
+ {statusStr && (
395
+ <Text color={colors.warning} bold>{statusStr}</Text>
396
+ )}
397
+
398
+ <Text>{""}</Text>
399
+
400
+ {error && <Text color={colors.error}>{error}</Text>}
401
+ {isMyTurn ? (
402
+ <Box flexDirection="column">
403
+ <Text color={colors.primary} bold>
404
+ YOUR TURN - {ui?.inputHint || "Enter your move"}
405
+ </Text>
406
+ <Box>
407
+ <Text color={colors.secondary}>{"> "}</Text>
408
+ <Text color={colors.text}>{inputBuffer}</Text>
409
+ <Text color={colors.dimmed}>{"_"}</Text>
410
+ </Box>
411
+ </Box>
412
+ ) : !error ? (
413
+ <Text color={colors.dimmed}>Waiting for opponent...</Text>
414
+ ) : null}
415
+
416
+ <Text color={colors.dimmed}>
417
+ {inputMode === "game" ? "Press '/' to chat" : ""}
418
+ </Text>
419
+ </Box>
420
+
421
+ <Box flexDirection="column">
422
+ <ChatPanel messages={chatMessages} players={players} />
423
+ {inputMode === "chat" && (
424
+ <Box>
425
+ <Text color={colors.secondary}>{"> "}</Text>
426
+ <Text color={colors.text}>{chatInput}</Text>
427
+ <Text color={colors.dimmed}>{"_"}</Text>
428
+ </Box>
429
+ )}
430
+ </Box>
431
+ </Box>
432
+ </Box>
433
+ );
434
+ }