diff --git a/players/agricogla/symbolic-planner/.dockerignore b/players/agricogla/symbolic-planner/.dockerignore new file mode 100644 index 0000000..8500148 --- /dev/null +++ b/players/agricogla/symbolic-planner/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +package-lock.json +smoke.ts +*.log diff --git a/players/agricogla/symbolic-planner/.gitignore b/players/agricogla/symbolic-planner/.gitignore new file mode 100644 index 0000000..320c107 --- /dev/null +++ b/players/agricogla/symbolic-planner/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +package-lock.json diff --git a/players/agricogla/symbolic-planner/Dockerfile b/players/agricogla/symbolic-planner/Dockerfile new file mode 100644 index 0000000..ef436dd --- /dev/null +++ b/players/agricogla/symbolic-planner/Dockerfile @@ -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"] diff --git a/players/agricogla/symbolic-planner/README.md b/players/agricogla/symbolic-planner/README.md new file mode 100644 index 0000000..135b511 --- /dev/null +++ b/players/agricogla/symbolic-planner/README.md @@ -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 `. +- `players/agricogla/symbolic-planner/dist/coplayer_manifest.json`. + +Optional flags: `--push ` 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 \ + --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. diff --git a/players/agricogla/symbolic-planner/build.sh b/players/agricogla/symbolic-planner/build.sh new file mode 100755 index 0000000..219ae66 --- /dev/null +++ b/players/agricogla/symbolic-planner/build.sh @@ -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 "$@" diff --git a/players/agricogla/symbolic-planner/package.json b/players/agricogla/symbolic-planner/package.json new file mode 100644 index 0000000..234013b --- /dev/null +++ b/players/agricogla/symbolic-planner/package.json @@ -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" + } +} diff --git a/players/agricogla/symbolic-planner/smoke.ts b/players/agricogla/symbolic-planner/smoke.ts new file mode 100644 index 0000000..9ef907c --- /dev/null +++ b/players/agricogla/symbolic-planner/smoke.ts @@ -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"); diff --git a/players/agricogla/symbolic-planner/src/baseline.ts b/players/agricogla/symbolic-planner/src/baseline.ts new file mode 100644 index 0000000..6e20079 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/baseline.ts @@ -0,0 +1,414 @@ +// The scripted baseline, ported verbatim from the original cogame-agricogla +// `agents/candidates.ts` + `agents/scripted.ts`. `enumerateCandidates` scores a +// list of concrete, shape-legal placements; `fallbackPlacement` takes the best +// one (or the first open simple space). This is BOTH the seam's +// `baselineDecision` and the no-LLM coworld certification player — keeping it +// byte-identical to the source is what proves trajectory parity. + +import { + ANIMALS, + HARVEST_ROUNDS, + bestCookRate, + cardById, + computePastures, + legalActions, + playerChoices, + scoreGame, + type ActionOption, + type GameState, + type Placement, + type PlayerChoices, + type Resource, +} from "./engine/index.js"; + +/** Audience: public broadcast or a list of seat indices. */ +export type Audience = "public" | number[]; + +/** Everything the scripted heuristic + autopilot render need to pick a move. + * Mirrors the source repo's `AgentView` (minus the operator-guidance field, + * which the autopilot threads in separately). */ +export interface SeamView { + state: GameState; + playerIdx: number; + options: ActionOption[]; + choices: PlayerChoices; +} + +/** Build a seat's decision view from the engine: legal action spaces + the + * per-seat choice data (costs, legal cells, hand cards, fence plans, …). */ +export function buildView(state: GameState, playerIdx: number): SeamView { + return { + state, + playerIdx, + options: legalActions(state, playerIdx), + choices: playerChoices(state, playerIdx), + }; +} + +export interface Candidate { + placement: Placement; + value: number; + label: string; +} + +function nextHarvestIn(round: number): number { + for (let r = round; r <= 14; r++) if (HARVEST_ROUNDS.has(r)) return r - round; + return 14 - round; +} + +/** Empty, unstabled, unpastured cells: each scores -1 at the end. */ +function countUnused(view: SeamView): number { + const player = view.state.players[view.playerIdx]!; + const layout = computePastures(player.spaces, player.fences); + return player.spaces.filter( + (sp, i) => sp.kind === "empty" && !sp.stable && !layout.pastureCells.has(i), + ).length; +} + +/** Static desirability of occupations for the scripted bot. */ +function occupationValue(id: string, round: number): number { + const card = cardById(id); + let v = (card.vp ?? 0) * 2; + if (card.onGain) v += round <= 7 ? 5 : 2; + if (card.cook) v += 4; + if (card.capacity) v += 3; + if (card.roomDiscount) v += round <= 8 ? 4 : 1; + if (card.plowExtra) v += 3; + if (card.onHarvest) v += 4; + if (card.bonusVp) v += 3; + if (card.onPlay) v += 2; + return v; +} + +function minorValue(id: string): number { + const card = cardById(id); + let v = (card.vp ?? 0) * 2; + if (card.cook) v += 3; + if (card.capacity) v += 3; + if (card.onPlay) v += 2; + if (card.onGain || card.onHarvest || card.onRoundStart) v += 3; + if (card.plowExtra) v += 2; + return v; +} + +/** Enumerate concrete, legal-ish candidate placements with heuristic values. + * Everything returned passes shape validation; rule legality is ensured by + * building from PlayerChoices data. */ +export function enumerateCandidates(view: SeamView): Candidate[] { + const { state, choices, options } = view; + const player = state.players[view.playerIdx]!; + const out: Candidate[] = []; + const round = state.round; + const family = player.family.length; + const rooms = player.spaces.filter((s) => s.kind === "room").length; + const fields = player.spaces.filter((s) => s.kind === "field").length; + const res = player.resources; + const harvestGap = nextHarvestIn(round); + + const cookRates = Object.fromEntries( + ANIMALS.map((t) => [t, bestCookRate(player, t)?.food ?? 0]), + ) as Record<(typeof ANIMALS)[number], number>; + const hasCooker = ANIMALS.some((t) => cookRates[t] > 0); + + // Projected food at the next harvest: supply + raw crops + cookable animals + // + crops the fields will yield at harvest. + const cookValue = choices.conversionOptions + .filter((c) => c.via !== "raw") + .reduce((s, c) => s + c.foodEach * c.max, 0); + const fieldYield = player.spaces.filter((sp) => sp.kind === "field" && sp.cropCount > 0).length; + const expectedFood = res.food + res.grain + res.vegetable + cookValue + fieldYield; + const deficit = choices.foodNeededNow - expectedFood; + // 0 = comfortable, up to ~4 = starving with the harvest imminent. + const urgency = deficit > 0 ? Math.max(1, 4 - harvestGap) : 0; + const hungry = urgency >= 2; + + const available = new Map(options.filter((o) => o.available).map((o) => [o.id, o])); + const add = (placement: Placement, value: number, label: string) => { + if (!available.has(placement.action)) return; + out.push({ placement, value, label }); + }; + /** Extra value for actions that produce food, scaled by how hungry we are. */ + const foodBonus = (yield_: number) => yield_ * urgency * 2.5; + + // --- family growth: a new member is worth ~3 vp + an action per round, but + // never grow into a deficit (begging costs more than the member earns). + const growthValue = deficit > 0 ? 5 : 60 - Math.max(0, deficit + 2) * 8; + add({ action: "r_family_growth" }, growthValue, "family growth"); + if (round >= 12) { + add({ action: "r_urgent_family" }, Math.min(40, growthValue), "urgent family growth"); + } + + // --- rooms when the family is housebound. + const canAffordRoom = Object.entries(choices.roomCost).every( + ([r, n]) => res[r as Resource] >= (n ?? 0), + ); + if (choices.legalRooms.length > 0 && canAffordRoom) { + const value = rooms <= family ? 42 : 12; + add( + { action: "farm_expansion", rooms: [choices.legalRooms[0]!], stables: [] }, + value, + "build room", + ); + } else if (choices.stablesLeft > 0 && res.wood >= 2 && choices.legalStables.length > 0) { + // Stables only when we already keep animals. + const animals = player.animals.sheep + player.animals.boar + player.animals.cattle; + if (animals > 0) { + add( + { action: "farm_expansion", rooms: [], stables: [choices.legalStables[0]!] }, + 10, + "build stable", + ); + } + } + + // --- occupations early, but they must not crowd out the food engine. + for (const spaceId of ["lessons", "lessons_b"] as const) { + const cost = choices.occupationCostBySpace[spaceId] ?? 0; + const playable = choices.handOccupations.filter((c) => c.prereqOk); + if (playable.length > 0 && res.food >= cost) { + const best = playable.reduce((a, b) => + occupationValue(b.id, round) > occupationValue(a.id, round) ? b : a, + ); + const value = + (round <= 8 ? 17 : 9) + + occupationValue(best.id, round) - + cost * 2 - + player.occupations.length * 4; + add({ action: spaceId, occupation: best.id }, value, `occupation ${best.name}`); + } + } + + // --- improvements. + const fireplaceOwned = player.majors.some((m) => m.startsWith("fireplace") || m.startsWith("hearth")); + const buyableMajors = choices.majors.filter((c) => c.affordable && c.prereqOk); + let bestMajor: { card: string; value: number } | null = null; + for (const m of buyableMajors) { + let v = m.vp * 3; + if ((m.id === "fireplace2" || m.id === "fireplace3") && !hasCooker) v += 24; + if (m.id === "well") v += 6; + if ((m.id === "clay_oven" || m.id === "stone_oven") && res.grain >= 1) v += 5; + if (m.id.startsWith("hearth") && fireplaceOwned) v -= 6; + if (!bestMajor || v > bestMajor.value) bestMajor = { card: m.id, value: v }; + } + const playableMinors = choices.handMinors.filter((c) => c.affordable && c.prereqOk); + let bestMinor: { card: string; value: number } | null = null; + for (const m of playableMinors) { + const v = 8 + minorValue(m.id); + if (!bestMinor || v > bestMinor.value) bestMinor = { card: m.id, value: v }; + } + if (bestMajor) { + add( + { action: "r_improvement", improvement: { kind: "major", card: bestMajor.card } }, + bestMajor.value, + `buy ${cardById(bestMajor.card).name}`, + ); + } else if (bestMinor) { + add( + { action: "r_improvement", improvement: { kind: "minor", card: bestMinor.card } }, + bestMinor.value, + `play ${cardById(bestMinor.card).name}`, + ); + } + if (bestMinor) { + add( + { + action: "meeting_place", + improvement: { kind: "minor", card: bestMinor.card }, + }, + bestMinor.value - 2, + `meeting place + ${cardById(bestMinor.card).name}`, + ); + } else { + add({ action: "meeting_place" }, 4, "starting player"); + } + + // Unused farmyard spaces cost a point each; fill them late. + const unusedSpaces = options.length > 0 ? countUnused(view) : 0; + const fillBonus = round >= 10 ? Math.min(10, unusedSpaces * 1.5) : 0; + + // --- fields & sowing. + if (choices.legalFields.length > 0) { + const value = (fields < 2 ? 26 : fields < 5 ? 16 : 6) + fillBonus; + add({ action: "farmland", spaces: [choices.legalFields[0]!] }, value, "plow"); + } + const sowable = choices.sowableFields; + if (sowable.length > 0 && (res.grain > 0 || res.vegetable > 0)) { + const sow: { space: number; crop: "grain" | "vegetable" }[] = []; + let grain = res.grain; + let veg = res.vegetable; + for (const space of sowable) { + if (veg > 0) { + sow.push({ space, crop: "vegetable" }); + veg--; + } else if (grain > 0) { + sow.push({ space, crop: "grain" }); + grain--; + } + } + if (sow.length > 0) { + add( + { action: "r_sow_bake", sow, bake: [] }, + 24 + sow.length * 4 + foodBonus(sow.length), + "sow", + ); + const cult = sow.slice(); + add( + { + action: "r_cultivation", + plow: choices.legalFields[0], + sow: cult, + }, + 26 + sow.length * 3, + "cultivate", + ); + } + } + if (choices.legalFields.length > 0) { + add({ action: "r_cultivation", plow: choices.legalFields[0]!, sow: [] }, 14, "cultivation plow"); + } + // Bake when hungry and an oven-ish card is owned. + if (res.grain > 0 && choices.bakeOptions.length > 0 && (hungry || res.grain >= 3)) { + const best = choices.bakeOptions.reduce((a, b) => (b.perGrain > a.perGrain ? b : a)); + const grain = Math.min(res.grain, best.maxGrain); + add( + { action: "r_sow_bake", sow: [], bake: [{ card: best.card, grain }] }, + 6 + grain * best.perGrain + foodBonus(grain * best.perGrain), + "bake bread", + ); + } + + // --- fences. + if (choices.fencePlans.length > 0) { + const plan = choices.fencePlans.find((p) => p.cells.length >= 2) ?? choices.fencePlans[0]!; + if (res.wood >= plan.cost) { + const havePasture = player.fences.length > 0; + const value = (havePasture ? 12 : round >= 5 ? 30 : 18) + fillBonus; + add({ action: "r_fences", edges: plan.edges }, value, `fence ${plan.cells.length} cells`); + } + } + + // --- renovation (never while the family is hungry). + if (choices.renovation) { + const affordable = Object.entries(choices.renovation).every( + ([r, n]) => res[r as Resource] >= (n ?? 0), + ); + if (affordable) { + const value = (round >= 9 ? 22 : 8) - urgency * 6; + add({ action: "r_renovate_improve" }, value, "renovate"); + add({ action: "r_redevelop", edges: [] }, value, "renovate (redevelopment)"); + } + } + + // --- animals: points for breeding pairs plus food when cookable. + const animalOfSpace = { r_sheep: "sheep", r_boar: "boar", r_cattle: "cattle" } as const; + for (const id of ["r_sheep", "r_boar", "r_cattle"] as const) { + const opt = available.get(id); + if (!opt) continue; + const type = animalOfSpace[id]; + const count = Object.values(opt.pile).reduce((s, n) => s + (n ?? 0), 0); + if (count === 0) continue; + const rate = cookRates[type]; + let value = count * (rate > 0 ? 5 : 4) + foodBonus(count * rate); + // A first breeding pair is future points and offspring. + if (player.animals[type] === 0 && count >= 2) value += 10; + add({ action: id }, value, `take ${type}`); + } + + // --- resource piles, with diminishing returns on what we already hoard. + const needsRoom = rooms <= family && family < 5; + const savingForFireplace = !hasCooker && round >= 2; + const woodHungry = (needsRoom && res.wood < 7) || (player.fencesBuilt < 6 && res.wood < 5); + const emptyField = player.spaces.some((sp) => sp.kind === "field" && sp.cropCount === 0); + const wants: Record = { + wood: woodHungry ? 3.2 : 1.8, + clay: savingForFireplace ? 3.0 : player.houseMaterial === "wood" && round >= 6 ? 2.4 : 1.6, + reed: needsRoom && res.reed < 2 ? 3.6 : res.reed < 2 ? 2.2 : 1.2, + stone: round >= 5 ? 2.6 : 1.8, + food: 1.4 + urgency * 1.8, + grain: res.grain === 0 ? (emptyField ? 11 : 9) : 4, + vegetable: 7, + }; + for (const g of ["wood", "clay", "reed", "stone"] as const) { + wants[g] = wants[g]! * Math.max(0.3, 1 - res[g] / 10); + } + for (const id of [ + "forest", + "clay_pit", + "reed_bank", + "fishing", + "copse", + "grove", + "hollow", + "quarry_stall", + "resource_market", + "traveling_players", + "r_west_quarry", + "r_east_quarry", + "grain_seeds", + "r_vegetable", + "day_laborer", + ] as const) { + const opt = available.get(id); + if (!opt) continue; + let value = 0; + const pile = { ...opt.pile } as Record; + if (id === "grain_seeds") pile.grain = (pile.grain ?? 0) + 1; + if (id === "r_vegetable") pile.vegetable = (pile.vegetable ?? 0) + 1; + if (id === "day_laborer") pile.food = (pile.food ?? 0) + 2; + if (id === "quarry_stall") pile.stone = (pile.stone ?? 0) + 1; + if (id === "resource_market") { + pile.reed = (pile.reed ?? 0) + 1; + pile.stone = (pile.stone ?? 0) + 1; + pile.food = (pile.food ?? 0) + 1; + } + for (const [g, n] of Object.entries(pile)) value += (wants[g] ?? 1.5) * (n ?? 0); + add({ action: id } as Placement, value, `take ${id}`); + } + + return out.sort((a, b) => b.value - a.value); +} + +/** Fallback placement: take the best candidate, else the first open simple space. */ +export function fallbackPlacement(view: SeamView): Placement { + const candidates = enumerateCandidates(view); + if (candidates.length > 0) return candidates[0]!.placement; + const open = view.options.find((o) => o.available); + if (!open) throw new Error(`no available action for player ${view.playerIdx}`); + return { action: open.id } as Placement; +} + +/** A scripted table-talk line from the baseline policy. */ +export interface BaselineMessage { + body: string; + to: Audience; +} + +/** Scripted "table talk" for the baseline policy: 0–1 short public lines read off + * the PUBLIC board (running score, larder, the harvest calendar) — never a seat's + * hand, so it is safe on the redacted public view the server's talk pass holds. + * This is what keeps the comms channel alive when a bot seat falls back from its + * LLM to the baseline. Deterministic and low-spam: it opens, signs off at the + * finale, and otherwise speaks about every other round. */ +export function baselineTalk(state: GameState, seat: number): BaselineMessage[] { + const me = state.players[seat]!; + const round = state.round; + const say = (body: string): BaselineMessage[] => [{ body, to: "public" }]; + + if (round <= 1) return say(`${me.name} here — first furrow's mine. Good farming, all.`); + if (round >= 14) return say("That's the last harvest in. Tally it up."); + // Quiet on the odd rounds so the channel isn't a firehose. + if (round % 2 === 1) return []; + + const sheets = scoreGame(state); + const myScore = sheets.find((s) => s.playerIdx === seat)?.total ?? 0; + const top = sheets.reduce((m, s) => (s.total > m.total ? s : m), sheets[0]!); + const behind = top.total - myScore; + const harvestSoon = HARVEST_ROUNDS.has(round); + const larder = me.resources.food + me.resources.grain + me.resources.vegetable; + const hungry = larder < me.family.length * 2; + + if (harvestSoon && hungry) return say("Larder's thin with a harvest due — I'm on food this round, leave the fishing hole be."); + if (top.playerIdx === seat) return say("Top of the table for now. Long way to round 14, though."); + if (behind > 6) return say("You're running ahead — enjoy it while it lasts, I'm only getting started."); + return say(harvestSoon ? "Harvest looming. Hope your families are fed." : "Steady season. Plenty of plowing left."); +} diff --git a/players/agricogla/symbolic-planner/src/engine/apply.ts b/players/agricogla/symbolic-planner/src/engine/apply.ts new file mode 100644 index 0000000..156c4e7 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/apply.ts @@ -0,0 +1,800 @@ +import { HARVEST_ROUNDS, spaceDef } from "./boards"; +import { cardById } from "./cards"; +import { CardDef } from "./cards/types"; +import { + bestCookRate, + canAccommodate, + emit, + gainGoods, + hookCtx, + playedCards, + takeAnimals, +} from "./effects"; +import { computePastures, neighborsOf, validateFencePlan } from "./farmyard"; +import { + BakeChoice, + FeedDecision, + ImprovementChoice, + Placement, + SowChoice, +} from "./placements"; +import { + ANIMALS, + AnimalType, + GameState, + Goods, + HouseMaterial, + PlayerState, + Resource, + goodsToText, +} from "./types"; +import { scoreGame } from "./scoring"; + +export class RuleError extends Error {} + +function req(condition: unknown, message: string): asserts condition { + if (!condition) throw new RuleError(message); +} + +function clone(value: T): T { + return structuredClone(value); +} + +export function findSpace(state: GameState, id: string) { + const space = state.actionSpaces.find((s) => s.id === id); + req(space, `action space ${id} is not in play`); + return space; +} + +function canPay(player: PlayerState, cost: Partial>): boolean { + return Object.entries(cost).every(([res, n]) => player.resources[res as Resource] >= (n ?? 0)); +} + +function pay(player: PlayerState, cost: Partial>): void { + req(canPay(player, cost), `cannot afford ${goodsToText(cost)}`); + for (const [res, n] of Object.entries(cost)) { + player.resources[res as Resource] -= n ?? 0; + } +} + +export function roomCost(player: PlayerState): Partial> { + const base: Goods = { [player.houseMaterial]: 5, reed: 2 }; + for (const card of playedCards(player)) { + if (!card.roomDiscount) continue; + const discount = card.roomDiscount(player.houseMaterial); + for (const [res, n] of Object.entries(discount)) { + const key = res as Resource; + base[key] = Math.max(0, (base[key] ?? 0) - (n ?? 0)); + } + } + return base as Partial>; +} + +export function renovationCost(player: PlayerState): Partial> { + req(player.houseMaterial !== "stone", "house is already stone"); + const next: HouseMaterial = player.houseMaterial === "wood" ? "clay" : "stone"; + const rooms = player.spaces.filter((s) => s.kind === "room").length; + return { [next]: rooms, reed: 1 }; +} + +export function legalRoomSpaces(player: PlayerState): number[] { + const { pastureCells } = computePastures(player.spaces, player.fences); + return player.spaces + .map((_, i) => i) + .filter((i) => { + const sp = player.spaces[i]!; + if (sp.kind !== "empty" || sp.stable || pastureCells.has(i)) return false; + return neighborsOf(i).some((n) => player.spaces[n]!.kind === "room"); + }); +} + +export function legalFieldSpaces(player: PlayerState): number[] { + const { pastureCells } = computePastures(player.spaces, player.fences); + const hasFields = player.spaces.some((s) => s.kind === "field"); + return player.spaces + .map((_, i) => i) + .filter((i) => { + const sp = player.spaces[i]!; + if (sp.kind !== "empty" || sp.stable || pastureCells.has(i)) return false; + if (!hasFields) return true; + return neighborsOf(i).some((n) => player.spaces[n]!.kind === "field"); + }); +} + +export function legalStableSpaces(player: PlayerState): number[] { + return player.spaces + .map((_, i) => i) + .filter((i) => { + const sp = player.spaces[i]!; + return sp.kind === "empty" && !sp.stable; + }); +} + +function buildRooms(state: GameState, player: PlayerState, rooms: number[]): void { + for (const space of rooms) { + const legal = legalRoomSpaces(player); + req(legal.includes(space), `cannot build a room at space ${space}`); + pay(player, roomCost(player)); + player.spaces[space]!.kind = "room"; + } + if (rooms.length > 0) { + emit(state, player.idx, "build", `${player.name} builds ${rooms.length} room(s)`); + } +} + +function buildStables(state: GameState, player: PlayerState, stables: number[]): void { + for (const space of stables) { + const built = player.spaces.filter((s) => s.stable).length; + req(built < 4, "maximum 4 stables"); + req(legalStableSpaces(player).includes(space), `cannot build a stable at space ${space}`); + pay(player, { wood: 2 }); + player.spaces[space]!.stable = true; + } + if (stables.length > 0) { + emit(state, player.idx, "build", `${player.name} builds ${stables.length} stable(s)`); + } +} + +function plowFields( + state: GameState, + player: PlayerState, + spaces: number[], + plowCard?: string, +): void { + let allowed = 1; + if (plowCard) { + const card = cardById(plowCard); + req( + player.occupations.includes(plowCard) || player.minors.includes(plowCard), + `${card.name} is not in play`, + ); + req(card.plowExtra, `${card.name} is not a plow`); + const data = (player.cardData[plowCard] ??= {}); + const used = data.plowUses ?? 0; + req(used < card.plowExtra.uses, `${card.name} has no uses left`); + data.plowUses = used + 1; + allowed += card.plowExtra.fields; + } + req(spaces.length >= 1 && spaces.length <= allowed, `may plow up to ${allowed} field(s)`); + for (const space of spaces) { + req(legalFieldSpaces(player).includes(space), `cannot plow at space ${space}`); + player.spaces[space]!.kind = "field"; + } + emit(state, player.idx, "plow", `${player.name} plows ${spaces.length} field(s)`); +} + +function sow(state: GameState, player: PlayerState, choices: SowChoice[]): void { + const seen = new Set(); + for (const { space, crop } of choices) { + req(!seen.has(space), `sowing space ${space} twice`); + seen.add(space); + const sp = player.spaces[space]; + req(sp && sp.kind === "field", `space ${space} is not a field`); + req(sp.cropCount === 0, `field ${space} is already sown`); + req(player.resources[crop] >= 1, `no ${crop} to sow`); + player.resources[crop] -= 1; + sp.crop = crop; + sp.cropCount = crop === "grain" ? 3 : 2; + } + if (choices.length > 0) { + emit(state, player.idx, "sow", `${player.name} sows ${choices.length} field(s)`); + } +} + +function bake(state: GameState, player: PlayerState, choices: BakeChoice[]): void { + const used = new Set(); + for (const { card: cardId, grain } of choices) { + req(!used.has(cardId), `baking twice with the same improvement`); + used.add(cardId); + const card = cardById(cardId); + const owned = + player.majors.includes(cardId) || + player.minors.includes(cardId) || + player.occupations.includes(cardId); + req(owned, `${card.name} is not in play`); + req(card.bake, `${card.name} cannot bake bread`); + req(grain <= card.bake.maxGrain, `${card.name} bakes at most ${card.bake.maxGrain} grain`); + req(player.resources.grain >= grain, "not enough grain"); + player.resources.grain -= grain; + const food = grain * card.bake.perGrain; + player.resources.food += food; + emit( + state, + player.idx, + "bake", + `${player.name} bakes ${grain} grain into ${food} food (${card.name})`, + ); + } +} + +function renovate(state: GameState, player: PlayerState): void { + const cost = renovationCost(player); + pay(player, cost); + player.houseMaterial = player.houseMaterial === "wood" ? "clay" : "stone"; + emit(state, player.idx, "renovate", `${player.name} renovates to a ${player.houseMaterial} home`); +} + +function buildFences(state: GameState, player: PlayerState, edges: string[]): void { + const result = validateFencePlan(player, edges); + req(result.ok, result.error ?? "illegal fence plan"); + let free = 0; + for (const card of playedCards(player)) free += card.freeFences ?? 0; + const woodCost = Math.max(0, edges.length - free); + pay(player, { wood: woodCost }); + player.fences.push(...edges); + player.fencesBuilt += edges.length; + emit( + state, + player.idx, + "fences", + `${player.name} builds ${edges.length} fence(s) (${result.layout!.pastures.length} pasture(s))`, + ); +} + +function familyGrowth(state: GameState, player: PlayerState, needRoom: boolean): void { + req(player.family.length < 5, "family is already 5"); + if (needRoom) { + const rooms = player.spaces.filter((s) => s.kind === "room").length; + req(rooms > player.family.length, "no free room for family growth"); + } + player.family.push({ bornRound: state.round, placed: true }); + emit(state, player.idx, "family", `${player.name}'s family grows to ${player.family.length}`); +} + +function occupationCost(state: GameState, player: PlayerState, spaceId: string): number { + const played = player.occupations.length; + if (spaceId === "lessons") return played === 0 ? 0 : 1; + // lessons_b: 3-player board charges 2 food; 4-player board charges 1 food + // for the player's first two occupations, then 2. + if (state.numPlayers === 3) return 2; + return played < 2 ? 1 : 2; +} + +function playOccupation(state: GameState, player: PlayerState, cardId: string, food: number) { + const idx = player.handOccupations.indexOf(cardId); + req(idx >= 0, `occupation ${cardId} is not in hand`); + const card = cardById(cardId); + checkPrereq(state, player, card); + pay(player, { food }); + player.handOccupations.splice(idx, 1); + player.occupations.push(cardId); + emit(state, player.idx, "occupation", `${player.name} plays occupation: ${card.name}`); + card.onPlay?.(hookCtx(state, player)); +} + +function checkPrereq(state: GameState, player: PlayerState, card: CardDef): void { + if (!card.prereq) return; + if (card.prereq.occupations !== undefined) { + req( + player.occupations.length >= card.prereq.occupations, + `${card.name} requires ${card.prereq.occupations} occupation(s): ${card.prereq.label}`, + ); + } + if (card.prereq.check) { + req(card.prereq.check(player, state), `prerequisite not met: ${card.prereq.label}`); + } +} + +function playMinor(state: GameState, player: PlayerState, choice: ImprovementChoice): void { + const idx = player.handMinors.indexOf(choice.card); + req(idx >= 0, `minor improvement ${choice.card} is not in hand`); + const card = cardById(choice.card); + req(card.kind === "minor", `${card.name} is not a minor improvement`); + checkPrereq(state, player, card); + pay(player, card.cost ?? {}); + player.handMinors.splice(idx, 1); + player.minors.push(choice.card); + emit(state, player.idx, "improvement", `${player.name} plays improvement: ${card.name}`); + card.onPlay?.(hookCtx(state, player)); + if (card.passing) { + // Traveling card: hand it to the left-hand neighbor after use. + const neighbor = state.players[(player.idx + 1) % state.numPlayers]!; + if (neighbor.idx !== player.idx) { + player.minors.splice(player.minors.indexOf(choice.card), 1); + neighbor.handMinors.push(choice.card); + emit(state, player.idx, "pass", `${card.name} passes to ${neighbor.name}`); + } + } +} + +function buyMajor(state: GameState, player: PlayerState, choice: ImprovementChoice): void { + const idx = state.majorsAvailable.indexOf(choice.card); + req(idx >= 0, `major improvement ${choice.card} is not available`); + const card = cardById(choice.card); + if (choice.returnFireplace) { + req( + choice.card === "hearth4" || choice.card === "hearth5", + "only a Cooking Hearth can be bought by returning a Fireplace", + ); + const fpIdx = player.majors.indexOf(choice.returnFireplace); + req( + fpIdx >= 0 && (choice.returnFireplace === "fireplace2" || choice.returnFireplace === "fireplace3"), + "no Fireplace to return", + ); + player.majors.splice(fpIdx, 1); + state.majorsAvailable.push(choice.returnFireplace); + } else { + pay(player, card.cost ?? {}); + } + state.majorsAvailable.splice(state.majorsAvailable.indexOf(choice.card), 1); + player.majors.push(choice.card); + emit(state, player.idx, "improvement", `${player.name} buys ${card.name}`); + card.onPlay?.(hookCtx(state, player)); + // Ovens allow an immediate bake on purchase. + if (choice.bake && choice.bake.length > 0) { + req(card.bake, `${card.name} cannot bake`); + req( + choice.bake.every((b) => b.card === choice.card), + "immediate bake must use the bought oven", + ); + bake(state, player, choice.bake); + } +} + +function playImprovement( + state: GameState, + player: PlayerState, + choice: ImprovementChoice, + allow: "minor" | "both", +): void { + if (choice.kind === "major") { + req(allow === "both", "only a minor improvement may be played here"); + buyMajor(state, player, choice); + } else { + playMinor(state, player, choice); + } +} + +/** Take everything from an accumulation space plus printed fixed goods. */ +function takeSpaceGoods(state: GameState, player: PlayerState, spaceId: string): void { + const space = findSpace(state, spaceId); + const def = spaceDef(spaceId, state.numPlayers); + const goods: Goods = { ...space.pile }; + for (const [g, n] of Object.entries(def.fixedGoods ?? {})) { + goods[g as keyof Goods] = (goods[g as keyof Goods] ?? 0) + (n ?? 0); + } + space.pile = {}; + emit(state, player.idx, "take", `${player.name} takes ${goodsToText(goods)} (${def.title})`); + gainGoods(state, player, spaceId, goods); +} + +/** Resolve a placement for the current player. Throws RuleError when illegal. */ +function resolvePlacement(state: GameState, player: PlayerState, placement: Placement): void { + switch (placement.action) { + case "farm_expansion": { + req( + placement.rooms.length + placement.stables.length > 0, + "must build at least one room or stable", + ); + buildRooms(state, player, placement.rooms); + buildStables(state, player, placement.stables); + break; + } + case "meeting_place": { + state.startingPlayer = player.idx; + for (const p of state.players) p.startingPlayerMarker = p.idx === player.idx; + emit(state, player.idx, "starting", `${player.name} takes the starting player marker`); + if (placement.improvement) { + req(placement.improvement.kind === "minor", "Meeting Place allows a minor improvement"); + playMinor(state, player, placement.improvement); + } + break; + } + case "farmland": { + plowFields(state, player, placement.spaces, placement.plowCard); + break; + } + case "lessons": + case "lessons_b": { + const food = occupationCost(state, player, placement.action); + playOccupation(state, player, placement.occupation, food); + break; + } + case "grain_seeds": + case "day_laborer": + case "forest": + case "clay_pit": + case "reed_bank": + case "fishing": + case "copse": + case "grove": + case "hollow": + case "quarry_stall": + case "resource_market": + case "traveling_players": + case "r_sheep": + case "r_west_quarry": + case "r_vegetable": + case "r_boar": + case "r_east_quarry": + case "r_cattle": { + takeSpaceGoods(state, player, placement.action); + break; + } + case "r_improvement": { + playImprovement(state, player, placement.improvement, "both"); + break; + } + case "r_fences": { + buildFences(state, player, placement.edges); + break; + } + case "r_sow_bake": { + req(placement.sow.length + placement.bake.length > 0, "must sow and/or bake"); + sow(state, player, placement.sow); + bake(state, player, placement.bake); + break; + } + case "r_renovate_improve": { + renovate(state, player); + if (placement.improvement) playImprovement(state, player, placement.improvement, "both"); + break; + } + case "r_family_growth": { + familyGrowth(state, player, true); + if (placement.improvement) { + req(placement.improvement.kind === "minor", "only a minor improvement after family growth"); + playMinor(state, player, placement.improvement); + } + break; + } + case "r_urgent_family": { + familyGrowth(state, player, false); + break; + } + case "r_cultivation": { + req(placement.plow !== undefined || placement.sow.length > 0, "must plow and/or sow"); + if (placement.plow !== undefined) plowFields(state, player, [placement.plow]); + sow(state, player, placement.sow); + break; + } + case "r_redevelop": { + renovate(state, player); + if (placement.edges.length > 0) buildFences(state, player, placement.edges); + break; + } + } +} + +export interface StepResult { + state: GameState; +} + +/** Place the next family member of `playerIdx` on an action space. */ +export function applyPlacement( + state: GameState, + playerIdx: number, + placement: Placement, +): StepResult { + req(state.phase === "work", `not in the work phase`); + req(state.currentPlayer === playerIdx, `not player ${playerIdx}'s turn`); + const next = clone(state); + const player = next.players[playerIdx]!; + const member = player.family.find((m) => !m.placed); + req(member, "no family members left to place"); + + const space = findSpace(next, placement.action); + req(space.occupiedBy === null, `${placement.action} is already occupied`); + + resolvePlacement(next, player, placement); + + space.occupiedBy = playerIdx; + member.placed = true; + const ctx = hookCtx(next, player); + for (const card of playedCards(player)) card.onAction?.(ctx, placement.action); + + advanceWork(next); + return { state: next }; +} + +function advanceWork(state: GameState): void { + const n = state.numPlayers; + for (let i = 1; i <= n; i++) { + const idx = (state.currentPlayer + i) % n; + if (state.players[idx]!.family.some((m) => !m.placed)) { + state.currentPlayer = idx; + return; + } + } + // Work phase over: return home. + for (const p of state.players) for (const m of p.family) m.placed = false; + emit(state, null, "phase", `Round ${state.round}: everyone returns home`); + if (HARVEST_ROUNDS.has(state.round)) { + startHarvest(state); + } else { + startRound(state); + } +} + +function startHarvest(state: GameState): void { + emit(state, null, "harvest", `Harvest after round ${state.round}`); + for (const player of state.players) { + let grain = 0; + let veg = 0; + for (const sp of player.spaces) { + if (sp.kind === "field" && sp.crop && sp.cropCount > 0) { + sp.cropCount -= 1; + if (sp.crop === "grain") grain += 1; + else veg += 1; + if (sp.cropCount === 0) sp.crop = null; + } + } + player.resources.grain += grain; + player.resources.vegetable += veg; + if (grain + veg > 0) { + emit( + state, + player.idx, + "field", + `${player.name} harvests ${grain} grain and ${veg} vegetable(s)`, + ); + } + const ctx = hookCtx(state, player); + for (const card of playedCards(player)) card.onHarvest?.(ctx); + // Reset per-harvest conversion trackers. + for (const data of Object.values(player.cardData)) delete data.harvestUsed; + } + state.phase = "feeding"; + state.toFeed = state.players.map((p) => p.idx); +} + +export function foodNeeded(state: GameState, player: PlayerState): number { + const perAdult = state.solo ? 3 : 2; + return player.family.reduce( + (sum, m) => sum + (m.bornRound === state.round ? 1 : perAdult), + 0, + ); +} + +/** Apply a feeding decision for one player during the feeding phase. */ +export function applyFeeding( + state: GameState, + playerIdx: number, + decision: FeedDecision, +): StepResult { + req(state.phase === "feeding", "not in the feeding phase"); + req(state.toFeed.includes(playerIdx), `player ${playerIdx} has already fed`); + const next = clone(state); + const player = next.players[playerIdx]!; + + for (const conv of decision.conversions) { + applyConversion(next, player, conv.via, conv.good, conv.count, true); + } + + const needed = foodNeeded(next, player); + const paid = Math.min(needed, player.resources.food); + player.resources.food -= paid; + const missing = needed - paid; + if (missing > 0) { + player.beggingCards += missing; + emit( + next, + player.idx, + "begging", + `${player.name} is short ${missing} food and takes ${missing} begging card(s)`, + ); + } else { + emit(next, player.idx, "feed", `${player.name} feeds the family (${needed} food)`); + } + + next.toFeed = next.toFeed.filter((i) => i !== playerIdx); + if (next.toFeed.length === 0) { + breed(next); + startRound(next); + } + return { state: next }; +} + +export function applyConversion( + state: GameState, + player: PlayerState, + via: string, + good: string, + count: number, + feeding: boolean, +): void { + req(count >= 1, "conversion count must be positive"); + if (via === "raw") { + req(good === "grain" || good === "vegetable", "only crops convert at 1 food raw"); + req(player.resources[good] >= count, `not enough ${good}`); + player.resources[good] -= count; + player.resources.food += count; + return; + } + const card = cardById(via); + const owned = + player.majors.includes(via) || player.minors.includes(via) || player.occupations.includes(via); + req(owned, `${card.name} is not in play`); + const cookRate = card.cook?.[good as AnimalType | "vegetable"]; + if (cookRate) { + if (good === "vegetable") { + req(player.resources.vegetable >= count, "not enough vegetables"); + player.resources.vegetable -= count; + } else { + const animal = good as AnimalType; + req((ANIMALS as readonly string[]).includes(animal), `cannot cook ${good}`); + req(player.animals[animal] >= count, `not enough ${animal}`); + player.animals[animal] -= count; + } + player.resources.food += cookRate * count; + emit( + state, + player.idx, + "cook", + `${player.name} converts ${count} ${good} into ${cookRate * count} food (${card.name})`, + ); + return; + } + if (card.harvestFood && card.harvestFood.from === good) { + req(feeding, `${card.name} converts only during a harvest`); + const data = (player.cardData[via] ??= {}); + const used = data.harvestUsed ?? 0; + req(used + count <= card.harvestFood.max, `${card.name}: at most ${card.harvestFood.max} per harvest`); + req(player.resources[good as Resource] >= count, `not enough ${good}`); + data.harvestUsed = used + count; + player.resources[good as Resource] -= count; + player.resources.food += card.harvestFood.food * count; + emit( + state, + player.idx, + "cook", + `${player.name} converts ${count} ${good} into ${card.harvestFood.food * count} food (${card.name})`, + ); + return; + } + throw new RuleError(`${card.name} cannot convert ${good}`); +} + +/** Breeding: one offspring per type with >=2 animals, if it can be housed. */ +function breed(state: GameState): void { + for (const player of state.players) { + const eligible = ANIMALS.filter((t) => player.animals[t] >= 2); + if (eligible.length === 0) continue; + // Choose the largest accommodatable subset of births; prefer valuable types. + const priority: AnimalType[] = ["cattle", "boar", "sheep"]; + let bestSubset: AnimalType[] = []; + const subsets = (list: AnimalType[]): AnimalType[][] => + list.reduce((acc, t) => [...acc, ...acc.map((s) => [...s, t])], [[]]); + for (const subset of subsets(eligible)) { + const counts = { ...player.animals }; + for (const t of subset) counts[t] += 1; + if (!canAccommodate(player, counts)) continue; + if ( + subset.length > bestSubset.length || + (subset.length === bestSubset.length && + priority.findIndex((t) => subset.includes(t)) < + priority.findIndex((t) => bestSubset.includes(t))) + ) { + bestSubset = subset; + } + } + for (const t of bestSubset) { + player.animals[t] += 1; + emit(state, player.idx, "breed", `${player.name}'s ${t} breed (+1)`); + } + } +} + +/** Reveal next round card, deliver scheduled goods, replenish, begin work. */ +export function startRound(state: GameState): void { + if (state.round >= 14) { + finishGame(state); + return; + } + state.round += 1; + state.phase = "work"; + for (const space of state.actionSpaces) space.occupiedBy = null; + + const revealed = state.roundDeck.shift(); + if (revealed) { + state.actionSpaces.push({ id: revealed, occupiedBy: null, pile: {} }); + emit( + state, + null, + "reveal", + `Round ${state.round}: ${spaceDef(revealed, state.numPlayers).title} is now available`, + ); + } + + // Scheduled goods (e.g. the Well) pay out as the round begins. + for (const sched of state.scheduled.filter((s) => s.round === state.round)) { + const player = state.players[sched.playerIdx]!; + if (sched.good === "sheep" || sched.good === "boar" || sched.good === "cattle") { + takeAnimals(state, player, { [sched.good]: sched.count }); + } else { + player.resources[sched.good as Resource] += sched.count; + } + emit( + state, + sched.playerIdx, + "scheduled", + `${player.name} collects ${sched.count} ${sched.good}`, + ); + } + state.scheduled = state.scheduled.filter((s) => s.round !== state.round); + + // Round-start card hooks. + for (const player of state.players) { + const ctx = hookCtx(state, player); + for (const card of playedCards(player)) card.onRoundStart?.(ctx, state.round); + } + + // Replenish accumulation spaces. + for (const space of state.actionSpaces) { + const def = spaceDef(space.id, state.numPlayers); + if (!def.accumulates) continue; + for (const [g, n] of Object.entries(def.accumulates)) { + space.pile[g as keyof Goods] = (space.pile[g as keyof Goods] ?? 0) + (n ?? 0); + } + } + + state.currentPlayer = state.startingPlayer; +} + +function finishGame(state: GameState): void { + state.phase = "finished"; + state.scores = scoreGame(state); + const winner = state.scores.reduce((a, b) => (b.total > a.total ? b : a)); + emit( + state, + null, + "end", + `Game over. ${state.players[winner.playerIdx]!.name} wins with ${winner.total} points`, + ); +} + +/** Auto-feed: cover the food need with the cheapest goods available. Used as + * agent fallback and by the UI's auto button. */ +export function computeAutoFeed(state: GameState, playerIdx: number): FeedDecision { + const player = clone(state.players[playerIdx]!); + const needed = foodNeeded(state, player); + let have = player.resources.food; + const conversions: FeedDecision["conversions"] = []; + const addConv = (via: string, good: string, count: number, foodEach: number) => { + conversions.push({ via, good: good as FeedDecision["conversions"][number]["good"], count }); + have += foodEach * count; + }; + + // 1. Workshop harvest conversions (spare building resources). + for (const cardId of [...player.majors, ...player.minors, ...player.occupations]) { + if (have >= needed) break; + const card = cardById(cardId); + if (!card.harvestFood) continue; + const res = card.harvestFood.from; + const spare = Math.max(0, player.resources[res] - 2); + const count = Math.min(card.harvestFood.max, spare, Math.ceil((needed - have) / card.harvestFood.food)); + if (count > 0) { + player.resources[res] -= count; + addConv(cardId, res, count, card.harvestFood.food); + } + } + // 2. Raw grain. + while (have < needed && player.resources.grain > 0) { + player.resources.grain -= 1; + addConv("raw", "grain", 1, 1); + } + // 3. Cook animals, keeping breeding pairs where possible: sheep, boar, cattle. + for (const type of ["sheep", "boar", "cattle"] as const) { + const cook = bestCookRate(player, type); + if (!cook) continue; + while (have < needed && player.animals[type] > 2) { + player.animals[type] -= 1; + addConv(cook.card.id, type, 1, cook.food); + } + } + // 4. Raw vegetables. + while (have < needed && player.resources.vegetable > 0) { + player.resources.vegetable -= 1; + addConv("raw", "vegetable", 1, 1); + } + // 5. Break up breeding pairs as a last resort. + for (const type of ["sheep", "boar", "cattle"] as const) { + const cook = bestCookRate(player, type); + if (!cook) continue; + while (have < needed && player.animals[type] > 0) { + player.animals[type] -= 1; + addConv(cook.card.id, type, 1, cook.food); + } + } + return { conversions }; +} diff --git a/players/agricogla/symbolic-planner/src/engine/boards.ts b/players/agricogla/symbolic-planner/src/engine/boards.ts new file mode 100644 index 0000000..04b8eed --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/boards.ts @@ -0,0 +1,223 @@ +import { shuffled, Rng } from "./rng"; +import { Goods } from "./types"; + +export interface SpaceDef { + id: string; + title: string; + /** Goods added every replenish phase. */ + accumulates?: Goods; + /** Goods granted directly when used (non-accumulating printed goods). */ + fixedGoods?: Goods; + /** Round-card stage (1..6) if this space enters play via the round deck. */ + stage?: number; + /** Short label of what the action does, for menus/prompts. */ + summary: string; +} + +const fixedSpaces: SpaceDef[] = [ + { + id: "farm_expansion", + title: "Farm Expansion", + summary: "Build room(s) at 5 wood + 2 reed each and/or stable(s) at 2 wood each", + }, + { + id: "meeting_place", + title: "Meeting Place", + summary: "Become starting player; may also play 1 minor improvement", + }, + { id: "grain_seeds", title: "Grain Seeds", fixedGoods: { grain: 1 }, summary: "Take 1 grain" }, + { id: "farmland", title: "Farmland", summary: "Plow 1 field" }, + { + id: "lessons", + title: "Lessons", + summary: "Play 1 occupation (first is free, later ones cost 1 food)", + }, + { id: "day_laborer", title: "Day Laborer", fixedGoods: { food: 2 }, summary: "Take 2 food" }, + { id: "forest", title: "Forest", accumulates: { wood: 3 }, summary: "Take all wood" }, + { id: "clay_pit", title: "Clay Pit", accumulates: { clay: 1 }, summary: "Take all clay" }, + { id: "reed_bank", title: "Reed Bank", accumulates: { reed: 1 }, summary: "Take all reed" }, + { id: "fishing", title: "Fishing", accumulates: { food: 1 }, summary: "Take all food" }, +]; + +const threePlayerSpaces: SpaceDef[] = [ + { id: "grove", title: "Grove", accumulates: { wood: 2 }, summary: "Take all wood" }, + { id: "hollow", title: "Hollow", accumulates: { clay: 1 }, summary: "Take all clay" }, + { + id: "quarry_stall", + title: "Stonecutter's Stall", + fixedGoods: { stone: 1 }, + summary: "Take 1 stone", + }, + { + id: "lessons_b", + title: "Lessons II", + summary: "Play 1 occupation (costs 2 food)", + }, +]; + +const fourPlayerSpaces: SpaceDef[] = [ + { id: "copse", title: "Copse", accumulates: { wood: 1 }, summary: "Take all wood" }, + { id: "grove", title: "Grove", accumulates: { wood: 2 }, summary: "Take all wood" }, + { id: "hollow", title: "Hollow", accumulates: { clay: 2 }, summary: "Take all clay" }, + { + id: "resource_market", + title: "Resource Market", + fixedGoods: { reed: 1, stone: 1, food: 1 }, + summary: "Take 1 reed, 1 stone and 1 food", + }, + { + id: "traveling_players", + title: "Traveling Players", + accumulates: { food: 1 }, + summary: "Take all food", + }, + { + id: "lessons_b", + title: "Lessons II", + summary: "Play 1 occupation (1 food for your first two occupations, then 2)", + }, +]; + +export const roundCards: SpaceDef[] = [ + // Stage 1 + { + id: "r_improvement", + title: "Improvement", + stage: 1, + summary: "Buy 1 major improvement or play 1 minor improvement", + }, + { + id: "r_sheep", + title: "Sheep Market", + stage: 1, + accumulates: { sheep: 1 }, + summary: "Take all sheep", + }, + { id: "r_fences", title: "Fences", stage: 1, summary: "Build fences at 1 wood each" }, + { id: "r_sow_bake", title: "Grain Utilization", stage: 1, summary: "Sow and/or bake bread" }, + // Stage 2 + { + id: "r_west_quarry", + title: "Western Quarry", + stage: 2, + accumulates: { stone: 1 }, + summary: "Take all stone", + }, + { + id: "r_renovate_improve", + title: "House Redevelopment", + stage: 2, + summary: "Renovate your home, then you may buy/play 1 improvement", + }, + { + id: "r_family_growth", + title: "Wish for Children", + stage: 2, + summary: "Family growth (needs a free room), then you may play 1 minor improvement", + }, + // Stage 3 + { + id: "r_vegetable", + title: "Vegetable Seeds", + stage: 3, + fixedGoods: { vegetable: 1 }, + summary: "Take 1 vegetable", + }, + { + id: "r_boar", + title: "Pig Market", + stage: 3, + accumulates: { boar: 1 }, + summary: "Take all wild boar", + }, + // Stage 4 + { + id: "r_east_quarry", + title: "Eastern Quarry", + stage: 4, + accumulates: { stone: 1 }, + summary: "Take all stone", + }, + { + id: "r_cattle", + title: "Cattle Market", + stage: 4, + accumulates: { cattle: 1 }, + summary: "Take all cattle", + }, + // Stage 5 + { + id: "r_urgent_family", + title: "Urgent Wish for Children", + stage: 5, + summary: "Family growth even without room in your home", + }, + { + id: "r_cultivation", + title: "Cultivation", + stage: 5, + summary: "Plow 1 field and/or sow", + }, + // Stage 6 + { + id: "r_redevelop", + title: "Farm Redevelopment", + stage: 6, + summary: "Renovate your home, then you may build fences", + }, +]; + +/** Rounds at whose end a harvest occurs. */ +export const HARVEST_ROUNDS = new Set([4, 7, 9, 11, 13, 14]); +export const TOTAL_ROUNDS = 14; + +export function stageOfRound(round: number): number { + if (round <= 4) return 1; + if (round <= 7) return 2; + if (round <= 9) return 3; + if (round <= 11) return 4; + if (round <= 13) return 5; + return 6; +} + +const spaceIndexById = new Map(); +for (const def of [...fixedSpaces, ...threePlayerSpaces, ...fourPlayerSpaces, ...roundCards]) { + // 3p/4p variants of grove/hollow/lessons_b share ids; player count decides + // which definition is live (resolved through boardSpaces at setup). + if (!spaceIndexById.has(def.id)) spaceIndexById.set(def.id, def); +} + +/** Action spaces present from round 1 for the given player count. */ +export function boardSpaces(numPlayers: number): SpaceDef[] { + if (numPlayers < 1 || numPlayers > 4) throw new Error("supported player counts: 1-4"); + const out = fixedSpaces.map((d) => ({ ...d })); + if (numPlayers === 1) { + const forest = out.find((d) => d.id === "forest")!; + forest.accumulates = { wood: 2 }; + } + if (numPlayers === 3) out.push(...threePlayerSpaces.map((d) => ({ ...d }))); + if (numPlayers === 4) out.push(...fourPlayerSpaces.map((d) => ({ ...d }))); + return out; +} + +/** The 14-round deck: stages in order, cards shuffled within each stage. */ +export function buildRoundDeck(rng: Rng): string[] { + const out: string[] = []; + for (let stage = 1; stage <= 6; stage++) { + const cards = roundCards.filter((c) => c.stage === stage).map((c) => c.id); + out.push(...shuffled(rng, cards)); + } + return out; +} + +export function spaceDef(id: string, numPlayers: number): SpaceDef { + if (numPlayers >= 3) { + const pool = numPlayers === 3 ? threePlayerSpaces : fourPlayerSpaces; + const hit = pool.find((d) => d.id === id); + if (hit) return hit; + } + const def = spaceIndexById.get(id); + if (!def) throw new Error(`unknown action space: ${id}`); + if (id === "forest" && numPlayers === 1) return { ...def, accumulates: { wood: 2 } }; + return def; +} diff --git a/players/agricogla/symbolic-planner/src/engine/cards/index.ts b/players/agricogla/symbolic-planner/src/engine/cards/index.ts new file mode 100644 index 0000000..98791c7 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/cards/index.ts @@ -0,0 +1,26 @@ +import { CardDef } from "./types"; +import { majors } from "./majors"; +import { occupations } from "./occupations"; +import { minors } from "./minors"; + +const registry = new Map(); +for (const card of [...majors, ...occupations, ...minors]) { + if (registry.has(card.id)) throw new Error(`duplicate card id: ${card.id}`); + registry.set(card.id, card); +} + +export function cardById(id: string): CardDef { + const card = registry.get(id); + if (!card) throw new Error(`unknown card: ${id}`); + return card; +} + +export function hasCard(id: string): boolean { + return registry.has(id); +} + +export const MAJOR_IDS = majors.map((c) => c.id); +export const OCCUPATION_IDS = occupations.map((c) => c.id); +export const MINOR_IDS = minors.map((c) => c.id); + +export { majors, occupations, minors }; diff --git a/players/agricogla/symbolic-planner/src/engine/cards/majors.ts b/players/agricogla/symbolic-planner/src/engine/cards/majors.ts new file mode 100644 index 0000000..558e1f0 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/cards/majors.ts @@ -0,0 +1,131 @@ +import { CardDef } from "./types"; +import { PlayerState } from "../types"; + +function tiered(count: number, tiers: [number, number][]): number { + let pts = 0; + for (const [threshold, points] of tiers) if (count >= threshold) pts = points; + return pts; +} + +function stored(p: PlayerState, res: "wood" | "clay" | "reed"): number { + return p.resources[res]; +} + +export const majors: CardDef[] = [ + { + id: "fireplace2", + kind: "major", + name: "Fireplace", + cost: { clay: 2 }, + vp: 1, + text: "Cook anytime: sheep 2, wild boar 2, cattle 3, vegetable 2 food. Bake bread: each grain becomes 2 food.", + cook: { sheep: 2, boar: 2, cattle: 3, vegetable: 2 }, + bake: { perGrain: 2, maxGrain: 99 }, + }, + { + id: "fireplace3", + kind: "major", + name: "Fireplace", + cost: { clay: 3 }, + vp: 1, + text: "Cook anytime: sheep 2, wild boar 2, cattle 3, vegetable 2 food. Bake bread: each grain becomes 2 food.", + cook: { sheep: 2, boar: 2, cattle: 3, vegetable: 2 }, + bake: { perGrain: 2, maxGrain: 99 }, + }, + { + id: "hearth4", + kind: "major", + name: "Cooking Hearth", + cost: { clay: 4 }, + vp: 1, + text: "Also buyable by returning a Fireplace. Cook anytime: sheep 2, wild boar 3, cattle 4, vegetable 3 food. Bake bread: each grain becomes 3 food.", + cook: { sheep: 2, boar: 3, cattle: 4, vegetable: 3 }, + bake: { perGrain: 3, maxGrain: 99 }, + }, + { + id: "hearth5", + kind: "major", + name: "Cooking Hearth", + cost: { clay: 5 }, + vp: 1, + text: "Also buyable by returning a Fireplace. Cook anytime: sheep 2, wild boar 3, cattle 4, vegetable 3 food. Bake bread: each grain becomes 3 food.", + cook: { sheep: 2, boar: 3, cattle: 4, vegetable: 3 }, + bake: { perGrain: 3, maxGrain: 99 }, + }, + { + id: "clay_oven", + kind: "major", + name: "Clay Oven", + cost: { clay: 3, stone: 1 }, + vp: 2, + text: "When you buy this, you may bake bread immediately. Bake bread: at most 1 grain becomes 5 food.", + bake: { perGrain: 5, maxGrain: 1 }, + }, + { + id: "stone_oven", + kind: "major", + name: "Stone Oven", + cost: { clay: 1, stone: 3 }, + vp: 3, + text: "When you buy this, you may bake bread immediately. Bake bread: at most 2 grain become 4 food each.", + bake: { perGrain: 4, maxGrain: 2 }, + }, + { + id: "joinery", + kind: "major", + name: "Joinery", + cost: { wood: 2, stone: 2 }, + vp: 2, + text: "Each harvest: may convert 1 wood to 2 food. End of game: 3/5/7 wood in your supply earn 1/2/3 bonus points.", + harvestFood: { from: "wood", food: 2, max: 1 }, + bonusVp: (p) => + tiered(stored(p, "wood"), [ + [3, 1], + [5, 2], + [7, 3], + ]), + }, + { + id: "pottery", + kind: "major", + name: "Pottery", + cost: { clay: 2, stone: 2 }, + vp: 2, + text: "Each harvest: may convert 1 clay to 2 food. End of game: 3/5/7 clay in your supply earn 1/2/3 bonus points.", + harvestFood: { from: "clay", food: 2, max: 1 }, + bonusVp: (p) => + tiered(stored(p, "clay"), [ + [3, 1], + [5, 2], + [7, 3], + ]), + }, + { + id: "basketmaker", + kind: "major", + name: "Basketmaker's Workshop", + cost: { reed: 2, stone: 2 }, + vp: 2, + text: "Each harvest: may convert 1 reed to 3 food. End of game: 2/4/5 reed in your supply earn 1/2/3 bonus points.", + harvestFood: { from: "reed", food: 3, max: 1 }, + bonusVp: (p) => + tiered(stored(p, "reed"), [ + [2, 1], + [4, 2], + [5, 3], + ]), + }, + { + id: "well", + kind: "major", + name: "Well", + cost: { wood: 1, stone: 3 }, + vp: 4, + text: "Place 1 food on each of the next 5 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 5); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "food", count: 1 }); + } + }, + }, +]; diff --git a/players/agricogla/symbolic-planner/src/engine/cards/minors.ts b/players/agricogla/symbolic-planner/src/engine/cards/minors.ts new file mode 100644 index 0000000..a312ce6 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/cards/minors.ts @@ -0,0 +1,560 @@ +import { CardDef, gain } from "./types"; + +/** The minor improvement deck. Card names and texts are this port's own; + * mechanics follow the base-deck archetypes. All effects are implemented. */ + +const GROWTH_ACTIONS = ["r_family_growth", "r_urgent_family"]; + +export const minors: CardDef[] = [ + // --- plows ---------------------------------------------------------------- + { + id: "min_wooden_plow", + kind: "minor", + name: "Wooden Plow", + cost: { wood: 1 }, + text: "On 2 future plow actions, you may plow 1 extra field each time.", + plowExtra: { fields: 1, uses: 2 }, + }, + { + id: "min_heavy_plow", + kind: "minor", + name: "Heavy Plow", + cost: { wood: 1, stone: 1 }, + vp: 1, + text: "On 3 future plow actions, you may plow 1 extra field each time.", + plowExtra: { fields: 1, uses: 3 }, + }, + // --- baking & cooking ----------------------------------------------------- + { + id: "min_hearth_stones", + kind: "minor", + name: "Hearth Stones", + cost: { stone: 1 }, + text: "When you bake bread, you may convert up to 2 grain into 2 food each with this card.", + bake: { perGrain: 2, maxGrain: 2 }, + }, + { + id: "min_dough_trough", + kind: "minor", + name: "Dough Trough", + cost: { wood: 1 }, + text: "When you bake bread, you may convert up to 1 grain into 2 food with this card.", + bake: { perGrain: 2, maxGrain: 1 }, + }, + { + id: "min_cooking_corner", + kind: "minor", + name: "Cooking Corner", + cost: { clay: 1 }, + text: "You can convert vegetables to 2 food and sheep to 2 food anytime.", + cook: { vegetable: 2, sheep: 2 }, + }, + { + id: "min_smokehouse", + kind: "minor", + name: "Smokehouse", + cost: { wood: 1, stone: 1 }, + vp: 1, + text: "You can convert wild boar to 3 food and sheep to 2 food anytime.", + cook: { boar: 3, sheep: 2 }, + }, + { + id: "min_stewpot", + kind: "minor", + name: "Stewpot", + cost: { clay: 1 }, + text: "You can convert sheep to 2 food, wild boar to 2 food and vegetables to 2 food anytime.", + cook: { sheep: 2, boar: 2, vegetable: 2 }, + }, + // --- animal capacity ------------------------------------------------------ + { + id: "min_animal_pen", + kind: "minor", + name: "Animal Pen", + cost: { wood: 1 }, + text: "This card can hold 2 animals of one type.", + capacity: () => [{ capacity: 2 }], + }, + { + id: "min_pig_sty", + kind: "minor", + name: "Pig Sty", + cost: { wood: 1 }, + text: "This card can hold 2 wild boar.", + capacity: () => [{ type: "boar", capacity: 2 }], + }, + { + id: "min_cattle_shed", + kind: "minor", + name: "Cattle Shed", + cost: { wood: 1, reed: 1 }, + vp: 1, + text: "This card can hold 2 cattle.", + capacity: () => [{ type: "cattle", capacity: 2 }], + }, + { + id: "min_sheep_fold", + kind: "minor", + name: "Sheep Fold", + cost: { wood: 1 }, + text: "This card can hold 2 sheep.", + capacity: () => [{ type: "sheep", capacity: 2 }], + }, + { + id: "min_paddock", + kind: "minor", + name: "Paddock", + cost: { wood: 2 }, + vp: 1, + prereq: { occupations: 1, label: "1 occupation" }, + text: "This card can hold 3 animals of one type.", + capacity: () => [{ capacity: 3 }], + }, + // --- scheduled goods on round spaces --------------------------------------- + { + id: "min_carp_pond", + kind: "minor", + name: "Carp Pond", + cost: { food: 1 }, + vp: 1, + prereq: { occupations: 2, label: "2 occupations" }, + text: "Place 1 food on each of the next 4 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 4); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "food", count: 1 }); + } + }, + }, + { + id: "min_reed_pond", + kind: "minor", + name: "Reed Pond", + cost: { food: 1 }, + vp: 1, + prereq: { occupations: 3, label: "3 occupations" }, + text: "Place 1 reed on each of the next 3 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 3); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "reed", count: 1 }); + } + }, + }, + { + id: "min_clay_deposit", + kind: "minor", + name: "Clay Deposit", + cost: { food: 1 }, + text: "Place 1 clay on each of the next 3 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 3); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "clay", count: 1 }); + } + }, + }, + { + id: "min_wood_cache", + kind: "minor", + name: "Wood Cache", + cost: { food: 1 }, + text: "Place 1 wood on each of the next 3 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 3); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "wood", count: 1 }); + } + }, + }, + { + id: "min_seed_stock", + kind: "minor", + name: "Seed Stock", + cost: { food: 1 }, + text: "Place 1 grain on each of the next 2 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 2); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "grain", count: 1 }); + } + }, + }, + // --- immediate gains -------------------------------------------------------- + { + id: "min_wood_cart", + kind: "minor", + name: "Wood Cart", + cost: { food: 1 }, + prereq: { occupations: 1, label: "1 occupation" }, + text: "When played, gain 3 wood.", + onPlay: (ctx) => { + ctx.player.resources.wood += 3; + }, + }, + { + id: "min_clay_pit_claim", + kind: "minor", + name: "Clay Pit Claim", + cost: { food: 1 }, + text: "When played, gain 2 clay.", + onPlay: (ctx) => { + ctx.player.resources.clay += 2; + }, + }, + { + id: "min_market_stall", + kind: "minor", + name: "Market Stall", + cost: { grain: 1 }, + text: "When played, gain 1 vegetable and 1 food.", + onPlay: (ctx) => { + ctx.player.resources.vegetable += 1; + ctx.player.resources.food += 1; + }, + }, + { + id: "min_stone_cart", + kind: "minor", + name: "Stone Cart", + cost: { wood: 1 }, + prereq: { occupations: 2, label: "2 occupations" }, + text: "When played, gain 2 stone.", + onPlay: (ctx) => { + ctx.player.resources.stone += 2; + }, + }, + // --- gains on actions ------------------------------------------------------- + { + id: "min_threshing_board", + kind: "minor", + name: "Threshing Board", + cost: { wood: 1 }, + vp: 1, + prereq: { occupations: 2, label: "2 occupations" }, + text: "Whenever you use the Grain Seeds space, take 1 extra grain.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "grain_seeds") gain(gains, "grain", 1); + }, + }, + { + id: "min_fish_weir", + kind: "minor", + name: "Fish Weir", + cost: { reed: 1 }, + text: "Whenever you use the Fishing space, take 1 extra food.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "fishing") gain(gains, "food", 1); + }, + }, + { + id: "min_handcart", + kind: "minor", + name: "Handcart", + cost: { wood: 2 }, + vp: 1, + text: "Whenever you take wood from an action space, take 1 extra wood.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.wood ?? 0) > 0) gain(gains, "wood", 1); + }, + }, + { + id: "min_shepherds_crook", + kind: "minor", + name: "Shepherd's Crook", + cost: { wood: 1 }, + text: "Whenever you take sheep from an action space, take 1 extra sheep.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.sheep ?? 0) > 0) gain(gains, "sheep", 1); + }, + }, + { + id: "min_pig_trough", + kind: "minor", + name: "Pig Trough", + cost: { wood: 1 }, + text: "Whenever you take wild boar from an action space, take 1 extra wild boar.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.boar ?? 0) > 0) gain(gains, "boar", 1); + }, + }, + { + id: "min_cattle_prod", + kind: "minor", + name: "Drover's Staff", + cost: { wood: 1, stone: 1 }, + vp: 1, + prereq: { occupations: 1, label: "1 occupation" }, + text: "Whenever you take cattle from an action space, take 1 extra cattle.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.cattle ?? 0) > 0) gain(gains, "cattle", 1); + }, + }, + // --- building discounts ----------------------------------------------------- + { + id: "min_timber_yard", + kind: "minor", + name: "Timber Yard", + cost: { food: 1 }, + vp: 1, + prereq: { occupations: 2, label: "2 occupations" }, + text: "Wooden rooms cost you 1 less wood.", + roomDiscount: (material) => (material === "wood" ? { wood: 1 } : {}), + }, + { + id: "min_clay_works", + kind: "minor", + name: "Clay Works", + cost: { food: 1 }, + vp: 1, + prereq: { occupations: 2, label: "2 occupations" }, + text: "Clay rooms cost you 1 less clay.", + roomDiscount: (material) => (material === "clay" ? { clay: 1 } : {}), + }, + { + id: "min_stone_yard", + kind: "minor", + name: "Stone Yard", + cost: { food: 1 }, + vp: 1, + prereq: { occupations: 2, label: "2 occupations" }, + text: "Stone rooms cost you 1 less stone.", + roomDiscount: (material) => (material === "stone" ? { stone: 1 } : {}), + }, + { + id: "min_fence_posts", + kind: "minor", + name: "Fence Posts", + cost: { wood: 1 }, + text: "Each time you build fences, 1 of those fences is free.", + freeFences: 1, + }, + // --- harvest helpers -------------------------------------------------------- + { + id: "min_herb_garden", + kind: "minor", + name: "Herb Garden", + cost: { wood: 1 }, + vp: 1, + text: "At each harvest, gain 1 food if you have at least 1 field.", + onHarvest: (ctx) => { + if (ctx.player.spaces.some((s) => s.kind === "field")) { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Herb Garden)`); + } + }, + }, + { + id: "min_milk_pail", + kind: "minor", + name: "Milk Pail", + cost: { wood: 1 }, + text: "At each harvest, gain 1 food for every 3 cattle you have.", + onHarvest: (ctx) => { + const food = Math.floor(ctx.player.animals.cattle / 3); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Milk Pail)`); + } + }, + }, + { + id: "min_shearing_shears", + kind: "minor", + name: "Shearing Shears", + cost: { wood: 1 }, + vp: 1, + text: "At each harvest, gain 1 food for every 4 sheep you have.", + onHarvest: (ctx) => { + const food = Math.floor(ctx.player.animals.sheep / 4); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Shearing Shears)`); + } + }, + }, + { + id: "min_drying_shed", + kind: "minor", + name: "Drying Shed", + cost: { clay: 1 }, + text: "At each harvest, gain 1 food if you have at least 2 animal types on your farm.", + onHarvest: (ctx) => { + const types = (["sheep", "boar", "cattle"] as const).filter( + (t) => ctx.player.animals[t] > 0, + ).length; + if (types >= 2) { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Drying Shed)`); + } + }, + }, + // --- round-start engines ---------------------------------------------------- + { + id: "min_dovecote", + kind: "minor", + name: "Dovecote", + cost: { stone: 2 }, + vp: 2, + text: "At the start of each round from round 10 on, gain 1 food.", + onRoundStart: (ctx, round) => { + if (round >= 10) { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Dovecote)`); + } + }, + }, + { + id: "min_rain_barrel", + kind: "minor", + name: "Rain Barrel", + cost: { wood: 1 }, + text: "At the start of each round from round 8 on, gain 1 food every even round.", + onRoundStart: (ctx, round) => { + if (round >= 8 && round % 2 === 0) { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Rain Barrel)`); + } + }, + }, + // --- family ---------------------------------------------------------------- + { + id: "min_cradle", + kind: "minor", + name: "Cradle", + cost: { wood: 1 }, + vp: 1, + text: "After each family growth action you take, gain 2 food.", + onAction: (ctx, spaceId) => { + if (GROWTH_ACTIONS.includes(spaceId)) { + ctx.player.resources.food += 2; + ctx.emit("card", `${ctx.player.name} gains 2 food (Cradle)`); + } + }, + }, + { + id: "min_toy_chest", + kind: "minor", + name: "Toy Chest", + cost: { wood: 1 }, + text: "End of game: 1 bonus point for each family member beyond the first 3.", + bonusVp: (p) => Math.max(0, p.family.length - 3), + }, + // --- pure / bonus points ----------------------------------------------------- + { + id: "min_carved_chest", + kind: "minor", + name: "Carved Chest", + cost: { wood: 2 }, + vp: 2, + prereq: { occupations: 1, label: "1 occupation" }, + text: "A fine piece of furniture. Worth 2 points.", + }, + { + id: "min_pewter_jug", + kind: "minor", + name: "Pewter Jug", + cost: { stone: 1 }, + vp: 1, + text: "Worth 1 point.", + }, + { + id: "min_gabled_house", + kind: "minor", + name: "Gabled Roof", + cost: { wood: 1, reed: 1 }, + vp: 3, + prereq: { + label: "stone house", + check: (p) => p.houseMaterial === "stone", + }, + text: "Requires a stone house. Worth 3 points.", + }, + { + id: "min_grain_loft", + kind: "minor", + name: "Grain Loft", + cost: { wood: 1, clay: 1 }, + vp: 1, + text: "End of game: 1 bonus point for every 4 grain you have (in supply and on fields).", + bonusVp: (p) => { + const grain = + p.resources.grain + + p.spaces.reduce((s, sp) => s + (sp.crop === "grain" ? sp.cropCount : 0), 0); + return Math.floor(grain / 4); + }, + }, + { + id: "min_root_cellar", + kind: "minor", + name: "Root Cellar", + cost: { stone: 1 }, + vp: 1, + text: "End of game: 1 bonus point for every 3 vegetables you have (in supply and on fields).", + bonusVp: (p) => { + const veg = + p.resources.vegetable + + p.spaces.reduce((s, sp) => s + (sp.crop === "vegetable" ? sp.cropCount : 0), 0); + return Math.floor(veg / 3); + }, + }, + { + id: "min_hunting_trophies", + kind: "minor", + name: "Hunting Trophies", + cost: { wood: 1 }, + text: "End of game: 1 bonus point for each animal type of which you have at least 5.", + bonusVp: (p) => + (p.animals.sheep >= 5 ? 1 : 0) + + (p.animals.boar >= 5 ? 1 : 0) + + (p.animals.cattle >= 5 ? 1 : 0), + }, + { + id: "min_scarecrow", + kind: "minor", + name: "Scarecrow", + cost: { wood: 1 }, + vp: 1, + text: "End of game: 1 bonus point if at least 2 of your fields are sown.", + bonusVp: (p) => + p.spaces.filter((s) => s.kind === "field" && s.cropCount > 0).length >= 2 ? 1 : 0, + }, + { + id: "min_well_bucket", + kind: "minor", + name: "Well Bucket", + cost: { wood: 1 }, + text: "End of game: 2 bonus points if you own the Well.", + bonusVp: (p) => (p.majors.includes("well") ? 2 : 0), + }, + // --- traveling (passing) cards ---------------------------------------------- + { + id: "min_lending_cart", + kind: "minor", + name: "Lending Cart", + cost: {}, + passing: true, + text: "When played, gain 2 wood. Then pass this card to the player on your left.", + onPlay: (ctx) => { + ctx.player.resources.wood += 2; + }, + }, + { + id: "min_traveling_tinker", + kind: "minor", + name: "Traveling Tinker", + cost: {}, + passing: true, + text: "When played, gain 1 clay and 1 food. Then pass this card to the player on your left.", + onPlay: (ctx) => { + ctx.player.resources.clay += 1; + ctx.player.resources.food += 1; + }, + }, + { + id: "min_seed_swap", + kind: "minor", + name: "Seed Swap", + cost: {}, + passing: true, + text: "When played, gain 1 grain. Then pass this card to the player on your left.", + onPlay: (ctx) => { + ctx.player.resources.grain += 1; + }, + }, +]; diff --git a/players/agricogla/symbolic-planner/src/engine/cards/occupations.ts b/players/agricogla/symbolic-planner/src/engine/cards/occupations.ts new file mode 100644 index 0000000..19f9b6a --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/cards/occupations.ts @@ -0,0 +1,496 @@ +import { CardDef, gain } from "./types"; +import { computePastures } from "../farmyard"; + +/** The occupation deck. Card names and texts are this port's own; mechanics + * follow the archetypes of the base game decks (resource bonuses, plows, + * cooking, capacity, bonus points, harvest engines). Every effect below is + * fully implemented through the hook system. */ + +const RENOVATE_ACTIONS = ["r_renovate_improve", "r_redevelop"]; +const GROWTH_ACTIONS = ["r_family_growth", "r_urgent_family"]; + +export const occupations: CardDef[] = [ + // --- gains on resource actions ------------------------------------------- + { + id: "occ_lumberjack", + kind: "occupation", + name: "Lumberjack", + text: "Whenever you take wood from an action space, take 1 extra wood.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.wood ?? 0) > 0) gain(gains, "wood", 1); + }, + }, + { + id: "occ_clay_digger", + kind: "occupation", + name: "Clay Digger", + text: "Whenever you take clay from an action space, take 1 extra clay.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.clay ?? 0) > 0) gain(gains, "clay", 1); + }, + }, + { + id: "occ_reed_gatherer", + kind: "occupation", + name: "Reed Gatherer", + text: "Whenever you take reed from an action space, take 1 extra reed.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.reed ?? 0) > 0) gain(gains, "reed", 1); + }, + }, + { + id: "occ_quarryman", + kind: "occupation", + name: "Quarryman", + text: "Whenever you take stone from an action space, take 1 extra stone.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.stone ?? 0) > 0) gain(gains, "stone", 1); + }, + }, + { + id: "occ_angler", + kind: "occupation", + name: "Angler", + text: "Whenever you use the Fishing space, take 2 extra food.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "fishing") gain(gains, "food", 2); + }, + }, + { + id: "occ_seasonal_hand", + kind: "occupation", + name: "Seasonal Hand", + text: "Whenever you use the Day Laborer space, also take 1 grain.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "day_laborer") gain(gains, "grain", 1); + }, + }, + { + id: "occ_peddler", + kind: "occupation", + name: "Peddler", + text: "Whenever you use the Day Laborer space, also take 1 clay.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "day_laborer") gain(gains, "clay", 1); + }, + }, + { + id: "occ_greengrocer", + kind: "occupation", + name: "Greengrocer", + text: "Whenever you use the Grain Seeds space, also take 1 vegetable.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "grain_seeds") gain(gains, "vegetable", 1); + }, + }, + { + id: "occ_quarry_foreman", + kind: "occupation", + name: "Quarry Foreman", + text: "Whenever you take stone from an action space, also take 1 food.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.stone ?? 0) > 0) gain(gains, "food", 1); + }, + }, + { + id: "occ_shepherds_friend", + kind: "occupation", + name: "Shepherd's Friend", + text: "Whenever you take sheep from an action space, take 1 extra sheep.", + onGain: (_ctx, _spaceId, gains) => { + if ((gains.sheep ?? 0) > 0) gain(gains, "sheep", 1); + }, + }, + { + id: "occ_wayfarer", + kind: "occupation", + name: "Wayfarer", + text: "Whenever you use Traveling Players, take 1 extra food and 1 wood.", + onGain: (_ctx, spaceId, gains) => { + if (spaceId === "traveling_players") { + gain(gains, "food", 1); + gain(gains, "wood", 1); + } + }, + }, + // --- cooking and baking --------------------------------------------------- + { + id: "occ_butcher", + kind: "occupation", + name: "Butcher", + text: "You can convert animals to food anytime: sheep 2, wild boar 2, cattle 3.", + cook: { sheep: 2, boar: 2, cattle: 3 }, + }, + { + id: "occ_charcutier", + kind: "occupation", + name: "Charcutier", + text: "You can convert wild boar to 3 food anytime.", + cook: { boar: 3 }, + }, + { + id: "occ_mutton_cook", + kind: "occupation", + name: "Mutton Cook", + text: "You can convert sheep to 3 food anytime.", + cook: { sheep: 3 }, + }, + { + id: "occ_field_cook", + kind: "occupation", + name: "Field Cook", + text: "You can convert vegetables to 2 food anytime.", + cook: { vegetable: 2 }, + }, + { + id: "occ_baker", + kind: "occupation", + name: "Baker", + text: "When you bake bread, you may convert up to 1 grain into 2 food with this card.", + bake: { perGrain: 2, maxGrain: 1 }, + }, + { + id: "occ_miller", + kind: "occupation", + name: "Miller", + text: "When you bake bread, you may convert up to 1 grain into 3 food with this card.", + prereq: { occupations: 1, label: "1 occupation" }, + bake: { perGrain: 3, maxGrain: 1 }, + }, + // --- plows ---------------------------------------------------------------- + { + id: "occ_plowwright", + kind: "occupation", + name: "Plowwright", + text: "On 3 future plow actions, you may plow 1 extra field each time.", + plowExtra: { fields: 1, uses: 3 }, + }, + { + id: "occ_furrow_master", + kind: "occupation", + name: "Furrow Master", + text: "On 1 future plow action, you may plow 2 extra fields.", + plowExtra: { fields: 2, uses: 1 }, + }, + // --- animal capacity ------------------------------------------------------ + { + id: "occ_stablemaster", + kind: "occupation", + name: "Stablemaster", + text: "Your home can hold 1 additional animal of any type.", + capacity: () => [{ capacity: 1 }], + }, + { + id: "occ_swineherd", + kind: "occupation", + name: "Swineherd", + text: "This card can hold 2 wild boar.", + capacity: () => [{ type: "boar", capacity: 2 }], + }, + { + id: "occ_cowherd", + kind: "occupation", + name: "Cowherd", + text: "This card can hold 2 cattle.", + capacity: () => [{ type: "cattle", capacity: 2 }], + }, + { + id: "occ_shepherd", + kind: "occupation", + name: "Shepherd", + text: "This card can hold 2 sheep.", + capacity: () => [{ type: "sheep", capacity: 2 }], + }, + // --- building ------------------------------------------------------------- + { + id: "occ_carpenter", + kind: "occupation", + name: "Carpenter", + text: "Wooden rooms cost you 2 less wood.", + roomDiscount: (material) => (material === "wood" ? { wood: 2 } : {}), + }, + { + id: "occ_bricklayer", + kind: "occupation", + name: "Bricklayer", + text: "Clay rooms cost you 2 less clay.", + roomDiscount: (material) => (material === "clay" ? { clay: 2 } : {}), + }, + { + id: "occ_mason", + kind: "occupation", + name: "Mason", + text: "Stone rooms cost you 2 less stone.", + roomDiscount: (material) => (material === "stone" ? { stone: 2 } : {}), + }, + { + id: "occ_thatcher", + kind: "occupation", + name: "Thatcher", + text: "Rooms cost you 1 less reed.", + roomDiscount: () => ({ reed: 1 }), + }, + { + id: "occ_hedge_warden", + kind: "occupation", + name: "Hedge Warden", + text: "Each time you build fences, 2 of those fences are free.", + freeFences: 2, + }, + { + id: "occ_stable_boy", + kind: "occupation", + name: "Stable Boy", + text: "After you use a renovation action, take 1 reed.", + onAction: (ctx, spaceId) => { + if (RENOVATE_ACTIONS.includes(spaceId)) { + ctx.player.resources.reed += 1; + ctx.emit("card", `${ctx.player.name} takes 1 reed (Stable Boy)`); + } + }, + }, + // --- family --------------------------------------------------------------- + { + id: "occ_midwife", + kind: "occupation", + name: "Midwife", + text: "After each family growth action you take, gain 2 food.", + onAction: (ctx, spaceId) => { + if (GROWTH_ACTIONS.includes(spaceId)) { + ctx.player.resources.food += 2; + ctx.emit("card", `${ctx.player.name} gains 2 food (Midwife)`); + } + }, + }, + { + id: "occ_patriarch", + kind: "occupation", + name: "Patriarch", + text: "End of game: 2 bonus points if your family has 5 members.", + bonusVp: (p) => (p.family.length >= 5 ? 2 : 0), + }, + // --- harvest engines ------------------------------------------------------ + { + id: "occ_forager", + kind: "occupation", + name: "Forager", + text: "At the start of each harvest, gain 1 food.", + onHarvest: (ctx) => { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Forager)`); + }, + }, + { + id: "occ_gleaner", + kind: "occupation", + name: "Gleaner", + text: "At each harvest, gain 1 food for every 2 of your sown fields.", + onHarvest: (ctx) => { + const sown = ctx.player.spaces.filter((s) => s.kind === "field" && s.cropCount > 0).length; + const food = Math.floor(sown / 2); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Gleaner)`); + } + }, + }, + { + id: "occ_milkman", + kind: "occupation", + name: "Milkman", + text: "At each harvest, gain 1 food for every 2 cattle you have.", + onHarvest: (ctx) => { + const food = Math.floor(ctx.player.animals.cattle / 2); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Milkman)`); + } + }, + }, + { + id: "occ_cheesemaker", + kind: "occupation", + name: "Cheesemaker", + text: "At each harvest, gain 1 food for every 3 sheep you have.", + onHarvest: (ctx) => { + const food = Math.floor(ctx.player.animals.sheep / 3); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Cheesemaker)`); + } + }, + }, + { + id: "occ_swine_keeper", + kind: "occupation", + name: "Swine Keeper", + text: "At each harvest, gain 1 food for every 3 wild boar you have.", + onHarvest: (ctx) => { + const food = Math.floor(ctx.player.animals.boar / 3); + if (food > 0) { + ctx.player.resources.food += food; + ctx.emit("card", `${ctx.player.name} gains ${food} food (Swine Keeper)`); + } + }, + }, + // --- round-start engines -------------------------------------------------- + { + id: "occ_grain_steward", + kind: "occupation", + name: "Grain Steward", + text: "When played, place 1 grain on each of the next 3 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 3); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "grain", count: 1 }); + } + }, + }, + { + id: "occ_water_carrier", + kind: "occupation", + name: "Water Carrier", + text: "When played, place 1 food on each of the next 4 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 4); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "food", count: 1 }); + } + }, + }, + { + id: "occ_woodward", + kind: "occupation", + name: "Woodward", + text: "When played, place 1 wood on each of the next 3 round spaces; collect them as those rounds begin.", + onPlay: (ctx) => { + for (let r = ctx.state.round + 1; r <= Math.min(14, ctx.state.round + 3); r++) { + ctx.state.scheduled.push({ round: r, playerIdx: ctx.player.idx, good: "wood", count: 1 }); + } + }, + }, + // --- one-time gains ------------------------------------------------------- + { + id: "occ_vagrant", + kind: "occupation", + name: "Vagrant", + text: "When played, gain 3 food.", + onPlay: (ctx) => { + ctx.player.resources.food += 3; + }, + }, + { + id: "occ_journeyman", + kind: "occupation", + name: "Journeyman", + text: "When played, gain 1 wood, 1 clay and 1 reed.", + onPlay: (ctx) => { + ctx.player.resources.wood += 1; + ctx.player.resources.clay += 1; + ctx.player.resources.reed += 1; + }, + }, + { + id: "occ_seed_merchant", + kind: "occupation", + name: "Seed Merchant", + text: "When played, gain 1 grain. Whenever you sow, gain 1 food.", + onPlay: (ctx) => { + ctx.player.resources.grain += 1; + }, + onAction: (ctx, spaceId) => { + if (spaceId === "r_sow_bake" || spaceId === "r_cultivation") { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Seed Merchant)`); + } + }, + }, + { + id: "occ_veg_peddler", + kind: "occupation", + name: "Vegetable Peddler", + text: "When played, gain 1 vegetable.", + prereq: { occupations: 1, label: "1 occupation" }, + onPlay: (ctx) => { + ctx.player.resources.vegetable += 1; + }, + }, + // --- bonus points --------------------------------------------------------- + { + id: "occ_schoolmaster", + kind: "occupation", + name: "Schoolmaster", + text: "End of game: 1 bonus point for each occupation you played after this one.", + bonusVp: (p) => { + const idx = p.occupations.indexOf("occ_schoolmaster"); + return idx < 0 ? 0 : p.occupations.length - idx - 1; + }, + }, + { + id: "occ_elder", + kind: "occupation", + name: "Village Elder", + text: "End of game: 1 bonus point for every 2 improvements you have in play.", + bonusVp: (p) => Math.floor((p.minors.length + p.majors.length) / 2), + }, + { + id: "occ_surveyor", + kind: "occupation", + name: "Estate Surveyor", + text: "End of game: 2 bonus points if your farmyard has no unused spaces.", + bonusVp: (p) => { + const layout = computePastures(p.spaces, p.fences); + const unused = p.spaces.filter( + (sp, i) => sp.kind === "empty" && !sp.stable && !layout.pastureCells.has(i), + ).length; + return unused === 0 ? 2 : 0; + }, + }, + { + id: "occ_animal_breeder", + kind: "occupation", + name: "Animal Breeder", + text: "End of game: 1 bonus point for each animal type of which you have at least 6.", + bonusVp: (p) => + (p.animals.sheep >= 6 ? 1 : 0) + + (p.animals.boar >= 6 ? 1 : 0) + + (p.animals.cattle >= 6 ? 1 : 0), + }, + { + id: "occ_master_builder", + kind: "occupation", + name: "Master Builder", + text: "End of game: 2 bonus points if you have at least 5 rooms.", + bonusVp: (p) => (p.spaces.filter((s) => s.kind === "room").length >= 5 ? 2 : 0), + }, + { + id: "occ_horticulturist", + kind: "occupation", + name: "Horticulturist", + text: "End of game: 1 bonus point for every 2 fields you have.", + bonusVp: (p) => Math.floor(p.spaces.filter((s) => s.kind === "field").length / 2), + }, + // --- misc action triggers --------------------------------------------------- + { + id: "occ_compost_carter", + kind: "occupation", + name: "Compost Carter", + text: "After each plow action you take, gain 1 food.", + onAction: (ctx, spaceId) => { + if (spaceId === "farmland" || spaceId === "r_cultivation") { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Compost Carter)`); + } + }, + }, + { + id: "occ_fence_hand", + kind: "occupation", + name: "Fence Hand", + text: "After each fences action you take, gain 1 food.", + onAction: (ctx, spaceId) => { + if (spaceId === "r_fences" || spaceId === "r_redevelop") { + ctx.player.resources.food += 1; + ctx.emit("card", `${ctx.player.name} gains 1 food (Fence Hand)`); + } + }, + }, +]; diff --git a/players/agricogla/symbolic-planner/src/engine/cards/types.ts b/players/agricogla/symbolic-planner/src/engine/cards/types.ts new file mode 100644 index 0000000..431d0af --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/cards/types.ts @@ -0,0 +1,72 @@ +import { CapacitySlot } from "../farmyard"; +import { AnimalType, GameState, Good, Goods, HouseMaterial, PlayerState, Resource } from "../types"; + +/** Context passed to card hooks. Hooks mutate state directly (the engine works + * on a cloned state per step) and append log events via `emit`. */ +export interface HookCtx { + state: GameState; + player: PlayerState; + emit: (type: string, text: string) => void; +} + +/** Anytime cooking rates: food per unit converted. */ +export type CookRates = Partial>; + +export interface BakeRates { + /** Food per grain baked. */ + perGrain: number; + /** Max grain converted per bake-bread action. */ + maxGrain: number; +} + +export interface HarvestFood { + from: Resource; + food: number; + /** Max units convertible per harvest. */ + max: number; +} + +export interface Prereq { + occupations?: number; + label: string; + check?: (p: PlayerState, s: GameState) => boolean; +} + +export interface CardDef { + id: string; + kind: "occupation" | "minor" | "major"; + name: string; + /** Rules text shown in the UI (this port's own wording). */ + text: string; + cost?: Partial>; + vp?: number; + prereq?: Prereq; + /** Traveling cards: passed to the left-hand neighbor after being played. */ + passing?: boolean; + + onPlay?: (ctx: HookCtx) => void; + /** Fires for every played card at the start of each round (after reveal). */ + onRoundStart?: (ctx: HookCtx, round: number) => void; + /** Adjust goods gained from an action space before they are received. */ + onGain?: (ctx: HookCtx, spaceId: string, gains: Goods) => void; + /** Fires after the owning player resolves the named action space. */ + onAction?: (ctx: HookCtx, spaceId: string) => void; + /** Fires during the harvest field phase. */ + onHarvest?: (ctx: HookCtx) => void; + + cook?: CookRates; + bake?: BakeRates; + harvestFood?: HarvestFood; + capacity?: (p: PlayerState) => CapacitySlot[]; + bonusVp?: (p: PlayerState, s: GameState) => number; + /** Extra fields plowable when taking a plow action (plow improvements). */ + plowExtra?: { fields: number; uses: number }; + /** Free fences granted on each fences action. */ + freeFences?: number; + /** Discount per room when building rooms. */ + roomDiscount?: (material: HouseMaterial) => Goods; +} + +export function gain(gains: Goods, good: Good, n: number): void { + gains[good] = (gains[good] ?? 0) + n; +} diff --git a/players/agricogla/symbolic-planner/src/engine/effects.ts b/players/agricogla/symbolic-planner/src/engine/effects.ts new file mode 100644 index 0000000..1a8e109 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/effects.ts @@ -0,0 +1,110 @@ +import { cardById } from "./cards"; +import { CardDef, HookCtx } from "./cards/types"; +import { CapacitySlot, maxRetention } from "./farmyard"; +import { AnimalType, ANIMALS, GameState, GameEvent, Goods, PlayerState } from "./types"; + +export function emit( + state: GameState, + playerIdx: number | null, + type: string, + text: string, +): GameEvent { + const ev: GameEvent = { round: state.round, playerIdx, type, text }; + state.log.push(ev); + return ev; +} + +export function hookCtx(state: GameState, player: PlayerState): HookCtx { + return { + state, + player, + emit: (type, text) => void emit(state, player.idx, type, text), + }; +} + +/** All cards in front of the player (occupations, minors, majors). */ +export function playedCards(player: PlayerState): CardDef[] { + return [...player.occupations, ...player.minors, ...player.majors].map(cardById); +} + +export function capacitySlots(player: PlayerState): CapacitySlot[] { + const out: CapacitySlot[] = []; + for (const card of playedCards(player)) { + if (card.capacity) out.push(...card.capacity(player)); + } + return out; +} + +/** Best anytime cooking rate for an animal type, with the card providing it. */ +export function bestCookRate( + player: PlayerState, + type: AnimalType, +): { card: CardDef; food: number } | null { + let best: { card: CardDef; food: number } | null = null; + for (const card of playedCards(player)) { + const rate = card.cook?.[type]; + if (rate && (!best || rate > best.food)) best = { card, food: rate }; + } + return best; +} + +/** Receive goods from an action space: applies onGain card hooks, then adds + * resources to the supply and animals to the farm (auto-packed). Overflow + * animals are cooked at the best available rate, otherwise released. */ +export function gainGoods( + state: GameState, + player: PlayerState, + spaceId: string, + goods: Goods, +): void { + const gains: Goods = { ...goods }; + const ctx = hookCtx(state, player); + for (const card of playedCards(player)) { + card.onGain?.(ctx, spaceId, gains); + } + const animalGains: Partial> = {}; + for (const [good, n] of Object.entries(gains)) { + if (!n) continue; + if ((ANIMALS as readonly string[]).includes(good)) { + animalGains[good as AnimalType] = n; + } else { + player.resources[good as keyof PlayerState["resources"]] += n; + } + } + if (Object.keys(animalGains).length > 0) takeAnimals(state, player, animalGains); +} + +/** Add animals to the farm, auto-packing; cook or release what cannot fit. */ +export function takeAnimals( + state: GameState, + player: PlayerState, + gained: Partial>, +): void { + const counts: Record = { ...player.animals }; + for (const t of ANIMALS) counts[t] += gained[t] ?? 0; + const holding = maxRetention(player, counts, capacitySlots(player)); + for (const t of ANIMALS) { + const overflow = counts[t] - holding.retained[t]; + player.animals[t] = holding.retained[t]; + if (overflow > 0) { + const cook = bestCookRate(player, t); + if (cook) { + player.resources.food += cook.food * overflow; + emit( + state, + player.idx, + "cook", + `${player.name} cooks ${overflow} ${t} for ${cook.food * overflow} food (no room)`, + ); + } else { + emit(state, player.idx, "release", `${player.name} releases ${overflow} ${t} (no room)`); + } + } + } +} + +/** Can the player accommodate these totals (with rearranging)? */ +export function canAccommodate(player: PlayerState, counts: Record): boolean { + const holding = maxRetention(player, counts, capacitySlots(player)); + return ANIMALS.every((t) => holding.retained[t] >= counts[t]); +} diff --git a/players/agricogla/symbolic-planner/src/engine/farmyard.ts b/players/agricogla/symbolic-planner/src/engine/farmyard.ts new file mode 100644 index 0000000..f4f7c89 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/farmyard.ts @@ -0,0 +1,280 @@ +import { + ANIMALS, + AnimalType, + COLS, + EdgeId, + FarmSpace, + NUM_SPACES, + PlayerState, + ROWS, + colOf, + rowOf, + spaceIndex, +} from "./types"; + +/** Horizontal edge above row r (r 0..ROWS) at column c. */ +export function hEdge(r: number, c: number): EdgeId { + return `h-${r}-${c}`; +} + +/** Vertical edge left of column c (c 0..COLS) at row r. */ +export function vEdge(r: number, c: number): EdgeId { + return `v-${r}-${c}`; +} + +export function allEdges(): EdgeId[] { + const out: EdgeId[] = []; + for (let r = 0; r <= ROWS; r++) for (let c = 0; c < COLS; c++) out.push(hEdge(r, c)); + for (let r = 0; r < ROWS; r++) for (let c = 0; c <= COLS; c++) out.push(vEdge(r, c)); + return out; +} + +export function isValidEdge(e: EdgeId): boolean { + const m = /^([hv])-(\d+)-(\d+)$/.exec(e); + if (!m) return false; + const r = Number(m[2]); + const c = Number(m[3]); + if (m[1] === "h") return r >= 0 && r <= ROWS && c >= 0 && c < COLS; + return r >= 0 && r < ROWS && c >= 0 && c <= COLS; +} + +/** The four edges around a cell. */ +export function edgesOfCell(space: number): EdgeId[] { + const r = rowOf(space); + const c = colOf(space); + return [hEdge(r, c), hEdge(r + 1, c), vEdge(r, c), vEdge(r, c + 1)]; +} + +/** Cells on each side of an edge; null = outside the farmyard. */ +export function cellsOfEdge(e: EdgeId): [number | null, number | null] { + const m = /^([hv])-(\d+)-(\d+)$/.exec(e); + if (!m) throw new Error(`bad edge id: ${e}`); + const r = Number(m[2]); + const c = Number(m[3]); + if (m[1] === "h") { + const above = r - 1 >= 0 ? spaceIndex(r - 1, c) : null; + const below = r < ROWS ? spaceIndex(r, c) : null; + return [above, below]; + } + const left = c - 1 >= 0 ? spaceIndex(r, c - 1) : null; + const right = c < COLS ? spaceIndex(r, c) : null; + return [left, right]; +} + +export function neighborsOf(space: number): number[] { + const r = rowOf(space); + const c = colOf(space); + const out: number[] = []; + if (r > 0) out.push(spaceIndex(r - 1, c)); + if (r < ROWS - 1) out.push(spaceIndex(r + 1, c)); + if (c > 0) out.push(spaceIndex(r, c - 1)); + if (c < COLS - 1) out.push(spaceIndex(r, c + 1)); + return out; +} + +export interface Pasture { + cells: number[]; + stables: number; + capacity: number; +} + +export interface PastureLayout { + pastures: Pasture[]; + /** Spaces that belong to some pasture. */ + pastureCells: Set; +} + +/** Compute enclosed regions given the fence set. Regions connected to the + * exterior (through any unfenced border or unfenced inner edge chain) are not + * enclosed. */ +export function computePastures(spaces: FarmSpace[], fences: EdgeId[]): PastureLayout { + const fenceSet = new Set(fences); + const EXTERIOR = -1; + const parent = new Map(); + const find = (x: number): number => { + let r = x; + while (parent.get(r) !== r) r = parent.get(r)!; + let cur = x; + while (parent.get(cur) !== cur) { + const next = parent.get(cur)!; + parent.set(cur, r); + cur = next; + } + return r; + }; + const union = (a: number, b: number) => { + parent.set(find(a), find(b)); + }; + parent.set(EXTERIOR, EXTERIOR); + for (let i = 0; i < NUM_SPACES; i++) parent.set(i, i); + + for (const e of allEdges()) { + if (fenceSet.has(e)) continue; + const [a, b] = cellsOfEdge(e); + union(a ?? EXTERIOR, b ?? EXTERIOR); + } + + const regions = new Map(); + for (let i = 0; i < NUM_SPACES; i++) { + const root = find(i); + if (root === find(EXTERIOR)) continue; + const list = regions.get(root) ?? []; + list.push(i); + regions.set(root, list); + } + + const pastures: Pasture[] = []; + const pastureCells = new Set(); + for (const cells of regions.values()) { + const stables = cells.filter((c) => spaces[c]!.stable).length; + pastures.push({ + cells: cells.sort((a, b) => a - b), + stables, + capacity: 2 * cells.length * 2 ** stables, + }); + for (const c of cells) pastureCells.add(c); + } + pastures.sort((a, b) => a.cells[0]! - b.cells[0]!); + return { pastures, pastureCells }; +} + +export interface FencePlanResult { + ok: boolean; + error?: string; + layout?: PastureLayout; +} + +/** Validate adding `newEdges` to the player's fences (rules 5.5). */ +export function validateFencePlan(player: PlayerState, newEdges: EdgeId[]): FencePlanResult { + if (newEdges.length === 0) return { ok: false, error: "must build at least 1 fence" }; + const existing = new Set(player.fences); + const seen = new Set(); + for (const e of newEdges) { + if (!isValidEdge(e)) return { ok: false, error: `invalid fence edge ${e}` }; + if (existing.has(e)) return { ok: false, error: `fence already built at ${e}` }; + if (seen.has(e)) return { ok: false, error: `duplicate fence ${e}` }; + seen.add(e); + } + if (player.fencesBuilt + newEdges.length > 15) { + return { ok: false, error: "fence limit is 15 per player" }; + } + + const before = computePastures(player.spaces, player.fences); + const fences = [...player.fences, ...newEdges]; + const layout = computePastures(player.spaces, fences); + + for (const p of layout.pastures) { + for (const c of p.cells) { + const sp = player.spaces[c]!; + if (sp.kind !== "empty") { + return { ok: false, error: `${sp.kind} at space ${c} may not be fenced in` }; + } + } + } + // Every fence must border an enclosed pasture cell. + for (const e of fences) { + const [a, b] = cellsOfEdge(e); + const borders = + (a !== null && layout.pastureCells.has(a)) || (b !== null && layout.pastureCells.has(b)); + if (!borders) { + return { ok: false, error: `fence ${e} is not part of any enclosed pasture` }; + } + } + // New pastures must border existing ones (if any existed). + if (before.pastures.length > 0) { + for (const p of layout.pastures) { + const overlapsOld = p.cells.some((c) => before.pastureCells.has(c)); + if (overlapsOld) continue; + const touchesOld = p.cells.some((c) => + neighborsOf(c).some((n) => before.pastureCells.has(n)), + ); + if (!touchesOld) { + return { ok: false, error: "new pastures must border existing pastures" }; + } + } + } + return { ok: true, layout }; +} + +/** Extra animal capacity granted by cards: `type` undefined = any one type. */ +export interface CapacitySlot { + type?: AnimalType; + capacity: number; +} + +export interface AnimalHolding { + retained: Record; + total: number; +} + +/** Maximum animals retainable given farm layout + card slots. Pastures hold a + * single type each; the house holds 1 pet; unfenced stables hold 1 each. */ +export function maxRetention( + player: PlayerState, + counts: Record, + cardSlots: CapacitySlot[], +): AnimalHolding { + const layout = computePastures(player.spaces, player.fences); + const unfencedStables = player.spaces.filter( + (sp, i) => sp.stable && sp.kind === "empty" && !layout.pastureCells.has(i), + ).length; + + // Typed slots fill first (they can't be repurposed). + const typedExtra: Record = { sheep: 0, boar: 0, cattle: 0 }; + let anySlots: number[] = []; + for (const s of cardSlots) { + if (s.type) typedExtra[s.type] += s.capacity; + else anySlots.push(s.capacity); + } + // House pet + each unfenced stable: capacity-1 any-type slots. + anySlots = anySlots.concat([1], Array(unfencedStables).fill(1)); + + const caps = layout.pastures.map((p) => p.capacity); + let best: AnimalHolding = { retained: { sheep: 0, boar: 0, cattle: 0 }, total: 0 }; + const nAssign = layout.pastures.length; + const options: (AnimalType | null)[] = [null, ...ANIMALS]; + + const assign = new Array(nAssign).fill(null); + const evaluate = () => { + const pastureCap: Record = { sheep: 0, boar: 0, cattle: 0 }; + for (let i = 0; i < nAssign; i++) { + const t = assign[i]; + if (t) pastureCap[t] += caps[i]!; + } + const leftover: Record = { sheep: 0, boar: 0, cattle: 0 }; + const retained: Record = { sheep: 0, boar: 0, cattle: 0 }; + for (const t of ANIMALS) { + retained[t] = Math.min(counts[t], pastureCap[t] + typedExtra[t]); + leftover[t] = counts[t] - retained[t]; + } + // Distribute any-type slots: each holds animals of one type, biggest first. + const slots = [...anySlots].sort((a, b) => b - a); + for (const cap of slots) { + let bestType: AnimalType | null = null; + for (const t of ANIMALS) { + if (leftover[t] > 0 && (bestType === null || leftover[t] > leftover[bestType])) { + bestType = t; + } + } + if (!bestType) break; + const take = Math.min(cap, leftover[bestType]); + retained[bestType] += take; + leftover[bestType] -= take; + } + const total = ANIMALS.reduce((s, t) => s + retained[t], 0); + if (total > best.total) best = { retained, total }; + }; + + const recurse = (i: number) => { + if (i === nAssign) { + evaluate(); + return; + } + for (const opt of options) { + assign[i] = opt; + recurse(i + 1); + } + }; + recurse(0); + return best; +} diff --git a/players/agricogla/symbolic-planner/src/engine/game.ts b/players/agricogla/symbolic-planner/src/engine/game.ts new file mode 100644 index 0000000..3580f7e --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/game.ts @@ -0,0 +1,97 @@ +import { boardSpaces, buildRoundDeck } from "./boards"; +import { MAJOR_IDS, MINOR_IDS, OCCUPATION_IDS } from "./cards"; +import { makeRng, randInt, shuffled } from "./rng"; +import { emit } from "./effects"; +import { startRound } from "./apply"; +import { + FarmSpace, + GameState, + NUM_SPACES, + PlayerState, + emptyGoods, + spaceIndex, +} from "./types"; + +export const PLAYER_COLORS = ["#7a4d8f", "#2e6b34", "#27557d", "#a03a2e"] as const; +export const DEFAULT_NAMES = ["Anna", "Bram", "Carla", "Diederik"] as const; + +function freshFarm(): FarmSpace[] { + const spaces: FarmSpace[] = []; + for (let i = 0; i < NUM_SPACES; i++) { + spaces.push({ kind: "empty", stable: false, crop: null, cropCount: 0 }); + } + // The starting wooden hut: middle-left and bottom-left spaces. + spaces[spaceIndex(1, 0)]!.kind = "room"; + spaces[spaceIndex(2, 0)]!.kind = "room"; + return spaces; +} + +export interface NewGameOptions { + seed: number; + numPlayers: number; + names?: string[]; +} + +export function newGame(opts: NewGameOptions): GameState { + const { seed, numPlayers } = opts; + if (numPlayers < 1 || numPlayers > 4) throw new Error("supported player counts: 1-4"); + const rng = makeRng(seed); + const solo = numPlayers === 1; + + const occupationDeck = shuffled(rng, OCCUPATION_IDS); + const minorDeck = shuffled(rng, MINOR_IDS); + + const players: PlayerState[] = []; + for (let idx = 0; idx < numPlayers; idx++) { + players.push({ + idx, + name: opts.names?.[idx] ?? DEFAULT_NAMES[idx]!, + color: PLAYER_COLORS[idx]!, + resources: { ...emptyGoods(), food: 0 } as PlayerState["resources"], + animals: { sheep: 0, boar: 0, cattle: 0 }, + spaces: freshFarm(), + fences: [], + fencesBuilt: 0, + houseMaterial: "wood", + family: [ + { bornRound: 0, placed: false }, + { bornRound: 0, placed: false }, + ], + beggingCards: 0, + startingPlayerMarker: false, + handOccupations: occupationDeck.splice(0, 7), + handMinors: minorDeck.splice(0, 7), + occupations: [], + minors: [], + majors: [], + cardData: {}, + }); + } + + const startingPlayer = randInt(rng, numPlayers); + for (const [i, p] of players.entries()) { + p.startingPlayerMarker = i === startingPlayer; + p.resources.food = solo ? 0 : i === startingPlayer ? 2 : 3; + } + + const state: GameState = { + seed, + numPlayers, + solo, + round: 0, + phase: "work", + startingPlayer, + currentPlayer: startingPlayer, + toFeed: [], + actionSpaces: boardSpaces(numPlayers).map((d) => ({ id: d.id, occupiedBy: null, pile: {} })), + roundDeck: buildRoundDeck(rng), + majorsAvailable: [...MAJOR_IDS], + scheduled: [], + players, + log: [], + scores: null, + }; + emit(state, null, "setup", `New ${numPlayers}-player game (seed ${seed})`); + startRound(state); + return state; +} diff --git a/players/agricogla/symbolic-planner/src/engine/index.ts b/players/agricogla/symbolic-planner/src/engine/index.ts new file mode 100644 index 0000000..59a20d4 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/index.ts @@ -0,0 +1,23 @@ +export * from "./types"; +export * from "./rng"; +export * from "./boards"; +export * from "./farmyard"; +export * from "./placements"; +export * from "./game"; +export * from "./legal"; +export * from "./scoring"; +export { + RuleError, + applyPlacement, + applyFeeding, + computeAutoFeed, + foodNeeded, + findSpace, + roomCost, + renovationCost, + legalRoomSpaces, + legalFieldSpaces, + legalStableSpaces, +} from "./apply"; +export { cardById, MAJOR_IDS, OCCUPATION_IDS, MINOR_IDS, majors, occupations, minors } from "./cards"; +export { capacitySlots, bestCookRate, canAccommodate } from "./effects"; diff --git a/players/agricogla/symbolic-planner/src/engine/legal.ts b/players/agricogla/symbolic-planner/src/engine/legal.ts new file mode 100644 index 0000000..37ea8fe --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/legal.ts @@ -0,0 +1,350 @@ +import { spaceDef } from "./boards"; +import { cardById } from "./cards"; +import { + foodNeeded, + legalFieldSpaces, + legalRoomSpaces, + legalStableSpaces, + renovationCost, + roomCost, +} from "./apply"; +import { bestCookRate, playedCards } from "./effects"; +import { computePastures, edgesOfCell, validateFencePlan } from "./farmyard"; +import { COLS, GameState, Goods, PlayerState, ROWS, Resource, spaceIndex } from "./types"; + +export interface ActionOption { + id: string; + title: string; + summary: string; + pile: Goods; + occupiedBy: number | null; + available: boolean; + reason?: string; +} + +export interface CardOption { + id: string; + name: string; + cost: Goods; + vp: number; + text: string; + affordable: boolean; + prereqOk: boolean; + prereqLabel?: string; +} + +export interface FencePlan { + edges: string[]; + cost: number; + cells: number[]; +} + +export interface BakeOption { + card: string; + name: string; + perGrain: number; + maxGrain: number; +} + +export interface ConversionOption { + via: string; + name: string; + good: string; + foodEach: number; + max: number; +} + +/** Everything a player (human UI or agent) needs to compose a placement. */ +export interface PlayerChoices { + roomCost: Goods; + renovation: Goods | null; + legalRooms: number[]; + legalFields: number[]; + legalStables: number[]; + stablesLeft: number; + occupationCostBySpace: Record; + handOccupations: CardOption[]; + handMinors: CardOption[]; + majors: CardOption[]; + fencePlans: FencePlan[]; + sowableFields: number[]; + bakeOptions: BakeOption[]; + familyGrowthOk: boolean; + urgentGrowthOk: boolean; + foodNeededNow: number; + conversionOptions: ConversionOption[]; +} + +function affordable(player: PlayerState, cost: Goods): boolean { + return Object.entries(cost).every( + ([res, n]) => player.resources[res as Resource] >= (n ?? 0), + ); +} + +function cardOption(state: GameState, player: PlayerState, id: string): CardOption { + const card = cardById(id); + let prereqOk = true; + if (card.prereq) { + if (card.prereq.occupations !== undefined) { + prereqOk = player.occupations.length >= card.prereq.occupations; + } + if (prereqOk && card.prereq.check) prereqOk = card.prereq.check(player, state); + } + return { + id, + name: card.name, + cost: card.cost ?? {}, + vp: card.vp ?? 0, + text: card.text, + affordable: affordable(player, card.cost ?? {}), + prereqOk, + prereqLabel: card.prereq?.label, + }; +} + +/** Candidate rectangular pastures (plus single-cell plans) for agents. The UI + * lets humans draw arbitrary fence sets; these are suggestions, not limits. */ +export function suggestFencePlans(player: PlayerState): FencePlan[] { + const layout = computePastures(player.spaces, player.fences); + const plans: FencePlan[] = []; + for (let r1 = 0; r1 < ROWS; r1++) { + for (let r2 = r1; r2 < ROWS; r2++) { + for (let c1 = 0; c1 < COLS; c1++) { + for (let c2 = c1; c2 < COLS; c2++) { + const cells: number[] = []; + let ok = true; + for (let r = r1; r <= r2 && ok; r++) { + for (let c = c1; c <= c2 && ok; c++) { + const i = spaceIndex(r, c); + const sp = player.spaces[i]!; + if (sp.kind !== "empty" || layout.pastureCells.has(i)) ok = false; + else cells.push(i); + } + } + if (!ok || cells.length === 0 || cells.length > 6) continue; + const existing = new Set(player.fences); + const edgeCount = new Map(); + for (const cell of cells) { + for (const e of edgesOfCell(cell)) { + edgeCount.set(e, (edgeCount.get(e) ?? 0) + 1); + } + } + // Perimeter edges appear once; interior edges twice (left open). + const edges = [...edgeCount.entries()] + .filter(([e, n]) => n === 1 && !existing.has(e)) + .map(([e]) => e); + if (edges.length === 0) continue; + const result = validateFencePlan(player, edges); + if (!result.ok) continue; + plans.push({ edges, cost: edges.length, cells }); + } + } + } + } + plans.sort((a, b) => a.cost - b.cost || b.cells.length - a.cells.length); + return plans.slice(0, 24); +} + +export function playerChoices(state: GameState, playerIdx: number): PlayerChoices { + const player = state.players[playerIdx]!; + let renovation: Goods | null = null; + if (player.houseMaterial !== "stone") { + renovation = renovationCost(player); + } + const rooms = player.spaces.filter((s) => s.kind === "room").length; + const stablesBuilt = player.spaces.filter((s) => s.stable).length; + + const occupationCostBySpace: Record = { + lessons: player.occupations.length === 0 ? 0 : 1, + lessons_b: + state.numPlayers === 3 ? 2 : player.occupations.length < 2 ? 1 : 2, + }; + + const bakeOptions: BakeOption[] = []; + for (const card of playedCards(player)) { + if (card.bake) { + bakeOptions.push({ + card: card.id, + name: card.name, + perGrain: card.bake.perGrain, + maxGrain: card.bake.maxGrain, + }); + } + } + + const conversionOptions: ConversionOption[] = []; + if (player.resources.grain > 0) { + conversionOptions.push({ + via: "raw", + name: "Raw grain", + good: "grain", + foodEach: 1, + max: player.resources.grain, + }); + } + if (player.resources.vegetable > 0) { + conversionOptions.push({ + via: "raw", + name: "Raw vegetable", + good: "vegetable", + foodEach: 1, + max: player.resources.vegetable, + }); + } + for (const type of ["sheep", "boar", "cattle"] as const) { + const cook = bestCookRate(player, type); + if (cook && player.animals[type] > 0) { + conversionOptions.push({ + via: cook.card.id, + name: cook.card.name, + good: type, + foodEach: cook.food, + max: player.animals[type], + }); + } + } + for (const card of playedCards(player)) { + if (card.cook?.vegetable && player.resources.vegetable > 0) { + conversionOptions.push({ + via: card.id, + name: card.name, + good: "vegetable", + foodEach: card.cook.vegetable, + max: player.resources.vegetable, + }); + } + if (card.harvestFood && state.phase === "feeding") { + const have = player.resources[card.harvestFood.from]; + const used = player.cardData[card.id]?.harvestUsed ?? 0; + const max = Math.min(card.harvestFood.max - used, have); + if (max > 0) { + conversionOptions.push({ + via: card.id, + name: card.name, + good: card.harvestFood.from, + foodEach: card.harvestFood.food, + max, + }); + } + } + } + + return { + roomCost: roomCost(player), + renovation, + legalRooms: legalRoomSpaces(player), + legalFields: legalFieldSpaces(player), + legalStables: legalStableSpaces(player), + stablesLeft: 4 - stablesBuilt, + occupationCostBySpace, + handOccupations: player.handOccupations.map((id) => cardOption(state, player, id)), + handMinors: player.handMinors.map((id) => cardOption(state, player, id)), + majors: state.majorsAvailable.map((id) => cardOption(state, player, id)), + fencePlans: suggestFencePlans(player), + sowableFields: player.spaces + .map((_, i) => i) + .filter((i) => player.spaces[i]!.kind === "field" && player.spaces[i]!.cropCount === 0), + bakeOptions, + familyGrowthOk: player.family.length < 5 && rooms > player.family.length, + urgentGrowthOk: player.family.length < 5, + foodNeededNow: foodNeeded(state, player), + conversionOptions, + }; +} + +/** Availability of each action space for the current player. */ +export function legalActions(state: GameState, playerIdx: number): ActionOption[] { + const player = state.players[playerIdx]!; + const choices = playerChoices(state, playerIdx); + return state.actionSpaces.map((space) => { + const def = spaceDef(space.id, state.numPlayers); + let available = state.phase === "work" && space.occupiedBy === null; + let reason: string | undefined; + const no = (why: string) => { + available = false; + reason = why; + }; + if (available) { + switch (space.id) { + case "farm_expansion": { + const roomOk = choices.legalRooms.length > 0 && affordable(player, choices.roomCost); + const stableOk = + choices.stablesLeft > 0 && + choices.legalStables.length > 0 && + player.resources.wood >= 2; + if (!roomOk && !stableOk) no("cannot afford a room or stable"); + break; + } + case "farmland": + if (choices.legalFields.length === 0) no("no legal field space"); + break; + case "lessons": + case "lessons_b": { + const cost = choices.occupationCostBySpace[space.id] ?? 0; + const playable = choices.handOccupations.filter( + (c) => c.prereqOk && player.resources.food >= cost, + ); + if (playable.length === 0) no("no playable occupation"); + break; + } + case "r_improvement": { + const minorOk = choices.handMinors.some((c) => c.affordable && c.prereqOk); + const majorOk = choices.majors.some( + (c) => + c.affordable || + ((c.id === "hearth4" || c.id === "hearth5") && + player.majors.some((m) => m === "fireplace2" || m === "fireplace3")), + ); + if (!minorOk && !majorOk) no("no playable improvement"); + break; + } + case "r_fences": + if (choices.fencePlans.length === 0 || player.resources.wood < choices.fencePlans[0]!.cost) { + no("no affordable fence plan"); + } + break; + case "r_sow_bake": { + const canSow = + choices.sowableFields.length > 0 && + (player.resources.grain > 0 || player.resources.vegetable > 0); + const canBake = choices.bakeOptions.length > 0 && player.resources.grain > 0; + if (!canSow && !canBake) no("nothing to sow or bake"); + break; + } + case "r_renovate_improve": + case "r_redevelop": + if (!choices.renovation || !affordable(player, choices.renovation)) { + no("cannot afford renovation"); + } + break; + case "r_family_growth": + if (!choices.familyGrowthOk) no("no free room (or family already 5)"); + break; + case "r_urgent_family": + if (!choices.urgentGrowthOk) no("family already 5"); + break; + case "r_cultivation": { + const canPlow = choices.legalFields.length > 0; + const canSow = + choices.sowableFields.length > 0 && + (player.resources.grain > 0 || player.resources.vegetable > 0); + if (!canPlow && !canSow) no("nothing to plow or sow"); + break; + } + default: + break; + } + } else if (space.occupiedBy !== null) { + reason = `occupied by ${state.players[space.occupiedBy]!.name}`; + } + return { + id: space.id, + title: def.title, + summary: def.summary, + pile: space.pile, + occupiedBy: space.occupiedBy, + available, + reason, + }; + }); +} diff --git a/players/agricogla/symbolic-planner/src/engine/placements.ts b/players/agricogla/symbolic-planner/src/engine/placements.ts new file mode 100644 index 0000000..f93c6aa --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/placements.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +/** Sub-choice payloads attached to a worker placement. Validation of legality + * (costs, adjacency, occupancy) happens in apply.ts; these schemas only fix + * the shapes. */ + +export const sowChoiceSchema = z.object({ + space: z.number().int().min(0).max(14), + crop: z.enum(["grain", "vegetable"]), +}); +export type SowChoice = z.infer; + +export const bakeChoiceSchema = z.object({ + card: z.string(), + grain: z.number().int().min(1), +}); +export type BakeChoice = z.infer; + +/** Playing a minor improvement (or buying a major via "improvement" choices). */ +export const improvementChoiceSchema = z.object({ + kind: z.enum(["major", "minor"]), + card: z.string(), + /** Buy a Cooking Hearth by returning a Fireplace instead of paying clay. */ + returnFireplace: z.string().optional(), + /** Immediate bake granted by ovens. */ + bake: z.array(bakeChoiceSchema).optional(), +}); +export type ImprovementChoice = z.infer; + +export const placementSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("farm_expansion"), + rooms: z.array(z.number().int().min(0).max(14)).default([]), + stables: z.array(z.number().int().min(0).max(14)).default([]), + }), + z.object({ + action: z.literal("meeting_place"), + improvement: improvementChoiceSchema.optional(), + }), + z.object({ action: z.literal("grain_seeds") }), + z.object({ + action: z.literal("farmland"), + spaces: z.array(z.number().int().min(0).max(14)).min(1), + /** Plow-improvement card enabling more than 1 field this action. */ + plowCard: z.string().optional(), + }), + z.object({ action: z.literal("lessons"), occupation: z.string() }), + z.object({ action: z.literal("lessons_b"), occupation: z.string() }), + z.object({ action: z.literal("day_laborer") }), + z.object({ action: z.literal("forest") }), + z.object({ action: z.literal("clay_pit") }), + z.object({ action: z.literal("reed_bank") }), + z.object({ action: z.literal("fishing") }), + z.object({ action: z.literal("copse") }), + z.object({ action: z.literal("grove") }), + z.object({ action: z.literal("hollow") }), + z.object({ action: z.literal("quarry_stall") }), + z.object({ action: z.literal("resource_market") }), + z.object({ action: z.literal("traveling_players") }), + z.object({ action: z.literal("r_improvement"), improvement: improvementChoiceSchema }), + z.object({ action: z.literal("r_sheep") }), + z.object({ action: z.literal("r_fences"), edges: z.array(z.string()).min(1) }), + z.object({ + action: z.literal("r_sow_bake"), + sow: z.array(sowChoiceSchema).default([]), + bake: z.array(bakeChoiceSchema).default([]), + }), + z.object({ action: z.literal("r_west_quarry") }), + z.object({ + action: z.literal("r_renovate_improve"), + improvement: improvementChoiceSchema.optional(), + }), + z.object({ + action: z.literal("r_family_growth"), + improvement: improvementChoiceSchema.optional(), + }), + z.object({ action: z.literal("r_vegetable") }), + z.object({ action: z.literal("r_boar") }), + z.object({ action: z.literal("r_east_quarry") }), + z.object({ action: z.literal("r_cattle") }), + z.object({ action: z.literal("r_urgent_family") }), + z.object({ + action: z.literal("r_cultivation"), + plow: z.number().int().min(0).max(14).optional(), + sow: z.array(sowChoiceSchema).default([]), + }), + z.object({ + action: z.literal("r_redevelop"), + edges: z.array(z.string()).default([]), + }), +]); + +export type Placement = z.infer; + +/** One conversion applied during feeding (or voluntarily). */ +export const conversionSchema = z.object({ + /** "raw" for grain/vegetable at 1 food, otherwise a card id. */ + via: z.string(), + good: z.enum(["grain", "vegetable", "sheep", "boar", "cattle", "wood", "clay", "reed"]), + count: z.number().int().min(1), +}); +export type Conversion = z.infer; + +export const feedDecisionSchema = z.object({ + conversions: z.array(conversionSchema).default([]), +}); +export type FeedDecision = z.infer; diff --git a/players/agricogla/symbolic-planner/src/engine/rng.ts b/players/agricogla/symbolic-planner/src/engine/rng.ts new file mode 100644 index 0000000..5be8ea3 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/rng.ts @@ -0,0 +1,26 @@ +/** Deterministic mulberry32 PRNG. All engine randomness flows through this. */ +export type Rng = () => number; + +export function makeRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function randInt(rng: Rng, n: number): number { + return Math.floor(rng() * n); +} + +export function shuffled(rng: Rng, items: readonly T[]): T[] { + const out = [...items]; + for (let i = out.length - 1; i > 0; i--) { + const j = randInt(rng, i + 1); + [out[i], out[j]] = [out[j]!, out[i]!]; + } + return out; +} diff --git a/players/agricogla/symbolic-planner/src/engine/scoring.ts b/players/agricogla/symbolic-planner/src/engine/scoring.ts new file mode 100644 index 0000000..b7de54a --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/scoring.ts @@ -0,0 +1,92 @@ +import { cardById } from "./cards"; +import { computePastures } from "./farmyard"; +import { GameState, PlayerState, ScoreCategory, ScoreSheet } from "./types"; + +function tiered(count: number, tiers: [number, number][], negativeBelow: number): number { + if (count < negativeBelow) return -1; + let pts = 0; + for (const [threshold, points] of tiers) if (count >= threshold) pts = points; + return pts; +} + +export function scorePlayer(state: GameState, player: PlayerState): ScoreSheet { + const categories: ScoreCategory[] = []; + const add = (label: string, points: number, detail: string) => { + categories.push({ label, points, detail }); + }; + + const fields = player.spaces.filter((s) => s.kind === "field").length; + add( + "Fields", + tiered(fields, [[2, 1], [3, 2], [4, 3], [5, 4]], 2), + `${fields} field(s)`, + ); + + const layout = computePastures(player.spaces, player.fences); + const pastures = layout.pastures.length; + add( + "Pastures", + tiered(pastures, [[1, 1], [2, 2], [3, 3], [4, 4]], 1), + `${pastures} pasture(s)`, + ); + + const grain = + player.resources.grain + + player.spaces.reduce((s, sp) => s + (sp.crop === "grain" ? sp.cropCount : 0), 0); + add("Grain", tiered(grain, [[1, 1], [4, 2], [6, 3], [8, 4]], 1), `${grain} grain`); + + const veg = + player.resources.vegetable + + player.spaces.reduce((s, sp) => s + (sp.crop === "vegetable" ? sp.cropCount : 0), 0); + add("Vegetables", tiered(veg, [[1, 1], [2, 2], [3, 3], [4, 4]], 1), `${veg} vegetable(s)`); + + add( + "Sheep", + tiered(player.animals.sheep, [[1, 1], [4, 2], [6, 3], [8, 4]], 1), + `${player.animals.sheep} sheep`, + ); + add( + "Wild boar", + tiered(player.animals.boar, [[1, 1], [3, 2], [5, 3], [7, 4]], 1), + `${player.animals.boar} wild boar`, + ); + add( + "Cattle", + tiered(player.animals.cattle, [[1, 1], [2, 2], [4, 3], [6, 4]], 1), + `${player.animals.cattle} cattle`, + ); + + const unused = player.spaces.filter( + (sp, i) => sp.kind === "empty" && !sp.stable && !layout.pastureCells.has(i), + ).length; + add("Unused spaces", -unused, `${unused} unused space(s)`); + + const fencedStables = player.spaces.filter( + (sp, i) => sp.stable && layout.pastureCells.has(i), + ).length; + add("Fenced stables", fencedStables, `${fencedStables} fenced stable(s)`); + + const rooms = player.spaces.filter((s) => s.kind === "room").length; + const roomPts = player.houseMaterial === "clay" ? rooms : player.houseMaterial === "stone" ? rooms * 2 : 0; + add("Rooms", roomPts, `${rooms} ${player.houseMaterial} room(s)`); + + add("Family", player.family.length * 3, `${player.family.length} family member(s)`); + add("Begging", -3 * player.beggingCards, `${player.beggingCards} begging card(s)`); + + let cardPts = 0; + let bonusPts = 0; + for (const id of [...player.occupations, ...player.minors, ...player.majors]) { + const card = cardById(id); + cardPts += card.vp ?? 0; + bonusPts += card.bonusVp ? card.bonusVp(player, state) : 0; + } + add("Card points", cardPts, "printed victory points"); + add("Bonus points", bonusPts, "card bonus points"); + + const total = categories.reduce((s, c) => s + c.points, 0); + return { playerIdx: player.idx, categories, total }; +} + +export function scoreGame(state: GameState): ScoreSheet[] { + return state.players.map((p) => scorePlayer(state, p)); +} diff --git a/players/agricogla/symbolic-planner/src/engine/types.ts b/players/agricogla/symbolic-planner/src/engine/types.ts new file mode 100644 index 0000000..8dc4ee0 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/engine/types.ts @@ -0,0 +1,150 @@ +/** Core engine types. Everything in GameState is JSON-serializable; card + * behavior lives in code (cards/*.ts) keyed by card id. */ + +export const RESOURCES = ["wood", "clay", "reed", "stone", "grain", "vegetable", "food"] as const; +export type Resource = (typeof RESOURCES)[number]; + +export const ANIMALS = ["sheep", "boar", "cattle"] as const; +export type AnimalType = (typeof ANIMALS)[number]; + +export type Good = Resource | AnimalType; +export const GOODS: readonly Good[] = [...RESOURCES, ...ANIMALS]; + +export type HouseMaterial = "wood" | "clay" | "stone"; + +export type Goods = Partial>; + +/** Farmyard: 3 rows x 5 cols, space index = row * 5 + col. */ +export const ROWS = 3; +export const COLS = 5; +export const NUM_SPACES = ROWS * COLS; + +export type CropType = "grain" | "vegetable"; + +export interface FarmSpace { + kind: "empty" | "room" | "field"; + stable: boolean; + crop: CropType | null; + cropCount: number; +} + +/** Fence edge ids: "h-r-c" horizontal edge above row r (r 0..3) at col c (0..4); + * "v-r-c" vertical edge left of col c (c 0..5) at row r (0..2). */ +export type EdgeId = string; + +export interface FamilyMember { + /** Round the member was born; 0 for the starting pair. */ + bornRound: number; + /** Placed on an action space this round? */ + placed: boolean; +} + +export interface PlayerState { + idx: number; + name: string; + color: string; + resources: Record; + animals: Record; + spaces: FarmSpace[]; + fences: EdgeId[]; + fencesBuilt: number; // lifetime, max 15 + houseMaterial: HouseMaterial; + family: FamilyMember[]; + beggingCards: number; + startingPlayerMarker: boolean; + handOccupations: string[]; + handMinors: string[]; + occupations: string[]; + minors: string[]; + majors: string[]; + /** Per-card persistent counters (uses left, goods stored on card, etc.). */ + cardData: Record>; +} + +export interface ActionSpaceState { + id: string; + /** Player idx occupying it this round, or null. */ + occupiedBy: number | null; + /** Goods piled on the space (accumulation spaces). */ + pile: Goods; +} + +/** Goods scheduled onto future round spaces (e.g. the Well). */ +export interface ScheduledGood { + round: number; + playerIdx: number; + good: Good; + count: number; +} + +export type Phase = "work" | "feeding" | "finished"; + +export interface GameEvent { + round: number; + playerIdx: number | null; + type: string; + text: string; +} + +export interface GameState { + seed: number; + numPlayers: number; + solo: boolean; + round: number; // 1..14 during play + phase: Phase; + startingPlayer: number; + currentPlayer: number; + /** Players who still need to submit a feeding decision this harvest. */ + toFeed: number[]; + actionSpaces: ActionSpaceState[]; + /** Upcoming round-card action ids, index 0 = next round to reveal. */ + roundDeck: string[]; + majorsAvailable: string[]; + scheduled: ScheduledGood[]; + players: PlayerState[]; + log: GameEvent[]; + /** Final scores, set when phase becomes finished. */ + scores: ScoreSheet[] | null; +} + +export interface ScoreCategory { + label: string; + points: number; + detail: string; +} + +export interface ScoreSheet { + playerIdx: number; + categories: ScoreCategory[]; + total: number; +} + +export function emptyGoods(): Record { + return Object.fromEntries(GOODS.map((g) => [g, 0])) as Record; +} + +export function addGoods(target: Goods, extra: Goods): void { + for (const [g, n] of Object.entries(extra)) { + if (!n) continue; + target[g as Good] = (target[g as Good] ?? 0) + n; + } +} + +export function goodsToText(goods: Goods): string { + const parts = Object.entries(goods) + .filter(([, n]) => (n ?? 0) > 0) + .map(([g, n]) => `${n} ${g}`); + return parts.length ? parts.join(", ") : "nothing"; +} + +export function spaceIndex(row: number, col: number): number { + return row * COLS + col; +} + +export function rowOf(space: number): number { + return Math.floor(space / COLS); +} + +export function colOf(space: number): number { + return space % COLS; +} diff --git a/players/agricogla/symbolic-planner/src/planner-player.ts b/players/agricogla/symbolic-planner/src/planner-player.ts new file mode 100644 index 0000000..2f596b4 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/planner-player.ts @@ -0,0 +1,74 @@ +// Standalone agricogla symbolic-planner coworld player. In the work phase it +// runs the model-based `planMove` (belief model + determinized full-game rollout +// + world-state value); in feeding it uses the engine's canonical auto-feed. +// Every decision is logged as one structured JSON line to stdout (captured as the +// per-slot policy log) so the optimization loop can attribute behavior. +// +// COWORLD_PLAYER_WS_URL=ws://… node dist/planner-player.js + +import { argv } from "node:process"; +import { fileURLToPath } from "node:url"; + +import { runCoworldPlayer, type PlayerDecideContext } from "./runtime/player-runtime.js"; +import { computeAutoFeed, type GameState } from "./engine/index.js"; +import { buildView } from "./baseline.js"; +import { planMove } from "./planner/planner.js"; + +type AgriDecision = import("./engine/index.js").Placement | import("./engine/index.js").FeedDecision; + +const r2 = (n: number) => Math.round(n * 100) / 100; + +export function decide(ctx: PlayerDecideContext): AgriDecision { + const state = ctx.view; + const seat = ctx.seat; + + if (state.phase === "feeding") { + const feed = computeAutoFeed(state, seat); + console.log( + JSON.stringify({ + kind: "planner_decision", + phase: "feeding", + seat, + turn: ctx.turn, + round: state.round, + conversions: feed.conversions.length, + }), + ); + return feed; + } + + const plan = planMove(buildView(state, seat)); + const b = plan.belief; + console.log( + JSON.stringify({ + kind: "planner_decision", + phase: "work", + seat, + turn: ctx.turn, + round: state.round, + action: plan.placement.action, + label: plan.best.label, + value: r2(plan.best.value), + margin: b.margin, + foodDeficit: b.food.deficit, + foodUrgency: b.food.urgency, + growthHeadroom: b.self.growthHeadroom, + candidates: plan.candidateCount, + alternatives: plan.alternatives.map((a) => ({ + action: a.placement.action, + value: r2(a.value), + denies: a.denies, + })), + }), + ); + return plan.placement; +} + +export function run(): Promise { + return runCoworldPlayer({ decide }); +} + +// Run only when invoked as the entrypoint, not when imported (e.g. by tests). +if (argv[1] === fileURLToPath(import.meta.url)) { + await run(); +} diff --git a/players/agricogla/symbolic-planner/src/planner/beliefs.ts b/players/agricogla/symbolic-planner/src/planner/beliefs.ts new file mode 100644 index 0000000..8d0a4bc --- /dev/null +++ b/players/agricogla/symbolic-planner/src/planner/beliefs.ts @@ -0,0 +1,204 @@ +// The belief layer of the symbolic planner: a typed, seat-centric model of the +// world distilled from the redacted engine view. Everything the planner reasons +// about — food security, farm development, the adversarial score gap — is read +// off a `WorldBelief` rather than poked out of the raw `GameState` ad hoc, so +// every decision is attributable to a structured belief snapshot. + +import { + ANIMALS, + HARVEST_ROUNDS, + bestCookRate, + computePastures, + foodNeeded, + roundCards, + scoreGame, + type GameState, + type PlayerState, +} from "../engine/index.js"; + +/** Sentinel the coworld host writes over information a seat may not see. */ +const HIDDEN = "hidden"; +/** The full round-card catalogue in stage order (stage 1 first … stage 6 last). + * The deck is only ever shuffled *within* a stage, so the remaining cards in + * stage order are a legal — if not perfectly ordered — completion of the deck. */ +const ROUND_CARD_IDS: readonly string[] = roundCards.map((c) => c.id); + +/** Rounds from `round` (inclusive) to the next harvest; 0 = harvest at round end. */ +export function nextHarvestIn(round: number): number { + for (let r = round; r <= 14; r++) if (HARVEST_ROUNDS.has(r)) return r - round; + return 14 - round; +} + +/** The food model: what the family will owe at the next harvest vs. what we can + * muster by then (supply + raw crops + cookable animals + this-harvest field + * yield). A positive `deficit` means begging cards (−3 vp each). */ +export interface FoodOutlook { + need: number; + projected: number; + deficit: number; + harvestGap: number; + /** 0 comfortable … 4 starving with the harvest imminent. */ + urgency: number; +} + +/** A single seat's farm, distilled to the quantities that drive scoring and the + * planner's forward value. Built for every seat so the planner can model + * opponents from their public board. */ +export interface SeatBelief { + idx: number; + isSelf: boolean; + food: number; + grain: number; + vegetable: number; + wood: number; + clay: number; + reed: number; + stone: number; + sheep: number; + boar: number; + cattle: number; + family: number; + rooms: number; + fields: number; + pastures: number; + stables: number; + fencedStables: number; + unusedSpaces: number; + houseMaterial: PlayerState["houseMaterial"]; + occupations: number; + minors: number; + majors: number; + beggingCards: number; + /** rooms − family: free beds available for family growth. */ + growthHeadroom: number; + /** animal types currently holding a breeding pair (≥2). */ + breedingPairs: number; + /** live engine score for this seat. */ + score: number; +} + +export interface GameClock { + round: number; + roundsRemaining: number; + harvestGap: number; + harvestsRemaining: number; + isHarvestRound: boolean; +} + +/** The planner's complete world model for one decision. */ +export interface WorldBelief { + self: SeatBelief; + opponents: SeatBelief[]; + clock: GameClock; + food: FoodOutlook; + /** self.score − best opponent score: the adversarial signal we maximize. */ + margin: number; +} + +/** Project a seat's food balance against the next harvest. */ +export function projectFood(state: GameState, player: PlayerState): FoodOutlook { + const harvestGap = nextHarvestIn(state.round); + const res = player.resources; + const cookable = ANIMALS.reduce( + (s, t) => s + (bestCookRate(player, t)?.food ?? 0) * player.animals[t], + 0, + ); + const fieldYield = player.spaces.filter( + (sp) => sp.kind === "field" && sp.crop && sp.cropCount > 0, + ).length; + const projected = res.food + res.grain + res.vegetable + cookable + fieldYield; + const need = foodNeeded(state, player); + const deficit = need - projected; + const urgency = deficit > 0 ? Math.max(1, 4 - harvestGap) : 0; + return { need, projected, deficit, harvestGap, urgency }; +} + +function seatBelief(player: PlayerState, score: number, isSelf: boolean): SeatBelief { + const layout = computePastures(player.spaces, player.fences); + const r = player.resources; + const rooms = player.spaces.filter((s) => s.kind === "room").length; + return { + idx: player.idx, + isSelf, + food: r.food, + grain: r.grain, + vegetable: r.vegetable, + wood: r.wood, + clay: r.clay, + reed: r.reed, + stone: r.stone, + sheep: player.animals.sheep, + boar: player.animals.boar, + cattle: player.animals.cattle, + family: player.family.length, + rooms, + fields: player.spaces.filter((s) => s.kind === "field").length, + pastures: layout.pastures.length, + stables: player.spaces.filter((s) => s.stable).length, + fencedStables: player.spaces.filter((sp, i) => sp.stable && layout.pastureCells.has(i)).length, + unusedSpaces: player.spaces.filter( + (sp, i) => sp.kind === "empty" && !sp.stable && !layout.pastureCells.has(i), + ).length, + houseMaterial: player.houseMaterial, + occupations: player.occupations.length, + minors: player.minors.length, + majors: player.majors.length, + beggingCards: player.beggingCards, + growthHeadroom: rooms - player.family.length, + breedingPairs: ANIMALS.filter((t) => player.animals[t] >= 2).length, + score, + }; +} + +/** Build the seat's world model from an engine state. */ +export function buildBeliefs(state: GameState, seat: number): WorldBelief { + const sheets = scoreGame(state); + const scoreOf = (idx: number) => sheets.find((s) => s.playerIdx === idx)!.total; + const self = seatBelief(state.players[seat]!, scoreOf(seat), true); + const opponents = state.players + .filter((p) => p.idx !== seat) + .map((p) => seatBelief(p, scoreOf(p.idx), false)); + const bestOpp = opponents.reduce((m, o) => Math.max(m, o.score), 0); + return { + self, + opponents, + clock: { + round: state.round, + roundsRemaining: 14 - state.round, + harvestGap: nextHarvestIn(state.round), + harvestsRemaining: [...HARVEST_ROUNDS].filter((r) => r >= state.round).length, + isHarvestRound: HARVEST_ROUNDS.has(state.round), + }, + food: projectFood(state, state.players[seat]!), + margin: self.score - bestOpp, + }; +} + +/** Determinize the hidden state into the one belief the planner can act on. A + * seat's coworld view redacts the undealt round deck and every other seat's + * hand to the `"hidden"` sentinel, so a forward simulation that touched them + * would crash. We fill those gaps with the only legal, information-free guess: + * the remaining round cards in stage order (a valid deck completion), and + * opponents holding no private cards (they still contest the public board). + * Each candidate is rolled out against this same belief, so the ranking is + * fair. On an unredacted state (self-play) nothing is masked, so it is a + * no-op and the real deck and hands drive the rollout. */ +export function determinize(state: GameState, seat: number): GameState { + const next = structuredClone(state); + if (next.roundDeck.includes(HIDDEN)) { + const revealed = new Set(next.actionSpaces.map((a) => a.id)); + const remaining = ROUND_CARD_IDS.filter((id) => !revealed.has(id)); + if (remaining.length !== next.roundDeck.length) { + throw new Error( + `deck belief mismatch: ${remaining.length} remaining cards vs ${next.roundDeck.length} undealt`, + ); + } + next.roundDeck = [...remaining]; + } + for (const p of next.players) { + if (p.idx === seat) continue; + if (p.handOccupations.includes(HIDDEN)) p.handOccupations = []; + if (p.handMinors.includes(HIDDEN)) p.handMinors = []; + } + return next; +} diff --git a/players/agricogla/symbolic-planner/src/planner/planner.ts b/players/agricogla/symbolic-planner/src/planner/planner.ts new file mode 100644 index 0000000..de10659 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/planner/planner.ts @@ -0,0 +1,100 @@ +// The planner: a rollout-based, model-driven move selector. For each promising +// candidate placement it (1) applies the move with the real engine, then (2) +// plays the game out to round 14 with the heuristic baseline driving every seat +// — a deterministic forward model of the whole rest of the game, harvests, +// feeding and begging included — and (3) scores the terminal margin. Picking the +// argmax is one-step policy improvement over the baseline: deviate once here, +// assume baseline play afterward, keep whatever deviation ends furthest ahead. +// Ties break toward denying the opponent a space they want. The engine reducers +// clone their input, so the whole search never touches the live state. + +import { + applyFeeding, + applyPlacement, + computeAutoFeed, + type GameState, + type Placement, +} from "../engine/index.js"; +import { buildView, enumerateCandidates, fallbackPlacement, type SeamView } from "../baseline.js"; +import { buildBeliefs, determinize, type WorldBelief } from "./beliefs.js"; +import { assessContention, evaluateState } from "./tools.js"; + +export interface ScoredPlacement { + placement: Placement; + label: string; + /** Terminal score margin reached by playing this move out under baseline. */ + value: number; + /** True when this move occupies a space an opponent wanted. */ + denies: boolean; +} + +export interface PlanResult { + placement: Placement; + belief: WorldBelief; + best: ScoredPlacement; + /** Top alternatives (already ranked), kept for decision attribution. */ + alternatives: ScoredPlacement[]; + candidateCount: number; +} + +/** How many of the baseline's top-ranked candidates to roll out. The baseline + * already sorts by a sound heuristic, so a wide-enough prefix keeps the search + * cheap without dropping the move a full rollout would prefer. */ +const ROLLOUT_WIDTH = 10; +const TOP_ALTERNATIVES = 4; + +/** Play `state` to the end of the game with the baseline driving every seat. */ +function rolloutToEnd(state: GameState): GameState { + let s = state; + let guard = 0; + while (s.phase !== "finished" && guard++ < 4000) { + if (s.phase === "feeding") { + const actor = s.toFeed[0]!; + s = applyFeeding(s, actor, computeAutoFeed(s, actor)).state; + } else { + const actor = s.currentPlayer; + s = applyPlacement(s, actor, fallbackPlacement(buildView(s, actor))).state; + } + } + return s; +} + +/** Choose a placement for the seat to move by rolling each candidate to the end. */ +export function planMove(view: SeamView): PlanResult { + const { state, playerIdx: seat } = view; + const belief = buildBeliefs(state, seat); + // The seat cannot see the undealt deck or opponents' hands, so every forward + // simulation runs against a single determinized belief of the hidden state. + // Candidates are still enumerated from the seat's real (observable) view. + const world = determinize(state, seat); + const wanted = assessContention(world, seat).wantedActions; + + const candidates = enumerateCandidates(view); + const seeds = + candidates.length > 0 + ? candidates.slice(0, ROLLOUT_WIDTH).map((c) => ({ placement: c.placement, label: c.label })) + : [{ placement: fallbackPlacement(view), label: "fallback" }]; + + const scored: ScoredPlacement[] = seeds.map(({ placement, label }) => { + const terminal = rolloutToEnd(applyPlacement(world, seat, placement).state); + return { placement, label, value: evaluateState(terminal, seat), denies: wanted.has(placement.action) }; + }); + + // Argmax terminal margin; ties go to opponent denial, then keep the baseline + // order (Array.sort is stable) so the policy stays fully deterministic. + scored.sort((a, b) => { + const d = b.value - a.value; + if (Math.abs(d) > 1e-9) return d; + if (a.denies !== b.denies) return a.denies ? -1 : 1; + return 0; + }); + + const best = scored[0]!; + return { + placement: best.placement, + belief, + best, + alternatives: scored.slice(0, TOP_ALTERNATIVES), + candidateCount: candidates.length, + }; +} diff --git a/players/agricogla/symbolic-planner/src/planner/tools.ts b/players/agricogla/symbolic-planner/src/planner/tools.ts new file mode 100644 index 0000000..37098a9 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/planner/tools.ts @@ -0,0 +1,38 @@ +// The planner's decision tools: a terminal value function over a *world state* +// (`evaluateState`) and an opponent-intent model (`assessContention`). The +// planner simulates each candidate move to the end of the game and ranks the +// resulting terminal states with these tools — modeling consequences instead of +// scoring the raw option list the way the heuristic baseline does. + +import { scoreGame, type GameState } from "../engine/index.js"; +import { buildView, enumerateCandidates } from "../baseline.js"; + +/** Value a world state from a seat's perspective as its adversarial score margin + * (own victory points − the best opponent's). Evaluated on the *terminal* state + * a rollout reaches, this is exactly the game's objective: end ahead. */ +export function evaluateState(state: GameState, seat: number): number { + const sheets = scoreGame(state); + const mine = sheets.find((s) => s.playerIdx === seat)!.total; + let bestOpp = 0; + for (const s of sheets) if (s.playerIdx !== seat) bestOpp = Math.max(bestOpp, s.total); + return mine - bestOpp; +} + +/** Opponent-intent model: the action spaces the opponents would most want to + * take right now (their top heuristic candidates). The planner uses this as a + * deterministic tie-break — when two of our plans reach equal terminal margins, + * prefer the one that denies an opponent a space they covet. */ +export interface ContentionReport { + wantedActions: Set; +} + +export function assessContention(state: GameState, seat: number): ContentionReport { + const wantedActions = new Set(); + for (const opp of state.players) { + if (opp.idx === seat) continue; + for (const c of enumerateCandidates(buildView(state, opp.idx)).slice(0, 2)) { + wantedActions.add(c.placement.action); + } + } + return { wantedActions }; +} diff --git a/players/agricogla/symbolic-planner/src/runtime/player-runtime.ts b/players/agricogla/symbolic-planner/src/runtime/player-runtime.ts new file mode 100644 index 0000000..e4eeaf7 --- /dev/null +++ b/players/agricogla/symbolic-planner/src/runtime/player-runtime.ts @@ -0,0 +1,58 @@ +// Standalone coworld player runtime — a WebSocket CLIENT of the game host's +// /player endpoint. Receives observations, calls the injected `decide`, sends +// replies. Derived from @cogweb/coworld's player-runtime but dependency-free +// (no @cogweb/* imports). + +import { WebSocket } from "ws"; + +export interface PlayerDecideContext { + view: View; + seat: number; + turn: number; + reason: string | null; +} + +export interface RunPlayerOpts { + connect?: string; + decide: (ctx: PlayerDecideContext) => Decision | Promise; +} + +export function runCoworldPlayer( + opts: RunPlayerOpts, +): Promise { + const url = opts.connect ?? process.env.COWORLD_PLAYER_WS_URL; + if (!url) throw new Error("no player socket URL: pass `connect` or set COWORLD_PLAYER_WS_URL"); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.on("error", reject); + ws.on("message", (data: Buffer) => { + const msg = JSON.parse(data.toString()); + switch (msg.type) { + case "welcome": + return; + case "final": + // Emit a zero-token Bedrock usage line (this is a no-LLM policy) so + // the episode bundle can confirm no LLM calls were made. + console.log(JSON.stringify({ kind: "bedrock_usage", inputTokens: 0, outputTokens: 0 })); + resolve(msg.scores); + ws.close(); + return; + case "observation": { + const ctx: PlayerDecideContext = { + view: msg.view as View, + seat: msg.seat, + turn: msg.turn, + reason: msg.reason ?? null, + }; + void Promise.resolve(opts.decide(ctx)).then((decision) => { + const reply = { type: "reply", id: msg.id, decision, messages: [] }; + ws.send(JSON.stringify(reply)); + }, reject); + return; + } + } + }); + }); +} diff --git a/players/agricogla/symbolic-planner/tsconfig.json b/players/agricogla/symbolic-planner/tsconfig.json new file mode 100644 index 0000000..d7155c3 --- /dev/null +++ b/players/agricogla/symbolic-planner/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}