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
|
@@ -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
|
+
}
|