catalog = $catalog ?? require dirname(__DIR__, 4) . '/config/tang_detective.php'; foreach ($this->catalog['chapters'] as $chapter) { $this->chapters[$chapter['chapter_id']] = $chapter; $this->cardIds[] = $chapter['memory_card_id']; } } public function catalog(): array { return $this->catalog; } public function defaultProgress(): array { return [ 'completedHotspots' => (object) [], 'completedChapters' => [], 'lastChapter' => 1, 'collectedMemoryCards' => [], 'comicReaderByChapter' => (object) [], 'lastPageId' => '', ]; } public function defaultState(int $userId): array { if ($userId <= 0) { throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录'); } return [ 'user_id' => $userId, 'schema_version' => 1, 'content_version' => 'season-01', 'revision' => 0, 'story_generation' => 0, 'progress' => $this->defaultProgress(), ]; } /** Decode raw JSON so objects, lists, integers and booleans stay distinct. */ public function decodeRequest(string $raw): array { if (strlen($raw) > self::MAX_BODY_BYTES) { throw new TangDetectiveProgressException('PAYLOAD_TOO_LARGE', '存档请求超过大小限制'); } try { $decoded = json_decode($raw, false, 12, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { $this->invalid('存档请求不是有效的 JSON 对象'); } $source = $this->record($decoded, [ 'schema_version', 'content_version', 'base_revision', 'story_generation', 'request_id', 'operation', 'progress', ], true); if ($source['schema_version'] !== 1 || $source['content_version'] !== 'season-01') { throw new TangDetectiveProgressException('UNSUPPORTED_CONTENT_VERSION', '存档版本不匹配,请更新后再试'); } $this->boundedInteger($source['base_revision'], 0, 2147483646); $this->boundedInteger($source['story_generation'], 0, 2147483646); if (!is_string($source['request_id']) || !preg_match('/\A[A-Za-z0-9_-]{16,64}\z/D', $source['request_id'])) { $this->invalid('存档请求标识无效'); } if (!in_array($source['operation'], ['replace', 'reset_story'], true)) { $this->invalid('存档操作无效'); } $progress = $this->normalizeProgress($source['progress']); if ($source['operation'] === 'reset_story') { $empty = $this->defaultProgress(); $empty['collectedMemoryCards'] = $progress['collectedMemoryCards']; if ($this->encode($empty) !== $this->encode($progress)) { $this->invalid('重新开始请求必须清空故事进度'); } } return [ 'schema_version' => 1, 'content_version' => 'season-01', 'base_revision' => $source['base_revision'], 'story_generation' => $source['story_generation'], 'request_id' => $source['request_id'], 'operation' => $source['operation'], 'progress' => $progress, ]; } /** Reject unknown fields; canonicalize only equivalent ordering of sets/maps. */ public function normalizeProgress($value): array { $source = $this->record($value, [ 'completedHotspots', 'completedChapters', 'lastChapter', 'collectedMemoryCards', 'comicReaderByChapter', 'lastPageId', ], true); $this->boundedInteger($source['lastChapter'], 1, count($this->chapters)); $hotspots = $this->record($source['completedHotspots'], array_keys($this->chapters)); $readers = $this->record($source['comicReaderByChapter'], array_keys($this->chapters)); $finished = $this->idSet($source['completedChapters'], array_keys($this->chapters)); $cards = $this->idSet($source['collectedMemoryCards'], $this->cardIds); $cleanHotspots = []; $cleanReaders = []; foreach ($this->chapters as $id => $chapter) { $events = $this->eventPrefix($hotspots[$id] ?? [], $chapter['hotspot_ids']); if (array_key_exists($id, $hotspots)) { $cleanHotspots[$id] = $events; } $isFinished = in_array($id, $finished, true); if (!array_key_exists($id, $readers)) { if ($events !== [] || $isFinished) { $this->invalid('章节存档缺少一致的阅读状态'); } continue; } $reader = $this->record($readers[$id], ['currentPageId', 'completedEventIds', 'chapterFinished'], true); $readerEvents = $this->eventPrefix($reader['completedEventIds'], $chapter['hotspot_ids']); if (!is_bool($reader['chapterFinished']) || $readerEvents !== $events || $reader['chapterFinished'] !== $isFinished || ($isFinished && count($events) !== 4)) { $this->invalid('章节完成状态与事件进度不一致'); } $pageIndex = is_string($reader['currentPageId']) ? array_search($reader['currentPageId'], $chapter['page_ids'], true) : false; $lastUnlocked = $isFinished ? 7 : min(6, 2 + count($events)); if ($pageIndex === false || $pageIndex > $lastUnlocked) { $this->invalid('阅读页码无效或尚未解锁'); } $cleanReaders[$id] = [ 'currentPageId' => $reader['currentPageId'], 'completedEventIds' => $readerEvents, 'chapterFinished' => $reader['chapterFinished'], ]; } if (!is_string($source['lastPageId'])) { $this->invalid('最后阅读页码无效'); } $lastId = sprintf('S01-C%02d', $source['lastChapter']); if ($source['lastPageId'] !== '' && ( !isset($cleanReaders[$lastId]) || $cleanReaders[$lastId]['currentPageId'] !== $source['lastPageId'] )) { $this->invalid('最后阅读页码与章节书签不一致'); } return [ 'completedHotspots' => (object) $cleanHotspots, 'completedChapters' => $finished, 'lastChapter' => $source['lastChapter'], 'collectedMemoryCards' => $cards, 'comicReaderByChapter' => (object) $cleanReaders, 'lastPageId' => $source['lastPageId'], ]; } /** Called under the user's database row lock; request must be decodeRequest's result. */ public function transition(array $current, array $request, string $lastRequestId = '', string $lastHash = ''): array { $hash = hash('sha256', $this->encode($request)); if ($lastRequestId !== '' && hash_equals($lastRequestId, $request['request_id'])) { if (!hash_equals($lastHash, $hash)) { throw new TangDetectiveProgressException('IDEMPOTENCY_CONFLICT', '同一请求标识不能提交不同存档'); } return ['state' => $current, 'request_hash' => $hash, 'idempotent' => true]; } if ($request['base_revision'] !== $current['revision'] || $request['story_generation'] !== $current['story_generation']) { throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请选择云端或本机进度'); } if ($current['revision'] >= 2147483646 || $current['story_generation'] >= 2147483646) { throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档版本超出范围,请联系管理员'); } $next = $current; $next['progress'] = $request['progress']; if ($request['operation'] === 'reset_story') { $next['progress'] = $this->defaultProgress(); $cards = array_unique(array_merge( $current['progress']['collectedMemoryCards'], $request['progress']['collectedMemoryCards'] )); $next['progress']['collectedMemoryCards'] = array_values(array_intersect($this->cardIds, $cards)); ++$next['story_generation']; } ++$next['revision']; return ['state' => $next, 'request_hash' => $hash, 'idempotent' => false]; } public function encode(array $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } private function record($value, array $allowed, bool $requireAll = false): array { if (!$value instanceof \stdClass) { $this->invalid('存档字段必须是对象'); } $fields = get_object_vars($value); if (array_diff(array_keys($fields), $allowed) !== [] || ($requireAll && array_diff($allowed, array_keys($fields)) !== [])) { $this->invalid('存档包含未知字段或缺少必要字段'); } return $fields; } private function idSet($value, array $allowed): array { if (!is_array($value) || $value !== array_values($value) || count($value) > count($allowed)) { $this->invalid('存档标识列表无效'); } $seen = []; foreach ($value as $id) { if (!is_string($id) || !in_array($id, $allowed, true) || isset($seen[$id])) { $this->invalid('存档包含非法或重复标识'); } $seen[$id] = true; } return array_values(array_filter($allowed, static fn (string $id): bool => isset($seen[$id]))); } private function eventPrefix($value, array $allowed): array { if (!is_array($value) || $value !== array_slice($allowed, 0, count($value))) { $this->invalid('事件进度必须是本章连续前缀'); } return $value; } private function boundedInteger($value, int $minimum, int $maximum): void { if (!is_int($value) || $value < $minimum || $value > $maximum) { $this->invalid('存档整数参数无效'); } } private function invalid(string $message): void { throw new TangDetectiveProgressException('INVALID_REQUEST', $message); } }