*/ public array $floatDraws = []; public function nextFloat1(): float { $value = parent::nextFloat1(); $this->floatDraws[] = $value; return $value; } } /** @return array */ function comparisonNpcRequest(): array { $request = json_decode(stream_get_contents(STDIN), true, flags: JSON_THROW_ON_ERROR); if (!is_array($request)) { throw new \InvalidArgumentException('fixture must be a JSON object'); } return $request; } /** @return list */ function comparisonNpcIntegerList(mixed $value, string $name): array { if (!is_array($value)) { throw new \InvalidArgumentException("{$name} must be an array"); } $result = []; foreach ($value as $item) { if (!is_int($item) || $item <= 0) { throw new \InvalidArgumentException("{$name} must contain positive integers"); } $result[] = $item; } return $result; } /** @return array */ function comparisonNpcPreviousPick(mixed $value): array { if ($value === null) { return []; } if (!is_array($value)) { throw new \InvalidArgumentException('previousPick must be an array'); } $result = []; foreach ($value as $item) { if ( !is_array($item) || !isset($item['id'], $item['keepCount']) || !is_int($item['id']) || $item['id'] <= 0 || !is_int($item['keepCount']) || $item['keepCount'] < 0 ) { throw new \InvalidArgumentException('previousPick entries require positive id and non-negative keepCount'); } $result[$item['id']] = $item['keepCount']; } return $result; } /** @return list */ function comparisonNpcCandidateRows(): array { return array_map( static fn(array $row): array => [ 'id' => (int)$row['no'], 'leadership' => (int)$row['leadership'], 'strength' => (int)$row['strength'], 'intel' => (int)$row['intel'], ], DB::db()->query('SELECT `no`, leadership, strength, intel FROM general WHERE npc=2'), ); } /** @return array */ function comparisonNpcState(): array { return [ 'candidates' => comparisonNpcCandidateRows(), 'tokens' => DB::db()->query( 'SELECT owner, valid_until, pick_more_from, pick_result, nonce FROM select_npc_token ORDER BY id', ), ]; } /** * @param array $case * @return array */ function comparisonNpcRunCase(array $case, string|int $hiddenSeed, int $owner, int $now, GameClock $clock): array { $db = DB::db(); $db->delete('select_npc_token', '1=1'); $insertReserved = static function (int $reservedOwner, array $reservedIds, int $validUntil) use ($db, $now): void { if ($reservedIds === []) { return; } $reservedPick = []; foreach ($reservedIds as $id) { $reservedPick[$id] = ['keepCnt' => 3]; } $db->insert('select_npc_token', [ 'owner' => $reservedOwner, 'valid_until' => $validUntil, 'pick_more_from' => $now, 'pick_result' => json_encode((object)$reservedPick, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'nonce' => 1, ]); }; $reservedIds = comparisonNpcIntegerList($case['reservedIds'] ?? [], 'reservedIds'); $boundaryReservedIds = comparisonNpcIntegerList( $case['boundaryReservedIds'] ?? [], 'boundaryReservedIds', ); $expiredReservedIds = comparisonNpcIntegerList( $case['expiredReservedIds'] ?? [], 'expiredReservedIds', ); $expiredAt = $now - $clock->ticksFromSeconds(1); $insertReserved($owner + 1, $reservedIds, GameClock::MAX_SAFE_TICK); $insertReserved($owner + 2, $boundaryReservedIds, $now); $insertReserved($owner + 3, $expiredReservedIds, $expiredAt); $candidates = []; $weights = []; foreach (comparisonNpcCandidateRows() as $row) { $id = $row['id']; $candidates[$id] = $row + ['keepCnt' => 3]; $weights[$id] = NpcPossessionSelector::weight($row); } NpcPossessionSelector::removeReserved( $candidates, $weights, $db->queryFirstColumn( 'SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%i', $owner, $now, ), ); $previousPick = comparisonNpcPreviousPick($case['previousPick'] ?? null); $keepIds = comparisonNpcIntegerList($case['keepIds'] ?? [], 'keepIds'); $oldPick = []; foreach ($previousPick as $id => $keepCount) { if (!isset($candidates[$id])) { throw new \InvalidArgumentException("previous candidate {$id} is not selectable"); } $oldPick[$id] = $candidates[$id]; $oldPick[$id]['keepCnt'] = $keepCount; } $hasPreviousToken = ($case['hasPreviousToken'] ?? false) === true || $oldPick !== []; if ($hasPreviousToken) { $db->insert('select_npc_token', [ 'owner' => $owner, 'valid_until' => GameClock::MAX_SAFE_TICK, 'pick_more_from' => $now, 'pick_result' => json_encode((object)$oldPick, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'nonce' => 2, ]); } $before = comparisonNpcState(); $keepSelection = NpcPossessionSelector::applyKeep($oldPick, $keepIds); $picked = $keepSelection['picked']; if ($hasPreviousToken && $keepSelection['cancelled']) { return [ 'name' => (string)($case['name'] ?? ''), 'cancelled' => true, 'seed' => null, 'candidateOrder' => array_keys($candidates), 'randomDraws' => [], 'draws' => [], 'pick' => array_map( static fn(int $id, array $candidate): array => [ 'id' => $id, 'keepCount' => (int)$candidate['keepCnt'], ], array_keys($oldPick), array_values($oldPick), ), 'selectionStateUnchanged' => comparisonNpcState() === $before, ]; } $seed = NpcPossessionSelector::buildSeed($hiddenSeed, $owner, $now); $rng = new ComparisonNpcTracingRandUtil(new LiteHashDRBG($seed)); $draws = []; $picked = NpcPossessionSelector::select( $candidates, $weights, $picked, $rng, static function (int|string $id) use (&$draws): void { $draws[] = (int)$id; }, ); return [ 'name' => (string)($case['name'] ?? ''), 'cancelled' => false, 'seed' => $seed, 'candidateOrder' => array_keys($candidates), 'randomDraws' => $rng->floatDraws, 'draws' => $draws, 'pick' => array_map( static fn(int $id, array $candidate): array => [ 'id' => $id, 'keepCount' => (int)$candidate['keepCnt'], ], array_keys($picked), array_values($picked), ), 'selectionStateUnchanged' => comparisonNpcState() === $before, ]; } $request = comparisonNpcRequest(); $hiddenSeed = $request['hiddenSeed'] ?? null; $owner = $request['owner'] ?? null; $now = $request['now'] ?? null; $candidateFixtures = $request['candidates'] ?? null; $cases = $request['cases'] ?? null; if ( (!is_string($hiddenSeed) && !is_int($hiddenSeed)) || !is_int($owner) || $owner <= 0 || !is_string($now) || !is_array($candidateFixtures) || !is_array($cases) ) { throw new \InvalidArgumentException('fixture requires hiddenSeed, positive owner, now, candidates and cases'); } $date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $now); if ($date === false || $date->format('Y-m-d H:i:s') !== $now) { throw new \InvalidArgumentException('now must use Y-m-d H:i:s'); } $db = DB::db(); $gameStorage = KVStorage::getStorage($db, 'game_env'); $clock = GameClock::fromStorage($gameStorage); $nowTick = $clock->dateTimeToTick($date); $db->update('general', ['npc' => 1], '1=1'); $fixtureIds = []; foreach ($candidateFixtures as $candidate) { if ( !is_array($candidate) || !isset($candidate['id'], $candidate['leadership'], $candidate['strength'], $candidate['intel']) || !is_int($candidate['id']) || $candidate['id'] <= 0 || !is_int($candidate['leadership']) || !is_int($candidate['strength']) || !is_int($candidate['intel']) ) { throw new \InvalidArgumentException('candidate entries require integer id and stats'); } if ($db->queryFirstField('SELECT `no` FROM general WHERE `no`=%i', $candidate['id']) === null) { throw new \RuntimeException("fixture general {$candidate['id']} does not exist"); } $db->update( 'general', [ 'npc' => 2, 'leadership' => $candidate['leadership'], 'strength' => $candidate['strength'], 'intel' => $candidate['intel'], ], '`no`=%i', $candidate['id'], ); $fixtureIds[] = $candidate['id']; } $results = []; foreach ($cases as $case) { if (!is_array($case)) { throw new \InvalidArgumentException('case must be an object'); } $results[] = comparisonNpcRunCase($case, $hiddenSeed, $owner, $nowTick, $clock); } echo json_encode( [ 'fixtureGeneralIds' => $fixtureIds, 'observedSqlOrder' => array_column(comparisonNpcCandidateRows(), 'id'), 'cases' => $results, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT, ); echo PHP_EOL;