[], 'unlocks' => []]; } function normalize_state(mixed $value): array { if (!is_array($value)) return empty_state(); $players = []; $unlocks = []; if (($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 = $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) || !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 { $players = isset($value['players']) && is_array($value['players']) ? $value['players'] : []; $unlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; } $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 = []; $players = []; foreach ($playerIds as $index => $id) { $playerIndex[$id] = $index; $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; $players[] = [ $id, max(1, (int)($player['firstSeen'] ?? 1)), max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))), substr((string)($player['gameVersion'] ?? ''), 0, 32), ]; } $unlocks = []; foreach ($state['unlocks'] as $achievementId => $records) { if (!is_string($achievementId) || !is_array($records) || !$records) continue; $flat = []; foreach ($records as $id => $timestamp) { if (!isset($playerIndex[$id])) continue; $flat[] = $playerIndex[$id]; $flat[] = max(1, (int)$timestamp); } if ($flat) $unlocks[$achievementId] = $flat; } ksort($unlocks, SORT_STRING); return ['v' => 2, 'p' => $players, 'u' => $unlocks]; } 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'); $payload = []; if ($method === 'POST') { $raw = file_get_contents('php://input'); $decoded = json_decode(is_string($raw) ? $raw : '', true); 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 (!valid_player_id($playerId)) { respond(['ok' => false, 'error' => 'invalid_player'], 400); } $syncUnlocks = []; 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); } 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 || $numericTimestamp <= 0) { respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); } $syncUnlocks[$achievementId] = (float)$numericTimestamp; } } $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; $handle = fopen($dataPath, 'c+'); if ($handle === false || !flock($handle, LOCK_EX)) { if (is_resource($handle)) fclose($handle); respond(['ok' => false, 'error' => 'storage_locked'], 503); } try { rewind($handle); $contents = stream_get_contents($handle); $state = normalize_state(json_decode(is_string($contents) ? $contents : '', true)); $now = time(); $player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId]) ? $state['players'][$playerId] : ['firstSeen' => $now]; $player['lastSeen'] = $now; $player['gameVersion'] = substr((string)($payload['gameVersion'] ?? ''), 0, 32); $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') { 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]); } } rewind($handle); ftruncate($handle, 0); fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); fflush($handle); $summary = build_summary($state); } finally { flock($handle, LOCK_UN); fclose($handle); } respond($summary);