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
package/src/index.ts ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ import dotenv from "dotenv";
3
+ dotenv.config({ quiet: true });
4
+
5
+ import { program } from "commander";
6
+ import { parseEther } from "ethers";
7
+ import { formatAddress } from "@dorkfun/core";
8
+ import React from "react";
9
+ import { render } from "ink";
10
+ import { App } from "./tui/App.js";
11
+ import { getAddress } from "./wallet/signer.js";
12
+ import * as api from "./transport/httpClient.js";
13
+ import { initConfig, getConfig, setCliOverride } from "./config/index.js";
14
+ import { registerConfigCommand } from "./commands/config.js";
15
+ import { runWizard } from "./commands/config.js";
16
+ import { registerAgentCommand } from "./commands/agent.js";
17
+
18
+ program
19
+ .name("dork")
20
+ .description("dork.fun - Play games with AI agents and humans")
21
+ .version("0.1.0", "-v, --version");
22
+
23
+ registerConfigCommand(program);
24
+ registerAgentCommand(program);
25
+
26
+ program
27
+ .command("play")
28
+ .description("Connect to the server and play a game")
29
+ .option("-g, --game <gameId>", "Game to play", "tictactoe")
30
+ .option("-k, --key <privateKey>", "Ethereum private key")
31
+ .option("--stake <ETH>", "Stake amount in ETH (e.g. 0.01 for 0.01 ETH, 1 for 1 ETH)")
32
+ .action(async (opts) => {
33
+ if (opts.key) {
34
+ setCliOverride("privateKey", opts.key);
35
+ }
36
+
37
+ let config = await initConfig();
38
+
39
+ // Auto-run setup wizard if no private key is configured
40
+ if (!config.privateKey) {
41
+ console.log("No private key configured. Let's set things up.\n");
42
+ await runWizard();
43
+ config = await initConfig();
44
+ }
45
+
46
+ // Convert ETH input to wei for the protocol
47
+ let stakeWei: string | undefined;
48
+ if (opts.stake) {
49
+ try {
50
+ stakeWei = parseEther(opts.stake).toString();
51
+ } catch {
52
+ console.error(`Invalid stake amount: "${opts.stake}". Use a decimal ETH value (e.g. 0.01, 1.5)`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ try {
58
+ const address = getAddress();
59
+ console.log(`Connected as ${address}`);
60
+ console.log(`Server: ${config.serverUrl}`);
61
+ if (stakeWei) {
62
+ console.log(`Stake: ${opts.stake} ETH`);
63
+ }
64
+ console.log("");
65
+
66
+ render(React.createElement(App, { playerId: address, stakeWei }));
67
+ } catch (err: any) {
68
+ console.error(`Error: ${err.message}`);
69
+ process.exit(1);
70
+ }
71
+ });
72
+
73
+ program
74
+ .command("games")
75
+ .description("List available games")
76
+ .action(async () => {
77
+ await initConfig();
78
+ try {
79
+ const result = await api.listGames();
80
+ console.log("\nAvailable Games:");
81
+ console.log("────────────────");
82
+ for (const game of result.games) {
83
+ console.log(` ${game.name} (${game.id})`);
84
+ console.log(` ${game.description}`);
85
+ console.log(` Players: ${game.minPlayers}-${game.maxPlayers}`);
86
+ console.log("");
87
+ }
88
+ } catch (err: any) {
89
+ console.error(`Error: ${err.message}`);
90
+ }
91
+ });
92
+
93
+ program
94
+ .command("matches")
95
+ .description("List active matches")
96
+ .action(async () => {
97
+ await initConfig();
98
+ try {
99
+ const result = await api.listMatches();
100
+ console.log("\nActive Matches:");
101
+ console.log("───────────────");
102
+ if (result.matches.length === 0) {
103
+ console.log(" No active matches");
104
+ }
105
+ const pn = result.matches[0]?.playerNames || {};
106
+ for (const match of result.matches) {
107
+ console.log(` ${match.matchId.slice(0, 8)} ${match.gameId} ${match.status}`);
108
+ console.log(` Players: ${match.players.map((p: string) => formatAddress(p, pn[p], "medium")).join(", ")}`);
109
+ console.log("");
110
+ }
111
+ } catch (err: any) {
112
+ console.error(`Error: ${err.message}`);
113
+ }
114
+ });
115
+
116
+ program
117
+ .command("queue")
118
+ .description("Show players waiting in game queues")
119
+ .action(async () => {
120
+ await initConfig();
121
+ try {
122
+ const result = await api.listQueues();
123
+ console.log("\nQueue Status:");
124
+ console.log("─────────────");
125
+ let totalWaiting = 0;
126
+ for (const q of result.queues) {
127
+ const count = q.entries.length;
128
+ totalWaiting += count;
129
+ console.log(` ${q.gameName} (${q.gameId}): ${count} waiting`);
130
+ const qn = result.playerNames || {};
131
+ for (const entry of q.entries) {
132
+ const name = qn[entry.playerId] || entry.displayName;
133
+ console.log(` - ${name} (${entry.playerId.slice(0, 10)}...)`);
134
+ }
135
+ if (count === 0) {
136
+ console.log(" (empty)");
137
+ }
138
+ console.log("");
139
+ }
140
+ if (totalWaiting === 0) {
141
+ console.log(" No players waiting in any queue");
142
+ }
143
+ } catch (err: any) {
144
+ console.error(`Error: ${err.message}`);
145
+ }
146
+ });
147
+
148
+ program
149
+ .command("archive")
150
+ .description("List completed/archived matches")
151
+ .option("-g, --game <gameId>", "Filter by game")
152
+ .option("-l, --limit <count>", "Number of matches to show", "20")
153
+ .action(async (opts) => {
154
+ await initConfig();
155
+ try {
156
+ const limit = parseInt(opts.limit) || 20;
157
+ const result = await api.listArchive(opts.game, limit, 0);
158
+ console.log("\nMatch Archive:");
159
+ console.log("──────────────");
160
+ if (result.matches.length === 0) {
161
+ console.log(" No archived matches found");
162
+ }
163
+ const archiveNames = result.playerNames || {};
164
+ for (const match of result.matches) {
165
+ const outcome = match.winner
166
+ ? `Winner: ${formatAddress(match.winner, archiveNames[match.winner], "medium")}`
167
+ : "Draw";
168
+ const date = match.completedAt
169
+ ? new Date(match.completedAt).toLocaleString()
170
+ : "-";
171
+ console.log(` ${match.matchId.slice(0, 8)} ${match.gameId} ${match.status}`);
172
+ console.log(` Players: ${match.players.map((p: string) => formatAddress(p, archiveNames[p], "medium")).join(" vs ")}`);
173
+ console.log(` Outcome: ${outcome}`);
174
+ console.log(` Ended: ${date}`);
175
+ console.log("");
176
+ }
177
+ console.log(` Showing ${result.matches.length} of ${result.total} total`);
178
+ } catch (err: any) {
179
+ console.error(`Error: ${err.message}`);
180
+ }
181
+ });
182
+
183
+ program
184
+ .command("watch")
185
+ .description("Watch a live or archived game")
186
+ .argument("[matchId]", "Match ID to watch")
187
+ .action(async (matchId?: string) => {
188
+ await initConfig();
189
+ if (!matchId) {
190
+ const result = await api.listMatches();
191
+ if (result.matches.length === 0) {
192
+ console.log("No active matches to watch");
193
+ return;
194
+ }
195
+ console.log("\nActive matches:");
196
+ for (const match of result.matches) {
197
+ console.log(` ${match.matchId} (${match.gameId})`);
198
+ }
199
+ return;
200
+ }
201
+
202
+ try {
203
+ const match = await api.getMatch(matchId);
204
+ console.log(`\nWatching: ${match.matchId}`);
205
+ console.log(`Game: ${match.gameId}`);
206
+ console.log(`Status: ${match.status}`);
207
+ const watchNames = match.playerNames || {};
208
+ console.log(`Players: ${match.players.map((p: string) => formatAddress(p, watchNames[p], "medium")).join(" vs ")}`);
209
+ if (match.observation) {
210
+ const board = (match.observation.publicData as any)?.board;
211
+ if (board) {
212
+ console.log("\nBoard:");
213
+ for (let r = 0; r < 3; r++) {
214
+ const row = board.slice(r * 3, r * 3 + 3).map((c: string) => c || ".").join(" | ");
215
+ console.log(` ${row}`);
216
+ if (r < 2) console.log(" ──┼───┼──");
217
+ }
218
+ }
219
+ }
220
+ if (["completed", "settled", "disputed"].includes(match.status)) {
221
+ const outcome = match.winner
222
+ ? `Winner: ${formatAddress(match.winner, watchNames[match.winner], "medium")}`
223
+ : "Draw";
224
+ console.log(`\nResult: ${outcome}`);
225
+ if (match.completedAt) {
226
+ console.log(`Ended: ${new Date(match.completedAt).toLocaleString()}`);
227
+ }
228
+ if (match.transcript) {
229
+ console.log(`Moves: ${match.transcript.length}`);
230
+ }
231
+ }
232
+ } catch (err: any) {
233
+ console.error(`Error: ${err.message}`);
234
+ }
235
+ });
236
+
237
+ program.parse();
@@ -0,0 +1,104 @@
1
+ import { buildAuthMessage } from "@dorkfun/core";
2
+ import { getConfig } from "../config/runtime.js";
3
+ import { signMessage } from "../wallet/signer.js";
4
+
5
+ async function request(path: string, opts?: RequestInit): Promise<any> {
6
+ const base = getConfig().serverUrl;
7
+ const res = await fetch(`${base}${path}`, {
8
+ ...opts,
9
+ headers: { "Content-Type": "application/json", ...opts?.headers },
10
+ });
11
+ if (!res.ok) {
12
+ const body = await res.json().catch(() => ({}));
13
+ throw new Error(body.error || `HTTP ${res.status}`);
14
+ }
15
+ return res.json();
16
+ }
17
+
18
+ /**
19
+ * Build the authentication payload for endpoints that require proof of
20
+ * EVM address ownership: { playerId, signature, timestamp }.
21
+ */
22
+ async function buildAuth(playerId: string) {
23
+ const timestamp = Date.now();
24
+ const message = buildAuthMessage(playerId, timestamp);
25
+ const signature = await signMessage(message);
26
+ return { playerId, signature, timestamp };
27
+ }
28
+
29
+ export async function listGames() {
30
+ return request("/api/games");
31
+ }
32
+
33
+ export async function listMatches() {
34
+ return request("/api/matches");
35
+ }
36
+
37
+ export async function getMatch(matchId: string) {
38
+ return request(`/api/matches/${matchId}`);
39
+ }
40
+
41
+ export async function joinQueue(playerId: string, gameId: string, ticket?: string, stakeWei?: string) {
42
+ const auth = await buildAuth(playerId);
43
+ return request("/api/matchmaking/join", {
44
+ method: "POST",
45
+ body: JSON.stringify({ ...auth, gameId, ticket, stakeWei }),
46
+ });
47
+ }
48
+
49
+ export async function leaveQueue(ticket: string) {
50
+ return request("/api/matchmaking/leave", {
51
+ method: "POST",
52
+ body: JSON.stringify({ ticket }),
53
+ });
54
+ }
55
+
56
+ export async function createPrivateMatch(playerId: string, gameId: string, stakeWei?: string) {
57
+ const auth = await buildAuth(playerId);
58
+ return request("/api/matches/private", {
59
+ method: "POST",
60
+ body: JSON.stringify({ ...auth, gameId, stakeWei }),
61
+ });
62
+ }
63
+
64
+ export async function acceptPrivateMatch(playerId: string, inviteCode: string) {
65
+ const auth = await buildAuth(playerId);
66
+ return request("/api/matches/accept", {
67
+ method: "POST",
68
+ body: JSON.stringify({ ...auth, inviteCode }),
69
+ });
70
+ }
71
+
72
+ export async function checkActiveMatch(playerId: string) {
73
+ const auth = await buildAuth(playerId);
74
+ return request("/api/matches/active", {
75
+ method: "POST",
76
+ body: JSON.stringify(auth),
77
+ });
78
+ }
79
+
80
+ export async function listQueues() {
81
+ return request("/api/queues");
82
+ }
83
+
84
+ export async function listArchive(gameId?: string, limit = 50, offset = 0) {
85
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
86
+ if (gameId) params.set("gameId", gameId);
87
+ return request(`/api/archive?${params}`);
88
+ }
89
+
90
+ export async function getLeaderboard(limit = 50, offset = 0, sort: "rating" | "earnings" = "rating") {
91
+ return request(`/api/leaderboard?limit=${limit}&offset=${offset}&sort=${sort}`);
92
+ }
93
+
94
+ export async function getGameLeaderboard(gameId: string, limit = 50, offset = 0, sort: "rating" | "earnings" = "rating") {
95
+ return request(`/api/leaderboard/${gameId}?limit=${limit}&offset=${offset}&sort=${sort}`);
96
+ }
97
+
98
+ export async function resolveEns(addresses: string[]): Promise<Record<string, string | null>> {
99
+ const result = await request("/api/ens/resolve", {
100
+ method: "POST",
101
+ body: JSON.stringify({ addresses }),
102
+ });
103
+ return result.names;
104
+ }
@@ -0,0 +1,214 @@
1
+ import WebSocket from "ws";
2
+ import { WsMessage, buildAuthMessage } from "@dorkfun/core";
3
+ import { getConfig } from "../config/runtime.js";
4
+ import { signMessage } from "../wallet/signer.js";
5
+
6
+ export type MessageHandler = (msg: WsMessage) => void;
7
+
8
+ const MAX_RECONNECT_ATTEMPTS = 5;
9
+ const RECONNECT_DELAY_MS = 2000;
10
+
11
+ export class GameWebSocket {
12
+ private ws: WebSocket | null = null;
13
+ private handlers: Map<string, MessageHandler[]> = new Map();
14
+ private matchId: string = "";
15
+ private reconnectAttempts = 0;
16
+ private closed = false;
17
+ private helloToken: string = "";
18
+ private helloPlayerId: string = "";
19
+ private wsPath: string = "game";
20
+
21
+ connect(matchId: string, path: string = "game"): Promise<void> {
22
+ this.matchId = matchId;
23
+ this.wsPath = path;
24
+ this.closed = false;
25
+ return this.doConnect();
26
+ }
27
+
28
+ private doConnect(): Promise<void> {
29
+ return new Promise((resolve, reject) => {
30
+ const url = `${getConfig().wsUrl}/ws/${this.wsPath}/${this.matchId}`;
31
+ this.ws = new WebSocket(url);
32
+
33
+ this.ws.on("open", async () => {
34
+ this.reconnectAttempts = 0;
35
+
36
+ // Re-authenticate on reconnect
37
+ if (this.helloPlayerId) {
38
+ if (this.helloToken) {
39
+ // First connection — use the one-time token
40
+ this.sendHello(this.helloToken, this.helloPlayerId);
41
+ } else {
42
+ // Reconnection — generate a fresh signature
43
+ try {
44
+ const timestamp = Date.now();
45
+ const message = buildAuthMessage(this.helloPlayerId, timestamp);
46
+ const signature = await signMessage(message);
47
+ this.sendHelloWithSignature(this.helloPlayerId, signature, timestamp);
48
+ } catch {
49
+ // Signing failed — can't reconnect
50
+ }
51
+ }
52
+ }
53
+ resolve();
54
+ });
55
+
56
+ this.ws.on("error", (err) => {
57
+ if (this.reconnectAttempts === 0 && !this.closed) {
58
+ reject(err);
59
+ }
60
+ });
61
+
62
+ this.ws.on("message", (raw) => {
63
+ try {
64
+ const msg = JSON.parse(raw.toString()) as WsMessage;
65
+ this.emit(msg.type, msg);
66
+ this.emit("*", msg);
67
+ } catch {
68
+ // Ignore parse errors
69
+ }
70
+ });
71
+
72
+ this.ws.on("close", () => {
73
+ if (this.closed) return;
74
+ this.tryReconnect();
75
+ });
76
+ });
77
+ }
78
+
79
+ private tryReconnect(): void {
80
+ if (this.closed) return;
81
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
82
+ this.emit("close", {
83
+ type: "ERROR",
84
+ matchId: this.matchId,
85
+ payload: { error: "Connection lost after max retries" },
86
+ sequence: 0,
87
+ prevHash: "",
88
+ timestamp: Date.now(),
89
+ } as WsMessage);
90
+ return;
91
+ }
92
+
93
+ this.reconnectAttempts++;
94
+ this.emit("reconnecting", {
95
+ type: "ERROR",
96
+ matchId: this.matchId,
97
+ payload: { error: `Reconnecting... (attempt ${this.reconnectAttempts})` },
98
+ sequence: 0,
99
+ prevHash: "",
100
+ timestamp: Date.now(),
101
+ } as WsMessage);
102
+
103
+ setTimeout(() => {
104
+ if (this.closed) return;
105
+ this.doConnect().catch(() => {
106
+ this.tryReconnect();
107
+ });
108
+ }, RECONNECT_DELAY_MS);
109
+ }
110
+
111
+ on(type: string, handler: MessageHandler): void {
112
+ if (!this.handlers.has(type)) {
113
+ this.handlers.set(type, []);
114
+ }
115
+ this.handlers.get(type)!.push(handler);
116
+ }
117
+
118
+ private emit(type: string, msg: WsMessage): void {
119
+ const handlers = this.handlers.get(type) || [];
120
+ for (const handler of handlers) {
121
+ handler(msg);
122
+ }
123
+ }
124
+
125
+ sendHello(token: string, playerId: string): void {
126
+ this.helloToken = token;
127
+ this.helloPlayerId = playerId;
128
+ this.send({
129
+ type: "HELLO",
130
+ matchId: "",
131
+ payload: { token, playerId },
132
+ sequence: 0,
133
+ prevHash: "",
134
+ timestamp: Date.now(),
135
+ });
136
+ }
137
+
138
+ /** Send a HELLO with signature-based auth (used for reconnection). */
139
+ sendHelloWithSignature(playerId: string, signature: string, timestamp: number): void {
140
+ this.helloPlayerId = playerId;
141
+ // Clear token so future reconnects use signature path
142
+ this.helloToken = "";
143
+ this.send({
144
+ type: "HELLO",
145
+ matchId: "",
146
+ payload: { playerId, signature, timestamp },
147
+ sequence: 0,
148
+ prevHash: "",
149
+ timestamp: Date.now(),
150
+ });
151
+ }
152
+
153
+ sendAction(matchId: string, action: { type: string; data: Record<string, unknown> }): void {
154
+ this.send({
155
+ type: "ACTION_COMMIT",
156
+ matchId,
157
+ payload: { action },
158
+ sequence: 0,
159
+ prevHash: "",
160
+ timestamp: Date.now(),
161
+ });
162
+ }
163
+
164
+ sendSpectateJoin(matchId: string, displayName: string): void {
165
+ this.send({
166
+ type: "SPECTATE_JOIN",
167
+ matchId,
168
+ payload: { displayName },
169
+ sequence: 0,
170
+ prevHash: "",
171
+ timestamp: Date.now(),
172
+ });
173
+ }
174
+
175
+ sendSyncRequest(matchId: string, clientIsMyTurn: boolean): void {
176
+ this.send({
177
+ type: "SYNC_REQUEST",
178
+ matchId,
179
+ payload: { clientIsMyTurn },
180
+ sequence: 0,
181
+ prevHash: "",
182
+ timestamp: Date.now(),
183
+ });
184
+ }
185
+
186
+ sendChat(matchId: string, message: string): void {
187
+ this.send({
188
+ type: "CHAT",
189
+ matchId,
190
+ payload: { message },
191
+ sequence: 0,
192
+ prevHash: "",
193
+ timestamp: Date.now(),
194
+ });
195
+ }
196
+
197
+ private send(msg: WsMessage): void {
198
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
199
+ this.ws.send(JSON.stringify(msg));
200
+ }
201
+ }
202
+
203
+ close(): void {
204
+ this.closed = true;
205
+ if (this.ws) {
206
+ this.ws.close();
207
+ this.ws = null;
208
+ }
209
+ }
210
+
211
+ get isConnected(): boolean {
212
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
213
+ }
214
+ }
@@ -0,0 +1,130 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, useInput, useApp } from "ink";
3
+ import { StatusBar } from "./components/StatusBar.js";
4
+ import { Lobby } from "./screens/Lobby.js";
5
+ import { Matchmaking } from "./screens/Matchmaking.js";
6
+ import { GameBoard } from "./screens/GameBoard.js";
7
+ import { GameOver } from "./screens/GameOver.js";
8
+ import { WatchList } from "./screens/WatchList.js";
9
+ import { WatchGame } from "./screens/WatchGame.js";
10
+ import { Leaderboard } from "./screens/Leaderboard.js";
11
+ import { getConfig } from "../config/runtime.js";
12
+ import * as api from "../transport/httpClient.js";
13
+
14
+ type Screen =
15
+ | { type: "lobby" }
16
+ | { type: "leaderboard" }
17
+ | { type: "matchmaking"; gameId: string; stakeWei?: string }
18
+ | { type: "game"; matchId: string; wsToken: string; gameId: string; stakeWei?: string }
19
+ | { type: "gameover"; winner: string | null; reason: string; stakeWei?: string }
20
+ | { type: "watchlist" }
21
+ | { type: "watching"; matchId: string; gameId: string };
22
+
23
+ interface AppProps {
24
+ playerId: string;
25
+ stakeWei?: string;
26
+ }
27
+
28
+ export function App({ playerId, stakeWei: initialStakeWei }: AppProps) {
29
+ const { exit } = useApp();
30
+ const [screen, setScreen] = useState<Screen>({ type: "lobby" });
31
+
32
+ // On startup, check if the player has an active match to reconnect to
33
+ useEffect(() => {
34
+ api.checkActiveMatch(playerId).then((res: any) => {
35
+ if (res.hasActiveMatch && res.matchId && res.wsToken) {
36
+ setScreen({
37
+ type: "game",
38
+ matchId: res.matchId,
39
+ wsToken: res.wsToken,
40
+ gameId: res.gameId || "unknown",
41
+ stakeWei: res.stakeWei && res.stakeWei !== "0" ? res.stakeWei : undefined,
42
+ });
43
+ }
44
+ }).catch(() => {
45
+ // Ignore — server might not be reachable yet
46
+ });
47
+ }, [playerId]);
48
+
49
+ useInput((input) => {
50
+ if (input === "q" && screen.type === "lobby") {
51
+ exit();
52
+ }
53
+ });
54
+
55
+ return (
56
+ <Box flexDirection="column">
57
+ <StatusBar
58
+ playerAddress={playerId}
59
+ serverUrl={getConfig().serverUrl}
60
+ connected={true}
61
+ />
62
+
63
+ {screen.type === "lobby" && (
64
+ <Lobby
65
+ defaultStakeWei={initialStakeWei}
66
+ onPlay={(gameId, stakeWei) => setScreen({ type: "matchmaking", gameId, stakeWei })}
67
+ onWatch={() => setScreen({ type: "watchlist" })}
68
+ onLeaderboard={() => setScreen({ type: "leaderboard" })}
69
+ />
70
+ )}
71
+
72
+ {screen.type === "leaderboard" && (
73
+ <Leaderboard onBack={() => setScreen({ type: "lobby" })} />
74
+ )}
75
+
76
+ {screen.type === "matchmaking" && (
77
+ <Matchmaking
78
+ playerId={playerId}
79
+ gameId={screen.gameId}
80
+ stakeWei={screen.stakeWei}
81
+ onMatched={(matchId, wsToken, _opponent) =>
82
+ setScreen({ type: "game", matchId, wsToken, gameId: screen.gameId, stakeWei: screen.stakeWei })
83
+ }
84
+ onCancel={() => setScreen({ type: "lobby" })}
85
+ />
86
+ )}
87
+
88
+ {screen.type === "game" && (
89
+ <GameBoard
90
+ matchId={screen.matchId}
91
+ wsToken={screen.wsToken}
92
+ playerId={playerId}
93
+ gameId={screen.gameId}
94
+ stakeWei={screen.stakeWei}
95
+ onGameOver={(winner, reason) =>
96
+ setScreen({ type: "gameover", winner, reason, stakeWei: screen.stakeWei })
97
+ }
98
+ />
99
+ )}
100
+
101
+ {screen.type === "gameover" && (
102
+ <GameOver
103
+ winner={screen.winner}
104
+ reason={screen.reason}
105
+ playerId={playerId}
106
+ stakeWei={screen.stakeWei}
107
+ onRematch={() => setScreen({ type: "lobby" })}
108
+ onQuit={() => setScreen({ type: "lobby" })}
109
+ />
110
+ )}
111
+
112
+ {screen.type === "watchlist" && (
113
+ <WatchList
114
+ onSelect={(matchId, gameId) =>
115
+ setScreen({ type: "watching", matchId, gameId })
116
+ }
117
+ onBack={() => setScreen({ type: "lobby" })}
118
+ />
119
+ )}
120
+
121
+ {screen.type === "watching" && (
122
+ <WatchGame
123
+ matchId={screen.matchId}
124
+ gameId={screen.gameId}
125
+ onBack={() => setScreen({ type: "watchlist" })}
126
+ />
127
+ )}
128
+ </Box>
129
+ );
130
+ }