Compare commits

..
31 changed files with 7334 additions and 31788 deletions
+22 -8
View File
@@ -22,8 +22,6 @@ $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env');
$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']);
increaseRefresh("내정보", 1);
$me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog);
$myset = $me->getVar('myset');
@@ -41,10 +39,26 @@ $lastRefresh = $db->queryFirstField(
$generalID
);
$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart);
if ($gameStor->turntime <= $gameStor->opentime) {
//서버 가오픈시 할 수 있는 행동
if ($me->getNPCType() == 0 && $me->getNationID() == 0) {
$nextChange = $me->getAuxVar('next_change');
if (!is_string($nextChange) || $nextChange === '') {
$nextChange = null;
}
increaseRefresh("내정보", 1);
if ($gameStor->turntime <= $gameStor->opentime) {
$targetTime = $me->getAuxVar('prestart_delete_after');
if (!is_string($targetTime) || $targetTime === '') {
$targetTime = addTurn(
$lastRefresh ?: TimeUtil::now(),
$gameStor->turnterm,
GameConst::$minTurnDieOnPrestart
);
$me->setAuxVar('prestart_delete_after', $targetTime);
$me->applyDB($db);
}
//서버 가오픈시 할 수 있는 행동
if ($me->getNPCType() == 0 && $me->getNationID() == 0) {
$showDieOnPrestartBtn = true;
if ($targetTime <= TimeUtil::now()) {
$availableDieOnPrestart = true;
@@ -174,7 +188,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
<?php endif; ?>
<?php if ($gameStor->npcmode == 2 && $me->getNPCType() == 0) : ?>
다른 장수 선택 (<?= substr($me->getAuxVar('next_change') ?? TimeUtil::now(), 0, 19) ?> 부터)<br>
다른 장수 선택 (<?= $nextChange ? substr($nextChange, 0, 19) : '지금' ?>부터)<br>
<a href="select_general_from_pool.php" id='select_general_from_pool'><button type="button" style=background-color:<?= GameConst::$basecolor2 ?>;color:white;width:160px;height:30px;font-size:14px;>다른 장수 선택</button></a><br><br>
<?php endif; ?>
@@ -272,4 +286,4 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
</div>
</body>
</html>
</html>
+17 -1
View File
@@ -51,6 +51,22 @@
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 {
display: none;
}
@@ -61,4 +77,4 @@
.custom_stat {
display: none;
}
}
+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)){
$class = buildGeneralSpecialDomesticClass($info['specialDomestic']);
$info['specialDomesticName'] = $class->getName();
@@ -34,7 +50,8 @@ $now = $oNow->format('Y-m-d H:i:s');
$db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env');
$npcmode = $gameStor->getValue('npcmode');
$eventEnv = $gameStor->getValues(['npcmode', 'startyear', 'year', 'month']);
$npcmode = $eventEnv['npcmode'];
if($npcmode!=2){
Json::die([
'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){
$generalAux = Json::decode($rawGeneral['aux']);
if(key_exists('next_change', $generalAux)&& $generalAux['next_change'] > $now){
@@ -62,7 +80,7 @@ if($tokens){
foreach($tokens as $token){
$valid_until = $token['reserved_until'];
$info = Json::decode($token['info']);
putInfoText($info);
putInfoText($info, $currentTargetEnv);
$info['uniqueName'] = $token['unique_name'];
$pick[] = $info;
}
@@ -83,7 +101,7 @@ $valid_until = null;
foreach(pickGeneralFromPool($db, $rng, $userID, 14) as $pickObj){
$valid_until = $pickObj->getValidUntil();
$info = $pickObj->getInfo();
putInfoText($info);
putInfoText($info, $currentTargetEnv);
$pick[] = $info;
}
sortTokens($pick);//좀 무식하지만..
@@ -91,4 +109,4 @@ Json::die([
'result'=>true,
'pick'=>$pick,
'validUntil'=>$valid_until
]);
]);
+96 -42
View File
@@ -6,21 +6,22 @@ include "func.php";
WebUtil::requireAJAX();
$pick = Util::getPost('pick');
$leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin);
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
$strength = Util::getPost(
$isCentennialAllStar ? 'strength' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$intel = Util::getPost(
$isCentennialAllStar ? 'intel' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$pick = Util::getPost('pick');
$leadership = Util::getPost('leadership', 'int', GameConst::$defaultStatMin);
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
$strength = Util::getPost(
$isCentennialAllStar ? 'strength' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$intel = Util::getPost(
$isCentennialAllStar ? 'intel' : 'leadership',
'int',
GameConst::$defaultStatMin
);
$personal = Util::getPost('personal', 'string', null);
$use_own_picture = Util::getPost('use_own_picture', 'bool', false);
$pictureSource = Util::getPost('picture_source', 'string', 'selected');
if(!$pick){
@@ -69,16 +70,39 @@ if(!$selectInfo){
}
$selectInfo = Json::decode($selectInfo);
$ownerInfo = RootDB::db()->queryFirstRow('SELECT `name`,`picture`,`imgsvr` FROM member WHERE `NO`=%i',$userID);
if(!$ownerInfo){
$ownerInfo = RootDB::db()->queryFirstRow(
'SELECT `name`,`picture`,`imgsvr`,`grade` FROM member WHERE `NO`=%i',
$userID
);
if(!$ownerInfo){
Json::die([
'result'=>false,
'reason'=>'멤버 정보를 가져오지 못했습니다.'
]);
}
$gencount = $db->queryFirstField('SELECT count(`no`) FROM general WHERE npc<2');
]);
}
if ($isCentennialAllStar) {
if (!in_array($pictureSource, ['selected', 'own'], true)) {
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) {
Json::die([
@@ -87,24 +111,27 @@ if ($gencount >= $maxgeneral) {
]);
}
$poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool);
/** @var AbsGeneralPool */
if ($isCentennialAllStar) {
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'selectPickedGeneral',
$userID,
$pick
)));
$pickedGeneral = new $poolClass($db, $rng, $selectInfo, $now);
} else {
$pickedGeneral = new $poolClass($db, $selectInfo, $now);
}
$poolClass = getGeneralPoolClass(GameConst::$targetGeneralPool);
/** @var AbsGeneralPool */
if ($isCentennialAllStar) {
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'selectPickedGeneral',
$userID,
$pick
)));
$pickedGeneral = new $poolClass($db, $rng, $selectInfo, $now);
} else {
$pickedGeneral = new $poolClass($db, $selectInfo, $now);
}
$builder = $pickedGeneral->getGeneralBuilder();
if ($isCentennialAllStar) {
CentennialAllStarGrowthService::prepareInitialUser($builder, $selectInfo);
}
foreach(GameConst::$generalPoolAllowOption as $allowOption){
if($allowOption == 'stat'){
if($allowOption == 'stat' && !$isCentennialAllStar){
$leadership = Util::valueFit($leadership, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
$strength = Util::valueFit($strength, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
$intel = Util::valueFit($intel, GameConst::$defaultStatMin, GameConst::$defaultStatMax);
@@ -117,17 +144,23 @@ foreach(GameConst::$generalPoolAllowOption as $allowOption){
}
$builder->setStat($leadership, $strength, $intel);
}
else if($allowOption == 'picture' && $use_own_picture){
$builder->setPicture($ownerInfo['imgsvr'], $ownerInfo['picture']);
else if(
$allowOption == 'picture'
&& (
(!$isCentennialAllStar && $use_own_picture)
|| ($isCentennialAllStar && $pictureSource === 'own')
)
){
$builder->setPicture($ownerInfo['imgsvr'], $ownerInfo['picture']);
}
else if($allowOption == 'ego'){
if(!$personal || $personal == 'Random'){
$personal = Util::choiceRandom(GameConst::$availablePersonality);
}
$invalidPersonal = $isCentennialAllStar
? !in_array($personal, GameConst::$availablePersonality, true)
: !array_search($personal, GameConst::$availablePersonality);
if($invalidPersonal){
$invalidPersonal = $isCentennialAllStar
? !in_array($personal, GameConst::$availablePersonality, true)
: !array_search($personal, GameConst::$availablePersonality);
if($invalidPersonal){
Json::die([
'result'=>false,
'reason'=>'올바르지 않은 성격입니다.'
@@ -135,7 +168,7 @@ foreach(GameConst::$generalPoolAllowOption as $allowOption){
}
$builder->setEgo($personal);
}
}
}
$userNick = $ownerInfo['name'];
@@ -144,7 +177,28 @@ $builder->setOwnerName($userNick);
$builder->setKillturn(5);
$builder->setNPCType(0);
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm']));
$builder->setAuxVar(
'prestart_delete_after',
addTurn($now, $env['turnterm'], GameConst::$minTurnDieOnPrestart)
);
$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);
$generalID = $builder->getGeneralID();
if(!$generalID){
@@ -189,4 +243,4 @@ $rootDB->insert('member_log', [
Json::die([
'result'=>true,
'reason'=>'success'
]);
]);
+82 -40
View File
@@ -7,6 +7,7 @@ include "func.php";
WebUtil::requireAJAX();
$pick = Util::getPost('pick');
$pictureSource = Util::getPost('picture_source', 'string', 'selected');
if(!$pick){
Json::die([
@@ -33,13 +34,22 @@ if(!$generalID){
}
list(
$year,
$month,
$startYear,
$maxgeneral,
$npcmode,
$turnterm
) = $gameStor->getValuesAsArray(['year', 'month', 'startyear', 'maxgeneral', 'npcmode', 'turnterm']);
$year,
$month,
$startYear,
$maxgeneral,
$npcmode,
$turnterm,
$showImgLevel
) = $gameStor->getValuesAsArray([
'year',
'month',
'startyear',
'maxgeneral',
'npcmode',
'turnterm',
'show_img_level',
]);
if($npcmode!=2){
Json::die([
@@ -56,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){
Json::die([
'result'=>false,
@@ -65,6 +78,27 @@ if(!$ownerInfo){
}
$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);
@@ -102,37 +136,45 @@ $db->update('select_pool',[
'reserved_until'=>null,
], '(owner=%i or reserved_until < %s) AND general_id is NULL', $userID, $now);
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
if ($isCentennialAllStar) {
CentennialAllStarGrowthService::applyTarget($generalObj, $info, [
'startyear' => $startYear,
'year' => $year,
'month' => $month,
]);
} else {
if(key_exists('leadership', $info)){
$generalObj->updateVar('leadership', $info['leadership']);
$generalObj->updateVar('strength', $info['strength']);
$generalObj->updateVar('intel', $info['intel']);
}
if(key_exists('dex', $info)){
$generalObj->updateVar('dex1', $info['dex'][0]);
$generalObj->updateVar('dex2', $info['dex'][1]);
$generalObj->updateVar('dex3', $info['dex'][2]);
$generalObj->updateVar('dex4', $info['dex'][3]);
$generalObj->updateVar('dex5', $info['dex'][4]);
}
if(key_exists('ego', $info)){
$generalObj->updateVar('personal', $info['ego']);
}
if(key_exists('specialDomestic', $info)){
$generalObj->updateVar('special', $info['specialDomestic']);
}
if(key_exists('specialWar', $info)){
$generalObj->updateVar('special2', $info['specialWar']);
}
}
if(key_exists('picture', $info)){
if ($isCentennialAllStar) {
CentennialAllStarGrowthService::prepareLegacyUserReselection($generalObj);
CentennialAllStarGrowthService::applyTarget($generalObj, $info, [
'startyear' => $startYear,
'year' => $year,
'month' => $month,
]);
} else {
if(key_exists('leadership', $info)){
$generalObj->updateVar('leadership', $info['leadership']);
$generalObj->updateVar('strength', $info['strength']);
$generalObj->updateVar('intel', $info['intel']);
}
if(key_exists('dex', $info)){
$generalObj->updateVar('dex1', $info['dex'][0]);
$generalObj->updateVar('dex2', $info['dex'][1]);
$generalObj->updateVar('dex3', $info['dex'][2]);
$generalObj->updateVar('dex4', $info['dex'][3]);
$generalObj->updateVar('dex5', $info['dex'][4]);
}
if(key_exists('ego', $info)){
$generalObj->updateVar('personal', $info['ego']);
}
if(key_exists('specialDomestic', $info)){
$generalObj->updateVar('special', $info['specialDomestic']);
}
if(key_exists('specialWar', $info)){
$generalObj->updateVar('special2', $info['specialWar']);
}
}
if ($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('picture', $info['picture']);
}
@@ -158,4 +200,4 @@ $generalObj->applyDB($db);
Json::die([
'result'=>true,
'reason'=>'success'
]);
]);
+18 -8
View File
@@ -39,6 +39,10 @@ class DieOnPrestart extends \sammo\BaseAPI
$gameStor->cacheValues(['turnterm', 'opentime', 'turntime', 'year', 'month']);
$general = $db->queryFirstRow('SELECT no,name,nation,owner_name,npc FROM general WHERE owner=%i AND npc = 0', $userID);
if (!$general) {
return '장수가 없습니다';
}
$lastRefresh = $db->queryFirstField(
'SELECT %b FROM general_access_log WHERE %b = %i',
GeneralAccessLogColumn::lastRefresh->value,
@@ -46,8 +50,9 @@ class DieOnPrestart extends \sammo\BaseAPI
$general['no']
);
if (!$general) {
return '장수가 없습니다';
$generalObj = General::createObjFromDB($general['no']);
if ($generalObj instanceof DummyGeneral) {
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
}
increaseRefresh("장수 삭제", 1);
@@ -61,18 +66,23 @@ class DieOnPrestart extends \sammo\BaseAPI
return '이미 국가에 소속되어있습니다.';
}
$targetTime = $generalObj->getAuxVar('prestart_delete_after');
if (!is_string($targetTime) || $targetTime === '') {
$targetTime = addTurn(
$lastRefresh ?: TimeUtil::now(),
$gameStor->turnterm,
GameConst::$minTurnDieOnPrestart
);
$generalObj->setAuxVar('prestart_delete_after', $targetTime);
$generalObj->applyDB($db);
}
//서버 가오픈시 할 수 있는 행동
$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart);
if ($targetTime > TimeUtil::now()) {
$targetTimeShort = substr($targetTime, 0, 19);
return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다.";
}
$generalObj = General::createObjFromDB($general['no']);
if ($generalObj instanceof DummyGeneral) {
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
}
$generalName = $generalObj->getName();
$josaYi = JosaUtil::pick($generalName, '이');
$generalObj->kill($db, true, "<Y>{$generalName}</>{$josaYi} 홀연히 모습을 <R>감추었습니다</>");
+7
View File
@@ -435,6 +435,13 @@ class Join extends \sammo\BaseAPI
'specage2' => $specage2,
'special2' => $special2,
'penalty' => Json::encode($penalty),
'aux' => Json::encode([
'prestart_delete_after' => addTurn(
$now,
$admin['turnterm'],
GameConst::$minTurnDieOnPrestart
),
]),
]);
$generalID = $db->insertId();
$db->insert('general_access_log', [
@@ -4,6 +4,7 @@ namespace sammo\API\InheritAction;
use sammo\Session;
use DateTimeInterface;
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\Enums\APIRecoveryType;
use sammo\Enums\RankColumn;
@@ -98,6 +99,10 @@ class ResetStat extends \sammo\BaseAPI
return 'NPC는 능력치 초기화를 할 수 없습니다.';
}
if (!CentennialAllStarGrowthService::isStatResetAllowed()) {
return '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.';
}
$db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env');
+7 -3
View File
@@ -8,6 +8,11 @@ use sammo\Util;
abstract class AbsFromUserPool extends AbsGeneralPool{
protected static function getCandidateWeight(array $info, int $owner): int|float
{
return array_sum($info['dex'] ?? []);
}
public function occupyGeneralName(): bool
{
$generalID = $this->getGeneralBuilder()->getGeneralID();
@@ -36,8 +41,7 @@ abstract class AbsFromUserPool extends AbsGeneralPool{
$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){
$cand['info'] = Json::decode($cand['info']);
$dexTotal = array_sum($cand['info']['dex']);
$pool[] = [$cand, $dexTotal];
$pool[] = [$cand, static::getCandidateWeight($cand['info'], $owner)];
}
if(count($pool) < $pickCnt){
@@ -69,4 +73,4 @@ abstract class AbsFromUserPool extends AbsGeneralPool{
return array_values($result);
}
}
}
+469 -9
View File
@@ -9,6 +9,7 @@ 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'];
@@ -18,15 +19,32 @@ final class CentennialAllStarGrowthService
return GameConst::$targetGeneralPool === self::POOL_CLASS;
}
public static function initialAux(array $targetInfo): array
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' => array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0),
'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,
];
}
@@ -35,18 +53,225 @@ final class CentennialAllStarGrowthService
$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): array
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 = CentennialAllStarGrowth::progress($startYear, $year, $month);
$progressMonth = max(0, ($year - $startYear) * 12 + $month - 1);
$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);
@@ -57,7 +282,27 @@ final class CentennialAllStarGrowthService
? $aux['granted']
: array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0);
$targetChanged = ($aux['targetId'] ?? '') !== $targetId;
$changed = false;
$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)) {
@@ -69,6 +314,9 @@ final class CentennialAllStarGrowthService
GameConst::$defaultStatMin,
$progress
);
if ($userCurrentTargetStats !== null) {
$floor = $userCurrentTargetStats[$key];
}
$current = (int) $general->getVar($key);
if ($targetChanged) {
$result = CentennialAllStarGrowth::replaceTarget(
@@ -95,10 +343,17 @@ final class CentennialAllStarGrowthService
if (!array_key_exists($idx, $targetDex)) {
continue;
}
$target = min(GameConst::$dexLimit, max(0, (int) $targetDex[$idx]));
$floor = CentennialAllStarGrowth::dexFloor($target, $progress);
$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) {
if ($targetChanged || $dexTargetRatioChanged) {
$result = CentennialAllStarGrowth::replaceTarget(
$current,
(int) ($granted[$key] ?? 0),
@@ -146,8 +401,12 @@ final class CentennialAllStarGrowthService
$milestone = min(5, (int) floor($progress * 5 + 0.0000001));
$aux['targetId'] = $targetId;
$aux['granted'] = $granted;
$aux['dexConsumed'] = $dexConsumed;
$aux['dexFloor'] = $dexFloor;
$aux['progressMonth'] = max((int) ($aux['progressMonth'] ?? -1), $progressMonth);
$aux['milestone'] = max($previousMilestone, $milestone);
$aux['userInitialStats'] = $nextUserInitialStats;
$aux['dexTargetRatio'] = $dexTargetRatio;
$general->setAuxVar(self::AUX_KEY, $aux);
return [
@@ -159,6 +418,207 @@ final class CentennialAllStarGrowthService
];
}
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;
}
self::initializeGeneratedNPC($general, $targetInfo);
$result = self::applyTarget(
$general,
$targetInfo,
$env,
self::NPC_PROGRESS_MULTIPLIER,
GameConst::$centennialNpcDexTargetRatio
);
$general->applyDB($db);
return $result;
}
/**
* Keeps the ordinary M/G-general stat total while aligning its strong,
* middle, and weak stats with the selected all-star target. The target's
* creation-date growth floor is applied immediately afterwards.
*/
public static function initializeGeneratedNPC(
General $general,
array $targetInfo
): void {
$generatedStats = [];
foreach (self::STAT_KEYS as $key) {
$generatedStats[$key] = (int) $general->getVar($key);
}
$initialStats = self::calculateGeneratedNPCInitialStats(
$targetInfo,
$generatedStats
);
foreach (self::STAT_KEYS as $key) {
$general->updateVar($key, $initialStats[$key]);
}
foreach (self::DEX_KEYS as $key) {
$general->updateVar($key, 0);
}
$general->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo));
}
/**
* @param array<string, mixed> $targetInfo
* @param array{leadership:int,strength:int,intel:int} $generatedStats
* @return array{leadership:int,strength:int,intel:int}
*/
public static function calculateGeneratedNPCInitialStats(
array $targetInfo,
array $generatedStats
): array {
$targetOrder = self::STAT_KEYS;
$keyOrder = array_flip(self::STAT_KEYS);
usort(
$targetOrder,
static function (string $lhs, string $rhs) use ($targetInfo, $keyOrder): int {
$targetCompare = (int) ($targetInfo[$rhs] ?? 0)
<=> (int) ($targetInfo[$lhs] ?? 0);
return $targetCompare !== 0
? $targetCompare
: $keyOrder[$lhs] <=> $keyOrder[$rhs];
}
);
$generatedValues = array_map(
static fn (string $key): int => (int) ($generatedStats[$key] ?? 0),
self::STAT_KEYS
);
rsort($generatedValues, SORT_NUMERIC);
$result = array_fill_keys(self::STAT_KEYS, 0);
foreach ($targetOrder as $idx => $key) {
$result[$key] = $generatedValues[$idx];
}
return $result;
}
/**
* Moves the event-backed part of a dex conversion with the converted
* value and consumes any guaranteed floor crossed by the source value.
* 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);
@@ -14,6 +14,7 @@ use function \sammo\tryUniqueItemLottery;
use \sammo\Constraint\ConstraintHelper;
use sammo\StaticEventHandler;
use sammo\CentennialAllStarGrowthService;
class che_숙련전환 extends Command\GeneralCommand
{
@@ -157,6 +158,7 @@ class che_숙련전환 extends Command\GeneralCommand
$logger = $general->getLogger();
$srcDex = $general->getVar('dex' . $this->srcArmType);
$destDex = $general->getVar('dex' . $this->destArmType);
$cutDex = Util::toInt($srcDex * static::$decreaseCoeff);
$cutDexText = number_format($cutDex);
$addDex = Util::toInt($cutDex * static::$convertCoeff);
@@ -164,6 +166,19 @@ class che_숙련전환 extends Command\GeneralCommand
$general->increaseVar('dex' . $this->srcArmType, -$cutDex);
$general->increaseVar('dex' . $this->destArmType, $addDex);
// 100기 이벤트 지급분을 목적 숙련으로 옮기고 소비한 성장 하한은 다시 채우지 않는다.
if (CentennialAllStarGrowthService::isActive()) {
CentennialAllStarGrowthService::reconcileDexConversion(
$general,
'dex' . $this->srcArmType,
'dex' . $this->destArmType,
$srcDex,
$general->getVar('dex' . $this->srcArmType),
$destDex,
$general->getVar('dex' . $this->destArmType),
static::$convertCoeff
);
}
$josaUl = JosaUtil::pick($cutDex, '을');
$josaRo = JosaUtil::pick($addDex, '로');
@@ -7,6 +7,7 @@ use \sammo\Util;
use \sammo\JosaUtil;
use \sammo\General;
use \sammo\ActionLogger;
use \sammo\CentennialAllStarGrowthService;
use \sammo\GameConst;
use \sammo\LastTurn;
use \sammo\GameUnitConst;
@@ -190,6 +191,12 @@ class che_인재탐색 extends Command\GeneralCommand
$newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env);
$newNPC->build($this->env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$this->env
);
$pickedNPC->occupyGeneralName();
$npcName = $newNPC->getGeneralName();
$josaRa = JosaUtil::pick($npcName, '라');
@@ -7,6 +7,7 @@ use \sammo\Util;
use \sammo\JosaUtil;
use \sammo\General;
use \sammo\ActionLogger;
use \sammo\CentennialAllStarGrowthService;
use \sammo\GameConst;
use \sammo\LastTurn;
use \sammo\GameUnitConst;
@@ -160,6 +161,12 @@ class che_의병모집 extends Command\NationCommand
$newNPC->fillRemainSpecAsRandom($pickTypeList, $avgGen, $env);
$newNPC->build($this->env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$this->env
);
$pickedNPC->occupyGeneralName();
}
@@ -25,7 +25,15 @@ class AdvanceCentennialAllStar extends \sammo\Event\Action
) as $row) {
$general = General::createObjFromDB((int) $row['no']);
$targetInfo = Json::decode($row['info']);
$result = CentennialAllStarGrowthService::applyTarget($general, $targetInfo, $env);
$result = CentennialAllStarGrowthService::applyTarget(
$general,
$targetInfo,
$env,
CentennialAllStarGrowthService::progressMultiplierFor($general),
CentennialAllStarGrowthService::dexTargetRatioForNPCType(
$general->getNPCType()
)
);
if ($result['milestone'] > $result['previousMilestone']) {
$percent = $result['milestone'] * 20;
+9 -1
View File
@@ -5,6 +5,7 @@ namespace sammo\Event\Action;
use \sammo\GameConst;
use \sammo\Util;
use \sammo\DB;
use sammo\CentennialAllStarGrowthService;
use sammo\LiteHashDRBG;
use sammo\RandUtil;
use sammo\UniqueConst;
@@ -35,7 +36,8 @@ class CreateManyNPC extends \sammo\Event\Action
)));
$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);
$birthYear = $env['year'] - $age;
$deathYear = $env['year'] + $rng->nextRangeInt(10, 50);
@@ -50,6 +52,12 @@ class CreateManyNPC extends \sammo\Event\Action
}
$newNPC->fillRemainSpecAsZero($env);
$newNPC->build($env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$newNPC,
$pickedNPC->getInfo(),
$env
);
$pickedNPC->occupyGeneralName();
$result[] = [
$newNPC->getGeneralName(), $newNPC->getGeneralID()
+6
View File
@@ -184,6 +184,10 @@ class GameConstBase
/** @var array 선택할 수 없으나 게임 내에 유효한 장수 내정 특기 */
public static $optionalSpecialDomestic = [
'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 기본 전투 특기 */
@@ -416,6 +420,8 @@ class GameConstBase
public static $retirementYear = 80;
public static $targetGeneralPool = 'RandomNameGeneral';
/** @var float 100기 올스타 NPC의 원본 목표 대비 최종 숙련 비율 */
public static $centennialNpcDexTargetRatio = 0.4;
public static $generalPoolAllowOption = ['stat', 'ego', 'picture'];
public static $randGenFirstName = [
File diff suppressed because it is too large Load Diff
+31
View File
@@ -9,6 +9,11 @@ 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;
@@ -36,6 +41,32 @@ class SPoolUnderU100 extends AbsFromUserPool
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'));
+33 -8
View File
@@ -4,13 +4,16 @@
"map": {
"mapName": "miniche",
"targetGeneralPool": "SPoolUnderU100",
"generalPoolAllowOption": ["stat", "ego", "picture"]
"generalPoolAllowOption": ["stat", "ego", "picture"],
"centennialNpcDexTargetRatio": 0.4
},
"history": [
"<C>●</>180년 1월:<L><b>【100기 이벤트】</b></> 역대 장수들이 평범한 능력으로 다시 모여, 지난 전성기의 힘과 서서히 동조하기 시작했다!"
],
"const": {
"npcBanMessageProb": 1
"npcBanMessageProb":0.005,
"defaultMaxGeneral": 800,
"uniqueTrialCoef": 2
},
"events": [
[
@@ -26,12 +29,34 @@
],
[
"month", 1000,
["Date", "==", 181, 12],
["ChangeCity", "occupied", {
"pop": "+60000",
"agri": "+1200",
"comm": "+1200"
}]
["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,
+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);
$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');
$isCentennialAllStar = CentennialAllStarGrowthService::isActive();
$canUseOwnPicture = $admin['show_img_level'] >= 1
&& $member['grade'] >= 1
&& $member['picture'] != "";
$nationList = $db->query('SELECT nation,`name`,color,scout FROM nation');
shuffle($nationList);
@@ -54,6 +62,7 @@ foreach (getCharacterList(false) as $id => [$name, $info]) {
<script>
var hasGeneralID = <?= $generalID === null ? 'false' : 'true' ?>;
var isCentennialAllStar = <?= $isCentennialAllStar ? 'true' : 'false' ?>;
var defaultStatTotal = <?= GameConst::$defaultStatTotal ?>;
var defaultStatMin = <?= GameConst::$defaultStatMin ?>;
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>
<form class="card_holder">
</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>
@@ -117,7 +147,28 @@ if ($gencount >= $admin['maxgeneral']) {
<form id='custom_form'>
<table class='tb_layout' style='width:100%;text-align:left;'>
<?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']);
echo "
<tr class='custom_picture'>
@@ -144,36 +195,42 @@ if ($gencount >= $admin['maxgeneral']) {
</select> <span id="charInfoText"></span>
</td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>통솔</td>
<td colspan=2><input type="number" name="leadership" id="leadership" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>무력</td>
<td colspan=2><input type="number" name="strength" id="strength" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>지력</td>
<td colspan=2><input type="number" name="intel" id="intel" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>능력치 조정</td>
<td colspan=2>
<input type=button value=랜덤형 onclick=abilityRand()>
<input type=button value=통솔무력형 onclick=abilityLeadpow()>
<input type=button value=통솔력형 onclick=abilityLeadint()>
<input type=button value=무력지력형 onclick=abilityPowint()>
</td>
</tr>
<tr class='custom_stat'>
<td align=center colspan=3>
<font color=orange>모든 능력치는 ( <?= GameConst::$defaultStatMin ?> <= 능력치 <=<?= GameConst::$defaultStatMax ?> ) 사이로 잡으셔야 합니다.<br>
그 외의 능력치는 가입되지 않습니다.</font>
</td>
</tr>
<?php if (!$isCentennialAllStar) : ?>
<tr class='custom_stat'>
<td align=right class='bg1'>통솔</td>
<td colspan=2><input type="number" name="leadership" id="leadership" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>무력</td>
<td colspan=2><input type="number" name="strength" id="strength" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>지력</td>
<td colspan=2><input type="number" name="intel" id="intel" value="50"></td>
</tr>
<tr class='custom_stat'>
<td align=right class='bg1'>능력치 조정</td>
<td colspan=2>
<input type=button value=랜덤형 onclick=abilityRand()>
<input type=button value=통솔력형 onclick=abilityLeadpow()>
<input type=button value=통솔지력형 onclick=abilityLeadint()>
<input type=button value=무력지력형 onclick=abilityPowint()>
</td>
</tr>
<tr class='custom_stat'>
<td align=center colspan=3>
<font color=orange>모든 능력치는 ( <?= GameConst::$defaultStatMin ?> <= 능력치 <=<?= GameConst::$defaultStatMax ?> ) 사이로 잡으셔야 합니다.<br>
그 외의 능력치는 가입되지 않습니다.</font>
</td>
</tr>
<?php endif; ?>
<tr>
<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>
</tr>
@@ -194,4 +251,4 @@ if ($gencount >= $admin['maxgeneral']) {
</div>
</body>
</html>
</html>
+15 -2
View File
@@ -172,7 +172,7 @@
<BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton>
</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="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6">
@@ -222,6 +222,17 @@
<BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton>
</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 class="row">
<div class="col">
@@ -297,6 +308,7 @@ declare const staticValues: {
}
>;
availableTargetGeneral: Record<number, string>;
canResetStat: boolean;
currentStat: {
leadership: number;
strength: number;
@@ -447,6 +459,7 @@ const {
availableSpecialWar,
availableUnique,
availableTargetGeneral,
canResetStat,
currentStat
} = staticValues;
@@ -739,4 +752,4 @@ async function getMoreLog(): Promise<void> {
.tnum {
font-feature-settings: "tnum";
}
</style>
</style>
+45 -27
View File
@@ -32,6 +32,10 @@ type CardItem = {
leadership?: number,
strength?: number,
intel?: number,
selectionStatLabel?: string,
selectionLeadership?: number,
selectionStrength?: number,
selectionIntel?: number,
dex?: number[],
}
@@ -42,7 +46,8 @@ type GeneralPoolResponse = {
}
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 const cards: Record<string, CardItem>;
declare const validCustomOption: string[];
@@ -51,9 +56,13 @@ const templateGeneralCard = '<div class="general_card">\
<h4 class="bg1 with_border"><%generalName%></h4>\
<h4><img src="<%iconPath%>" height=64 width=64></h4><p>\
<%if(event100Growth){%><b>195년 최종 동조 목표</b><br><%}%>\
<%if(leadership){%>\
<%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(specialDomesticText||specialWarText){%>\
<%specialDomesticText%> / <%specialWarText%><br>\
@@ -107,9 +116,12 @@ async function pickGeneral(this: HTMLElement, e: JQuery.Event) {
url: 'j_update_picked_general.php',
method: 'post',
responseType: 'json',
data: convertFormData({
pick: unwrap_any<string>($btn.val())
})
data: convertFormData({
pick: unwrap_any<string>($btn.val()),
picture_source: unwrap_any<string>(
$('input[name="reselect_picture_source"]:checked').val() ?? 'selected'
)
})
});
result = response.data;
if (!result.result) {
@@ -138,23 +150,25 @@ async function buildGeneral(e: JQuery.Event) {
let result: InvalidResponse;
try {
const response = await axios({
url: 'j_select_picked_general.php',
method: 'post',
responseType: 'json',
data: convertFormData({
pick: unwrap(currentGeneralInfo).uniqueName,
use_own_picture: $('#use_own_picture').is(':checked'),
leadership: parseInt(unwrap_any<string>($('#leadership').val())),
strength: parseInt(unwrap_any<string>(
$(unwrap(currentGeneralInfo).event100Growth ? '#strength' : '#leadership').val()
)),
intel: parseInt(unwrap_any<string>(
$(unwrap(currentGeneralInfo).event100Growth ? '#intel' : '#leadership').val()
)),
personal: unwrap_any<string>($('#selChar').val())
})
})
const formData: Record<string, string|number|boolean> = {
pick: unwrap(currentGeneralInfo).uniqueName,
use_own_picture: $('#use_own_picture').is(':checked'),
picture_source: unwrap_any<string>(
$('input[name="picture_source"]:checked').val() ?? 'selected'
),
personal: unwrap_any<string>($('#selChar').val())
};
if (!isCentennialAllStar) {
formData.leadership = parseInt(unwrap_any<string>($('#leadership').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;
if (!result.result) {
throw result.reason;
@@ -200,9 +214,13 @@ function printGenerals(value: GeneralPoolResponse) {
const emptyCard = {
'leadership': null,
'strength': null,
'intel': null,
'personalText': null,
'strength': null,
'intel': null,
'selectionStatLabel': null,
'selectionLeadership': null,
'selectionStrength': null,
'selectionIntel': null,
'personalText': null,
'specialDomesticText': null,
'specialWarText': null,
'dex': null
+2 -1
View File
@@ -105,6 +105,7 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
'availableUnique' => $availableUnique,
'lastInheritPointLogs' => $lastInheritPointLogs,
'availableTargetGeneral' => $availableTargetGeneral,
'canResetStat' => CentennialAllStarGrowthService::isStatResetAllowed(),
'currentStat' => [
'leadership' => Util::clamp($me->getVar('leadership'), 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>
</body>
</html>
</html>
+19 -3
View File
@@ -26,6 +26,11 @@ const OUTPUT_COLUMNS = [
'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_신산',
@@ -110,7 +115,7 @@ function decodeSourceRow(string $line, int $lineNo): array
if ($sourceName === '') {
fail("line {$lineNo}: empty general name");
}
$generalName = sprintf('%d기】%s', $phaseNo, $sourceName);
$generalName = sprintf('%d·%s', $phaseNo, $sourceName);
if (mb_strlen($generalName) > 32) {
fail("line {$lineNo}: generated name exceeds 32 characters: {$generalName}");
}
@@ -158,6 +163,7 @@ $rows = [];
$seenSources = [];
$nameIndexes = [];
$phaseCounts = [];
$chiefCounts = [];
$reasonCounts = [];
$lineNo = 0;
@@ -182,6 +188,9 @@ while (($line = fgets(STDIN)) !== false) {
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;
}
@@ -201,11 +210,18 @@ foreach ($nameIndexes as $generalName => $indexes) {
}
}
ksort($phaseCounts, SORT_NUMERIC);
if (array_keys($phaseCounts) !== range(1, 99)) {
fail('input does not cover every phase from 1 through 99');
$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,
];
+1
View File
@@ -23,6 +23,7 @@ phases AS (
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
+932
View File
@@ -3,7 +3,15 @@
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
@@ -17,6 +25,202 @@ final class CentennialAllStarGrowthTest extends TestCase
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 testGeneratedNpcKeepsOrdinaryTotalAndUsesCreationDateTargetFloor(): void
{
$vars = [
'leadership' => 72,
'strength' => 66,
'intel' => 12,
'dex1' => 120000,
'dex2' => 240000,
'dex3' => 360000,
'dex4' => 480000,
'dex5' => 600000,
'special' => 'None',
];
$target = [
'uniqueName' => 'A1000001',
'leadership' => 100,
'strength' => 80,
'intel' => 10,
'dex' => [900000, 800000, 700000, 600000, 500000],
];
$aux = CentennialAllStarGrowthService::initialAux($target);
$general = $this->createStateGeneralMock($vars, $aux);
CentennialAllStarGrowthService::initializeGeneratedNPC($general, $target);
CentennialAllStarGrowthService::applyTarget(
$general,
$target,
['startyear' => 180, 'year' => 195, 'month' => 1],
CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER,
GameConst::$centennialNpcDexTargetRatio
);
self::assertSame(91, $vars['leadership']);
self::assertSame(73, $vars['strength']);
self::assertSame(12, $vars['intel']);
self::assertSame(176, $vars['leadership'] + $vars['strength'] + $vars['intel']);
self::assertSame(
[360000, 320000, 280000, 240000, 200000],
$this->dexValues($vars)
);
self::assertSame(19, $aux['granted']['leadership']);
self::assertSame(7, $aux['granted']['strength']);
self::assertSame(0, $aux['granted']['intel']);
self::assertSame(360000, $aux['granted']['dex1']);
}
public function testFernandoAndUmaMusumeStartAtOrdinaryNpcTotal(): void
{
$pool = json_decode(
file_get_contents(__DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$columns = array_flip($pool['columns']);
$targets = [];
foreach ($pool['data'] as $row) {
$name = $row[$columns['generalName']];
if (!in_array($name, ['43·페르난도', '47·우마무스메'], true)) {
continue;
}
$targets[$name] = [
'leadership' => $row[$columns['leadership']],
'strength' => $row[$columns['strength']],
'intel' => $row[$columns['intel']],
];
}
self::assertCount(2, $targets);
$generated = [
'leadership' => 73,
'strength' => 10,
'intel' => 67,
];
$fernando = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$targets['43·페르난도'],
$generated
);
$umaMusume = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$targets['47·우마무스메'],
$generated
);
self::assertSame([
'leadership' => 67,
'strength' => 73,
'intel' => 10,
], $fernando);
self::assertSame([
'leadership' => 10,
'strength' => 67,
'intel' => 73,
], $umaMusume);
self::assertSame(GameConst::$defaultStatNPCTotal, array_sum($fernando));
self::assertSame(GameConst::$defaultStatNPCTotal, array_sum($umaMusume));
}
public function testEveryCandidateKeepsTheGeneratedNpcStatTotal(): void
{
$pool = json_decode(
file_get_contents(__DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$columns = array_flip($pool['columns']);
$generated = [
'leadership' => 73,
'strength' => 67,
'intel' => 10,
];
foreach ($pool['data'] as $row) {
$target = [
'leadership' => $row[$columns['leadership']],
'strength' => $row[$columns['strength']],
'intel' => $row[$columns['intel']],
];
$initial = CentennialAllStarGrowthService::calculateGeneratedNPCInitialStats(
$target,
$generated
);
self::assertSame(
GameConst::$defaultStatNPCTotal,
array_sum($initial),
$row[$columns['generalName']]
);
sort($initial, SORT_NUMERIC);
self::assertSame([10, 67, 73], array_values($initial));
}
}
public function testProgressMultiplierMustStayWithinUnitInterval(): void
{
$this->expectException(InvalidArgumentException::class);
CentennialAllStarGrowthService::calculateProgress(180, 195, 1, 1.01);
}
public function testLowStatGrowsWhileHigherStatStays(): void
{
$floor = CentennialAllStarGrowth::statFloor(90, 15, 0.6);
@@ -31,6 +235,112 @@ final class CentennialAllStarGrowthTest extends TestCase
);
}
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(
@@ -61,6 +371,521 @@ final class CentennialAllStarGrowthTest extends TestCase
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(
@@ -71,4 +896,111 @@ final class CentennialAllStarGrowthTest extends TestCase
)
);
}
private function createStateGeneralMock(array &$vars, array &$aux): General
{
$general = $this->getMockBuilder(General::class)
->disableOriginalConstructor()
->onlyMethods(['getAuxVar', 'setAuxVar', 'getVar', 'updateVar'])
->getMock();
$general->method('getAuxVar')->willReturnCallback(
static function (string $key) use (&$aux) {
return $key === CentennialAllStarGrowthService::AUX_KEY
? $aux
: null;
}
);
$general->method('getVar')->willReturnCallback(
static function (string $key) use (&$vars) {
return $vars[$key] ?? null;
}
);
$general->method('updateVar')->willReturnCallback(
static function (string $key, $value) use (&$vars): void {
$vars[$key] = $value;
}
);
$general->method('setAuxVar')->willReturnCallback(
static function (string $key, $value) use (&$aux): void {
if ($key === CentennialAllStarGrowthService::AUX_KEY) {
$aux = $value;
}
}
);
return $general;
}
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']
);
}
}
+91 -3
View File
@@ -1,12 +1,21 @@
<?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 testPoolCoversEveryCompletedPhase(): void
public function testPoolCoversEveryCompletedNonEventPhase(): void
{
$pool = json_decode(
file_get_contents(self::POOL_PATH),
@@ -31,10 +40,18 @@ final class CentennialAllStarPoolTest extends TestCase
],
$pool['columns']
);
self::assertCount(5757, $pool['data']);
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) {
@@ -49,6 +66,13 @@ final class CentennialAllStarPoolTest extends TestCase
$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']]);
@@ -57,11 +81,48 @@ final class CentennialAllStarPoolTest extends TestCase
'/^(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);
self::assertSame(range(1, 99), array_keys($phases));
$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
@@ -84,4 +145,31 @@ final class CentennialAllStarPoolTest extends TestCase
);
}
}
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}`);
@@ -0,0 +1,76 @@
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();
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 page = await context.newPage();
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 myPageURL = new URL('hwe/b_myPage.php', baseURL).href;
const readCooldownText = async () => {
await page.goto(myPageURL, {waitUntil: 'networkidle', timeout: 60_000});
const bodyText = await page.locator('body').innerText();
const lines = bodyText.split('\n').map(line => line.trim());
const reselect = lines.find(line => line.startsWith('다른 장수 선택 ('));
const deletion = lines.find(line => line.startsWith('가오픈 기간 내 장수 삭제 ('));
if (!reselect || !deletion) {
throw new Error(`cooldown text missing: ${bodyText.slice(0, 4000)}`);
}
return {reselect, deletion};
};
const first = await readCooldownText();
await page.waitForTimeout(1100);
const second = await readCooldownText();
await page.waitForTimeout(1100);
const third = await readCooldownText();
if (JSON.stringify(first) !== JSON.stringify(second)
|| JSON.stringify(second) !== JSON.stringify(third)
) {
throw new Error(`cooldown moved after refresh: ${JSON.stringify({first, second, third})}`);
}
await page.screenshot({path: screenshotPath, fullPage: true});
fs.writeFileSync(
resultPath,
`${JSON.stringify({
username,
url: page.url(),
viewport: {width: 1280, height: 960, deviceScaleFactor: 1},
first,
second,
third,
}, null, 2)}\n`,
{mode: 0o600},
);
await browser.close();
console.log(`S100 my-page cooldown refresh guard verified: ${screenshotPath}`);
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\GameConst;
use sammo\General;
use sammo\GeneralPool\SPoolUnderU100;
use sammo\Json;
use sammo\LiteHashDRBG;
use sammo\RandUtil;
use sammo\Util;
const APP_ROOT = '/var/www/html';
const TARGET_NAMES = ['43·페르난도', '47·우마무스메'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['REQUEST_URI'] = '/s100-generated-npc-stat-floor-check';
require APP_ROOT . '/hwe/lib.php';
require APP_ROOT . '/hwe/func.php';
$env = [
'startyear' => 180,
'year' => 180,
'month' => 12,
'fiction' => [1],
'show_img_level' => 3,
];
$pool = Json::decode((string) file_get_contents(
APP_ROOT . '/hwe/sammo/GeneralPool/Pool/UnderS100.json'
));
$columns = array_flip($pool['columns']);
$targets = [];
foreach ($pool['data'] as $idx => $row) {
$name = $row[$columns['generalName']];
if (!in_array($name, TARGET_NAMES, true)) {
continue;
}
$info = array_combine($pool['columns'], $row);
$info['uniqueName'] = sprintf('A100%04d', $idx + 1);
$info['event100Growth'] = true;
$targets[$name] = $info;
}
if (count($targets) !== count(TARGET_NAMES)) {
throw new RuntimeException('Required S100 candidates are missing');
}
$db = DB::db();
$results = [];
foreach (TARGET_NAMES as $name) {
$target = $targets[$name];
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
's100-generated-npc-stat-floor-check',
$target['uniqueName']
)));
$poolGeneral = new SPoolUnderU100(
$db,
$rng,
$target,
'2999-01-01 00:00:00'
);
$builder = $poolGeneral->getGeneralBuilder();
$builder->setNationID(0)
->setNPCType(3)
->setMoney(1000, 1000)
->setExpDed(0, 0)
->setLifeSpan(160, 230);
$builder->fillRandomStat(['무' => 0.333, '지' => 0.333, '무지' => 0.334]);
$generatedStats = $builder->getStat();
assertSameValue(
GameConst::$defaultStatNPCTotal,
array_sum($generatedStats),
"{$name} generic total"
);
$builder->fillRemainSpecAsZero($env);
$builder->build($env);
CentennialAllStarGrowthService::applyCurrentTargetToBuiltNPC(
$db,
$builder,
$target,
$env
);
$general = General::createObjFromDB($builder->getGeneralID());
$actualStats = [
'leadership' => (int) $general->getVar('leadership'),
'strength' => (int) $general->getVar('strength'),
'intel' => (int) $general->getVar('intel'),
];
if (array_sum($actualStats) < GameConst::$defaultStatNPCTotal) {
throw new RuntimeException(
"{$name} total fell below " . GameConst::$defaultStatNPCTotal
);
}
$targetOrder = ['leadership', 'strength', 'intel'];
usort(
$targetOrder,
static fn (string $lhs, string $rhs): int =>
(int) $target[$rhs] <=> (int) $target[$lhs]
);
if ($actualStats[$targetOrder[0]] < $actualStats[$targetOrder[1]]
|| $actualStats[$targetOrder[1]] < $actualStats[$targetOrder[2]]
) {
throw new RuntimeException("{$name} target stat order was not preserved");
}
$results[] = [
'name' => $name,
'target' => array_intersect_key($target, $actualStats),
'generic' => array_combine(
['leadership', 'strength', 'intel'],
$generatedStats
),
'actual' => $actualStats,
'total' => array_sum($actualStats),
];
}
echo Json::encode($results, Json::PRETTY), PHP_EOL;
function assertSameValue(int $expected, int $actual, string $label): void
{
if ($actual !== $expected) {
throw new RuntimeException(
"{$label}: expected {$expected}, got {$actual}"
);
}
}
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
use sammo\CentennialAllStarGrowthService;
use sammo\DB;
use sammo\Event\Action\AdvanceCentennialAllStar;
use sammo\GameConst;
use sammo\General;
use sammo\Json;
use sammo\KVStorage;
use sammo\RootDB;
const APP_ROOT = '/var/www/html';
const TEST_ENV = ['startyear' => 180, 'year' => 195, 'month' => 1];
const STAT_KEYS = ['leadership', 'strength', 'intel'];
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['REQUEST_URI'] = '/s100-reselection-final-growth-check';
require APP_ROOT . '/hwe/lib.php';
require APP_ROOT . '/hwe/func.php';
$mode = $argv[1] ?? '';
$username = $argv[2] ?? 's100user01';
$snapshotPath = $argv[3] ?? '/tmp/s100-reselection-final-growth.json';
if (!in_array($mode, ['prepare', 'verify'], true)) {
throw new InvalidArgumentException('Mode must be prepare or verify');
}
if (preg_match('/^s100user[0-9]{2}$/', $username) !== 1) {
throw new InvalidArgumentException('Username must match s100userNN');
}
$db = DB::db();
$owner = RootDB::db()->queryFirstField(
'SELECT no FROM member WHERE id=%s',
$username
);
if ($owner === null) {
throw new RuntimeException("No member for {$username}");
}
$generalID = $db->queryFirstField('SELECT no FROM general WHERE owner=%i', $owner);
if ($generalID === null) {
throw new RuntimeException("No general for owner {$owner}");
}
if ($mode === 'prepare') {
$gameStor = KVStorage::getStorage($db, 'game_env');
$gameStor->setValue('year', TEST_ENV['year']);
$gameStor->setValue('month', TEST_ENV['month']);
$eventResult = (new AdvanceCentennialAllStar())->run(TEST_ENV);
$general = General::createObjFromDB((int) $generalID);
$beforeGrowth = readGeneralState($general);
$statGrowthKey = null;
foreach (STAT_KEYS as $key) {
if ((int) $general->getVar($key) < GameConst::$maxLevel) {
$statGrowthKey = $key;
break;
}
}
if ($statGrowthKey === null) {
throw new RuntimeException('No stat can receive an organic level-up');
}
$general->increaseVar(
"{$statGrowthKey}_exp",
GameConst::$upgradeLimit
);
if (!$general->checkStatChange()) {
throw new RuntimeException('Organic stat level-up did not occur');
}
$general->addDex($general->getCrewTypeObj(), 12345, false);
$general->setAuxVar('next_change', '2000-01-01 00:00:00');
$general->applyDB($db);
$afterGrowth = readGeneralState($general);
$snapshot = [
'username' => $username,
'generalID' => (int) $generalID,
'eventResult' => $eventResult,
'statGrowthKey' => $statGrowthKey,
'beforeGrowth' => $beforeGrowth,
'afterGrowth' => $afterGrowth,
];
if (file_put_contents(
$snapshotPath,
Json::encode($snapshot, Json::PRETTY) . PHP_EOL
) === false) {
throw new RuntimeException("Could not write {$snapshotPath}");
}
chmod($snapshotPath, 0600);
printf(
"Prepared final-growth reselection: user=%s target=%s stat=%s dexDelta=%d\n",
$username,
$afterGrowth['targetId'],
$statGrowthKey,
array_sum($afterGrowth['dex']) - array_sum($beforeGrowth['dex'])
);
exit(0);
}
$snapshot = Json::decode((string) file_get_contents($snapshotPath));
$general = General::createObjFromDB((int) $generalID);
$actual = readGeneralState($general);
$targetInfoRaw = $db->queryFirstField(
'SELECT info FROM select_pool WHERE general_id=%i',
$generalID
);
if ($targetInfoRaw === null) {
throw new RuntimeException('Reselected target is not assigned to the general');
}
$targetInfo = Json::decode($targetInfoRaw);
$oldTargetId = $snapshot['afterGrowth']['targetId'];
$newTargetId = (string) ($targetInfo['uniqueName'] ?? '');
if ($newTargetId === '' || $newTargetId === $oldTargetId) {
throw new RuntimeException('Chromium reselection did not change the target');
}
if ($actual['targetId'] !== $newTargetId) {
throw new RuntimeException('General aux target does not match the assigned pool target');
}
$expectedStats = CentennialAllStarGrowthService::calculateUserCurrentTargetStats(
$targetInfo,
TEST_ENV
);
$expectedDex = [];
foreach (STAT_KEYS as $key) {
$organic = max(
0,
(int) $snapshot['afterGrowth']['stats'][$key]
- (int) $snapshot['afterGrowth']['granted'][$key]
);
$expectedStats[$key] = max($organic, $expectedStats[$key]);
assertSameValue($expectedStats[$key], $actual['stats'][$key], $key);
assertSameValue(
$actual['stats'][$key] - $organic,
$actual['granted'][$key],
"{$key} event grant"
);
}
foreach (DEX_KEYS as $idx => $key) {
$organic = max(
0,
(int) $snapshot['afterGrowth']['dex'][$key]
- (int) $snapshot['afterGrowth']['granted'][$key]
);
$targetFloor = CentennialAllStarGrowthService::calculateDexTargetFloor(
(int) ($targetInfo['dex'][$idx] ?? 0),
TEST_ENV
);
$expectedDex[$key] = max($organic, $targetFloor);
assertSameValue($expectedDex[$key], $actual['dex'][$key], $key);
assertSameValue($targetFloor, $actual['dexFloor'][$key], "{$key} floor");
assertSameValue(
$actual['dex'][$key] - $organic,
$actual['granted'][$key],
"{$key} event grant"
);
}
printf(
"Verified final-growth reselection: user=%s old=%s new=%s stats=%s dex=%s\n",
$username,
$oldTargetId,
$newTargetId,
Json::encode($actual['stats']),
Json::encode($actual['dex'])
);
/**
* @return array{
* targetId:string,
* stats:array<string,int>,
* dex:array<string,int>,
* granted:array<string,int>,
* dexFloor:array<string,int>
* }
*/
function readGeneralState(General $general): array
{
$aux = $general->getAuxVar(CentennialAllStarGrowthService::AUX_KEY);
if (!is_array($aux)) {
throw new RuntimeException('Centennial growth ledger is missing');
}
$stats = [];
foreach (STAT_KEYS as $key) {
$stats[$key] = (int) $general->getVar($key);
}
$dex = [];
foreach (DEX_KEYS as $key) {
$dex[$key] = (int) $general->getVar($key);
}
return [
'targetId' => (string) ($aux['targetId'] ?? ''),
'stats' => $stats,
'dex' => $dex,
'granted' => $aux['granted'] ?? [],
'dexFloor' => $aux['dexFloor'] ?? [],
];
}
function assertSameValue(int $expected, int $actual, string $label): void
{
if ($actual !== $expected) {
throw new RuntimeException(
"{$label}: expected {$expected}, got {$actual}"
);
}
}