softlock_sweep.py
Python, 117 lines from Game design overview
#!/usr/bin/env python3
"""Soft-lock sweep: generate many worlds, play them with a cheapest-legal-move
bot, and report every seed where the next required target is unreachable.
The world and rules below are a toy ring map, written to reproduce a real bug:
`can_attack` used `ring == current_ring`, so leftovers from older rings became
unattackable and could wall off the next ring's capital. Swap in your own
generator and your game's real rule functions (port them, or call your Luau
through a shim), keep the sweep loop.
python3 softlock_sweep.py # buggy rule
python3 softlock_sweep.py --fixed # ring <= current_ring
"""
import random
import sys
from collections import deque
SEEDS = 300
RINGS = 5
FIXED = "--fixed" in sys.argv
def gen_world(seed):
"""Rings of countries around a home node. Each ring has one capital.
Edges link neighbours in a ring and some pairs across adjacent rings."""
rng = random.Random(seed)
nodes = {"home": {"ring": 0, "capital": False}}
edges = {"home": set()}
prev = ["home"]
for r in range(1, RINGS + 1):
n = 6 + 2 * r
ring = [f"r{r}_{i}" for i in range(n)]
cap = rng.choice(ring)
for c in ring:
nodes[c] = {"ring": r, "capital": c == cap}
edges[c] = set()
for i, c in enumerate(ring): # ring neighbours
d = ring[(i + 1) % n]
edges[c].add(d)
edges[d].add(c)
for c in ring: # 1-2 links inward
for p in rng.sample(prev, k=min(len(prev), rng.choice((1, 2)))):
edges[c].add(p)
edges[p].add(c)
prev = ring
return nodes, edges
def can_attack(nodes, owned, current_ring, c):
ring = nodes[c]["ring"]
if FIXED:
return ring <= current_ring and c not in owned
return ring == current_ring and c not in owned # the bug
def frontier(nodes, edges, owned, current_ring):
out = set()
for o in owned:
for c in edges[o]:
if can_attack(nodes, owned, current_ring, c):
out.add(c)
return out
def capital_reachable(nodes, edges, owned, current_ring):
"""BFS over legal attacks only: can the current ring's capital be reached?"""
seen, q = set(owned), deque(owned)
while q:
o = q.popleft()
for c in edges[o]:
if c in seen or not can_attack(nodes, seen, current_ring, c):
continue
if nodes[c]["capital"] and nodes[c]["ring"] == current_ring:
return True
seen.add(c)
q.append(c)
return False
def play(seed):
"""Returns None if the bot finished, else the ring it got stuck on."""
rng = random.Random(seed * 7919)
nodes, edges = gen_world(seed)
owned, current_ring = {"home"}, 1
while current_ring <= RINGS:
if not capital_reachable(nodes, edges, owned, current_ring):
return current_ring
# take a few ordinary countries, then the capital when it borders us
f = frontier(nodes, edges, owned, current_ring)
caps = [c for c in f if nodes[c]["capital"] and nodes[c]["ring"] == current_ring]
normal = [c for c in f if not nodes[c]["capital"]]
if caps and (not normal or rng.random() < 0.5):
owned.add(caps[0])
current_ring += 1 # leftovers of the old ring stay unowned
elif normal:
owned.add(rng.choice(normal))
else:
return current_ring
return None
def main():
stuck = {}
for seed in range(1, SEEDS + 1):
r = play(seed)
if r is not None:
stuck.setdefault(r, []).append(seed)
print(f"{'fixed' if FIXED else 'buggy'} rule, {SEEDS} seeds")
if not stuck:
print("no soft-locks")
for r in sorted(stuck):
print(f"ring {r}: {len(stuck[r])} seeds stuck, e.g. {stuck[r][:5]}")
if __name__ == "__main__":
main()