diff --git a/hwe/b_myPage.php b/hwe/b_myPage.php index 54052f97..ea3d488d 100644 --- a/hwe/b_myPage.php +++ b/hwe/b_myPage.php @@ -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", $ npcmode == 2 && $me->getNPCType() == 0) : ?> - 다른 장수 선택 (getAuxVar('next_change') ?? TimeUtil::now(), 0, 19) ?> 부터)
+ 다른 장수 선택 (부터)


@@ -272,4 +286,4 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $ - \ No newline at end of file + 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/func_command.php b/hwe/func_command.php index 8dbc22c9..a3524184 100644 --- a/hwe/func_command.php +++ b/hwe/func_command.php @@ -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' ]; -} \ No newline at end of file +} 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/hwe/j_select_picked_general.php b/hwe/j_select_picked_general.php index 81bea0cf..0b014069 100644 --- a/hwe/j_select_picked_general.php +++ b/hwe/j_select_picked_general.php @@ -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( diff --git a/hwe/sammo/API/General/DieOnPrestart.php b/hwe/sammo/API/General/DieOnPrestart.php index e2c8552b..c38a8673 100644 --- a/hwe/sammo/API/General/DieOnPrestart.php +++ b/hwe/sammo/API/General/DieOnPrestart.php @@ -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, "{$generalName}{$josaYi} 홀연히 모습을 감추었습니다"); diff --git a/hwe/sammo/API/General/Join.php b/hwe/sammo/API/General/Join.php index bc20229c..43b5a2cc 100644 --- a/hwe/sammo/API/General/Join.php +++ b/hwe/sammo/API/General/Join.php @@ -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', [ diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index f8c46eb5..8ae99c21 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -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. diff --git a/hwe/sammo/General.php b/hwe/sammo/General.php index 68066a78..43a35816 100644 --- a/hwe/sammo/General.php +++ b/hwe/sammo/General.php @@ -1045,18 +1045,64 @@ class General extends GeneralBase implements iAction /** @var Map|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']); } 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/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 613a5cc0..34266707 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -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); 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'])); + } +} diff --git a/tests/browser/s100-my-page-cooldown-refresh-guard.mjs b/tests/browser/s100-my-page-cooldown-refresh-guard.mjs new file mode 100644 index 00000000..9bf34ba4 --- /dev/null +++ b/tests/browser/s100-my-page-cooldown-refresh-guard.mjs @@ -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}`); diff --git a/tests/s100-reselection-final-growth-check.php b/tests/s100-reselection-final-growth-check.php new file mode 100644 index 00000000..0e038494 --- /dev/null +++ b/tests/s100-reselection-final-growth-check.php @@ -0,0 +1,212 @@ + 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, + * dex:array, + * granted:array, + * dexFloor:array + * } + */ +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}" + ); + } +}