Finally got it working with arbitrary board size and number of stones.
The packing algorithm now starts out with separate radii of 1 for all stones and progressively increment the radius of one of them at a time when they get successfully separated by the current radii. It quits after failing to improve for certain number of times consecutively, which can be adjusted.
I wonder if thereās a way to get similar result by starting with uniform probability distribution over the board and applying some kind of transformation to it after each stone placement. It might be more efficient since there would be no trial/error involved.
scatter-go.py
import copy
import enum
import itertools
import random
print(
"""
Scatter Stones on Go Board
To run the program normally, add -O (capital alphabet 'O') flag:
$ python3 -O scatter-go.py
To run tests, without the flag:
$ python3 scatter-go.py
""")
if __debug__:
print('\nRunning tests...\n')
###### Adjustable Constants
SIZE_BOARD = 29
N_STONE_BLK, N_STONE_WHT = 11, 11
MARGIN_EDGE = 2 # stone-free margin
FACTOR_NONEMPTY = 25 # scale factor for n of consecutive failures to tolerate in spread_stones()
#################################################################################################
###### Derived Constants
N_STONE = N_STONE_BLK + N_STONE_WHT
END_LOW = 1 + MARGIN_EDGE
END_HIGH = SIZE_BOARD - MARGIN_EDGE
###### Custom Types
class Vec:
"""2-d vector: v(ertical), h(orizontal)"""
def __init__(self, vert, horz):
self.v, self.h = vert, horz
def __neg__(self):
return self.__class__(-self.v, -self.h)
def __add__(self, other):
return self.__class__(self.v + other.v, self.h + other.h)
def __sub__(self, other):
return self + (-other)
def __repr__(self):
return '(%s, %s)' % (self.v, self.h)
def __eq__(self, other):
try:
return self.v == other.v and self.h == other.h
except:
return self.v == other[0] and self.h == other[1]
def __lt__(self, other):
if self.v < other.v:
return True
elif self.v == other.v:
return self.h < other.h
return False
if __debug__:
assert Vec(4, 9) - Vec(2, -2) == (2, 11)
class CoordOutOfBounds(Exception): pass
def assertCOOB(stmt): # stmt must be passed in quotes
try:
eval(stmt)
assert False, 'expected CoordOutOfBounds'
except CoordOutOfBounds: pass
class Coord(int):
"""coordinate on Go board"""
def __new__(cls, val):
if val < END_LOW or val > END_HIGH:
raise CoordOutOfBounds
return super().__new__(cls, val)
def __add__(self, other):
res = super().__add__(other)
return self.__class__(res)
def __sub__(self, other):
return self + (-other)
@classmethod
def random(cls):
return cls(random.randint(END_LOW, END_HIGH))
if __debug__:
assertCOOB( 'Coord(4) - 2' )
class Stone(Vec):
def __init__(self, vert, horz):
try:
self.v, self.h = Coord(vert), Coord(horz)
except:
raise
def __repr__(self):
return '(%s, %s)' % (self.v, chr(96 + self.h))
if __debug__:
assertCOOB( 'Stone(4, 9) - Vec(2, -2)' )
class Direction(enum.IntEnum):
"""4 cardinal directions"""
south = 0
east = 1
north = 2
west = 3
def __neg__(self):
return self.__class__((self + 2) % 4)
def apply_sign(self, num):
if num < 0: return -self
else: return self
def sign(self):
if self < 2: return 1
else: return -1
@classmethod
def vert(cls):
return cls(0)
@classmethod
def horz(cls):
return cls(1)
def apply_step(self, stone):
stn = copy.deepcopy(stone) # avoid side effect
if self % 2 == Direction.vert():
step = Vec(self.sign(), 0)
elif self % 2 == Direction.horz():
step = Vec(0, self.sign())
try:
return stn + step
except CoordOutOfBounds:
raise
if __debug__:
stn_test = Stone(4, 9)
stn_test = Direction.north.apply_step(stn_test)
assert stn_test == (3, 9)
assertCOOB( 'Direction.north.apply_step(stn_test)' )
class NonNegInt(int):
def __sub__(self, other):
res = super().__sub__(other)
return self.__class__(max(0, res))
if __debug__:
assert NonNegInt(2) - 4 == 0
###### Randomly Place Stones
def random_stones():
stones = []
while len(stones) < N_STONE:
candidate = Vec(Coord.random(), Coord.random())
if candidate not in stones:
stones.append(candidate)
return stones
###### Spread Apart Stones
def pairs_with_total(total):
"""[a, b] where a + b == total"""
for n in range(total // 2 + 1):
yield (n, total - n)
if __debug__:
expected = [(0, 7), (1, 6), (2, 5), (3, 4)]
assert sorted(pairs_with_total(7)) == sorted(expected)
def circle_taxicab(radius):
"""relative position of points on taxicab circle"""
vecs = []
for pair in pairs_with_total(radius):
for vert, horz in itertools.permutations(pair, 2): # [a, b], [b, a]
# reflective symmetries
for sign_v, sign_h in itertools.product([-1, 1], repeat=2):
candidate = Vec(sign_v * vert, sign_h * horz)
if candidate not in vecs:
vecs.append(candidate)
return vecs
if __debug__:
expected = [(0, 7), (0, -7), (7, 0), (-7, 0),
(1, 6), (-1, 6), (1, -6), (-1, -6), (6, 1), (-6, 1), (6, -1), (-6, -1),
(2, 5), (-2, 5), (2, -5), (-2, -5), (5, 2), (-5, 2), (5, -2), (-5, -2),
(3, 4), (-3, 4), (3, -4), (-3, -4), (4, 3), (-4, 3), (4, -3), (-4, -3)]
assert sorted(circle_taxicab(7)) == sorted(expected)
def sparsity_direction(stn, radius, stones):
"""
Sparsity in each direction starts with given radius.
Stones on taxicab circle, if any, reduce the sparsity
by their component in that direction. (0 <= component <= radius)
For example, a stone directly in some direction reduces sparsity
by full radius, making the sparsity in that direction 0.
In contrast, a stone orthogonal to that direction does not affect
the sparsity in that direction at all.
"""
spars_dirs = {key:NonNegInt(radius) for key in [dr for dr in Direction]}
for d in circle_taxicab(radius):
try:
candidate = Stone(stn.v + d.v, stn.h + d.h)
if candidate in stones:
spars_dirs[Direction.vert().apply_sign(d.v)] -= abs(d.v)
spars_dirs[Direction.horz().apply_sign(d.h)] -= abs(d.h)
except:
continue
if min(spars_dirs.values()) == radius: # no stones on circle
return None
return spars_dirs
if __debug__:
origin_test = Stone(9, 8)
stones_test = [origin_test,
Stone(9, 7), Stone(8, 8),
Stone(9, 6), Stone(8, 9),
Stone(6, 8), Stone(10, 6), Stone(11, 9),
Stone(6, 9), Stone(8, 5), Stone(11, 10)]
# south, east, north, west
expected = [ [1, 1, 0, 0],
[2, 1, 1, 0],
[0, 2, 0, 1],
[2, 1, 0, 1] ]
for idx in range(len(expected)):
spars_dirs = sparsity_direction(origin_test, idx + 1, stones_test)
assert list(spars_dirs.values()) == expected[idx]
assert sparsity_direction(origin_test, 5, stones_test) is None
def pick_direction(spars_dirs):
"""probability to be picked is proportional to sparsity"""
dirs = list(spars_dirs.keys())
spars = list(spars_dirs.values())
return random.choices(dirs, spars)[0] # choices() return in list
def increment_at_boundary(nums):
"""[1, 1, 0, 0, 0] -> [1, 1, 1, 0, 0]"""
n_min = min(nums)
for idx, n in enumerate(nums):
if n == n_min:
nums[idx] += 1
break
if __debug__:
nums_test = [1, 1, 1, 0, 0]
expected = [[1, 1, 1, 1, 0],
[1, 1, 1, 1, 1],
[2, 1, 1, 1, 1]]
for idx, nums_e in enumerate(expected):
increment_at_boundary(nums_test)
assert nums_test == nums_e
def spread_stones(stones_):
stones = copy.deepcopy(stones_)
count_empty = len(stones)
count_nonempty = len(stones) * FACTOR_NONEMPTY
radii_repel = [1 for _ in range(len(stones))]
stones_backup = copy.deepcopy(stones)
radii_backup = radii_repel[:]
while count_nonempty > 0:
for idx, stn in enumerate(stones):
rad_max = radii_repel[idx] + max(radii_repel)
for rad in range(1, rad_max + 1):
spars_dirs = sparsity_direction(stn, rad, stones)
if spars_dirs is None: # no stones at radius
if rad == rad_max:
count_empty -= 1
if count_empty == 0: # all stones made it at current radii
if __debug__:
print_board(stones, stones, radii_repel)
# reset counters
count_empty = len(stones)
count_nonempty = len(stones) * FACTOR_NONEMPTY
# prepare for challenge
stones_backup = copy.deepcopy(stones)
radii_backup = radii_repel[:]
# increment radius for one of the stones
increment_at_boundary(radii_repel)
else: # found stones at radius
# reset count_empty as moving a stone might put it
# within another stone's range
count_empty = len(stones)
count_nonempty -= 1
# look ahead one step farther
spars_dirs_next = sparsity_direction(stn, rad + 1, stones)
if spars_dirs_next is not None:
for dr in spars_dirs:
# scaling analogous to when adding fractions
spars_dirs[dr] *= rad + 1
spars_dirs[dr] += spars_dirs_next[dr] * rad
try: # possibly at edge margin
stones[idx] = pick_direction(spars_dirs).apply_step(stn)
except: # possibly cornered
break # move to next stone in case it really is
break
if count_nonempty == 0:
break
# go back to before it was still working
return stones_backup, radii_backup
###### Random Walk
def random_walk_stones(stones_, radii_repel):
stones = copy.deepcopy(stones_)
directions = [dr for dr in Direction]
for idx in range(len(stones)):
for _ in range(radii_repel[idx]):
while True:
try:
stones[idx] = random.choice(directions).apply_step(stones[idx])
break
except:
continue
return stones
###### Display
def print_board(stones, stones_prev=None, radii_repel=None):
board = []
# we start by making a blank board
# first row shows coordinate names in alphabets
board.append([' .'] + [chr(97 + n) for n in range(SIZE_BOARD)])
# remaining rows
for n in range(1, SIZE_BOARD + 1):
board.append(['{:>2}'.format(n)] + ['+']*SIZE_BOARD)
# draw things by substituting symbols
if radii_repel is not None:
for idx, rad in enumerate(radii_repel):
stn = stones_prev[idx]
for d in circle_taxicab(rad):
try:
board[stn.v + d.v][stn.h + d.h] = '%s' % (rad)
except:
continue
if stones_prev is not None:
for stn_prev in stones_prev:
board[stn_prev.v][stn_prev.h] = '%'
for idx, stn in enumerate(stones):
if idx < N_STONE_BLK:
board[stn.v][stn.h] = '@'
else:
board[stn.v][stn.h] = '&'
# print row by row
for row in board:
print(' '.join(row))
print('')
###### Put Everything Together
def main():
print('Randomly Place Stones (@: black, &: white):')
stones = random_stones()
print_board(stones)
stones, radii_repel = spread_stones(stones)
print('Spread Apart Stones (numbers show radii of separation):')
print_board(stones, stones, radii_repel)
stones_prev = copy.deepcopy(stones)
print('Random Walk on Stones (%: previous location):')
stones = random_walk_stones(stones, radii_repel)
print_board(stones, stones_prev, radii_repel)
print('Only Show Stones:')
print_board(stones)
if __debug__:
print('Passed all tests!!!')
elif __name__ == '__main__':
main()
###### END OF FILE