diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index d44db1ea..ba1b7091 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -10,6 +10,8 @@ final class CentennialAllStarGrowthService public const AUX_KEY = 'event100_allstar'; public const TRAIT_UNLOCK_PROGRESS = 0.4; public const NPC_PROGRESS_MULTIPLIER = 0.9; + public const DEX_EXTENSION_START_YEAR_OFFSET = 20; + public const DEX_EXTENSION_YEARS = 5; private const STAT_KEYS = ['leadership', 'strength', 'intel']; private const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5']; @@ -41,6 +43,7 @@ final class CentennialAllStarGrowthService 'dexFloor' => array_fill_keys(self::DEX_KEYS, 0), 'progressMonth' => -1, 'milestone' => 0, + 'dexExtensionMilestone' => 0, 'naturalSpecialDomestic' => null, 'eventSpecialDomestic' => null, 'userInitialStats' => $userInitialStats, @@ -236,7 +239,7 @@ final class CentennialAllStarGrowthService if ($targetRatio < 0 || $targetRatio > 1) { throw new \InvalidArgumentException('dex target ratio must be between 0 and 1'); } - $target = min(GameConst::$dexLimit, max(0, $target)); + $target = self::calculateDexExtendedTarget($target, $env); $scaledTarget = (int) floor($target * $targetRatio); $progress = self::calculateProgress( (int) $env['startyear'], @@ -246,10 +249,34 @@ final class CentennialAllStarGrowthService return CentennialAllStarGrowth::dexFloor($scaledTarget, $progress); } + public static function calculateDexExtensionProgress(array $env): float + { + return CentennialAllStarGrowth::progress( + (int) $env['startyear'] + self::DEX_EXTENSION_START_YEAR_OFFSET, + (int) $env['year'], + (int) $env['month'], + self::DEX_EXTENSION_YEARS + ); + } + + public static function calculateDexExtendedTarget(int $target, array $env): int + { + $target = max(0, $target); + $baseTarget = min(GameConst::$dexLimit, $target); + $extensionTarget = max(0, $target - $baseTarget); + if ($extensionTarget === 0) { + return $baseTarget; + } + + return $baseTarget + (int) floor( + $extensionTarget * self::calculateDexExtensionProgress($env) + ); + } + /** * Mutates the General object but leaves persistence to the caller. * - * @return array{progress:float,milestone:int,previousMilestone:int,targetChanged:bool,changed:bool} + * @return array{progress:float,milestone:int,previousMilestone:int,dexExtensionMilestone:int,previousDexExtensionMilestone:int,hasDexExtensionTarget:bool,targetChanged:bool,changed:bool} */ public static function applyTarget( General $general, @@ -339,10 +366,14 @@ final class CentennialAllStarGrowthService } $targetDex = $targetInfo['dex'] ?? []; + $hasDexExtensionTarget = false; foreach (self::DEX_KEYS as $idx => $key) { if (!array_key_exists($idx, $targetDex)) { continue; } + if ((int) $targetDex[$idx] > GameConst::$dexLimit) { + $hasDexExtensionTarget = true; + } $floor = max( 0, self::calculateDexTargetFloor( @@ -399,12 +430,21 @@ final class CentennialAllStarGrowthService $previousMilestone = (int) ($aux['milestone'] ?? 0); $milestone = min(5, (int) floor($progress * 5 + 0.0000001)); + $previousDexExtensionMilestone = (int) ($aux['dexExtensionMilestone'] ?? 0); + $dexExtensionProgress = self::calculateDexExtensionProgress($env); + $dexExtensionMilestone = $hasDexExtensionTarget + ? min(5, (int) floor($dexExtensionProgress * 5 + 0.0000001)) + : 0; $aux['targetId'] = $targetId; $aux['granted'] = $granted; $aux['dexConsumed'] = $dexConsumed; $aux['dexFloor'] = $dexFloor; $aux['progressMonth'] = max((int) ($aux['progressMonth'] ?? -1), $progressMonth); $aux['milestone'] = max($previousMilestone, $milestone); + $aux['dexExtensionMilestone'] = max( + $previousDexExtensionMilestone, + $dexExtensionMilestone + ); $aux['userInitialStats'] = $nextUserInitialStats; $aux['dexTargetRatio'] = $dexTargetRatio; $general->setAuxVar(self::AUX_KEY, $aux); @@ -413,8 +453,14 @@ final class CentennialAllStarGrowthService 'progress' => $progress, 'milestone' => $milestone, 'previousMilestone' => $previousMilestone, + 'dexExtensionMilestone' => $dexExtensionMilestone, + 'previousDexExtensionMilestone' => $previousDexExtensionMilestone, + 'hasDexExtensionTarget' => $hasDexExtensionTarget, 'targetChanged' => $targetChanged, - 'changed' => $changed || $targetChanged || $milestone > $previousMilestone, + 'changed' => $changed + || $targetChanged + || $milestone > $previousMilestone + || $dexExtensionMilestone > $previousDexExtensionMilestone, ]; } diff --git a/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php b/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php index 5ac86d16..b9432798 100644 --- a/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php +++ b/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php @@ -45,6 +45,18 @@ class AdvanceCentennialAllStar extends \sammo\Event\Action "올스타 동조율 {$percent}% 달성" ); } + if ($result['hasDexExtensionTarget'] + && $result['dexExtensionMilestone'] > $result['previousDexExtensionMilestone'] + ) { + $percent = $result['dexExtensionMilestone'] * 20; + $general->getLogger()->pushGeneralActionLog( + "추가 숙련 동조율이 {$percent}%에 도달했습니다!", + ActionLogger::PLAIN + ); + $general->getLogger()->pushGeneralHistoryLog( + "추가 숙련 동조율 {$percent}% 달성" + ); + } if ($general->applyDB($db)) { $updated++; } diff --git a/hwe/ts/select_general_from_pool.ts b/hwe/ts/select_general_from_pool.ts index 03801c77..3f603313 100644 --- a/hwe/ts/select_general_from_pool.ts +++ b/hwe/ts/select_general_from_pool.ts @@ -55,7 +55,7 @@ declare const validCustomOption: string[]; const templateGeneralCard = '
\

