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,83 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { formatEther } from "ethers";
4
+ import { formatAddress } from "@dorkfun/core";
5
+ import { colors } from "../theme.js";
6
+ import { useEnsNames } from "../hooks/useEnsNames.js";
7
+
8
+ interface GameOverProps {
9
+ winner: string | null;
10
+ reason: string;
11
+ playerId: string;
12
+ stakeWei?: string;
13
+ onRematch: () => void;
14
+ onQuit: () => void;
15
+ }
16
+
17
+ export function GameOver({ winner, reason, playerId, stakeWei, onRematch, onQuit }: GameOverProps) {
18
+ const ensNames = useEnsNames(winner ? [winner] : []);
19
+ const isWinner = winner === playerId;
20
+ const isDraw = winner === null;
21
+ const isStaked = stakeWei && stakeWei !== "0";
22
+ const stakeDisplay = isStaked ? formatEther(stakeWei) : null;
23
+
24
+ useInput((input) => {
25
+ if (input === "r") onRematch();
26
+ if (input === "q") onQuit();
27
+ });
28
+
29
+ return (
30
+ <Box flexDirection="column" paddingX={2} paddingY={1} alignItems="center">
31
+ <Text color={colors.primary} bold>
32
+ {"═══════════════════"}
33
+ </Text>
34
+ <Text color={colors.primary} bold>
35
+ {" GAME OVER "}
36
+ </Text>
37
+ <Text color={colors.primary} bold>
38
+ {"═══════════════════"}
39
+ </Text>
40
+
41
+ <Text>{""}</Text>
42
+
43
+ {isDraw ? (
44
+ <Text color={colors.secondary} bold>
45
+ DRAW - {reason}
46
+ </Text>
47
+ ) : isWinner ? (
48
+ <Text color={colors.primary} bold>
49
+ YOU WIN! 🎉
50
+ </Text>
51
+ ) : (
52
+ <Text color={colors.error} bold>
53
+ YOU LOSE
54
+ </Text>
55
+ )}
56
+
57
+ {isStaked && (
58
+ <>
59
+ <Text>{""}</Text>
60
+ {isDraw ? (
61
+ <Text color={colors.secondary}>Stake returned: {stakeDisplay} ETH</Text>
62
+ ) : isWinner ? (
63
+ <Text color={colors.primary}>Won: +{stakeDisplay} ETH</Text>
64
+ ) : (
65
+ <Text color={colors.error}>Lost: -{stakeDisplay} ETH</Text>
66
+ )}
67
+ </>
68
+ )}
69
+
70
+ <Text>{""}</Text>
71
+
72
+ {winner && (
73
+ <Text color={colors.dimmed}>
74
+ Winner: {formatAddress(winner, ensNames[winner], "medium")}
75
+ </Text>
76
+ )}
77
+ <Text color={colors.dimmed}>Reason: {reason}</Text>
78
+
79
+ <Text>{""}</Text>
80
+ <Text color={colors.dimmed}>[R] Rematch [Q] Quit to lobby</Text>
81
+ </Box>
82
+ );
83
+ }
@@ -0,0 +1,196 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { formatAddress } from "@dorkfun/core";
4
+ import { colors } from "../theme.js";
5
+ import * as api from "../../transport/httpClient.js";
6
+
7
+ interface LeaderboardEntry {
8
+ rank: number;
9
+ address: string;
10
+ displayName: string;
11
+ ensName?: string | null;
12
+ rating: number;
13
+ gamesPlayed: number;
14
+ gamesWon: number;
15
+ gamesDrawn: number;
16
+ gamesLost: number;
17
+ totalEarningsWei: string;
18
+ }
19
+
20
+ interface LeaderboardProps {
21
+ onBack: () => void;
22
+ }
23
+
24
+ interface Tab {
25
+ id: string;
26
+ label: string;
27
+ }
28
+
29
+ type SortBy = "rating" | "earnings";
30
+
31
+ const PAGE_SIZE = 20;
32
+
33
+ export function Leaderboard({ onBack }: LeaderboardProps) {
34
+ const [tabs, setTabs] = useState<Tab[]>([{ id: "overall", label: "Overall" }]);
35
+ const [activeTab, setActiveTab] = useState(0);
36
+ const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
37
+ const [selectedRow, setSelectedRow] = useState(0);
38
+ const [loading, setLoading] = useState(true);
39
+ const [error, setError] = useState("");
40
+ const [page, setPage] = useState(0);
41
+ const [total, setTotal] = useState(0);
42
+ const [sortBy, setSortBy] = useState<SortBy>("rating");
43
+
44
+ // Fetch game list on mount to populate tabs
45
+ useEffect(() => {
46
+ api
47
+ .listGames()
48
+ .then((res) => {
49
+ const gameTabs = (res.games || []).map((g: any) => ({
50
+ id: g.id,
51
+ label: g.name,
52
+ }));
53
+ setTabs([{ id: "overall", label: "Overall" }, ...gameTabs]);
54
+ })
55
+ .catch(() => {});
56
+ }, []);
57
+
58
+ // Fetch leaderboard data when tab, page, or sort changes
59
+ useEffect(() => {
60
+ setLoading(true);
61
+ setError("");
62
+ const currentTab = tabs[activeTab];
63
+ if (!currentTab) return;
64
+
65
+ const fetchFn =
66
+ currentTab.id === "overall"
67
+ ? api.getLeaderboard(PAGE_SIZE, page * PAGE_SIZE, sortBy)
68
+ : api.getGameLeaderboard(currentTab.id, PAGE_SIZE, page * PAGE_SIZE, sortBy);
69
+
70
+ fetchFn
71
+ .then((res) => {
72
+ setEntries(res.players || []);
73
+ setTotal(res.total || 0);
74
+ setSelectedRow(0);
75
+ setLoading(false);
76
+ })
77
+ .catch((err: Error) => {
78
+ setError(err.message);
79
+ setLoading(false);
80
+ });
81
+ }, [activeTab, page, tabs, sortBy]);
82
+
83
+ useInput((input, key) => {
84
+ if (key.escape) {
85
+ onBack();
86
+ return;
87
+ }
88
+
89
+ // Tab switching
90
+ if (key.tab || key.rightArrow) {
91
+ setActiveTab((t) => (t + 1) % tabs.length);
92
+ setPage(0);
93
+ return;
94
+ }
95
+ if (key.leftArrow) {
96
+ setActiveTab((t) => (t - 1 + tabs.length) % tabs.length);
97
+ setPage(0);
98
+ return;
99
+ }
100
+
101
+ // Row navigation
102
+ if (key.upArrow) setSelectedRow((s) => Math.max(0, s - 1));
103
+ if (key.downArrow) setSelectedRow((s) => Math.min(entries.length - 1, s + 1));
104
+
105
+ // Pagination
106
+ if (input === "n" && (page + 1) * PAGE_SIZE < total) setPage((p) => p + 1);
107
+ if (input === "p" && page > 0) setPage((p) => p - 1);
108
+
109
+ // Sort toggle
110
+ if (input === "s") {
111
+ setSortBy((s) => (s === "rating" ? "earnings" : "rating"));
112
+ setPage(0);
113
+ }
114
+ });
115
+
116
+ const formatWei = (wei: string) => {
117
+ try {
118
+ const eth = (Number(wei) / 1e18).toFixed(6);
119
+ return eth.replace(/\.?0+$/, "");
120
+ } catch {
121
+ return "0";
122
+ }
123
+ };
124
+
125
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
126
+
127
+ return (
128
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
129
+ <Text color={colors.primary} bold>
130
+ {"═══ LEADERBOARD ═══"}
131
+ </Text>
132
+ <Text>{""}</Text>
133
+
134
+ {/* Tab bar */}
135
+ <Box flexDirection="row" gap={2}>
136
+ {tabs.map((tab, i) => (
137
+ <Text
138
+ key={tab.id}
139
+ color={i === activeTab ? colors.primary : colors.dimmed}
140
+ bold={i === activeTab}
141
+ >
142
+ {i === activeTab ? `[${tab.label}]` : ` ${tab.label} `}
143
+ </Text>
144
+ ))}
145
+ <Text color={colors.dimmed}> | </Text>
146
+ <Text
147
+ color={sortBy === "rating" ? colors.primary : colors.dimmed}
148
+ bold={sortBy === "rating"}
149
+ >
150
+ {sortBy === "rating" ? "[Rating]" : " Rating "}
151
+ </Text>
152
+ <Text
153
+ color={sortBy === "earnings" ? colors.primary : colors.dimmed}
154
+ bold={sortBy === "earnings"}
155
+ >
156
+ {sortBy === "earnings" ? "[Earnings]" : " Earnings "}
157
+ </Text>
158
+ </Box>
159
+ <Text>{""}</Text>
160
+
161
+ {/* Header row */}
162
+ <Text color={colors.secondary} bold>
163
+ {" # Player Rating W D L GP Earnings"}
164
+ </Text>
165
+ <Text color={colors.border}>
166
+ {" ─── ─────────────── ────── ──── ──── ──── ──── ──────────"}
167
+ </Text>
168
+
169
+ {loading && <Text color={colors.dimmed}> Loading...</Text>}
170
+ {error && <Text color={colors.error}> Error: {error}</Text>}
171
+
172
+ {!loading && !error && entries.length === 0 && (
173
+ <Text color={colors.warning}> No players ranked yet.</Text>
174
+ )}
175
+
176
+ {!loading &&
177
+ !error &&
178
+ entries.map((e, i) => {
179
+ const earnings = e.totalEarningsWei !== "0" ? formatWei(e.totalEarningsWei) + " ETH" : "-";
180
+ return (
181
+ <Box key={e.address} flexDirection="row">
182
+ <Text color={i === selectedRow ? colors.primary : colors.text}>
183
+ {i === selectedRow ? " ▸ " : " "}
184
+ {String(e.rank).padStart(3)} {(e.ensName || e.displayName || formatAddress(e.address)).padEnd(15)} {String(e.rating).padStart(6)} {String(e.gamesWon).padStart(3)} {String(e.gamesDrawn).padStart(3)} {String(e.gamesLost).padStart(3)} {String(e.gamesPlayed).padStart(3)} {earnings.padStart(10)}
185
+ </Text>
186
+ </Box>
187
+ );
188
+ })}
189
+
190
+ <Text>{""}</Text>
191
+ <Text color={colors.dimmed}>
192
+ {" "}Page {page + 1}/{totalPages} | ←→/Tab: switch view | ↑↓: navigate | n/p: page | s: sort ({sortBy}) | Esc: back
193
+ </Text>
194
+ </Box>
195
+ );
196
+ }
@@ -0,0 +1,197 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { formatEther, parseEther } from "ethers";
4
+ import { colors } from "../theme.js";
5
+ import * as api from "../../transport/httpClient.js";
6
+
7
+ interface LobbyProps {
8
+ defaultStakeWei?: string;
9
+ onPlay: (gameId: string, stakeWei?: string) => void;
10
+ onWatch: () => void;
11
+ onLeaderboard: () => void;
12
+ }
13
+
14
+ interface GameSummary {
15
+ id: string;
16
+ name: string;
17
+ description: string;
18
+ stakingEnabled?: boolean;
19
+ }
20
+
21
+ export function Lobby({ defaultStakeWei, onPlay, onWatch, onLeaderboard }: LobbyProps) {
22
+ const [games, setGames] = useState<GameSummary[]>([]);
23
+ const [selected, setSelected] = useState(0);
24
+ const [error, setError] = useState("");
25
+ const [statusMsg, setStatusMsg] = useState("");
26
+
27
+ // Stake input sub-state
28
+ const [stakeMode, setStakeMode] = useState(false);
29
+ const [stakeGameId, setStakeGameId] = useState("");
30
+ const [stakeInput, setStakeInput] = useState(
31
+ defaultStakeWei ? formatEther(defaultStakeWei) : ""
32
+ );
33
+
34
+ // Stake confirmation sub-state
35
+ const [confirmMode, setConfirmMode] = useState(false);
36
+ const [pendingStakeWei, setPendingStakeWei] = useState<string | undefined>(undefined);
37
+
38
+ useEffect(() => {
39
+ api
40
+ .listGames()
41
+ .then((res) => setGames(res.games))
42
+ .catch((err: Error) => setError(err.message));
43
+ }, []);
44
+
45
+ const handleWatch = async () => {
46
+ setStatusMsg("");
47
+ try {
48
+ const result = await api.listMatches();
49
+ if (result.matches.length === 0) {
50
+ setStatusMsg("No active games to watch right now.");
51
+ return;
52
+ }
53
+ onWatch();
54
+ } catch (err: any) {
55
+ setStatusMsg(`Failed to fetch matches: ${err.message}`);
56
+ }
57
+ };
58
+
59
+ const handleSelectGame = (gameId: string) => {
60
+ setStakeGameId(gameId);
61
+ setStakeMode(true);
62
+ setStatusMsg("");
63
+ };
64
+
65
+ const handleConfirmStake = () => {
66
+ const trimmed = stakeInput.trim();
67
+ if (!trimmed || trimmed === "0") {
68
+ onPlay(stakeGameId);
69
+ return;
70
+ }
71
+
72
+ // Check if staking is available for this game
73
+ const game = games.find((g) => g.id === stakeGameId);
74
+ if (game && game.stakingEnabled === false) {
75
+ setStatusMsg("Staking is not available for this game (settlement not configured on server).");
76
+ return;
77
+ }
78
+
79
+ try {
80
+ const wei = parseEther(trimmed).toString();
81
+ setPendingStakeWei(wei);
82
+ setConfirmMode(true);
83
+ } catch {
84
+ setStatusMsg(`Invalid amount: "${trimmed}". Enter a decimal ETH value (e.g. 0.01)`);
85
+ }
86
+ };
87
+
88
+ const handleFinalConfirm = () => {
89
+ onPlay(stakeGameId, pendingStakeWei);
90
+ };
91
+
92
+ const handleCancelConfirm = () => {
93
+ setConfirmMode(false);
94
+ setPendingStakeWei(undefined);
95
+ };
96
+
97
+ const options = [
98
+ ...games.map((g) => ({ label: `Play ${g.name}`, action: () => handleSelectGame(g.id) })),
99
+ { label: "Watch live games", action: handleWatch },
100
+ { label: "Leaderboard", action: () => onLeaderboard() },
101
+ ];
102
+
103
+ useInput((input, key) => {
104
+ if (confirmMode) {
105
+ if (input === "y" || input === "Y") {
106
+ handleFinalConfirm();
107
+ } else if (input === "n" || input === "N" || key.escape) {
108
+ handleCancelConfirm();
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (stakeMode) {
114
+ if (key.return) {
115
+ handleConfirmStake();
116
+ } else if (key.escape) {
117
+ setStakeMode(false);
118
+ setStakeInput(defaultStakeWei ? formatEther(defaultStakeWei) : "");
119
+ setStatusMsg("");
120
+ } else if (key.backspace || key.delete) {
121
+ setStakeInput((prev) => prev.slice(0, -1));
122
+ } else if (input && !key.ctrl && !key.meta) {
123
+ setStakeInput((prev) => prev + input);
124
+ }
125
+ return;
126
+ }
127
+
128
+ if (key.upArrow) setSelected((s) => Math.max(0, s - 1));
129
+ if (key.downArrow) setSelected((s) => Math.min(options.length - 1, s + 1));
130
+ if (key.return) options[selected]?.action();
131
+ });
132
+
133
+ return (
134
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
135
+ <Text color={colors.primary} bold>
136
+ {"═══ LOBBY ═══"}
137
+ </Text>
138
+ <Text color={colors.dimmed}>{""}</Text>
139
+
140
+ {confirmMode && pendingStakeWei ? (
141
+ <Box flexDirection="column">
142
+ <Text color={colors.warning} bold>
143
+ Confirm Stake
144
+ </Text>
145
+ <Text>{""}</Text>
146
+ <Text color={colors.white}>
147
+ You are about to enter matchmaking with a stake of {formatEther(pendingStakeWei)} ETH
148
+ </Text>
149
+ <Text color={colors.error}>
150
+ If you lose the game, you will lose {formatEther(pendingStakeWei)} ETH
151
+ </Text>
152
+ <Text>{""}</Text>
153
+ <Text color={colors.primary} bold>
154
+ Press [Y] to confirm, [N] to go back
155
+ </Text>
156
+ </Box>
157
+ ) : stakeMode ? (
158
+ <Box flexDirection="column">
159
+ <Text color={colors.white}>Stake for {stakeGameId}:</Text>
160
+ <Text color={colors.dimmed}>Enter ETH amount (or press Enter for free play)</Text>
161
+ <Text>{""}</Text>
162
+ <Box>
163
+ <Text color={colors.secondary}>{"> "}</Text>
164
+ <Text color={colors.text}>{stakeInput || "0"}</Text>
165
+ <Text color={colors.dimmed}> ETH</Text>
166
+ <Text color={colors.dimmed}>{"_"}</Text>
167
+ </Box>
168
+ <Text>{""}</Text>
169
+ <Text color={colors.dimmed}>Enter to confirm, Esc to go back</Text>
170
+ </Box>
171
+ ) : error ? (
172
+ <Text color={colors.error}>Error: {error}</Text>
173
+ ) : (
174
+ options.map((opt, i) => (
175
+ <Text key={i} color={i === selected ? colors.primary : colors.dimmed}>
176
+ {i === selected ? " ▸ " : " "}
177
+ {opt.label}
178
+ </Text>
179
+ ))
180
+ )}
181
+
182
+ {statusMsg && (
183
+ <>
184
+ <Text color={colors.dimmed}>{""}</Text>
185
+ <Text color={colors.warning}> {statusMsg}</Text>
186
+ </>
187
+ )}
188
+
189
+ {!stakeMode && (
190
+ <>
191
+ <Text color={colors.dimmed}>{""}</Text>
192
+ <Text color={colors.dimmed}>Use ↑↓ to select, Enter to confirm, q to quit</Text>
193
+ </>
194
+ )}
195
+ </Box>
196
+ );
197
+ }
@@ -0,0 +1,107 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { formatEther } from "ethers";
4
+ import Spinner from "ink-spinner";
5
+ import { colors } from "../theme.js";
6
+ import * as api from "../../transport/httpClient.js";
7
+
8
+ interface MatchmakingProps {
9
+ playerId: string;
10
+ gameId: string;
11
+ stakeWei?: string;
12
+ onMatched: (matchId: string, wsToken: string, opponent: string) => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ export function Matchmaking({ playerId, gameId, stakeWei, onMatched, onCancel }: MatchmakingProps) {
17
+ const [status, setStatus] = useState("Searching for opponent...");
18
+ const [elapsed, setElapsed] = useState(0);
19
+ const [error, setError] = useState("");
20
+
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ let ticket = "";
24
+
25
+ const poll = async () => {
26
+ try {
27
+ const result = await api.joinQueue(playerId, gameId, undefined, stakeWei);
28
+
29
+ if (result.status === "matched") {
30
+ if (!cancelled) {
31
+ onMatched(result.matchId, result.wsToken, result.opponent);
32
+ }
33
+ } else {
34
+ ticket = result.ticket;
35
+ setStatus("In queue, waiting for opponent...");
36
+
37
+ // Poll every 2 seconds, reusing the same ticket
38
+ const interval = setInterval(async () => {
39
+ if (cancelled) {
40
+ clearInterval(interval);
41
+ return;
42
+ }
43
+ try {
44
+ const retry = await api.joinQueue(playerId, gameId, ticket, stakeWei);
45
+ if (retry.status === "matched") {
46
+ clearInterval(interval);
47
+ if (!cancelled) {
48
+ onMatched(retry.matchId, retry.wsToken, retry.opponent);
49
+ }
50
+ }
51
+ } catch {
52
+ // Ignore poll errors
53
+ }
54
+ }, 2000);
55
+ }
56
+ } catch (err: any) {
57
+ setError(err.message);
58
+ }
59
+ };
60
+
61
+ poll();
62
+
63
+ const timer = setInterval(() => {
64
+ if (!cancelled) setElapsed((e) => e + 1);
65
+ }, 1000);
66
+
67
+ return () => {
68
+ cancelled = true;
69
+ clearInterval(timer);
70
+ if (ticket) api.leaveQueue(ticket).catch(() => {});
71
+ };
72
+ }, [playerId, gameId, stakeWei]);
73
+
74
+ const stakeDisplay = stakeWei ? `${formatEther(stakeWei)} ETH` : "free";
75
+
76
+ return (
77
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
78
+ <Text color={colors.primary} bold>
79
+ {"═══ MATCHMAKING ═══"}
80
+ </Text>
81
+ <Text>{""}</Text>
82
+
83
+ {error ? (
84
+ <Text color={colors.error}>Error: {error}</Text>
85
+ ) : (
86
+ <Box>
87
+ <Text color={colors.secondary}>
88
+ <Spinner type="dots" />
89
+ </Text>
90
+ <Text color={colors.white}> {status} ({elapsed}s)</Text>
91
+ </Box>
92
+ )}
93
+
94
+ <Text>{""}</Text>
95
+ <Text color={colors.dimmed}>Game: {gameId}</Text>
96
+ {stakeWei && stakeWei !== "0" ? (
97
+ <Text color={colors.warning} bold>Stake: {stakeDisplay}</Text>
98
+ ) : (
99
+ <Text color={colors.dimmed}>Stake: {stakeDisplay}</Text>
100
+ )}
101
+ {stakeWei && stakeWei !== "0" && (
102
+ <Text color={colors.dimmed}>Only opponents with matching stake will be paired</Text>
103
+ )}
104
+ <Text color={colors.dimmed}>Press Ctrl+C to cancel</Text>
105
+ </Box>
106
+ );
107
+ }