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