test(compare): trace NPC possession selection

This commit is contained in:
2026-07-31 04:39:00 +00:00
parent 765dee831c
commit 90d078c100
4 changed files with 514 additions and 30 deletions
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
http_response_code(404);
fwrite(STDERR, "NPC possession comparison is disabled.\n");
exit(64);
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
final class ComparisonNpcTracingRandUtil extends RandUtil
{
/** @var list<float> */
public array $floatDraws = [];
public function nextFloat1(): float
{
$value = parent::nextFloat1();
$this->floatDraws[] = $value;
return $value;
}
}
/** @return array<string, mixed> */
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<int> */
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<int, int> */
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<array{id: int, leadership: int, strength: int, intel: int}> */
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<string, mixed> */
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<string, mixed> $case
* @return array<string, mixed>
*/
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;
+12 -30
View File
@@ -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')
]);
]);
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace sammo;
final class NpcPossessionSelector
{
public static function buildSeed(string|int $hiddenSeed, int $owner, string $now): string
{
return Util::simpleSerialize($hiddenSeed, 'SelectNPCToken', $owner, $now);
}
/** @param array<string, mixed> $general */
public static function weight(array $general): float
{
return pow(
(int)$general['leadership'] + (int)$general['strength'] + (int)$general['intel'],
1.5,
);
}
/**
* @param array<int|string, array<string, mixed>> $oldPick
* @param list<int> $keepIds
* @return array{picked: array<int|string, array<string, mixed>>, 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<int|string, array<string, mixed>> $candidates
* @param array<int|string, float> $weights
* @param iterable<string> $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<int|string, array<string, mixed>> $candidates
* @param array<int|string, float> $weights
* @param array<int|string, array<string, mixed>> $picked
* @param null|callable(int|string): void $onDraw
* @return array<int|string, array<string, mixed>>
*/
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;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use sammo\Json;
use sammo\LiteHashDRBG;
use sammo\NpcPossessionSelector;
use sammo\RandUtil;
require_once __DIR__ . '/../src/sammo/NpcPossessionSelector.php';
final class NpcPossessionSelectorTest extends TestCase
{
/** @return array<int, array{id: int, leadership: int, strength: int, intel: int, keepCnt: int}> */
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']));
}
}