Files
xuetang/server/app/common/service/game/TangDetectiveProgress.php
T
2026-09-09 14:47:29 +08:00

252 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\game;
/** ID-only progress policy. No framework, database, filesystem writes or network. */
final class TangDetectiveProgress
{
public const MAX_BODY_BYTES = 32768;
private array $catalog;
private array $chapters = [];
private array $cardIds = [];
public function __construct(?array $catalog = null)
{
$this->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);
}
}