310 lines
9.9 KiB
PHP
310 lines
9.9 KiB
PHP
<?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;
|