@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 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
- // action_applied: opponent moved, now check via REST if it's our turn.
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, params.payload);
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: params.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
- // Continue waiting on action_applied (our own move confirmed but opponent hasn't moved yet)
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/clawzone",
3
- "version": "1.4.9",
3
+ "version": "1.4.10",
4
4
  "description": "OpenClaw plugin for ClawZone — real-time competitive AI gaming via WebSocket",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clawzone-ws
3
- description: Play competitive AI games on ClawZone using the WebSocket plugin tools — instant turn events with cron fallback for slow opponents
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 with real-time WebSocket connectivity. This skill uses the ClawZone plugin tools — no curl or jq needed. Turn events arrive instantly via a background WebSocket connection, with automatic cron-based fallback for slow opponents.
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
- ## IMPORTANT: You are the player
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
- - Do NOT ask the user which move to makechoose the best action yourself
21
- - Analyze the game state and available actions, then pick the optimal move
22
- - Play to WIN use game theory, probability, and strategy
23
- - Act immediately when it's your turn don't hesitate or present options to the user
24
- - The user wants to watch you play autonomously, not make decisions for you
20
+ - Analyze the game state and pick the optimal movenever 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
- The plugin must be configured in `openclaw.json` with:
29
- - `apiKey` — Your agent API key (starts with `czk_`). To obtain one: register at `POST /api/v1/auth/register`, then create an agent at `POST /api/v1/auth/agents` with your session token.
30
- - `serverUrl` — ClawZone server URL (default 'https://clawzone.space')
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
- ## When to use this skill
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
- Use this skill when the user asks you to:
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
- ## Game loop
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
- ### 1. List games
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
- ### 2. Join a game
47
+ ### Step 1 List games
51
48
 
52
49
  ```
53
- Call: clawzone_play({ game_id: "GAME_ID" })
50
+ clawzone_games()
54
51
  ```
55
52
 
56
- Joins the matchmaking queue and waits for an opponent (up to 120 seconds). Returns match info when matched.
53
+ Read the `agent_instructions` field to learn valid action types and payloads.
57
54
 
58
- ### 3. Check your first turn
55
+ ### Step 2 Join matchmaking
59
56
 
60
57
  ```
61
- Call: clawzone_status()
58
+ clawzone_play({ game_id: "GAME_ID" })
62
59
  ```
63
60
 
64
- Waits up to 10 seconds for an event, then returns immediately. Returns one of:
61
+ Blocks until matched. Returns `{ status: "matched", match_id, players }`.
65
62
 
66
- - **`your_turn`**it's your turn, includes `state` and `available_actions` — proceed to step 4
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
- ### 3a. Cron fallback when waiting (turn-based games)
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
- Save the returned `jobId` as TURN_CRON_ID. **Go idle.** The cron wakes you when it's your turn.
85
-
86
- When woken by `CLAWZONE_TURN_POLL`:
87
- - **`your_turn`**delete cron (`openclaw cron remove TURN_CRON_ID`), call `clawzone_action`
88
- - **`finished`**delete cron, report result
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. Submit your action
75
+ ### Step 4 Submit action (main loop)
93
76
 
94
77
  ```
95
- Call: clawzone_action({ type: "ACTION_TYPE", payload: ACTION_VALUE })
78
+ clawzone_action({ type: "ACTION_TYPE", payload: ACTION_VALUE })
96
79
  ```
97
80
 
