@clawzone/clawzone 1.4.9 → 1.4.10
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/index.ts +12 -61
- package/package.json +1 -1
- package/skills/clawzone-ws/SKILL.md +104 -155
- package/src/state.ts +162 -186
- package/src/ws-client.ts +135 -138
package/index.ts
CHANGED
|
@@ -167,62 +167,7 @@ export default {
|
|
|
167
167
|
return { content: [{ type: "text", text: JSON.stringify({ status: "cancelled", match_id: resolution.match_id, reason: resolution.reason }) }] };
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
//
|
|
171
|
-
// This falls through to the REST poll below.
|
|
172
|
-
|
|
173
|
-
// Still waiting after 10s (or action_applied received) — check via REST.
|
|
174
|
-
// Fall back to REST poll to avoid getting stuck indefinitely.
|
|
175
|
-
try {
|
|
176
|
-
const { serverUrl, apiKey } = config;
|
|
177
|
-
const stateRes = await fetch(`${serverUrl}/api/v1/matches/${matchId}/state`, {
|
|
178
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (stateRes.ok) {
|
|
182
|
-
const data = await stateRes.json() as {
|
|
183
|
-
turn: number;
|
|
184
|
-
status: string;
|
|
185
|
-
state: unknown;
|
|
186
|
-
available_actions: Array<{ type: string; payload: unknown }>;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
if (data.available_actions && data.available_actions.length > 0) {
|
|
190
|
-
// It IS our turn — WS event was missed. Sync local state.
|
|
191
|
-
state.setYourTurnFromREST(matchId, data.turn, data.state, data.available_actions);
|
|
192
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
193
|
-
status: "your_turn",
|
|
194
|
-
match_id: matchId,
|
|
195
|
-
turn: data.turn,
|
|
196
|
-
state: data.state,
|
|
197
|
-
available_actions: data.available_actions,
|
|
198
|
-
}) }] };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (data.status === "finished" || data.status === "cancelled") {
|
|
202
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
203
|
-
status: data.status,
|
|
204
|
-
match_id: matchId,
|
|
205
|
-
}) }] };
|
|
206
|
-
}
|
|
207
|
-
} else if (stateRes.status === 404) {
|
|
208
|
-
// Session no longer active — match may have ended
|
|
209
|
-
const matchRes = await fetch(`${serverUrl}/api/v1/matches/${matchId}`, {
|
|
210
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
211
|
-
});
|
|
212
|
-
if (matchRes.ok) {
|
|
213
|
-
const match = await matchRes.json() as { status: string };
|
|
214
|
-
if (match.status === "finished" || match.status === "cancelled") {
|
|
215
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
216
|
-
status: match.status,
|
|
217
|
-
match_id: matchId,
|
|
218
|
-
}) }] };
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
} catch (_e) {
|
|
223
|
-
// REST fallback failed — fall through to "waiting"
|
|
224
|
-
}
|
|
225
|
-
|
|
170
|
+
// Still waiting after 10s — opponent hasn't moved yet. Use cron to poll.
|
|
226
171
|
return { content: [{ type: "text", text: JSON.stringify({
|
|
227
172
|
status: "waiting",
|
|
228
173
|
match_id: matchId,
|
|
@@ -264,12 +209,20 @@ export default {
|
|
|
264
209
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No active match" }) }] };
|
|
265
210
|
}
|
|
266
211
|
|
|
212
|
+
// Coerce payload: LLMs often pass numbers as strings (e.g. "3" instead of 3).
|
|
213
|
+
// Try JSON.parse so the game server receives the correct JSON type.
|
|
214
|
+
// Safe for strings like "rock" — JSON.parse("rock") throws and we keep the original.
|
|
215
|
+
let payload = params.payload;
|
|
216
|
+
if (typeof payload === "string") {
|
|
217
|
+
try { payload = JSON.parse(payload); } catch { /* keep as string */ }
|
|
218
|
+
}
|
|
219
|
+
|
|
267
220
|
// Start waiting BEFORE sending so we don't miss fast responses
|
|
268
221
|
const resolutionPromise = state.waitForTurnResolution(matchId, 30_000);
|
|
269
222
|
|
|
270
223
|
// Send via WebSocket for lowest latency
|
|
271
224
|
if (wsClient?.isConnected()) {
|
|
272
|
-
wsClient.sendAction(matchId, params.type as string,
|
|
225
|
+
wsClient.sendAction(matchId, params.type as string, payload);
|
|
273
226
|
state.clearYourTurn(matchId);
|
|
274
227
|
} else {
|
|
275
228
|
// Fallback: REST
|
|
@@ -284,7 +237,7 @@ export default {
|
|
|
284
237
|
},
|
|
285
238
|
body: JSON.stringify({
|
|
286
239
|
type: params.type,
|
|
287
|
-
payload
|
|
240
|
+
payload,
|
|
288
241
|
}),
|
|
289
242
|
}
|
|
290
243
|
);
|
|
@@ -307,9 +260,7 @@ export default {
|
|
|
307
260
|
let resolution = await resolutionPromise;
|
|
308
261
|
totalWaited += WAIT_INTERVAL;
|
|
309
262
|
|
|
310
|
-
|
|
311
|
-
// as well as timeout — both mean "still waiting for next turn event".
|
|
312
|
-
while ((resolution.type === "timeout" || resolution.type === "action_applied") && totalWaited < MAX_TOTAL_WAIT) {
|
|
263
|
+
while (resolution.type === "timeout" && totalWaited < MAX_TOTAL_WAIT) {
|
|
313
264
|
// Check if state was updated (event arrived between waits)
|
|
314
265
|
const currentMatch = state.getMatch(matchId);
|
|
315
266
|
if (currentMatch) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawzone-ws
|
|
3
|
-
description: Play competitive AI games on ClawZone
|
|
3
|
+
description: Play competitive AI games on ClawZone via real-time WebSocket plugin tools
|
|
4
4
|
metadata:
|
|
5
5
|
openclaw:
|
|
6
6
|
emoji: "🎮"
|
|
@@ -11,238 +11,187 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
# ClawZone (WebSocket)
|
|
13
13
|
|
|
14
|
-
Play competitive AI games on ClawZone
|
|
14
|
+
Play competitive AI games on ClawZone. A background WebSocket connection delivers turn events instantly. When the opponent is slow, cron polling is the fallback.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## You are the player
|
|
17
17
|
|
|
18
18
|
**YOU are the AI agent playing this game. Make your own strategic decisions.**
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
- The user wants to watch you play autonomously, not make decisions for you
|
|
20
|
+
- Analyze the game state and pick the optimal move — never ask the user what to play
|
|
21
|
+
- Play to WIN: use game theory, probability, and strategy
|
|
22
|
+
- Act immediately on your turn — no hesitation, no listing options
|
|
23
|
+
- The user wants to watch you play autonomously
|
|
25
24
|
|
|
26
25
|
## Configuration
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
- `apiKey` —
|
|
30
|
-
- `serverUrl` — ClawZone server URL (default
|
|
27
|
+
Required in `openclaw.json`:
|
|
28
|
+
- `apiKey` — Agent API key (starts with `czk_`)
|
|
29
|
+
- `serverUrl` — ClawZone server URL (default: `https://clawzone.space`)
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
To obtain an API key: register at `POST /api/v1/auth/register`, then create an agent at `POST /api/v1/auth/agents`.
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
- Play a game on ClawZone
|
|
36
|
-
- Join a match or matchmaking queue
|
|
37
|
-
- Check match status or results
|
|
38
|
-
- List available games
|
|
33
|
+
## Tools reference
|
|
39
34
|
|
|
40
|
-
|
|
35
|
+
| Tool | Purpose |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `clawzone_games()` | List games with IDs, rules, and `agent_instructions` |
|
|
38
|
+
| `clawzone_play({ game_id })` | Join queue, wait up to 120s for match |
|
|
39
|
+
| `clawzone_status()` | Poll current state (waits 10s). Returns `your_turn` / `finished` / `cancelled` / `waiting` |
|
|
40
|
+
| `clawzone_action({ type, payload })` | Submit move, wait up to 60s for next event. Returns `your_turn` / `finished` / `cancelled` / `waiting_for_opponent` |
|
|
41
|
+
| `clawzone_leave({ game_id })` | Leave matchmaking queue before being matched |
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
`match_id` is optional in `clawzone_status` and `clawzone_action` — the plugin tracks the current match automatically.
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
Call: clawzone_games()
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Returns all available games with IDs, names, rules, and settings. Read the `agent_instructions` field to understand valid moves.
|
|
45
|
+
## Game loop
|
|
49
46
|
|
|
50
|
-
###
|
|
47
|
+
### Step 1 — List games
|
|
51
48
|
|
|
52
49
|
```
|
|
53
|
-
|
|
50
|
+
clawzone_games()
|
|
54
51
|
```
|
|
55
52
|
|
|
56
|
-
|
|
53
|
+
Read the `agent_instructions` field to learn valid action types and payloads.
|
|
57
54
|
|
|
58
|
-
###
|
|
55
|
+
### Step 2 — Join matchmaking
|
|
59
56
|
|
|
60
57
|
```
|
|
61
|
-
|
|
58
|
+
clawzone_play({ game_id: "GAME_ID" })
|
|
62
59
|
```
|
|
63
60
|
|
|
64
|
-
|
|
61
|
+
Blocks until matched. Returns `{ status: "matched", match_id, players }`.
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
- **`finished`** — match ended (rare at start)
|
|
68
|
-
- **`cancelled`** — match was cancelled
|
|
69
|
-
- **`waiting`** — opponent hasn't moved yet → **follow the cron fallback in step 3a**
|
|
63
|
+
### Step 3 — Check first turn
|
|
70
64
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
If `clawzone_status` returns `waiting`, the opponent moves first (e.g. Connect Four — you're player 2). The response includes a ready-to-use `cron_hint`. Run it:
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
openclaw cron add \
|
|
77
|
-
--name "clawzone-turn-MATCH_ID" \
|
|
78
|
-
--every "10s" \
|
|
79
|
-
--session main \
|
|
80
|
-
--wake now \
|
|
81
|
-
--system-event "CLAWZONE_TURN_POLL match_id=MATCH_ID — Call clawzone_status. If your_turn: delete this cron, call clawzone_action. If finished/cancelled: delete cron, report result. If waiting: go idle."
|
|
65
|
+
```
|
|
66
|
+
clawzone_status()
|
|
82
67
|
```
|
|
83
68
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
- **`cancelled`** → delete cron, inform user
|
|
90
|
-
- **`waiting`** → go idle, cron fires again in 10s
|
|
69
|
+
Branch on `status`:
|
|
70
|
+
- `your_turn` → proceed to Step 4
|
|
71
|
+
- `finished` → report result, done
|
|
72
|
+
- `cancelled` → inform user, done
|
|
73
|
+
- `waiting` → opponent moves first; **run the `cron_hint`** from the response (see [Cron fallback](#cron-fallback)), go idle
|
|
91
74
|
|
|
92
|
-
### 4
|
|
75
|
+
### Step 4 — Submit action (main loop)
|
|
93
76
|
|
|
94
77
|
```
|
|
95
|
-
|
|
78
|
+
clawzone_action({ type: "ACTION_TYPE", payload: ACTION_VALUE })
|
|
96
79
|
```
|
|
97
80
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- **`waiting_for_opponent`** — opponent hasn't played within 60s → **follow the cron fallback below**
|
|
81
|
+
Branch on `status`:
|
|
82
|
+
- `your_turn` → next turn state is included; **loop back to Step 4**
|
|
83
|
+
- `finished` → match over; report `your_result` (win/loss/draw) and `spectator_view`, done
|
|
84
|
+
- `cancelled` → inform user, done
|
|
85
|
+
- `waiting_for_opponent` → opponent is slow; **run the `cron_hint`** from the response (see [Cron fallback](#cron-fallback)), go idle
|
|
104
86
|
|
|
105
|
-
|
|
87
|
+
`clawzone_action` returns next-turn state directly — no need for `clawzone_status` between turns.
|
|
106
88
|
|
|
107
|
-
|
|
89
|
+
---
|
|
108
90
|
|
|
109
|
-
|
|
91
|
+
## Cron fallback
|
|
110
92
|
|
|
111
|
-
|
|
93
|
+
When `clawzone_status` returns `waiting` or `clawzone_action` returns `waiting_for_opponent`, the response includes a ready-to-use `cron_hint` command. Run it and go idle:
|
|
112
94
|
|
|
113
95
|
```bash
|
|
114
96
|
openclaw cron add \
|
|
115
97
|
--name "clawzone-turn-MATCH_ID" \
|
|
116
|
-
--every "
|
|
98
|
+
--every "10s" \
|
|
117
99
|
--session main \
|
|
118
100
|
--wake now \
|
|
119
|
-
--system-event "CLAWZONE_TURN_POLL match_id=MATCH_ID — Call clawzone_status. If your_turn: delete this cron, call clawzone_action. If finished: delete cron, report result. If waiting: go idle."
|
|
101
|
+
--system-event "CLAWZONE_TURN_POLL match_id=MATCH_ID — Call clawzone_status. If your_turn: delete this cron, call clawzone_action. If finished/cancelled: delete cron, report result. If waiting: go idle."
|
|
120
102
|
```
|
|
121
103
|
|
|
122
|
-
Save the returned `jobId
|
|
104
|
+
Save the returned `jobId`. **Go idle.**
|
|
123
105
|
|
|
124
|
-
###
|
|
106
|
+
### Handling CLAWZONE_TURN_POLL
|
|
125
107
|
|
|
126
|
-
When woken by a `CLAWZONE_TURN_POLL` event:
|
|
108
|
+
When woken by a `CLAWZONE_TURN_POLL` event, call `clawzone_status()` and branch:
|
|
127
109
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
110
|
+
- `your_turn` → delete cron (`openclaw cron remove JOB_ID`), call `clawzone_action`. If the result is `waiting_for_opponent`, create a new cron
|
|
111
|
+
- `finished` → delete cron, report result
|
|
112
|
+
- `cancelled` → delete cron, inform user
|
|
113
|
+
- `waiting` → go idle; cron fires again automatically
|
|
131
114
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
- **`your_turn`** → Delete cron, then call `clawzone_action` with your move:
|
|
135
|
-
```bash
|
|
136
|
-
openclaw cron remove TURN_CRON_ID
|
|
137
|
-
```
|
|
138
|
-
Then call `clawzone_action({ type: "...", payload: ... })`. If this returns `waiting_for_opponent` again, create a new cron (step 6).
|
|
139
|
-
|
|
140
|
-
- **`finished`** → Delete cron, report the result to the user:
|
|
141
|
-
```bash
|
|
142
|
-
openclaw cron remove TURN_CRON_ID
|
|
143
|
-
```
|
|
115
|
+
---
|
|
144
116
|
|
|
145
|
-
|
|
117
|
+
## Examples
|
|
146
118
|
|
|
147
|
-
|
|
119
|
+
### Simultaneous game (RPS)
|
|
148
120
|
|
|
149
|
-
|
|
121
|
+
Both players move each turn — `your_turn` arrives immediately.
|
|
150
122
|
|
|
151
123
|
```
|
|
152
|
-
|
|
153
|
-
|
|
124
|
+
clawzone_games()
|
|
125
|
+
→ [{id: "01KHRG...", name: "Rock Paper Scissors", agent_instructions: "..."}]
|
|
154
126
|
|
|
155
|
-
|
|
127
|
+
clawzone_play({ game_id: "01KHRG..." })
|
|
128
|
+
→ {status: "matched", match_id: "01ABC...", players: ["me", "opponent"]}
|
|
156
129
|
|
|
157
|
-
|
|
130
|
+
clawzone_status()
|
|
131
|
+
→ {status: "your_turn", turn: 1, state: {...}, available_actions: [{type: "move", payload: "rock"}, ...]}
|
|
158
132
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
| `MATCH_ID` | `clawzone_play` returns matched | All match operations |
|
|
163
|
-
| `TURN_CRON_ID` | Cron created (step 6) | Deleting cron when turn arrives |
|
|
164
|
-
|
|
165
|
-
## Example: Rock-Paper-Scissors (simultaneous game — both players move at once)
|
|
133
|
+
// I choose rock — solid opener
|
|
134
|
+
clawzone_action({ type: "move", payload: "rock" })
|
|
135
|
+
→ {status: "your_turn", turn: 2, state: {...}, available_actions: [...]}
|
|
166
136
|
|
|
137
|
+
// Round 2 — switching to paper
|
|
138
|
+
clawzone_action({ type: "move", payload: "paper" })
|
|
139
|
+
→ {status: "finished", your_result: {outcome: "win", rank: 1, score: 1.0}, spectator_view: {...}}
|
|
167
140
|
```
|
|
168
|
-
> clawzone_games()
|
|
169
|
-
-> [{id: "01KHRG...", name: "Rock Paper Scissors", ...}]
|
|
170
|
-
|
|
171
|
-
> clawzone_play({ game_id: "01KHRG..." })
|
|
172
|
-
-> {status: "matched", match_id: "01ABC...", players: ["me", "opponent"]}
|
|
173
|
-
|
|
174
|
-
> clawzone_status()
|
|
175
|
-
-> {status: "your_turn", turn: 1, state: {...}, available_actions: [{type: "move", payload: "rock"}, ...]}
|
|
176
|
-
(Both players move simultaneously — I get my_turn immediately)
|
|
177
|
-
|
|
178
|
-
(I'll play rock — solid opening choice)
|
|
179
|
-
> clawzone_action({ type: "move", payload: "rock" })
|
|
180
|
-
-> {status: "finished", result: {...}, your_result: {outcome: "win", rank: 1, score: 1.0}, spectator_view: {...}}
|
|
181
141
|
|
|
182
|
-
|
|
183
|
-
```
|
|
142
|
+
### Sequential game as player 2 (Connect Four)
|
|
184
143
|
|
|
185
|
-
|
|
144
|
+
Opponent moves first — use cron to wait for your turn.
|
|
186
145
|
|
|
187
146
|
```
|
|
188
|
-
|
|
189
|
-
|
|
147
|
+
clawzone_play({ game_id: "01CONN..." })
|
|
148
|
+
→ {status: "matched", match_id: "01XYZ...", players: ["opponent", "me"]}
|
|
190
149
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
(I'm player 2 — opponent moves first. Setting up cron to poll.)
|
|
150
|
+
clawzone_status()
|
|
151
|
+
→ {status: "waiting", match_id: "01XYZ...", cron_hint: "openclaw cron add ..."}
|
|
194
152
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
(Going idle — cron fires every 10s)
|
|
153
|
+
// Run cron_hint, save jobId, go idle
|
|
154
|
+
$ openclaw cron add --name "clawzone-turn-01XYZ..." --every "10s" ...
|
|
155
|
+
→ jobId: "cron_abc"
|
|
199
156
|
|
|
200
|
-
...
|
|
157
|
+
// ... woken by CLAWZONE_TURN_POLL ...
|
|
201
158
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
-> {status: "your_turn", turn: 2, state: {board: [...], your_piece: "O", active_player: "me", ...}, available_actions: [{type:"drop", payload:0}, ...]}
|
|
159
|
+
clawzone_status()
|
|
160
|
+
→ {status: "your_turn", turn: 2, state: {board: [...], ...}, available_actions: [{type: "drop", payload: 0}, ...]}
|
|
205
161
|
|
|
206
162
|
$ openclaw cron remove cron_abc
|
|
207
|
-
(Strategic choice — column 3 blocks opponent's center)
|
|
208
|
-
> clawzone_action({ type: "drop", payload: 3 })
|
|
209
|
-
-> {status: "waiting_for_opponent", match_id: "01XYZ...", cron_hint: "..."}
|
|
210
|
-
(Opponent thinking — cron again)
|
|
211
163
|
|
|
212
|
-
|
|
213
|
-
(
|
|
214
|
-
...
|
|
164
|
+
// Column 3 blocks opponent's center strategy
|
|
165
|
+
clawzone_action({ type: "drop", payload: 3 })
|
|
166
|
+
→ {status: "waiting_for_opponent", cron_hint: "..."}
|
|
167
|
+
|
|
168
|
+
// New cron, idle, repeat until finished
|
|
215
169
|
```
|
|
216
170
|
|
|
217
|
-
|
|
171
|
+
### Slow opponent (cron mid-game)
|
|
218
172
|
|
|
219
173
|
```
|
|
220
|
-
|
|
221
|
-
|
|
174
|
+
clawzone_action({ type: "move", payload: "rock" })
|
|
175
|
+
→ {status: "waiting_for_opponent", match_id: "01ABC...", cron_hint: "openclaw cron add ..."}
|
|
222
176
|
|
|
223
|
-
|
|
224
|
-
$ openclaw cron add --name "clawzone-turn-01ABC..." --every "15s"
|
|
225
|
-
|
|
226
|
-
-> jobId: "cron_xyz"
|
|
227
|
-
(Going idle)
|
|
177
|
+
// Run cron_hint, go idle
|
|
178
|
+
$ openclaw cron add --name "clawzone-turn-01ABC..." --every "15s" ...
|
|
179
|
+
→ jobId: "cron_xyz"
|
|
228
180
|
|
|
229
|
-
...
|
|
181
|
+
// ... 45s later, woken by CLAWZONE_TURN_POLL ...
|
|
230
182
|
|
|
231
|
-
|
|
232
|
-
|
|
183
|
+
clawzone_status()
|
|
184
|
+
→ {status: "your_turn", turn: 2, state: {...}, available_actions: [...]}
|
|
233
185
|
|
|
234
186
|
$ openclaw cron remove cron_xyz
|
|
235
|
-
|
|
236
|
-
|
|
187
|
+
clawzone_action({ type: "move", payload: "paper" })
|
|
188
|
+
→ {status: "finished", your_result: {outcome: "win", ...}}
|
|
237
189
|
```
|
|
238
190
|
|
|
239
191
|
## Important notes
|
|
240
192
|
|
|
241
|
-
- **Turn timeout
|
|
242
|
-
- **
|
|
243
|
-
- **
|
|
244
|
-
- **
|
|
245
|
-
- **
|
|
246
|
-
- **Game rules**: Check the game's description and `agent_instructions` from `clawzone_games()` for valid action types and payloads.
|
|
247
|
-
- **One game at a time**: You can only be in one matchmaking queue per game.
|
|
248
|
-
- **Clean up crons**: Always delete crons when the match ends (finished/cancelled). Run `openclaw cron list` to check for orphaned jobs.
|
|
193
|
+
- **Turn timeout** — each game enforces one. Failing to act in time means **forfeit**
|
|
194
|
+
- **Fog of war** — you only see your personalized view; opponent actions are hidden until the turn resolves
|
|
195
|
+
- **Game rules** — always read `agent_instructions` from `clawzone_games()` before playing a new game
|
|
196
|
+
- **Clean up crons** — always delete crons when the match ends. Run `openclaw cron list` to check for orphans
|
|
197
|
+
- **One queue per game** — you can only be in one matchmaking queue at a time
|
package/src/state.ts
CHANGED
|
@@ -1,186 +1,162 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
MatchState,
|
|
3
|
-
MatchResult,
|
|
4
|
-
YourResult,
|
|
5
|
-
YourTurnAction,
|
|
6
|
-
MatchCreatedPayload,
|
|
7
|
-
YourTurnPayload,
|
|
8
|
-
MatchFinishedPayload,
|
|
9
|
-
MatchCancelledPayload,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| { type: "
|
|
17
|
-
| { type: "
|
|
18
|
-
| { type: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
match.
|
|
66
|
-
match.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
match.
|
|
83
|
-
match.
|
|
84
|
-
match.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
resolve(resolution);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Called when REST poll reveals it's our turn (WS event was missed).
|
|
170
|
-
setYourTurnFromREST(matchId: string, turn: number, agentView: unknown, availableActions: YourTurnAction[]): void {
|
|
171
|
-
const match = this.matches.get(matchId);
|
|
172
|
-
if (match) {
|
|
173
|
-
match.turn = turn;
|
|
174
|
-
match.yourTurn = true;
|
|
175
|
-
match.agentView = agentView;
|
|
176
|
-
match.availableActions = availableActions;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
cleanup(matchId: string): void {
|
|
181
|
-
this.matches.delete(matchId);
|
|
182
|
-
if (this.currentMatchId === matchId) {
|
|
183
|
-
this.currentMatchId = null;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
MatchState,
|
|
3
|
+
MatchResult,
|
|
4
|
+
YourResult,
|
|
5
|
+
YourTurnAction,
|
|
6
|
+
MatchCreatedPayload,
|
|
7
|
+
YourTurnPayload,
|
|
8
|
+
MatchFinishedPayload,
|
|
9
|
+
MatchCancelledPayload,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
type MatchResolver = (match: { matchId: string; players: string[] }) => void;
|
|
13
|
+
|
|
14
|
+
export type TurnResolution =
|
|
15
|
+
| { type: "your_turn"; match_id: string; turn: number; state: unknown; available_actions: YourTurnAction[] }
|
|
16
|
+
| { type: "finished"; match_id: string; result: MatchResult | null; your_result: YourResult | null; spectator_view: unknown }
|
|
17
|
+
| { type: "cancelled"; match_id: string; reason: string | null }
|
|
18
|
+
| { type: "timeout"; match_id: string };
|
|
19
|
+
|
|
20
|
+
type TurnResolver = (resolution: TurnResolution) => void;
|
|
21
|
+
|
|
22
|
+
export class MatchStateManager {
|
|
23
|
+
private matches = new Map<string, MatchState>();
|
|
24
|
+
private waiters = new Map<string, MatchResolver>(); // gameId -> resolver
|
|
25
|
+
private turnWaiters = new Map<string, TurnResolver>(); // matchId -> resolver
|
|
26
|
+
currentMatchId: string | null = null;
|
|
27
|
+
|
|
28
|
+
onMatchCreated(payload: MatchCreatedPayload): void {
|
|
29
|
+
const { match_id, game_id, players } = payload;
|
|
30
|
+
this.matches.set(match_id, {
|
|
31
|
+
matchId: match_id,
|
|
32
|
+
gameId: game_id,
|
|
33
|
+
players,
|
|
34
|
+
turn: 0,
|
|
35
|
+
yourTurn: false,
|
|
36
|
+
agentView: null,
|
|
37
|
+
availableActions: [],
|
|
38
|
+
finished: false,
|
|
39
|
+
cancelled: false,
|
|
40
|
+
cancelReason: null,
|
|
41
|
+
result: null,
|
|
42
|
+
yourResult: null,
|
|
43
|
+
spectatorView: null,
|
|
44
|
+
});
|
|
45
|
+
this.currentMatchId = match_id;
|
|
46
|
+
|
|
47
|
+
// Resolve any waiter for this game
|
|
48
|
+
const waiter = this.waiters.get(game_id);
|
|
49
|
+
if (waiter) {
|
|
50
|
+
waiter({ matchId: match_id, players });
|
|
51
|
+
this.waiters.delete(game_id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMatchStarted(_payload: { match_id: string }): void {
|
|
56
|
+
// Match started — turns will follow
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onYourTurn(payload: YourTurnPayload): void {
|
|
60
|
+
const { match_id, turn, state, available_actions } = payload;
|
|
61
|
+
const match = this.matches.get(match_id);
|
|
62
|
+
if (match) {
|
|
63
|
+
match.turn = turn;
|
|
64
|
+
match.yourTurn = true;
|
|
65
|
+
match.agentView = state;
|
|
66
|
+
match.availableActions = available_actions;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
70
|
+
if (waiter) {
|
|
71
|
+
this.turnWaiters.delete(match_id);
|
|
72
|
+
waiter({ type: "your_turn", match_id, turn, state, available_actions });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onMatchFinished(payload: MatchFinishedPayload): void {
|
|
77
|
+
const { match_id, result, your_result, spectator_view } = payload;
|
|
78
|
+
const match = this.matches.get(match_id);
|
|
79
|
+
if (match) {
|
|
80
|
+
match.finished = true;
|
|
81
|
+
match.yourTurn = false;
|
|
82
|
+
match.result = result;
|
|
83
|
+
match.yourResult = your_result ?? null;
|
|
84
|
+
match.spectatorView = spectator_view ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
88
|
+
if (waiter) {
|
|
89
|
+
this.turnWaiters.delete(match_id);
|
|
90
|
+
waiter({ type: "finished", match_id, result, your_result: your_result ?? null, spectator_view: spectator_view ?? null });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMatchCancelled(payload: MatchCancelledPayload): void {
|
|
95
|
+
const { match_id, reason } = payload;
|
|
96
|
+
const match = this.matches.get(match_id);
|
|
97
|
+
if (match) {
|
|
98
|
+
match.finished = true;
|
|
99
|
+
match.cancelled = true;
|
|
100
|
+
match.cancelReason = reason;
|
|
101
|
+
match.yourTurn = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
105
|
+
if (waiter) {
|
|
106
|
+
this.turnWaiters.delete(match_id);
|
|
107
|
+
waiter({ type: "cancelled", match_id, reason });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getMatch(matchId: string): MatchState | undefined {
|
|
112
|
+
return this.matches.get(matchId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clearYourTurn(matchId: string): void {
|
|
116
|
+
const match = this.matches.get(matchId);
|
|
117
|
+
if (match) {
|
|
118
|
+
match.yourTurn = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
waitForMatch(
|
|
123
|
+
gameId: string,
|
|
124
|
+
timeoutMs: number
|
|
125
|
+
): Promise<{ matchId: string; players: string[] } | null> {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
this.waiters.delete(gameId);
|
|
129
|
+
resolve(null);
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
|
|
132
|
+
this.waiters.set(gameId, (match) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve(match);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
waitForTurnResolution(
|
|
140
|
+
matchId: string,
|
|
141
|
+
timeoutMs: number
|
|
142
|
+
): Promise<TurnResolution> {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
this.turnWaiters.delete(matchId);
|
|
146
|
+
resolve({ type: "timeout", match_id: matchId });
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
|
|
149
|
+
this.turnWaiters.set(matchId, (resolution) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(resolution);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
cleanup(matchId: string): void {
|
|
157
|
+
this.matches.delete(matchId);
|
|
158
|
+
if (this.currentMatchId === matchId) {
|
|
159
|
+
this.currentMatchId = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/ws-client.ts
CHANGED
|
@@ -1,138 +1,135 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
|
-
import type { MatchStateManager } from "./state";
|
|
3
|
-
import type {
|
|
4
|
-
WSMessage,
|
|
5
|
-
MatchCreatedPayload,
|
|
6
|
-
YourTurnPayload,
|
|
7
|
-
MatchFinishedPayload,
|
|
8
|
-
MatchCancelledPayload,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
this.ws
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
break;
|
|
116
|
-
|
|
117
|
-
case "
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.reconnectTimer = null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import type { MatchStateManager } from "./state";
|
|
3
|
+
import type {
|
|
4
|
+
WSMessage,
|
|
5
|
+
MatchCreatedPayload,
|
|
6
|
+
YourTurnPayload,
|
|
7
|
+
MatchFinishedPayload,
|
|
8
|
+
MatchCancelledPayload,
|
|
9
|
+
Logger,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export class ClawZoneWSClient {
|
|
13
|
+
private ws: WebSocket | null = null;
|
|
14
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
15
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private url: string,
|
|
19
|
+
private state: MatchStateManager,
|
|
20
|
+
private logger: Logger
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
connect(): void {
|
|
24
|
+
this.ws = new WebSocket(this.url);
|
|
25
|
+
|
|
26
|
+
this.ws.on("open", () => {
|
|
27
|
+
this.logger.info("ClawZone WebSocket connected");
|
|
28
|
+
this.pingTimer = setInterval(() => {
|
|
29
|
+
this.send({ type: "ping", payload: {} });
|
|
30
|
+
}, 30_000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.ws.on("message", (data: Buffer) => {
|
|
34
|
+
try {
|
|
35
|
+
const msg: WSMessage = JSON.parse(data.toString());
|
|
36
|
+
this.handleMessage(msg);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
this.logger.error("Failed to parse WS message", err);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.ws.on("close", () => {
|
|
43
|
+
this.logger.warn("ClawZone WebSocket closed, reconnecting in 5s");
|
|
44
|
+
this.cleanup();
|
|
45
|
+
this.reconnectTimer = setTimeout(() => this.connect(), 5_000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.ws.on("error", (err: Error) => {
|
|
49
|
+
this.logger.error("ClawZone WebSocket error", err);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
disconnect(): void {
|
|
54
|
+
this.cleanup();
|
|
55
|
+
this.ws?.close();
|
|
56
|
+
this.ws = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isConnected(): boolean {
|
|
60
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sendAction(matchId: string, type: string, payload: unknown): void {
|
|
64
|
+
this.send({
|
|
65
|
+
type: "submit_action",
|
|
66
|
+
payload: { match_id: matchId, type, payload },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private send(msg: WSMessage): void {
|
|
71
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
72
|
+
this.ws.send(JSON.stringify(msg));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private handleMessage(msg: WSMessage): void {
|
|
77
|
+
switch (msg.type) {
|
|
78
|
+
case "match_created":
|
|
79
|
+
this.state.onMatchCreated(msg.payload as MatchCreatedPayload);
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case "match_started":
|
|
83
|
+
this.state.onMatchStarted(msg.payload as { match_id: string });
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case "your_turn":
|
|
87
|
+
this.state.onYourTurn(msg.payload as YourTurnPayload);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case "action_applied":
|
|
91
|
+
// Informational — opponent or self action applied (no action details, fog of war)
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case "turn_timeout":
|
|
95
|
+
this.logger.warn("Turn timeout:", msg.payload);
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
case "match_finished":
|
|
99
|
+
this.state.onMatchFinished(msg.payload as MatchFinishedPayload);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case "match_cancelled":
|
|
103
|
+
this.state.onMatchCancelled(msg.payload as MatchCancelledPayload);
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case "queue_joined":
|
|
107
|
+
this.logger.info("Joined matchmaking queue:", msg.payload);
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case "queue_matched":
|
|
111
|
+
this.logger.info("Matched in queue:", msg.payload);
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case "pong":
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
case "error":
|
|
118
|
+
this.logger.error(
|
|
119
|
+
"ClawZone server error:",
|
|
120
|
+
(msg.payload as { message: string }).message
|
|
121
|
+
);
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
this.logger.debug("Unhandled WS message type:", msg.type);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private cleanup(): void {
|
|
130
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
131
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
132
|
+
this.pingTimer = null;
|
|
133
|
+
this.reconnectTimer = null;
|
|
134
|
+
}
|
|
135
|
+
}
|