Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions players/agricogla/symbolic-planner/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
package-lock.json
smoke.ts
*.log
3 changes: 3 additions & 0 deletions players/agricogla/symbolic-planner/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
package-lock.json
18 changes: 18 additions & 0 deletions players/agricogla/symbolic-planner/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Agricogla symbolic-planner player image.
#
# Built via this leaf's ``build.sh``. The build context is the policy dir; the
# policy is self-contained (its own copy of the engine + planner source) and
# carries no dependency on metta's @cogweb/* packages.

FROM --platform=linux/amd64 node:22-slim AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src/ src/
RUN npx esbuild src/planner-player.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/planner-player.js --banner:js="import{createRequire}from'module';const require=createRequire(import.meta.url);"

FROM --platform=linux/amd64 node:22-slim
WORKDIR /app
COPY --from=build /app/dist/planner-player.js ./planner-player.js
CMD ["node", "planner-player.js"]
107 changes: 107 additions & 0 deletions players/agricogla/symbolic-planner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Agricogla Symbolic Planner

A deterministic, no-LLM policy for the `agricogla` 4-player worker-placement
Coworld. For each legal move it rolls the rest of the game out to round 14 under
a baseline continuation and picks the move with the best terminal score margin
(one-step policy improvement over the baseline).

- Policy name (uploaded): `agricogla-planner-fork`
- Player id: `players-agricogla-symbolic-planner`
- Game: `agricogla`
- Protocol: [`cogweb.player.v1`](../../../docs/coworld-player-packaging.md#game-specific-player-protocols)

This is a self-contained **fork** of the in-tree
`packages/cogweb/games/agricogla` planner player. It carries its own copy of the
game engine and planner source and has **no dependency on metta's `@cogweb/*`
packages**, so it builds and runs entirely from this directory.

## Strategy

The planner plans only on **observable** state. The host redacts each seat's
view — other farms' hands and the undealt round-card deck are masked with the
sentinel `"hidden"` — so a naive full-game rollout that reads hidden cards
crashes (`unknown card: hidden`). The fix is a `determinize` belief
(`src/planner/beliefs.ts`) that reconstructs the only legal guess of hidden
state (remaining round cards in stage order; opponents holding no private
cards) before any forward simulation.

- `src/planner/beliefs.ts` — typed belief model + determinization.
- `src/planner/tools.ts` — terminal value function + opponent-contention model.
- `src/planner/planner.ts` — the determinized rollout + argmax over candidates.
- `src/baseline.ts` — the always-legal baseline continuation (and the fallback
used for non-planner seats in the smoke test).
- `src/engine/` — the pure game engine (state, cards, legal moves, scoring),
copied verbatim from the source game.

## Runtime contract

This player ships as a self-contained Coworld player container:

- Reads `COWORLD_PLAYER_WS_URL` (engine endpoint + slot/token) from the
environment, connects as a websocket **client** of the game host's `/player`
endpoint, and speaks `cogweb.player.v1` (`welcome` / `observation` / `reply` /
`final`).
- On each `observation` it decides a worker placement (work phase) or a feeding
plan (feeding phase), keyed by `view.phase`, and replies with the same `id`.
- Exits when the game sends `final` or closes the socket.

`src/runtime/player-runtime.ts` is a minimal standalone implementation of that
loop over the `ws` library; `src/planner-player.ts` is the entry point.

## Build & artifacts

```bash
players/agricogla/symbolic-planner/build.sh
```

Produces:

- A `linux/amd64` Docker image tagged `players-agricogla-symbolic-planner:dev`
(override with `--tag`).
- A `coworld_manifest.json` `player[]` snippet on stdout, optionally also
written to `--manifest-out <path>`.
- `players/agricogla/symbolic-planner/dist/coplayer_manifest.json`.

Optional flags: `--push <registry-ref>` to re-tag and push, `--no-build` to
render manifests only.

## Local verification

```bash
npm install
npm run typecheck
npm run smoke # full 4-player episode, seat 0 piloted by the planner over
# REDACTED views; asserts all 14 rounds complete with no crash
```

## Upload & submit

```bash
coworld upload-policy players-agricogla-symbolic-planner:dev \
--name agricogla-planner-fork --run node --run planner-player.js
coworld submit agricogla-planner-fork --league <league_id> \
--auto-champion always --no-open-browser
```

## Layout

```
symbolic-planner/
├── src/
│ ├── engine/ # pure game engine (verbatim copy)
│ ├── planner/ # belief model + rollout planner
│ ├── runtime/ # standalone cogweb.player.v1 websocket loop
│ ├── baseline.ts # baseline continuation + view builder
│ └── planner-player.ts # container entry point
├── smoke.ts # local end-to-end smoke test
├── Dockerfile # linux/amd64 player image
├── build.sh # Coworld build entrypoint
└── README.md # This file
```

## See also

- [`docs/coworld-player-packaging.md`](../../../docs/coworld-player-packaging.md)
— Coworld player contract.
- `packages/cogweb/games/agricogla` (metta) — the source game + the in-tree
planner this fork is derived from.
30 changes: 30 additions & 0 deletions players/agricogla/symbolic-planner/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Build the agricogla symbolic-planner player image and emit Coworld manifest
# artifacts. See ``docs/coworld-player-packaging.md`` for the full contract.
set -euo pipefail

SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" && pwd )"
REPO_ROOT="$( cd "$SCRIPT_DIR/../../.." && pwd )"
POLICY_DIR="$SCRIPT_DIR"
export POLICY_DIR

source "$REPO_ROOT/tools/players_build/build_lib.sh"

PLAYER_ID="agricogla-symbolic-planner"
PLAYER_NAME="Agricogla Symbolic Planner"
PLAYER_DESCRIPTION="Deterministic symbolic-planner policy for the agricogla 4-player Coworld: a determinized full-game rollout with a typed belief model, no LLM."
PLAYER_GAMES_JSON='["agricogla"]'
PLAYER_AUTHOR="players@softmax.com"
IMAGE_LOCAL_TAG="players-agricogla-symbolic-planner:dev"
IMAGE_PUBLIC_URI="ghcr.io/metta-ai/players-agricogla-symbolic-planner:latest"
DOCKERFILE="$POLICY_DIR/Dockerfile"
# Self-contained policy: the image only needs this leaf's own source, so the
# build context is the policy dir (not the repo root). The Dockerfile COPYs
# package.json/tsconfig.json/src from here.
BUILD_CONTEXT="$POLICY_DIR"
PLAYER_ENV_JSON='{}'
# The image's default CMD is the player, but encode the argv explicitly so the
# uploaded policy's `run` attribute is unambiguous.
PLAYER_RUN_JSON='["node", "planner-player.js"]'

run_player_build "$@"
22 changes: 22 additions & 0 deletions players/agricogla/symbolic-planner/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "agricogla-symbolic-planner",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/planner-player.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/planner-player.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\"",
"typecheck": "tsc --noEmit",
"smoke": "tsx smoke.ts"
},
"dependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"esbuild": "^0.24.0",
"tsx": "^4.22.4",
"typescript": "^5.7.0"
}
}
65 changes: 65 additions & 0 deletions players/agricogla/symbolic-planner/smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Local end-to-end smoke: play a full 4-player episode where seat 0 is piloted
// by the symbolic planner over REDACTED views (the exact path the host serves),
// and seats 1-3 by the engine baseline. Verifies the standalone fork plays all
// 14 rounds with no crash on hidden state. Run: npx tsx scratch-smoke.ts
import {
newGame,
applyPlacement,
applyFeeding,
computeAutoFeed,
scoreGame,
type GameState,
type Placement,
type FeedDecision,
} from "./src/engine/index.js";
import { buildView, fallbackPlacement } from "./src/baseline.js";
import { planMove } from "./src/planner/planner.js";

