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.
- package/.env.example +6 -0
- package/dist/commands/agent.d.ts +2 -0
- package/dist/commands/agent.js +163 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +124 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/config/configFile.d.ts +6 -0
- package/dist/config/configFile.js +43 -0
- package/dist/config/configFile.js.map +1 -0
- package/dist/config/defaults.d.ts +10 -0
- package/dist/config/defaults.js +19 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +5 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/resolve.d.ts +3 -0
- package/dist/config/resolve.js +24 -0
- package/dist/config/resolve.js.map +1 -0
- package/dist/config/runtime.d.ts +3 -0
- package/dist/config/runtime.js +13 -0
- package/dist/config/runtime.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +230 -0
- package/dist/index.js.map +1 -0
- package/dist/transport/httpClient.d.ts +13 -0
- package/dist/transport/httpClient.js +91 -0
- package/dist/transport/httpClient.js.map +1 -0
- package/dist/transport/wsClient.d.ts +30 -0
- package/dist/transport/wsClient.js +196 -0
- package/dist/transport/wsClient.js.map +1 -0
- package/dist/tui/App.d.ts +6 -0
- package/dist/tui/App.js +40 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/components/ChatPanel.d.ts +12 -0
- package/dist/tui/components/ChatPanel.js +18 -0
- package/dist/tui/components/ChatPanel.js.map +1 -0
- package/dist/tui/components/ColoredBoard.d.ts +7 -0
- package/dist/tui/components/ColoredBoard.js +73 -0
- package/dist/tui/components/ColoredBoard.js.map +1 -0
- package/dist/tui/components/PlayerInfo.d.ts +10 -0
- package/dist/tui/components/PlayerInfo.js +10 -0
- package/dist/tui/components/PlayerInfo.js.map +1 -0
- package/dist/tui/components/StatusBar.d.ts +7 -0
- package/dist/tui/components/StatusBar.js +13 -0
- package/dist/tui/components/StatusBar.js.map +1 -0
- package/dist/tui/components/TicTacToeBoard.d.ts +6 -0
- package/dist/tui/components/TicTacToeBoard.js +19 -0
- package/dist/tui/components/TicTacToeBoard.js.map +1 -0
- package/dist/tui/hooks/useEnsNames.d.ts +5 -0
- package/dist/tui/hooks/useEnsNames.js +33 -0
- package/dist/tui/hooks/useEnsNames.js.map +1 -0
- package/dist/tui/screens/GameBoard.d.ts +10 -0
- package/dist/tui/screens/GameBoard.js +245 -0
- package/dist/tui/screens/GameBoard.js.map +1 -0
- package/dist/tui/screens/GameOver.d.ts +10 -0
- package/dist/tui/screens/GameOver.js +21 -0
- package/dist/tui/screens/GameOver.js.map +1 -0
- package/dist/tui/screens/Leaderboard.d.ts +5 -0
- package/dist/tui/screens/Leaderboard.js +102 -0
- package/dist/tui/screens/Leaderboard.js.map +1 -0
- package/dist/tui/screens/Lobby.d.ts +8 -0
- package/dist/tui/screens/Lobby.js +113 -0
- package/dist/tui/screens/Lobby.js.map +1 -0
- package/dist/tui/screens/Matchmaking.d.ts +9 -0
- package/dist/tui/screens/Matchmaking.js +66 -0
- package/dist/tui/screens/Matchmaking.js.map +1 -0
- package/dist/tui/screens/WatchGame.d.ts +7 -0
- package/dist/tui/screens/WatchGame.js +99 -0
- package/dist/tui/screens/WatchGame.js.map +1 -0
- package/dist/tui/screens/WatchList.d.ts +6 -0
- package/dist/tui/screens/WatchList.js +49 -0
- package/dist/tui/screens/WatchList.js.map +1 -0
- package/dist/tui/theme.d.ts +30 -0
- package/dist/tui/theme.js +31 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/wallet/signer.d.ts +13 -0
- package/dist/wallet/signer.js +41 -0
- package/dist/wallet/signer.js.map +1 -0
- package/package.json +43 -0
- package/play-agents.cjs +444 -0
- package/src/commands/agent.ts +175 -0
- package/src/commands/config.ts +162 -0
- package/src/config/configFile.ts +55 -0
- package/src/config/defaults.ts +28 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolve.ts +33 -0
- package/src/config/runtime.ts +18 -0
- package/src/index.ts +237 -0
- package/src/transport/httpClient.ts +104 -0
- package/src/transport/wsClient.ts +214 -0
- package/src/tui/App.tsx +130 -0
- package/src/tui/components/ChatPanel.tsx +53 -0
- package/src/tui/components/ColoredBoard.tsx +98 -0
- package/src/tui/components/PlayerInfo.tsx +31 -0
- package/src/tui/components/StatusBar.tsx +35 -0
- package/src/tui/hooks/useEnsNames.ts +35 -0
- package/src/tui/screens/GameBoard.tsx +434 -0
- package/src/tui/screens/GameOver.tsx +83 -0
- package/src/tui/screens/Leaderboard.tsx +196 -0
- package/src/tui/screens/Lobby.tsx +197 -0
- package/src/tui/screens/Matchmaking.tsx +107 -0
- package/src/tui/screens/WatchGame.tsx +182 -0
- package/src/tui/screens/WatchList.tsx +99 -0
- package/src/tui/theme.ts +31 -0
- package/src/wallet/signer.ts +54 -0
- 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
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -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
|
+
}
|