File size: 4,020 Bytes
4be6a52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
"""Players receive visible state only; episode seeds stay in the runner."""

import math
import random
from dataclasses import dataclass
from typing import Protocol

from stackcraft.engine import legal_actions, place
from stackcraft.schema import HEIGHT, RULES_VERSION, WIDTH, Board, GameState, Placement


@dataclass(frozen=True)
class Observation:
    board: Board
    current: str
    next_piece: str
    legal_actions: tuple[Placement, ...]
    rules_version: str = RULES_VERSION


@dataclass(frozen=True)
class Decision:
    action_id: str
    probabilities: dict[str, float] | None = None


class Player(Protocol):
    name: str
    revision: str

    def choose(self, observation: Observation) -> Decision: ...


def observe(state: GameState) -> Observation:
    """Do not expose seed, piece index, or any hidden random-generator state."""
    return Observation(state.board, state.current, state.next_piece, legal_actions(state))


def validate_decision(decision: Decision, observation: Observation) -> None:
    if not isinstance(decision, Decision):
        raise ValueError("player must return a Decision")
    ids = {action.id for action in observation.legal_actions}
    if not isinstance(decision.action_id, str) or decision.action_id not in ids:
        raise ValueError("player selected an illegal action")
    probabilities = decision.probabilities
    if probabilities is None:
        return
    if not isinstance(probabilities, dict) or probabilities.keys() != ids:
        raise ValueError("probabilities must cover every legal action exactly")
    if any(
        type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= 1
        for value in probabilities.values()
    ):
        raise ValueError("probabilities must be finite numbers between zero and one")
    if not math.isclose(sum(probabilities.values()), 1.0, rel_tol=0, abs_tol=1e-6):
        raise ValueError("probabilities must sum to one")


class RandomPlayer:
    """Uniform legal placement with an RNG independent of the piece generator."""

    name = "random"

    def __init__(self, seed: int = 0) -> None:
        if type(seed) is not int:
            raise ValueError("player RNG seed must be an integer")
        self.revision = f"random-v1-rng-{seed}"
        self._rng = random.Random(seed)

    def choose(self, observation: Observation) -> Decision:
        if not observation.legal_actions:
            raise ValueError("cannot choose with no legal actions")
        actions = observation.legal_actions
        return Decision(
            self._rng.choice(actions).id,
            {action.id: 1.0 / len(actions) for action in actions},
        )


@dataclass(frozen=True)
class BoardFeatures:
    aggregate_height: int
    holes: int
    bumpiness: int


def board_features(board: Board) -> BoardFeatures:
    heights = []
    holes = 0
    for x in range(WIDTH):
        first = next((y for y in range(HEIGHT) if board[y][x]), HEIGHT)
        heights.append(HEIGHT - first)
        holes += sum(board[y][x] == 0 for y in range(first, HEIGHT))
    return BoardFeatures(
        sum(heights), holes, sum(abs(a - b) for a, b in zip(heights, heights[1:], strict=False))
    )


def board_value(board: Board, cleared: int) -> int:
    """Fixed, untuned v1 weights: -height -4*holes -bumpiness +8*lines."""
    features = board_features(board)
    return -features.aggregate_height - 4 * features.holes - features.bumpiness + 8 * cleared


class HeuristicPlayer:
    """Greedy afterboard value; ties use the engine's rotation/column order."""

    name = "heuristic"
    revision = "heuristic-v1-height1-holes4-bump1-lines8"

    def choose(self, observation: Observation) -> Decision:
        if not observation.legal_actions:
            raise ValueError("cannot choose with no legal actions")
        action = max(
            observation.legal_actions,
            key=lambda action: board_value(*place(observation.board, observation.current, action)),
        )
        return Decision(action.id)