tarinai/achievement_api.php
2026-07-29 20:52:11 +09:00

1227 lines
60 KiB
PHP

<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('X-Frame-Options: DENY');
function load_achievement_catalog(): array {
$path = __DIR__ . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . 'achievement_catalog.js';
$raw = @file_get_contents($path);
if (!is_string($raw)) throw new RuntimeException('achievement_catalog_unavailable');
$prefix = 'window.TARINAI_ACHIEVEMENT_CATALOG = ';
$trimmed = trim($raw);
if (!str_starts_with($trimmed, $prefix) || !str_ends_with($trimmed, ';')) {
throw new RuntimeException('achievement_catalog_invalid');
}
$json = substr($trimmed, strlen($prefix), -1);
try { $catalog = json_decode($json, true, 512, JSON_THROW_ON_ERROR); }
catch (JsonException) { throw new RuntimeException('achievement_catalog_invalid'); }
if (!is_array($catalog) || !is_array($catalog['definitions'] ?? null)) {
throw new RuntimeException('achievement_catalog_invalid');
}
return $catalog;
}
$achievementCatalog = load_achievement_catalog();
$achievementDefinitions = [];
$achievementIds = [];
$progressRules = [];
foreach ($achievementCatalog['definitions'] as $definition) {
if (!is_array($definition)) continue;
$id = (string)($definition['id'] ?? '');
if ($id === '' || isset($achievementDefinitions[$id])) throw new RuntimeException('achievement_catalog_invalid');
$achievementDefinitions[$id] = $definition;
$achievementIds[] = $id;
if (is_array($definition['progress'] ?? null)) $progressRules[$id] = $definition['progress'];
}
define('ACHIEVEMENT_IDS', $achievementIds);
define('ACHIEVEMENT_DEFINITIONS', $achievementDefinitions);
define('PROGRESS_RULES', $progressRules);
define('COMPLETIONIST_ID', (string)($achievementCatalog['completionistId'] ?? 'true_tarinai_observer'));
define('CATALOG_REVISION', (string)($achievementCatalog['revision'] ?? 'unknown'));
define('ADDITIVE_PROGRESS_CAPS', is_array($achievementCatalog['additiveProgressCaps'] ?? null) ? $achievementCatalog['additiveProgressCaps'] : []);
define('MEDICINE_LEDGER_TYPES', is_array($achievementCatalog['medicineLedgerTypes'] ?? null) ? array_values($achievementCatalog['medicineLedgerTypes']) : []);
define('LINK_PROGRESS_TYPES', is_array($achievementCatalog['linkProgressTypes'] ?? null) ? array_values($achievementCatalog['linkProgressTypes']) : []);
define('MAD_SCIENTIST_TARGET', max(1, (int)($progressRules['mad_scientist']['target'] ?? 10)));
const MAX_REQUEST_BYTES = 131072;
const RATE_WINDOW_SECONDS = 60;
const RATE_READ_LIMIT = 240;
const RATE_SESSION_IP_LIMIT = 120;
const RATE_WRITE_IP_LIMIT = 600;
const RATE_PLAYER_WRITE_LIMIT = 120;
const QUALIFY_MIN_ACTIVITY = 2;
const QUALIFY_MIN_AGE_SECONDS = 30;
const SESSION_COOKIE_NAME = 'tarinai_achievement_session_v2';
const SESSION_COOKIE_MAX_AGE = 31536000;
const IDENTITY_ISSUE_WINDOW_SECONDS = 86400;
const IDENTITY_ISSUE_LIMIT_PER_IP = 512;
const SYNC_PROTOCOL_VERSION = 5;
function respond(array $payload, int $status = 200): never {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
function valid_player_id(string $value): bool {
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1;
}
function valid_player_secret(string $value): bool {
return preg_match('/^[0-9a-f]{64}$/i', $value) === 1;
}
function client_ip(): string {
return substr((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'), 0, 128);
}
function directory_has_achievement_data(string $path): bool {
return is_file($path . DIRECTORY_SEPARATOR . 'state.json')
|| is_file($path . DIRECTORY_SEPARATOR . 'summary.json')
|| is_file($path . DIRECTORY_SEPARATOR . 'session-key.json')
|| is_dir($path . DIRECTORY_SEPARATOR . 'players');
}
function copy_directory_tree(string $source, string $target): void {
ensure_directory($target);
$items = @scandir($source);
if (!is_array($items)) throw new RuntimeException('storage_unavailable');
foreach ($items as $name) {
if ($name === '.' || $name === '..') continue;
$from = $source . DIRECTORY_SEPARATOR . $name;
$to = $target . DIRECTORY_SEPARATOR . $name;
if (is_dir($from)) copy_directory_tree($from, $to);
elseif (is_file($from) && !is_file($to) && !@copy($from, $to)) throw new RuntimeException('storage_write_failed');
}
}
function legacy_state_snapshot(string $path): ?array {
if (!is_file($path)) return null;
$bytes = @file_get_contents($path);
if (!is_string($bytes) || trim($bytes) === '') throw new RuntimeException('legacy_state_unreadable');
return [
'path' => $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<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Deny from all\n</IfModule>\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);
}