Examples/Catan

Catan

Created from a prompt

Re-run on 2026-10-16 with OpenCode (DeepSeek V4 Pro via OpenRouter) against the updated starter (scoped picture bundle, answer scrub, moveButton, key_input clocks, check_env.py). No hardcoded demo code. Cursor did not edit workspace/.

2026-10-16 · task MytzW7u4y9nT · opencode · openrouter/deepseek/deepseek-v4-pro-0813

The prompt

Build a full four-player game of Settlers of Catan that I can play as one colour against three computer opponents. First to ten points wins. Use the normal pieces: settlements, roads, cities, the robber, development cards, and trading with the bank and harbours. Players do not trade with each other.

I should click the board when I am placing a piece, and use buttons for things like ending the turn, buying a card, and trading. The opponents take their turns by themselves, including the moments where I would have only one legal choice, such as rolling the dice. Show the island, my hand, everyone's points, and a short log of what the bots did. Winning is the main score. Having more points if the game runs long should still be worth something. Several maps. The same map should come up the same way, including the dice, if I restart it.

Live environment

Running the prompt-created workspace on this machine — not a mock. Save an episode, then open Replay below to scrub the timeline.

opening · 0 ms

Generated workspace

Exact files the agent wrote — not rewritten by hand.

from __future__ import annotations

import math
import random
from typing import Any, Dict, List, Optional, Tuple

from helpers import base_observation, pick_task, rng_for

RESOURCES = ["brick", "lumber", "ore", "grain", "wool"]
RES_LABELS = {
    "brick": "Brick",
    "lumber": "Lumber",
    "ore": "Ore",
    "grain": "Grain",
    "wool": "Wool",
}
RES_COLORS = {
    "brick": "#c2410c",
    "lumber": "#15803d",
    "ore": "#57534e",
    "grain": "#ca8a04",
    "wool": "#84cc16",
}
DESERT_COLOR = "#e7d5a3"

PLAYER_NAMES = ["Red", "Blue", "Orange", "White"]
PLAYER_COLORS = ["#dc2626", "#2563eb", "#f97316", "#e2e8f0"]
PLAYER_STROKE = ["#7f1d1d", "#1e3a8a", "#9a3412", "#475569"]

COSTS = {
    "road": {"brick": 1, "lumber": 1},
    "settlement": {"brick": 1, "lumber": 1, "grain": 1, "wool": 1},
    "city": {"grain": 2, "ore": 3},
    "dev": {"grain": 1, "ore": 1, "wool": 1},
}

WEIGHT = {2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 8: 5, 9: 4, 10: 3, 11: 2, 12: 1}

SETUP_ORDER = [(0, 1), (1, 1), (2, 1), (3, 1), (3, 2), (2, 2), (1, 2), (0, 2)]

TURN_LIMIT = 60

MAP_IDS = ["map_a", "map_b", "map_c", "map_d"]

# terrain per hex id (id order is rows by (r, q)). b brick l lumber o ore g grain w wool d desert.
MAPS = {
    "map_a": "blgwolgbowgwgwlbdow",
    "map_b": "wglblowgwlbodgwlwbog",
    "map_c": "lwbgwgbogolwodlbwlg",
    "map_d": "gowlbwlwbgoglo dbwgl".replace(" ", ""),
}

AXIAL_NEIGHBORS = [(1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1)]


def _name(p: int) -> str:
    return "You" if p == 0 else PLAYER_NAMES[p]


