Merge branch compare/npc-possession-differential-20260731
This commit is contained in:
+22
-8
@@ -22,8 +22,6 @@ $db = DB::db();
|
||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||
$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']);
|
||||
|
||||
increaseRefresh("내정보", 1);
|
||||
|
||||
$me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog);
|
||||
|
||||
$myset = $me->getVar('myset');
|
||||
@@ -41,10 +39,26 @@ $lastRefresh = $db->queryFirstField(
|
||||
$generalID
|
||||
);
|
||||
|
||||
$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart);
|
||||
if ($gameStor->turntime <= $gameStor->opentime) {
|
||||
//서버 가오픈시 할 수 있는 행동
|
||||
if ($me->getNPCType() == 0 && $me->getNationID() == 0) {
|
||||
$nextChange = $me->getAuxVar('next_change');
|
||||
if (!is_string($nextChange) || $nextChange === '') {
|
||||
$nextChange = null;
|
||||
}
|
||||
|
||||
increaseRefresh("내정보", 1);
|
||||
if ($gameStor->turntime <= $gameStor->opentime) {
|
||||
$targetTime = $me->getAuxVar('prestart_delete_after');
|
||||
if (!is_string($targetTime) || $targetTime === '') {
|
||||
$targetTime = addTurn(
|
||||
$lastRefresh ?: TimeUtil::now(),
|
||||
$gameStor->turnterm,
|
||||
GameConst::$minTurnDieOnPrestart
|
||||
);
|
||||
$me->setAuxVar('prestart_delete_after', $targetTime);
|
||||
$me->applyDB($db);
|
||||
}
|
||||
|
||||
//서버 가오픈시 할 수 있는 행동
|
||||
if ($me->getNPCType() == 0 && $me->getNationID() == 0) {
|
||||
$showDieOnPrestartBtn = true;
|
||||
if ($targetTime <= TimeUtil::now()) {
|
||||
$availableDieOnPrestart = true;
|
||||
@@ -174,7 +188,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($gameStor->npcmode == 2 && $me->getNPCType() == 0) : ?>
|
||||
다른 장수 선택 (<?= substr($me->getAuxVar('next_change') ?? TimeUtil::now(), 0, 19) ?> 부터)<br>
|
||||
다른 장수 선택 (<?= $nextChange ? substr($nextChange, 0, 19) : '지금' ?>부터)<br>
|
||||
<a href="select_general_from_pool.php" id='select_general_from_pool'><button type="button" style=background-color:<?= GameConst::$basecolor2 ?>;color:white;width:160px;height:30px;font-size:14px;>다른 장수 선택</button></a><br><br>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -272,4 +286,4 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
+65
-37
@@ -42,15 +42,21 @@ function pushGeneralCommand(int $generalID, int $turnCnt=1){
|
||||
|
||||
$db = DB::db();
|
||||
|
||||
$db->update('general_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx + %i', $turnCnt)
|
||||
], 'general_id=%i ORDER BY turn_idx DESC', $generalID);
|
||||
$db->update('general_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx - %i', GameConst::$maxTurn),
|
||||
'action'=>'휴식',
|
||||
'arg'=>'{}',
|
||||
'brief'=>'휴식'
|
||||
], 'general_id=%i AND turn_idx >= %i', $generalID, GameConst::$maxTurn);
|
||||
$db->query(
|
||||
'UPDATE general_turn AS dst
|
||||
LEFT JOIN general_turn AS src
|
||||
ON src.general_id = dst.general_id
|
||||
AND src.turn_idx = dst.turn_idx - %i
|
||||
SET dst.action = IF(src.id IS NULL, %s, src.action),
|
||||
dst.arg = IF(src.id IS NULL, %s, src.arg),
|
||||
dst.brief = IF(src.id IS NULL, %s, src.brief)
|
||||
WHERE dst.general_id = %i',
|
||||
$turnCnt,
|
||||
'휴식',
|
||||
'{}',
|
||||
'휴식',
|
||||
$generalID
|
||||
);
|
||||
}
|
||||
|
||||
function pullGeneralCommand(int $generalID, int $turnCnt=1){
|
||||
@@ -67,15 +73,21 @@ function pullGeneralCommand(int $generalID, int $turnCnt=1){
|
||||
|
||||
$db = DB::db();
|
||||
|
||||
$db->update('general_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx + %i', GameConst::$maxTurn),
|
||||
'action'=>'휴식',
|
||||
'arg'=>'{}',
|
||||
'brief'=>'휴식'
|
||||
], 'general_id=%i AND turn_idx < %i', $generalID, $turnCnt);
|
||||
$db->update('general_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx - %i', $turnCnt)
|
||||
], 'general_id=%i ORDER BY turn_idx ASC', $generalID);
|
||||
$db->query(
|
||||
'UPDATE general_turn AS dst
|
||||
LEFT JOIN general_turn AS src
|
||||
ON src.general_id = dst.general_id
|
||||
AND src.turn_idx = dst.turn_idx + %i
|
||||
SET dst.action = IF(src.id IS NULL, %s, src.action),
|
||||
dst.arg = IF(src.id IS NULL, %s, src.arg),
|
||||
dst.brief = IF(src.id IS NULL, %s, src.brief)
|
||||
WHERE dst.general_id = %i',
|
||||
$turnCnt,
|
||||
'휴식',
|
||||
'{}',
|
||||
'휴식',
|
||||
$generalID
|
||||
);
|
||||
}
|
||||
|
||||
function repeatGeneralCommand(int $generalId, int $turnCnt){
|
||||
@@ -126,15 +138,23 @@ function pushNationCommand(int $nationID, int $officerLevel, int $turnCnt=1){
|
||||
|
||||
$db = DB::db();
|
||||
|
||||
$db->update('nation_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx + %i', $turnCnt)
|
||||
], 'nation_id=%i AND officer_level=%i ORDER BY turn_idx DESC', $nationID, $officerLevel);
|
||||
$db->update('nation_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx - %i', GameConst::$maxChiefTurn),
|
||||
'action'=>'휴식',
|
||||
'arg'=>'{}',
|
||||
'brief'=>'휴식'
|
||||
], 'nation_id=%i AND officer_level=%i AND turn_idx >= %i ORDER BY turn_idx ASC', $nationID, $officerLevel, GameConst::$maxChiefTurn);
|
||||
$db->query(
|
||||
'UPDATE nation_turn AS dst
|
||||
LEFT JOIN nation_turn AS src
|
||||
ON src.nation_id = dst.nation_id
|
||||
AND src.officer_level = dst.officer_level
|
||||
AND src.turn_idx = dst.turn_idx - %i
|
||||
SET dst.action = IF(src.id IS NULL, %s, src.action),
|
||||
dst.arg = IF(src.id IS NULL, %s, src.arg),
|
||||
dst.brief = IF(src.id IS NULL, %s, src.brief)
|
||||
WHERE dst.nation_id = %i AND dst.officer_level = %i',
|
||||
$turnCnt,
|
||||
'휴식',
|
||||
'{}',
|
||||
'휴식',
|
||||
$nationID,
|
||||
$officerLevel
|
||||
);
|
||||
}
|
||||
|
||||
function pullNationCommand(int $nationID, int $officerLevel, int $turnCnt=1){
|
||||
@@ -157,15 +177,23 @@ function pullNationCommand(int $nationID, int $officerLevel, int $turnCnt=1){
|
||||
|
||||
$db = DB::db();
|
||||
|
||||
$db->update('nation_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx + %i', GameConst::$maxChiefTurn),
|
||||
'action'=>'휴식',
|
||||
'arg'=>'{}',
|
||||
'brief'=>'휴식',
|
||||
], 'nation_id=%i AND officer_level=%i AND turn_idx < %i', $nationID, $officerLevel, $turnCnt);
|
||||
$db->update('nation_turn', [
|
||||
'turn_idx'=>$db->sqleval('turn_idx - %i', $turnCnt)
|
||||
], 'nation_id=%i AND officer_level=%i ORDER BY turn_idx ASC', $nationID, $officerLevel);
|
||||
$db->query(
|
||||
'UPDATE nation_turn AS dst
|
||||
LEFT JOIN nation_turn AS src
|
||||
ON src.nation_id = dst.nation_id
|
||||
AND src.officer_level = dst.officer_level
|
||||
AND src.turn_idx = dst.turn_idx + %i
|
||||
SET dst.action = IF(src.id IS NULL, %s, src.action),
|
||||
dst.arg = IF(src.id IS NULL, %s, src.arg),
|
||||
dst.brief = IF(src.id IS NULL, %s, src.brief)
|
||||
WHERE dst.nation_id = %i AND dst.officer_level = %i',
|
||||
$turnCnt,
|
||||
'휴식',
|
||||
'{}',
|
||||
'휴식',
|
||||
$nationID,
|
||||
$officerLevel
|
||||
);
|
||||
}
|
||||
|
||||
function repeatNationCommand(int $nationID, int $officerLevel, int $turnCnt){
|
||||
@@ -494,4 +522,4 @@ function setNationCommand(int $generalID, array $turnList, string $command, ?arr
|
||||
'arg_test'=>true,
|
||||
'reason'=>'success'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
]);
|
||||
]);
|
||||
|
||||
@@ -177,6 +177,10 @@ $builder->setOwnerName($userNick);
|
||||
$builder->setKillturn(5);
|
||||
$builder->setNPCType(0);
|
||||
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm']));
|
||||
$builder->setAuxVar(
|
||||
'prestart_delete_after',
|
||||
addTurn($now, $env['turnterm'], GameConst::$minTurnDieOnPrestart)
|
||||
);
|
||||
$builder->fillRemainSpecAsZero($env);
|
||||
if ($isCentennialAllStar) {
|
||||
$candidateCities = $db->queryFirstColumn(
|
||||
|
||||
@@ -39,6 +39,10 @@ class DieOnPrestart extends \sammo\BaseAPI
|
||||
$gameStor->cacheValues(['turnterm', 'opentime', 'turntime', 'year', 'month']);
|
||||
|
||||
$general = $db->queryFirstRow('SELECT no,name,nation,owner_name,npc FROM general WHERE owner=%i AND npc = 0', $userID);
|
||||
if (!$general) {
|
||||
return '장수가 없습니다';
|
||||
}
|
||||
|
||||
$lastRefresh = $db->queryFirstField(
|
||||
'SELECT %b FROM general_access_log WHERE %b = %i',
|
||||
GeneralAccessLogColumn::lastRefresh->value,
|
||||
@@ -46,8 +50,9 @@ class DieOnPrestart extends \sammo\BaseAPI
|
||||
$general['no']
|
||||
);
|
||||
|
||||
if (!$general) {
|
||||
return '장수가 없습니다';
|
||||
$generalObj = General::createObjFromDB($general['no']);
|
||||
if ($generalObj instanceof DummyGeneral) {
|
||||
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
|
||||
}
|
||||
|
||||
increaseRefresh("장수 삭제", 1);
|
||||
@@ -61,18 +66,23 @@ class DieOnPrestart extends \sammo\BaseAPI
|
||||
return '이미 국가에 소속되어있습니다.';
|
||||
}
|
||||
|
||||
$targetTime = $generalObj->getAuxVar('prestart_delete_after');
|
||||
if (!is_string($targetTime) || $targetTime === '') {
|
||||
$targetTime = addTurn(
|
||||
$lastRefresh ?: TimeUtil::now(),
|
||||
$gameStor->turnterm,
|
||||
GameConst::$minTurnDieOnPrestart
|
||||
);
|
||||
$generalObj->setAuxVar('prestart_delete_after', $targetTime);
|
||||
$generalObj->applyDB($db);
|
||||
}
|
||||
|
||||
//서버 가오픈시 할 수 있는 행동
|
||||
$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart);
|
||||
if ($targetTime > TimeUtil::now()) {
|
||||
$targetTimeShort = substr($targetTime, 0, 19);
|
||||
return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다.";
|
||||
}
|
||||
|
||||
$generalObj = General::createObjFromDB($general['no']);
|
||||
if ($generalObj instanceof DummyGeneral) {
|
||||
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
|
||||
}
|
||||
|
||||
$generalName = $generalObj->getName();
|
||||
$josaYi = JosaUtil::pick($generalName, '이');
|
||||
$generalObj->kill($db, true, "<Y>{$generalName}</>{$josaYi} 홀연히 모습을 <R>감추었습니다</>");
|
||||
|
||||
@@ -435,6 +435,13 @@ class Join extends \sammo\BaseAPI
|
||||
'specage2' => $specage2,
|
||||
'special2' => $special2,
|
||||
'penalty' => Json::encode($penalty),
|
||||
'aux' => Json::encode([
|
||||
'prestart_delete_after' => addTurn(
|
||||
$now,
|
||||
$admin['turnterm'],
|
||||
GameConst::$minTurnDieOnPrestart
|
||||
),
|
||||
]),
|
||||
]);
|
||||
$generalID = $db->insertId();
|
||||
$db->insert('general_access_log', [
|
||||
|
||||
@@ -451,6 +451,7 @@ final class CentennialAllStarGrowthService
|
||||
if (!in_array($general->getNPCType(), [3, 4], true)) {
|
||||
return null;
|
||||
}
|
||||
self::initializeGeneratedNPC($general, $targetInfo);
|
||||
$result = self::applyTarget(
|
||||
$general,
|
||||
$targetInfo,
|
||||
@@ -462,6 +463,36 @@ final class CentennialAllStarGrowthService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards the builder's generic random stat/dex values before applying
|
||||
* the selected all-star target. A newly generated NPC has no organic
|
||||
* growth yet, so those temporary values must not mask the creation-date
|
||||
* event baseline.
|
||||
*/
|
||||
public static function initializeGeneratedNPC(
|
||||
General $general,
|
||||
array $targetInfo
|
||||
): void {
|
||||
foreach (self::STAT_KEYS as $key) {
|
||||
$target = min(
|
||||
GameConst::$maxLevel,
|
||||
max(0, (int) ($targetInfo[$key] ?? 0))
|
||||
);
|
||||
$general->updateVar(
|
||||
$key,
|
||||
CentennialAllStarGrowth::statFloor(
|
||||
$target,
|
||||
GameConst::$defaultStatMin,
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
foreach (self::DEX_KEYS as $key) {
|
||||
$general->updateVar($key, 0);
|
||||
}
|
||||
$general->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the event-backed part of a dex conversion with the converted
|
||||
* value and consumes any guaranteed floor crossed by the source value.
|
||||
|
||||
+63
-16
@@ -1045,18 +1045,64 @@ class General extends GeneralBase implements iAction
|
||||
|
||||
/** @var Map<GeneralAccessLog,int>|null */
|
||||
$rawAccessLog = null;
|
||||
$rankColumnValues = array_map(fn (\BackedEnum $e) => $e->value, $rankColumn);
|
||||
|
||||
if (!$accessLogColumn) {
|
||||
$rawGeneral = $db->queryFirstRow('SELECT %l FROM general WHERE no = %i', Util::formatListOfBackticks($column), $generalID);
|
||||
if ($rankColumn) {
|
||||
$rawGeneral = $db->queryFirstRow(
|
||||
'SELECT %l, (
|
||||
SELECT GROUP_CONCAT(
|
||||
CONCAT(`type`, CHAR(61), `value`)
|
||||
ORDER BY `type`
|
||||
)
|
||||
FROM rank_data
|
||||
WHERE rank_data.general_id = general.no
|
||||
AND `type` IN %ls
|
||||
) AS `_rank_values`
|
||||
FROM general WHERE no = %i',
|
||||
Util::formatListOfBackticks($column),
|
||||
$rankColumnValues,
|
||||
$generalID
|
||||
);
|
||||
} else {
|
||||
$rawGeneral = $db->queryFirstRow(
|
||||
'SELECT %l FROM general WHERE no = %i',
|
||||
Util::formatListOfBackticks($column),
|
||||
$generalID
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$rawGeneral = $db->queryFirstRow(
|
||||
'SELECT %l, %l FROM `general` LEFT JOIN general_access_log
|
||||
ON general.no = general_access_log.general_id WHERE no = %i',
|
||||
Util::formatListOfBackticks($column),
|
||||
Util::formatListOfBackticks($accessLogColumn),
|
||||
$generalID
|
||||
);
|
||||
if ($rankColumn) {
|
||||
$rawGeneral = $db->queryFirstRow(
|
||||
'SELECT %l, %l, (
|
||||
SELECT GROUP_CONCAT(
|
||||
CONCAT(`type`, CHAR(61), `value`)
|
||||
ORDER BY `type`
|
||||
)
|
||||
FROM rank_data
|
||||
WHERE rank_data.general_id = general.no
|
||||
AND `type` IN %ls
|
||||
) AS `_rank_values`
|
||||
FROM `general` LEFT JOIN general_access_log
|
||||
ON general.no = general_access_log.general_id
|
||||
WHERE no = %i',
|
||||
Util::formatListOfBackticks($column),
|
||||
Util::formatListOfBackticks($accessLogColumn),
|
||||
$rankColumnValues,
|
||||
$generalID
|
||||
);
|
||||
} else {
|
||||
$rawGeneral = $db->queryFirstRow(
|
||||
'SELECT %l, %l FROM `general` LEFT JOIN general_access_log
|
||||
ON general.no = general_access_log.general_id WHERE no = %i',
|
||||
Util::formatListOfBackticks($column),
|
||||
Util::formatListOfBackticks($accessLogColumn),
|
||||
$generalID
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($accessLogColumn) {
|
||||
$rawAccessLog = new Map();
|
||||
foreach ($accessLogColumn as $accessLogKey) {
|
||||
if (!key_exists($accessLogKey->value, $rawGeneral)) {
|
||||
@@ -1076,15 +1122,16 @@ class General extends GeneralBase implements iAction
|
||||
|
||||
$rawRankValues = new Map();
|
||||
if ($rankColumn) {
|
||||
$rawValue = $db->queryAllLists(
|
||||
'SELECT `type`, `value` FROM rank_data WHERE general_id = %i AND `type` IN %ls',
|
||||
$generalID,
|
||||
array_map(fn (\BackedEnum $e) => $e->value, $rankColumn)
|
||||
);
|
||||
foreach ($rawValue as [$rawRankType, $rankValue]) {
|
||||
$rankType = RankColumn::tryFrom($rawRankType);
|
||||
$rawRankValues->put($rankType, $rankValue);
|
||||
$rawRankPairs = $rawGeneral['_rank_values'];
|
||||
foreach ($rawRankPairs === null || $rawRankPairs === ''
|
||||
? []
|
||||
: explode(',', $rawRankPairs) as $rawRankPair
|
||||
) {
|
||||
[$rawRankType, $rankValue] = explode('=', $rawRankPair, 2);
|
||||
$rankType = RankColumn::from($rawRankType);
|
||||
$rawRankValues->put($rankType, (int) $rankValue);
|
||||
}
|
||||
unset($rawGeneral['_rank_values']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,51 @@ final class CentennialAllStarGrowthTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testGeneratedNpcUsesExactCreationDateTargetInsteadOfRandomBase(): void
|
||||
{
|
||||
$vars = [
|
||||
'leadership' => 72,
|
||||
'strength' => 61,
|
||||
'intel' => 32,
|
||||
'dex1' => 120000,
|
||||
'dex2' => 240000,
|
||||
'dex3' => 360000,
|
||||
'dex4' => 480000,
|
||||
'dex5' => 600000,
|
||||
'special' => 'None',
|
||||
];
|
||||
$target = [
|
||||
'uniqueName' => 'A1000001',
|
||||
'leadership' => 100,
|
||||
'strength' => 80,
|
||||
'intel' => 10,
|
||||
'dex' => [900000, 800000, 700000, 600000, 500000],
|
||||
];
|
||||
$aux = CentennialAllStarGrowthService::initialAux($target);
|
||||
$general = $this->createStateGeneralMock($vars, $aux);
|
||||
|
||||
CentennialAllStarGrowthService::initializeGeneratedNPC($general, $target);
|
||||
CentennialAllStarGrowthService::applyTarget(
|
||||
$general,
|
||||
$target,
|
||||
['startyear' => 180, 'year' => 195, 'month' => 1],
|
||||
CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER,
|
||||
GameConst::$centennialNpcDexTargetRatio
|
||||
);
|
||||
|
||||
self::assertSame(91, $vars['leadership']);
|
||||
self::assertSame(73, $vars['strength']);
|
||||
self::assertSame(10, $vars['intel']);
|
||||
self::assertSame(
|
||||
[360000, 320000, 280000, 240000, 200000],
|
||||
$this->dexValues($vars)
|
||||
);
|
||||
self::assertSame(76, $aux['granted']['leadership']);
|
||||
self::assertSame(58, $aux['granted']['strength']);
|
||||
self::assertSame(0, $aux['granted']['intel']);
|
||||
self::assertSame(360000, $aux['granted']['dex1']);
|
||||
}
|
||||
|
||||
public function testProgressMultiplierMustStayWithinUnitInterval(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {chromium} from 'playwright';
|
||||
|
||||
const baseURL = process.env.REF_BROWSER_URL;
|
||||
const screenshotPath = process.env.REF_SCREENSHOT_PATH;
|
||||
const resultPath = process.env.REF_RESULT_PATH;
|
||||
const username = process.env.REF_USER_ID ?? 's100user01';
|
||||
const password = fs.readFileSync('/run/secrets/test_user_password', 'utf8').trim();
|
||||
|
||||
if (!baseURL || !screenshotPath || !resultPath) {
|
||||
throw new Error('REF_BROWSER_URL, REF_SCREENSHOT_PATH, and REF_RESULT_PATH are required');
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({headless: true});
|
||||
const context = await browser.newContext({
|
||||
viewport: {width: 1280, height: 960},
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(baseURL, {waitUntil: 'domcontentloaded', timeout: 60_000});
|
||||
const salt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(salt + password + salt)
|
||||
.digest('hex');
|
||||
const loginResponse = await page.request.post(
|
||||
new URL('api.php?path=Login/LoginByID', baseURL).href,
|
||||
{data: {username, password: passwordHash}},
|
||||
);
|
||||
const loginResult = await loginResponse.json();
|
||||
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||
throw new Error(`login failed: ${String(loginResult.reason ?? loginResponse.status())}`);
|
||||
}
|
||||
|
||||
const myPageURL = new URL('hwe/b_myPage.php', baseURL).href;
|
||||
const readCooldownText = async () => {
|
||||
await page.goto(myPageURL, {waitUntil: 'networkidle', timeout: 60_000});
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
const lines = bodyText.split('\n').map(line => line.trim());
|
||||
const reselect = lines.find(line => line.startsWith('다른 장수 선택 ('));
|
||||
const deletion = lines.find(line => line.startsWith('가오픈 기간 내 장수 삭제 ('));
|
||||
if (!reselect || !deletion) {
|
||||
throw new Error(`cooldown text missing: ${bodyText.slice(0, 4000)}`);
|
||||
}
|
||||
return {reselect, deletion};
|
||||
};
|
||||
|
||||
const first = await readCooldownText();
|
||||
await page.waitForTimeout(1100);
|
||||
const second = await readCooldownText();
|
||||
await page.waitForTimeout(1100);
|
||||
const third = await readCooldownText();
|
||||
|
||||
if (JSON.stringify(first) !== JSON.stringify(second)
|
||||
|| JSON.stringify(second) !== JSON.stringify(third)
|
||||
) {
|
||||
throw new Error(`cooldown moved after refresh: ${JSON.stringify({first, second, third})}`);
|
||||
}
|
||||
|
||||
await page.screenshot({path: screenshotPath, fullPage: true});
|
||||
fs.writeFileSync(
|
||||
resultPath,
|
||||
`${JSON.stringify({
|
||||
username,
|
||||
url: page.url(),
|
||||
viewport: {width: 1280, height: 960, deviceScaleFactor: 1},
|
||||
first,
|
||||
second,
|
||||
third,
|
||||
}, null, 2)}\n`,
|
||||
{mode: 0o600},
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
console.log(`S100 my-page cooldown refresh guard verified: ${screenshotPath}`);
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use sammo\CentennialAllStarGrowthService;
|
||||
use sammo\DB;
|
||||
use sammo\Event\Action\AdvanceCentennialAllStar;
|
||||
use sammo\GameConst;
|
||||
use sammo\General;
|
||||
use sammo\Json;
|
||||
use sammo\KVStorage;
|
||||
use sammo\RootDB;
|
||||
|
||||
const APP_ROOT = '/var/www/html';
|
||||
const TEST_ENV = ['startyear' => 180, 'year' => 195, 'month' => 1];
|
||||
const STAT_KEYS = ['leadership', 'strength', 'intel'];
|
||||
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'];
|
||||
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
$_SERVER['REQUEST_URI'] = '/s100-reselection-final-growth-check';
|
||||
|
||||
require APP_ROOT . '/hwe/lib.php';
|
||||
require APP_ROOT . '/hwe/func.php';
|
||||
|
||||
$mode = $argv[1] ?? '';
|
||||
$username = $argv[2] ?? 's100user01';
|
||||
$snapshotPath = $argv[3] ?? '/tmp/s100-reselection-final-growth.json';
|
||||
if (!in_array($mode, ['prepare', 'verify'], true)) {
|
||||
throw new InvalidArgumentException('Mode must be prepare or verify');
|
||||
}
|
||||
if (preg_match('/^s100user[0-9]{2}$/', $username) !== 1) {
|
||||
throw new InvalidArgumentException('Username must match s100userNN');
|
||||
}
|
||||
|
||||
$db = DB::db();
|
||||
$owner = RootDB::db()->queryFirstField(
|
||||
'SELECT no FROM member WHERE id=%s',
|
||||
$username
|
||||
);
|
||||
if ($owner === null) {
|
||||
throw new RuntimeException("No member for {$username}");
|
||||
}
|
||||
$generalID = $db->queryFirstField('SELECT no FROM general WHERE owner=%i', $owner);
|
||||
if ($generalID === null) {
|
||||
throw new RuntimeException("No general for owner {$owner}");
|
||||
}
|
||||
|
||||
if ($mode === 'prepare') {
|
||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||
$gameStor->setValue('year', TEST_ENV['year']);
|
||||
$gameStor->setValue('month', TEST_ENV['month']);
|
||||
|
||||
$eventResult = (new AdvanceCentennialAllStar())->run(TEST_ENV);
|
||||
$general = General::createObjFromDB((int) $generalID);
|
||||
$beforeGrowth = readGeneralState($general);
|
||||
|
||||
$statGrowthKey = null;
|
||||
foreach (STAT_KEYS as $key) {
|
||||
if ((int) $general->getVar($key) < GameConst::$maxLevel) {
|
||||
$statGrowthKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($statGrowthKey === null) {
|
||||
throw new RuntimeException('No stat can receive an organic level-up');
|
||||
}
|
||||
$general->increaseVar(
|
||||
"{$statGrowthKey}_exp",
|
||||
GameConst::$upgradeLimit
|
||||
);
|
||||
if (!$general->checkStatChange()) {
|
||||
throw new RuntimeException('Organic stat level-up did not occur');
|
||||
}
|
||||
|
||||
$general->addDex($general->getCrewTypeObj(), 12345, false);
|
||||
$general->setAuxVar('next_change', '2000-01-01 00:00:00');
|
||||
$general->applyDB($db);
|
||||
|
||||
$afterGrowth = readGeneralState($general);
|
||||
$snapshot = [
|
||||
'username' => $username,
|
||||
'generalID' => (int) $generalID,
|
||||
'eventResult' => $eventResult,
|
||||
'statGrowthKey' => $statGrowthKey,
|
||||
'beforeGrowth' => $beforeGrowth,
|
||||
'afterGrowth' => $afterGrowth,
|
||||
];
|
||||
if (file_put_contents(
|
||||
$snapshotPath,
|
||||
Json::encode($snapshot, Json::PRETTY) . PHP_EOL
|
||||
) === false) {
|
||||
throw new RuntimeException("Could not write {$snapshotPath}");
|
||||
}
|
||||
chmod($snapshotPath, 0600);
|
||||
printf(
|
||||
"Prepared final-growth reselection: user=%s target=%s stat=%s dexDelta=%d\n",
|
||||
$username,
|
||||
$afterGrowth['targetId'],
|
||||
$statGrowthKey,
|
||||
array_sum($afterGrowth['dex']) - array_sum($beforeGrowth['dex'])
|
||||
);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$snapshot = Json::decode((string) file_get_contents($snapshotPath));
|
||||
$general = General::createObjFromDB((int) $generalID);
|
||||
$actual = readGeneralState($general);
|
||||
$targetInfoRaw = $db->queryFirstField(
|
||||
'SELECT info FROM select_pool WHERE general_id=%i',
|
||||
$generalID
|
||||
);
|
||||
if ($targetInfoRaw === null) {
|
||||
throw new RuntimeException('Reselected target is not assigned to the general');
|
||||
}
|
||||
$targetInfo = Json::decode($targetInfoRaw);
|
||||
$oldTargetId = $snapshot['afterGrowth']['targetId'];
|
||||
$newTargetId = (string) ($targetInfo['uniqueName'] ?? '');
|
||||
if ($newTargetId === '' || $newTargetId === $oldTargetId) {
|
||||
throw new RuntimeException('Chromium reselection did not change the target');
|
||||
}
|
||||
if ($actual['targetId'] !== $newTargetId) {
|
||||
throw new RuntimeException('General aux target does not match the assigned pool target');
|
||||
}
|
||||
|
||||
$expectedStats = CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
|
||||
$targetInfo,
|
||||
TEST_ENV
|
||||
);
|
||||
$expectedDex = [];
|
||||
foreach (STAT_KEYS as $key) {
|
||||
$organic = max(
|
||||
0,
|
||||
(int) $snapshot['afterGrowth']['stats'][$key]
|
||||
- (int) $snapshot['afterGrowth']['granted'][$key]
|
||||
);
|
||||
$expectedStats[$key] = max($organic, $expectedStats[$key]);
|
||||
assertSameValue($expectedStats[$key], $actual['stats'][$key], $key);
|
||||
assertSameValue(
|
||||
$actual['stats'][$key] - $organic,
|
||||
$actual['granted'][$key],
|
||||
"{$key} event grant"
|
||||
);
|
||||
}
|
||||
foreach (DEX_KEYS as $idx => $key) {
|
||||
$organic = max(
|
||||
0,
|
||||
(int) $snapshot['afterGrowth']['dex'][$key]
|
||||
- (int) $snapshot['afterGrowth']['granted'][$key]
|
||||
);
|
||||
$targetFloor = CentennialAllStarGrowthService::calculateDexTargetFloor(
|
||||
(int) ($targetInfo['dex'][$idx] ?? 0),
|
||||
TEST_ENV
|
||||
);
|
||||
$expectedDex[$key] = max($organic, $targetFloor);
|
||||
assertSameValue($expectedDex[$key], $actual['dex'][$key], $key);
|
||||
assertSameValue($targetFloor, $actual['dexFloor'][$key], "{$key} floor");
|
||||
assertSameValue(
|
||||
$actual['dex'][$key] - $organic,
|
||||
$actual['granted'][$key],
|
||||
"{$key} event grant"
|
||||
);
|
||||
}
|
||||
|
||||
printf(
|
||||
"Verified final-growth reselection: user=%s old=%s new=%s stats=%s dex=%s\n",
|
||||
$username,
|
||||
$oldTargetId,
|
||||
$newTargetId,
|
||||
Json::encode($actual['stats']),
|
||||
Json::encode($actual['dex'])
|
||||
);
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* targetId:string,
|
||||
* stats:array<string,int>,
|
||||
* dex:array<string,int>,
|
||||
* granted:array<string,int>,
|
||||
* dexFloor:array<string,int>
|
||||
* }
|
||||
*/
|
||||
function readGeneralState(General $general): array
|
||||
{
|
||||
$aux = $general->getAuxVar(CentennialAllStarGrowthService::AUX_KEY);
|
||||
if (!is_array($aux)) {
|
||||
throw new RuntimeException('Centennial growth ledger is missing');
|
||||
}
|
||||
$stats = [];
|
||||
foreach (STAT_KEYS as $key) {
|
||||
$stats[$key] = (int) $general->getVar($key);
|
||||
}
|
||||
$dex = [];
|
||||
foreach (DEX_KEYS as $key) {
|
||||
$dex[$key] = (int) $general->getVar($key);
|
||||
}
|
||||
return [
|
||||
'targetId' => (string) ($aux['targetId'] ?? ''),
|
||||
'stats' => $stats,
|
||||
'dex' => $dex,
|
||||
'granted' => $aux['granted'] ?? [],
|
||||
'dexFloor' => $aux['dexFloor'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
function assertSameValue(int $expected, int $actual, string $label): void
|
||||
{
|
||||
if ($actual !== $expected) {
|
||||
throw new RuntimeException(
|
||||
"{$label}: expected {$expected}, got {$actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user