#!/usr/bin/env python3 """Meow-Jong layout solvability study (original data for meowjong.com). Replicates the shipped game's rules from the recovered Unity source (MahjongPlayfield.cs / TileManager.cs, commit 3eb2402 = Steam 3.2.0 and later): * grid 34 x 20 half-tile cells x 5 layers (3400-char layout strings, '1' = tile) * a tile is FREE when no tile sits on layer z+1 within +-1 cell in x and y, and it is not flanked on BOTH sides (x-2 and x+2, y-1..y+1) on its own layer * tiles match when they carry the same picture (44 cat pictures; no suit groups) * the game deals by REVERSE construction (BuildPairList): repeatedly pick two random free tiles, pair them, remove them; up to 50 attempts; so every initial deal is solvable by construction. This script measures, per layout: 1. structural facts (tiles, layers, initial free tiles) 2. how often a NAIVE random deal (shuffle pictures onto positions, the way many classic implementations deal) is solvable, via an exact search 3. how often random, hint-free play gets stuck on a solvable naive deal 4. how many reverse-deal attempts the game's method needs (fallback rate) """ import os, sys, json, random, time, glob, itertools from multiprocessing import Pool W, H, L = 34, 20, 5 ROOT = os.path.expanduser('~/MeowJong-Recovery/repo/Scripts/Boards/Pack0') OUT = os.path.expanduser('~/Meow-Jong-marketing-2026-09-15/research/solvability-20261004') N_IMAGES = 44 class Timeout(Exception): pass def load_layout(path): s = open(path, encoding='utf-8', errors='ignore').read().replace('\r', '').replace('\n', '').strip('') assert len(s) == W * H * L, (path, len(s)) tiles, i = [], 0 for z in range(L): for y in range(H): for x in range(W): if s[i] == '1': tiles.append((x, y, z)) i += 1 return tiles class Board: def __init__(self, tiles): self.tiles = tiles self.n = len(tiles) idx = {t: i for i, t in enumerate(tiles)} self.above = [0] * self.n self.left = [0] * self.n self.right = [0] * self.n for i, (x, y, z) in enumerate(tiles): a = l = r = 0 for dy in (-1, 0, 1): for dx in (-1, 0, 1): j = idx.get((x + dx, y + dy, z + 1)) if j is not None: a |= 1 << j j = idx.get((x - 2, y + dy, z)) if j is not None: l |= 1 << j j = idx.get((x + 2, y + dy, z)) if j is not None: r |= 1 << j self.above[i], self.left[i], self.right[i] = a, l, r # how many other tiles each tile helps block (move-ordering heuristic) self.blocks = [0] * self.n for i in range(self.n): for m in (self.above[i], self.left[i], self.right[i]): while m: b = m & -m self.blocks[b.bit_length() - 1] += 1 m ^= b def is_free(self, i, rem): if self.above[i] & rem: return False return not ((self.left[i] & rem) and (self.right[i] & rem)) def free_list(self, rem): out = [] m = rem while m: b = m & -m i = b.bit_length() - 1 if self.is_free(i, rem): out.append(i) m ^= b return out def game_pair_ids(n_pairs, rng): """TileManager.BuildLayout: list of (pairs/2)+1 picture ids, each twice; pop at random per pair.""" pool = [] for i in range((n_pairs // 2) + 1): pool.append(i % N_IMAGES) pool.append(i % N_IMAGES) ids = [] for _ in range(n_pairs): k = rng.randrange(len(pool)) ids.append(pool.pop(k)) return ids def naive_deal(board, rng): """Shuffle pictures onto positions with no regard for solvability.""" n_pairs = board.n // 2 pair_ids = game_pair_ids(n_pairs, rng) pics = [] for p in pair_ids: pics += [p, p] rng.shuffle(pics) return pics def reverse_deal_attempts(board, rng, max_attempts=50): """The game's BuildPairList/DealPairs: returns attempts needed (None if all fail).""" full = (1 << board.n) - 1 for attempt in range(1, max_attempts + 1): rem = full ok = True while rem: free = board.free_list(rem) if len(free) < 2: ok = False break a = free.pop(rng.randrange(len(free))) b = free[rng.randrange(len(free))] rem &= ~((1 << a) | (1 << b)) if ok: return attempt return None def random_play_stuck(board, pics, rng): """Hint-free random play: pick a random available match until done or stuck. Returns tiles left.""" rem = (1 << board.n) - 1 while rem: free = board.free_list(rem) by = {} for i in free: by.setdefault(pics[i], []).append(i) moves = [lst for lst in by.values() if len(lst) >= 2] if not moves: return bin(rem).count('1') lst = rng.choice(moves) a, b = rng.sample(lst, 2) rem &= ~((1 << a) | (1 << b)) return 0 def solve(board, pics, node_cap): """Exact search: True solvable, False unsolvable, raises Timeout past node_cap.""" sys.setrecursionlimit(20000) n = board.n dead = set() nodes = [0] def rec(rem): if rem == 0: return True if rem in dead: return False nodes[0] += 1 if nodes[0] > node_cap: raise Timeout() while True: free = board.free_list(rem) by = {} for i in free: by.setdefault(pics[i], []).append(i) remaining = {} m = rem while m: b = m & -m i = b.bit_length() - 1 remaining[pics[i]] = remaining.get(pics[i], 0) + 1 m ^= b forced = 0 for p, lst in by.items(): if len(lst) >= 2 and len(lst) == remaining[p] and len(lst) % 2 == 0: for i in lst: forced |= 1 << i if forced: rem &= ~forced if rem == 0: return True if rem in dead: return False continue break moves = [] for p, lst in by.items(): if len(lst) >= 2: for a, b in itertools.combinations(lst, 2): moves.append((board.blocks[a] + board.blocks[b] + (3 if remaining[p] == 2 else 0), a, b)) if not moves: dead.add(rem) return False moves.sort(reverse=True) for _, a, b in moves: if rec(rem & ~((1 << a) | (1 << b))): return True dead.add(rem) return False return rec((1 << n) - 1), nodes[0] def study_layout(args): path, n_deals, node_cap, seed = args page = os.path.basename(os.path.dirname(path)) name = os.path.basename(path)[:-4] rng = random.Random(seed) board = Board(load_layout(path)) t0 = time.time() full = (1 << board.n) - 1 res = { 'page': page, 'board': name, 'tiles': board.n, 'layers': 1 + max(t[2] for t in board.tiles), 'initial_free': len(board.free_list(full)), 'deals': n_deals, 'solvable': 0, 'unsolvable': 0, 'unknown': 0, 'stuck_random_play_on_solvable': 0, 'solvable_played': 0, 'reverse_deal_attempts': [], 'reverse_deal_failures': 0, 'nodes_median': None, 'seconds': 0.0, } node_counts = [] for d in range(n_deals): pics = naive_deal(board, rng) try: ok, nodes = solve(board, pics, node_cap) node_counts.append(nodes) if ok: res['solvable'] += 1 res['solvable_played'] += 1 if random_play_stuck(board, pics, rng) > 0: res['stuck_random_play_on_solvable'] += 1 else: res['unsolvable'] += 1 except Timeout: res['unknown'] += 1 for _ in range(50): a = reverse_deal_attempts(board, rng) if a is None: res['reverse_deal_failures'] += 1 else: res['reverse_deal_attempts'].append(a) if node_counts: node_counts.sort() res['nodes_median'] = node_counts[len(node_counts) // 2] res['seconds'] = round(time.time() - t0, 1) return res def main(): n_deals = int(sys.argv[1]) if len(sys.argv) > 1 else 100 node_cap = int(sys.argv[2]) if len(sys.argv) > 2 else 400000 only = sys.argv[3].split(',') if len(sys.argv) > 3 else None paths = sorted(p for p in glob.glob(os.path.join(ROOT, 'Page0*', '*.txt')) if not p.endswith('PAGEDAT.txt')) if only: paths = [p for p in paths if os.path.basename(p)[:-4] in only] jobs = [(p, n_deals, node_cap, 20261004 + i) for i, p in enumerate(paths)] procs = max(1, (os.cpu_count() or 2) - 2) out_path = os.path.join(OUT, 'results.json' if not only else 'calibration.json') results = [] t0 = time.time() with Pool(procs) as pool: for r in pool.imap_unordered(study_layout, jobs): results.append(r) print(f"{len(results)}/{len(jobs)} {r['page']}/{r['board']} tiles={r['tiles']} solv={r['solvable']}/{r['deals']} unk={r['unknown']} stuck={r['stuck_random_play_on_solvable']}/{r['solvable_played']} rev_fail={r['reverse_deal_failures']} {r['seconds']}s", flush=True) with open(out_path, 'w') as f: json.dump({'n_deals': n_deals, 'node_cap': node_cap, 'elapsed': round(time.time() - t0, 1), 'results': results}, f, indent=1) print('done', round(time.time() - t0, 1), 's ->', out_path) if __name__ == '__main__': main()