$path, 'bytes' => $bytes, 'hash' => hash('sha256', $bytes), 'size' => strlen($bytes), 'mtime' => max(0, (int)@filemtime($path)), ]; } function remember_legacy_state_source(string $target, array $source): void { atomic_write_json($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', [ 'schema' => 1, 'sourcePath' => (string)$source['path'], 'sourceHash' => (string)$source['hash'], 'sourceSize' => (int)$source['size'], 'sourceMtime' => (int)$source['mtime'], 'copiedAt' => time(), ]); } function copy_legacy_state_snapshot(string $target, array $source): void { atomic_write_bytes($target . DIRECTORY_SEPARATOR . 'state.json', (string)$source['bytes']); remember_legacy_state_source($target, $source); } function migrate_legacy_data_root(string $target, array $legacyRoots): void { $targetCurrent = is_file($target . DIRECTORY_SEPARATOR . 'summary.json') || is_file($target . DIRECTORY_SEPARATOR . 'session-key.json') || is_dir($target . DIRECTORY_SEPARATOR . 'players'); $targetStatePath = $target . DIRECTORY_SEPARATOR . 'state.json'; $targetState = legacy_state_snapshot($targetStatePath); $candidates = []; $stateCandidates = []; foreach ($legacyRoots as $legacy) { $path = rtrim((string)$legacy, DIRECTORY_SEPARATOR); if ($path === '' || $path === $target || !is_dir($path) || !directory_has_achievement_data($path)) continue; $directoryMtime = max( (int)@filemtime($path), (int)@filemtime($path . DIRECTORY_SEPARATOR . 'state.json'), (int)@filemtime($path . DIRECTORY_SEPARATOR . 'summary.json') ); $candidates[$path] = $directoryMtime; $snapshot = legacy_state_snapshot($path . DIRECTORY_SEPARATOR . 'state.json'); if ($snapshot !== null) $stateCandidates[$path] = $snapshot; } arsort($candidates, SORT_NUMERIC); uasort($stateCandidates, static function (array $a, array $b): int { $mtime = ((int)$b['mtime']) <=> ((int)$a['mtime']); if ($mtime !== 0) return $mtime; return ((int)$b['size']) <=> ((int)$a['size']); }); // When a compact source was copied into a private current root, keep // comparing the original source on later requests. This captures final // writes made by the legacy server during cut-over instead of freezing the // first copied snapshot forever. if ($targetState !== null && $stateCandidates) { $sourceMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', []); $migrationMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json', []); $preferredPath = (string)($sourceMarker['sourcePath'] ?? ''); $source = $preferredPath !== '' && isset($stateCandidates[dirname($preferredPath)]) ? $stateCandidates[dirname($preferredPath)] : reset($stateCandidates); if (is_array($source) && !hash_equals((string)$targetState['hash'], (string)$source['hash'])) { $trackedSource = $preferredPath !== '' && (string)$source['path'] === $preferredPath; $targetIsLastImportedSnapshot = hash_equals( strtolower((string)($migrationMarker['sourceHash'] ?? '')), strtolower((string)$targetState['hash']) ); $sourceIsNotOlder = (int)$source['mtime'] >= (int)$targetState['mtime']; if ($trackedSource || $targetIsLastImportedSnapshot || $sourceIsNotOlder) { copy_legacy_state_snapshot($target, $source); $targetState = $source; } } return; } if (!$candidates) return; // A prior current build may already have created an empty summary/player // tree. Copy the newest compact source into it so the importer can merge it // without discarding current records. if ($targetCurrent) { $source = $stateCandidates ? reset($stateCandidates) : null; if (is_array($source)) copy_legacy_state_snapshot($target, $source); return; } $sourcePath = (string)array_key_first($candidates); ensure_directory(dirname($target)); if (isset($stateCandidates[$sourcePath])) { copy_directory_tree($sourcePath, $target); remember_legacy_state_source($target, $stateCandidates[$sourcePath]); return; } if (!file_exists($target) && @rename($sourcePath, $target)) { // A separate compact source may coexist with a current-format legacy // directory. Merge the compact snapshot as well. $stateSource = $stateCandidates ? reset($stateCandidates) : null; if (is_array($stateSource)) copy_legacy_state_snapshot($target, $stateSource); return; } copy_directory_tree($sourcePath, $target); $stateSource = $stateCandidates ? reset($stateCandidates) : null; if (is_array($stateSource) && !is_file($targetStatePath)) copy_legacy_state_snapshot($target, $stateSource); } function data_root(): string { $configured = trim((string)(getenv('TARINAI_ACHIEVEMENT_DATA_DIR') ?: '')); if ($configured !== '') { $target = rtrim($configured, DIRECTORY_SEPARATOR); migrate_legacy_data_root($target, [ __DIR__ . DIRECTORY_SEPARATOR . '.achievement_data', dirname(__DIR__) . DIRECTORY_SEPARATOR . '.achievement_data', ]); return $target; } $documentRoot = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? '')); $publicRoot = is_string($documentRoot) && $documentRoot !== '' ? $documentRoot : __DIR__; $installPath = realpath(__DIR__) ?: __DIR__; $installKey = substr(hash('sha256', $installPath), 0, 16); $legacyParent = dirname($publicRoot); $legacyRoots = [ $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-' . $installKey, __DIR__ . DIRECTORY_SEPARATOR . '.achievement_data', $publicRoot . DIRECTORY_SEPARATOR . '.achievement_data', ]; $globbed = glob($legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-*', GLOB_ONLYDIR); if (is_array($globbed)) $legacyRoots = array_merge($legacyRoots, $globbed); // Keep local-server data independent of the ZIP extraction directory and // listening port. This makes upgrades and side-by-side extracted versions // share one persistent achievement identity and server record. $xdg = trim((string)(getenv('XDG_DATA_HOME') ?: '')); $home = trim((string)(getenv('HOME') ?: getenv('USERPROFILE') ?: '')); if ($xdg !== '') $target = rtrim($xdg, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements'; elseif ($home !== '') $target = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements'; else $target = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements'; try { migrate_legacy_data_root($target, $legacyRoots); return $target; } catch (RuntimeException) { // Hosted environments may not permit writes under HOME. The fallback // remains outside the document root and is stable across sibling builds. $fallback = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements'; migrate_legacy_data_root($fallback, $legacyRoots); return $fallback; } } function ensure_directory(string $path): void { if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) throw new RuntimeException('storage_unavailable'); } function protect_data_root(string $path): void { ensure_directory($path); $rules = "Options -Indexes\n\n Require all denied\n\n\n Deny from all\n\n"; $htaccess = $path . DIRECTORY_SEPARATOR . '.htaccess'; if (!is_file($htaccess)) @file_put_contents($htaccess, $rules, LOCK_EX); $index = $path . DIRECTORY_SEPARATOR . 'index.html'; if (!is_file($index)) @file_put_contents($index, '', LOCK_EX); } function read_json_file(string $path, array $fallback): array { if (!is_file($path)) return $fallback; $raw = file_get_contents($path); if (!is_string($raw)) throw new RuntimeException('storage_unavailable'); if (trim($raw) === '') return $fallback; try { $value = json_decode($raw, true, 128, JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('storage_corrupt', 0, $error); } return is_array($value) ? $value : $fallback; } function atomic_write_json(string $path, array $value): void { ensure_directory(dirname($path)); try { $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('storage_encode_failed', 0, $error); } $temp = tempnam(dirname($path), basename($path) . '.tmp.'); if ($temp === false) throw new RuntimeException('storage_unavailable'); $handle = @fopen($temp, 'wb'); if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); } try { $offset = 0; $length = strlen($encoded); 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); } catch (Throwable $error) { fclose($handle); @unlink($temp); throw $error; } fclose($handle); @chmod($temp, 0660); if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); } } function atomic_write_bytes(string $path, string $bytes): void { ensure_directory(dirname($path)); $temp = tempnam(dirname($path), basename($path) . '.tmp.'); if ($temp === false) throw new RuntimeException('storage_unavailable'); $handle = @fopen($temp, 'wb'); if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); } try { $offset = 0; $length = strlen($bytes); while ($offset < $length) { $written = fwrite($handle, substr($bytes, $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); } catch (Throwable $error) { fclose($handle); @unlink($temp); throw $error; } fclose($handle); @chmod($temp, 0660); if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); } } function valid_legacy_player_id(string $value): bool { return preg_match('/^[A-Za-z0-9_-]{22}$/', $value) === 1; } function legacy_player_uuid(string $legacyId): string { if (!valid_legacy_player_id($legacyId)) throw new RuntimeException('legacy_state_invalid'); $decoded = base64_decode(strtr($legacyId, '-_', '+/') . '==', true); if (!is_string($decoded) || strlen($decoded) !== 16) throw new RuntimeException('legacy_state_invalid'); $bytes = $decoded; $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); } /** * Imports the compact schema-4 state.json used by the pre-credential server. * The source remains untouched and is copied byte-for-byte into legacy-backup. * Player writes are deterministic and idempotent, so a process interruption is * safely resumed on the next request before the completion marker is written. */ function migrate_legacy_compact_state_v4(string $root, string $summaryPath): ?array { $sourcePath = $root . DIRECTORY_SEPARATOR . 'state.json'; if (!is_file($sourcePath)) return null; $markerPath = $root . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json'; $marker = read_json_file($markerPath, []); $sourceSize = max(0, (int)@filesize($sourcePath)); $sourceMtime = max(0, (int)@filemtime($sourcePath)); $sourceRaw = @file_get_contents($sourcePath); if (!is_string($sourceRaw) || trim($sourceRaw) === '') throw new RuntimeException('legacy_state_unreadable'); $sourceHash = hash('sha256', $sourceRaw); if (($marker['status'] ?? '') === 'complete' && hash_equals((string)($marker['sourceHash'] ?? ''), $sourceHash)) { $marker['sourceSize'] = $sourceSize; $marker['sourceMtime'] = $sourceMtime; atomic_write_json($markerPath, $marker); return $marker; } // If the legacy server wrote a final update during cut-over, import the // changed snapshot again. The merge is grow-only and deterministic. try { $data = json_decode($sourceRaw, true, 256, JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('legacy_state_invalid', 0, $error); } if (!is_array($data) || count($data) !== 6 || ($data[0] ?? null) !== 4) throw new RuntimeException('legacy_state_invalid'); $baseTimestamp = $data[1] ?? null; $versions = $data[2] ?? null; $catalogIds = $data[3] ?? null; $playerRows = $data[4] ?? null; $unlockRows = $data[5] ?? null; if (!is_int($baseTimestamp) || $baseTimestamp < 946684800 || !is_array($versions) || !$versions || !is_array($catalogIds) || !is_array($playerRows) || !is_array($unlockRows)) { throw new RuntimeException('legacy_state_invalid'); } foreach ($versions as $version) { if (!is_string($version) || $version === '' || strlen($version) > 32) throw new RuntimeException('legacy_state_invalid'); } if (array_values($catalogIds) !== array_values(ACHIEVEMENT_IDS)) throw new RuntimeException('legacy_catalog_mismatch'); $perPlayerUnlocks = array_fill(0, count($playerRows), []); $legacyCounts = array_fill_keys(ACHIEVEMENT_IDS, 0); $seenAchievements = []; $unlockAssignments = 0; foreach ($unlockRows as $unlockRow) { if (!is_array($unlockRow) || count($unlockRow) < 3 || count($unlockRow) % 2 !== 1) throw new RuntimeException('legacy_state_invalid'); $achievementIndex = $unlockRow[0] ?? null; if (!is_int($achievementIndex) || $achievementIndex < 0 || $achievementIndex >= count(ACHIEVEMENT_IDS) || isset($seenAchievements[$achievementIndex])) throw new RuntimeException('legacy_state_invalid'); $seenAchievements[$achievementIndex] = true; $achievementId = ACHIEVEMENT_IDS[$achievementIndex]; $seenPlayers = []; for ($offset = 1; $offset < count($unlockRow); $offset += 2) { $playerIndex = $unlockRow[$offset] ?? null; $timeOffset = $unlockRow[$offset + 1] ?? null; if (!is_int($playerIndex) || $playerIndex < 0 || $playerIndex >= count($playerRows) || isset($seenPlayers[$playerIndex]) || !is_int($timeOffset) || $timeOffset < 0) { throw new RuntimeException('legacy_state_invalid'); } $timestamp = $baseTimestamp + $timeOffset; if ($timestamp < 946684800) throw new RuntimeException('legacy_state_invalid'); $seenPlayers[$playerIndex] = true; $perPlayerUnlocks[$playerIndex][$achievementId] = $timestamp; $legacyCounts[$achievementId]++; $unlockAssignments++; } } $backupPath = $root . DIRECTORY_SEPARATOR . 'legacy-backup' . DIRECTORY_SEPARATOR . 'state-v4-' . $sourceHash . '.json'; if (!is_file($backupPath)) atomic_write_bytes($backupPath, $sourceRaw); $backupRaw = @file_get_contents($backupPath); if (!is_string($backupRaw) || !hash_equals($sourceHash, hash('sha256', $backupRaw))) throw new RuntimeException('legacy_backup_failed'); atomic_write_json($markerPath, [ 'schema' => 1, 'status' => 'in_progress', 'sourceSchema' => 4, 'sourceHash' => $sourceHash, 'sourceSize' => $sourceSize, 'sourceMtime' => $sourceMtime, 'startedAt' => time(), 'expectedPlayers' => count($playerRows), 'expectedUnlockAssignments' => $unlockAssignments, 'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath), ]); $legacyIdsSeen = []; $migratedPlayerIds = []; foreach ($playerRows as $index => $row) { if (!is_array($row) || count($row) < 3 || count($row) > 4) throw new RuntimeException('legacy_state_invalid'); $legacyId = $row[0] ?? null; $firstOffset = $row[1] ?? null; $duration = $row[2] ?? null; $versionIndex = $row[3] ?? 0; if (!is_string($legacyId) || !valid_legacy_player_id($legacyId) || isset($legacyIdsSeen[$legacyId]) || !is_int($firstOffset) || $firstOffset < 0 || !is_int($duration) || $duration < 0 || !is_int($versionIndex) || $versionIndex < 0 || $versionIndex >= count($versions)) { throw new RuntimeException('legacy_state_invalid'); } $legacyIdsSeen[$legacyId] = true; $playerId = legacy_player_uuid($legacyId); if (isset($migratedPlayerIds[$playerId])) throw new RuntimeException('legacy_player_collision'); $migratedPlayerIds[$playerId] = true; $claimHash = hash('sha256', $legacyId); $path = player_path($root, $playerId); if (is_file($path)) { $existingRaw = read_json_file($path, []); $existingClaimHash = strtolower((string)($existingRaw['legacyClaimHash'] ?? '')); if ($existingClaimHash !== '' && !hash_equals($existingClaimHash, $claimHash)) throw new RuntimeException('legacy_player_collision'); if ($existingClaimHash === '' && (string)($existingRaw['ownerHash'] ?? '') !== '') throw new RuntimeException('legacy_player_collision'); $player = normalize_player($existingRaw, $playerId); } else { $player = default_player($playerId, '', $baseTimestamp + $firstOffset); } $player['schema'] = 5; $player['legacyClaimHash'] = $claimHash; $player['legacyClaimedAt'] = max(0, (int)($player['legacyClaimedAt'] ?? 0)); $player['firstSeen'] = min((int)$player['firstSeen'], $baseTimestamp + $firstOffset); $player['lastSeen'] = max((int)$player['lastSeen'], $baseTimestamp + $firstOffset + $duration); $player['activityCount'] = max(2, (int)$player['activityCount']); $player['qualified'] = true; if ((string)$player['gameVersion'] === '') $player['gameVersion'] = $versions[$versionIndex]; // A later explicit reset must remain authoritative if migration is // resumed after the player has already advanced to a newer generation. if ((int)$player['generation'] === 0) { foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) { if (!isset($player['unlocked'][$achievementId])) $player['unlocked'][$achievementId] = $timestamp; else $player['unlocked'][$achievementId] = min((int)$player['unlocked'][$achievementId], $timestamp); } } reconcile_completionist($player['unlocked'], $player['lastSeen']); atomic_write_json($path, $player); } $verifiedAssignments = 0; foreach ($playerRows as $index => $row) { $legacyId = (string)$row[0]; $playerId = legacy_player_uuid($legacyId); $player = normalize_player(read_json_file(player_path($root, $playerId), []), $playerId); if (!hash_equals(hash('sha256', $legacyId), (string)$player['legacyClaimHash'])) throw new RuntimeException('legacy_migration_verify_failed'); if ((int)$player['generation'] === 0) { foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) { if (!isset($player['unlocked'][$achievementId]) || (int)$player['unlocked'][$achievementId] > $timestamp) { throw new RuntimeException('legacy_migration_verify_failed'); } $verifiedAssignments++; } } } if ($verifiedAssignments !== $unlockAssignments) throw new RuntimeException('legacy_migration_verify_failed'); $summary = repair_summary_cache($root, $summaryPath); if ((int)$summary['totalPlayers'] < count($playerRows)) throw new RuntimeException('legacy_migration_verify_failed'); foreach ($legacyCounts as $achievementId => $count) { if ((int)($summary['counts'][$achievementId] ?? 0) < $count) throw new RuntimeException('legacy_migration_verify_failed'); } $complete = [ 'schema' => 1, 'status' => 'complete', 'sourceSchema' => 4, 'sourceHash' => $sourceHash, 'sourceSize' => $sourceSize, 'sourceMtime' => $sourceMtime, 'completedAt' => time(), 'migratedPlayers' => count($playerRows), 'migratedUnlockAssignments' => $unlockAssignments, 'summaryPlayers' => (int)$summary['totalPlayers'], 'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath), ]; atomic_write_json($markerPath, $complete); return $complete; } function with_lock(string $path, int $mode, callable $callback): mixed { ensure_directory(dirname($path)); $handle = @fopen($path, 'c'); if ($handle === false || !flock($handle, $mode)) { if (is_resource($handle)) fclose($handle); throw new RuntimeException('storage_locked'); } try { return $callback(); } finally { flock($handle, LOCK_UN); fclose($handle); } } function player_path(string $root, string $playerId): string { $bucket = substr(hash('sha256', $playerId), 0, 2); return $root . DIRECTORY_SEPARATOR . 'players' . DIRECTORY_SEPARATOR . $bucket . DIRECTORY_SEPARATOR . hash('sha256', $playerId) . '.json'; } function default_summary(): array { return [ 'schema' => 1, 'totalPlayers' => 0, 'counts' => array_fill_keys(ACHIEVEMENT_IDS, 0), ]; } function normalize_summary(array $summary): array { $out = default_summary(); $out['totalPlayers'] = max(0, (int)($summary['totalPlayers'] ?? 0)); $counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : []; foreach (ACHIEVEMENT_IDS as $id) $out['counts'][$id] = max(0, (int)($counts[$id] ?? 0)); return $out; } function default_progress_state(): array { return [ 'progress' => [ 'linkTypes' => [], 'signalActivated' => false, 'medicineTypes' => [], 'dailyPlayStreak' => 0, 'dailyPlayLastDay' => 0, 'mysteryDrugTarinaiIds' => [], ], 'ledger' => [ 'base' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), 0), 'shards' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), []), ], ]; } function progress_counter_value(array $progressState, string $key): int { $cap = (int)(ADDITIVE_PROGRESS_CAPS[$key] ?? 0); if ($cap <= 0) return 0; $base = bounded_int($progressState['ledger']['base'][$key] ?? 0, 0, $cap); $shards = is_array($progressState['ledger']['shards'][$key] ?? null) ? $progressState['ledger']['shards'][$key] : []; $sum = $base; foreach ($shards as $value) $sum += bounded_int($value, 0, $cap); return min($cap, $sum); } function bounded_int(mixed $value, int $minimum, int $maximum): int { if (!is_int($value) && !is_float($value) && !is_string($value)) return $minimum; if (!is_numeric($value)) return $minimum; $numeric = (float)$value; if (!is_finite($numeric)) return $minimum; return max($minimum, min($maximum, (int)floor($numeric))); } function unique_allowed_strings(mixed $input, array $allowed): array { if (!is_array($input)) return []; $lookup = array_fill_keys($allowed, true); $out = []; foreach ($input as $value) { $text = (string)$value; if (isset($lookup[$text])) $out[$text] = true; } return array_keys($out); } function normalize_progress_state(mixed $input): array { $out = default_progress_state(); if (!is_array($input)) return $out; $progressSource = is_array($input['progress'] ?? null) ? $input['progress'] : []; $ledgerSource = is_array($input['ledger'] ?? null) ? $input['ledger'] : []; $baseSource = is_array($ledgerSource['base'] ?? null) ? $ledgerSource['base'] : []; $shardsSource = is_array($ledgerSource['shards'] ?? null) ? $ledgerSource['shards'] : []; foreach (ADDITIVE_PROGRESS_CAPS as $key => $rawCap) { $cap = max(0, (int)$rawCap); $base = bounded_int($baseSource[$key] ?? 0, 0, $cap); $shards = []; $rawShards = is_array($shardsSource[$key] ?? null) ? $shardsSource[$key] : []; foreach ($rawShards as $actorId => $rawValue) { $id = substr((string)$actorId, 0, 160); if ($id === '' || preg_match('/[\x00-\x1F\x7F]/', $id) === 1) continue; $value = bounded_int($rawValue, 0, $cap); if ($value > 0) $shards[$id] = max((int)($shards[$id] ?? 0), $value); } $total = min($cap, $base + array_sum($shards)); // Protocol v4 and older duplicated additive counters in progress. Read // them as a one-time migration floor, but never emit them again. $legacyFloor = bounded_int($progressSource[$key] ?? 0, 0, $cap); if ($legacyFloor > $total) { $base = min($cap, $base + ($legacyFloor - $total)); $total = $legacyFloor; } if ($total >= $cap) { $base = $cap; $shards = []; } $out['ledger']['base'][$key] = $base; $out['ledger']['shards'][$key] = $shards; } $out['progress']['linkTypes'] = unique_allowed_strings($progressSource['linkTypes'] ?? [], LINK_PROGRESS_TYPES); $out['progress']['signalActivated'] = (bool)($progressSource['signalActivated'] ?? false); $out['progress']['medicineTypes'] = unique_allowed_strings($progressSource['medicineTypes'] ?? [], MEDICINE_LEDGER_TYPES); $out['progress']['dailyPlayStreak'] = bounded_int($progressSource['dailyPlayStreak'] ?? 0, 0, 7); $out['progress']['dailyPlayLastDay'] = bounded_int($progressSource['dailyPlayLastDay'] ?? 0, 0, 1000000); $ids = []; foreach ((array)($progressSource['mysteryDrugTarinaiIds'] ?? []) as $rawId) { $id = substr(trim((string)$rawId), 0, 160); if ($id !== '' && preg_match('/[\x00-\x1F\x7F]/', $id) !== 1) $ids[$id] = true; if (count($ids) >= MAD_SCIENTIST_TARGET) break; } $out['progress']['mysteryDrugTarinaiIds'] = array_keys($ids); return $out; } function merge_progress_state(mixed $targetInput, mixed $sourceInput): array { $target = normalize_progress_state($targetInput); $source = normalize_progress_state($sourceInput); foreach (ADDITIVE_PROGRESS_CAPS as $key => $cap) { $target['ledger']['base'][$key] = max((int)$target['ledger']['base'][$key], (int)$source['ledger']['base'][$key]); foreach ($source['ledger']['shards'][$key] as $actorId => $value) { $target['ledger']['shards'][$key][$actorId] = max((int)($target['ledger']['shards'][$key][$actorId] ?? 0), (int)$value); } } $targetDay = (int)$target['progress']['dailyPlayLastDay']; $sourceDay = (int)$source['progress']['dailyPlayLastDay']; if ($sourceDay > $targetDay) { $target['progress']['dailyPlayLastDay'] = $sourceDay; $target['progress']['dailyPlayStreak'] = (int)$source['progress']['dailyPlayStreak']; } elseif ($sourceDay === $targetDay) { $target['progress']['dailyPlayStreak'] = max((int)$target['progress']['dailyPlayStreak'], (int)$source['progress']['dailyPlayStreak']); } $target['progress']['signalActivated'] = (bool)$target['progress']['signalActivated'] || (bool)$source['progress']['signalActivated']; $target['progress']['linkTypes'] = array_values(array_unique(array_merge($target['progress']['linkTypes'], $source['progress']['linkTypes']))); $target['progress']['medicineTypes'] = array_values(array_unique(array_merge($target['progress']['medicineTypes'], $source['progress']['medicineTypes']))); $target['progress']['mysteryDrugTarinaiIds'] = array_slice(array_values(array_unique(array_merge( $target['progress']['mysteryDrugTarinaiIds'], $source['progress']['mysteryDrugTarinaiIds'] ))), 0, MAD_SCIENTIST_TARGET); return normalize_progress_state($target); } function progress_unlock_satisfied(string $id, array $progressState): ?bool { $rule = PROGRESS_RULES[$id] ?? null; if (!is_array($rule)) return null; $type = (string)($rule['type'] ?? ''); $key = (string)($rule['key'] ?? ''); $target = max(0, (int)($rule['target'] ?? 0)); $progress = $progressState['progress']; if ($type === 'counter') return progress_counter_value($progressState, $key) >= $target; if ($type === 'value') return bounded_int($progress[$key] ?? 0, 0, max($target, 1000000)) >= $target; if ($type === 'set') { $required = (string)($rule['required'] ?? ''); $requiredValues = $required === 'medicineLedgerTypes' ? MEDICINE_LEDGER_TYPES : []; return count(array_intersect($requiredValues, (array)($progress[$key] ?? []))) === count($requiredValues); } if ($type === 'unique') return count((array)($progress[$key] ?? [])) >= $target; if ($type === 'mechanized') { return (bool)($progress['signalActivated'] ?? false) && count(array_intersect(LINK_PROGRESS_TYPES, (array)($progress['linkTypes'] ?? []))) === count(LINK_PROGRESS_TYPES); } return null; } function reconcile_progress_unlocks(array &$unlocked, array $progressState, int $now): void { foreach (ACHIEVEMENT_IDS as $id) { if ($id === COMPLETIONIST_ID || isset($unlocked[$id])) continue; if (progress_unlock_satisfied($id, $progressState) === true) $unlocked[$id] = $now; } } function default_player(string $playerId, string $ownerHash, int $now): array { return [ 'schema' => 5, 'playerId' => $playerId, 'ownerHash' => $ownerHash, 'firstSeen' => $now, 'lastSeen' => $now, 'activityCount' => 0, 'qualified' => false, 'gameVersion' => '', 'generation' => 0, 'unlocked' => [], 'progressState' => default_progress_state(), ]; } function normalize_player(array $player, string $playerId): array { $now = time(); $unlocked = []; foreach ((array)($player['unlocked'] ?? []) as $id => $timestamp) { if (is_string($id) && in_array($id, ACHIEVEMENT_IDS, true)) $unlocked[$id] = max(1, (int)$timestamp); } return [ 'schema' => 5, 'playerId' => $playerId, 'ownerHash' => (string)($player['ownerHash'] ?? ''), 'firstSeen' => max(1, (int)($player['firstSeen'] ?? $now)), 'lastSeen' => max(1, (int)($player['lastSeen'] ?? $now)), 'activityCount' => max(0, (int)($player['activityCount'] ?? 0)), 'qualified' => (bool)($player['qualified'] ?? false), 'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32), 'generation' => max(0, (int)($player['generation'] ?? 0)), 'unlocked' => $unlocked, 'progressState' => normalize_progress_state($player['progressState'] ?? null), 'legacyClaimHash' => preg_match('/^[0-9a-f]{64}$/', strtolower((string)($player['legacyClaimHash'] ?? ''))) === 1 ? strtolower((string)$player['legacyClaimHash']) : '', 'legacyClaimedAt' => max(0, (int)($player['legacyClaimedAt'] ?? 0)), ]; } /** * The summary is a derived cache, never the source of truth. Rebuilding it from * player records makes a corrupt/missing summary self-healing and repairs a * process interruption between the player and summary renames. */ function rebuild_summary_from_players(string $root): array { $summary = default_summary(); $playersRoot = $root . DIRECTORY_SEPARATOR . 'players'; if (!is_dir($playersRoot)) return $summary; try { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($playersRoot, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile() || strtolower($file->getExtension()) !== 'json') continue; try { $raw = read_json_file($file->getPathname(), []); $playerId = strtolower((string)($raw['playerId'] ?? '')); if (!valid_player_id($playerId)) continue; $player = normalize_player($raw, $playerId); } catch (Throwable) { // One damaged player record must not take down every player. continue; } if (!(bool)$player['qualified']) continue; $summary['totalPlayers']++; foreach (array_keys($player['unlocked']) as $id) { if (isset($summary['counts'][$id])) $summary['counts'][$id]++; } } } catch (UnexpectedValueException) { throw new RuntimeException('storage_unavailable'); } return normalize_summary($summary); } function repair_summary_cache(string $root, string $summaryPath): array { $summary = rebuild_summary_from_players($root); atomic_write_json($summaryPath, $summary); return $summary; } function verify_owner(array &$player, string $secret): void { $submitted = hash('sha256', strtolower($secret)); $stored = (string)($player['ownerHash'] ?? ''); if ($stored === '' || !hash_equals($stored, $submitted)) respond(['ok' => false, 'error' => 'invalid_owner'], 403); } function reconcile_completionist(array &$unlocked, int $now): void { $latestPrerequisite = 0; foreach (ACHIEVEMENT_IDS as $id) { if ($id === COMPLETIONIST_ID) continue; if (!isset($unlocked[$id])) { unset($unlocked[COMPLETIONIST_ID]); return; } $latestPrerequisite = max($latestPrerequisite, max(1, (int)$unlocked[$id])); } $completionTime = max(1, $latestPrerequisite ?: $now); if (!isset($unlocked[COMPLETIONIST_ID])) $unlocked[COMPLETIONIST_ID] = $completionTime; else $unlocked[COMPLETIONIST_ID] = max((int)$unlocked[COMPLETIONIST_ID], $completionTime); } function normalize_client_unlock_time(mixed $value, int $now): int { if (!is_int($value) && !is_float($value) && !is_string($value)) return $now; if (!is_numeric($value)) return $now; $numeric = (float)$value; if (!is_finite($numeric) || $numeric <= 0) return $now; // Browser timestamps are milliseconds; legacy/server timestamps are seconds. if ($numeric >= 100000000000.0) $numeric /= 1000.0; $seconds = (int)floor($numeric); // Reject implausible/future values, but preserve valid offline unlock times. if ($seconds < 946684800 || $seconds > $now + 300) return $now; return $seconds; } function apply_summary_delta(array &$summary, array $before, array $after): void { foreach (ACHIEVEMENT_IDS as $id) { $had = isset($before[$id]); $has = isset($after[$id]); if ($had === $has) continue; $summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + ($has ? 1 : -1)); } } function maybe_qualify(array &$player, array &$summary, int $now): bool { if ($player['qualified']) return false; if ((int)$player['activityCount'] < QUALIFY_MIN_ACTIVITY) return false; if ($now - (int)$player['firstSeen'] < QUALIFY_MIN_AGE_SECONDS) return false; $player['qualified'] = true; $summary['totalPlayers'] = max(0, (int)$summary['totalPlayers'] + 1); foreach (array_keys($player['unlocked']) as $id) $summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + 1); return true; } function public_summary(array $summary, ?array $player = null): array { $total = max(0, (int)$summary['totalPlayers']); $achievements = []; foreach (ACHIEVEMENT_IDS as $id) { $count = max(0, min($total, (int)($summary['counts'][$id] ?? 0))); $achievements[$id] = [ 'unlockedPlayers' => $count, 'percentage' => $total > 0 ? round($count * 100 / $total, 1) : 0.0, ]; } $out = ['ok' => true, 'totalPlayers' => $total, 'achievements' => $achievements, 'statisticsTrust' => 'client-reported']; if ($player !== null) { $out['qualified'] = (bool)($player['qualified'] ?? false); $out['playerGeneration'] = max(0, (int)($player['generation'] ?? 0)); // Return the authenticated player's grow-only set. The client uploads // its offline snapshot first, then merges this atomic union response. $out['playerUnlocked'] = (object)(is_array($player['unlocked'] ?? null) ? $player['unlocked'] : []); $out['playerProgress'] = normalize_progress_state($player['progressState'] ?? null); } return $out; } function enforce_rate_limit(string $root, string $scope, int $limit): void { $now = time(); $hash = hash('sha256', $scope); $path = $root . DIRECTORY_SEPARATOR . 'rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json'; ensure_directory(dirname($path)); $lockPath = $path . '.lock'; with_lock($lockPath, LOCK_EX, function () use ($path, $now, $limit): void { $state = read_json_file($path, ['window' => $now, 'count' => 0]); $window = (int)($state['window'] ?? $now); $count = (int)($state['count'] ?? 0); if ($now - $window >= RATE_WINDOW_SECONDS) { $window = $now; $count = 0; } $count++; if ($count > $limit) respond(['ok' => false, 'error' => 'rate_limited', 'retryAfter' => max(1, RATE_WINDOW_SECONDS - ($now - $window))], 429); atomic_write_json($path, ['window' => $window, 'count' => $count]); }); } function claim_identity_issue_slot(string $root, string $ip): void { $now = time(); $hash = hash('sha256', 'identity:' . $ip); $path = $root . DIRECTORY_SEPARATOR . 'identity-rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json'; with_lock($path . '.lock', LOCK_EX, function () use ($path, $now): void { $state = read_json_file($path, ['window' => $now, 'count' => 0]); $window = max(1, (int)($state['window'] ?? $now)); $count = max(0, (int)($state['count'] ?? 0)); if ($now - $window >= IDENTITY_ISSUE_WINDOW_SECONDS) { $window = $now; $count = 0; } if ($count >= IDENTITY_ISSUE_LIMIT_PER_IP) { respond([ 'ok' => false, 'error' => 'identity_creation_limited', 'retryAfter' => max(1, IDENTITY_ISSUE_WINDOW_SECONDS - ($now - $window)), ], 429); } atomic_write_json($path, ['window' => $window, 'count' => $count + 1]); }); } function random_uuid_v4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); } function session_signing_key(string $root): string { $path = $root . DIRECTORY_SEPARATOR . 'session-key.json'; return with_lock($path . '.lock', LOCK_EX, function () use ($path): string { $state = read_json_file($path, []); $hex = strtolower((string)($state['key'] ?? '')); if (preg_match('/^[0-9a-f]{64}$/', $hex) !== 1) { $hex = bin2hex(random_bytes(32)); atomic_write_json($path, ['key' => $hex]); } $key = hex2bin($hex); if (!is_string($key) || strlen($key) !== 32) throw new RuntimeException('storage_corrupt'); return $key; }); } function make_session_cookie(string $root, string $playerId, string $secret): string { $payload = strtolower($playerId) . '.' . strtolower($secret); return $payload . '.' . hash_hmac('sha256', $payload, session_signing_key($root)); } function read_session_cookie(string $root): ?array { $token = strtolower((string)($_COOKIE[SESSION_COOKIE_NAME] ?? '')); $parts = explode('.', $token); if (count($parts) !== 3) return null; [$playerId, $secret, $mac] = $parts; if (!valid_player_id($playerId) || !valid_player_secret($secret) || preg_match('/^[0-9a-f]{64}$/', $mac) !== 1) return null; $payload = $playerId . '.' . $secret; $expected = hash_hmac('sha256', $payload, session_signing_key($root)); return hash_equals($expected, $mac) ? ['playerId' => $playerId, 'playerSecret' => $secret] : null; } function set_session_cookie(string $root, string $playerId, string $secret): void { $secure = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off'); setcookie(SESSION_COOKIE_NAME, make_session_cookie($root, $playerId, $secret), [ 'expires' => time() + SESSION_COOKIE_MAX_AGE, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Strict', ]); } function stored_owner_matches(string $root, string $playerId, string $secret): bool { $path = player_path($root, $playerId); if (!is_file($path)) return false; try { $player = normalize_player(read_json_file($path, []), $playerId); } catch (RuntimeException) { return false; } $stored = (string)($player['ownerHash'] ?? ''); return $stored !== '' && hash_equals($stored, hash('sha256', strtolower($secret))); } function resolve_session_credentials(string $root, string $submittedPlayerId, string $submittedSecret, string $legacyPlayerId): array { if (valid_legacy_player_id($legacyPlayerId) && valid_player_secret($submittedSecret)) { $migratedPlayerId = legacy_player_uuid($legacyPlayerId); $path = player_path($root, $migratedPlayerId); if (is_file($path)) { $raw = read_json_file($path, []); $claimHash = strtolower((string)($raw['legacyClaimHash'] ?? '')); if ($claimHash !== '' && hash_equals($claimHash, hash('sha256', $legacyPlayerId))) { $ownerHash = strtolower((string)($raw['ownerHash'] ?? '')); $submittedOwnerHash = hash('sha256', strtolower($submittedSecret)); if ($ownerHash !== '' && !hash_equals($ownerHash, $submittedOwnerHash)) { // A valid durable identity or HttpOnly cookie must remain a // recovery path even when stale legacy-ID storage survived // but its generated secret did not. Otherwise the stale // legacy value permanently blocks an already-authenticated // browser before cookie recovery is considered. if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret) && stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) { return [ 'playerId' => strtolower($submittedPlayerId), 'playerSecret' => strtolower($submittedSecret), 'created' => false, ]; } $cookie = read_session_cookie($root); if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) { return [ 'playerId' => $cookie['playerId'], 'playerSecret' => $cookie['playerSecret'], 'created' => false, ]; } respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403); } return [ 'playerId' => $migratedPlayerId, 'playerSecret' => strtolower($submittedSecret), 'created' => false, 'legacyClaimId' => $legacyPlayerId, ]; } } } if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret) && stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) { return ['playerId' => strtolower($submittedPlayerId), 'playerSecret' => strtolower($submittedSecret), 'created' => false]; } $cookie = read_session_cookie($root); if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) { return ['playerId' => $cookie['playerId'], 'playerSecret' => $cookie['playerSecret'], 'created' => false]; } claim_identity_issue_slot($root, client_ip()); return ['playerId' => random_uuid_v4(), 'playerSecret' => bin2hex(random_bytes(32)), 'created' => true]; } $method = strtoupper((string)($_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'); if (!in_array($action, ['session', 'sync', 'reset', 'summary'], true)) respond(['ok' => false, 'error' => 'invalid_action'], 400); if ($method === 'GET' && $action !== 'summary') respond(['ok' => false, 'error' => 'method_not_allowed'], 405); try { $root = data_root(); protect_data_root($root); $summaryPath = $root . DIRECTORY_SEPARATOR . 'summary.json'; $globalLock = $root . DIRECTORY_SEPARATOR . 'summary.lock'; with_lock($globalLock, LOCK_EX, fn() => migrate_legacy_compact_state_v4($root, $summaryPath)); } catch (RuntimeException $error) { $known = [ 'storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked', 'legacy_state_unreadable', 'legacy_state_invalid', 'legacy_catalog_mismatch', 'legacy_backup_failed', 'legacy_player_collision', 'legacy_migration_verify_failed', ]; $message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; respond(['ok' => false, 'error' => $message], 503); } if ($action === 'summary') { enforce_rate_limit($root, 'ip:' . client_ip() . ':read', RATE_READ_LIMIT); } elseif ($action === 'session') { enforce_rate_limit($root, 'ip:' . client_ip() . ':session', RATE_SESSION_IP_LIMIT); } else { // Authenticated writes are primarily player-scoped. The high IP ceiling is // only an abuse fuse and does not make a classroom share a 30-request pool. enforce_rate_limit($root, 'ip:' . client_ip() . ':write-abuse', RATE_WRITE_IP_LIMIT); } if ($action === 'summary') { try { $result = with_lock($globalLock, LOCK_EX, fn() => public_summary(repair_summary_cache($root, $summaryPath))); respond($result); } catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); } } $submittedPlayerId = strtolower((string)($payload['playerId'] ?? '')); $submittedSecret = strtolower((string)($payload['playerSecret'] ?? '')); $submittedLegacyPlayerId = (string)($payload['legacyPlayerId'] ?? ''); $sessionCredentials = null; if ($action === 'session') { try { $sessionCredentials = resolve_session_credentials($root, $submittedPlayerId, $submittedSecret, $submittedLegacyPlayerId); } catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); } $playerId = $sessionCredentials['playerId']; $secret = $sessionCredentials['playerSecret']; } else { $playerId = $submittedPlayerId; $secret = $submittedSecret; if (!valid_player_id($playerId)) respond(['ok' => false, 'error' => 'invalid_player'], 400); if (!valid_player_secret($secret)) respond(['ok' => false, 'error' => 'invalid_owner'], 403); if (!is_file(player_path($root, $playerId))) respond(['ok' => false, 'error' => 'unknown_player'], 403); } enforce_rate_limit($root, 'player:' . $playerId, RATE_PLAYER_WRITE_LIMIT); $clientGeneration = max(0, (int)($payload['generation'] ?? 0)); $generationRecovery = (bool)($payload['generationRecovery'] ?? false); $submittedUnlocks = []; $submittedProgress = default_progress_state(); if ($action === 'sync') { if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) { respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426); } if ((string)($payload['mergeMode'] ?? '') !== 'grow-only-union-v5') { respond(['ok' => false, 'error' => 'invalid_merge_mode'], 400); } $rawUnlocks = $payload['unlocked'] ?? null; if (!is_array($rawUnlocks) || count($rawUnlocks) > count(ACHIEVEMENT_IDS)) respond(['ok' => false, 'error' => 'invalid_unlocks'], 400); if ((bool)($payload['replace'] ?? false)) respond(['ok' => false, 'error' => 'destructive_sync_disabled'], 409); $rawProgress = $payload['progressState'] ?? null; if (!is_array($rawProgress)) respond(['ok' => false, 'error' => 'invalid_progress'], 400); $submittedProgress = normalize_progress_state($rawProgress); $receivedAt = time(); foreach ($rawUnlocks as $id => $timestamp) { if (!is_string($id) || !in_array($id, ACHIEVEMENT_IDS, true)) respond(['ok' => false, 'error' => 'invalid_achievement'], 400); if ($id === COMPLETIONIST_ID) continue; // completionist is always server-derived. $submittedUnlocks[$id] = normalize_client_unlock_time($timestamp, $receivedAt); } } if ($action === 'reset') { if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) { respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426); } if ((string)($payload['confirmReset'] ?? '') !== 'RESET_ACHIEVEMENTS') respond(['ok' => false, 'error' => 'reset_confirmation_required'], 409); if ((string)($payload['catalogRevision'] ?? '') !== CATALOG_REVISION) respond(['ok' => false, 'error' => 'catalog_mismatch'], 409); } try { $result = with_lock($globalLock, LOCK_EX, function () use ($root, $summaryPath, $playerId, $secret, $payload, $action, $submittedUnlocks, $submittedProgress, $sessionCredentials, $clientGeneration, $generationRecovery): array { $now = time(); $summary = default_summary(); $path = player_path($root, $playerId); $exists = is_file($path); if (!$exists && $action !== 'session') respond(['ok' => false, 'error' => 'unknown_player'], 403); $ownerHash = hash('sha256', $secret); $player = $exists ? normalize_player(read_json_file($path, []), $playerId) : default_player($playerId, $ownerHash, $now); $legacyClaimId = (string)($sessionCredentials['legacyClaimId'] ?? ''); if ($action === 'session' && $legacyClaimId !== '') { $expectedClaimHash = hash('sha256', $legacyClaimId); if (!hash_equals((string)($player['legacyClaimHash'] ?? ''), $expectedClaimHash)) { respond(['ok' => false, 'error' => 'legacy_claim_invalid'], 403); } $storedOwnerHash = (string)($player['ownerHash'] ?? ''); if ($storedOwnerHash !== '' && !hash_equals($storedOwnerHash, $ownerHash)) { respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403); } $player['ownerHash'] = $ownerHash; if ((int)($player['legacyClaimedAt'] ?? 0) <= 0) $player['legacyClaimedAt'] = $now; } verify_owner($player, $secret); $beforeQualified = (bool)$player['qualified']; $beforeUnlocks = $player['unlocked']; $serverGeneration = max(0, (int)($player['generation'] ?? 0)); if ($action === 'sync') { if ($clientGeneration < $serverGeneration) { $summary = repair_summary_cache($root, $summaryPath); $error = public_summary($summary, $player); $error['ok'] = false; $error['error'] = 'stale_generation'; respond($error, 409); } if ($clientGeneration > $serverGeneration) { if (!$generationRecovery) { $summary = repair_summary_cache($root, $summaryPath); $error = public_summary($summary, $player); $error['ok'] = false; $error['error'] = 'generation_ahead'; respond($error, 409); } // A locally durable newer generation is authoritative after a // server backup restore or a newly-issued replacement identity. $player['generation'] = $clientGeneration; $player['unlocked'] = []; $player['progressState'] = default_progress_state(); $serverGeneration = $clientGeneration; } } $player['lastSeen'] = $now; $player['activityCount'] = max(0, (int)$player['activityCount']) + 1; $version = substr((string)($payload['gameVersion'] ?? ''), 0, 32); if ($version !== '') $player['gameVersion'] = $version; if ($action === 'sync') { $player['progressState'] = merge_progress_state($player['progressState'] ?? null, $submittedProgress); foreach ($submittedUnlocks as $id => $submittedAt) { if (!isset($player['unlocked'][$id])) $player['unlocked'][$id] = $submittedAt; else $player['unlocked'][$id] = min((int)$player['unlocked'][$id], $submittedAt); } // Cumulative achievements are independently reconstructed from the // merged progress CRDT, so 99 actions on one device plus 1 on // another unlocks exactly once without trusting request ordering. reconcile_progress_unlocks($player['unlocked'], $player['progressState'], $now); } elseif ($action === 'reset') { // The client generation is the target generation. Equal or older // reset requests are already-processed retries and must be no-ops. if ($clientGeneration > $serverGeneration) { $player['generation'] = $clientGeneration; $player['unlocked'] = []; $player['progressState'] = default_progress_state(); } } reconcile_completionist($player['unlocked'], $now); if ($beforeQualified) apply_summary_delta($summary, $beforeUnlocks, $player['unlocked']); maybe_qualify($player, $summary, $now); atomic_write_json($path, $player); $summary = repair_summary_cache($root, $summaryPath); $public = public_summary($summary, $player); if ($action === 'session') { $public['playerId'] = $playerId; $public['playerSecret'] = $secret; $public['serverIssued'] = (bool)($sessionCredentials['created'] ?? false); $public['legacyClaimed'] = (string)($sessionCredentials['legacyClaimId'] ?? '') !== ''; } return $public; }); if ($action === 'session') set_session_cookie($root, $playerId, $secret); respond($result); } catch (RuntimeException $error) { $known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked']; $message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; respond(['ok' => false, 'error' => $message], 503); }