errorCode() === $code, 'expected stable error: ' . $code); return; } throw new RuntimeException('Expected error was not thrown: ' . $code); }; $encode = static fn (array $value): string => json_encode($value, JSON_THROW_ON_ERROR); $command = static function (array $progress, int $revision = 0, int $generation = 0, string $id = 'test_request_00000001', string $operation = 'replace'): array { return [ 'schema_version' => 1, 'content_version' => 'season-01', 'base_revision' => $revision, 'story_generation' => $generation, 'request_id' => $id, 'operation' => $operation, 'progress' => $progress, ]; }; $progressFor = static function (int $chapterNumber, int $events, bool $finished = false) use ($policy): array { $id = sprintf('S01-C%02d', $chapterNumber); $catalog = $policy->catalog()['chapters'][$chapterNumber - 1]; $done = array_slice($catalog['hotspot_ids'], 0, $events); $page = sprintf('%s-P%02d', $id, $finished ? 8 : min(7, 3 + $events)); return [ 'completedHotspots' => (object) [$id => $done], 'completedChapters' => $finished ? [$id] : [], 'lastChapter' => $chapterNumber, 'collectedMemoryCards' => [], 'comicReaderByChapter' => (object) [$id => (object) [ 'currentPageId' => $page, 'completedEventIds' => $done, 'chapterFinished' => $finished, ]], 'lastPageId' => $page, ]; }; $initial = $policy->defaultState(11); $expect($initial['user_id'] === 11 && $initial['revision'] === 0, 'own-user empty read is revision zero'); $expect($initial['progress']['completedHotspots'] instanceof stdClass, 'empty maps serialize as objects'); $expectError('AUTH_REQUIRED', static fn () => $policy->defaultState(0)); $expect(count($policy->catalog()['chapters']) === 15, 'catalog contains 15 chapters'); for ($chapter = 1; $chapter <= 15; ++$chapter) { for ($count = 0; $count <= 4; ++$count) { $parsed = $policy->decodeRequest($encode($command($progressFor($chapter, $count)))); $id = sprintf('S01-C%02d', $chapter); $expect(count($parsed['progress']['completedHotspots']->$id) === $count, 'all canonical prefixes accepted'); $expect($parsed['progress']['completedChapters'] === [], 'four events do not finish a chapter automatically'); } $parsed = $policy->decodeRequest($encode($command($progressFor($chapter, 4, true)))); $expect(count($parsed['progress']['completedChapters']) === 1, 'explicit emotion completion permits memory page'); } $valid = $command($progressFor(1, 1)); $request = $policy->decodeRequest($encode($valid)); $saved = $policy->transition($initial, $request); $expect($saved['state']['revision'] === 1 && !$saved['idempotent'], 'first save advances exactly one revision'); $retry = $policy->transition($saved['state'], $request, $request['request_id'], $saved['request_hash']); $expect($retry['idempotent'] && $retry['state'] === $saved['state'], 'acknowledgement loss retries are idempotent'); $changed = $request; $changed['progress']['lastPageId'] = ''; $expectError('IDEMPOTENCY_CONFLICT', static fn () => $policy->transition( $saved['state'], $changed, $request['request_id'], $saved['request_hash'] )); $racing = $request; $racing['request_id'] = 'other_device_00000001'; $expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($saved['state'], $racing)); $expect($saved['state']['progress']['lastPageId'] === 'S01-C01-P04', 'conflicting copy never changes confirmed state'); $withCard = $progressFor(1, 4, true); $withCard['collectedMemoryCards'] = ['S01-C01-MC01']; $current = $policy->transition($initial, $policy->decodeRequest($encode($command($withCard))))['state']; $resetProgress = $policy->defaultProgress(); $resetProgress['collectedMemoryCards'] = ['S01-C02-MC01']; $resetRequest = $policy->decodeRequest($encode($command($resetProgress, 1, 0, 'reset_request_000001', 'reset_story'))); $reset = $policy->transition($current, $resetRequest); $expect($reset['state']['progress']['collectedMemoryCards'] === ['S01-C01-MC01', 'S01-C02-MC01'], 'reset preserves cloud and unsynced local card IDs'); $expect($reset['state']['story_generation'] === 1 && $reset['state']['revision'] === 2, 'reset advances generation and revision'); $expect(get_object_vars($reset['state']['progress']['comicReaderByChapter']) === [], 'reset empties reader bookmarks'); $resetRetry = $policy->transition($reset['state'], $resetRequest, $resetRequest['request_id'], $reset['request_hash']); $expect($resetRetry['state']['story_generation'] === 1 && $resetRetry['idempotent'], 'reset retry never increments generation twice'); $oldGeneration = $command($withCard, 2, 0, 'old_device_000000001'); $expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($reset['state'], $policy->decodeRequest($encode($oldGeneration)))); $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($withCard, 1, 0, 'reset_request_000002', 'reset_story')))); $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('[]')); $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('{invalid')); $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('null')); $expectError('PAYLOAD_TOO_LARGE', static fn () => $policy->decodeRequest(str_repeat(' ', 32769))); $exact = $encode($valid); $exact .= str_repeat(' ', 32768 - strlen($exact)); $expect($policy->decodeRequest($exact)['request_id'] === $valid['request_id'], 'exactly 32 KiB allowed'); $invalidBodies = []; $body = $valid; $body['user_id'] = 99; $invalidBodies[] = $body; $body = $valid; $body['progress']['memoryCardSnapshots'] = (object) ['private' => 'must not be stored']; $invalidBodies[] = $body; $body = $valid; $body['progress']['healthAnswer'] = 'private'; $invalidBodies[] = $body; $body = $valid; $body['progress']['lastChapter'] = '1'; $invalidBodies[] = $body; $body = $valid; $body['progress']['completedHotspots'] = []; $invalidBodies[] = $body; $body = $valid; $body['base_revision'] = -1; $invalidBodies[] = $body; $body = $valid; $body['request_id'] = 'short'; $invalidBodies[] = $body; $body = $valid; $body['progress']['collectedMemoryCards'] = ['S01-C16-MC01']; $invalidBodies[] = $body; $body = $valid; $body['progress']['collectedMemoryCards'] = ['S01-C01-MC01', 'S01-C01-MC01']; $invalidBodies[] = $body; foreach ($invalidBodies as $body) { $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($body))); } foreach ([['S01-H02'], ['S01-H01', 'S01-H03'], ['S01-H02', 'S01-H01'], ['S01-H01', 'S01-H01'], ['S01-H05']] as $badPrefix) { $progress = $progressFor(1, 1); $progress['completedHotspots']->{'S01-C01'} = $badPrefix; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); } $progress = $progressFor(1, 3); $progress['completedChapters'] = ['S01-C01']; $progress['comicReaderByChapter']->{'S01-C01'}->chapterFinished = true; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); $progress = $progressFor(1, 4); $progress['comicReaderByChapter']->{'S01-C01'}->currentPageId = 'S01-C01-P08'; $progress['lastPageId'] = 'S01-C01-P08'; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); $progress = $progressFor(1, 1); $progress['comicReaderByChapter']->{'S01-C01'}->answer = 'private'; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); $progress = $progressFor(1, 1); $progress['comicReaderByChapter']->{'S01-C01'}->completedEventIds = []; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); $progress = $progressFor(1, 1); $progress['lastPageId'] = 'S01-C02-P01'; $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); $body = $valid; $body['schema_version'] = '1'; $expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body))); $body = $valid; $body['content_version'] = 'season-02'; $expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body))); echo "Tang Detective progress contract: {$assertions} assertions OK\n";