diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index 4d224808..b90578d2 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -32,6 +32,8 @@ final class CentennialAllStarGrowthService return [ 'targetId' => (string) ($targetInfo['uniqueName'] ?? ''), 'granted' => $granted, + 'dexConsumed' => array_fill_keys(self::DEX_KEYS, 0), + 'dexFloor' => array_fill_keys(self::DEX_KEYS, 0), 'progressMonth' => -1, 'milestone' => 0, 'naturalSpecialDomestic' => null, @@ -275,6 +277,15 @@ final class CentennialAllStarGrowthService ? $aux['granted'] : array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0); $targetChanged = ($aux['targetId'] ?? '') !== $targetId; + $dexConsumed = is_array($aux['dexConsumed'] ?? null) + ? $aux['dexConsumed'] + : array_fill_keys(self::DEX_KEYS, 0); + if ($targetChanged) { + $dexConsumed = array_fill_keys(self::DEX_KEYS, 0); + } + $dexFloor = is_array($aux['dexFloor'] ?? null) + ? $aux['dexFloor'] + : array_fill_keys(self::DEX_KEYS, 0); $isUserTarget = is_array($aux['userInitialStats'] ?? null); $nextUserInitialStats = $targetChanged && $isUserTarget ? self::calculateUserInitialStats($targetInfo) @@ -327,11 +338,15 @@ final class CentennialAllStarGrowthService if (!array_key_exists($idx, $targetDex)) { continue; } - $floor = self::calculateDexTargetFloor( - (int) $targetDex[$idx], - $env, - $dexTargetRatio + $floor = max( + 0, + self::calculateDexTargetFloor( + (int) $targetDex[$idx], + $env, + $dexTargetRatio + ) - max(0, (int) ($dexConsumed[$key] ?? 0)) ); + $dexFloor[$key] = $floor; $current = (int) $general->getVar($key); if ($targetChanged || $dexTargetRatioChanged) { $result = CentennialAllStarGrowth::replaceTarget( @@ -381,6 +396,8 @@ final class CentennialAllStarGrowthService $milestone = min(5, (int) floor($progress * 5 + 0.0000001)); $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['userInitialStats'] = $nextUserInitialStats; @@ -440,6 +457,93 @@ final class CentennialAllStarGrowthService 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. + * This keeps the monthly floor from refilling points already converted. + */ + public static function reconcileDexConversion( + General $general, + string $sourceKey, + string $destinationKey, + int $sourceBefore, + int $sourceAfter, + int $destinationBefore, + int $destinationAfter, + float $convertCoeff + ): void { + if (!in_array($sourceKey, self::DEX_KEYS, true) + || !in_array($destinationKey, self::DEX_KEYS, true) + || $sourceKey === $destinationKey + ) { + throw new \InvalidArgumentException('invalid dex conversion keys'); + } + if ($convertCoeff < 0 || $convertCoeff > 1) { + throw new \InvalidArgumentException('dex conversion coefficient must be between 0 and 1'); + } + + $sourceDecrease = max(0, $sourceBefore - $sourceAfter); + $destinationIncrease = max(0, $destinationAfter - $destinationBefore); + if ($sourceDecrease === 0 && $destinationIncrease === 0) { + return; + } + + $aux = $general->getAuxVar(self::AUX_KEY); + if (!is_array($aux)) { + return; + } + $granted = is_array($aux['granted'] ?? null) + ? $aux['granted'] + : array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0); + $dexConsumed = is_array($aux['dexConsumed'] ?? null) + ? $aux['dexConsumed'] + : array_fill_keys(self::DEX_KEYS, 0); + $dexFloor = is_array($aux['dexFloor'] ?? null) + ? $aux['dexFloor'] + : []; + + $sourceGrantedBefore = min( + 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); + + $destinationGrantedBefore = min( + max(0, $destinationBefore), + max(0, (int) ($granted[$destinationKey] ?? 0)) + ); + $eventGrantTransferred = min( + $destinationIncrease, + (int) floor($eventGrantRemoved * $convertCoeff) + ); + $granted[$sourceKey] = $sourceGrantedAfter; + $granted[$destinationKey] = min( + max(0, $destinationAfter), + $destinationGrantedBefore + $eventGrantTransferred + ); + + $sourceFloor = max( + 0, + (int) ($dexFloor[$sourceKey] ?? $sourceBefore) + ); + $gapBefore = max(0, $sourceFloor - $sourceBefore); + $gapAfter = max(0, $sourceFloor - $sourceAfter); + $dexConsumed[$sourceKey] = max( + 0, + (int) ($dexConsumed[$sourceKey] ?? 0) + + max(0, $gapAfter - $gapBefore) + ); + + $aux['granted'] = $granted; + $aux['dexConsumed'] = $dexConsumed; + $general->setAuxVar(self::AUX_KEY, $aux); + } + public static function recordableValue(General $general, string $key): int { $aux = $general->getAuxVar(self::AUX_KEY); diff --git a/hwe/sammo/Command/General/che_숙련전환.php b/hwe/sammo/Command/General/che_숙련전환.php index c0e10754..b9977aaf 100644 --- a/hwe/sammo/Command/General/che_숙련전환.php +++ b/hwe/sammo/Command/General/che_숙련전환.php @@ -14,6 +14,7 @@ use function \sammo\tryUniqueItemLottery; use \sammo\Constraint\ConstraintHelper; use sammo\StaticEventHandler; +use sammo\CentennialAllStarGrowthService; class che_숙련전환 extends Command\GeneralCommand { @@ -157,6 +158,7 @@ class che_숙련전환 extends Command\GeneralCommand $logger = $general->getLogger(); $srcDex = $general->getVar('dex' . $this->srcArmType); + $destDex = $general->getVar('dex' . $this->destArmType); $cutDex = Util::toInt($srcDex * static::$decreaseCoeff); $cutDexText = number_format($cutDex); $addDex = Util::toInt($cutDex * static::$convertCoeff); @@ -164,6 +166,19 @@ class che_숙련전환 extends Command\GeneralCommand $general->increaseVar('dex' . $this->srcArmType, -$cutDex); $general->increaseVar('dex' . $this->destArmType, $addDex); + // 100기 이벤트 지급분을 목적 숙련으로 옮기고 소비한 성장 하한은 다시 채우지 않는다. + if (CentennialAllStarGrowthService::isActive()) { + CentennialAllStarGrowthService::reconcileDexConversion( + $general, + 'dex' . $this->srcArmType, + 'dex' . $this->destArmType, + $srcDex, + $general->getVar('dex' . $this->srcArmType), + $destDex, + $general->getVar('dex' . $this->destArmType), + static::$convertCoeff + ); + } $josaUl = JosaUtil::pick($cutDex, '을'); $josaRo = JosaUtil::pick($addDex, '로'); diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 5a50822e..4720efa5 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -369,6 +369,221 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(0.4, $aux['dexTargetRatio']); } + public function testDexConversionConsumesEventFloorWithoutMonthlyRefill(): void + { + $vars = [ + 'leadership' => 100, + 'strength' => 100, + 'intel' => 100, + 'dex1' => 216000, + 'dex2' => 489600, + 'dex3' => 360000, + 'dex4' => 360000, + 'dex5' => 360000, + 'special' => 'None', + ]; + $aux = [ + 'targetId' => 'A1000001', + 'granted' => [ + 'leadership' => 85, + 'strength' => 85, + 'intel' => 85, + 'dex1' => 360000, + 'dex2' => 360000, + 'dex3' => 360000, + 'dex4' => 360000, + 'dex5' => 360000, + ], + 'dexConsumed' => [ + 'dex1' => 0, + 'dex2' => 0, + 'dex3' => 0, + 'dex4' => 0, + 'dex5' => 0, + ], + 'progressMonth' => 180, + 'milestone' => 5, + 'naturalSpecialDomestic' => null, + 'eventSpecialDomestic' => null, + 'userInitialStats' => [], + 'dexTargetRatio' => 1.0, + ]; + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::reconcileDexConversion( + $general, + 'dex1', + 'dex2', + 360000, + 216000, + 360000, + 489600, + 0.9 + ); + + self::assertSame(216000, $aux['granted']['dex1']); + self::assertSame(489600, $aux['granted']['dex2']); + self::assertSame(144000, $aux['dexConsumed']['dex1']); + + CentennialAllStarGrowthService::applyTarget( + $general, + [ + 'uniqueName' => 'A1000001', + 'leadership' => 100, + 'strength' => 100, + 'intel' => 100, + 'dex' => [360000, 360000, 360000, 360000, 360000], + ], + ['startyear' => 180, 'year' => 195, 'month' => 1] + ); + + self::assertSame(216000, $vars['dex1']); + self::assertSame(489600, $vars['dex2']); + self::assertSame(216000, $aux['dexFloor']['dex1']); + self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex2')); + + $vars['dex1'] = 129600; + $vars['dex2'] = 567360; + CentennialAllStarGrowthService::reconcileDexConversion( + $general, + 'dex1', + 'dex2', + 216000, + 129600, + 489600, + 567360, + 0.9 + ); + CentennialAllStarGrowthService::applyTarget( + $general, + [ + 'uniqueName' => 'A1000001', + 'leadership' => 100, + 'strength' => 100, + 'intel' => 100, + 'dex' => [360000, 360000, 360000, 360000, 360000], + ], + ['startyear' => 180, 'year' => 195, 'month' => 1] + ); + + self::assertSame(129600, $vars['dex1']); + self::assertSame(567360, $vars['dex2']); + self::assertSame(230400, $aux['dexConsumed']['dex1']); + self::assertSame(129600, $aux['dexFloor']['dex1']); + self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + self::assertSame(0, CentennialAllStarGrowthService::recordableValue($general, 'dex2')); + } + + public function testNaturalDexConversionDoesNotBecomeEventGrant(): void + { + $vars = [ + 'dex1' => 216000, + 'dex2' => 129600, + ]; + $aux = [ + 'targetId' => 'A1000001', + 'granted' => [ + 'dex1' => 0, + 'dex2' => 0, + ], + 'dexConsumed' => [ + 'dex1' => 0, + 'dex2' => 0, + ], + 'dexFloor' => [ + 'dex1' => 360000, + 'dex2' => 0, + ], + ]; + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::reconcileDexConversion( + $general, + 'dex1', + 'dex2', + 360000, + 216000, + 0, + 129600, + 0.9 + ); + + self::assertSame(0, $aux['granted']['dex1']); + self::assertSame(0, $aux['granted']['dex2']); + self::assertSame(144000, $aux['dexConsumed']['dex1']); + self::assertSame(216000, CentennialAllStarGrowthService::recordableValue($general, 'dex1')); + self::assertSame(129600, CentennialAllStarGrowthService::recordableValue($general, 'dex2')); + } + + public function testReselectionResetsConsumedDexFloorAndTransferredGrant(): void + { + $vars = [ + 'leadership' => 100, + 'strength' => 100, + 'intel' => 100, + 'dex1' => 216000, + 'dex2' => 489600, + 'dex3' => 360000, + 'dex4' => 360000, + 'dex5' => 360000, + 'special' => 'None', + ]; + $aux = [ + 'targetId' => 'A1000001', + 'granted' => [ + 'leadership' => 85, + 'strength' => 85, + 'intel' => 85, + 'dex1' => 216000, + 'dex2' => 489600, + 'dex3' => 360000, + 'dex4' => 360000, + 'dex5' => 360000, + ], + 'dexConsumed' => [ + 'dex1' => 144000, + 'dex2' => 0, + 'dex3' => 0, + 'dex4' => 0, + 'dex5' => 0, + ], + 'dexFloor' => [ + 'dex1' => 216000, + 'dex2' => 360000, + 'dex3' => 360000, + 'dex4' => 360000, + 'dex5' => 360000, + ], + 'progressMonth' => 180, + 'milestone' => 5, + 'naturalSpecialDomestic' => null, + 'eventSpecialDomestic' => null, + 'userInitialStats' => [], + 'dexTargetRatio' => 1.0, + ]; + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::applyTarget( + $general, + [ + 'uniqueName' => 'A1000002', + 'leadership' => 100, + 'strength' => 100, + 'intel' => 100, + 'dex' => [400000, 400000, 400000, 400000, 400000], + ], + ['startyear' => 180, 'year' => 195, 'month' => 1] + ); + + self::assertSame(400000, $vars['dex1']); + self::assertSame(400000, $vars['dex2']); + self::assertSame(0, $aux['dexConsumed']['dex1']); + self::assertSame(400000, $aux['dexFloor']['dex1']); + self::assertSame(400000, $aux['granted']['dex1']); + self::assertSame(400000, $aux['granted']['dex2']); + } + public function testUserDexStillReachesFullHistoricalTarget(): void { self::assertSame( @@ -390,4 +605,37 @@ final class CentennialAllStarGrowthTest extends TestCase ) ); } + + private function createStateGeneralMock(array &$vars, array &$aux): General + { + $general = $this->getMockBuilder(General::class) + ->disableOriginalConstructor() + ->onlyMethods(['getAuxVar', 'setAuxVar', 'getVar', 'updateVar']) + ->getMock(); + $general->method('getAuxVar')->willReturnCallback( + static function (string $key) use (&$aux) { + return $key === CentennialAllStarGrowthService::AUX_KEY + ? $aux + : null; + } + ); + $general->method('getVar')->willReturnCallback( + static function (string $key) use (&$vars) { + return $vars[$key] ?? null; + } + ); + $general->method('updateVar')->willReturnCallback( + static function (string $key, $value) use (&$vars): void { + $vars[$key] = $value; + } + ); + $general->method('setAuxVar')->willReturnCallback( + static function (string $key, $value) use (&$aux): void { + if ($key === CentennialAllStarGrowthService::AUX_KEY) { + $aux = $value; + } + } + ); + return $general; + } }