diff --git a/hwe/compare/npc_possession_selection_trace.php b/hwe/compare/npc_possession_selection_trace.php new file mode 100644 index 00000000..fa511b86 --- /dev/null +++ b/hwe/compare/npc_possession_selection_trace.php @@ -0,0 +1,309 @@ + */ + 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, string $now): array +{ + $db = DB::db(); + $db->delete('select_npc_token', '1=1'); + + $insertReserved = static function (int $reservedOwner, array $reservedIds, string $validUntil) use ($db): 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' => '2000-01-01 01:00:00', + '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 = (new \DateTimeImmutable($now))->modify('-1 second')->format('Y-m-d H:i:s'); + $insertReserved($owner + 1, $reservedIds, '2099-12-31 23:59:59'); + $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 >=%s', + $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' => '2099-12-31 23:59:59', + 'pick_more_from' => '2000-01-01 01:00:00', + '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(); +$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, $now); +} + +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; diff --git a/hwe/j_get_select_npc_token.php b/hwe/j_get_select_npc_token.php index 0227114d..e18ff0d6 100644 --- a/hwe/j_get_select_npc_token.php +++ b/hwe/j_get_select_npc_token.php @@ -55,14 +55,9 @@ if($token && $refresh){ if($nowT >= $pickMoreFrom){ $oldPickResult = Json::decode($token['pick_result']); - foreach($keepResult as $keepId){ - if(\key_exists($keepId, $oldPickResult) && $oldPickResult[$keepId]['keepCnt'] > 0){ - $pickResult[$keepId] = $oldPickResult[$keepId]; - $pickResult[$keepId]['keepCnt']-=1; - } - } - - if(count($pickResult) == count($oldPickResult)){ + $keepSelection = NpcPossessionSelector::applyKeep($oldPickResult, $keepResult); + $pickResult = $keepSelection['picked']; + if($keepSelection['cancelled']){ $refresh = false; } } @@ -96,35 +91,22 @@ foreach($db->query('SELECT `no`, `name`, leadership, strength, intel, nation, im $general['personal'] = buildPersonalityClass($general['personal'])->getName(); $general['nation'] = getNationStaticInfo($general['nation'])['name']; $candidates[$general['no']] = $general + ['keepCnt'=>KEEP_CNT]; - $allStat = $general['leadership'] + $general['strength'] + $general['intel']; - $weight[$general['no']] = pow($allStat, 1.5); + $weight[$general['no']] = NpcPossessionSelector::weight($general); } -foreach($db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%s', $userID, $now) as $reserved){ - $reserved = Json::decode($reserved); - foreach(array_keys($reserved) as $reservedNPC){ - if(key_exists($reservedNPC, $weight)){ - unset($candidates[$reservedNPC]); - unset($weight[$reservedNPC]); - } - } -} +NpcPossessionSelector::removeReserved( + $candidates, + $weight, + $db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%s', $userID, $now), +); -$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( +$rng = new RandUtil(new LiteHashDRBG(NpcPossessionSelector::buildSeed( UniqueConst::$hiddenSeed, - 'SelectNPCToken', $userID, $now, ))); -$pickLimit = min(count($candidates), 5); - -while(count($pickResult) < $pickLimit){ - $generalID = $rng->choiceUsingWeight($weight); - if(!key_exists($generalID, $pickResult)){ - $pickResult[$generalID] = $candidates[$generalID]; - } -} +$pickResult = NpcPossessionSelector::select($candidates, $weight, $pickResult, $rng); $newNonce = random_int(0, 0xfffffff); @@ -176,4 +158,4 @@ Json::die([ 'pickMoreFrom'=>($inserted===-1)?$pickMoreFrom->format('Y-m-d H:i:s'):'2000-01-01 01:00:00', 'pickMoreSeconds'=>($inserted===-1)?$pickMoreSecond:0, 'validUntil'=>$validUntil->format('Y-m-d H:i:s') -]); \ No newline at end of file +]); diff --git a/src/sammo/NpcPossessionSelector.php b/src/sammo/NpcPossessionSelector.php new file mode 100644 index 00000000..89678b64 --- /dev/null +++ b/src/sammo/NpcPossessionSelector.php @@ -0,0 +1,86 @@ + $general */ + public static function weight(array $general): float + { + return pow( + (int)$general['leadership'] + (int)$general['strength'] + (int)$general['intel'], + 1.5, + ); + } + + /** + * @param array> $oldPick + * @param list $keepIds + * @return array{picked: array>, cancelled: bool} + */ + public static function applyKeep(array $oldPick, array $keepIds): array + { + $picked = []; + foreach ($keepIds as $keepId) { + if (array_key_exists($keepId, $oldPick) && (int)$oldPick[$keepId]['keepCnt'] > 0) { + $picked[$keepId] = $oldPick[$keepId]; + $picked[$keepId]['keepCnt'] = (int)$picked[$keepId]['keepCnt'] - 1; + } + } + return [ + 'picked' => $picked, + 'cancelled' => count($picked) === count($oldPick), + ]; + } + + /** + * @param array> $candidates + * @param array $weights + * @param iterable $reservedPayloads + */ + public static function removeReserved(array &$candidates, array &$weights, iterable $reservedPayloads): void + { + foreach ($reservedPayloads as $reservedPayload) { + $reserved = Json::decode($reservedPayload); + foreach (array_keys($reserved) as $reservedNpc) { + if (array_key_exists($reservedNpc, $weights)) { + unset($candidates[$reservedNpc], $weights[$reservedNpc]); + } + } + } + } + + /** + * @param array> $candidates + * @param array $weights + * @param array> $picked + * @param null|callable(int|string): void $onDraw + * @return array> + */ + public static function select( + array $candidates, + array $weights, + array $picked, + RandUtil $rng, + ?callable $onDraw = null, + ): array { + $pickLimit = min(count($candidates), 5); + while (count($picked) < $pickLimit) { + $generalId = $rng->choiceUsingWeight($weights); + if ($onDraw !== null) { + $onDraw($generalId); + } + if (!array_key_exists($generalId, $picked)) { + $picked[$generalId] = $candidates[$generalId]; + } + } + return $picked; + } +} diff --git a/tests/NpcPossessionSelectorTest.php b/tests/NpcPossessionSelectorTest.php new file mode 100644 index 00000000..4377c348 --- /dev/null +++ b/tests/NpcPossessionSelectorTest.php @@ -0,0 +1,107 @@ + */ + private function candidates(): array + { + $rows = [ + ['id' => 1, 'leadership' => 40, 'strength' => 50, 'intel' => 60, 'keepCnt' => 3], + ['id' => 2, 'leadership' => 73, 'strength' => 44, 'intel' => 81, 'keepCnt' => 3], + ['id' => 3, 'leadership' => 91, 'strength' => 32, 'intel' => 47, 'keepCnt' => 3], + ['id' => 4, 'leadership' => 55, 'strength' => 88, 'intel' => 38, 'keepCnt' => 3], + ['id' => 5, 'leadership' => 66, 'strength' => 67, 'intel' => 68, 'keepCnt' => 3], + ['id' => 6, 'leadership' => 99, 'strength' => 20, 'intel' => 21, 'keepCnt' => 3], + ['id' => 7, 'leadership' => 35, 'strength' => 93, 'intel' => 72, 'keepCnt' => 3], + ['id' => 8, 'leadership' => 84, 'strength' => 75, 'intel' => 69, 'keepCnt' => 3], + ]; + $result = []; + foreach ($rows as $row) { + $result[$row['id']] = $row; + } + return $result; + } + + public function testSharedKernelPreservesThePreviousEndpointLoop(): void + { + $candidates = $this->candidates(); + $weights = []; + foreach ($candidates as $id => $candidate) { + $weights[$id] = pow( + $candidate['leadership'] + $candidate['strength'] + $candidate['intel'], + 1.5, + ); + self::assertSame($weights[$id], NpcPossessionSelector::weight($candidate)); + } + $seed = NpcPossessionSelector::buildSeed( + 'npc-possession-differential-seed', + 7701, + '2026-08-01 00:00:01', + ); + + $legacyRng = new RandUtil(new LiteHashDRBG($seed)); + $legacyPicked = []; + $legacyDraws = []; + $pickLimit = min(count($candidates), 5); + while (count($legacyPicked) < $pickLimit) { + $id = $legacyRng->choiceUsingWeight($weights); + $legacyDraws[] = (int)$id; + if (!array_key_exists($id, $legacyPicked)) { + $legacyPicked[$id] = $candidates[$id]; + } + } + + $sharedDraws = []; + $sharedPicked = NpcPossessionSelector::select( + $candidates, + $weights, + [], + new RandUtil(new LiteHashDRBG($seed)), + static function (int|string $id) use (&$sharedDraws): void { + $sharedDraws[] = (int)$id; + }, + ); + + self::assertSame([1, 8, 1, 4, 5, 5, 8, 8, 5, 3], $legacyDraws); + self::assertSame($legacyDraws, $sharedDraws); + self::assertSame($legacyPicked, $sharedPicked); + } + + public function testKeepAndReservedHelpersPreserveEndpointSemantics(): void + { + $candidates = $this->candidates(); + $weights = array_map( + static fn(array $candidate): float => NpcPossessionSelector::weight($candidate), + $candidates, + ); + NpcPossessionSelector::removeReserved( + $candidates, + $weights, + [ + Json::encode((object)[2 => ['keepCnt' => 3], 7 => ['keepCnt' => 3]]), + ], + ); + self::assertSame([1, 3, 4, 5, 6, 8], array_keys($candidates)); + self::assertSame([1, 3, 4, 5, 6, 8], array_keys($weights)); + + $oldPick = array_intersect_key($this->candidates(), array_flip([1, 8, 4, 5, 3])); + $partial = NpcPossessionSelector::applyKeep($oldPick, [1, 4]); + self::assertFalse($partial['cancelled']); + self::assertSame(2, $partial['picked'][1]['keepCnt']); + self::assertSame(2, $partial['picked'][4]['keepCnt']); + + $all = NpcPossessionSelector::applyKeep($oldPick, [1, 8, 4, 5, 3]); + self::assertTrue($all['cancelled']); + self::assertSame([1, 8, 4, 5, 3], array_keys($all['picked'])); + } +}