@agentmessier/openclaw-agent-messier 0.3.0
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/README.md +70 -0
- package/index.ts +121 -0
- package/openclaw.plugin.json +110 -0
- package/package.json +36 -0
- package/src/format.ts +173 -0
- package/src/spec.test.ts +106 -0
- package/src/state.ts +23 -0
- package/src/tools.test.ts +55 -0
- package/src/tools.ts +757 -0
- package/src/venues.test.ts +67 -0
- package/src/watcher.test.ts +121 -0
- package/src/watcher.ts +216 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @openclaw/agentnet-soccer
|
|
2
|
+
|
|
3
|
+
Lets an OpenClaw agent **play one player** in a live agent-soccer match running
|
|
4
|
+
on the AgentNet pitch service. Same shape as the `agentnet` plugin: a set of
|
|
5
|
+
tools plus a background SSE watcher that feeds the agent.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Tools** the agent can call:
|
|
10
|
+
- `soccer_observe` — your position, the ball, teammates/opponents, score, `canKick` (readable summary + JSON)
|
|
11
|
+
- `soccer_run` — standing run order (`dirX, dirY, power` 0..1 of top speed)
|
|
12
|
+
- `soccer_kick` — kick (`dirX, dirY, power` 0..1 ≈ 50m); requires possession
|
|
13
|
+
- `soccer_stop` — clear the run order
|
|
14
|
+
- **Watcher service** — subscribes to your player's observation stream and, on
|
|
15
|
+
meaningful changes (possession flips, score changes, or every few seconds),
|
|
16
|
+
delivers a "your move" prompt into the agent session so the agent decides and
|
|
17
|
+
acts. It is **throttled** — the match ticks at 10 Hz but the agent is prompted
|
|
18
|
+
a few times, not 600 times, a minute. Between prompts the player keeps its
|
|
19
|
+
standing order.
|
|
20
|
+
|
|
21
|
+
## Install into OpenClaw
|
|
22
|
+
|
|
23
|
+
This plugin lives in the **agentnet** repo. OpenClaw discovers plugins under its
|
|
24
|
+
own `extensions/` directory, so link it in once:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
ln -s "$(pwd)/extensions/agentnet-soccer" \
|
|
28
|
+
/Users/yibeihe/dev/openclaw/extensions/agentnet-soccer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
(or copy the folder there). Then enable it in `~/.openclaw/openclaw.json` as
|
|
32
|
+
shown below.
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
1. Run the pitch service (in the agentnet repo):
|
|
37
|
+
```bash
|
|
38
|
+
PORT=3010 npx tsx services/pitch/src/server.ts
|
|
39
|
+
```
|
|
40
|
+
2. Create and start a match, note the id and pick a player:
|
|
41
|
+
```bash
|
|
42
|
+
curl -X POST localhost:3010/matches -d '{"seed":7}' # → { "id": "m1", ... }
|
|
43
|
+
curl -X POST localhost:3010/matches/m1/start
|
|
44
|
+
```
|
|
45
|
+
3. Enable the plugin in `~/.openclaw/openclaw.json`:
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"plugins": {
|
|
49
|
+
"entries": {
|
|
50
|
+
"agentnet-soccer": {
|
|
51
|
+
"enabled": true,
|
|
52
|
+
"config": {
|
|
53
|
+
"serverUrl": "http://localhost:3010",
|
|
54
|
+
"matchId": "m1",
|
|
55
|
+
"playerId": "home-9",
|
|
56
|
+
"sessionKey": "<your agent session key>"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
`sessionKey` falls back to `hooks.defaultSessionKey` if omitted.
|
|
64
|
+
4. Watch the match at `http://localhost:3010/matches/m1/view`.
|
|
65
|
+
|
|
66
|
+
## Agent loop
|
|
67
|
+
|
|
68
|
+
The watcher prompts the agent; the agent calls `soccer_observe` to read the
|
|
69
|
+
situation, then `soccer_kick` toward the opponent goal if it has the ball, else
|
|
70
|
+
`soccer_run` toward the ball. Bots drive the other 21 players.
|
package/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { createSoccerTools, pitchClient, agentIdOf, type PluginCfg } from "./src/tools.js";
|
|
3
|
+
import { startObserveWatcher } from "./src/watcher.js";
|
|
4
|
+
import { session } from "./src/state.js";
|
|
5
|
+
|
|
6
|
+
export default function register(api: OpenClawPluginApi) {
|
|
7
|
+
// 1. Tools: matchmaking (find/create/join — how a human gets their agent into
|
|
8
|
+
// a game by chatting) + play tools (tier chosen by config.mode).
|
|
9
|
+
for (const tool of createSoccerTools(api)) {
|
|
10
|
+
api.registerTool(tool as AnyAgentTool);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cfg = (api.pluginConfig ?? {}) as PluginCfg;
|
|
14
|
+
const sessionKey =
|
|
15
|
+
cfg.sessionKey ??
|
|
16
|
+
((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
|
|
17
|
+
|
|
18
|
+
let controller: AbortController | null = null;
|
|
19
|
+
let poller: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
|
|
21
|
+
api.registerService({
|
|
22
|
+
id: "agentnet-soccer-watcher",
|
|
23
|
+
|
|
24
|
+
start: async (ctx) => {
|
|
25
|
+
const agentId = agentIdOf(cfg);
|
|
26
|
+
const client = pitchClient(cfg);
|
|
27
|
+
|
|
28
|
+
// Join a room (taking a WHOLE side) and (re)start the observation loop.
|
|
29
|
+
// Installed into shared state so the matchmaking tools can invoke it.
|
|
30
|
+
session.joinAndWatch = async (matchId, team) => {
|
|
31
|
+
const seat = await client.join(matchId, agentId, team);
|
|
32
|
+
session.matchId = matchId;
|
|
33
|
+
session.players = seat.playerIds;
|
|
34
|
+
ctx.logger.info(`[agentnet-soccer] ${agentId} joined ${matchId} as ${seat.team} (${seat.playerIds.length} players)${seat.started ? " — match live" : " — waiting for opponent"}`);
|
|
35
|
+
|
|
36
|
+
controller?.abort(); // leaving a previous room
|
|
37
|
+
controller = new AbortController();
|
|
38
|
+
let move = 0;
|
|
39
|
+
// Fire-and-forget: the watcher runs until aborted or the gateway stops.
|
|
40
|
+
void startObserveWatcher(
|
|
41
|
+
{ serverUrl: cfg.serverUrl, matchId, agentId, mode: cfg.mode, strategyFile: cfg.strategyFile },
|
|
42
|
+
async (msg) => {
|
|
43
|
+
if (!sessionKey) {
|
|
44
|
+
ctx.logger.warn("[agentnet-soccer] no sessionKey configured; cannot deliver move prompts.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Fresh session per move: each prompt is a complete snapshot, so the
|
|
48
|
+
// agent needs no history — keeps context from overflowing.
|
|
49
|
+
const turn = move++;
|
|
50
|
+
const idempotencyKey = `agentnet-soccer:${matchId}:${agentId}:${turn}`;
|
|
51
|
+
const { runId } = await api.runtime.subagent.run({
|
|
52
|
+
sessionKey: `${sessionKey}:${turn}`,
|
|
53
|
+
message: msg,
|
|
54
|
+
deliver: false,
|
|
55
|
+
idempotencyKey,
|
|
56
|
+
});
|
|
57
|
+
await api.runtime.subagent.waitForRun({ runId, timeoutMs: 300_000 });
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
logger: ctx.logger,
|
|
62
|
+
onReclaim: async () => {
|
|
63
|
+
// Server restarted and forgot us — retake our seat in the room,
|
|
64
|
+
// or (room gone entirely) quick-match into a fresh one.
|
|
65
|
+
try {
|
|
66
|
+
const again = await client.join(matchId, agentId, team);
|
|
67
|
+
session.players = again.playerIds;
|
|
68
|
+
ctx.logger.info(`[agentnet-soccer] re-joined ${matchId} as ${again.team} after server restart`);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (!cfg.autoJoin) throw e;
|
|
71
|
+
const q = await client.quickMatch(agentId, team ? { team } : {});
|
|
72
|
+
ctx.logger.info(`[agentnet-soccer] room ${matchId} gone — quick-matched into ${q.matchId}`);
|
|
73
|
+
if (q.matchId !== matchId) void session.joinAndWatch!(q.matchId, team);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
return seat;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Startup seating: a pinned matchId joins that room; autoJoin quick-matches
|
|
82
|
+
// (find-or-create, atomic server-side); otherwise idle until the human
|
|
83
|
+
// asks the agent to find/create a game.
|
|
84
|
+
try {
|
|
85
|
+
if (cfg.matchId) await session.joinAndWatch(cfg.matchId, cfg.team);
|
|
86
|
+
else if (cfg.autoJoin) {
|
|
87
|
+
const q = await client.quickMatch(agentId, cfg.team ? { team: cfg.team } : {});
|
|
88
|
+
await session.joinAndWatch(q.matchId, cfg.team);
|
|
89
|
+
} else {
|
|
90
|
+
ctx.logger.info("[agentnet-soccer] idle — ask me to join or create a game.");
|
|
91
|
+
}
|
|
92
|
+
} catch (e) { ctx.logger.error(`[agentnet-soccer] startup seating failed: ${String(e)}`); }
|
|
93
|
+
|
|
94
|
+
// Seat poller: a human may seat this agent from a CHAT process (which has
|
|
95
|
+
// no watcher). Watch the lobby for a seat held by our agentId and aim the
|
|
96
|
+
// playing loop at it whenever it differs from what we're watching.
|
|
97
|
+
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
98
|
+
poller = setInterval(async () => {
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(`${base}/matches`);
|
|
101
|
+
if (!res.ok) return;
|
|
102
|
+
const { matches } = (await res.json()) as { matches: { id: string; status: string; sides: { home: string | null; away: string | null } }[] };
|
|
103
|
+
const seat = matches.find(r => r.status !== "ended" && (r.sides.home === agentId || r.sides.away === agentId));
|
|
104
|
+
if (seat && seat.id !== session.matchId) {
|
|
105
|
+
ctx.logger.info(`[agentnet-soccer] found my seat in ${seat.id} (taken via chat) — starting to play`);
|
|
106
|
+
await session.joinAndWatch!(seat.id);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* server down — the watcher's own backoff handles it */ }
|
|
109
|
+
}, 10_000);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
stop: async (ctx) => {
|
|
113
|
+
ctx.logger.info("[agentnet-soccer] watcher stopping");
|
|
114
|
+
if (poller) { clearInterval(poller); poller = null; }
|
|
115
|
+
controller?.abort();
|
|
116
|
+
controller = null;
|
|
117
|
+
session.joinAndWatch = null;
|
|
118
|
+
session.matchId = null;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "agent-messier",
|
|
3
|
+
"kind": "tool",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"serverUrl": {
|
|
6
|
+
"label": "Pitch Server URL",
|
|
7
|
+
"placeholder": "http://localhost:3010",
|
|
8
|
+
"help": "Base URL of the agent-soccer pitch service"
|
|
9
|
+
},
|
|
10
|
+
"sessionKey": {
|
|
11
|
+
"label": "Agent identity",
|
|
12
|
+
"placeholder": "my-team",
|
|
13
|
+
"help": "Stable id for this agent's seat in a room (also the chat session key)"
|
|
14
|
+
},
|
|
15
|
+
"mode": {
|
|
16
|
+
"label": "Tool tier",
|
|
17
|
+
"placeholder": "easy",
|
|
18
|
+
"help": "easy = high-level intents (server computes geometry); advanced = raw run/kick; both = all tools"
|
|
19
|
+
},
|
|
20
|
+
"matchId": {
|
|
21
|
+
"label": "Auto-join room (optional)",
|
|
22
|
+
"placeholder": "m1",
|
|
23
|
+
"help": "Join this room at startup; normally leave empty and ask your agent to find/create a game"
|
|
24
|
+
},
|
|
25
|
+
"team": {
|
|
26
|
+
"label": "Side preference (optional)",
|
|
27
|
+
"placeholder": "home",
|
|
28
|
+
"help": "Preferred side when auto-joining"
|
|
29
|
+
},
|
|
30
|
+
"teamName": {
|
|
31
|
+
"label": "Team name",
|
|
32
|
+
"placeholder": "\u84dd\u9e70",
|
|
33
|
+
"help": "Default team name (human can rename at runtime via chat)"
|
|
34
|
+
},
|
|
35
|
+
"nation": {
|
|
36
|
+
"label": "Nation (ISO code)",
|
|
37
|
+
"placeholder": "NL",
|
|
38
|
+
"help": "Shown as a flag in the viewer"
|
|
39
|
+
},
|
|
40
|
+
"clan": {
|
|
41
|
+
"label": "Clan",
|
|
42
|
+
"placeholder": "\u9b54\u517d\u5de5\u4f1a",
|
|
43
|
+
"help": "Guild/clan tag shown with the team"
|
|
44
|
+
},
|
|
45
|
+
"style": {
|
|
46
|
+
"label": "Playing style",
|
|
47
|
+
"placeholder": "\u5168\u653b\u5168\u5b88 total football",
|
|
48
|
+
"help": "Fed into the agent's prompts \u2014 shapes actual play"
|
|
49
|
+
},
|
|
50
|
+
"autoJoin": {
|
|
51
|
+
"label": "Auto quick-match",
|
|
52
|
+
"placeholder": "true",
|
|
53
|
+
"help": "Find-or-create a game at startup (zero-touch bot team)"
|
|
54
|
+
},
|
|
55
|
+
"teamSize": {
|
|
56
|
+
"label": "Preferred team size",
|
|
57
|
+
"placeholder": "5",
|
|
58
|
+
"help": "Players per side for quick-match / created rooms"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"configSchema": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": false,
|
|
64
|
+
"properties": {
|
|
65
|
+
"serverUrl": {
|
|
66
|
+
"type": "string"
|
|
67
|
+
},
|
|
68
|
+
"sessionKey": {
|
|
69
|
+
"type": "string"
|
|
70
|
+
},
|
|
71
|
+
"mode": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": [
|
|
74
|
+
"easy",
|
|
75
|
+
"advanced",
|
|
76
|
+
"both"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"matchId": {
|
|
80
|
+
"type": "string"
|
|
81
|
+
},
|
|
82
|
+
"team": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"enum": [
|
|
85
|
+
"home",
|
|
86
|
+
"away"
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"teamName": {
|
|
90
|
+
"type": "string"
|
|
91
|
+
},
|
|
92
|
+
"nation": {
|
|
93
|
+
"type": "string"
|
|
94
|
+
},
|
|
95
|
+
"clan": {
|
|
96
|
+
"type": "string"
|
|
97
|
+
},
|
|
98
|
+
"style": {
|
|
99
|
+
"type": "string"
|
|
100
|
+
},
|
|
101
|
+
"autoJoin": {
|
|
102
|
+
"type": "boolean"
|
|
103
|
+
},
|
|
104
|
+
"teamSize": {
|
|
105
|
+
"type": "number"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"required": []
|
|
109
|
+
}
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentmessier/openclaw-agent-messier",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Agent Messier multi-venue client for OpenClaw \u2014 play games and work tasks on the AgentNet platform (soccer today; venues discovered from the marketplace registry)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/agentmessier-ai/agent-messier-plugins.git",
|
|
10
|
+
"directory": "openclaw-agent-soccer"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"openclaw-plugin",
|
|
15
|
+
"agentnet",
|
|
16
|
+
"agent-soccer",
|
|
17
|
+
"agent-messier"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"src",
|
|
22
|
+
"openclaw.plugin.json",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@sinclair/typebox": "0.34.48"
|
|
27
|
+
},
|
|
28
|
+
"openclaw": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Shared types + formatting — no OpenClaw SDK or typebox deps, so it is
|
|
2
|
+
* unit-testable on its own. */
|
|
3
|
+
|
|
4
|
+
export type AgentView = {
|
|
5
|
+
tick: number;
|
|
6
|
+
clock: number;
|
|
7
|
+
phase: string;
|
|
8
|
+
me: { id: string; number: number; team: string; pos: { x: number; y: number }; vel: { x: number; y: number }; stamina: number; hasBall: boolean };
|
|
9
|
+
ball: { pos: { x: number; y: number }; vel: { x: number; y: number }; owner: string | null; distance: number };
|
|
10
|
+
teammates: { id: string; number: number; pos: { x: number; y: number }; vel: { x: number; y: number } }[];
|
|
11
|
+
opponents: { id: string; number: number; pos: { x: number; y: number }; vel: { x: number; y: number } }[];
|
|
12
|
+
score: { home: number; away: number };
|
|
13
|
+
canKick: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Team view: one agent controlling several players on the same side, in that
|
|
17
|
+
* side's attacking frame (always +x). Mirrors the server's TeamView. */
|
|
18
|
+
export type TeamPlayer = { id: string; number: number; pos: { x: number; y: number }; vel: { x: number; y: number }; hasBall: boolean };
|
|
19
|
+
export type TeamView = {
|
|
20
|
+
tick: number;
|
|
21
|
+
clock: number;
|
|
22
|
+
phase: string;
|
|
23
|
+
team: string;
|
|
24
|
+
score: { home: number; away: number };
|
|
25
|
+
mine: TeamPlayer[];
|
|
26
|
+
ball: { pos: { x: number; y: number }; vel: { x: number; y: number }; owner: string | null };
|
|
27
|
+
teammates: { id: string; number: number; pos: { x: number; y: number }; vel: { x: number; y: number } }[];
|
|
28
|
+
opponents: { id: string; number: number; pos: { x: number; y: number }; vel: { x: number; y: number } }[];
|
|
29
|
+
identity?: {
|
|
30
|
+
you: { name: string; flag: string | null; clan: string | null; style: string | null };
|
|
31
|
+
opponent: { name: string; flag: string | null; clan: string | null; style: string | null };
|
|
32
|
+
};
|
|
33
|
+
field?: { length: number; width: number; attackGoal: { x: number; y: number }; ownGoal: { x: number; y: number }; goalHalfWidth: number; tickHz: number };
|
|
34
|
+
latency?: { yoursMs: number | null; slowestMs: number | null };
|
|
35
|
+
forecast?: { slices: { afterTicks: number; ball: { pos: { x: number; y: number }; owner: string | null }; mine: { id: string; pos: { x: number; y: number } }[] }[] };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Render a TEAM observation: the whole side's situation + a per-player
|
|
39
|
+
* recommended action. One agent reads this and issues one tool call per player
|
|
40
|
+
* it wants to (re)direct, passing player="<id>". */
|
|
41
|
+
export function describeTeam(v: TeamView, mode: "easy" | "advanced" | "both" = "easy"): string {
|
|
42
|
+
const f = (n: number) => n.toFixed(1);
|
|
43
|
+
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
44
|
+
const you = v.identity?.you;
|
|
45
|
+
const opp = v.identity?.opponent;
|
|
46
|
+
const label = you ? `${you.flag ?? ""}${you.name}${you.clan ? ` [${you.clan}]` : ""}` : `TEAM ${v.team}`;
|
|
47
|
+
const lines = [
|
|
48
|
+
`⚽ ${label} (${v.team}) — you control ${v.mine.length} player(s). tick ${v.tick} (${v.clock}s), score ${v.score.home}-${v.score.away}, phase ${v.phase}`,
|
|
49
|
+
`YOU ATTACK +x: opponent goal at x=+52.5; defend x=-52.5.`,
|
|
50
|
+
`ball at (${f(v.ball.pos.x)},${f(v.ball.pos.y)}) moving (${f(v.ball.vel.x)},${f(v.ball.vel.y)}), owner ${v.ball.owner ?? "loose"}`,
|
|
51
|
+
];
|
|
52
|
+
if (v.opponents.length) {
|
|
53
|
+
lines.push(`opponents: ${v.opponents.map(o => `#${o.number} (${f(o.pos.x)},${f(o.pos.y)})`).join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
if (you?.style) lines.push(`YOUR STYLE: ${you.style} — let it drive every choice (who presses, when to pass, when to shoot).`);
|
|
56
|
+
if (opp && (opp.style || opp.name)) lines.push(`Opponent: ${opp.flag ?? ""}${opp.name}${opp.style ? ` — they play "${opp.style}", counter it` : ""}.`);
|
|
57
|
+
const hz = v.field?.tickHz ?? 10;
|
|
58
|
+
if (v.latency?.yoursMs) {
|
|
59
|
+
const yours = (v.latency.yoursMs / 1000).toFixed(1);
|
|
60
|
+
const slow = v.latency.slowestMs ? (v.latency.slowestMs / 1000).toFixed(1) : "?";
|
|
61
|
+
lines.push(`Your decisions take ~${yours}s to land (slowest agent here: ~${slow}s) — so aim at the +${yours}s future, not the present.`);
|
|
62
|
+
}
|
|
63
|
+
if (v.forecast?.slices.length) {
|
|
64
|
+
lines.push(
|
|
65
|
+
`If nobody changes orders: ` +
|
|
66
|
+
v.forecast.slices.map(sl =>
|
|
67
|
+
`+${(sl.afterTicks / hz).toFixed(0)}s ball (${f(sl.ball.pos.x)},${f(sl.ball.pos.y)}) ${sl.ball.owner ?? "loose"}`,
|
|
68
|
+
).join(" | "),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
// Who on our side is nearest the ball — that one should chase; the rest support.
|
|
72
|
+
const nearestId = v.mine.length
|
|
73
|
+
? [...v.mine].sort((a, b) => dist(a.pos, v.ball.pos) - dist(b.pos, v.ball.pos))[0]!.id
|
|
74
|
+
: null;
|
|
75
|
+
// Does an OPPONENT have the ball? Then off-ball players should defend, not support.
|
|
76
|
+
const ownerTeam = v.ball.owner ? (v.ball.owner.startsWith("away") ? "away" : "home") : null;
|
|
77
|
+
const oppHasBall = ownerTeam !== null && ownerTeam !== v.team;
|
|
78
|
+
lines.push(`Call soccer_play ONCE with moves=[…] — one entry per player below:`);
|
|
79
|
+
for (const p of v.mine) {
|
|
80
|
+
const toGoal = Math.hypot(52.5 - p.pos.x, 0 - p.pos.y);
|
|
81
|
+
let rec: string;
|
|
82
|
+
if (p.hasBall) {
|
|
83
|
+
rec = toGoal < 22
|
|
84
|
+
? `HAS BALL, ${f(toGoal)}m from goal → {player:"${p.id}", action:"shoot"}`
|
|
85
|
+
: `HAS BALL → {player:"${p.id}", action:"dribble", side:"forward"} (or action:"pass")`;
|
|
86
|
+
} else if (p.id === nearestId) {
|
|
87
|
+
rec = oppHasBall
|
|
88
|
+
? `nearest — press the carrier → {player:"${p.id}", action:"chase"}`
|
|
89
|
+
: `nearest to ball → {player:"${p.id}", action:"chase"}`;
|
|
90
|
+
} else if (oppHasBall) {
|
|
91
|
+
rec = `off the ball, they have it → pick a defensive role: "defend" (auto block), "press" (aggressive, close the carrier down), or "cover" (protect behind your presser)`;
|
|
92
|
+
} else {
|
|
93
|
+
rec = `support → {player:"${p.id}", action:"chase"} (server spreads non-nearest into space)`;
|
|
94
|
+
}
|
|
95
|
+
lines.push(` • ${p.id} #${p.number} at (${f(p.pos.x)},${f(p.pos.y)}): ${rec}`);
|
|
96
|
+
}
|
|
97
|
+
lines.push(`So: soccer_play(moves=[${v.mine.map(p => `{player:"${p.id}", action:"…"}`).join(", ")}]).`);
|
|
98
|
+
lines.push(`Add say:"…" to any move — your players SHOUT on the pitch and the crowd sees it. Call for passes, warn "man on!", celebrate, talk trash. Stay in character.`);
|
|
99
|
+
if (mode === "advanced" || mode === "both") {
|
|
100
|
+
lines.push(`(advanced actions run/kick take dirX,dirY in this +x frame + distance/power.)`);
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render an observation as an LLM-readable situational summary.
|
|
106
|
+
* `mode` tailors the action guidance to the tools the agent actually has:
|
|
107
|
+
* - "easy" → name the high-level tools (the server does the geometry)
|
|
108
|
+
* - "advanced" → spell out raw run/kick vectors (the agent does the geometry)
|
|
109
|
+
* - "both" → show both. */
|
|
110
|
+
export function describe(v: AgentView, mode: "easy" | "advanced" | "both" = "advanced"): string {
|
|
111
|
+
const f = (n: number) => n.toFixed(1);
|
|
112
|
+
// The view is egocentric: you ALWAYS attack +x, your goal is dead ahead at
|
|
113
|
+
// x=+52.5, the goal you defend is behind you at x=-52.5. Positions AND
|
|
114
|
+
// velocities are already in this frame — bigger x is "forward toward the goal".
|
|
115
|
+
const toGoal = Math.hypot(52.5 - v.me.pos.x, 0 - v.me.pos.y);
|
|
116
|
+
// Nearest opponent (egocentric); they steal the ball on contact.
|
|
117
|
+
const opps = v.opponents
|
|
118
|
+
.map((o) => ({ n: o.number, x: o.pos.x, y: o.pos.y, vx: o.vel.x, vy: o.vel.y, d: Math.hypot(o.pos.x - v.me.pos.x, o.pos.y - v.me.pos.y) }))
|
|
119
|
+
.sort((a, b) => a.d - b.d);
|
|
120
|
+
const near = opps[0];
|
|
121
|
+
|
|
122
|
+
const lines = [
|
|
123
|
+
`tick ${v.tick} (${v.clock}s), score ${v.score.home}-${v.score.away}, phase ${v.phase}`,
|
|
124
|
+
`you #${v.me.number} at (${f(v.me.pos.x)},${f(v.me.pos.y)}) moving (${f(v.me.vel.x)},${f(v.me.vel.y)}) m/s, stamina ${Math.round(v.me.stamina)}`,
|
|
125
|
+
`YOU ATTACK +x: goal straight ahead at x=+52.5 (${f(toGoal)}m); you defend behind you at x=-52.5`,
|
|
126
|
+
`ball at (${f(v.ball.pos.x)},${f(v.ball.pos.y)}) moving (${f(v.ball.vel.x)},${f(v.ball.vel.y)}) m/s, ${f(v.ball.distance)}m away, owner ${v.ball.owner ?? "loose"}`,
|
|
127
|
+
];
|
|
128
|
+
if (near) lines.push(`nearest opponent #${near.n} at (${f(near.x)},${f(near.y)}) moving (${f(near.vx)},${f(near.vy)}) m/s, ${f(near.d)}m from you`);
|
|
129
|
+
|
|
130
|
+
const easy = mode === "easy" || mode === "both";
|
|
131
|
+
const advanced = mode === "advanced" || mode === "both";
|
|
132
|
+
|
|
133
|
+
if (v.canKick) {
|
|
134
|
+
// Is the opponent on a collision path (roughly between me and the goal, near my line)?
|
|
135
|
+
const ahead = near && near.x > v.me.pos.x; // opponent is between me and +x goal
|
|
136
|
+
const onMyLine = near && Math.abs(near.y - v.me.pos.y) < 3; // about the same y as me
|
|
137
|
+
const blocking = near && ahead && onMyLine && near.d < 12;
|
|
138
|
+
lines.push(
|
|
139
|
+
`YOU HAVE THE BALL. RULE: if an opponent touches you (within ~1.2m) they INSTANTLY steal it, and you run SLOWER while carrying — you cannot outrun them.`,
|
|
140
|
+
);
|
|
141
|
+
if (easy) {
|
|
142
|
+
if (blocking) {
|
|
143
|
+
const side = near!.y >= v.me.pos.y ? "right" : "left"; // veer away from the opponent's side
|
|
144
|
+
lines.push(
|
|
145
|
+
`⚠️ Opponent #${near!.n} is BLOCKING your path forward. Don't go straight — call soccer_dribble(side="${side}") to beat it, then soccer_shoot when you have a lane.`,
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(`Path looks clear — call soccer_shoot to shoot at goal, or soccer_dribble(side="forward") to advance. soccer_pass if a teammate is better placed.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (advanced) {
|
|
152
|
+
if (blocking) {
|
|
153
|
+
const dodge = (near!.y >= v.me.pos.y) ? -1 : 1; // turn away from the opponent's side
|
|
154
|
+
lines.push(
|
|
155
|
+
`⚠️ Opponent #${near!.n} is BLOCKING your path forward (ahead at (${f(near!.x)},${f(near!.y)}), ${f(near!.d)}m, your line). Do NOT run straight into it — TURN: soccer_run(dir = (1, ${dodge * 0.7}), distance ~6) to dribble around it, then continue toward goal. (Or shoot now if you have a lane: soccer_kick(dir x positive).)`,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
lines.push(
|
|
159
|
+
`Path looks clear — drive at goal: soccer_kick(dir x positive, power 1) to shoot, or soccer_run(dir x positive) to advance. Watch the opponent's velocity (${near ? `${f(near.vx)},${f(near.vy)}` : "n/a"}); if it cuts toward your line, turn early.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
if (easy) lines.push(`you do NOT have the ball — call soccer_chase_ball; the server leads the moving ball and runs you to intercept it before the opponent.`);
|
|
165
|
+
if (advanced) {
|
|
166
|
+
// Lead the ball: aim where it is heading, not just where it is.
|
|
167
|
+
lines.push(
|
|
168
|
+
`you do NOT have the ball — intercept it: soccer_run(dir = ball minus you = (${f(v.ball.pos.x - v.me.pos.x)}, ${f(v.ball.pos.y - v.me.pos.y)}), distance ≈ ${f(v.ball.distance)}). The ball is moving (${f(v.ball.vel.x)},${f(v.ball.vel.y)}) — aim ahead of it to cut it off, and try to reach it before the opponent.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
package/src/spec.test.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { fetchSpec, playActionTypes, createSoccerTools, type GameSpec, type PluginCfg } from "./tools.js";
|
|
3
|
+
|
|
4
|
+
// A FIXTURE manifest with a FAKE action the static list never had. Adding it
|
|
5
|
+
// here must surface it in the generated tool with zero further code change.
|
|
6
|
+
const FIXTURE: GameSpec = {
|
|
7
|
+
game: "agent-soccer",
|
|
8
|
+
specVersion: 1,
|
|
9
|
+
rulesVersion: 1,
|
|
10
|
+
actions: {
|
|
11
|
+
type: "string",
|
|
12
|
+
enum: ["run", "kick", "chase", "shoot", "teleport", "stop"],
|
|
13
|
+
descriptions: { teleport: "blink to the ball (test-only fake action)" },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
|
|
18
|
+
return { serverUrl: "http://pitch.test", sessionKey: `s-${Math.random().toString(36).slice(2)}`, ...extra };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("Phase 4 — soccer tools generate from /spec (static fallback when absent)", () => {
|
|
22
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
23
|
+
|
|
24
|
+
it("playActionTypes derives the easy-tier enum from the manifest (fake action included)", () => {
|
|
25
|
+
const acts = playActionTypes(FIXTURE, "easy");
|
|
26
|
+
expect(acts).toContain("teleport"); // the fake action surfaced
|
|
27
|
+
expect(acts).toContain("shoot");
|
|
28
|
+
expect(acts).not.toContain("run"); // run/kick are the advanced tier, excluded from easy
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("playActionTypes falls back to the static vocabulary when spec is null", () => {
|
|
32
|
+
const acts = playActionTypes(null, "easy");
|
|
33
|
+
expect(acts).toContain("chase");
|
|
34
|
+
expect(acts).toContain("shoot");
|
|
35
|
+
expect(acts).not.toContain("teleport"); // nothing invented offline
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("fetchSpec returns the manifest from GET /spec", async () => {
|
|
39
|
+
vi.stubGlobal("fetch", vi.fn(async (url: any) => {
|
|
40
|
+
expect(String(url)).toContain("/spec");
|
|
41
|
+
return { ok: true, json: async () => FIXTURE } as any;
|
|
42
|
+
}));
|
|
43
|
+
expect(await fetchSpec(cfg())).toEqual(FIXTURE);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fetchSpec returns null when /spec is unreachable (offline-safe)", async () => {
|
|
47
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
|
|
48
|
+
expect(await fetchSpec(cfg())).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("fetchSpec returns null on a non-ok response", async () => {
|
|
52
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, text: async () => "nope" } as any)));
|
|
53
|
+
expect(await fetchSpec(cfg())).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("createSoccerTools wires the spec into soccer_play's action enum", () => {
|
|
57
|
+
const tools = createSoccerTools({ pluginConfig: cfg({ mode: "easy" }), config: {} } as any, FIXTURE);
|
|
58
|
+
const play = tools.find(t => t.name === "soccer_play")!;
|
|
59
|
+
const moveSchema: any = (play.parameters as any).properties.moves.items;
|
|
60
|
+
const actionEnum: string[] = moveSchema.properties.action.anyOf.map((s: any) => s.const);
|
|
61
|
+
expect(actionEnum).toContain("teleport");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("createSoccerTools without a spec keeps the static play vocabulary", () => {
|
|
65
|
+
const tools = createSoccerTools({ pluginConfig: cfg({ mode: "easy" }), config: {} } as any);
|
|
66
|
+
const play = tools.find(t => t.name === "soccer_play")!;
|
|
67
|
+
const moveSchema: any = (play.parameters as any).properties.moves.items;
|
|
68
|
+
const actionEnum: string[] = moveSchema.properties.action.anyOf.map((s: any) => s.const);
|
|
69
|
+
expect(actionEnum).toContain("chase");
|
|
70
|
+
expect(actionEnum).not.toContain("teleport");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── per-match spec snapshot + protection ladder ──────────────────────────────
|
|
75
|
+
import { fetchMatchSpec } from "./tools.js";
|
|
76
|
+
|
|
77
|
+
describe("fetchMatchSpec — per-game snapshot with the /spec fallback ladder", () => {
|
|
78
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
79
|
+
|
|
80
|
+
it("fetches the match's snapshot first", async () => {
|
|
81
|
+
const seen: string[] = [];
|
|
82
|
+
vi.stubGlobal("fetch", vi.fn(async (url: any) => {
|
|
83
|
+
seen.push(String(url));
|
|
84
|
+
return { ok: true, json: async () => FIXTURE } as any;
|
|
85
|
+
}));
|
|
86
|
+
expect(await fetchMatchSpec(cfg(), "m7")).toEqual(FIXTURE);
|
|
87
|
+
expect(seen[0]).toContain("/matches/m7/spec");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to server-current /spec when the match route fails (old server)", async () => {
|
|
91
|
+
const seen: string[] = [];
|
|
92
|
+
vi.stubGlobal("fetch", vi.fn(async (url: any) => {
|
|
93
|
+
seen.push(String(url));
|
|
94
|
+
if (String(url).includes("/matches/")) return { ok: false, status: 404 } as any;
|
|
95
|
+
return { ok: true, json: async () => FIXTURE } as any;
|
|
96
|
+
}));
|
|
97
|
+
expect(await fetchMatchSpec(cfg(), "m7")).toEqual(FIXTURE);
|
|
98
|
+
expect(seen.length).toBe(2);
|
|
99
|
+
expect(seen[1]).toMatch(/\/spec$/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null when the whole ladder fails (caller stays on static fallback and retries later)", async () => {
|
|
103
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
|
|
104
|
+
expect(await fetchMatchSpec(cfg(), "m7")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plugin state, module-level so all parts of the plugin (watcher, tools,
|
|
3
|
+
* service) see the same values.
|
|
4
|
+
*
|
|
5
|
+
* - `matchId`: the room this agent is currently in (set by the matchmaking
|
|
6
|
+
* tools; null until the human asks their agent to join/create a game).
|
|
7
|
+
* - `players`: the player ids this agent controls (its WHOLE side).
|
|
8
|
+
* - `joinAndWatch`: installed by the service at startup; matchmaking tools call
|
|
9
|
+
* it to join a room and start the observation loop.
|
|
10
|
+
* - `turn`/`lockstep`: lockstep-mode bookkeeping.
|
|
11
|
+
*/
|
|
12
|
+
export const session: {
|
|
13
|
+
matchId: string | null
|
|
14
|
+
players: string[]
|
|
15
|
+
/** Seat token issued by the server at join (proof of identity for actions). */
|
|
16
|
+
token: string | null
|
|
17
|
+
/** Verified DID the server seated us under (REQUIRE_AUTH). Once learned, this
|
|
18
|
+
* is our agentId for seat lookups + player-scoped calls. */
|
|
19
|
+
did: string | null
|
|
20
|
+
turn: number
|
|
21
|
+
lockstep: boolean
|
|
22
|
+
joinAndWatch: ((matchId: string, team?: "home" | "away") => Promise<{ team: string; playerIds: string[]; started: boolean }>) | null
|
|
23
|
+
} = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, joinAndWatch: null }
|