Merge remote-tracking branch 'origin/devel' into compare/scenario-effects

This commit is contained in:
2026-07-30 18:38:44 +00:00
29 changed files with 119507 additions and 122 deletions
+5 -2
View File
@@ -243,11 +243,14 @@ $templates = new \League\Plates\Engine(__DIR__ . '/templates');
"SELECT nation,no,name,owner_name as ownerName, owner, picture, imgsvr, "SELECT nation,no,name,owner_name as ownerName, owner, picture, imgsvr,
experience, dedication, experience, dedication,
dex1, dex2, dex3, dex4, dex5, dex1, dex2, dex3, dex4, dex5,
horse, weapon, book, item horse, weapon, book, item, aux
FROM general WHERE %l", FROM general WHERE %l",
$btn == "NPC 보기" ? "npc>=2" : "npc<2" $btn == "NPC 보기" ? "npc>=2" : "npc<2"
) as $general) { ) as $general) {
$generalID = $general['no']; $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['bgColor'] = $nationColor[$general['nation']] ?? GameConst::$basecolor4;
$general['fgColor'] = newColor($general['bgColor']); $general['fgColor'] = newColor($general['bgColor']);
$general['nationName'] = $nationName[$general['nation']]; $general['nationName'] = $nationName[$general['nation']];
@@ -427,4 +430,4 @@ $templates = new \League\Plates\Engine(__DIR__ . '/templates');
</div> </div>
</body> </body>
</html> </html>
+17 -1
View File
@@ -51,6 +51,22 @@
display: none; display: none;
} }
.picture_choice {
margin: 8px auto;
}
.picture_choice label,
.event_picture label {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 4px 8px;
}
.picture_choice img {
object-fit: cover;
}
.custom_picture { .custom_picture {
display: none; display: none;
} }
@@ -61,4 +77,4 @@
.custom_stat { .custom_stat {
display: none; display: none;
} }
+5 -2
View File
@@ -1401,8 +1401,11 @@ function CheckHall($no)
foreach ($types as [$typeName, $valueType]) { foreach ($types as [$typeName, $valueType]) {
if ($valueType === 'natural') { if ($valueType === 'natural') {
$value = $generalObj->getVar($typeName); $value = $generalObj->getVar($typeName);
if (in_array($typeName, ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'], true)) {
$value = CentennialAllStarGrowthService::recordableValue($generalObj, $typeName);
}
} else if ($valueType === 'rank') { } else if ($valueType === 'rank') {
$value = $generalObj->getRankVar(RankColumn::from($typeName)); $value = $generalObj->getRankVar(RankColumn::from($typeName));
} else if ($valueType === 'calc') { } else if ($valueType === 'calc') {
+24 -6
View File
@@ -10,7 +10,23 @@ function sortTokens(&$tokens){
}); });
} }
function putInfoText(&$info){ function putInfoText(&$info, ?array $currentTargetEnv){
if (($info['event100Growth'] ?? false) === true) {
if ($currentTargetEnv === null) {
$displayStats = CentennialAllStarGrowthService::calculateUserInitialStats($info);
$info['selectionStatLabel'] = '시작 능력치';
} else {
$displayStats = CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
$info,
$currentTargetEnv
);
$info['selectionStatLabel'] = '현재 변경 기준 능력치';
}
$info['selectionLeadership'] = $displayStats['leadership'];
$info['selectionStrength'] = $displayStats['strength'];
$info['selectionIntel'] = $displayStats['intel'];
}
if(key_exists('specialDomestic', $info)){ if(key_exists('specialDomestic', $info)){
$class = buildGeneralSpecialDomesticClass($info['specialDomestic']); $class = buildGeneralSpecialDomesticClass($info['specialDomestic']);
$info['specialDomesticName'] = $class->getName(); $info['specialDomesticName'] = $class->getName();
@@ -34,7 +50,8 @@ $now = $oNow->format('Y-m-d H:i:s');
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$npcmode = $gameStor->getValue('npcmode'); $eventEnv = $gameStor->getValues(['npcmode', 'startyear', 'year', 'month']);
$npcmode = $eventEnv['npcmode'];
if($npcmode!=2){ if($npcmode!=2){
Json::die([ Json::die([
'result'=>false, 'result'=>false,
@@ -42,7 +59,8 @@ if($npcmode!=2){
]); ]);
} }
$rawGeneral = $db->queryFirstRow('SELECT no, aux FROM general WHERE `owner` = %i', $userID); $rawGeneral = $db->queryFirstRow('SELECT no, aux FROM general WHERE `owner` = %i', $userID);
$currentTargetEnv = $rawGeneral ? $eventEnv : null;
if($rawGeneral){ if($rawGeneral){
$generalAux = Json::decode($rawGeneral['aux']); $generalAux = Json::decode($rawGeneral['aux']);
if(key_exists('next_change', $generalAux)&& $generalAux['next_change'] > $now){ if(key_exists('next_change', $generalAux)&& $generalAux['next_change'] > $now){
@@ -62,7 +80,7 @@ if($tokens){
foreach($tokens as $token){ foreach($tokens as $token){
$valid_until = $token['reserved_until']; $valid_until = $token['reserved_until'];
$info = Json::decode($token['info']); $info = Json::decode($token['info']);
putInfoText($info); putInfoText($info, $currentTargetEnv);
$info['uniqueName'] = $token['unique_name']; $info['uniqueName'] = $token['unique_name'];
$pick[] = $info; $pick[] = $info;
} }
@@ -83,7 +101,7 @@ $valid_until = null;
foreach(pickGeneralFromPool($db, $rng, $userID, 14) as $pickObj){ foreach(pickGeneralFromPool($db, $rng, $userID, 14) as $pickObj){
$valid_until = $pickObj->getValidUntil(); $valid_until = $pickObj->getValidUntil();
$info = $pickObj->getInfo(); $info = $pickObj->getInfo();
putInfoText($info); putInfoText($info, $currentTargetEnv);
$pick[] = $info; $pick[] = $info;
} }
sortTokens($pick);//좀 무식하지만.. sortTokens($pick);//좀 무식하지만..
@@ -91,4 +109,4 @@ Json::die([
'result'=>true, 'result'=>true,
'pick'=>$pick, 'pick'=>$pick,
'validUntil'=>$valid_until 'validUntil'=>$valid_until
]); ]);
+88 -16
View File
@@ -8,10 +8,20 @@ WebUtil::requireAJAX();
$pick = Util::getPost('pick'); $pick = Util::getPost('pick');
$leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); $leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin);
$strength = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); $isCentennialAllStar = CentennialAllStarGrowthService::isActive();
$intel = Util::getPost('leadership', 'int', GameConst::$defaultStatMin); $strength = Util::getPost(
$isCentennialAllStar ? 'strength' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$intel = Util::getPost(
$isCentennialAllStar ? 'intel' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$personal = Util::getPost('personal', 'string', null); $personal = Util::getPost('personal', 'string', null);
$use_own_picture = Util::getPost('use_own_picture', 'bool', false); $use_own_picture = Util::getPost('use_own_picture', 'bool', false);
$pictureSource = Util::getPost('picture_source', 'string', 'selected');
if(!$pick){ if(!$pick){
@@ -60,16 +70,39 @@ if(!$selectInfo){
} }
$selectInfo = Json::decode($selectInfo); $selectInfo = Json::decode($selectInfo);
$ownerInfo = RootDB::db()->queryFirstRow('SELECT `name`,`picture`,`imgsvr` FROM member WHERE `NO`=%i',$userID); $ownerInfo = RootDB::db()->queryFirstRow(
if(!$ownerInfo){ 'SELECT `name`,`picture`,`imgsvr`,`grade` FROM member WHERE `NO`=%i',
$userID
);
if(!$ownerInfo){
Json::die([ Json::die([
'result'=>false, 'result'=>false,
'reason'=>'멤버 정보를 가져오지 못했습니다.' 'reason'=>'멤버 정보를 가져오지 못했습니다.'
]); ]);
} }
if ($isCentennialAllStar) {
if (!in_array($pictureSource, ['selected', 'own'], true)) {
$gencount = $db->queryFirstField('SELECT count(`no`) FROM general WHERE npc<2'); Json::die([
'result' => false,
'reason' => '올바르지 않은 전콘 선택입니다.',
]);
}
if ($pictureSource === 'own') {
$canUseOwnPicture = in_array('picture', GameConst::$generalPoolAllowOption, true)
&& $env['show_img_level'] >= 1
&& $ownerInfo['grade'] >= 1
&& $ownerInfo['picture'] !== '';
if (!$canUseOwnPicture) {
Json::die([
'result' => false,
'reason' => '사용할 수 있는 내 전콘이 없습니다.',
]);
}
}
}
$gencount = $db->queryFirstField('SELECT count(`no`) FROM general WHERE npc<2');
if ($gencount >= $maxgeneral) { if ($gencount >= $maxgeneral) {
Json::die([ Json::die([
@@ -80,12 +113,25 @@ if ($gencount >= $maxgeneral) {
$poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool); $poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool);
/** @var AbsGeneralPool */ /** @var AbsGeneralPool */
$pickedGeneral = new $poolClass($db, $selectInfo, $now); 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(); $builder = $pickedGeneral->getGeneralBuilder();
if ($isCentennialAllStar) {
CentennialAllStarGrowthService::prepareInitialUser($builder, $selectInfo);
}
foreach(GameConst::$generalPoolAllowOption as $allowOption){ foreach(GameConst::$generalPoolAllowOption as $allowOption){
if($allowOption == 'stat'){ if($allowOption == 'stat' && !$isCentennialAllStar){
$leadership = Util::valueFit($leadership, GameConst::$defaultStatMin, GameConst::$defaultStatMax); $leadership = Util::valueFit($leadership, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
$strength = Util::valueFit($strength, GameConst::$defaultStatMin, GameConst::$defaultStatMax); $strength = Util::valueFit($strength, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
$intel = Util::valueFit($intel, GameConst::$defaultStatMin, GameConst::$defaultStatMax); $intel = Util::valueFit($intel, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
@@ -98,14 +144,23 @@ foreach(GameConst::$generalPoolAllowOption as $allowOption){
} }
$builder->setStat($leadership, $strength, $intel); $builder->setStat($leadership, $strength, $intel);
} }
else if($allowOption == 'picture' && $use_own_picture){ else if(
$builder->setPicture($ownerInfo['imgsvr'], $ownerInfo['picture']); $allowOption == 'picture'
&& (
(!$isCentennialAllStar && $use_own_picture)
|| ($isCentennialAllStar && $pictureSource === 'own')
)
){
$builder->setPicture($ownerInfo['imgsvr'], $ownerInfo['picture']);
} }
else if($allowOption == 'ego'){ else if($allowOption == 'ego'){
if(!$personal || $personal == 'Random'){ if(!$personal || $personal == 'Random'){
$personal = Util::choiceRandom(GameConst::$availablePersonality); $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([ Json::die([
'result'=>false, 'result'=>false,
'reason'=>'올바르지 않은 성격입니다.' 'reason'=>'올바르지 않은 성격입니다.'
@@ -113,7 +168,7 @@ foreach(GameConst::$generalPoolAllowOption as $allowOption){
} }
$builder->setEgo($personal); $builder->setEgo($personal);
} }
} }
$userNick = $ownerInfo['name']; $userNick = $ownerInfo['name'];
@@ -123,6 +178,23 @@ $builder->setKillturn(5);
$builder->setNPCType(0); $builder->setNPCType(0);
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm'])); $builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm']));
$builder->fillRemainSpecAsZero($env); $builder->fillRemainSpecAsZero($env);
if ($isCentennialAllStar) {
$candidateCities = $db->queryFirstColumn(
'SELECT city FROM city WHERE level >= 5 AND level <= 6 AND nation = 0'
);
if (!$candidateCities) {
$candidateCities = $db->queryFirstColumn(
'SELECT city FROM city WHERE level >= 5 AND level <= 6'
);
}
if (!$candidateCities) {
Json::die([
'result' => false,
'reason' => '장수를 생성할 소·중성이 없습니다.',
]);
}
$builder->setCityID($rng->choice($candidateCities));
}
$builder->build($env); $builder->build($env);
$generalID = $builder->getGeneralID(); $generalID = $builder->getGeneralID();
if(!$generalID){ if(!$generalID){
@@ -167,4 +239,4 @@ $rootDB->insert('member_log', [
Json::die([ Json::die([
'result'=>true, 'result'=>true,
'reason'=>'success' 'reason'=>'success'
]); ]);
+77 -25
View File
@@ -7,6 +7,7 @@ include "func.php";
WebUtil::requireAJAX(); WebUtil::requireAJAX();
$pick = Util::getPost('pick'); $pick = Util::getPost('pick');
$pictureSource = Util::getPost('picture_source', 'string', 'selected');
if(!$pick){ if(!$pick){
Json::die([ Json::die([
@@ -35,10 +36,20 @@ if(!$generalID){
list( list(
$year, $year,
$month, $month,
$startYear,
$maxgeneral, $maxgeneral,
$npcmode, $npcmode,
$turnterm $turnterm,
) = $gameStor->getValuesAsArray(['year', 'month', 'maxgeneral', 'npcmode', 'turnterm']); $showImgLevel
) = $gameStor->getValuesAsArray([
'year',
'month',
'startyear',
'maxgeneral',
'npcmode',
'turnterm',
'show_img_level',
]);
if($npcmode!=2){ if($npcmode!=2){
Json::die([ Json::die([
@@ -55,7 +66,10 @@ if(!$info){
]); ]);
} }
$ownerInfo = RootDB::db()->queryFirstRow('SELECT `name`,`picture`,`imgsvr` FROM member WHERE `NO`=%i',$userID); $ownerInfo = RootDB::db()->queryFirstRow(
'SELECT `name`,`picture`,`imgsvr`,`grade` FROM member WHERE `NO`=%i',
$userID
);
if(!$ownerInfo){ if(!$ownerInfo){
Json::die([ Json::die([
'result'=>false, 'result'=>false,
@@ -64,6 +78,27 @@ if(!$ownerInfo){
} }
$info = Json::decode($info); $info = Json::decode($info);
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
if ($isCentennialAllStar) {
if (!in_array($pictureSource, ['current', 'own', 'selected'], true)) {
Json::die([
'result' => false,
'reason' => '올바르지 않은 전콘 선택입니다.',
]);
}
if ($pictureSource === 'own') {
$canUseOwnPicture = in_array('picture', GameConst::$generalPoolAllowOption, true)
&& $showImgLevel >= 1
&& $ownerInfo['grade'] >= 1
&& $ownerInfo['picture'] !== '';
if (!$canUseOwnPicture) {
Json::die([
'result' => false,
'reason' => '사용할 수 있는 내 전콘이 없습니다.',
]);
}
}
}
$generalObj = General::createObjFromDB($generalID); $generalObj = General::createObjFromDB($generalID);
@@ -101,34 +136,51 @@ $db->update('select_pool',[
'reserved_until'=>null, 'reserved_until'=>null,
], '(owner=%i or reserved_until < %s) AND general_id is NULL', $userID, $now); ], '(owner=%i or reserved_until < %s) AND general_id is NULL', $userID, $now);
if(key_exists('leadership', $info)){ if ($isCentennialAllStar) {
$generalObj->updateVar('leadership', $info['leadership']); CentennialAllStarGrowthService::prepareLegacyUserReselection($generalObj);
$generalObj->updateVar('strength', $info['strength']); CentennialAllStarGrowthService::applyTarget($generalObj, $info, [
$generalObj->updateVar('intel', $info['intel']); '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)){ if ($isCentennialAllStar) {
if ($pictureSource === 'own') {
$generalObj->updateVar('imgsvr', $ownerInfo['imgsvr']);
$generalObj->updateVar('picture', $ownerInfo['picture']);
} elseif ($pictureSource === 'selected' && key_exists('picture', $info)) {
$generalObj->updateVar('imgsvr', $info['imgsvr']);
$generalObj->updateVar('picture', $info['picture']);
}
} elseif(key_exists('picture', $info)){
$generalObj->updateVar('imgsvr', $info['imgsvr']); $generalObj->updateVar('imgsvr', $info['imgsvr']);
$generalObj->updateVar('picture', $info['picture']); $generalObj->updateVar('picture', $info['picture']);
} }
if(key_exists('generalName', $info)){ if(key_exists('generalName', $info)){
$generalObj->updateVar('name', $info['generalName']); $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)); $generalObj->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $turnterm));
$userNick = $ownerInfo['name']; $userNick = $ownerInfo['name'];
@@ -148,4 +200,4 @@ $generalObj->applyDB($db);
Json::die([ Json::die([
'result'=>true, 'result'=>true,
'reason'=>'success' 'reason'=>'success'
]); ]);
@@ -4,6 +4,7 @@ namespace sammo\API\InheritAction;
use sammo\Session; use sammo\Session;
use DateTimeInterface; use DateTimeInterface;
use sammo\CentennialAllStarGrowthService;
use sammo\DB; use sammo\DB;
use sammo\Enums\APIRecoveryType; use sammo\Enums\APIRecoveryType;
use sammo\Enums\RankColumn; use sammo\Enums\RankColumn;
@@ -98,6 +99,10 @@ class ResetStat extends \sammo\BaseAPI
return 'NPC는 능력치 초기화를 할 수 없습니다.'; return 'NPC는 능력치 초기화를 할 수 없습니다.';
} }
if (!CentennialAllStarGrowthService::isStatResetAllowed()) {
return '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.';
}
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
+7 -3
View File
@@ -8,6 +8,11 @@ use sammo\Util;
abstract class AbsFromUserPool extends AbsGeneralPool{ abstract class AbsFromUserPool extends AbsGeneralPool{
protected static function getCandidateWeight(array $info, int $owner): int|float
{
return array_sum($info['dex'] ?? []);
}
public function occupyGeneralName(): bool public function occupyGeneralName(): bool
{ {
$generalID = $this->getGeneralBuilder()->getGeneralID(); $generalID = $this->getGeneralBuilder()->getGeneralID();
@@ -36,8 +41,7 @@ abstract class AbsFromUserPool extends AbsGeneralPool{
$pool = []; $pool = [];
foreach($db->query('SELECT id, unique_name, info FROM select_pool WHERE reserved_until IS NULL AND general_id IS NULL', $pickCnt) as $cand){ foreach($db->query('SELECT id, unique_name, info FROM select_pool WHERE reserved_until IS NULL AND general_id IS NULL', $pickCnt) as $cand){
$cand['info'] = Json::decode($cand['info']); $cand['info'] = Json::decode($cand['info']);
$dexTotal = array_sum($cand['info']['dex']); $pool[] = [$cand, static::getCandidateWeight($cand['info'], $owner)];
$pool[] = [$cand, $dexTotal];
} }
if(count($pool) < $pickCnt){ if(count($pool) < $pickCnt){
@@ -69,4 +73,4 @@ abstract class AbsFromUserPool extends AbsGeneralPool{
return array_values($result); return array_values($result);
} }
} }
@@ -0,0 +1,578 @@
<?php
namespace sammo;
use sammo\Scenario\GeneralBuilder;
final class CentennialAllStarGrowthService
{
public const POOL_CLASS = 'SPoolUnderU100';
public const AUX_KEY = 'event100_allstar';
public const TRAIT_UNLOCK_PROGRESS = 0.4;
public const NPC_PROGRESS_MULTIPLIER = 0.9;
private const STAT_KEYS = ['leadership', 'strength', 'intel'];
private const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'];
public static function isActive(): bool
{
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);
if ($userInitialStats !== null) {
foreach (self::STAT_KEYS as $key) {
$initial = (int) ($userInitialStats[$key] ?? GameConst::$defaultStatMin);
$granted[$key] = max(0, $initial - min($initial, GameConst::$defaultStatMin));
}
}
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,
'eventSpecialDomestic' => null,
'userInitialStats' => $userInitialStats,
'dexTargetRatio' => 1.0,
];
}
public static function attachInitialTarget(GeneralBuilder $builder, array $targetInfo): void
{
$builder->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo));
}
/**
* Builds an ordinary-user stat total while preserving the selected
* candidate's relative strengths as closely as integer stats allow.
*
* @return array{leadership:int,strength:int,intel:int}
*/
public static function calculateUserInitialStats(array $targetInfo): array
{
$targets = [];
$bases = [];
foreach (self::STAT_KEYS as $key) {
$target = min(
GameConst::$defaultStatMax,
max(0, (int) ($targetInfo[$key] ?? 0))
);
$targets[$key] = $target;
$bases[$key] = min($target, GameConst::$defaultStatMin);
}
$targetTotal = array_sum($targets);
$desiredTotal = min(GameConst::$defaultStatTotal, $targetTotal);
$baseTotal = array_sum($bases);
$capacityTotal = $targetTotal - $baseTotal;
if ($capacityTotal <= 0 || $desiredTotal <= $baseTotal) {
return $bases;
}
$ratio = ($desiredTotal - $baseTotal) / $capacityTotal;
$result = [];
$fractions = [];
foreach (self::STAT_KEYS as $idx => $key) {
$raw = $bases[$key] + ($targets[$key] - $bases[$key]) * $ratio;
$result[$key] = (int) floor($raw);
$fractions[] = [
'key' => $key,
'fraction' => $raw - $result[$key],
'order' => $idx,
];
}
usort($fractions, static function (array $lhs, array $rhs): int {
$fractionOrder = $rhs['fraction'] <=> $lhs['fraction'];
return $fractionOrder !== 0 ? $fractionOrder : $lhs['order'] <=> $rhs['order'];
});
$remainder = $desiredTotal - array_sum($result);
foreach ($fractions as $fraction) {
if ($remainder <= 0) {
break;
}
$key = $fraction['key'];
if ($result[$key] >= $targets[$key]) {
continue;
}
$result[$key]++;
$remainder--;
}
return $result;
}
/**
* Returns the event stat baseline that a newly selected target receives at
* the supplied game date. Organic growth can still leave the actual stat
* above this baseline.
*
* @return array{leadership:int,strength:int,intel:int}
*/
public static function calculateUserCurrentTargetStats(
array $targetInfo,
array $env
): array {
$initialStats = self::calculateUserInitialStats($targetInfo);
$progress = self::calculateProgress(
(int) $env['startyear'],
(int) $env['year'],
(int) $env['month']
);
$result = [];
foreach (self::STAT_KEYS as $key) {
$target = min(
GameConst::$maxLevel,
max(0, (int) ($targetInfo[$key] ?? 0))
);
$result[$key] = max(
$initialStats[$key],
CentennialAllStarGrowth::statFloor(
$target,
GameConst::$defaultStatMin,
$progress
)
);
}
return $result;
}
public static function prepareInitialUser(
GeneralBuilder $builder,
array $targetInfo
): void {
$initialStats = self::calculateUserInitialStats($targetInfo);
$builder->setStat(
$initialStats['leadership'],
$initialStats['strength'],
$initialStats['intel']
);
$builder->setAuxVar(
self::AUX_KEY,
self::initialAux($targetInfo, $initialStats)
);
}
/**
* Old 100th-season characters did not distinguish their form-entered
* initial stats from organic growth. Before their first reselection, treat
* the ordinary creation range as the replaceable initial allocation.
*/
public static function prepareLegacyUserReselection(General $general): void
{
$aux = $general->getAuxVar(self::AUX_KEY);
if (!is_array($aux) || is_array($aux['userInitialStats'] ?? null)) {
return;
}
$granted = is_array($aux['granted'] ?? null)
? $aux['granted']
: array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0);
$legacyInitialStats = [];
foreach (self::STAT_KEYS as $key) {
$current = (int) $general->getVar($key);
$granted[$key] = self::calculateLegacyUserGrant(
$current,
(int) ($granted[$key] ?? 0)
);
$beforeEventGrant = max(
0,
$current - max(0, (int) ($aux['granted'][$key] ?? 0))
);
$legacyInitialStats[$key] = min(
$beforeEventGrant,
GameConst::$defaultStatMax
);
}
$aux['granted'] = $granted;
$aux['userInitialStats'] = $legacyInitialStats;
$general->setAuxVar(self::AUX_KEY, $aux);
}
public static function calculateLegacyUserGrant(int $current, int $eventGrant): int
{
$eventGrant = max(0, $eventGrant);
$beforeEventGrant = max(0, $current - $eventGrant);
$replaceableInitialGrant = max(
0,
min($beforeEventGrant, GameConst::$defaultStatMax)
- min($beforeEventGrant, GameConst::$defaultStatMin)
);
return $eventGrant + $replaceableInitialGrant;
}
public static function calculateProgress(
int $startYear,
int $year,
int $month,
float $progressMultiplier = 1.0
): float {
if ($progressMultiplier < 0 || $progressMultiplier > 1) {
throw new \InvalidArgumentException('progress multiplier must be between 0 and 1');
}
return min(
1,
CentennialAllStarGrowth::progress($startYear, $year, $month)
* $progressMultiplier
);
}
public static function calculateDexTargetFloor(
int $target,
array $env,
float $targetRatio = 1.0
): int {
if ($targetRatio < 0 || $targetRatio > 1) {
throw new \InvalidArgumentException('dex target ratio must be between 0 and 1');
}
$target = min(GameConst::$dexLimit, max(0, $target));
$scaledTarget = (int) floor($target * $targetRatio);
$progress = self::calculateProgress(
(int) $env['startyear'],
(int) $env['year'],
(int) $env['month']
);
return CentennialAllStarGrowth::dexFloor($scaledTarget, $progress);
}
/**
* 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,
float $progressMultiplier = 1.0,
float $dexTargetRatio = 1.0
): array
{
$startYear = (int) $env['startyear'];
$year = (int) $env['year'];
$month = (int) $env['month'];
$progress = self::calculateProgress(
$startYear,
$year,
$month,
$progressMultiplier
);
$progressMonth = (int) floor(
max(0, ($year - $startYear) * 12 + $month - 1)
* $progressMultiplier
);
$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;
$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)
: ($aux['userInitialStats'] ?? null);
$previousDexTargetRatio = is_numeric($aux['dexTargetRatio'] ?? null)
? (float) $aux['dexTargetRatio']
: 1.0;
$dexTargetRatioChanged = $previousDexTargetRatio !== $dexTargetRatio;
$userCurrentTargetStats = $isUserTarget
? self::calculateUserCurrentTargetStats($targetInfo, $env)
: null;
$changed = $dexTargetRatioChanged;
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
);
if ($userCurrentTargetStats !== null) {
$floor = $userCurrentTargetStats[$key];
}
$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;
}
$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(
$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['dexConsumed'] = $dexConsumed;
$aux['dexFloor'] = $dexFloor;
$aux['progressMonth'] = max((int) ($aux['progressMonth'] ?? -1), $progressMonth);
$aux['milestone'] = max($previousMilestone, $milestone);
$aux['userInitialStats'] = $nextUserInitialStats;
$aux['dexTargetRatio'] = $dexTargetRatio;
$general->setAuxVar(self::AUX_KEY, $aux);
return [
'progress' => $progress,
'milestone' => $milestone,
'previousMilestone' => $previousMilestone,
'targetChanged' => $targetChanged,
'changed' => $changed || $targetChanged || $milestone > $previousMilestone,
];
}
public static function progressMultiplierFor(General $general): float
{
return self::progressMultiplierForNPCType($general->getNPCType());
}
public static function progressMultiplierForNPCType(int $npcType): float
{
return in_array($npcType, [3, 4], true)
? self::NPC_PROGRESS_MULTIPLIER
: 1.0;
}
public static function dexTargetRatioForNPCType(int $npcType): float
{
return in_array($npcType, [3, 4], true)
? GameConst::$centennialNpcDexTargetRatio
: 1.0;
}
public static function applyCurrentTargetToBuiltNPC(
\MeekroDB $db,
GeneralBuilder $builder,
array $targetInfo,
array $env
): ?array {
if (!self::isActive()) {
return null;
}
$general = General::createObjFromDB($builder->getGeneralID());
if (!in_array($general->getNPCType(), [3, 4], true)) {
return null;
}
$result = self::applyTarget(
$general,
$targetInfo,
$env,
self::NPC_PROGRESS_MULTIPLIER,
GameConst::$centennialNpcDexTargetRatio
);
$general->applyDB($db);
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))
);
/*
* 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,
$eventGrantRemoved,
$eventGrantTransferred
);
$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);
$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);
}
}
@@ -14,6 +14,7 @@ use function \sammo\tryUniqueItemLottery;
use \sammo\Constraint\ConstraintHelper; use \sammo\Constraint\ConstraintHelper;
use sammo\StaticEventHandler; use sammo\StaticEventHandler;
use sammo\CentennialAllStarGrowthService;
class che_숙련전환 extends Command\GeneralCommand class che_숙련전환 extends Command\GeneralCommand
{ {
@@ -157,6 +158,7 @@ class che_숙련전환 extends Command\GeneralCommand
$logger = $general->getLogger(); $logger = $general->getLogger();
$srcDex = $general->getVar('dex' . $this->srcArmType); $srcDex = $general->getVar('dex' . $this->srcArmType);
$destDex = $general->getVar('dex' . $this->destArmType);
$cutDex = Util::toInt($srcDex * static::$decreaseCoeff); $cutDex = Util::toInt($srcDex * static::$decreaseCoeff);
$cutDexText = number_format($cutDex); $cutDexText = number_format($cutDex);
$addDex = Util::toInt($cutDex * static::$convertCoeff); $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->srcArmType, -$cutDex);
$general->increaseVar('dex' . $this->destArmType, $addDex); $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, '을'); $josaUl = JosaUtil::pick($cutDex, '을');
$josaRo = JosaUtil::pick($addDex, '로'); $josaRo = JosaUtil::pick($addDex, '로');
@@ -7,6 +7,7 @@ use \sammo\Util;
use \sammo\JosaUtil; use \sammo\JosaUtil;
use \sammo\General; use \sammo\General;
use \sammo\ActionLogger; use \sammo\ActionLogger;
use \sammo\CentennialAllStarGrowthService;
use \sammo\GameConst; use \sammo\GameConst;
use \sammo\LastTurn; use \sammo\LastTurn;
use \sammo\GameUnitConst; use \sammo\GameUnitConst;
@@ -190,6 +191,12 @@ class che_인재탐색 extends Command\GeneralCommand
$newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env); $newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env);
$newNPC->build($this->env); $newNPC->build($this->env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$this->env
);
$pickedNPC->occupyGeneralName(); $pickedNPC->occupyGeneralName();
$npcName = $newNPC->getGeneralName(); $npcName = $newNPC->getGeneralName();
$josaRa = JosaUtil::pick($npcName, '라'); $josaRa = JosaUtil::pick($npcName, '라');
@@ -7,6 +7,7 @@ use \sammo\Util;
use \sammo\JosaUtil; use \sammo\JosaUtil;
use \sammo\General; use \sammo\General;
use \sammo\ActionLogger; use \sammo\ActionLogger;
use \sammo\CentennialAllStarGrowthService;
use \sammo\GameConst; use \sammo\GameConst;
use \sammo\LastTurn; use \sammo\LastTurn;
use \sammo\GameUnitConst; use \sammo\GameUnitConst;
@@ -160,6 +161,12 @@ class che_의병모집 extends Command\NationCommand
$newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env); $newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env);
$newNPC->build($this->env); $newNPC->build($this->env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$this->env
);
$pickedNPC->occupyGeneralName(); $pickedNPC->occupyGeneralName();
} }
@@ -0,0 +1,55 @@
<?php
namespace sammo\Event\Action;
use sammo\ActionLogger;
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\General;
use sammo\Json;
class AdvanceCentennialAllStar extends \sammo\Event\Action
{
public function run(array $env)
{
if (!CentennialAllStarGrowthService::isActive()) {
return [__CLASS__, 0];
}
$db = DB::db();
$updated = 0;
foreach ($db->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,
CentennialAllStarGrowthService::progressMultiplierFor($general),
CentennialAllStarGrowthService::dexTargetRatioForNPCType(
$general->getNPCType()
)
);
if ($result['milestone'] > $result['previousMilestone']) {
$percent = $result['milestone'] * 20;
$general->getLogger()->pushGeneralActionLog(
"<L>올스타 동조율</>이 <C>{$percent}%</>에 도달했습니다!",
ActionLogger::PLAIN
);
$general->getLogger()->pushGeneralHistoryLog(
"<L>올스타 동조율 {$percent}% 달성</>"
);
}
if ($general->applyDB($db)) {
$updated++;
}
}
return [__CLASS__, $updated];
}
}
+9 -1
View File
@@ -5,6 +5,7 @@ namespace sammo\Event\Action;
use \sammo\GameConst; use \sammo\GameConst;
use \sammo\Util; use \sammo\Util;
use \sammo\DB; use \sammo\DB;
use sammo\CentennialAllStarGrowthService;
use sammo\LiteHashDRBG; use sammo\LiteHashDRBG;
use sammo\RandUtil; use sammo\RandUtil;
use sammo\UniqueConst; use sammo\UniqueConst;
@@ -35,7 +36,8 @@ class CreateManyNPC extends \sammo\Event\Action
))); )));
$result = []; $result = [];
foreach (pickGeneralFromPool(DB::db(), $rng, 0, $cnt) as $pickedNPC) { $db = DB::db();
foreach (pickGeneralFromPool($db, $rng, 0, $cnt) as $pickedNPC) {
$age = $rng->nextRangeInt(20, 25); $age = $rng->nextRangeInt(20, 25);
$birthYear = $env['year'] - $age; $birthYear = $env['year'] - $age;
$deathYear = $env['year'] + $rng->nextRangeInt(10, 50); $deathYear = $env['year'] + $rng->nextRangeInt(10, 50);
@@ -50,6 +52,12 @@ class CreateManyNPC extends \sammo\Event\Action
} }
$newNPC->fillRemainSpecAsZero($env); $newNPC->fillRemainSpecAsZero($env);
$newNPC->build($env); $newNPC->build($env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$env
);
$pickedNPC->occupyGeneralName(); $pickedNPC->occupyGeneralName();
$result[] = [ $result[] = [
$newNPC->getGeneralName(), $newNPC->getGeneralID() $newNPC->getGeneralName(), $newNPC->getGeneralID()
+6
View File
@@ -184,6 +184,10 @@ class GameConstBase
/** @var array 선택할 수 없으나 게임 내에 유효한 장수 내정 특기 */ /** @var array 선택할 수 없으나 게임 내에 유효한 장수 내정 특기 */
public static $optionalSpecialDomestic = [ public static $optionalSpecialDomestic = [
'None', 'None',
'che_event_귀병', 'che_event_신산', 'che_event_환술', 'che_event_집중', 'che_event_신중',
'che_event_반계', 'che_event_보병', 'che_event_궁병', 'che_event_기병', 'che_event_공성',
'che_event_돌격', 'che_event_무쌍', 'che_event_견고', 'che_event_위압', 'che_event_저격',
'che_event_필살', 'che_event_징병', 'che_event_의술', 'che_event_격노', 'che_event_척사',
]; ];
/** @var string 기본 전투 특기 */ /** @var string 기본 전투 특기 */
@@ -416,6 +420,8 @@ class GameConstBase
public static $retirementYear = 80; public static $retirementYear = 80;
public static $targetGeneralPool = 'RandomNameGeneral'; public static $targetGeneralPool = 'RandomNameGeneral';
/** @var float 100기 올스타 NPC의 원본 목표 대비 최종 숙련 비율 */
public static $centennialNpcDexTargetRatio = 0.4;
public static $generalPoolAllowOption = ['stat', 'ego', 'picture']; public static $generalPoolAllowOption = ['stat', 'ego', 'picture'];
public static $randGenFirstName = [ public static $randGenFirstName = [
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace sammo\GeneralPool;
use sammo\AbsFromUserPool;
use sammo\CentennialAllStarGrowthService;
use sammo\Json;
use sammo\RandUtil;
class SPoolUnderU100 extends AbsFromUserPool
{
private const MIN_DEX_WEIGHT = 100000;
private const STAT_BONUS_MIN_TOTAL = 160;
private const STAT_BONUS_MAX_TOTAL = 190;
private const STAT_BONUS_MAX_MULTIPLIER = 1.5;
public function __construct(\MeekroDB $db, RandUtil $rng, array $info, string $validUntil)
{
$targetInfo = $info;
$initialInfo = $info;
foreach ([
'leadership',
'strength',
'intel',
'experience',
'dedication',
'dex',
'specialDomestic',
'specialWar',
] as $targetKey) {
unset($initialInfo[$targetKey]);
}
parent::__construct($db, $rng, $initialInfo, $validUntil);
$this->info = $targetInfo;
CentennialAllStarGrowthService::attachInitialTarget($this->builder, $targetInfo);
}
public static function getPoolName(): string
{
return '100기 올스타 클래식';
}
protected static function getCandidateWeight(array $info, int $owner): int|float
{
$dexWeight = max(
self::MIN_DEX_WEIGHT,
array_sum($info['dex'] ?? [])
);
if ($owner <= 0) {
return $dexWeight;
}
$statTotal = array_sum([
(int) ($info['leadership'] ?? 0),
(int) ($info['strength'] ?? 0),
(int) ($info['intel'] ?? 0),
]);
$normalizedStat = min(1, max(
0,
($statTotal - self::STAT_BONUS_MIN_TOTAL)
/ (self::STAT_BONUS_MAX_TOTAL - self::STAT_BONUS_MIN_TOTAL)
));
$statMultiplier = 1
+ (self::STAT_BONUS_MAX_MULTIPLIER - 1) * $normalizedStat;
return $dexWeight * $statMultiplier;
}
public static function initPool(\MeekroDB $db)
{
$jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS100.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);
}
}
+71
View File
@@ -0,0 +1,71 @@
{
"title": "【공백지】 100기 올스타 클래식",
"startYear": 180,
"map": {
"mapName": "miniche",
"targetGeneralPool": "SPoolUnderU100",
"generalPoolAllowOption": ["stat", "ego", "picture"],
"centennialNpcDexTargetRatio": 0.4
},
"history": [
"<C>●</>180년 1월:<L><b>【100기 이벤트】</b></> 역대 장수들이 평범한 능력으로 다시 모여, 지난 전성기의 힘과 서서히 동조하기 시작했다!"
],
"const": {
"npcBanMessageProb":0.005,
"defaultMaxGeneral": 800,
"uniqueTrialCoef": 2
},
"events": [
[
"month", 8000,
true,
["AdvanceCentennialAllStar"]
],
[
"month", 1000,
["Date", "==", null, 12],
["CreateManyNPC", 100, 0],
["DeleteEvent"]
],
[
"month", 1000,
["Date", "==", 181, 1],
["RaiseNPCNation"],
["DeleteEvent"]
],
[
"destroy_nation", 1000,
["and",
["Date", ">=", 183, 1],
["RemainNation", "==", 1]
],
["BlockScoutAction"],
["DeleteEvent"]
],
[
"month", 999,
["Date", "==", 181, 1],
["OpenNationBetting", 4, 5000],
["OpenNationBetting", 1, 2000],
["DeleteEvent"]
],
[
"month", 999,
["and",
["Date", ">=", 183, 1],
["RemainNation", "<=", 8]
],
["OpenNationBetting", 1, 1000],
["DeleteEvent"]
],
[
"destroy_nation", 1000,
["and",
["Date", ">=", 183, 1],
["RemainNation", "==", 1]
],
["BlockScoutAction"],
["DeleteEvent"]
]
]
}
+88 -31
View File
@@ -19,8 +19,16 @@ if ($admin['npcmode'] != 2) {
$member = RootDB::db()->queryFirstRow("SELECT no,name,picture,imgsvr,grade from member where no= %i", $userID); $member = RootDB::db()->queryFirstRow("SELECT no,name,picture,imgsvr,grade from member where no= %i", $userID);
$generalID = $db->queryFirstField('SELECT no FROM general WHERE owner = %i', $userID); $currentGeneral = $db->queryFirstRow(
'SELECT no,picture,imgsvr FROM general WHERE owner = %i',
$userID
);
$generalID = $currentGeneral['no'] ?? null;
$gencount = $db->queryFirstField('SELECT count(`no`) FROM general WHERE npc<2'); $gencount = $db->queryFirstField('SELECT count(`no`) FROM general WHERE npc<2');
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
$canUseOwnPicture = $admin['show_img_level'] >= 1
&& $member['grade'] >= 1
&& $member['picture'] != "";
$nationList = $db->query('SELECT nation,`name`,color,scout FROM nation'); $nationList = $db->query('SELECT nation,`name`,color,scout FROM nation');
shuffle($nationList); shuffle($nationList);
@@ -54,6 +62,7 @@ foreach (getCharacterList(false) as $id => [$name, $info]) {
<script> <script>
var hasGeneralID = <?= $generalID === null ? 'false' : 'true' ?>; var hasGeneralID = <?= $generalID === null ? 'false' : 'true' ?>;
var isCentennialAllStar = <?= $isCentennialAllStar ? 'true' : 'false' ?>;
var defaultStatTotal = <?= GameConst::$defaultStatTotal ?>; var defaultStatTotal = <?= GameConst::$defaultStatTotal ?>;
var defaultStatMin = <?= GameConst::$defaultStatMin ?>; var defaultStatMin = <?= GameConst::$defaultStatMin ?>;
var defaultStatMax = <?= GameConst::$defaultStatMax ?>; var defaultStatMax = <?= GameConst::$defaultStatMax ?>;
@@ -104,6 +113,27 @@ if ($gencount >= $admin['maxgeneral']) {
<small id="valid_until">(<span id="valid_until_text"></span>까지 유효)</small><small id="outdate_token">- 만료 -</small><br> <small id="valid_until">(<span id="valid_until_text"></span>까지 유효)</small><small id="outdate_token">- 만료 -</small><br>
<form class="card_holder"> <form class="card_holder">
</form> </form>
<?php if ($isCentennialAllStar && $generalID !== null) : ?>
<div id="reselect_picture_plate" class="picture_choice">
<strong>변경 후 전콘</strong>
<label>
<input type="radio" name="reselect_picture_source" value="selected" checked>
새로 선택할 장수 전콘
</label>
<label>
<input type="radio" name="reselect_picture_source" value="current">
<img width="32" height="32" src="<?= GetImageURL($currentGeneral['imgsvr']) ?>/<?= $currentGeneral['picture'] ?>" border="0">
현재 장수 전콘
</label>
<?php if ($canUseOwnPicture) : ?>
<label>
<input type="radio" name="reselect_picture_source" value="own">
<img width="32" height="32" src="<?= GetImageURL($member['imgsvr']) ?>/<?= $member['picture'] ?>" border="0">
내 원래 전콘
</label>
<?php endif; ?>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -117,7 +147,28 @@ if ($gencount >= $admin['maxgeneral']) {
<form id='custom_form'> <form id='custom_form'>
<table class='tb_layout' style='width:100%;text-align:left;'> <table class='tb_layout' style='width:100%;text-align:left;'>
<?php <?php
if ($admin['show_img_level'] >= 1 && $member['grade'] >= 1 && $member['picture'] != "") { if ($isCentennialAllStar) {
echo "
<tr class='event_picture'>
<td align=right class='bg1'>전콘 선택</td>
<td colspan=2>
<label><input type=radio name=picture_source value=selected checked> 선택한 장수 전콘</label>
";
if ($canUseOwnPicture) {
$imageTemp = GetImageURL($member['imgsvr']);
echo "
<label>
<input type=radio name=picture_source value=own>
<img width='64' height='64' src='{$imageTemp}/{$member['picture']}' border='0'>
내 전콘
</label>
";
}
echo "
</td>
</tr>
";
} elseif ($canUseOwnPicture) {
$imageTemp = GetImageURL($member['imgsvr']); $imageTemp = GetImageURL($member['imgsvr']);
echo " echo "
<tr class='custom_picture'> <tr class='custom_picture'>
@@ -144,36 +195,42 @@ if ($gencount >= $admin['maxgeneral']) {
</select> <span id="charInfoText"></span> </select> <span id="charInfoText"></span>
</td> </td>
</tr> </tr>
<tr class='custom_stat'> <?php if (!$isCentennialAllStar) : ?>
<td align=right class='bg1'>통솔</td> <tr class='custom_stat'>
<td colspan=2><input type="number" name="leadership" id="leadership" value="50"></td> <td align=right class='bg1'>통솔</td>
</tr> <td colspan=2><input type="number" name="leadership" id="leadership" value="50"></td>
<tr class='custom_stat'> </tr>
<td align=right class='bg1'>무력</td> <tr class='custom_stat'>
<td colspan=2><input type="number" name="strength" id="strength" value="50"></td> <td align=right class='bg1'>무력</td>
</tr> <td colspan=2><input type="number" name="strength" id="strength" value="50"></td>
<tr class='custom_stat'> </tr>
<td align=right class='bg1'>지력</td> <tr class='custom_stat'>
<td colspan=2><input type="number" name="intel" id="intel" value="50"></td> <td align=right class='bg1'>지력</td>
</tr> <td colspan=2><input type="number" name="intel" id="intel" value="50"></td>
<tr class='custom_stat'> </tr>
<td align=right class='bg1'>능력치 조정</td> <tr class='custom_stat'>
<td colspan=2> <td align=right class='bg1'>능력치 조정</td>
<input type=button value=랜덤형 onclick=abilityRand()> <td colspan=2>
<input type=button value=통솔무력형 onclick=abilityLeadpow()> <input type=button value=랜덤형 onclick=abilityRand()>
<input type=button value=통솔력형 onclick=abilityLeadint()> <input type=button value=통솔력형 onclick=abilityLeadpow()>
<input type=button value=무력지력형 onclick=abilityPowint()> <input type=button value=통솔지력형 onclick=abilityLeadint()>
</td> <input type=button value=무력지력형 onclick=abilityPowint()>
</tr> </td>
<tr class='custom_stat'> </tr>
<td align=center colspan=3> <tr class='custom_stat'>
<font color=orange>모든 능력치는 ( <?= GameConst::$defaultStatMin ?> <= 능력치 <=<?= GameConst::$defaultStatMax ?> ) 사이로 잡으셔야 합니다.<br> <td align=center colspan=3>
그 외의 능력치는 가입되지 않습니다.</font> <font color=orange>모든 능력치는 ( <?= GameConst::$defaultStatMin ?> <= 능력치 <=<?= GameConst::$defaultStatMax ?> ) 사이로 잡으셔야 합니다.<br>
</td> 그 외의 능력치는 가입되지 않습니다.</font>
</tr> </td>
</tr>
<?php endif; ?>
<tr> <tr>
<td align=center colspan=3> <td align=center colspan=3>
<span class='custom_stat'>능력치의 총합은 <?= GameConst::$defaultStatTotal ?>입니다. 가입후 0~10의 능력치 보너스를 받게 됩니다.<br></span> <?php if ($isCentennialAllStar) : ?>
선택한 장수의 최종 능력치 비율을 반영한 약화 능력치로 시작합니다.<br>
<?php else : ?>
<span class='custom_stat'>능력치의 총합은 <?= GameConst::$defaultStatTotal ?>입니다. 가입후 0~10의 능력치 보너스를 받게 됩니다.<br></span>
<?php endif; ?>
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다. 임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
</td> </td>
</tr> </tr>
@@ -194,4 +251,4 @@ if ($gencount >= $admin['maxgeneral']) {
</div> </div>
</body> </body>
</html> </html>
+15 -2
View File
@@ -172,7 +172,7 @@
<BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton> <BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton>
</div> </div>
</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="row px-4">
<div class="a-right col-6 align-self-center">능력치 초기화</div> <div class="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6"> <div class="col-6">
@@ -222,6 +222,17 @@
<BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton> <BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton>
</div> </div>
</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>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@@ -297,6 +308,7 @@ declare const staticValues: {
} }
>; >;
availableTargetGeneral: Record<number, string>; availableTargetGeneral: Record<number, string>;
canResetStat: boolean;
currentStat: { currentStat: {
leadership: number; leadership: number;
strength: number; strength: number;
@@ -447,6 +459,7 @@ const {
availableSpecialWar, availableSpecialWar,
availableUnique, availableUnique,
availableTargetGeneral, availableTargetGeneral,
canResetStat,
currentStat currentStat
} = staticValues; } = staticValues;
@@ -739,4 +752,4 @@ async function getMoreLog(): Promise<void> {
.tnum { .tnum {
font-feature-settings: "tnum"; font-feature-settings: "tnum";
} }
</style> </style>
+59 -31
View File
@@ -10,7 +10,7 @@ import { unwrap } from '@util/unwrap';
import { TemplateEngine } from '@util/TemplateEngine'; import { TemplateEngine } from '@util/TemplateEngine';
import { Tooltip } from 'bootstrap'; import { Tooltip } from 'bootstrap';
import { trim } from 'lodash-es'; import { trim } from 'lodash-es';
type CardItem = { type CardItem = {
uniqueName: string, uniqueName: string,
imgsvr: 0|1, imgsvr: 0|1,
@@ -26,9 +26,18 @@ type CardItem = {
specialWarInfo?: string, specialWarInfo?: string,
specialWarText?: string, specialWarText?: string,
personal?: string, personal?: string,
personalText?: string, personalText?: string,
} event100Growth?: boolean,
leadership?: number,
strength?: number,
intel?: number,
selectionStatLabel?: string,
selectionLeadership?: number,
selectionStrength?: number,
selectionIntel?: number,
dex?: number[],
}
type GeneralPoolResponse = { type GeneralPoolResponse = {
result: true, result: true,
@@ -37,17 +46,23 @@ type GeneralPoolResponse = {
} }
declare const characterInfo: Record<string, { name: string, info: string }>; declare const characterInfo: Record<string, { name: string, info: string }>;
declare const hasGeneralID: number; declare const hasGeneralID: number;
declare const isCentennialAllStar: boolean;
declare let currentGeneralInfo: CardItem | undefined; declare let currentGeneralInfo: CardItem | undefined;
declare const cards: Record<string, CardItem>; declare const cards: Record<string, CardItem>;
declare const validCustomOption: string[]; declare const validCustomOption: string[];
const templateGeneralCard = '<div class="general_card">\ const templateGeneralCard = '<div class="general_card">\
<h4 class="bg1 with_border"><%generalName%></h4>\ <h4 class="bg1 with_border"><%generalName%></h4>\
<h4><img src="<%iconPath%>" height=64 width=64></h4><p>\ <h4><img src="<%iconPath%>" height=64 width=64></h4><p>\
<%if(leadership){%>\ <%if(event100Growth){%><b>195년 최종 동조 목표</b><br><%}%>\
<%leadership%> / <%strength%> / <%intel%><br>\ <%if(leadership){%>\
<%}%>\ <%leadership%> / <%strength%> / <%intel%><br>\
<%}%>\
<%if(selectionStatLabel){%>\
<b><%selectionStatLabel%></b><br>\
<%selectionLeadership%> / <%selectionStrength%> / <%selectionIntel%><br>\
<%}%>\
<%if(personalText){%><%personalText%><br><%}%>\ <%if(personalText){%><%personalText%><br><%}%>\
<%if(specialDomesticText||specialWarText){%>\ <%if(specialDomesticText||specialWarText){%>\
<%specialDomesticText%> / <%specialWarText%><br>\ <%specialDomesticText%> / <%specialWarText%><br>\
@@ -101,9 +116,12 @@ async function pickGeneral(this: HTMLElement, e: JQuery.Event) {
url: 'j_update_picked_general.php', url: 'j_update_picked_general.php',
method: 'post', method: 'post',
responseType: 'json', responseType: 'json',
data: convertFormData({ data: convertFormData({
pick: unwrap_any<string>($btn.val()) pick: unwrap_any<string>($btn.val()),
}) picture_source: unwrap_any<string>(
$('input[name="reselect_picture_source"]:checked').val() ?? 'selected'
)
})
}); });
result = response.data; result = response.data;
if (!result.result) { if (!result.result) {
@@ -132,19 +150,25 @@ async function buildGeneral(e: JQuery.Event) {
let result: InvalidResponse; let result: InvalidResponse;
try { try {
const response = await axios({ const formData: Record<string, string|number|boolean> = {
url: 'j_select_picked_general.php', pick: unwrap(currentGeneralInfo).uniqueName,
method: 'post', use_own_picture: $('#use_own_picture').is(':checked'),
responseType: 'json', picture_source: unwrap_any<string>(
data: convertFormData({ $('input[name="picture_source"]:checked').val() ?? 'selected'
pick: unwrap(currentGeneralInfo).uniqueName, ),
use_own_picture: $('#use_own_picture').is(':checked'), personal: unwrap_any<string>($('#selChar').val())
leadership: parseInt(unwrap_any<string>($('#leadership').val())), };
strength: parseInt(unwrap_any<string>($('#leadership').val())), if (!isCentennialAllStar) {
intel: parseInt(unwrap_any<string>($('#leadership').val())), formData.leadership = parseInt(unwrap_any<string>($('#leadership').val()));
personal: unwrap_any<string>($('#selChar').val()) formData.strength = parseInt(unwrap_any<string>($('#leadership').val()));
}) formData.intel = parseInt(unwrap_any<string>($('#leadership').val()));
}) }
const response = await axios({
url: 'j_select_picked_general.php',
method: 'post',
responseType: 'json',
data: convertFormData(formData)
})
result = response.data; result = response.data;
if (!result.result) { if (!result.result) {
throw result.reason; throw result.reason;
@@ -190,9 +214,13 @@ function printGenerals(value: GeneralPoolResponse) {
const emptyCard = { const emptyCard = {
'leadership': null, 'leadership': null,
'strength': null, 'strength': null,
'intel': null, 'intel': null,
'personalText': null, 'selectionStatLabel': null,
'selectionLeadership': null,
'selectionStrength': null,
'selectionIntel': null,
'personalText': null,
'specialDomesticText': null, 'specialDomesticText': null,
'specialWarText': null, 'specialWarText': null,
'dex': null 'dex': null
@@ -287,4 +315,4 @@ $(async function ($) {
$('.custom_stat').show(); $('.custom_stat').show();
} }
} }
}); });
+2 -1
View File
@@ -105,6 +105,7 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
'availableUnique' => $availableUnique, 'availableUnique' => $availableUnique,
'lastInheritPointLogs' => $lastInheritPointLogs, 'lastInheritPointLogs' => $lastInheritPointLogs,
'availableTargetGeneral' => $availableTargetGeneral, 'availableTargetGeneral' => $availableTargetGeneral,
'canResetStat' => CentennialAllStarGrowthService::isStatResetAllowed(),
'currentStat' => [ 'currentStat' => [
'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax), 'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax),
'strength' => Util::clamp($me->getVar('strength'), 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> <div id="app"></div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "js/index.js", "main": "js/index.js",
"scripts": { "scripts": {
"test": "npm-run-all test-php-gateway test-ts", "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", "test-ts": "mocha",
"build": "webpack", "build": "webpack",
"buildDev": "webpack --mode=development", "buildDev": "webpack --mode=development",
+256
View File
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
/**
* Build the static 100th-season all-star pool from a tab-separated query result.
*
* Usage:
* mariadb --batch --raw --skip-column-names DATABASE \
* < src/centennial_allstar_candidates.sql \
* | php src/build_centennial_allstar_pool.php OUTPUT.json
*/
const OUTPUT_COLUMNS = [
'generalName',
'leadership',
'strength',
'intel',
'specialDomestic',
'dex',
'imgsvr',
'picture',
'sourcePhase',
'sourceServerId',
'sourceGeneralNo',
'selectionReasons',
];
const EXCLUDED_EVENT_PHASES = [
5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
55, 60, 65, 70, 75, 80, 85, 90, 95,
];
const LEGACY_SPECIAL_WAR_MAP = [
40 => 'che_event_귀병',
41 => 'che_event_신산',
42 => 'che_event_환술',
43 => 'che_event_집중',
44 => 'che_event_신중',
45 => 'che_event_반계',
50 => 'che_event_보병',
51 => 'che_event_궁병',
52 => 'che_event_기병',
53 => 'che_event_공성',
60 => 'che_event_돌격',
61 => 'che_event_무쌍',
62 => 'che_event_견고',
63 => 'che_event_위압',
70 => 'che_event_저격',
71 => 'che_event_필살',
72 => 'che_event_징병',
73 => 'che_event_의술',
74 => 'che_event_격노',
75 => 'che_event_척사',
];
function fail(string $message): never
{
fwrite(STDERR, "error: {$message}\n");
exit(1);
}
function firstInt(array $data, array $keys): int
{
foreach ($keys as $key) {
if (array_key_exists($key, $data) && is_numeric($data[$key])) {
return (int) $data[$key];
}
}
fail('missing numeric field: ' . implode(' or ', $keys));
}
function normalizeEventSpecial(mixed $rawSpecial): ?string
{
if (is_numeric($rawSpecial)) {
return LEGACY_SPECIAL_WAR_MAP[(int) $rawSpecial] ?? null;
}
if (
!is_string($rawSpecial)
|| $rawSpecial === ''
|| $rawSpecial === '0'
|| strcasecmp($rawSpecial, 'none') === 0
) {
return null;
}
if (str_starts_with($rawSpecial, 'che_event_')) {
return $rawSpecial;
}
if (str_starts_with($rawSpecial, 'che_')) {
return 'che_event_' . substr($rawSpecial, strlen('che_'));
}
fail("unknown historical war special: {$rawSpecial}");
}
function decodeSourceRow(string $line, int $lineNo): array
{
$fields = explode("\t", rtrim($line, "\r\n"), 6);
if (count($fields) !== 6) {
fail("line {$lineNo}: expected 6 tab-separated fields, got " . count($fields));
}
[$phase, $serverId, $generalNo, $name, $rawData, $rawReasons] = $fields;
$data = json_decode($rawData, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
fail("line {$lineNo}: general data is not an object");
}
$phaseNo = (int) $phase;
$sourceGeneralNo = (int) $generalNo;
if ($phaseNo < 1 || $phaseNo > 99 || $sourceGeneralNo <= 2) {
fail("line {$lineNo}: invalid phase/general number");
}
$sourceName = trim($name);
if ($sourceName === '') {
fail("line {$lineNo}: empty general name");
}
$generalName = sprintf('%d·%s', $phaseNo, $sourceName);
if (mb_strlen($generalName) > 32) {
fail("line {$lineNo}: generated name exceeds 32 characters: {$generalName}");
}
$dex = [
firstInt($data, ['dex1', 'dex0']),
firstInt($data, ['dex2', 'dex10']),
firstInt($data, ['dex3', 'dex20']),
firstInt($data, ['dex4', 'dex30']),
firstInt($data, ['dex5', 'dex40']),
];
foreach ($dex as $value) {
if ($value < 0) {
fail("line {$lineNo}: negative dex value");
}
}
$reasons = $rawReasons === '' ? [] : explode(',', $rawReasons);
sort($reasons, SORT_STRING);
return [
$generalName,
firstInt($data, ['leadership', 'leader']),
firstInt($data, ['strength', 'power']),
firstInt($data, ['intel']),
normalizeEventSpecial($data['special2'] ?? null),
$dex,
firstInt($data, ['imgsvr']),
is_string($data['picture'] ?? null) && $data['picture'] !== ''
? $data['picture']
: 'default.jpg',
$phaseNo,
$serverId,
$sourceGeneralNo,
$reasons,
];
}
if ($argc !== 2) {
fail('usage: php build_centennial_allstar_pool.php OUTPUT.json');
}
$outputPath = $argv[1];
$rows = [];
$seenSources = [];
$nameIndexes = [];
$phaseCounts = [];
$chiefCounts = [];
$reasonCounts = [];
$lineNo = 0;
while (($line = fgets(STDIN)) !== false) {
$lineNo++;
if (trim($line) === '') {
continue;
}
try {
$row = decodeSourceRow($line, $lineNo);
} catch (JsonException $e) {
fail("line {$lineNo}: invalid JSON: {$e->getMessage()}");
}
$sourceKey = "{$row[8]}:{$row[10]}";
if (isset($seenSources[$sourceKey])) {
fail("line {$lineNo}: duplicate source general {$sourceKey}");
}
$seenSources[$sourceKey] = true;
$nameIndexes[$row[0]][] = count($rows);
$phaseCounts[$row[8]] = ($phaseCounts[$row[8]] ?? 0) + 1;
foreach ($row[11] as $reason) {
$reasonGroup = str_starts_with($reason, 'chief:') ? 'chief' : 'hall';
$reasonCounts[$reasonGroup] = ($reasonCounts[$reasonGroup] ?? 0) + 1;
if ($reasonGroup === 'chief') {
$chiefCounts[$row[8]] = ($chiefCounts[$row[8]] ?? 0) + 1;
}
}
$rows[] = $row;
}
if ($rows === []) {
fail('no input rows');
}
foreach ($nameIndexes as $generalName => $indexes) {
if (count($indexes) === 1) {
continue;
}
foreach ($indexes as $index) {
$rows[$index][0] .= "#{$rows[$index][10]}";
if (mb_strlen($rows[$index][0]) > 32) {
fail("disambiguated name exceeds 32 characters: {$rows[$index][0]}");
}
}
}
ksort($phaseCounts, SORT_NUMERIC);
$expectedPhases = array_values(array_diff(range(1, 99), EXCLUDED_EVENT_PHASES));
if (array_keys($phaseCounts) !== $expectedPhases) {
fail('input does not cover every non-event phase from 1 through 99');
}
foreach ($expectedPhases as $phase) {
if (($chiefCounts[$phase] ?? 0) !== 8) {
fail("phase {$phase}: expected 8 unification chiefs including the ruler");
}
}
$payload = [
'excludedEventPhases' => EXCLUDED_EVENT_PHASES,
'columns' => OUTPUT_COLUMNS,
'data' => $rows,
];
$encoded = json_encode(
$payload,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . "\n";
$outputDir = dirname($outputPath);
if (!is_dir($outputDir)) {
fail("output directory does not exist: {$outputDir}");
}
$tempPath = tempnam($outputDir, basename($outputPath) . '.tmp.');
if ($tempPath === false) {
fail("could not create temporary output in {$outputDir}");
}
if (file_put_contents($tempPath, $encoded) === false || !rename($tempPath, $outputPath)) {
@unlink($tempPath);
fail("could not write output: {$outputPath}");
}
fwrite(
STDERR,
sprintf(
"wrote %d candidates across phases %d-%d (%d hall reasons, %d chief reasons)\n",
count($rows),
min(array_keys($phaseCounts)),
max(array_keys($phaseCounts)),
$reasonCounts['hall'] ?? 0,
$reasonCounts['chief'] ?? 0,
)
);
+131
View File
@@ -0,0 +1,131 @@
WITH
phases AS (
SELECT
e.no AS phase_no,
e.server_id,
g.winner_nation,
e.l12name,
e.l12pic,
e.l11name,
e.l11pic,
e.l10name,
e.l10pic,
e.l9name,
e.l9pic,
e.l8name,
e.l8pic,
e.l7name,
e.l7pic,
e.l6name,
e.l6pic,
e.l5name,
e.l5pic
FROM emperior e
LEFT JOIN ng_games g ON g.server_id = e.server_id
WHERE e.no BETWEEN 1 AND 99
AND MOD(e.no, 5) <> 0
),
hall_eligible AS (
SELECT
p.phase_no,
h.server_id,
h.general_no,
h.type,
h.value,
h.id
FROM phases p
JOIN hall h ON h.server_id = p.server_id
JOIN ng_old_generals og
ON og.server_id = h.server_id
AND og.general_no = h.general_no
WHERE h.general_no > 2
AND CAST(COALESCE(JSON_VALUE(og.data, '$.npc'), 0) AS SIGNED) = 0
),
hall_ranked AS (
SELECT
phase_no,
server_id,
general_no,
type,
ROW_NUMBER() OVER (
PARTITION BY server_id, type
ORDER BY value DESC, id ASC
) AS hall_rank
FROM hall_eligible
),
selection_reasons AS (
SELECT
phase_no,
server_id,
general_no,
CONCAT('hall:', type) AS reason
FROM hall_ranked
WHERE hall_rank <= 10
),
chief_slots AS (
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases
UNION ALL
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases
),
chief_reasons AS (
SELECT
c.phase_no,
c.server_id,
og.general_no,
CONCAT('chief:', c.officer_level) AS reason
FROM chief_slots c
JOIN ng_old_generals og
ON og.server_id = c.server_id
AND og.name = c.name
AND SUBSTRING_INDEX(
COALESCE(JSON_VALUE(og.data, '$.picture'), ''),
'?=',
1
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
AND (
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
OR (
JSON_VALUE(og.data, '$.officer_level') IS NULL
AND (
c.winner_nation IS NULL
OR CAST(JSON_VALUE(og.data, '$.nation') AS SIGNED) = c.winner_nation
)
)
)
WHERE og.general_no > 2
),
all_reasons AS (
SELECT phase_no, server_id, general_no, reason FROM selection_reasons
UNION ALL
SELECT phase_no, server_id, general_no, reason FROM chief_reasons
)
SELECT
r.phase_no,
r.server_id,
r.general_no,
og.name,
og.data,
GROUP_CONCAT(DISTINCT r.reason ORDER BY r.reason SEPARATOR ',') AS reasons
FROM all_reasons r
JOIN ng_old_generals og
ON og.server_id = r.server_id
AND og.general_no = r.general_no
GROUP BY
r.phase_no,
r.server_id,
r.general_no,
og.name,
og.data
ORDER BY r.phase_no, r.general_no;
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace sammo;
final class CentennialAllStarGrowth
{
public const DEFAULT_GROWTH_YEARS = 15;
public static function progress(
int $startYear,
int $year,
int $month,
int $growthYears = self::DEFAULT_GROWTH_YEARS
): float {
if ($growthYears <= 0) {
throw new \InvalidArgumentException('growthYears must be positive');
}
if ($month < 1 || $month > 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));
}
}
+874
View File
@@ -0,0 +1,874 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\CentennialAllStarGrowth;
use sammo\CentennialAllStarGrowthService;
use sammo\General;
use sammo\GameConst;
$loader = require __DIR__ . '/../vendor/autoload.php';
$loader->addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true);
require_once __DIR__ . '/../hwe/sammo/ActionLogger.php';
require_once __DIR__ . '/../hwe/sammo/GameConstBase.php';
require_once __DIR__ . '/../hwe/d_setting/GameConst.php';
require_once __DIR__ . '/../hwe/sammo/CentennialAllStarGrowthService.php';
final class CentennialAllStarGrowthTest extends TestCase
{
public function testProgressMilestones(): void
{
self::assertSame(0.0, CentennialAllStarGrowth::progress(180, 180, 1));
self::assertEqualsWithDelta(0.2, CentennialAllStarGrowth::progress(180, 183, 1), 0.000001);
self::assertEqualsWithDelta(0.4, CentennialAllStarGrowth::progress(180, 186, 1), 0.000001);
self::assertSame(1.0, CentennialAllStarGrowth::progress(180, 195, 1));
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(
CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER,
CentennialAllStarGrowthService::progressMultiplierForNPCType(3)
);
self::assertSame(
CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER,
CentennialAllStarGrowthService::progressMultiplierForNPCType(4)
);
self::assertSame(1.0, CentennialAllStarGrowthService::progressMultiplierForNPCType(2));
self::assertEqualsWithDelta(
0.36,
CentennialAllStarGrowthService::calculateProgress(180, 186, 1, 0.9),
0.000001
);
self::assertEqualsWithDelta(
0.9,
CentennialAllStarGrowthService::calculateProgress(180, 195, 1, 0.9),
0.000001
);
self::assertEqualsWithDelta(
0.9,
CentennialAllStarGrowthService::calculateProgress(180, 210, 1, 0.9),
0.000001
);
self::assertSame(
91,
CentennialAllStarGrowth::statFloor(100, 15, 0.9)
);
self::assertSame(
0.4,
CentennialAllStarGrowthService::dexTargetRatioForNPCType(3)
);
self::assertSame(
0.4,
CentennialAllStarGrowthService::dexTargetRatioForNPCType(4)
);
self::assertSame(
1.0,
CentennialAllStarGrowthService::dexTargetRatioForNPCType(2)
);
}
public function testProgressMultiplierMustStayWithinUnitInterval(): void
{
$this->expectException(InvalidArgumentException::class);
CentennialAllStarGrowthService::calculateProgress(180, 195, 1, 1.01);
}
public function testLowStatGrowsWhileHigherStatStays(): void
{
$floor = CentennialAllStarGrowth::statFloor(90, 15, 0.6);
self::assertSame(60, $floor);
self::assertSame(
['value' => 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 testUserInitialStatsPreserveCandidateShapeAtOrdinaryTotal(): void
{
$initial = CentennialAllStarGrowthService::calculateUserInitialStats([
'leadership' => 80,
'strength' => 70,
'intel' => 50,
]);
self::assertSame([
'leadership' => 65,
'strength' => 58,
'intel' => 42,
], $initial);
self::assertSame(165, array_sum($initial));
self::assertLessThanOrEqual(80, $initial['leadership']);
self::assertLessThanOrEqual(70, $initial['strength']);
self::assertLessThanOrEqual(50, $initial['intel']);
}
public function testUserInitialStatsDoNotRaiseCandidateBelowOrdinaryTotal(): void
{
self::assertSame([
'leadership' => 60,
'strength' => 45,
'intel' => 30,
], CentennialAllStarGrowthService::calculateUserInitialStats([
'leadership' => 60,
'strength' => 45,
'intel' => 30,
]));
}
public function testCurrentReselectionBaselineUsesCurrentYearProgress(): void
{
$target = [
'leadership' => 100,
'strength' => 15,
'intel' => 100,
];
self::assertSame([
'leadership' => 75,
'strength' => 15,
'intel' => 75,
], CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
$target,
['startyear' => 180, 'year' => 180, 'month' => 1]
));
self::assertSame([
'leadership' => 83,
'strength' => 15,
'intel' => 83,
], CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
$target,
['startyear' => 180, 'year' => 192, 'month' => 1]
));
self::assertSame([
'leadership' => 100,
'strength' => 15,
'intel' => 100,
], CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
$target,
['startyear' => 180, 'year' => 195, 'month' => 1]
));
}
public function testInitialUserGrantMakesInitialAllocationReplaceable(): void
{
$initial = CentennialAllStarGrowthService::calculateUserInitialStats([
'uniqueName' => 'A1000001',
'leadership' => 80,
'strength' => 70,
'intel' => 50,
]);
$aux = CentennialAllStarGrowthService::initialAux([
'uniqueName' => 'A1000001',
], $initial);
self::assertSame('A1000001', $aux['targetId']);
self::assertSame($initial, $aux['userInitialStats']);
self::assertSame(1.0, $aux['dexTargetRatio']);
self::assertSame(50, $aux['granted']['leadership']);
self::assertSame(43, $aux['granted']['strength']);
self::assertSame(27, $aux['granted']['intel']);
}
public function testLegacyInitialGrantKeepsOnlyGrowthBeyondCreationRange(): void
{
self::assertSame(
35,
CentennialAllStarGrowthService::calculateLegacyUserGrant(50, 0)
);
self::assertSame(
75,
CentennialAllStarGrowthService::calculateLegacyUserGrant(90, 40)
);
self::assertSame(
105,
CentennialAllStarGrowthService::calculateLegacyUserGrant(140, 40)
);
self::assertSame(
35,
140 - CentennialAllStarGrowthService::calculateLegacyUserGrant(140, 40)
);
}
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 testNpcDexStopsAtFortyPercentOfHistoricalTarget(): void
{
$target = 900000;
$ratio = GameConst::$centennialNpcDexTargetRatio;
self::assertSame(0.4, $ratio);
self::assertSame(
57600,
CentennialAllStarGrowthService::calculateDexTargetFloor(
$target,
['startyear' => 180, 'year' => 186, 'month' => 1],
$ratio
)
);
self::assertSame(
360000,
CentennialAllStarGrowthService::calculateDexTargetFloor(
$target,
['startyear' => 180, 'year' => 195, 'month' => 1],
$ratio
)
);
self::assertSame(
360000,
CentennialAllStarGrowthService::calculateDexTargetFloor(
$target,
['startyear' => 180, 'year' => 210, 'month' => 1],
$ratio
)
);
}
public function testScenarioConfigKeepsNpcDexTargetRatioAtFortyPercent(): void
{
$scenario = json_decode(
file_get_contents(__DIR__ . '/../hwe/scenario/scenario_915.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame(
GameConst::$centennialNpcDexTargetRatio,
$scenario['map']['centennialNpcDexTargetRatio']
);
}
public function testNpcDexRatioChangeRemovesOnlyOldEventGrant(): void
{
self::assertSame(
['value' => 360000, 'granted' => 360000, 'organic' => 0],
CentennialAllStarGrowth::replaceTarget(810000, 810000, 360000)
);
self::assertSame(
['value' => 360000, 'granted' => 310000, 'organic' => 50000],
CentennialAllStarGrowth::replaceTarget(860000, 810000, 360000)
);
self::assertSame(
['value' => 400000, 'granted' => 0, 'organic' => 400000],
CentennialAllStarGrowth::replaceTarget(1210000, 810000, 360000)
);
}
public function testExistingNpcDexGrantIsRebasedWithoutChangingFinalStats(): void
{
$vars = [
'leadership' => 91,
'strength' => 91,
'intel' => 91,
'dex1' => 810000,
'dex2' => 810000,
'dex3' => 810000,
'dex4' => 810000,
'dex5' => 810000,
'special' => 'None',
];
$aux = [
'targetId' => 'A1000001',
'granted' => [
'leadership' => 76,
'strength' => 76,
'intel' => 76,
'dex1' => 810000,
'dex2' => 810000,
'dex3' => 810000,
'dex4' => 810000,
'dex5' => 810000,
],
'progressMonth' => 162,
'milestone' => 4,
'naturalSpecialDomestic' => null,
'eventSpecialDomestic' => null,
'userInitialStats' => null,
];
$general = $this->getMockBuilder(General::class)
->disableOriginalConstructor()
->onlyMethods(['getAuxVar', 'setAuxVar', 'getVar', 'updateVar'])
->getMock();
$general->method('getAuxVar')->willReturnCallback(
static fn(string $key) => $key === CentennialAllStarGrowthService::AUX_KEY
? $aux
: null
);
$general->method('getVar')->willReturnCallback(
static fn(string $key) => $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;
}
}
);
$result = CentennialAllStarGrowthService::applyTarget(
$general,
[
'uniqueName' => 'A1000001',
'leadership' => 100,
'strength' => 100,
'intel' => 100,
'dex' => [900000, 900000, 900000, 900000, 900000],
],
['startyear' => 180, 'year' => 195, 'month' => 1],
CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER,
GameConst::$centennialNpcDexTargetRatio
);
self::assertTrue($result['changed']);
self::assertSame(91, $vars['leadership']);
self::assertSame(91, $vars['strength']);
self::assertSame(91, $vars['intel']);
foreach (['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as $key) {
self::assertSame(360000, $vars[$key]);
self::assertSame(360000, $aux['granted'][$key]);
}
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 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 = [
'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(
900000,
CentennialAllStarGrowthService::calculateDexTargetFloor(
900000,
['startyear' => 180, 'year' => 195, 'month' => 1]
)
);
}
public function testUntrackedGeneralKeepsFullHallValue(): void
{
self::assertSame(
123456,
CentennialAllStarGrowthService::recordableRawValue(
['dex1' => 123456, 'aux' => '{}'],
'dex1'
)
);
}
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;
}
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']
);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\GameConst;
use sammo\GeneralPool\SPoolUnderU100;
require_once __DIR__ . '/../hwe/sammo/AbsGeneralPool.php';
require_once __DIR__ . '/../hwe/sammo/AbsFromUserPool.php';
require_once __DIR__ . '/../hwe/sammo/GeneralPool/SPoolUnderU100.php';
require_once __DIR__ . '/../hwe/sammo/ActionLogger.php';
require_once __DIR__ . '/../hwe/sammo/GameConstBase.php';
require_once __DIR__ . '/../hwe/d_setting/GameConst.php';
final class CentennialAllStarPoolTest extends TestCase
{
private const POOL_PATH = __DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json';
public function testPoolCoversEveryCompletedNonEventPhase(): void
{
$pool = json_decode(
file_get_contents(self::POOL_PATH),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame(
[
'generalName',
'leadership',
'strength',
'intel',
'specialDomestic',
'dex',
'imgsvr',
'picture',
'sourcePhase',
'sourceServerId',
'sourceGeneralNo',
'selectionReasons',
],
$pool['columns']
);
self::assertCount(4682, $pool['data']);
self::assertSame(
[
5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
55, 60, 65, 70, 75, 80, 85, 90, 95,
],
$pool['excludedEventPhases']
);
$column = array_flip($pool['columns']);
$phases = [];
$chiefCounts = [];
$sourceKeys = [];
$generalNames = [];
foreach ($pool['data'] as $row) {
self::assertCount(count($pool['columns']), $row);
$phase = $row[$column['sourcePhase']];
$sourceKey = "{$phase}:{$row[$column['sourceGeneralNo']]}";
self::assertArrayNotHasKey($sourceKey, $sourceKeys);
$sourceKeys[$sourceKey] = true;
$phases[$phase] = true;
$generalName = $row[$column['generalName']];
self::assertArrayNotHasKey($generalName, $generalNames);
$generalNames[$generalName] = true;
self::assertMatchesRegularExpression('/^(\d{1,2})·.+$/u', $generalName);
self::assertSame(
1,
preg_match('/^(\d{1,2})·/u', $generalName, $nameMatch)
);
self::assertSame($phase, (int) $nameMatch[1]);
self::assertLessThanOrEqual(32, mb_strlen($generalName));
self::assertCount(5, $row[$column['dex']]);
self::assertNotEmpty($row[$column['selectionReasons']]);
foreach ($row[$column['selectionReasons']] as $reason) {
self::assertMatchesRegularExpression(
'/^(hall:[a-z0-9_]+|chief:(?:5|6|7|8|9|10|11|12))$/',
$reason
);
if (str_starts_with($reason, 'chief:')) {
$chiefCounts[$phase] = ($chiefCounts[$phase] ?? 0) + 1;
}
}
}
ksort($phases, SORT_NUMERIC);
$expectedPhases = array_values(array_diff(
range(1, 99),
$pool['excludedEventPhases']
));
self::assertSame($expectedPhases, array_keys($phases));
foreach ($expectedPhases as $phase) {
self::assertSame(
8,
$chiefCounts[$phase] ?? 0,
"phase {$phase} must include the ruler and seven chiefs"
);
}
}
public function testCentennialSelectionWeightFavorsUserStatsAndKeepsZeroDexEligible(): void
{
$method = new ReflectionMethod(SPoolUnderU100::class, 'getCandidateWeight');
$method->setAccessible(true);
$low = [
'leadership' => 80,
'strength' => 70,
'intel' => 10,
'dex' => [0, 0, 0, 0, 0],
];
$high = [
'leadership' => 95,
'strength' => 85,
'intel' => 10,
'dex' => [0, 0, 0, 0, 0],
];
self::assertSame(100000, $method->invoke(null, $low, 0));
self::assertSame(100000.0, $method->invoke(null, $low, 1));
self::assertSame(150000.0, $method->invoke(null, $high, 1));
self::assertSame(100000, $method->invoke(null, $high, 0));
}
public function testEveryHistoricalEventSpecialHasAnImplementation(): void
{
$pool = json_decode(
file_get_contents(self::POOL_PATH),
true,
512,
JSON_THROW_ON_ERROR
);
$column = array_flip($pool['columns']);
foreach ($pool['data'] as $row) {
$special = $row[$column['specialDomestic']];
if ($special === null) {
continue;
}
self::assertFileExists(
__DIR__ . "/../hwe/sammo/ActionSpecialDomestic/{$special}.php"
);
}
}
public function testEveryHistoricalEventSpecialIsExposedToTheFrontend(): void
{
$pool = json_decode(
file_get_contents(self::POOL_PATH),
true,
512,
JSON_THROW_ON_ERROR
);
$column = array_flip($pool['columns']);
$registeredSpecials = array_flip(array_merge(
GameConst::$availableSpecialDomestic,
GameConst::$optionalSpecialDomestic
));
foreach ($pool['data'] as $row) {
$special = $row[$column['specialDomestic']];
if ($special === null || $special === '') {
continue;
}
self::assertArrayHasKey(
$special,
$registeredSpecials,
"{$special} must be included in GetConst iActionInfo.specialDomestic"
);
}
}
}
@@ -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}`);