341 lines
14 KiB
Python
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()
|