<%generalName%>

\

\ - <%if(event100Growth){%>195년 최종 동조 목표
<%}%>\ + <%if(event100Growth){%>역사 장수 최종 목표
능력치·기본 숙련 195년 / 추가 숙련 205년 동조
<%}%>\ <%if(leadership){%>\ <%leadership%> / <%strength%> / <%intel%>
\ <%}%>\ diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 579c0064..42738173 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -371,6 +371,209 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(900000, CentennialAllStarGrowth::dexFloor(900000, 1)); } + public function testDexExtensionRunsFromYear200ThroughYear205(): void + { + $env = static fn(int $year, int $month): array => [ + 'startyear' => 180, + 'year' => $year, + 'month' => $month, + ]; + + self::assertSame(0.0, CentennialAllStarGrowthService::calculateDexExtensionProgress($env(199, 12))); + self::assertSame(0.0, CentennialAllStarGrowthService::calculateDexExtensionProgress($env(200, 1))); + self::assertEqualsWithDelta( + 0.2, + CentennialAllStarGrowthService::calculateDexExtensionProgress($env(201, 1)), + 0.000001 + ); + self::assertEqualsWithDelta( + 0.5, + CentennialAllStarGrowthService::calculateDexExtensionProgress($env(202, 7)), + 0.000001 + ); + self::assertSame(1.0, CentennialAllStarGrowthService::calculateDexExtensionProgress($env(205, 1))); + self::assertSame(1.0, CentennialAllStarGrowthService::calculateDexExtensionProgress($env(210, 1))); + } + + public function testDexExtensionOnlyAddsHistoricalTargetAboveOneMillion(): void + { + $env = static fn(int $year, int $month): array => [ + 'startyear' => 180, + 'year' => $year, + 'month' => $month, + ]; + + self::assertSame( + 900000, + CentennialAllStarGrowthService::calculateDexTargetFloor(900000, $env(205, 1)) + ); + self::assertSame( + 1000000, + CentennialAllStarGrowthService::calculateDexTargetFloor(2000000, $env(199, 12)) + ); + self::assertSame( + 1000000, + CentennialAllStarGrowthService::calculateDexTargetFloor(2000000, $env(200, 1)) + ); + self::assertSame( + 1200000, + CentennialAllStarGrowthService::calculateDexTargetFloor(2000000, $env(201, 1)) + ); + self::assertSame( + 1500000, + CentennialAllStarGrowthService::calculateDexTargetFloor(2000000, $env(202, 7)) + ); + self::assertSame( + 2000000, + CentennialAllStarGrowthService::calculateDexTargetFloor(2000000, $env(205, 1)) + ); + } + + public function testEveryCandidateDexTargetExtendsMonotonicallyToItsHistoricalValue(): 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']); + $dates = [ + ['startyear' => 180, 'year' => 199, 'month' => 7], + ['startyear' => 180, 'year' => 200, 'month' => 1], + ['startyear' => 180, 'year' => 201, 'month' => 1], + ['startyear' => 180, 'year' => 202, 'month' => 7], + ['startyear' => 180, 'year' => 205, 'month' => 1], + ]; + $extendedCandidateCount = 0; + $maximumTarget = 0; + + foreach ($pool['data'] as $row) { + $hasExtendedTarget = false; + foreach ($row[$columns['dex']] as $target) { + $target = (int) $target; + $maximumTarget = max($maximumTarget, $target); + $hasExtendedTarget = $hasExtendedTarget || $target > GameConst::$dexLimit; + $floors = array_map( + static fn(array $env): int => CentennialAllStarGrowthService::calculateDexTargetFloor( + $target, + $env + ), + $dates + ); + self::assertSame( + min(GameConst::$dexLimit, $target), + $floors[0], + $row[$columns['generalName']] + ); + self::assertSame($floors[0], $floors[1], $row[$columns['generalName']]); + $sortedFloors = $floors; + sort($sortedFloors, SORT_NUMERIC); + self::assertSame($sortedFloors, $floors, $row[$columns['generalName']]); + self::assertSame($target, $floors[4], $row[$columns['generalName']]); + } + if ($hasExtendedTarget) { + $extendedCandidateCount++; + } + } + + self::assertSame(208, $extendedCandidateCount); + self::assertSame(2990401, $maximumTarget); + } + + public function testNpcReceivesFortyPercentOfExtendedHistoricalDexTarget(): void + { + $ratio = GameConst::$centennialNpcDexTargetRatio; + + self::assertSame( + 400000, + CentennialAllStarGrowthService::calculateDexTargetFloor( + 2000000, + ['startyear' => 180, 'year' => 200, 'month' => 1], + $ratio + ) + ); + self::assertSame( + 480000, + CentennialAllStarGrowthService::calculateDexTargetFloor( + 2000000, + ['startyear' => 180, 'year' => 201, 'month' => 1], + $ratio + ) + ); + self::assertSame( + 800000, + CentennialAllStarGrowthService::calculateDexTargetFloor( + 2000000, + ['startyear' => 180, 'year' => 205, 'month' => 1], + $ratio + ) + ); + } + + public function testDexExtensionNeverLowersNaturalGrowthAndKeepsItRecordable(): void + { + $target = $this->singleDexTarget('extended', 0, 2000000); + $vars = $this->emptyGeneralVars(); + $aux = CentennialAllStarGrowthService::initialAux($target, []); + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::applyTarget( + $general, + $target, + ['startyear' => 180, 'year' => 199, 'month' => 7] + ); + self::assertSame(1000000, $vars['dex1']); + self::assertSame(1000000, $aux['granted']['dex1']); + + $vars['dex1'] += 300000; + $result = CentennialAllStarGrowthService::applyTarget( + $general, + $target, + ['startyear' => 180, 'year' => 201, 'month' => 1] + ); + self::assertSame(1300000, $vars['dex1']); + self::assertSame(1000000, $aux['granted']['dex1']); + self::assertSame(300000, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + self::assertSame(1, $result['dexExtensionMilestone']); + self::assertSame(0, $result['previousDexExtensionMilestone']); + + $result = CentennialAllStarGrowthService::applyTarget( + $general, + $target, + ['startyear' => 180, 'year' => 205, 'month' => 1] + ); + self::assertSame(2000000, $vars['dex1']); + self::assertSame(1700000, $aux['granted']['dex1']); + self::assertSame(300000, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + self::assertSame(5, $result['dexExtensionMilestone']); + self::assertSame(1, $result['previousDexExtensionMilestone']); + } + + public function testDexExtensionDoesNotRefillConsumedConversionBudget(): void + { + $target = $this->singleDexTarget('extended-consumed', 0, 2000000); + $vars = $this->emptyGeneralVars(); + $vars['dex1'] = 800000; + $aux = CentennialAllStarGrowthService::initialAux($target, []); + $aux['granted']['dex1'] = 800000; + $aux['dexConsumed']['dex1'] = 200000; + $aux['dexFloor']['dex1'] = 800000; + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::applyTarget( + $general, + $target, + ['startyear' => 180, 'year' => 205, 'month' => 1] + ); + + self::assertSame(1800000, $vars['dex1']); + self::assertSame(1800000, $aux['granted']['dex1']); + self::assertSame(200000, $aux['dexConsumed']['dex1']); + self::assertSame(1800000, $aux['dexFloor']['dex1']); + self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + } + public function testNpcDexStopsAtFortyPercentOfHistoricalTarget(): void { $target = 900000; @@ -945,10 +1148,10 @@ final class CentennialAllStarGrowthTest extends TestCase ]; } - private function singleDexTarget(string $id, int $dexIndex): array + private function singleDexTarget(string $id, int $dexIndex, int $amount = 900000): array { $dex = [0, 0, 0, 0, 0]; - $dex[$dexIndex] = 900000; + $dex[$dexIndex] = $amount; return [ 'uniqueName' => $id, 'leadership' => 100,