From 95f66e4c392f4fd91d9efd4cabcb7377ed1ee1a5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 10:46:23 +0000 Subject: [PATCH] feat: add 100th-season all-star growth event --- hwe/a_bestGeneral.php | 7 +- hwe/func.php | 7 +- hwe/j_select_picked_general.php | 40 +++- hwe/j_update_picked_general.php | 66 ++++--- hwe/sammo/CentennialAllStarGrowthService.php | 180 ++++++++++++++++++ .../Event/Action/AdvanceCentennialAllStar.php | 47 +++++ hwe/sammo/GeneralPool/SPoolUnderU100.php | 61 ++++++ hwe/scenario/scenario_915.json | 46 +++++ hwe/ts/select_general_from_pool.ts | 38 ++-- package.json | 2 +- src/sammo/CentennialAllStarGrowth.php | 77 ++++++++ tests/CentennialAllStarGrowthTest.php | 74 +++++++ 12 files changed, 589 insertions(+), 56 deletions(-) create mode 100644 hwe/sammo/CentennialAllStarGrowthService.php create mode 100644 hwe/sammo/Event/Action/AdvanceCentennialAllStar.php create mode 100644 hwe/sammo/GeneralPool/SPoolUnderU100.php create mode 100644 hwe/scenario/scenario_915.json create mode 100644 src/sammo/CentennialAllStarGrowth.php create mode 100644 tests/CentennialAllStarGrowthTest.php diff --git a/hwe/a_bestGeneral.php b/hwe/a_bestGeneral.php index 53b1735a..6785f42a 100644 --- a/hwe/a_bestGeneral.php +++ b/hwe/a_bestGeneral.php @@ -243,11 +243,14 @@ $templates = new \League\Plates\Engine(__DIR__ . '/templates'); "SELECT nation,no,name,owner_name as ownerName, owner, picture, imgsvr, experience, dedication, dex1, dex2, dex3, dex4, dex5, - horse, weapon, book, item + horse, weapon, book, item, aux FROM general WHERE %l", $btn == "NPC 보기" ? "npc>=2" : "npc<2" ) as $general) { $generalID = $general['no']; + foreach (['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as $dexKey) { + $general[$dexKey] = CentennialAllStarGrowthService::recordableRawValue($general, $dexKey); + } $general['bgColor'] = $nationColor[$general['nation']] ?? GameConst::$basecolor4; $general['fgColor'] = newColor($general['bgColor']); $general['nationName'] = $nationName[$general['nation']]; @@ -427,4 +430,4 @@ $templates = new \League\Plates\Engine(__DIR__ . '/templates'); - \ No newline at end of file + diff --git a/hwe/func.php b/hwe/func.php index c5a2f6e0..026c4b69 100644 --- a/hwe/func.php +++ b/hwe/func.php @@ -1401,8 +1401,11 @@ function CheckHall($no) foreach ($types as [$typeName, $valueType]) { - if ($valueType === 'natural') { - $value = $generalObj->getVar($typeName); + if ($valueType === 'natural') { + $value = $generalObj->getVar($typeName); + if (in_array($typeName, ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'], true)) { + $value = CentennialAllStarGrowthService::recordableValue($generalObj, $typeName); + } } else if ($valueType === 'rank') { $value = $generalObj->getRankVar(RankColumn::from($typeName)); } else if ($valueType === 'calc') { diff --git a/hwe/j_select_picked_general.php b/hwe/j_select_picked_general.php index d5f99e96..b7ebbe8a 100644 --- a/hwe/j_select_picked_general.php +++ b/hwe/j_select_picked_general.php @@ -6,10 +6,19 @@ include "func.php"; WebUtil::requireAJAX(); -$pick = Util::getPost('pick'); -$leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); -$strength = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); -$intel = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); +$pick = Util::getPost('pick'); +$leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); +$isCentennialAllStar = CentennialAllStarGrowthService::isActive(); +$strength = Util::getPost( + $isCentennialAllStar ? 'strength' : 'leadership', + 'int', + GameConst::$defaultStatMin +); +$intel = Util::getPost( + $isCentennialAllStar ? 'intel' : 'leadership', + 'int', + GameConst::$defaultStatMin +); $personal = Util::getPost('personal', 'string', null); $use_own_picture = Util::getPost('use_own_picture', 'bool', false); @@ -78,9 +87,19 @@ if ($gencount >= $maxgeneral) { ]); } -$poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool); -/** @var AbsGeneralPool */ -$pickedGeneral = new $poolClass($db, $selectInfo, $now); +$poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool); +/** @var AbsGeneralPool */ +if ($isCentennialAllStar) { + $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( + UniqueConst::$hiddenSeed, + 'selectPickedGeneral', + $userID, + $pick + ))); + $pickedGeneral = new $poolClass($db, $rng, $selectInfo, $now); +} else { + $pickedGeneral = new $poolClass($db, $selectInfo, $now); +} $builder = $pickedGeneral->getGeneralBuilder(); @@ -105,7 +124,10 @@ foreach(GameConst::$generalPoolAllowOption as $allowOption){ if(!$personal || $personal == 'Random'){ $personal = Util::choiceRandom(GameConst::$availablePersonality); } - if(!array_search($personal, GameConst::$availablePersonality)){ + $invalidPersonal = $isCentennialAllStar + ? !in_array($personal, GameConst::$availablePersonality, true) + : !array_search($personal, GameConst::$availablePersonality); + if($invalidPersonal){ Json::die([ 'result'=>false, 'reason'=>'올바르지 않은 성격입니다.' @@ -167,4 +189,4 @@ $rootDB->insert('member_log', [ Json::die([ 'result'=>true, 'reason'=>'success' -]); \ No newline at end of file +]); diff --git a/hwe/j_update_picked_general.php b/hwe/j_update_picked_general.php index e5aac0d0..a91c7af2 100644 --- a/hwe/j_update_picked_general.php +++ b/hwe/j_update_picked_general.php @@ -33,12 +33,13 @@ if(!$generalID){ } list( - $year, - $month, - $maxgeneral, - $npcmode, - $turnterm -) = $gameStor->getValuesAsArray(['year', 'month', 'maxgeneral', 'npcmode', 'turnterm']); + $year, + $month, + $startYear, + $maxgeneral, + $npcmode, + $turnterm +) = $gameStor->getValuesAsArray(['year', 'month', 'startyear', 'maxgeneral', 'npcmode', 'turnterm']); if($npcmode!=2){ Json::die([ @@ -101,11 +102,36 @@ $db->update('select_pool',[ 'reserved_until'=>null, ], '(owner=%i or reserved_until < %s) AND general_id is NULL', $userID, $now); -if(key_exists('leadership', $info)){ - $generalObj->updateVar('leadership', $info['leadership']); - $generalObj->updateVar('strength', $info['strength']); - $generalObj->updateVar('intel', $info['intel']); -} +$isCentennialAllStar = CentennialAllStarGrowthService::isActive(); +if ($isCentennialAllStar) { + CentennialAllStarGrowthService::applyTarget($generalObj, $info, [ + 'startyear' => $startYear, + 'year' => $year, + 'month' => $month, + ]); +} else { + if(key_exists('leadership', $info)){ + $generalObj->updateVar('leadership', $info['leadership']); + $generalObj->updateVar('strength', $info['strength']); + $generalObj->updateVar('intel', $info['intel']); + } + if(key_exists('dex', $info)){ + $generalObj->updateVar('dex1', $info['dex'][0]); + $generalObj->updateVar('dex2', $info['dex'][1]); + $generalObj->updateVar('dex3', $info['dex'][2]); + $generalObj->updateVar('dex4', $info['dex'][3]); + $generalObj->updateVar('dex5', $info['dex'][4]); + } + if(key_exists('ego', $info)){ + $generalObj->updateVar('personal', $info['ego']); + } + if(key_exists('specialDomestic', $info)){ + $generalObj->updateVar('special', $info['specialDomestic']); + } + if(key_exists('specialWar', $info)){ + $generalObj->updateVar('special2', $info['specialWar']); + } +} if(key_exists('picture', $info)){ $generalObj->updateVar('imgsvr', $info['imgsvr']); $generalObj->updateVar('picture', $info['picture']); @@ -113,22 +139,6 @@ if(key_exists('picture', $info)){ if(key_exists('generalName', $info)){ $generalObj->updateVar('name', $info['generalName']); } -if(key_exists('dex', $info)){ - $generalObj->updateVar('dex1', $info['dex'][0]); - $generalObj->updateVar('dex2', $info['dex'][1]); - $generalObj->updateVar('dex3', $info['dex'][2]); - $generalObj->updateVar('dex4', $info['dex'][3]); - $generalObj->updateVar('dex5', $info['dex'][4]); -} -if(key_exists('ego', $info)){ - $generalObj->updateVar('personal', $info['ego']); -} -if(key_exists('specialDomestic', $info)){ - $generalObj->updateVar('special', $info['specialDomestic']); -} -if(key_exists('specialWar', $info)){ - $generalObj->updateVar('special2', $info['specialWar']); -} $generalObj->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $turnterm)); $userNick = $ownerInfo['name']; @@ -148,4 +158,4 @@ $generalObj->applyDB($db); Json::die([ 'result'=>true, 'reason'=>'success' -]); \ No newline at end of file +]); diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php new file mode 100644 index 00000000..23056488 --- /dev/null +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -0,0 +1,180 @@ + (string) ($targetInfo['uniqueName'] ?? ''), + 'granted' => array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0), + 'progressMonth' => -1, + 'milestone' => 0, + 'naturalSpecialDomestic' => null, + 'eventSpecialDomestic' => null, + ]; + } + + public static function attachInitialTarget(GeneralBuilder $builder, array $targetInfo): void + { + $builder->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo)); + } + + /** + * Mutates the General object but leaves persistence to the caller. + * + * @return array{progress:float,milestone:int,previousMilestone:int,targetChanged:bool,changed:bool} + */ + public static function applyTarget(General $general, array $targetInfo, array $env): array + { + $startYear = (int) $env['startyear']; + $year = (int) $env['year']; + $month = (int) $env['month']; + $progress = CentennialAllStarGrowth::progress($startYear, $year, $month); + $progressMonth = max(0, ($year - $startYear) * 12 + $month - 1); + $targetId = (string) ($targetInfo['uniqueName'] ?? ''); + + $aux = $general->getAuxVar(self::AUX_KEY); + if (!is_array($aux)) { + $aux = self::initialAux($targetInfo); + } + $granted = is_array($aux['granted'] ?? null) + ? $aux['granted'] + : array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0); + $targetChanged = ($aux['targetId'] ?? '') !== $targetId; + $changed = false; + + foreach (self::STAT_KEYS as $key) { + if (!array_key_exists($key, $targetInfo)) { + continue; + } + $target = min(GameConst::$maxLevel, max(0, (int) $targetInfo[$key])); + $floor = CentennialAllStarGrowth::statFloor( + $target, + GameConst::$defaultStatMin, + $progress + ); + $current = (int) $general->getVar($key); + if ($targetChanged) { + $result = CentennialAllStarGrowth::replaceTarget( + $current, + (int) ($granted[$key] ?? 0), + $floor + ); + } else { + $result = CentennialAllStarGrowth::advance( + $current, + (int) ($granted[$key] ?? 0), + $floor + ); + } + if ($result['value'] !== $current) { + $general->updateVar($key, $result['value']); + $changed = true; + } + $granted[$key] = $result['granted']; + } + + $targetDex = $targetInfo['dex'] ?? []; + foreach (self::DEX_KEYS as $idx => $key) { + if (!array_key_exists($idx, $targetDex)) { + continue; + } + $target = min(GameConst::$dexLimit, max(0, (int) $targetDex[$idx])); + $floor = CentennialAllStarGrowth::dexFloor($target, $progress); + $current = (int) $general->getVar($key); + if ($targetChanged) { + $result = CentennialAllStarGrowth::replaceTarget( + $current, + (int) ($granted[$key] ?? 0), + $floor + ); + } else { + $result = CentennialAllStarGrowth::advance( + $current, + (int) ($granted[$key] ?? 0), + $floor + ); + } + if ($result['value'] !== $current) { + $general->updateVar($key, $result['value']); + $changed = true; + } + $granted[$key] = $result['granted']; + } + + $oldEventSpecial = $aux['eventSpecialDomestic'] ?? null; + if ($targetChanged && $oldEventSpecial !== null + && $general->getVar('special') === $oldEventSpecial + ) { + $general->updateVar( + 'special', + $aux['naturalSpecialDomestic'] ?? GameConst::$defaultSpecialDomestic + ); + $changed = true; + $aux['eventSpecialDomestic'] = null; + } + + $targetSpecial = $targetInfo['specialDomestic'] ?? null; + if ($progress >= self::TRAIT_UNLOCK_PROGRESS && is_string($targetSpecial) && $targetSpecial !== '') { + if (($aux['naturalSpecialDomestic'] ?? null) === null) { + $aux['naturalSpecialDomestic'] = $general->getVar('special'); + } + if ($general->getVar('special') !== $targetSpecial) { + $general->updateVar('special', $targetSpecial); + $changed = true; + } + $aux['eventSpecialDomestic'] = $targetSpecial; + } + + $previousMilestone = (int) ($aux['milestone'] ?? 0); + $milestone = min(5, (int) floor($progress * 5 + 0.0000001)); + $aux['targetId'] = $targetId; + $aux['granted'] = $granted; + $aux['progressMonth'] = max((int) ($aux['progressMonth'] ?? -1), $progressMonth); + $aux['milestone'] = max($previousMilestone, $milestone); + $general->setAuxVar(self::AUX_KEY, $aux); + + return [ + 'progress' => $progress, + 'milestone' => $milestone, + 'previousMilestone' => $previousMilestone, + 'targetChanged' => $targetChanged, + 'changed' => $changed || $targetChanged || $milestone > $previousMilestone, + ]; + } + + public static function recordableValue(General $general, string $key): int + { + $aux = $general->getAuxVar(self::AUX_KEY); + $granted = is_array($aux) && is_array($aux['granted'] ?? null) + ? (int) ($aux['granted'][$key] ?? 0) + : 0; + return CentennialAllStarGrowth::recordableValue((int) $general->getVar($key), $granted); + } + + public static function recordableRawValue(array $general, string $key): int + { + $aux = Json::decode($general['aux'] ?? '{}'); + $eventAux = is_array($aux[self::AUX_KEY] ?? null) ? $aux[self::AUX_KEY] : []; + $granted = is_array($eventAux['granted'] ?? null) + ? (int) ($eventAux['granted'][$key] ?? 0) + : 0; + return CentennialAllStarGrowth::recordableValue((int) ($general[$key] ?? 0), $granted); + } +} diff --git a/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php b/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php new file mode 100644 index 00000000..63831491 --- /dev/null +++ b/hwe/sammo/Event/Action/AdvanceCentennialAllStar.php @@ -0,0 +1,47 @@ +query( + 'SELECT general.no, select_pool.info + FROM general + JOIN select_pool ON select_pool.general_id = general.no' + ) as $row) { + $general = General::createObjFromDB((int) $row['no']); + $targetInfo = Json::decode($row['info']); + $result = CentennialAllStarGrowthService::applyTarget($general, $targetInfo, $env); + + if ($result['milestone'] > $result['previousMilestone']) { + $percent = $result['milestone'] * 20; + $general->getLogger()->pushGeneralActionLog( + "올스타 동조율이 {$percent}%에 도달했습니다!", + ActionLogger::PLAIN + ); + $general->getLogger()->pushGeneralHistoryLog( + "올스타 동조율 {$percent}% 달성" + ); + } + if ($general->applyDB($db)) { + $updated++; + } + } + + return [__CLASS__, $updated]; + } +} diff --git a/hwe/sammo/GeneralPool/SPoolUnderU100.php b/hwe/sammo/GeneralPool/SPoolUnderU100.php new file mode 100644 index 00000000..c616ee72 --- /dev/null +++ b/hwe/sammo/GeneralPool/SPoolUnderU100.php @@ -0,0 +1,61 @@ +info = $targetInfo; + CentennialAllStarGrowthService::attachInitialTarget($this->builder, $targetInfo); + } + + public static function getPoolName(): string + { + return '100기 올스타 클래식'; + } + + public static function initPool(\MeekroDB $db) + { + // 현재 ref 저장소에 보존된 공식 클래식 명장 자료는 1~29기 자료다. + // 풀 형식과 event100Unique는 이후 30~99기 자료를 같은 형태로 합칠 수 있다. + $jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS30.json')); + $columns = $jsonData['columns']; + $sqlValues = []; + foreach ($jsonData['data'] as $idx => $rawItem) { + if (count($rawItem) !== count($columns)) { + throw new \RuntimeException(($rawItem[0] ?? (string) $idx) . ' Error'); + } + $item = array_combine($columns, $rawItem); + $uniqueName = sprintf('A100%04d', $idx + 1); + $item['uniqueName'] = $uniqueName; + $item['event100Growth'] = true; + $sqlValues[] = [ + 'unique_name' => $uniqueName, + 'info' => Json::encode($item), + ]; + } + $db->insert('select_pool', $sqlValues); + } +} diff --git a/hwe/scenario/scenario_915.json b/hwe/scenario/scenario_915.json new file mode 100644 index 00000000..5238f142 --- /dev/null +++ b/hwe/scenario/scenario_915.json @@ -0,0 +1,46 @@ +{ + "title": "【공백지】 100기 올스타 클래식", + "startYear": 180, + "map": { + "mapName": "miniche", + "targetGeneralPool": "SPoolUnderU100", + "generalPoolAllowOption": ["stat", "ego", "picture"] + }, + "history": [ + "●180년 1월:【100기 이벤트】 역대 장수들이 평범한 능력으로 다시 모여, 지난 전성기의 힘과 서서히 동조하기 시작했다!" + ], + "const": { + "npcBanMessageProb": 1 + }, + "events": [ + [ + "month", 8000, + true, + ["AdvanceCentennialAllStar"] + ], + [ + "month", 1000, + ["Date", "==", null, 12], + ["CreateManyNPC", 100, 0], + ["DeleteEvent"] + ], + [ + "month", 1000, + ["Date", "==", 181, 12], + ["ChangeCity", "occupied", { + "pop": "+60000", + "agri": "+1200", + "comm": "+1200" + }] + ], + [ + "destroy_nation", 1000, + ["and", + ["Date", ">=", 183, 1], + ["RemainNation", "==", 1] + ], + ["BlockScoutAction"], + ["DeleteEvent"] + ] + ] +} diff --git a/hwe/ts/select_general_from_pool.ts b/hwe/ts/select_general_from_pool.ts index 541c4356..93820853 100644 --- a/hwe/ts/select_general_from_pool.ts +++ b/hwe/ts/select_general_from_pool.ts @@ -10,7 +10,7 @@ import { unwrap } from '@util/unwrap'; import { TemplateEngine } from '@util/TemplateEngine'; import { Tooltip } from 'bootstrap'; import { trim } from 'lodash-es'; -type CardItem = { +type CardItem = { uniqueName: string, imgsvr: 0|1, @@ -26,9 +26,14 @@ type CardItem = { specialWarInfo?: string, specialWarText?: string, - personal?: string, - personalText?: string, -} + personal?: string, + personalText?: string, + event100Growth?: boolean, + leadership?: number, + strength?: number, + intel?: number, + dex?: number[], +} type GeneralPoolResponse = { result: true, @@ -42,9 +47,10 @@ declare let currentGeneralInfo: CardItem | undefined; declare const cards: Record; declare const validCustomOption: string[]; -const templateGeneralCard = '
\ -

<%generalName%>

\ -

\ +const templateGeneralCard = '

\ +

<%generalName%>

\ +

\ + <%if(event100Growth){%>195년 최종 동조 목표
<%}%>\ <%if(leadership){%>\ <%leadership%> / <%strength%> / <%intel%>
\ <%}%>\ @@ -137,12 +143,16 @@ async function buildGeneral(e: JQuery.Event) { method: 'post', responseType: 'json', data: convertFormData({ - pick: unwrap(currentGeneralInfo).uniqueName, - use_own_picture: $('#use_own_picture').is(':checked'), - leadership: parseInt(unwrap_any($('#leadership').val())), - strength: parseInt(unwrap_any($('#leadership').val())), - intel: parseInt(unwrap_any($('#leadership').val())), - personal: unwrap_any($('#selChar').val()) + pick: unwrap(currentGeneralInfo).uniqueName, + use_own_picture: $('#use_own_picture').is(':checked'), + leadership: parseInt(unwrap_any($('#leadership').val())), + strength: parseInt(unwrap_any( + $(unwrap(currentGeneralInfo).event100Growth ? '#strength' : '#leadership').val() + )), + intel: parseInt(unwrap_any( + $(unwrap(currentGeneralInfo).event100Growth ? '#intel' : '#leadership').val() + )), + personal: unwrap_any($('#selChar').val()) }) }) result = response.data; @@ -287,4 +297,4 @@ $(async function ($) { $('.custom_stat').show(); } } -}); \ No newline at end of file +}); diff --git a/package.json b/package.json index 6c7107a9..eb612a10 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "js/index.js", "scripts": { "test": "npm-run-all test-php-gateway test-ts", - "test-php-gateway": "vendor/bin/phpunit --bootstrap vendor/autoload.php tests", + "test-php-gateway": "vendor/bin/phpunit --do-not-cache-result --bootstrap vendor/autoload.php tests", "test-ts": "mocha", "build": "webpack", "buildDev": "webpack --mode=development", diff --git a/src/sammo/CentennialAllStarGrowth.php b/src/sammo/CentennialAllStarGrowth.php new file mode 100644 index 00000000..81c30fc5 --- /dev/null +++ b/src/sammo/CentennialAllStarGrowth.php @@ -0,0 +1,77 @@ + 12) { + throw new \InvalidArgumentException('month must be between 1 and 12'); + } + + $elapsedMonths = max(0, ($year - $startYear) * 12 + $month - 1); + return min(1.0, $elapsedMonths / ($growthYears * 12)); + } + + public static function statFloor(int $target, int $minimum, float $progress): int + { + $progress = self::clampProgress($progress); + if ($target <= $minimum) { + return $target; + } + return min($target, (int) floor($minimum + ($target - $minimum) * $progress)); + } + + public static function dexFloor(int $target, float $progress): int + { + $progress = self::clampProgress($progress); + return min($target, (int) floor($target * $progress * $progress)); + } + + /** + * @return array{value:int, granted:int, delta:int} + */ + public static function advance(int $current, int $granted, int $floor): array + { + $delta = max(0, $floor - $current); + return [ + 'value' => $current + $delta, + 'granted' => max(0, $granted) + $delta, + 'delta' => $delta, + ]; + } + + /** + * @return array{value:int, granted:int, organic:int} + */ + public static function replaceTarget(int $current, int $oldGranted, int $newFloor): array + { + $organic = max(0, $current - max(0, $oldGranted)); + $value = max($organic, $newFloor); + return [ + 'value' => $value, + 'granted' => $value - $organic, + 'organic' => $organic, + ]; + } + + public static function recordableValue(int $current, int $granted): int + { + return max(0, $current - max(0, $granted)); + } + + private static function clampProgress(float $progress): float + { + return max(0.0, min(1.0, $progress)); + } +} diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php new file mode 100644 index 00000000..d090fcf4 --- /dev/null +++ b/tests/CentennialAllStarGrowthTest.php @@ -0,0 +1,74 @@ + 70, 'granted' => 0, 'delta' => 0], + CentennialAllStarGrowth::advance(70, 0, $floor) + ); + self::assertSame( + ['value' => 60, 'granted' => 10, 'delta' => 10], + CentennialAllStarGrowth::advance(50, 0, $floor) + ); + } + + public function testReselectDropsOldGrantAndKeepsOrganicGrowth(): void + { + self::assertSame( + ['value' => 82, 'granted' => 7, 'organic' => 75], + CentennialAllStarGrowth::replaceTarget(86, 11, 82) + ); + self::assertSame( + ['value' => 90, 'granted' => 0, 'organic' => 90], + CentennialAllStarGrowth::replaceTarget(101, 11, 82) + ); + } + + public function testRepeatedAdvanceIsIdempotent(): void + { + $first = CentennialAllStarGrowth::advance(50, 0, 60); + $second = CentennialAllStarGrowth::advance($first['value'], $first['granted'], 60); + self::assertSame(60, $second['value']); + self::assertSame(10, $second['granted']); + self::assertSame(0, $second['delta']); + self::assertSame(50, CentennialAllStarGrowth::recordableValue(60, 10)); + } + + public function testDexUsesSlowEarlyCurve(): void + { + self::assertSame(0, CentennialAllStarGrowth::dexFloor(900000, 0)); + self::assertSame(36000, CentennialAllStarGrowth::dexFloor(900000, 0.2)); + self::assertSame(144000, CentennialAllStarGrowth::dexFloor(900000, 0.4)); + self::assertSame(900000, CentennialAllStarGrowth::dexFloor(900000, 1)); + } + + public function testUntrackedGeneralKeepsFullHallValue(): void + { + self::assertSame( + 123456, + CentennialAllStarGrowthService::recordableRawValue( + ['dex1' => 123456, 'aux' => '{}'], + 'dex1' + ) + ); + } +}