From a98e2c3c1e98b7d76009c049c83b9cb4311c141a Mon Sep 17 00:00:00 2001 From: Bram van den Heuvel Date: Sun, 5 Jul 2026 08:29:30 +0200 Subject: [PATCH] Create UrHeuristic agent --- agents/ur_heuristic.py | 121 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 agents/ur_heuristic.py diff --git a/agents/ur_heuristic.py b/agents/ur_heuristic.py new file mode 100644 index 0000000..97b7255 --- /dev/null +++ b/agents/ur_heuristic.py @@ -0,0 +1,121 @@ +""" + This module contains a heuristic in the Royal Game of Ur. +""" + +from __future__ import annotations + +import random + +from .agent import Agent, Payload + +BAD_OMEN = { + -1 : 4 / 16, + -2 : 6 / 16, + -3 : 4 / 16, + -4 : 1 / 16, +} + +class UrHeuristic(Agent): + """ + Describe here what your agent does and how it behaves! This will help + others understand how your agent works. + """ + + def __init__(self) -> None: + """ + Create a custom instance of your agent. This function allows you + to define which games your agent can play. + + You may add parameters to the function if your agent requires more + information to be able to operate. + """ + + super().__init__( + name="Ur heuristic agent", + author="Bram", + version="1.0.0", + profile={ + "me.noordstar.peanuts.is_ai": False, + }, + ) + + # Indicate that you're willing to play tic-tac-toe + # Remove this if you don't want your bot to participate there. + self.add_royal_game_of_ur(on_move=self.play_ur, profile={}) + + def enemy_at(self, payload : Payload, i : int) -> bool: + """ + Determine whether an enemy is at a given position. + """ + return payload.get(f"{i}_enemy", payload.get(str(i), "")) == "ENEMY" + + def play_ur(self, payload : Payload) -> Payload: + """ + Use a heuristic. The score of each state is the total number of + spaces that your pieces have moved forward minus the total number + of spaces that your opponent's pieces have moved forward. + """ + moves = [ int(n) for n in payload.get("valid_moves", [0]) ] + star_fields = [ int(n) for n in payload.get("star_fields", []) ] + safe_fields = [ int(n) for n in payload.get("safe_fields", []) ] + roll = int(payload.get("roll", 0)) + + scores : dict[int, float] = { i : 0 for i in moves } + + for m in moves: + dest = m + roll + + # The total sum of steps taken will be the same regardless of which + # piece we move. Hence we ignore the roll itself. + # + # There's a few other aspects we need to keep in mind, however. + + # If we can hit a star, we'll be able to roll again. + # The expected value is that we'll be able to take 2 extra steps. + if dest in star_fields: + scores[m] += 2 + + # If we can capture an enemy's piece, they'll be put back to start. + # That is a massive gain of points. + if self.enemy_at(payload=payload, i=dest) and dest not in safe_fields and dest not in star_fields: + scores[m] += dest + + # Every spot has a certain danger to it. + # Improve the score based on the danger of the spot we're leaving. + scores[m] += self.risk_score(payload=payload, i=m) + + # Similarly, punish the score based on the danger we're getting + # ourselves into. + scores[m] -= self.risk_score(payload=payload, i=dest) + + # Near the end, you can get stuck if it takes too long to finish + # Therefore, slightly before the finish, you're rewarded with + # finishing precisely. + off = m - 15 + if off in BAD_OMEN: + scores[m] = 1 / BAD_OMEN[off] + + highscore = max(scores.values()) + keys = [ pos for pos, score in scores.items() if score == highscore ] + + return { "move": random.choice(keys) } + + def risk_score(self, payload : Payload, i : int) -> float: + """ + What is the risk score of a given field? + + The risk score is the expected number of steps you'll need to move + back as a result of your opponent's next turn. + """ + + if i in list(payload.get("safe_fields", [])) or i in list(payload.get("star_fields", [])): + return 0.0 + + score : float = 0.0 + + for offset, risk in BAD_OMEN.items(): + if self.enemy_at(payload=payload, i=i+offset): + score += risk + + return i * score +