Spaces:
Running
Running
File size: 8,856 Bytes
32974dd 1a3bdb6 32974dd 1a3bdb6 32974dd | 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 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | """Deterministic Tetris engine used by the LM Tetris Arena.
The engine works at the *placement* level: for each new piece we enumerate every
(rotation, column) that can be hard-dropped straight down, simulate the result and
describe the outcome in plain English. Players (language models) only choose among
those outcomes, so every move they make is legal.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
WIDTH = 10
HEIGHT = 20
PIECE_NAMES = ["I", "O", "T", "S", "Z", "J", "L"]
# (row, col) cells of each piece in its spawn orientation
_BASE = {
"I": [(0, 0), (0, 1), (0, 2), (0, 3)],
"O": [(0, 0), (0, 1), (1, 0), (1, 1)],
"T": [(0, 1), (1, 0), (1, 1), (1, 2)],
"S": [(0, 1), (0, 2), (1, 0), (1, 1)],
"Z": [(0, 0), (0, 1), (1, 1), (1, 2)],
"J": [(0, 0), (1, 0), (1, 1), (1, 2)],
"L": [(0, 2), (1, 0), (1, 1), (1, 2)],
}
COLOR_ID = {name: i + 1 for i, name in enumerate(PIECE_NAMES)} # 1..7
LINE_POINTS = {0: 0, 1: 100, 2: 300, 3: 500, 4: 800}
def _normalize(cells):
mr = min(r for r, _ in cells)
mc = min(c for _, c in cells)
return tuple(sorted((r - mr, c - mc) for r, c in cells))
def _rotations(cells):
out, seen = [], set()
cur = list(cells)
for _ in range(4):
n = _normalize(cur)
if n not in seen:
seen.add(n)
out.append(n)
cur = [(c, -r) for r, c in cur] # rotate 90 degrees clockwise
return out
ROTATIONS = {name: _rotations(cells) for name, cells in _BASE.items()}
class PieceSequence:
"""7-bag randomizer. Two sequences built from the same seed are identical."""
def __init__(self, seed: int):
self._rng = random.Random(seed)
self._pieces: list[str] = []
def __getitem__(self, i: int) -> str:
while len(self._pieces) <= i:
bag = PIECE_NAMES[:]
self._rng.shuffle(bag)
self._pieces.extend(bag)
return self._pieces[i]
# ----------------------------------------------------------------------------
# Board helpers
# ----------------------------------------------------------------------------
def empty_grid():
return [[0] * WIDTH for _ in range(HEIGHT)]
def column_heights(grid):
heights = [0] * WIDTH
for c in range(WIDTH):
for r in range(HEIGHT):
if grid[r][c]:
heights[c] = HEIGHT - r
break
return heights
def count_holes(grid):
holes = 0
for c in range(WIDTH):
seen_block = False
for r in range(HEIGHT):
if grid[r][c]:
seen_block = True
elif seen_block:
holes += 1
return holes
def bumpiness(heights):
return sum(abs(heights[i] - heights[i + 1]) for i in range(WIDTH - 1))
# ----------------------------------------------------------------------------
# Natural-language description of an outcome
# ----------------------------------------------------------------------------
_LINES_TXT = {
0: "clears no lines",
1: "clears one line",
2: "clears two lines",
3: "clears three lines",
4: "clears four lines at once",
}
def describe(lines: int, new_holes: int, max_height: int, bump: int, landing: int) -> str:
"""Neutral, factual English description of what a move does.
Values are bucketed into words on purpose: tiny LMs handle words far better
than numbers, and a small closed set of phrases keeps scoring cacheable.
`landing` = how many rows above the lowest column the piece comes to rest.
"""
if landing <= 0:
landing_txt = "drops the piece into the lowest part of the board"
elif landing <= 2:
landing_txt = "drops the piece a little above the lowest part of the board"
else:
landing_txt = "drops the piece far above the lowest part of the board"
lines_txt = _LINES_TXT[lines]
if new_holes < 0:
holes_txt = "removes some holes"
elif new_holes == 0:
holes_txt = "creates no new holes"
elif new_holes == 1:
holes_txt = "creates one new hole"
else:
holes_txt = "creates several new holes"
if max_height <= 4:
height_txt = "keeps the stack very low"
elif max_height <= 8:
height_txt = "keeps the stack low"
elif max_height <= 12:
height_txt = "makes the stack high"
elif max_height <= 16:
height_txt = "makes the stack very high"
else:
height_txt = "brings the stack close to the top"
if bump <= 4:
surface_txt = "leaves the surface flat"
elif bump <= 10:
surface_txt = "leaves the surface a little uneven"
else:
surface_txt = "leaves the surface very bumpy"
return f"{landing_txt}, {lines_txt}, {holes_txt}, {height_txt} and {surface_txt}"
@dataclass
class Candidate:
rotation: int
x: int
cells: list # absolute (row, col) cells where the piece lands
grid: list # board after placement and line clears
lines: int
holes: int
new_holes: int
max_height: int
agg_height: int
bump: int
landing: int
description: str
def enumerate_placements(grid, piece: str, holes_before: int | None = None) -> list[Candidate]:
if holes_before is None:
holes_before = count_holes(grid)
heights = column_heights(grid)
tops = [HEIGHT - h for h in heights] # first filled row index (HEIGHT if empty)
lowest = min(heights)
color = COLOR_ID[piece]
out = []
for rot_idx, shape in enumerate(ROTATIONS[piece]):
w = max(c for _, c in shape) + 1
bottom = {}
for r, c in shape:
bottom[c] = max(bottom.get(c, -1), r)
for x in range(WIDTH - w + 1):
# landing offset: the piece stops when any column touches the stack
y = min(tops[x + pc] - 1 - br for pc, br in bottom.items())
cells = [(r + y, c + x) for r, c in shape]
if any(r < 0 for r, _ in cells):
continue # would stick out of the top: illegal (top-out)
g = [row[:] for row in grid]
for r, c in cells:
g[r][c] = color
kept = [row for row in g if not all(row)]
lines = HEIGHT - len(kept)
if lines:
g = [[0] * WIDTH for _ in range(lines)] + kept
hs = column_heights(g)
holes = count_holes(g)
bump = bumpiness(hs)
mh = max(hs)
landing = (HEIGHT - 1 - max(r for r, _ in cells)) - lowest
out.append(
Candidate(
rotation=rot_idx,
x=x,
cells=cells,
grid=g,
lines=lines,
holes=holes,
new_holes=holes - holes_before,
max_height=mh,
agg_height=sum(hs),
bump=bump,
landing=landing,
description=describe(lines, holes - holes_before, mh, bump, landing),
)
)
return out
@dataclass
class TetrisGame:
seed: int
grid: list = field(default_factory=empty_grid)
score: int = 0
lines: int = 0
pieces: int = 0
tetrises: int = 0
alive: bool = True
last_cells: list = field(default_factory=list)
last_description: str = ""
last_value: float | None = None
last_piece: str = ""
last_rotation: int = 0
last_rotations: int = 1
last_col: int = 0
last_lines: int = 0
last_options: int = 0
def __post_init__(self):
self.sequence = PieceSequence(self.seed)
self._holes = 0
@property
def current_piece(self) -> str:
return self.sequence[self.pieces]
@property
def next_piece(self) -> str:
return self.sequence[self.pieces + 1]
def candidates(self) -> list[Candidate]:
return enumerate_placements(self.grid, self.current_piece, self._holes)
def apply(self, cand: Candidate, value: float | None = None, options: int = 0):
piece = self.current_piece
self.last_piece = piece
self.last_rotation = cand.rotation
self.last_rotations = len(ROTATIONS[piece])
self.last_col = cand.x + 1 # leftmost column of the piece, 1..10
self.last_lines = cand.lines
self.last_options = options
self.grid = cand.grid
self._holes = cand.holes
self.lines += cand.lines
self.score += LINE_POINTS[cand.lines]
self.tetrises += int(cand.lines == 4)
self.pieces += 1
# cells of the placed piece that survived line clears (for highlighting)
self.last_cells = cand.cells if cand.lines == 0 else []
self.last_description = cand.description
self.last_value = value
def top_out(self):
self.alive = False
|