Compare commits

...
15 changed files with 1222 additions and 79 deletions
+22 -8
View File
@@ -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>
+65 -37
View File
@@ -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'
];
}
}
+4
View File
@@ -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(
+18 -8
View File
@@ -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>감추었습니다</>");
+7
View File
@@ -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', [
@@ -4,6 +4,7 @@ namespace sammo\API\InheritAction;
use sammo\Session;
use DateTimeInterface;
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\Enums\APIRecoveryType;
use sammo\Enums\RankColumn;
@@ -98,6 +99,10 @@ class ResetStat extends \sammo\BaseAPI
return 'NPC는 능력치 초기화를 할 수 없습니다.';
}
if (!CentennialAllStarGrowthService::isStatResetAllowed()) {
return '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.';
}
$db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env');
+82 -7
View File
@@ -19,6 +19,11 @@ final class CentennialAllStarGrowthService
return GameConst::$targetGeneralPool === self::POOL_CLASS;
}
public static function isStatResetAllowed(): bool
{
return !self::isActive();
}
public static function initialAux(array $targetInfo, ?array $userInitialStats = null): array
{
$granted = array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0);
@@ -446,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,
@@ -457,6 +463,67 @@ final class CentennialAllStarGrowthService
return $result;
}
/**
* Keeps the ordinary M/G-general stat total while aligning its strong,
* middle, and weak stats with the selected all-star target. The target's
* creation-date growth floor is applied immediately afterwards.
*/
public static function initializeGeneratedNPC(
General $general,
array $targetInfo
): void {
$generatedStats = [];
foreach (self::STAT_KEYS as $key) {
$generatedStats[$key] = (int) $general->getVar($key);
}
$initialStats = self::calculateGeneratedNPCInitialStats(
$targetInfo,
$generatedStats
);
foreach (self::STAT_KEYS as $key) {
$general->updateVar($key, $initialStats[$key]);
}
foreach (self::DEX_KEYS as $key) {
$general->updateVar($key, 0);
}
$general->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo));
}
/**
* @param array<string, mixed> $targetInfo
* @param array{leadership:int,strength:int,intel:int} $generatedStats
* @return array{leadership:int,strength:int,intel:int}
*/
public static function calculateGeneratedNPCInitialStats(
array $targetInfo,
array $generatedStats
): array {
$targetOrder = self::STAT_KEYS;
$keyOrder = array_flip(self::STAT_KEYS);
usort(
$targetOrder,
static function (string $lhs, string $rhs) use ($targetInfo, $keyOrder): int {
$targetCompare = (int) ($targetInfo[$rhs] ?? 0)
<=> (int) ($targetInfo[$lhs] ?? 0);
return $targetCompare !== 0
? $targetCompare
: $keyOrder[$lhs] <=> $keyOrder[$rhs];
}
);
$generatedValues = array_map(
static fn (string $key): int => (int) ($generatedStats[$key] ?? 0),
self::STAT_KEYS
);
rsort($generatedValues, SORT_NUMERIC);
$result = array_fill_keys(self::STAT_KEYS, 0);
foreach ($targetOrder as $idx => $key) {
$result[$key] = $generatedValues[$idx];
}
return $result;
}
/**
* Moves the event-backed part of a dex conversion with the converted
* value and consumes any guaranteed floor crossed by the source value.
@@ -506,20 +573,28 @@ final class CentennialAllStarGrowthService
max(0, $sourceBefore),
max(0, (int) ($granted[$sourceKey] ?? 0))
);
$sourceOrganicBefore = max(0, $sourceBefore - $sourceGrantedBefore);
$sourceGrantedAfter = max(
0,
$sourceAfter - min($sourceAfter, $sourceOrganicBefore)
);
$eventGrantRemoved = max(0, $sourceGrantedBefore - $sourceGrantedAfter);
/*
* A dex value can contain both organic and event-backed points. Split
* the actual command deltas by their share of the source total instead
* of consuming either bucket first. This keeps the combined ownership
* stable when conversion and target reselection are interleaved.
*/
$eventGrantRemoved = $sourceBefore > 0
? intdiv($sourceDecrease * $sourceGrantedBefore, $sourceBefore)
: 0;
$sourceGrantedAfter = max(0, $sourceGrantedBefore - $eventGrantRemoved);
$destinationGrantedBefore = min(
max(0, $destinationBefore),
max(0, (int) ($granted[$destinationKey] ?? 0))
);
$eventGrantTransferred = $sourceBefore > 0
? intdiv($destinationIncrease * $sourceGrantedBefore, $sourceBefore)
: 0;
$eventGrantTransferred = min(
$destinationIncrease,
(int) floor($eventGrantRemoved * $convertCoeff)
$eventGrantRemoved,
$eventGrantTransferred
);
$granted[$sourceKey] = $sourceGrantedAfter;
$granted[$destinationKey] = min(
+63 -16
View File
@@ -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']);
}
+15 -2
View File
@@ -172,7 +172,7 @@
<BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton>
</div>
</div>
<div class="col col-lg-4 col-sm-6 col-12 py-2">
<div v-if="canResetStat" class="col col-lg-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6">
@@ -222,6 +222,17 @@
<BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton>
</div>
</div>
<div v-else class="col col-lg-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6 align-self-center">사용 불가</div>
</div>
<div class="a-right">
<small class="form-text text-muted">
100 올스타 장수는 장수 전환 능력치 성장 기록을 보존하기 위해 능력치 초기화를 사용할 없습니다.
</small>
</div>
</div>
</div>
<div class="row">
<div class="col">
@@ -297,6 +308,7 @@ declare const staticValues: {
}
>;
availableTargetGeneral: Record<number, string>;
canResetStat: boolean;
currentStat: {
leadership: number;
strength: number;
@@ -447,6 +459,7 @@ const {
availableSpecialWar,
availableUnique,
availableTargetGeneral,
canResetStat,
currentStat
} = staticValues;
@@ -739,4 +752,4 @@ async function getMoreLog(): Promise<void> {
.tnum {
font-feature-settings: "tnum";
}
</style>
</style>
+2 -1
View File
@@ -105,6 +105,7 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
'availableUnique' => $availableUnique,
'lastInheritPointLogs' => $lastInheritPointLogs,
'availableTargetGeneral' => $availableTargetGeneral,
'canResetStat' => CentennialAllStarGrowthService::isStatResetAllowed(),
'currentStat' => [
'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax),
'strength' => Util::clamp($me->getVar('strength'), GameConst::$defaultStatMin, GameConst::$defaultStatMax),
@@ -120,4 +121,4 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
<div id="app"></div>
</body>
</html>
</html>
+365
View File
@@ -25,6 +25,20 @@ final class CentennialAllStarGrowthTest extends TestCase
self::assertSame(1.0, CentennialAllStarGrowth::progress(180, 210, 1));
}
public function testStatResetIsDisabledOnlyForCentennialAllStarPool(): void
{
$previousPool = GameConst::$targetGeneralPool;
try {
GameConst::$targetGeneralPool = CentennialAllStarGrowthService::POOL_CLASS;
self::assertFalse(CentennialAllStarGrowthService::isStatResetAllowed());
GameConst::$targetGeneralPool = 'RandomNameGeneral';
self::assertTrue(CentennialAllStarGrowthService::isStatResetAllowed());
} finally {
GameConst::$targetGeneralPool = $previousPool;
}
}
public function testMAndGGeneralsAdvanceAtNinetyPercentProgress(): void
{
self::assertSame(
@@ -69,6 +83,138 @@ final class CentennialAllStarGrowthTest extends TestCase
);
}
public function testGeneratedNpcKeepsOrdinaryTotalAndUsesCreationDateTargetFloor(): void
{
$vars = [
'leadership' => 72,
'strength' => 66,
'intel' => 12,
'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(12, $vars['intel']);
self::assertSame(176, $vars['leadership'] + $vars['strength'] + $vars['intel']);
self::assertSame(
[360000, 320000, 280000, 240000, 200000],
$this->dexValues($vars)
);
self::assertSame(19, $aux['granted']['leadership']);
self::assertSame(7, $aux['granted']['strength']);
self::assertSame(0, $aux['granted']['intel']);
self::assertSame(360000, $aux['granted']['dex1']);
}
public function testFernandoAndUmaMusumeStartAtOrdinaryNpcTotal(): void
{
$pool = json_decode(
file_get_contents(__DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$columns = array_flip($pool['columns']);
$targets = [];
foreach ($pool['data'] as $row) {
$name = $row[$columns['generalName']];
if (!in_array($name, ['43·페르난도', '47·우마무스메'], true)) {
continue;
}
$targets[$name] = [
'leadership' => $row[$columns['leadership']],
'strength' => $row[$columns['strength']],
'intel' => $row[$columns['intel']],
];
}
self::assertCount(2, $targets);
$generated = [
'leadership' => 73,
'strength' => 10,
'intel' => 67,
];
$fernando = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$targets['43·페르난도'],
$generated
);
$umaMusume = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$targets['47·우마무스메'],
$generated
);
self::assertSame([
'leadership' => 67,
'strength' => 73,
'intel' => 10,
], $fernando);
self::assertSame([
'leadership' => 10,
'strength' => 67,
'intel' => 73,
], $umaMusume);
self::assertSame(GameConst::$defaultStatNPCTotal, array_sum($fernando));
self::assertSame(GameConst::$defaultStatNPCTotal, array_sum($umaMusume));
}
public function testEveryCandidateKeepsTheGeneratedNpcStatTotal(): void
{
$pool = json_decode(
file_get_contents(__DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$columns = array_flip($pool['columns']);
$generated = [
'leadership' => 73,
'strength' => 67,
'intel' => 10,
];
foreach ($pool['data'] as $row) {
$target = [
'leadership' => $row[$columns['leadership']],
'strength' => $row[$columns['strength']],
'intel' => $row[$columns['intel']],
];
$initial = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$target,
$generated
);
self::assertSame(
GameConst::$defaultStatNPCTotal,
array_sum($initial),
$row[$columns['generalName']]
);
sort($initial, SORT_NUMERIC);
self::assertSame([10, 67, 73], array_values($initial));
}
}
public function testProgressMultiplierMustStayWithinUnitInterval(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -516,6 +662,151 @@ final class CentennialAllStarGrowthTest extends TestCase
self::assertSame(129600, CentennialAllStarGrowthService::recordableValue($general, 'dex2'));
}
public function testMixedDexConversionSplitsEventAndNaturalPointsByTotalShare(): void
{
$vars = [
'dex1' => 360000,
'dex2' => 600000,
];
$aux = [
'targetId' => 'archer',
'granted' => [
'dex1' => 0,
'dex2' => 900000,
],
'dexConsumed' => [
'dex1' => 0,
'dex2' => 0,
],
'dexFloor' => [
'dex1' => 0,
'dex2' => 900000,
],
];
$general = $this->createStateGeneralMock($vars, $aux);
CentennialAllStarGrowthService::reconcileDexConversion(
$general,
'dex2',
'dex1',
1000000,
600000,
0,
360000,
0.9
);
self::assertSame(540000, $aux['granted']['dex2']);
self::assertSame(324000, $aux['granted']['dex1']);
self::assertSame(60_000, CentennialAllStarGrowthService::recordableValue($general, 'dex2'));
self::assertSame(36_000, CentennialAllStarGrowthService::recordableValue($general, 'dex1'));
self::assertSame(864000, $aux['granted']['dex1'] + $aux['granted']['dex2']);
self::assertSame(
96000,
CentennialAllStarGrowthService::recordableValue($general, 'dex1')
+ CentennialAllStarGrowthService::recordableValue($general, 'dex2')
);
}
public function testArcherCavalryGhostConversionChainKeepsOnlyCurrentTargetBudget(): void
{
$vars = $this->emptyGeneralVars();
$aux = CentennialAllStarGrowthService::initialAux(
$this->singleDexTarget('archer', 1),
[]
);
$general = $this->createStateGeneralMock($vars, $aux);
$env = ['startyear' => 180, 'year' => 195, 'month' => 1];
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('archer', 1),
$env
);
$this->convertDex($general, $vars, 'dex2', 'dex1');
self::assertSame([324000, 540000, 0, 0, 0], $this->dexValues($vars));
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('cavalry', 2),
$env
);
self::assertSame([0, 0, 900000, 0, 0], $this->dexValues($vars));
$this->convertDex($general, $vars, 'dex3', 'dex1');
self::assertSame([324000, 0, 540000, 0, 0], $this->dexValues($vars));
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('ghost', 3),
$env
);
self::assertSame([0, 0, 0, 900000, 0], $this->dexValues($vars));
$this->convertDex($general, $vars, 'dex4', 'dex1');
self::assertSame([324000, 0, 0, 540000, 0], $this->dexValues($vars));
self::assertSame(864000, array_sum($this->dexValues($vars)));
foreach (['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as $key) {
self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, $key));
}
}
public function testSelectingInfantryAfterConversionReplacesOldEventBudget(): void
{
$vars = $this->emptyGeneralVars();
$aux = CentennialAllStarGrowthService::initialAux(
$this->singleDexTarget('archer', 1),
[]
);
$general = $this->createStateGeneralMock($vars, $aux);
$env = ['startyear' => 180, 'year' => 195, 'month' => 1];
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('archer', 1),
$env
);
$this->convertDex($general, $vars, 'dex2', 'dex1');
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('infantry', 0),
$env
);
self::assertSame([900000, 0, 0, 0, 0], $this->dexValues($vars));
self::assertSame(900000, array_sum($aux['granted']));
self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex1'));
}
public function testMixedConversionThenReselectionPreservesOnlyPostLossNaturalTotal(): void
{
$vars = $this->emptyGeneralVars();
$aux = CentennialAllStarGrowthService::initialAux(
$this->singleDexTarget('archer', 1),
[]
);
$general = $this->createStateGeneralMock($vars, $aux);
$env = ['startyear' => 180, 'year' => 195, 'month' => 1];
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('archer', 1),
$env
);
$vars['dex2'] += 100000;
$this->convertDex($general, $vars, 'dex2', 'dex1');
CentennialAllStarGrowthService::applyTarget(
$general,
$this->singleDexTarget('infantry', 0),
$env
);
self::assertSame([900000, 60000, 0, 0, 0], $this->dexValues($vars));
self::assertSame(864000, $aux['granted']['dex1']);
self::assertSame(0, $aux['granted']['dex2']);
self::assertSame(96000, array_sum($this->recordableDexValues($general)));
self::assertSame(960000, array_sum($this->dexValues($vars)));
}
public function testReselectionResetsConsumedDexFloorAndTransferredGrant(): void
{
$vars = [
@@ -638,4 +929,78 @@ final class CentennialAllStarGrowthTest extends TestCase
);
return $general;
}
private function emptyGeneralVars(): array
{
return [
'leadership' => 100,
'strength' => 100,
'intel' => 100,
'dex1' => 0,
'dex2' => 0,
'dex3' => 0,
'dex4' => 0,
'dex5' => 0,
'special' => 'None',
];
}
private function singleDexTarget(string $id, int $dexIndex): array
{
$dex = [0, 0, 0, 0, 0];
$dex[$dexIndex] = 900000;
return [
'uniqueName' => $id,
'leadership' => 100,
'strength' => 100,
'intel' => 100,
'dex' => $dex,
];
}
private function convertDex(
General $general,
array &$vars,
string $sourceKey,
string $destinationKey
): void {
$sourceBefore = $vars[$sourceKey];
$destinationBefore = $vars[$destinationKey];
$cut = (int) ($sourceBefore * 0.4);
$add = (int) ($cut * 0.9);
$vars[$sourceKey] -= $cut;
$vars[$destinationKey] += $add;
CentennialAllStarGrowthService::reconcileDexConversion(
$general,
$sourceKey,
$destinationKey,
$sourceBefore,
$vars[$sourceKey],
$destinationBefore,
$vars[$destinationKey],
0.9
);
}
private function dexValues(array $vars): array
{
return [
$vars['dex1'],
$vars['dex2'],
$vars['dex3'],
$vars['dex4'],
$vars['dex5'],
];
}
private function recordableDexValues(General $general): array
{
return array_map(
static fn(string $key): int => CentennialAllStarGrowthService::recordableValue(
$general,
$key
),
['dex1', 'dex2', 'dex3', 'dex4', 'dex5']
);
}
}
@@ -0,0 +1,155 @@
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();
const expectedReason = '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.';
const expectedWarning = '100기 올스타 장수는 장수 전환 시 능력치 성장 기록을 보존하기 위해 능력치 초기화를 사용할 수 없습니다.';
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 browserMessages = [];
const attachPageListeners = targetPage => {
targetPage.on('console', message => {
browserMessages.push({type: message.type(), text: message.text()});
});
targetPage.on('pageerror', error => {
browserMessages.push({type: 'pageerror', text: error.message});
});
};
let page = await context.newPage();
attachPageListeners(page);
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 gameLoginResponse = await page.request.get(
new URL('hwe/api.php?path=Global/GetConst', baseURL).href,
);
const gameLoginResult = await gameLoginResponse.json();
if (!gameLoginResponse.ok() || gameLoginResult.result !== true) {
throw new Error(`game login failed: ${JSON.stringify(gameLoginResult)}`);
}
await page.close();
browserMessages.length = 0;
page = await context.newPage();
attachPageListeners(page);
await page.goto(
new URL('hwe/v_inheritPoint.php', baseURL).href,
{waitUntil: 'networkidle', timeout: 60_000},
);
const bodyText = await page.locator('body').innerText();
if (bodyText.includes('#0 /var/www/html/')) {
throw new Error('PHP stack trace was rendered in the inheritance page');
}
if (!bodyText.includes(expectedWarning)) {
await page.screenshot({path: screenshotPath, fullPage: true});
fs.writeFileSync(
resultPath,
`${JSON.stringify({
username,
url: page.url(),
bodyText: bodyText.slice(0, 4000),
browserMessages,
}, null, 2)}\n`,
{mode: 0o600},
);
throw new Error('S100 stat-reset restriction was not rendered');
}
if (await page.getByRole('button', {name: '능력치 초기화'}).count() !== 0) {
throw new Error('S100 stat-reset button must not be rendered');
}
const warning = page.getByText(expectedWarning, {exact: true});
await warning.waitFor({state: 'visible', timeout: 60_000});
const warningGeometry = await warning.evaluate(element => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
style: {
color: style.color,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
},
};
});
const beforeState = await page.evaluate(() => ({
currentStat: staticValues.currentStat,
previousPoint: staticValues.items.previous,
}));
const resetResponse = await page.request.put(
new URL('hwe/api.php?path=InheritAction/ResetStat', baseURL).href,
{
data: {
leadership: 55,
strength: 55,
intel: 55,
},
},
);
const resetResult = await resetResponse.json();
if (!resetResponse.ok()
|| resetResult.result !== false
|| resetResult.reason !== expectedReason
) {
throw new Error(`unexpected ResetStat response: ${JSON.stringify(resetResult)}`);
}
await page.reload({waitUntil: 'networkidle', timeout: 60_000});
const afterState = await page.evaluate(() => ({
currentStat: staticValues.currentStat,
previousPoint: staticValues.items.previous,
}));
if (JSON.stringify(afterState) !== JSON.stringify(beforeState)) {
throw new Error(`ResetStat rejection changed state: ${JSON.stringify({beforeState, afterState})}`);
}
await page.screenshot({path: screenshotPath, fullPage: true});
fs.writeFileSync(
resultPath,
`${JSON.stringify({
username,
url: page.url(),
viewport: {width: 1280, height: 960, deviceScaleFactor: 1},
warningGeometry,
resetResult,
beforeState,
afterState,
browserMessages,
}, null, 2)}\n`,
{mode: 0o600},
);
await browser.close();
console.log(`S100 inheritance stat-reset guard verified: ${screenshotPath}`);
@@ -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,131 @@
<?php
declare(strict_types=1);
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\GameConst;
use sammo\General;
use sammo\GeneralPool\SPoolUnderU100;
use sammo\Json;
use sammo\LiteHashDRBG;
use sammo\RandUtil;
use sammo\Util;
const APP_ROOT = '/var/www/html';
const TARGET_NAMES = ['43·페르난도', '47·우마무스메'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['REQUEST_URI'] = '/s100-generated-npc-stat-floor-check';
require APP_ROOT . '/hwe/lib.php';
require APP_ROOT . '/hwe/func.php';
$env = [
'startyear' => 180,
'year' => 180,
'month' => 12,
'fiction' => [1],
'show_img_level' => 3,
];
$pool = Json::decode((string) file_get_contents(
APP_ROOT . '/hwe/sammo/GeneralPool/Pool/UnderS100.json'
));
$columns = array_flip($pool['columns']);
$targets = [];
foreach ($pool['data'] as $idx => $row) {
$name = $row[$columns['generalName']];
if (!in_array($name, TARGET_NAMES, true)) {
continue;
}
$info = array_combine($pool['columns'], $row);
$info['uniqueName'] = sprintf('A100%04d', $idx + 1);
$info['event100Growth'] = true;
$targets[$name] = $info;
}
if (count($targets) !== count(TARGET_NAMES)) {
throw new RuntimeException('Required S100 candidates are missing');
}
$db = DB::db();
$results = [];
foreach (TARGET_NAMES as $name) {
$target = $targets[$name];
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
's100-generated-npc-stat-floor-check',
$target['uniqueName']
)));
$poolGeneral = new SPoolUnderU100(
$db,
$rng,
$target,
'2999-01-01 00:00:00'
);
$builder = $poolGeneral->getGeneralBuilder();
$builder->setNationID(0)
->setNPCType(3)
->setMoney(1000, 1000)
->setExpDed(0, 0)
->setLifeSpan(160, 230);
$builder->fillRandomStat(['무' => 0.333, '지' => 0.333, '무지' => 0.334]);
$generatedStats = $builder->getStat();
assertSameValue(
GameConst::$defaultStatNPCTotal,
array_sum($generatedStats),
"{$name} generic total"
);
$builder->fillRemainSpecAsZero($env);
$builder->build($env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$builder,
$target,
$env
);
$general = General::createObjFromDB($builder->getGeneralID());
$actualStats = [
'leadership' => (int) $general->getVar('leadership'),
'strength' => (int) $general->getVar('strength'),
'intel' => (int) $general->getVar('intel'),
];
if (array_sum($actualStats) < GameConst::$defaultStatNPCTotal) {
throw new RuntimeException(
"{$name} total fell below " . GameConst::$defaultStatNPCTotal
);
}
$targetOrder = ['leadership', 'strength', 'intel'];
usort(
$targetOrder,
static fn (string $lhs, string $rhs): int =>
(int) $target[$rhs] <=> (int) $target[$lhs]
);
if ($actualStats[$targetOrder[0]] < $actualStats[$targetOrder[1]]
|| $actualStats[$targetOrder[1]] < $actualStats[$targetOrder[2]]
) {
throw new RuntimeException("{$name} target stat order was not preserved");
}
$results[] = [
'name' => $name,
'target' => array_intersect_key($target, $actualStats),
'generic' => array_combine(
['leadership', 'strength', 'intel'],
$generatedStats
),
'actual' => $actualStats,
'total' => array_sum($actualStats),
];
}
echo Json::encode($results, Json::PRETTY), PHP_EOL;
function assertSameValue(int $expected, int $actual, string $label): void
{
if ($actual !== $expected) {
throw new RuntimeException(
"{$label}: expected {$expected}, got {$actual}"
);
}
}
@@ -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}"
);
}
}