From 4ffd7b5bc07b686f9954315556fdeeb155f1b6d7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 05:06:15 +0000 Subject: [PATCH] fix: preserve S100 generated NPC stat floor --- hwe/sammo/CentennialAllStarGrowthService.php | 63 ++++++--- tests/CentennialAllStarGrowthTest.php | 99 ++++++++++++- tests/s100-generated-npc-stat-floor-check.php | 131 ++++++++++++++++++ 3 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 tests/s100-generated-npc-stat-floor-check.php diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index 8ae99c21..d44db1ea 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -464,28 +464,24 @@ final class CentennialAllStarGrowthService } /** - * 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. + * 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) { - $target = min( - GameConst::$maxLevel, - max(0, (int) ($targetInfo[$key] ?? 0)) - ); - $general->updateVar( - $key, - CentennialAllStarGrowth::statFloor( - $target, - GameConst::$defaultStatMin, - 0 - ) - ); + $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); @@ -493,6 +489,41 @@ final class CentennialAllStarGrowthService $general->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo)); } + /** + * @param array $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. diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 34266707..579c0064 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -83,12 +83,12 @@ final class CentennialAllStarGrowthTest extends TestCase ); } - public function testGeneratedNpcUsesExactCreationDateTargetInsteadOfRandomBase(): void + public function testGeneratedNpcKeepsOrdinaryTotalAndUsesCreationDateTargetFloor(): void { $vars = [ 'leadership' => 72, - 'strength' => 61, - 'intel' => 32, + 'strength' => 66, + 'intel' => 12, 'dex1' => 120000, 'dex2' => 240000, 'dex3' => 360000, @@ -117,17 +117,104 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(91, $vars['leadership']); self::assertSame(73, $vars['strength']); - self::assertSame(10, $vars['intel']); + 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(76, $aux['granted']['leadership']); - self::assertSame(58, $aux['granted']['strength']); + 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); diff --git a/tests/s100-generated-npc-stat-floor-check.php b/tests/s100-generated-npc-stat-floor-check.php new file mode 100644 index 00000000..c040324a --- /dev/null +++ b/tests/s100-generated-npc-stat-floor-check.php @@ -0,0 +1,131 @@ + 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}" + ); + } +}