class Catan:
    """A full four-player game of Settlers of Catan. Human is player 0 (Red)."""

    def __init__(self) -> None:
        self.task_id = ""
        self.map_id = "map_a"
        self.map_label = "Map A"
        self.hex_list: List[Dict[str, Any]] = []
        self.vertices: List[Dict[str, Any]] = []
        self.edges: List[Dict[str, Any]] = []
        self.hex_neighbors: List[List[int]] = []
        self.hex_vertices: List[List[int]] = []
        self.players: List[Dict[str, Any]] = []
        self.dice_pairs: List[Tuple[int, int]] = []
        self.dice_index = 0
        self.deck: List[str] = []
        self.phase = "setup"
        self.state = "setup_settle"
        self.current_player = 0
        self.setup_index = 0
        self.turn = 0
        self.robber_hex = 0
        self.longest_road: Optional[int] = None
        self.largest_army: Optional[int] = None
        self.log: List[str] = []
        self.dice: List[int] = [0, 0]
        self.roll_total = 0
        self.winner: Optional[int] = None
        self.end_reason = ""
        self.final_reward: Optional[float] = None
        self.last_move: Optional[str] = None
        self.discard_remaining = 0
        self.trade_want: Optional[str] = None
        self.yop_remaining = 0
        self.roadbuild_remaining = 0
        self.pending_road_vertex: Optional[int] = None
        self.board_w = 0
        self.board_h = 0
        self.hex_size = 52.0

    # ------------------------------------------------------------------ reset

    def reset(self, task_id: str = "") -> Dict[str, Any]:
        self.task_id = task_id
        self.map_id = pick_task(task_id, MAP_IDS, "map_a")
        self.map_label = self.map_id.replace("map_", "Map ").title()
        self._build_board(self.map_id)
        self._init_players()
        rng = rng_for(task_id)
        self.dice_pairs = [(rng.randint(1, 6), rng.randint(1, 6)) for _ in range(400)]
        self.dice_index = 0
        self.deck = (
            ["knight"] * 14 + ["vp"] * 5 + ["monopoly"] * 2 + ["yop"] * 2 + ["road"] * 2
        )
        rng.shuffle(self.deck)
        self.phase = "setup"
        self.state = "setup_settle"
        self.current_player = 0
        self.setup_index = 0
        self.turn = 0
        self.longest_road = None
        self.largest_army = None
        self.log = ["Welcome to Catan. First to 10 points wins."]
        self.dice = [0, 0]
        self.roll_total = 0
        self.winner = None
        self.end_reason = ""
        self.final_reward = None
        self.last_move = None
        self.discard_remaining = 0
        self.trade_want = None
        self.yop_remaining = 0
        self.roadbuild_remaining = 0
        self.pending_road_vertex = None
        return self.observe()

    # ------------------------------------------------------------------ board

    def _build_board(self, map_id: str) -> None:
        terrain = MAPS[map_id]
        hex_ids: Dict[Tuple[int, int], int] = {}
        self.hex_list = []
        for r in range(-2, 3):
            for q in range(-2, 3):
                if max(abs(q), abs(r), abs(q + r)) <= 2:
                    hid = len(self.hex_list)
                    hex_ids[(q, r)] = hid
                    self.hex_list.append(
                        {
                            "id": hid,
                            "q": q,
                            "r": r,
                            "cx": math.sqrt(3) * (q + r / 2.0),
                            "cy": 1.5 * r,
                        }
                    )

        self.hex_neighbors = []
        for h in self.hex_list:
            q, r = h["q"], h["r"]
            neigh = []
            for dq, dr in AXIAL_NEIGHBORS:
                key = (q + dq, r + dr)
                if key in hex_ids:
                    neigh.append(hex_ids[key])
            self.hex_neighbors.append(neigh)

        numbers = self._place_numbers(terrain)

        self.hex_vertices = [[] for _ in self.hex_list]

        # corners -> vertices
        corner_vid: Dict[Tuple[int, int], int] = {}
        vert_key: Dict[Tuple[int, int], int] = {}
        self.vertices = []
        for h in self.hex_list:
            cx, cy = h["cx"], h["cy"]
            for k in range(6):
                ang = math.radians(30 + 60 * k)
                x = cx + math.cos(ang)
                y = cy + math.sin(ang)
                key = (round(x, 4), round(y, 4))
                if key not in vert_key:
                    vid = len(self.vertices)
                    vert_key[key] = vid
                    self.vertices.append(
                        {
                            "id": vid,
                            "x": x,
                            "y": y,
                            "hexes": set(),
                            "edges": [],
                            "adjacent": [],
                            "owner": None,
                            "building": None,
                            "harbour": None,
                        }
                    )
                vid = vert_key[key]
                corner_vid[(h["id"], k)] = vid
                self.vertices[vid]["hexes"].add(h["id"])

        # edges from consecutive corners
        edge_key: Dict[Tuple[int, int], int] = {}
        self.edges = []
        for h in self.hex_list:
            for k in range(6):
                v1 = corner_vid[(h["id"], k)]
                v2 = corner_vid[(h["id"], (k + 1) % 6)]
                a, b = sorted((v1, v2))
                key = (a, b)
                if key not in edge_key:
                    eid = len(self.edges)
                    edge_key[key] = eid
                    self.edges.append({"id": eid, "v1": a, "v2": b, "hexes": set(), "owner": None})
                self.edges[edge_key[key]]["hexes"].add(h["id"])

        for v in self.vertices:
            v["hexes"] = sorted(v["hexes"])
            for h in v["hexes"]:
                self.hex_vertices[h].append(v["id"])

        for e in self.edges:
            v1, v2 = e["v1"], e["v2"]
            self.vertices[v1]["edges"].append(e["id"])
            self.vertices[v2]["edges"].append(e["id"])
            self.vertices[v1]["adjacent"].append(v2)
            self.vertices[v2]["adjacent"].append(v1)

        # fill terrain, numbers, colours
        char_to_res = {"b": "brick", "l": "lumber", "o": "ore", "g": "grain", "w": "wool"}
        for h in self.hex_list:
            res = terrain[h["id"]]
            h["resource"] = None if res == "d" else char_to_res[res]
            h["desert"] = res == "d"
            h["number"] = numbers[h["id"]]

        # scale to pixels
        allx = [v["x"] for v in self.vertices] + [h["cx"] for h in self.hex_list]
        ally = [v["y"] for v in self.vertices] + [h["cy"] for h in self.hex_list]
        minx, maxx = min(allx), max(allx)
        miny, maxy = min(ally), max(ally)
        S = self.hex_size
        PAD = 30.0

        def px(x: float) -> float:
            return PAD + (x - minx) * S

        def py(y: float) -> float:
            return PAD + (y - miny) * S

        for v in self.vertices:
            v["px"] = round(px(v["x"]), 1)
            v["py"] = round(py(v["y"]), 1)
        for h in self.hex_list:
            h["px"] = round(px(h["cx"]), 1)
            h["py"] = round(py(h["cy"]), 1)
        for e in self.edges:
            v1, v2 = self.vertices[e["v1"]], self.vertices[e["v2"]]
            e["x1"] = v1["px"]
            e["y1"] = v1["py"]
            e["x2"] = v2["px"]
            e["y2"] = v2["py"]

        self.board_w = int(math.ceil(px(maxx) + PAD))
        self.board_h = int(math.ceil(py(maxy) + PAD))

        self.robber_hex = next(
            (h["id"] for h in self.hex_list if h["desert"]), 0
        )
        self._assign_harbours()

    def _place_numbers(self, terrain: str) -> Dict[int, Optional[int]]:
        import hashlib

        seed = int(hashlib.md5(self.map_id.encode()).hexdigest()[:8], 16)
        rng = random.Random(seed)
        non_desert = [h["id"] for h in self.hex_list if terrain[h["id"]] != "d"]

        # pick 4 pairwise non-adjacent red hexes
        center = next(h["id"] for h in self.hex_list if h["q"] == 0 and h["r"] == 0)
        reds: List[int] = []
        ordered = sorted(non_desert, key=lambda h: self._hex_dist(h, center))
        for h in ordered:
            if all(not self._hexes_adjacent(h, r) for r in reds):
                reds.append(h)
            if len(reds) == 4:
                break

        numbers: Dict[int, Optional[int]] = {h: None for h in range(len(self.hex_list))}
        red_tokens = [6, 8, 6, 8]
        rng.shuffle(red_tokens)
        for i, h in enumerate(reds):
            numbers[h] = red_tokens[i]

        rest = [2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12]
        rng.shuffle(rest)
        remaining = [h for h in non_desert if numbers[h] is None]
        for i, h in enumerate(remaining):
            numbers[h] = rest[i]
        return numbers

    def _hex_dist(self, h: int, center: int) -> int:
        hq, hr = self.hex_list[h]["q"], self.hex_list[h]["r"]
        cq, cr = self.hex_list[center]["q"], self.hex_list[center]["r"]
        dq, dr = hq - cq, hr - cr
        return (abs(dq) + abs(dr) + abs(dq + dr)) // 2

    def _hexes_adjacent(self, a: int, b: int) -> bool:
        return b in self.hex_neighbors[a]

    def _assign_harbours(self) -> None:
        center = next(h["id"] for h in self.hex_list if h["q"] == 0 and h["r"] == 0)
        cx0, cy0 = self.hex_list[center]["cx"], self.hex_list[center]["cy"]
        coastal = [v["id"] for v in self.vertices if len(v["hexes"]) == 2]

        def ang(vid: int) -> float:
            v = self.vertices[vid]
            return math.atan2(v["y"] - cy0, v["x"] - cx0)

        coastal.sort(key=ang)
        n = len(coastal)
        picked: List[int] = []
        if n:
            for i in range(9):
                idx = round(i * (n - 1) / 8)
                if coastal[idx] not in picked:
                    picked.append(coastal[idx])
        if len(picked) < 9:
            for c in coastal:
                if c not in picked:
                    picked.append(c)
                if len(picked) == 9:
                    break

        assigned = set()
        for r in RESOURCES:
            best, bestscore = None, -1
            for v in picked:
                if v in assigned:
                    continue
                score = sum(1 for h in self.vertices[v]["hexes"] if self.hex_list[h]["resource"] == r)
                if score > bestscore:
                    bestscore, best = score, v
            if best is not None:
                self.vertices[best]["harbour"] = {"type": "2:1", "resource": r}
                assigned.add(best)
        for v in picked:
            if v not in assigned:
                self.vertices[v]["harbour"] = {"type": "3:1", "resource": None}

    # ------------------------------------------------------------------ players

    def _init_players(self) -> None:
        self.players = []
        for _ in range(4):
            self.players.append(
                {
                    "resources": {r: 0 for r in RESOURCES},
                    "dev": {"knight": 0, "vp": 0, "monopoly": 0, "yop": 0, "road": 0},
                    "new_dev": {"knight": 0, "vp": 0, "monopoly": 0, "yop": 0, "road": 0},
                    "knights": 0,
                    "vp_cards": 0,
                    "roads": [],
                    "settlements": [],
                    "cities": [],
                }
            )

    def _total_cards(self, p: int) -> int:
        return sum(self.players[p]["resources"].values())

    def _can_afford(self, p: int, cost: Dict[str, int]) -> bool:
        res = self.players[p]["resources"]
        return all(res[r] >= n for r, n in cost.items())

    def _pay(self, p: int, cost: Dict[str, int]) -> None:
        res = self.players[p]["resources"]
        for r, n in cost.items():
            res[r] -= n

    def _points(self, p: int) -> int:
        pl = self.players[p]
        return (
            len(pl["settlements"])
            + 2 * len(pl["cities"])
            + pl["vp_cards"]
            + (2 if self.longest_road == p else 0)
            + (2 if self.largest_army == p else 0)
        )

    # ------------------------------------------------------------------ legality

    def _vertex_free_for_settlement(self, vid: int, p: int, setup: bool) -> bool:
        v = self.vertices[vid]
        if v["owner"] is not None:
            return False
        for adj in v["adjacent"]:
            if self.vertices[adj]["owner"] is not None:
                return False
        if not setup:
            if not any(self.edges[e]["owner"] == p for e in v["edges"]):
                return False
        return True

    def _road_legal(self, eid: int, p: int) -> bool:
        e = self.edges[eid]
        if e["owner"] is not None:
            return False
        for v in (e["v1"], e["v2"]):
            vv = self.vertices[v]
            if vv["owner"] is not None and vv["owner"] != p:
                continue
            if vv["owner"] == p:
                return True
            if any(self.edges[o]["owner"] == p for o in vv["edges"] if o != eid):
                return True
        return False

    def _spot_score(self, vid: int) -> float:
        v = self.vertices[vid]
        score = 0.0
        for h in v["hexes"]:
            num = self.hex_list[h]["number"]
            if num is not None:
                score += WEIGHT[num]
        return score

    # ------------------------------------------------------------------ actions

    def _place_settlement(self, vid: int, p: int, setup_round: int = 0) -> None:
        v = self.vertices[vid]
        v["owner"] = p
        v["building"] = "settlement"
        self.players[p]["settlements"].append(vid)
        if setup_round == 2:
            self._grant_setup_resources(p, vid)

    def _grant_setup_resources(self, p: int, vid: int) -> None:
        for h in self.vertices[vid]["hexes"]:
            res = self.hex_list[h]["resource"]
            if res:
                self.players[p]["resources"][res] += 1

    def _place_city(self, vid: int, p: int) -> None:
        v = self.vertices[vid]
        v["building"] = "city"
        self.players[p]["settlements"].remove(vid)
        self.players[p]["cities"].append(vid)

    def _place_road(self, eid: int, p: int) -> None:
        self.edges[eid]["owner"] = p
        self.players[p]["roads"].append(eid)

    def _draw_dev(self, p: int) -> str:
        card = self.deck.pop(0)
        pl = self.players[p]
        pl["dev"][card] += 1
        pl["new_dev"][card] += 1
        if card == "vp":
            pl["vp_cards"] += 1
        return card

    def _produce(self, total: int) -> None:
        gains = {r: 0 for r in RESOURCES}
        for h in self.hex_list:
            if h["desert"] or h["id"] == self.robber_hex:
                continue
            if h["number"] != total:
                continue
            for vid in self.hex_vertices[h["id"]]:
                v = self.vertices[vid]
                if v["owner"] is None:
                    continue
                amt = 2 if v["building"] == "city" else 1
                self.players[v["owner"]]["resources"][h["resource"]] += amt
                if v["owner"] == 0:
                    gains[h["resource"]] += amt
        if any(gains.values()):
            parts = ", ".join(f"{n} {RES_LABELS[r]}" for r, n in gains.items() if n)
            self._log(f"You gained {parts}")

    def _has_building_on_hex(self, p: int, h: int) -> bool:
        for vid in self.hex_vertices[h]:
            if self.vertices[vid]["owner"] == p:
                return True
        return False

    def _steal_victims(self) -> List[int]:
        victims = []
        for p in range(4):
            if p == self.current_player:
                continue
            if self._total_cards(p) == 0:
                continue
            if self._has_building_on_hex(p, self.robber_hex):
                victims.append(p)
        return victims

    def _do_steal(self, thief: int, victim: int) -> None:
        res = self.players[victim]["resources"]
        cand = [r for r in RESOURCES if res[r] > 0]
        if not cand:
            return
        r = max(cand, key=lambda x: res[x])
        res[r] -= 1
        self.players[thief]["resources"][r] += 1
        self._log(f"{_name(thief)} stole a {RES_LABELS[r]} from {_name(victim)}")

    def _harbour_rates(self, p: int) -> Dict[str, int]:
        rates = {r: 4 for r in RESOURCES}
        has3 = False
        for vid in self.players[p]["settlements"] + self.players[p]["cities"]:
            hb = self.vertices[vid]["harbour"]
            if hb:
                if hb["type"] == "2:1":
                    rates[hb["resource"]] = 2
                else:
                    has3 = True
        if has3:
            for r in RESOURCES:
                rates[r] = min(rates[r], 3)
        return rates

    # ------------------------------------------------------------------ longest road

    def _longest_road_len(self, p: int) -> int:
        adj: Dict[int, List[Tuple[int, int]]] = {}
        for e in self.edges:
            if e["owner"] == p:
                adj.setdefault(e["v1"], []).append((e["id"], e["v2"]))
                adj.setdefault(e["v2"], []).append((e["id"], e["v1"]))

        def blocked(v: int) -> bool:
            o = self.vertices[v]["owner"]
            return o is not None and o != p

        best = 0

        def dfs(v: int, used: frozenset) -> None:
            nonlocal best
            best = max(best, len(used))
            for eid, w in adj.get(v, []):
                if eid in used:
                    continue
                if blocked(w):
                    continue
                dfs(w, used | {eid})

        for v in adj:
            dfs(v, frozenset())
        return best

    def _recompute_awards(self) -> None:
        lengths = [self._longest_road_len(p) for p in range(4)]
        maxlen = max(lengths)
        if maxlen >= 5:
            holders = [p for p in range(4) if lengths[p] == maxlen]
            if self.longest_road not in holders:
                self.longest_road = holders[0]
        else:
            self.longest_road = None

        knights = [self.players[p]["knights"] for p in range(4)]
        maxk = max(knights)
        if maxk >= 3:
            holders = [p for p in range(4) if knights[p] == maxk]
            if self.largest_army not in holders:
                self.largest_army = holders[0]
        else:
            self.largest_army = None

    def _check_win(self) -> bool:
        for p in range(4):
            if self._points(p) >= 10:
                self._end_game(p, "win")
                return True
        return False

    def _end_game(self, winner: Optional[int], reason: str) -> None:
        if self.phase == "over":
            return
        self.phase = "over"
        self.state = "over"
        if winner is None:
            winner = max(range(4), key=lambda p: self._points(p))
        self.winner = winner
        self.end_reason = reason
        wp = self._points(winner)
        hp = self._points(0)
        if reason == "win":
            self.final_reward = 1.0 if winner == 0 else -1.0
        else:
            self.final_reward = 1.0 if winner == 0 else (hp / wp if wp > 0 else 0.0)
        self._log(f"Game over — {_name(winner)} wins!")

    # ------------------------------------------------------------------ logging

    def _log(self, text: str) -> None:
        self.log.append(text)
        if len(self.log) > 40:
            self.log = self.log[-40:]

    # ------------------------------------------------------------------ dice / turns

    def _next_dice(self) -> Tuple[int, int]:
        pair = self.dice_pairs[self.dice_index]
        self.dice_index += 1
        return pair

    def _start_human_turn(self) -> None:
        p = 0
        self.players[p]["new_dev"] = {"knight": 0, "vp": 0, "monopoly": 0, "yop": 0, "road": 0}
        d1, d2 = self._next_dice()
        total = d1 + d2
        self.dice = [d1, d2]
        self.roll_total = total
        self._log(f"You rolled {total}")
        if total == 7:
            if self._total_cards(p) > 7:
                self.state = "discard"
                self.discard_remaining = self._total_cards(p) // 2
            else:
                self.state = "rob_move"
        else:
            self._produce(total)
            self.state = "actions"

    def _advance_turn(self) -> None:
        self.current_player = (self.current_player + 1) % 4
        if self.current_player == 0:
            self.turn += 1

    def _advance(self) -> None:
        while True:
            if self.phase == "over":
                return
            if self.phase == "setup":
                if self.setup_index >= len(SETUP_ORDER):
                    self.phase = "main"
                    self.current_player = 0
                    self._start_human_turn()
                    return
                p = SETUP_ORDER[self.setup_index][0]
                if p == 0:
                    self.state = "setup_settle"
                    return
                self._bot_setup_placement(p)
                self.setup_index += 1
                continue
            # main phase
            if self.current_player == 0:
                self._start_human_turn()
                return
            self._bot_turn(self.current_player)
            if self.phase == "over":
                return
            self._advance_turn()
            if self.turn >= TURN_LIMIT:
                self._end_game(None, "limit")
                return
            continue

    # ------------------------------------------------------------------ bots

    def _bot_setup_placement(self, p: int) -> None:
        rnd = SETUP_ORDER[self.setup_index][1]
        spots = [v for v in range(len(self.vertices)) if self._vertex_free_for_settlement(v, p, True)]
        spot = max(spots, key=self._spot_score)
        self._place_settlement(spot, p, rnd)
        self._log(f"{_name(p)} placed a settlement")
        roads = [e for e in self.vertices[spot]["edges"] if self.edges[e]["owner"] is None]
        if roads:
            best = max(roads, key=lambda e: self._edge_road_score(e))
            self._place_road(best, p)
            self._log(f"{_name(p)} placed a road")

    def _edge_road_score(self, eid: int) -> float:
        e = self.edges[eid]
        score = 0.0
        for v in (e["v1"], e["v2"]):
            if self.vertices[v]["owner"] is None:
                score = max(score, self._spot_score(v))
        return score

    def _bot_turn(self, p: int) -> None:
        self.players[p]["new_dev"] = {"knight": 0, "vp": 0, "monopoly": 0, "yop": 0, "road": 0}
        d1, d2 = self._next_dice()
        total = d1 + d2
        self.dice = [d1, d2]
        self.roll_total = total
        self._log(f"{_name(p)} rolled {total}")
        if total == 7:
            self._bot_discard(p)
            self._bot_move_robber(p)
            self._bot_steal(p)
        else:
            self._produce(total)
        # play a knight if available
        if self.players[p]["dev"]["knight"] > self.players[p]["new_dev"]["knight"]:
            self._bot_play_knight(p)
        self._bot_build(p)

    def _bot_discard(self, p: int) -> None:
        total = self._total_cards(p)
        if total <= 7:
            return
        drop = total // 2
        res = self.players[p]["resources"]
        for _ in range(drop):
            r = max(RESOURCES, key=lambda x: res[x])
            if res[r] == 0:
                break
            res[r] -= 1
        self._log(f"{_name(p)} discarded {drop} cards to the robber")

    def _bot_move_robber(self, p: int) -> None:
        best, bestscore = None, -1.0
        for h in self.hex_list:
            if h["id"] == self.robber_hex:
                continue
            if h["number"] is None:
                continue
            score = 0.0
            for vid in self.hex_vertices[h["id"]]:
                v = self.vertices[vid]
                if v["owner"] is not None and v["owner"] != p:
                    score += WEIGHT[h["number"]] * (2 if v["building"] == "city" else 1)
            if score > bestscore:
                bestscore, best = score, h["id"]
        if best is None:
            best = next(h["id"] for h in self.hex_list if h["id"] != self.robber_hex)
        self.robber_hex = best
        self._log(f"{_name(p)} moved the robber")

    def _bot_steal(self, p: int) -> None:
        victims = self._steal_victims()
        if not victims:
            return
        victim = max(victims, key=lambda x: self._points(x))
        self._do_steal(p, victim)

    def _bot_play_knight(self, p: int) -> None:
        self.players[p]["dev"]["knight"] -= 1
        self.players[p]["knights"] += 1
        self._log(f"{_name(p)} played a knight")
        self._recompute_awards()
        self._bot_move_robber(p)
        self._bot_steal(p)

    def _bot_build(self, p: int) -> None:
        for _ in range(12):
            if self._bot_build_settlement(p):
                continue
            if self._bot_build_city(p):
                continue
            if self._bot_buy_dev(p):
                continue
            if self._bot_build_road(p):
                continue
            if self._bot_play_dev(p):
                continue
            if self._bot_trade(p):
                continue
            break

    def _bot_build_settlement(self, p: int) -> bool:
        if not self._can_afford(p, COSTS["settlement"]):
            return False
        spots = [v for v in range(len(self.vertices)) if self._vertex_free_for_settlement(v, p, False)]
        if not spots:
            return False
        spot = max(spots, key=self._spot_score)
        self._pay(p, COSTS["settlement"])
        self._place_settlement(spot, p)
        self._log(f"{_name(p)} built a settlement")
        self._recompute_awards()
        self._check_win()
        return True

    def _bot_build_city(self, p: int) -> bool:
        if not self._can_afford(p, COSTS["city"]):
            return False
        if not self.players[p]["settlements"]:
            return False
        vid = max(self.players[p]["settlements"], key=self._spot_score)
        self._pay(p, COSTS["city"])
        self._place_city(vid, p)
        self._log(f"{_name(p)} upgraded to a city")
        self._recompute_awards()
        self._check_win()
        return True

    def _bot_buy_dev(self, p: int) -> bool:
        if not self.deck:
            return False
        if not self._can_afford(p, COSTS["dev"]):
            return False
        self._pay(p, COSTS["dev"])
        card = self._draw_dev(p)
        if card != "vp":
            self._log(f"{_name(p)} bought a development card")
        else:
            self._log(f"{_name(p)} bought a development card")
        self._check_win()
        return True

    def _bot_build_road(self, p: int) -> bool:
        if not self._can_afford(p, COSTS["road"]):
            return False
        roads = [e for e in range(len(self.edges)) if self._road_legal(e, p)]
        if not roads:
            return False
        best = max(roads, key=self._edge_road_score)
        self._pay(p, COSTS["road"])
        self._place_road(best, p)
        self._recompute_awards()
        return True

    def _bot_play_dev(self, p: int) -> bool:
        pl = self.players[p]
        if pl["dev"]["monopoly"] > pl["new_dev"]["monopoly"]:
            r = max(RESOURCES, key=lambda x: sum(self.players[q]["resources"][x] for q in range(4) if q != p))
            pl["dev"]["monopoly"] -= 1
            got = 0
            for q in range(4):
                if q != p:
                    got += self.players[q]["resources"][r]
                    self.players[q]["resources"][r] = 0
            pl["resources"][r] += got
            self._log(f"{_name(p)} played Monopoly on {RES_LABELS[r]}")
            return True
        if pl["dev"]["yop"] > pl["new_dev"]["yop"]:
            pl["dev"]["yop"] -= 1
            need = [r for r in RESOURCES if pl["resources"][r] < 1]
            picks = (need + RESOURCES)[:2]
            for r in picks:
                pl["resources"][r] += 1
            self._log(f"{_name(p)} played Year of Plenty")
            return True
        if pl["dev"]["road"] > pl["new_dev"]["road"]:
            pl["dev"]["road"] -= 1
            built = 0
            for _ in range(2):
                roads = [e for e in range(len(self.edges)) if self._road_legal(e, p)]
                if not roads:
                    break
                best = max(roads, key=self._edge_road_score)
                self._place_road(best, p)
                built += 1
            if built:
                self._log(f"{_name(p)} played Road Building")
                self._recompute_awards()
                return True
            return False
        return False

    def _bot_trade(self, p: int) -> bool:
        res = self.players[p]["resources"]
        rates = self._harbour_rates(p)
        give = max(RESOURCES, key=lambda x: res[x])
        if res[give] < 4:
            return False
        want = min(RESOURCES, key=lambda x: res[x])
        if want == give:
            return False
        rate = rates[give]
        res[give] -= rate
        res[want] += 1
        self._log(f"{_name(p)} traded {rate} {RES_LABELS[give]} for a {RES_LABELS[want]}")
        return True

    # ------------------------------------------------------------------ legal moves

    def legal_moves(self) -> List[str]:
        if self.phase == "over":
            return []
        if self.phase == "setup":
            if self.state == "setup_settle":
                return [
                    f"settle:{v}"
                    for v in range(len(self.vertices))
                    if self._vertex_free_for_settlement(v, 0, True)
                ]
            if self.state == "setup_road":
                return [
                    f"road:{e}"
                    for e in self.vertices[self.pending_road_vertex]["edges"]
                    if self.edges[e]["owner"] is None
                ]
            return []

        s = self.state
        res = self.players[0]["resources"]
        if s == "discard":
            return [f"discard:{r}" for r in RESOURCES if res[r] > 0]
        if s == "rob_move":
            return [f"rob:{h['id']}" for h in self.hex_list if h["id"] != self.robber_hex]
        if s == "rob_steal":
            return [f"steal:{p}" for p in self._steal_victims()]
        if s == "mono":
            return [f"mono:{r}" for r in RESOURCES]
        if s == "yop":
            return [f"yop:{r}" for r in RESOURCES]
        if s == "roadbuild":
            edges = [e for e in range(len(self.edges)) if self._road_legal(e, 0)]
            moves = [f"rb:{e}" for e in edges]
            if self.roadbuild_remaining < 2 or not edges:
                moves.append("done")
            return moves
        if s == "trade_want":
            return [f"twant:{r}" for r in RESOURCES] + ["cancel"]
        if s == "trade_give":
            rates = self._harbour_rates(0)
            want = self.trade_want
            moves = []
            for r in RESOURCES:
                if r == want:
                    continue
                if res[r] >= rates[r]:
                    moves.append(f"tgive:{r}")
            moves.append("cancel")
            return moves

        # actions
        moves = []
        for v in range(len(self.vertices)):
            if self._vertex_free_for_settlement(v, 0, False) and self._can_afford(0, COSTS["settlement"]):
                moves.append(f"settle:{v}")
        if self._can_afford(0, COSTS["city"]):
            for v in self.players[0]["settlements"]:
                moves.append(f"city:{v}")
        if self._can_afford(0, COSTS["road"]):
            for e in range(len(self.edges)):
                if self._road_legal(e, 0):
                    moves.append(f"road:{e}")
        if self.deck and self._can_afford(0, COSTS["dev"]):
            moves.append("buy_dev")
        dev = self.players[0]["dev"]
        nd = self.players[0]["new_dev"]
        if dev["knight"] > nd["knight"]:
            moves.append("play:knight")
        if dev["monopoly"] > nd["monopoly"]:
            moves.append("play:mono")
        if dev["yop"] > nd["yop"]:
            moves.append("play:yop")
        if dev["road"] > nd["road"]:
            moves.append("play:road")
        moves.append("trade")
        moves.append("end_turn")
        return moves

    # ------------------------------------------------------------------ step

    def step(self, action: Any) -> Tuple[Dict[str, Any], bool, Optional[str]]:
        move = getattr(action, "move", None) or ""
        if self.phase == "over":
            return self.observe(), False, "game over"
        legal = self.legal_moves()
        if move not in legal:
            return self.observe(), False, f"not legal: {move}"
        self.last_move = move
        err = self._apply_human(move)
        if err:
            return self.observe(), False, err
        return self.observe(), True, None

    def _apply_human(self, move: str) -> Optional[str]:
        s = self.state
        if s == "setup_settle":
            vid = int(move.split(":", 1)[1])
            rnd = SETUP_ORDER[self.setup_index][1]
            self._place_settlement(vid, 0, rnd)
            self.pending_road_vertex = vid
            self.state = "setup_road"
            self._log("You placed a settlement")
            return None
        if s == "setup_road":
            eid = int(move.split(":", 1)[1])
            self._place_road(eid, 0)
            self._log("You placed a road")
            self.setup_index += 1
            self._advance()
            return None
        if s == "discard":
            r = move.split(":", 1)[1]
            self.players[0]["resources"][r] -= 1
            self.discard_remaining -= 1
            self._log(f"You discarded a {RES_LABELS[r]}")
            if self.discard_remaining <= 0:
                self.state = "rob_move"
            return None
        if s == "rob_move":
            h = int(move.split(":", 1)[1])
            self.robber_hex = h
            self._log("You moved the robber")
            victims = self._steal_victims()
            if not victims:
                self.state = "actions"
            elif len(victims) == 1:
                self._do_steal(0, victims[0])
                self.state = "actions"
            else:
                self.state = "rob_steal"
            return None
        if s == "rob_steal":
            p = int(move.split(":", 1)[1])
            self._do_steal(0, p)
            self.state = "actions"
            return None
        if s == "mono":
            r = move.split(":", 1)[1]
            self.players[0]["dev"]["monopoly"] -= 1
            got = 0
            for p in range(1, 4):
                got += self.players[p]["resources"][r]
                self.players[p]["resources"][r] = 0
            self.players[0]["resources"][r] += got
            self._log(f"You played Monopoly on {RES_LABELS[r]} (+{got})")
            self.state = "actions"
            return None
        if s == "yop":
            r = move.split(":", 1)[1]
            self.players[0]["resources"][r] += 1
            self.yop_remaining -= 1
            if self.yop_remaining <= 0:
                self.state = "actions"
            return None
        if s == "roadbuild":
            if move == "done":
                self.state = "actions"
                return None
            eid = int(move.split(":", 1)[1])
            self._place_road(eid, 0)
            self.roadbuild_remaining -= 1
            self._recompute_awards()
            if self.roadbuild_remaining <= 0 or not any(self._road_legal(e, 0) for e in range(len(self.edges))):
                self.state = "actions"
            return None
        if s == "trade_want":
            if move == "cancel":
                self.state = "actions"
                return None
            self.trade_want = move.split(":", 1)[1]
            self.state = "trade_give"
            return None
        if s == "trade_give":
            if move == "cancel":
                self.trade_want = None
                self.state = "actions"
                return None
            give = move.split(":", 1)[1]
            rates = self._harbour_rates(0)
            rate = rates[give]
            self.players[0]["resources"][give] -= rate
            self.players[0]["resources"][self.trade_want] += 1
            self._log(f"You traded {rate} {RES_LABELS[give]} for a {RES_LABELS[self.trade_want]}")
            self.trade_want = None
            self.state = "actions"
            return None
        if s == "actions":
            if move == "end_turn":
                self._log("You ended your turn")
                self._advance_turn()
                if self.turn >= TURN_LIMIT:
                    self._end_game(None, "limit")
                else:
                    self._advance()
                return None
            if move == "buy_dev":
                if not self.deck:
                    return "no cards left"
                self._pay(0, COSTS["dev"])
                self._draw_dev(0)
                self._log("You bought a development card")
                self._check_win()
                return None
            if move == "trade":
                self.state = "trade_want"
                self.trade_want = None
                return None
            if move.startswith("settle:"):
                vid = int(move.split(":", 1)[1])
                self._pay(0, COSTS["settlement"])
                self._place_settlement(vid, 0)
                self._log("You built a settlement")
                self._recompute_awards()
                self._check_win()
                return None
            if move.startswith("city:"):
                vid = int(move.split(":", 1)[1])
                self._pay(0, COSTS["city"])
                self._place_city(vid, 0)
                self._log("You upgraded to a city")
                self._recompute_awards()
                self._check_win()
                return None
            if move.startswith("road:"):
                eid = int(move.split(":", 1)[1])
                self._pay(0, COSTS["road"])
                self._place_road(eid, 0)
                self._log("You built a road")
                self._recompute_awards()
                self._check_win()
                return None
            if move == "play:knight":
                self.players[0]["dev"]["knight"] -= 1
                self.players[0]["knights"] += 1
                self._log("You played a knight")
                self._recompute_awards()
                self.state = "rob_move"
                return None
            if move == "play:mono":
                self.players[0]["dev"]["monopoly"] -= 1
                self.state = "mono"
                return None
            if move == "play:yop":
                self.players[0]["dev"]["yop"] -= 1
                self.yop_remaining = 2
                self.state = "yop"
                return None
            if move == "play:road":
                self.players[0]["dev"]["road"] -= 1
                self.roadbuild_remaining = 2
                self.state = "roadbuild"
                return None
        return f"unhandled move in state {s}"

    # ------------------------------------------------------------------ observe

    def _status(self) -> str:
        if self.phase == "over":
            return f"Game over — {_name(self.winner)} wins!"
        if self.phase == "setup":
            if self.state == "setup_settle":
                rnd = SETUP_ORDER[self.setup_index][1]
                return "Place your first settlement" if rnd == 1 else "Place your second settlement"
            return "Place a road next to your settlement"
        s = self.state
        if s == "discard":
            return f"You must discard {self.discard_remaining} cards"
        if s == "rob_move":
            return "Move the robber to a hex"
        if s == "rob_steal":
            return "Choose who to steal from"
        if s == "mono":
            return "Choose a resource to monopolise"
        if s == "yop":
            return "Choose a resource to receive"
        if s == "roadbuild":
            return "Place a free road (or done)"
        if s == "trade_want":
            return "Choose a resource to receive"
        if s == "trade_give":
            return "Choose a resource to give"
        return "Your turn — build, buy, trade, or end turn"

    def observe(self) -> Dict[str, Any]:
        done = self.phase == "over"
        reward = self.final_reward if (done and self.final_reward is not None) else 0.0
        hexes = [
            {
                "id": h["id"],
                "x": h["px"],
                "y": h["py"],
                "resource": h["resource"],
                "number": h["number"],
            }
            for h in self.hex_list
        ]
        vertices = [
            {
                "id": v["id"],
                "x": v["px"],
                "y": v["py"],
                "owner": v["owner"],
                "building": v["building"],
                "harbour": v["harbour"],
            }
            for v in self.vertices
        ]
        edges = [
            {
                "id": e["id"],
                "x1": e["x1"],
                "y1": e["y1"],
                "x2": e["x2"],
                "y2": e["y2"],
                "owner": e["owner"],
            }
            for e in self.edges
        ]
        players = [
            {
                "id": p,
                "label": PLAYER_NAMES[p],
                "name": "You" if p == 0 else PLAYER_NAMES[p],
                "color": PLAYER_COLORS[p],
                "stroke": PLAYER_STROKE[p],
                "points": self._points(p),
                "human": p == 0,
            }
            for p in range(4)
        ]
        resources = [
            {"id": r, "label": RES_LABELS[r], "color": RES_COLORS[r]} for r in RESOURCES
        ]
        hand = {r: self.players[0]["resources"][r] for r in RESOURCES}
        dev = dict(self.players[0]["dev"])

        extra = {
            "task_id": self.task_id,
            "map_id": self.map_id,
            "map_label": self.map_label,
            "phase": self.phase,
            "status": self._status(),
            "turn": self.turn,
            "current_player": self.current_player,
            "players": players,
            "resources": resources,
            "hand": hand,
            "dev_cards": dev,
            "dice": self.dice,
            "roll_total": self.roll_total,
            "robber_hex": self.robber_hex,
            "longest_road": self.longest_road,
            "largest_army": self.largest_army,
            "hexes": hexes,
            "vertices": vertices,
            "edges": edges,
            "hex_size": self.hex_size,
            "board_w": self.board_w,
            "board_h": self.board_h,
            "log": self.log,
            "winner": self.winner,
            "player_names": PLAYER_NAMES,
            "player_colors": PLAYER_COLORS,
        }
        return base_observation(
            reward=reward,
            done=done,
            legal_moves=self.legal_moves(),
            last_move=self.last_move,
            **extra,
        )


GAME_CLASS = "Catan"