[], 'unlocks' => []]; } function compact_player_id(string $value): string { $hex = str_replace('-', '', strtolower($value)); if (strlen($hex) !== 32 || !ctype_xdigit($hex)) return ''; $binary = hex2bin($hex); if ($binary === false) return ''; return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); } function expand_player_id(string $value): string { if (strlen($value) !== 22 || preg_match('/^[A-Za-z0-9_-]{22}$/', $value) !== 1) return ''; $binary = base64_decode(strtr($value, '-_', '+/') . '==', true); if ($binary === false || strlen($binary) !== 16) return ''; $hex = bin2hex($binary); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); } function detect_state_format(mixed $value): string { if (!is_array($value)) return 'invalid'; if ($value === []) return 'empty'; if (array_is_list($value)) { return (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4]) && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) ? 'v3' : 'invalid'; } if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { return 'v2'; } if (array_key_exists('players', $value) || array_key_exists('unlocks', $value)) return 'verbose'; return 'invalid'; } function normalize_state(mixed $value): array { if (!is_array($value)) return empty_state(); $players = []; $unlocks = []; // v3 positional form: [version, baseTime, versions, players, unlocks]. if (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4]) && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) { $baseTime = max(1, (int)$value[1]); $versions = array_map(static fn($entry): string => substr((string)$entry, 0, 32), $value[2]); $playerIds = []; foreach ($value[3] as $row) { if (!is_array($row) || !isset($row[0]) || !is_string($row[0])) continue; $id = expand_player_id($row[0]); if (!valid_player_id($id)) continue; $firstSeen = $baseTime + max(0, (int)($row[1] ?? 0)); $lastSeen = $firstSeen + max(0, (int)($row[2] ?? 0)); $versionIndex = max(0, (int)($row[3] ?? 0)); $playerIds[] = $id; $players[$id] = [ 'firstSeen' => max(1, $firstSeen), 'lastSeen' => max(1, $lastSeen), 'gameVersion' => $versions[$versionIndex] ?? '', ]; } foreach ($value[4] as $row) { if (!is_array($row) || count($row) < 3) continue; $achievementIndex = (int)$row[0]; $achievementId = ACHIEVEMENT_IDS[$achievementIndex] ?? ''; if ($achievementId === '') continue; $records = []; $count = count($row); for ($i = 1; $i + 1 < $count; $i += 2) { $playerIndex = (int)$row[$i]; if (!isset($playerIds[$playerIndex])) continue; $records[$playerIds[$playerIndex]] = $baseTime + max(0, (int)$row[$i + 1]); } if ($records) $unlocks[$achievementId] = $records; } // v2 is an on-read migration source and is always rewritten as v3. } elseif (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { $playerIds = []; foreach ($value['p'] as $row) { if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue; $id = strtolower($row[0]); $playerIds[] = $id; $players[$id] = [ 'firstSeen' => max(1, (int)($row[1] ?? 1)), 'lastSeen' => max(1, (int)($row[2] ?? ($row[1] ?? 1))), 'gameVersion' => substr((string)($row[3] ?? ''), 0, 32), ]; } foreach ($value['u'] as $achievementId => $flat) { if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue; $records = []; $count = count($flat); for ($i = 0; $i + 1 < $count; $i += 2) { $playerIndex = (int)$flat[$i]; if (!isset($playerIds[$playerIndex])) continue; $records[$playerIds[$playerIndex]] = max(1, (int)$flat[$i + 1]); } if ($records) $unlocks[$achievementId] = $records; } } else { $rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : []; foreach ($rawPlayers as $id => $player) { if (!is_string($id) || !valid_player_id($id) || !is_array($player)) continue; $players[strtolower($id)] = [ 'firstSeen' => max(1, (int)($player['firstSeen'] ?? 1)), 'lastSeen' => max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))), 'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32), ]; } $rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; foreach ($rawUnlocks as $achievementId => $records) { if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue; foreach ($records as $id => $timestamp) { $normalizedId = strtolower((string)$id); if (!isset($players[$normalizedId])) continue; $unlocks[$achievementId][$normalizedId] = max(1, (int)$timestamp); } } } $legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first']) ? $unlocks['signal_automation_first'] : []; $legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman']) ? $unlocks['link_craftsman'] : []; if ($legacySignal && $legacyLinks) { if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) { $unlocks['mechanized_industry'] = []; } foreach ($legacySignal as $playerId => $signalTime) { if (!isset($legacyLinks[$playerId])) continue; $unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]); } } unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']); return ['players' => $players, 'unlocks' => $unlocks]; } function compact_state(array $state): array { $playerIds = array_keys($state['players']); sort($playerIds, SORT_STRING); $playerIndex = []; $timestamps = []; $versionCounts = []; foreach ($playerIds as $id) { $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; $timestamps[] = max(1, (int)($player['firstSeen'] ?? 1)); $timestamps[] = max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))); $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); $versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1; } foreach ($state['unlocks'] as $achievementId => $records) { if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue; foreach ($records as $timestamp) $timestamps[] = max(1, (int)$timestamp); } $baseTime = $timestamps ? min($timestamps) : 1; uksort($versionCounts, static function (string $a, string $b) use ($versionCounts): int { $countOrder = ($versionCounts[$b] ?? 0) <=> ($versionCounts[$a] ?? 0); return $countOrder !== 0 ? $countOrder : strcmp($a, $b); }); $versions = array_keys($versionCounts); if (!$versions) $versions = ['']; $versionIndex = array_flip($versions); $players = []; foreach ($playerIds as $index => $id) { $playerIndex[$id] = $index; $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; $firstSeen = max(1, (int)($player['firstSeen'] ?? 1)); $lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen)); $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); $row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen]; $indexValue = (int)($versionIndex[$version] ?? 0); if ($indexValue !== 0) $row[] = $indexValue; $players[] = $row; } $unlocks = []; foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) { $records = $state['unlocks'][$achievementId] ?? null; if (!is_array($records) || !$records) continue; $pairs = []; foreach ($records as $id => $timestamp) { if (!isset($playerIndex[$id])) continue; $pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime]; } usort($pairs, static fn(array $a, array $b): int => $a[0] <=> $b[0]); if (!$pairs) continue; $row = [$achievementIndex]; foreach ($pairs as [$indexValue, $timestampDelta]) { $row[] = $indexValue; $row[] = max(0, $timestampDelta); } $unlocks[] = $row; } return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks]; } function encode_compact_state(array $state): string { try { return json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('storage_encode_failed', 0, $error); } } function decode_stored_state(string $contents): array { if (trim($contents) === '') return [empty_state(), 'empty']; try { $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('storage_corrupt', 0, $error); } $format = detect_state_format($decoded); if ($format === 'invalid') throw new RuntimeException('storage_corrupt'); return [normalize_state($decoded), $format]; } function atomic_write_state(string $dataPath, string $encoded): void { $dataDir = dirname($dataPath); $tempPath = tempnam($dataDir, DATA_FILE_NAME . '.tmp.'); if ($tempPath === false) throw new RuntimeException('storage_unavailable'); $handle = @fopen($tempPath, 'wb'); if ($handle === false) { @unlink($tempPath); throw new RuntimeException('storage_unavailable'); } try { $length = strlen($encoded); $offset = 0; while ($offset < $length) { $written = fwrite($handle, substr($encoded, $offset)); if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed'); $offset += $written; } if (!fflush($handle)) throw new RuntimeException('storage_write_failed'); if (function_exists('fsync') && !fsync($handle)) throw new RuntimeException('storage_write_failed'); } catch (Throwable $error) { fclose($handle); @unlink($tempPath); throw $error; } fclose($handle); @chmod($tempPath, 0664); if (!@rename($tempPath, $dataPath)) { @unlink($tempPath); throw new RuntimeException('storage_write_failed'); } } function build_summary(array $state): array { $total = count($state['players']); $achievements = []; foreach (ACHIEVEMENT_IDS as $id) { $unlocked = isset($state['unlocks'][$id]) && is_array($state['unlocks'][$id]) ? count($state['unlocks'][$id]) : 0; $percentage = $total > 0 ? round(($unlocked / $total) * 100, 1) : 0.0; $achievements[$id] = [ 'unlockedPlayers' => $unlocked, 'percentage' => $percentage, ]; } return [ 'ok' => true, 'totalPlayers' => $total, 'achievements' => $achievements, ]; } $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405); $payload = []; if ($method === 'POST') { $declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0)); if ($declaredLength > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); $raw = file_get_contents('php://input', false, null, 0, MAX_REQUEST_BYTES + 1); if (!is_string($raw) || strlen($raw) > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); try { $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); } catch (JsonException) { respond(['ok' => false, 'error' => 'invalid_json'], 400); } if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400); $payload = $decoded; } $action = $method === 'POST' ? (string)($payload['action'] ?? '') : (string)($_GET['action'] ?? 'summary'); $playerId = $method === 'POST' ? (string)($payload['playerId'] ?? '') : (string)($_GET['playerId'] ?? ''); if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) { respond(['ok' => false, 'error' => 'invalid_action'], 400); } if ($method === 'GET' && $action !== 'summary') { respond(['ok' => false, 'error' => 'method_not_allowed'], 405); } if (!valid_player_id($playerId)) { respond(['ok' => false, 'error' => 'invalid_player'], 400); } $playerId = strtolower($playerId); $syncUnlocks = []; $completionistEligible = null; if ($action === 'unlock') { $achievementId = (string)($payload['achievementId'] ?? ''); if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) { respond(['ok' => false, 'error' => 'invalid_achievement'], 400); } } elseif ($action === 'sync') { $submittedUnlocks = $payload['unlocked'] ?? null; if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) { respond(['ok' => false, 'error' => 'invalid_unlocks'], 400); } if (array_key_exists('completionistEligible', $payload)) { if (!is_bool($payload['completionistEligible'])) { respond(['ok' => false, 'error' => 'invalid_completionist_state'], 400); } $completionistEligible = $payload['completionistEligible']; } foreach ($submittedUnlocks as $achievementId => $timestamp) { if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) { respond(['ok' => false, 'error' => 'invalid_achievement'], 400); } if (!is_int($timestamp) && !is_float($timestamp) && !is_string($timestamp)) { respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); } $numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT); if ($numericTimestamp === false || !is_finite((float)$numericTimestamp) || $numericTimestamp <= 0) { respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); } $syncUnlocks[$achievementId] = (float)$numericTimestamp; } if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']); } $dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME; if (!is_dir($dataDir) && !mkdir($dataDir, 0775, true) && !is_dir($dataDir)) { respond(['ok' => false, 'error' => 'storage_unavailable'], 503); } $dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME; $lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME; $lockHandle = @fopen($lockPath, 'c'); if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) { if (is_resource($lockHandle)) fclose($lockHandle); respond(['ok' => false, 'error' => 'storage_locked'], 503); } $summary = null; $storageError = null; try { $contents = is_file($dataPath) ? file_get_contents($dataPath) : ''; if ($contents === false) throw new RuntimeException('storage_unavailable'); [$state, $sourceFormat] = decode_stored_state($contents); $now = time(); $mutatesPlayer = $action !== 'summary'; if ($mutatesPlayer) { $player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId]) ? $state['players'][$playerId] : ['firstSeen' => $now]; $player['lastSeen'] = $now; $submittedGameVersion = substr((string)($payload['gameVersion'] ?? ''), 0, 32); if ($submittedGameVersion !== '' || !isset($player['gameVersion'])) $player['gameVersion'] = $submittedGameVersion; $state['players'][$playerId] = $player; } if ($action === 'unlock') { $achievementId = (string)$payload['achievementId']; if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) { $state['unlocks'][$achievementId] = []; } if (!isset($state['unlocks'][$achievementId][$playerId])) { $state['unlocks'][$achievementId][$playerId] = $now; } } elseif ($action === 'sync') { if ($completionistEligible === false && isset($state['unlocks']['true_tarinai_observer'][$playerId])) { unset($state['unlocks']['true_tarinai_observer'][$playerId]); } foreach ($syncUnlocks as $achievementId => $submittedTimestamp) { if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) { $state['unlocks'][$achievementId] = []; } if (isset($state['unlocks'][$achievementId][$playerId])) continue; $timestampSeconds = $submittedTimestamp >= 100000000000 ? (int)floor($submittedTimestamp / 1000) : (int)floor($submittedTimestamp); $state['unlocks'][$achievementId][$playerId] = max(1, min($now, $timestampSeconds)); } } elseif ($action === 'reset') { foreach ($state['unlocks'] as $achievementId => $records) { if (is_array($records)) unset($state['unlocks'][$achievementId][$playerId]); } } $encoded = encode_compact_state($state); if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) { atomic_write_state($dataPath, $encoded); } $summary = build_summary($state); } catch (RuntimeException $error) { $known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed']; $storageError = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; } finally { flock($lockHandle, LOCK_UN); fclose($lockHandle); } if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503); respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503);