/** Mirror of the seam's game.redact: mask future deck + other seats' hands. */
function redact(s: GameState, seat: number): GameState {
const next = structuredClone(s);
next.seed = 0;
next.roundDeck = next.roundDeck.map(() => "hidden");
for (const p of next.players) {
if (p.idx !== seat) {
p.handOccupations = p.handOccupations.map(() => "hidden");
p.handMinors = p.handMinors.map(() => "hidden");
}
}
return next;
}

let state = newGame({ seed: 12345, numPlayers: 4, names: ["Planner", "B1", "B2", "B3"] });
let plannerDecisions = 0;
let guard = 0;

while (state.phase !== "finished") {
guard++;
if (guard > 5000) throw new Error("loop guard tripped — game did not finish");

if (state.phase === "feeding") {
const seat = state.toFeed[0]!;
const feed: FeedDecision = computeAutoFeed(redact(state, seat), seat);
state = applyFeeding(state, seat, feed).state;
continue;
}

const seat = state.currentPlayer;
const view = redact(state, seat);
let placement: Placement;
if (seat === 0) {
placement = planMove(buildView(view, seat)).placement;
plannerDecisions++;
} else {
placement = fallbackPlacement(buildView(view, seat));
}
state = applyPlacement(state, seat, placement).state;
}

const sheets = scoreGame(state);
const scores = sheets.map((s) => s.total);
console.log(JSON.stringify({ rounds: state.round, plannerDecisions, scores }, null, 2));
const plannerScore = sheets.find((s) => s.playerIdx === 0)!.total;
const maxOpp = Math.max(...sheets.filter((s) => s.playerIdx !== 0).map((s) => s.total));
console.log(`planner=${plannerScore} bestOpp=${maxOpp} margin=${plannerScore - maxOpp}`);
console.log(plannerScore >= maxOpp ? "PLANNER WINS/TIES" : "planner lost");
Loading