tarinai/scripts/achievement_server_state_audit.py
2026-07-16 22:12:03 +09:00

341 lines
14 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import base64
import concurrent.futures
import json
import os
from pathlib import Path
import random
import re
import shutil
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[1]
API_SOURCE = ROOT / "achievement_api.php"
def achievement_ids() -> list[str]:
source = API_SOURCE.read_text(encoding="utf-8")
match = re.search(r"const ACHIEVEMENT_IDS = \[(.*?)\n\];", source, re.S)
if not match:
raise AssertionError("ACHIEVEMENT_IDS was not found")
values = re.findall(r"'([^']+)'", match.group(1))
if len(values) != 64 or len(values) != len(set(values)):
raise AssertionError(f"expected 64 unique achievement IDs, got {len(values)}")
return values
ACHIEVEMENTS = achievement_ids()
def compact_uuid(value: str) -> str:
return base64.urlsafe_b64encode(uuid.UUID(value).bytes).decode("ascii").rstrip("=")
def expand_uuid(value: str) -> str:
return str(uuid.UUID(bytes=base64.urlsafe_b64decode(value + "==")))
def decode_v3(value: object) -> dict:
if not isinstance(value, list) or len(value) < 5 or value[0] != 3:
raise AssertionError("state is not compact v3")
base_time = int(value[1])
versions = value[2]
rows = value[3]
unlock_rows = value[4]
if not isinstance(versions, list) or not isinstance(rows, list) or not isinstance(unlock_rows, list):
raise AssertionError("invalid v3 sections")
players: dict[str, dict] = {}
player_ids: list[str] = []
for row in rows:
player_id = expand_uuid(str(row[0]))
first_seen = base_time + max(0, int(row[1] if len(row) > 1 else 0))
last_seen = first_seen + max(0, int(row[2] if len(row) > 2 else 0))
version_index = max(0, int(row[3] if len(row) > 3 else 0))
player_ids.append(player_id)
players[player_id] = {
"firstSeen": first_seen,
"lastSeen": last_seen,
"gameVersion": str(versions[version_index] if version_index < len(versions) else ""),
}
unlocks: dict[str, dict[str, int]] = {}
for row in unlock_rows:
achievement_index = int(row[0])
if achievement_index < 0 or achievement_index >= len(ACHIEVEMENTS):
continue
records: dict[str, int] = {}
for offset in range(1, len(row) - 1, 2):
player_index = int(row[offset])
if 0 <= player_index < len(player_ids):
records[player_ids[player_index]] = base_time + max(0, int(row[offset + 1]))
if records:
unlocks[ACHIEVEMENTS[achievement_index]] = records
return {"players": players, "unlocks": unlocks}
def make_random_v2(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
rng = random.Random(seed)
versions = ["39.16.60", "39.16.63", "39.16.64", "39.16.65", "dev"]
base_time = 1_780_000_000 + seed * 10_000
rows: list[list] = []
players: dict[str, dict] = {}
player_ids: list[str] = []
for index in range(player_count):
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
first_seen = base_time + rng.randint(0, 200_000)
last_seen = first_seen + rng.randint(0, 80_000)
version = rng.choices(versions, weights=[2, 4, 9, 12, 1], k=1)[0]
player_ids.append(player_id)
rows.append([player_id, first_seen, last_seen, version])
players[player_id] = {"firstSeen": first_seen, "lastSeen": last_seen, "gameVersion": version}
unlocks_flat: dict[str, list[int]] = {}
unlocks: dict[str, dict[str, int]] = {}
for achievement_id in ACHIEVEMENTS:
flat: list[int] = []
records: dict[str, int] = {}
for player_index, player_id in enumerate(player_ids):
if rng.random() >= unlock_probability:
continue
timestamp = players[player_id]["firstSeen"] + rng.randint(0, max(0, players[player_id]["lastSeen"] - players[player_id]["firstSeen"]))
flat.extend([player_index, timestamp])
records[player_id] = timestamp
if flat:
unlocks_flat[achievement_id] = flat
unlocks[achievement_id] = records
return {"v": 2, "p": rows, "u": unlocks_flat}, {"players": players, "unlocks": unlocks}
def make_random_verbose(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
v2, expected = make_random_v2(seed, player_count, unlock_probability)
players = {
row[0]: {"firstSeen": row[1], "lastSeen": row[2], "gameVersion": row[3]}
for row in v2["p"]
}
player_ids = [row[0] for row in v2["p"]]
unlocks = {
achievement_id: {player_ids[flat[i]]: flat[i + 1] for i in range(0, len(flat), 2)}
for achievement_id, flat in v2["u"].items()
}
return {"players": players, "unlocks": unlocks}, expected
def normalize_expected(value: dict) -> dict:
return {
"players": {key: value["players"][key] for key in sorted(value["players"])},
"unlocks": {
achievement_id: {key: records[key] for key in sorted(records)}
for achievement_id, records in value["unlocks"].items()
if records
},
}
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class PhpServer:
def __init__(self, root: Path):
self.root = root
self.port = free_port()
env = dict(os.environ)
env["PHP_CLI_SERVER_WORKERS"] = "8"
self.process = subprocess.Popen(
["php", "-S", f"127.0.0.1:{self.port}"],
cwd=root,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.time() + 8
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
break
except OSError:
if self.process.poll() is not None:
raise RuntimeError("PHP test server exited")
time.sleep(0.05)
else:
raise RuntimeError("PHP test server did not start")
def close(self) -> None:
if self.process.poll() is None:
os.killpg(self.process.pid, signal.SIGTERM)
try:
self.process.wait(timeout=4)
except subprocess.TimeoutExpired:
os.killpg(self.process.pid, signal.SIGKILL)
self.process.wait(timeout=4)
def request(self, *, action: str = "summary", player_id: str, payload: dict | None = None) -> tuple[int, dict]:
if payload is None:
url = f"http://127.0.0.1:{self.port}/achievement_api.php?action={action}&playerId={player_id}"
request = urllib.request.Request(url, method="GET")
else:
url = f"http://127.0.0.1:{self.port}/achievement_api.php"
data = json.dumps(payload, separators=(",", ":")).encode()
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def assert_equal_state(actual: dict, expected: dict, label: str) -> None:
actual_normalized = normalize_expected(actual)
expected_normalized = normalize_expected(expected)
if actual_normalized != expected_normalized:
raise AssertionError(f"{label}: semantic state changed")
def run_migration_cases(server: PhpServer, state_path: Path) -> tuple[int, int]:
total_original = 0
total_compact = 0
probe_id = str(uuid.uuid4())
for case_index in range(32):
player_count = 1 + (case_index * 37) % 260
probability = 0.02 + ((case_index * 11) % 55) / 100
if case_index % 5 == 0:
source, expected = make_random_verbose(10_000 + case_index, player_count, probability)
else:
source, expected = make_random_v2(10_000 + case_index, player_count, probability)
original = json.dumps(source, separators=(",", ":")).encode()
state_path.write_bytes(original)
status, summary = server.request(player_id=probe_id)
if status != 200 or not summary.get("ok"):
raise AssertionError(f"migration case {case_index} failed: {status} {summary}")
if summary.get("totalPlayers") != player_count:
raise AssertionError("read-only summary changed or miscounted players")
first_bytes = state_path.read_bytes()
decoded = decode_v3(json.loads(first_bytes))
assert_equal_state(decoded, expected, f"migration case {case_index}")
status, second_summary = server.request(player_id=probe_id)
if status != 200 or second_summary.get("totalPlayers") != player_count:
raise AssertionError("v3 reread failed")
if state_path.read_bytes() != first_bytes:
raise AssertionError("canonical v3 changed on a second read")
total_original += len(original)
total_compact += len(first_bytes)
if total_compact >= total_original * 0.72:
raise AssertionError(f"aggregate compaction is insufficient: {total_compact}/{total_original}")
return total_original, total_compact
def run_corruption_cases(server: PhpServer, state_path: Path) -> None:
probe_id = str(uuid.uuid4())
for payload in [b'{"v":2,', b'{"v":99}', b'[3,1]']:
state_path.write_bytes(payload)
status, body = server.request(player_id=probe_id)
if status != 503 or body.get("error") != "storage_corrupt":
raise AssertionError(f"corrupt state was not rejected: {status} {body}")
if state_path.read_bytes() != payload:
raise AssertionError("corrupt state was overwritten")
def run_completionist_case(server: PhpServer, state_path: Path) -> None:
state_path.write_text("", encoding="utf-8")
player_id = str(uuid.uuid4())
now_ms = int(time.time() * 1000)
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": False,
"unlocked": {"first_birth": now_ms, "true_tarinai_observer": now_ms},
})
if status != 200 or body["achievements"]["first_birth"]["unlockedPlayers"] != 1:
raise AssertionError("ordinary sync unlock was lost")
if body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 0:
raise AssertionError("ineligible completionist unlock was re-added")
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": True,
"unlocked": {"true_tarinai_observer": now_ms},
})
if status != 200 or body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 1:
raise AssertionError("eligible completionist unlock was not accepted")
before = state_path.read_bytes()
status, body = server.request(action="reset", player_id=player_id)
if status != 405 or body.get("error") != "method_not_allowed":
raise AssertionError("GET reset was not rejected")
if state_path.read_bytes() != before:
raise AssertionError("rejected GET reset mutated state")
def run_concurrency_case(server: PhpServer, state_path: Path) -> None:
state_path.write_text("", encoding="utf-8")
rng = random.Random(52_001)
requests: list[tuple[str, dict[str, int]]] = []
now_ms = int(time.time() * 1000)
for index in range(72):
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
requests.append((player_id, unlocks))
def submit(entry: tuple[str, dict[str, int]]) -> None:
player_id, unlocks = entry
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": "true_tarinai_observer" in unlocks,
"unlocked": unlocks,
})
if status != 200 or not body.get("ok"):
raise AssertionError(f"concurrent sync failed: {status} {body}")
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as executor:
list(executor.map(submit, requests))
status, summary = server.request(player_id=str(uuid.uuid4()))
if status != 200 or summary.get("totalPlayers") != len(requests):
raise AssertionError("concurrent updates lost players")
decoded = decode_v3(json.loads(state_path.read_bytes()))
if len(decoded["players"]) != len(requests):
raise AssertionError("concurrent state player count mismatch")
for player_id, unlocks in requests:
for achievement_id in unlocks:
if player_id not in decoded["unlocks"].get(achievement_id, {}):
raise AssertionError(f"concurrent update lost {achievement_id} for {player_id}")
def main() -> None:
with tempfile.TemporaryDirectory(prefix="tarinai-achievement-random-audit-") as temp_dir:
root = Path(temp_dir)
shutil.copy2(API_SOURCE, root / "achievement_api.php")
data_dir = root / ".achievement_data"
data_dir.mkdir()
state_path = data_dir / "state.json"
server = PhpServer(root)
try:
original, compact = run_migration_cases(server, state_path)
run_corruption_cases(server, state_path)
run_completionist_case(server, state_path)
run_concurrency_case(server, state_path)
finally:
server.close()
print(
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
f"completionist filtering, GET mutation rejection, and 72 concurrent syncs passed; "
f"aggregate JSON shrank {original}{compact} bytes ({compact / original:.1%})"
)
if __name__ == "__main__":
main()