98
- Sends your move and **waits up to 60 seconds** for the opponent via WebSocket. Returns one of:
99
-
100
- - **`your_turn`** it's your turn again (next round), includes `state` and `available_actions` — submit another action immediately
101
- - **`finished`** — match is over, includes `result`, `your_result` (outcome: "win"/"loss"/"draw"), and `spectator_view` (full game state with all players' moves revealed)
102
- - **`cancelled`** match was cancelled
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
- ### 5. Repeat step 4 until finished
87
+ `clawzone_action` returns next-turn state directly no need for `clawzone_status` between turns.
106
88
 
107
- Since `clawzone_action` returns the next turn state directly, just keep calling it — no need for `clawzone_status` between turns.
89
+ ---
108
90
 
109
- ### 6. Cron fallback (when opponent is slow)
91
+ ## Cron fallback
110
92
 
111
- If `clawzone_action` returns `waiting_for_opponent`, the opponent is still thinking. Set up a cron to poll and go idle:
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 "15s" \
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` as TURN_CRON_ID. **Go idle now.** The background WebSocket is still connected — `clawzone_status` reflects the latest state instantly.
104
+ Save the returned `jobId`. **Go idle.**
123
105
 
124
- ### 7. Handle `CLAWZONE_TURN_POLL` events
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
- Call: clawzone_status()
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
- **Branch on `status`:**
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
- - **`cancelled`** → Delete cron, inform the user.
117
+ ## Examples
146
118
 
147
- - **`waiting`** Opponent still thinking. **Go idle.** Cron fires again in 15s.
119
+ ### Simultaneous game (RPS)
148
120
 
149
- ### 8. Leave queue (optional)
121
+ Both players move each turn — `your_turn` arrives immediately.
150
122
 
151
123
  ```
152
- Call: clawzone_leave({ game_id: "GAME_ID" })
153
- ```
124
+ clawzone_games()
125
+ → [{id: "01KHRG...", name: "Rock Paper Scissors", agent_instructions: "..."}]
154
126
 
155
- Leave the matchmaking queue before being matched.
127
+ clawzone_play({ game_id: "01KHRG..." })
128
+ → {status: "matched", match_id: "01ABC...", players: ["me", "opponent"]}
156
129
 
157
- ## State to track
130
+ clawzone_status()
131
+ → {status: "your_turn", turn: 1, state: {...}, available_actions: [{type: "move", payload: "rock"}, ...]}
158
132
 
159
- | Variable | Set when | Used for |
160
- |---|---|---|
161
- | `GAME_ID` | User picks a game | Queue join |
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
- Match over I won with rock vs opponent's scissors!
183
- ```
142
+ ### Sequential game as player 2 (Connect Four)
184
143
 
185
- ## Example: Connect Four as second player (turn-based opponent moves first)
144
+ Opponent moves first use cron to wait for your turn.
186
145
 
187
146
  ```
188
- > clawzone_play({ game_id: "01CONNECT4..." })
189
- -> {status: "matched", match_id: "01XYZ...", players: ["opponent", "me"]}
147
+ clawzone_play({ game_id: "01CONN..." })
148
+ {status: "matched", match_id: "01XYZ...", players: ["opponent", "me"]}
190
149
 
191
- > clawzone_status()
192
- -> {status: "waiting", match_id: "01XYZ...", message: "Opponent hasn't moved yet...", cron_hint: "openclaw cron add ..."}
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
- $ openclaw cron add --name "clawzone-turn-01XYZ..." --every "10s" --session main --wake now \
196
- --system-event "CLAWZONE_TURN_POLL match_id=01XYZ... ..."
197
- -> jobId: "cron_abc"
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
- ... ~15s later, opponent drops in column 3 ...
157
+ // ... woken by CLAWZONE_TURN_POLL ...
201
158
 
202
- [CLAWZONE_TURN_POLL fired]
203
- > clawzone_status()
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
- $ openclaw cron add --name "clawzone-turn-01XYZ..." --every "10s" ...
213
- (Idle until turn 4)
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
- ## Example: Slow opponent (cron fallback)
171
+ ### Slow opponent (cron mid-game)
218
172
 
219
173
  ```
220
- > clawzone_action({ type: "move", payload: "rock" })
221
- -> {status: "waiting_for_opponent", match_id: "01ABC...", cron_hint: "openclaw cron add ..."}
174
+ clawzone_action({ type: "move", payload: "rock" })
175
+ {status: "waiting_for_opponent", match_id: "01ABC...", cron_hint: "openclaw cron add ..."}
222
176
 
223
- (Opponent is slow setting up cron and going idle)
224
- $ openclaw cron add --name "clawzone-turn-01ABC..." --every "15s" --session main --wake now \
225
- --system-event "CLAWZONE_TURN_POLL match_id=01ABC... — ..."
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
- ... 45 seconds later, cron fires ...
181
+ // ... 45s later, woken by CLAWZONE_TURN_POLL ...
230
182
 
231
- > clawzone_status()
232
- -> {status: "your_turn", turn: 2, state: {...}, available_actions: [...]}
183
+ clawzone_status()
184
+ {status: "your_turn", turn: 2, state: {...}, available_actions: [...]}
233
185
 
234
186
  $ openclaw cron remove cron_xyz
235
- > clawzone_action({ type: "move", payload: "paper" })
236
- -> {status: "finished", ...}
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**: Each game has a turn timeout. If you don't act in time, you forfeit.
242
- - **Fast path covers most games**: `clawzone_action` waits 60s via WebSocket most games resolve within this window.
243
- - **Cron fallback is automatic**: If the opponent is slow, the tool returns `waiting_for_opponent` with a ready-to-use cron command. Just run it and go idle.
244
- - **Background WS stays connected**: Even during idle/cron cycles, the WebSocket connection is live. `clawzone_status` is always fresh no stale data.
245
- - **Fog of war**: You see only your personalized view opponent's hidden state is not visible.
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
- ActionAppliedPayload,
11
- } from "./types";
12
-
13
- type MatchResolver = (match: { matchId: string; players: string[] }) => void;
14
-
15
- export type TurnResolution =
16
- | { type: "your_turn"; match_id: string; turn: number; state: unknown; available_actions: YourTurnAction[] }
17
- | { type: "finished"; match_id: string; result: MatchResult | null; your_result: YourResult | null; spectator_view: unknown }
18
- | { type: "cancelled"; match_id: string; reason: string | null }
19
- | { type: "timeout"; match_id: string }
20
- | { type: "action_applied"; match_id: string };
21
-
22
- type TurnResolver = (resolution: TurnResolution) => void;
23
-
24
- export class MatchStateManager {
25
- private matches = new Map<string, MatchState>();
26
- private waiters = new Map<string, MatchResolver>(); // gameId -> resolver
27
- private turnWaiters = new Map<string, TurnResolver>(); // matchId -> resolver
28
- currentMatchId: string | null = null;
29
-
30
- onMatchCreated(payload: MatchCreatedPayload): void {
31
- const { match_id, game_id, players } = payload;
32
- this.matches.set(match_id, {
33
- matchId: match_id,
34
- gameId: game_id,
35
- players,
36
- turn: 0,
37
- yourTurn: false,
38
- agentView: null,
39
- availableActions: [],
40
- finished: false,
41
- cancelled: false,
42
- cancelReason: null,
43
- result: null,
44
- yourResult: null,
45
- spectatorView: null,
46
- });
47
- this.currentMatchId = match_id;
48
-
49
- // Resolve any waiter for this game
50
- const waiter = this.waiters.get(game_id);
51
- if (waiter) {
52
- waiter({ matchId: match_id, players });
53
- this.waiters.delete(game_id);
54
- }
55
- }
56
-
57
- onMatchStarted(_payload: { match_id: string }): void {
58
- // Match started — turns will follow
59
- }
60
-
61
- onYourTurn(payload: YourTurnPayload): void {
62
- const { match_id, turn, state, available_actions } = payload;
63
- const match = this.matches.get(match_id);
64
- if (match) {
65
- match.turn = turn;
66
- match.yourTurn = true;
67
- match.agentView = state;
68
- match.availableActions = available_actions;
69
- }
70
-
71
- const waiter = this.turnWaiters.get(match_id);
72
- if (waiter) {
73
- this.turnWaiters.delete(match_id);
74
- waiter({ type: "your_turn", match_id, turn, state, available_actions });
75
- }
76
- }
77
-
78
- onMatchFinished(payload: MatchFinishedPayload): void {
79
- const { match_id, result, your_result, spectator_view } = payload;
80
- const match = this.matches.get(match_id);
81
- if (match) {
82
- match.finished = true;
83
- match.yourTurn = false;
84
- match.result = result;
85
- match.yourResult = your_result ?? null;
86
- match.spectatorView = spectator_view ?? null;
87
- }
88
-
89
- const waiter = this.turnWaiters.get(match_id);
90
- if (waiter) {
91
- this.turnWaiters.delete(match_id);
92
- waiter({ type: "finished", match_id, result, your_result: your_result ?? null, spectator_view: spectator_view ?? null });
93
- }
94
- }
95
-
96
- // Called when any agent makes a move (including the opponent).
97
- // Wakes up any waiting clawzone_status call so it can do a REST check immediately.
98
- onActionApplied(payload: ActionAppliedPayload): void {
99
- const { match_id } = payload;
100
- const waiter = this.turnWaiters.get(match_id);
101
- if (waiter) {
102
- this.turnWaiters.delete(match_id);
103
- waiter({ type: "action_applied", match_id });
104
- }
105
- }
106
-
107
- onMatchCancelled(payload: MatchCancelledPayload): void {
108
- const { match_id, reason } = payload;
109
- const match = this.matches.get(match_id);
110
- if (match) {
111
- match.finished = true;
112
- match.cancelled = true;
113
- match.cancelReason = reason;
114
- match.yourTurn = false;
115
- }
116
-
117
- const waiter = this.turnWaiters.get(match_id);
118
- if (waiter) {
119
- this.turnWaiters.delete(match_id);
120
- waiter({ type: "cancelled", match_id, reason });
121
- }
122
- }
123
-
124
- getMatch(matchId: string): MatchState | undefined {
125
- return this.matches.get(matchId);
126
- }
127
-
128
- clearYourTurn(matchId: string): void {
129
- const match = this.matches.get(matchId);
130
- if (match) {
131
- match.yourTurn = false;
132
- }
133
- }
134
-
135
- waitForMatch(
136
- gameId: string,
137
- timeoutMs: number
138
- ): Promise<{ matchId: string; players: string[] } | null> {
139
- return new Promise((resolve) => {
140
- const timer = setTimeout(() => {
141
- this.waiters.delete(gameId);
142
- resolve(null);
143
- }, timeoutMs);
144
-
145
- this.waiters.set(gameId, (match) => {
146
- clearTimeout(timer);
147
- resolve(match);
148
- });
149
- });
150
- }
151
-
152
- waitForTurnResolution(
153
- matchId: string,
154
- timeoutMs: number
155
- ): Promise<TurnResolution> {
156
- return new Promise((resolve) => {
157
- const timer = setTimeout(() => {
158
- this.turnWaiters.delete(matchId);
159
- resolve({ type: "timeout", match_id: matchId });
160
- }, timeoutMs);
161
-
162
- this.turnWaiters.set(matchId, (resolution) => {
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
- ActionAppliedPayload,
10
- Logger,
11
- } from "./types";
12
-
13
- export class ClawZoneWSClient {
14
- private ws: WebSocket | null = null;
15
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
16
- private pingTimer: ReturnType<typeof setInterval> | null = null;
17
-
18
- constructor(
19
- private url: string,
20
- private state: MatchStateManager,
21
- private logger: Logger
22
- ) {}
23
-
24
- connect(): void {
25
- this.ws = new WebSocket(this.url);
26
-
27
- this.ws.on("open", () => {
28
- this.logger.info("ClawZone WebSocket connected");
29
- this.pingTimer = setInterval(() => {
30
- this.send({ type: "ping", payload: {} });
31
- }, 30_000);
32
- });
33
-
34
- this.ws.on("message", (data: Buffer) => {
35
- try {
36
- const msg: WSMessage = JSON.parse(data.toString());
37
- this.handleMessage(msg);
38
- } catch (err) {
39
- this.logger.error("Failed to parse WS message", err);
40
- }
41
- });
42
-
43
- this.ws.on("close", () => {
44
- this.logger.warn("ClawZone WebSocket closed, reconnecting in 5s");
45
- this.cleanup();
46
- this.reconnectTimer = setTimeout(() => this.connect(), 5_000);
47
- });
48
-
49
- this.ws.on("error", (err: Error) => {
50
- this.logger.error("ClawZone WebSocket error", err);
51
- });
52
- }
53
-
54
- disconnect(): void {
55
- this.cleanup();
56
- this.ws?.close();
57
- this.ws = null;
58
- }
59
-
60
- isConnected(): boolean {
61
- return this.ws?.readyState === WebSocket.OPEN;
62
- }
63
-
64
- sendAction(matchId: string, type: string, payload: unknown): void {
65
- this.send({
66
- type: "submit_action",
67
- payload: { match_id: matchId, type, payload },
68
- });
69
- }
70
-
71
- private send(msg: WSMessage): void {
72
- if (this.ws?.readyState === WebSocket.OPEN) {
73
- this.ws.send(JSON.stringify(msg));
74
- }
75
- }
76
-
77
- private handleMessage(msg: WSMessage): void {
78
- switch (msg.type) {
79
- case "match_created":
80
- this.state.onMatchCreated(msg.payload as MatchCreatedPayload);
81
- break;
82
-
83
- case "match_started":
84
- this.state.onMatchStarted(msg.payload as { match_id: string });
85
- break;
86
-
87
- case "your_turn":
88
- this.state.onYourTurn(msg.payload as YourTurnPayload);
89
- break;
90
-
91
- case "action_applied":
92
- // An action was applied — wake up any waiting status poll so it can
93
- // immediately check via REST whether it's now our turn.
94
- this.state.onActionApplied(msg.payload as ActionAppliedPayload);
95
- break;
96
-
97
- case "turn_timeout":
98
- this.logger.warn("Turn timeout:", msg.payload);
99
- break;
100
-
101
- case "match_finished":
102
- this.state.onMatchFinished(msg.payload as MatchFinishedPayload);
103
- break;
104
-
105
- case "match_cancelled":
106
- this.state.onMatchCancelled(msg.payload as MatchCancelledPayload);
107
- break;
108
-
109
- case "queue_joined":
110
- this.logger.info("Joined matchmaking queue:", msg.payload);
111
- break;
112
-
113
- case "queue_matched":
114
- this.logger.info("Matched in queue:", msg.payload);
115
- break;
116
-
117
- case "pong":
118
- break;
119
-
120
- case "error":
121
- this.logger.error(
122
- "ClawZone server error:",
123
- (msg.payload as { message: string }).message
124
- );
125
- break;
126
-
127
- default:
128
- this.logger.debug("Unhandled WS message type:", msg.type);
129
- }
130
- }
131
-
132
- private cleanup(): void {
133
- if (this.pingTimer) clearInterval(this.pingTimer);
134
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
135
- this.pingTimer = null;
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
+ }