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
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.
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
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"