Compare commits

..
99 changed files with 1103 additions and 6391 deletions
-213
View File
@@ -1,213 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
use Ds\Map;
use sammo\Enums\RankColumn;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
final class ComparisonTracingRNG implements RNG
{
private int $sequence = 0;
/** @var array<int, array<string, mixed>> */
public array $calls = [];
public function __construct(private readonly RNG $inner)
{
}
public static function getMaxInt(): int
{
return LiteHashDRBG::getMaxInt();
}
public function nextBytes(int $bytes): string
{
$value = $this->inner->nextBytes($bytes);
$this->record('nextBytes', ['bytes' => $bytes], bin2hex($value));
return $value;
}
public function nextBits(int $bits): string
{
$value = $this->inner->nextBits($bits);
$this->record('nextBits', ['bits' => $bits], bin2hex($value));
return $value;
}
public function nextInt(?int $max = null): int
{
$value = $this->inner->nextInt($max);
$this->record('nextInt', ['maxInclusive' => $max], $value);
return $value;
}
public function nextFloat1(): float
{
$value = $this->inner->nextFloat1();
$this->record('nextFloat1', [], $value);
return $value;
}
private function record(string $operation, array $arguments, mixed $result): void
{
$this->calls[] = [
'seq' => $this->sequence++,
'operation' => $operation,
'arguments' => $arguments,
'result' => $result,
];
}
}
function comparisonExtractRankVar(array $raw): Map
{
$rankVars = new Map();
foreach ($raw as $rawKey => $rawValue) {
$key = RankColumn::tryFrom($rawKey);
if ($key !== null) {
$rankVars[$key] = $rawValue;
}
}
return $rankVars;
}
function comparisonBuildGeneral(
array $raw,
array $city,
array $nation,
int $year,
int $month,
bool $defender = false,
): General {
$aux = [];
if (array_key_exists('inheritBuff', $raw)) {
$aux['inheritBuff'] = $raw['inheritBuff'];
}
$raw['aux'] = Json::encode($aux);
$raw['owner'] = 0;
return new General(
$raw,
comparisonExtractRankVar($raw),
null,
$city,
$nation,
$year,
$month,
$defender,
);
}
function comparisonRunBattle(array $fixture): array
{
$seed = (string)($fixture['seed'] ?? 'battle-differential');
$year = (int)$fixture['year'];
$month = (int)$fixture['month'];
$startYear = (int)($fixture['startYear'] ?? 180);
$rawAttacker = $fixture['attackerGeneral'];
$rawAttackerCity = $fixture['attackerCity'];
$rawAttackerNation = $fixture['attackerNation'];
$rawDefenderCity = $fixture['defenderCity'];
$rawDefenderNation = $fixture['defenderNation'];
$tracingRng = new ComparisonTracingRNG(new LiteHashDRBG($seed));
$warRng = new RandUtil($tracingRng);
$attacker = new WarUnitGeneral(
$warRng,
comparisonBuildGeneral($rawAttacker, $rawAttackerCity, $rawAttackerNation, $year, $month),
$rawAttackerNation,
true,
);
$city = new WarUnitCity($warRng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
$defenderList = [];
foreach ($fixture['defenderGenerals'] as $rawDefender) {
$defenderList[] = new WarUnitGeneral(
$warRng,
comparisonBuildGeneral($rawDefender, $rawDefenderCity, $rawDefenderNation, $year, $month, true),
$rawDefenderNation,
false,
);
}
if (count($defenderList) && extractBattleOrder($city, $attacker) > 0) {
$defenderList[] = $city;
}
usort(
$defenderList,
fn(WarUnit $lhs, WarUnit $rhs): int =>
-(extractBattleOrder($lhs, $attacker) <=> extractBattleOrder($rhs, $attacker)),
);
$iterDefender = new \ArrayIterator($defenderList);
$iterDefender->rewind();
$finishedDefenders = [];
$getNextDefender = function (?WarUnit $previous, bool $requestNext) use (
$iterDefender,
$attacker,
&$finishedDefenders,
): ?WarUnit {
if ($previous !== null) {
$finishedDefenders[] = buildWarTraceUnitSnapshot($previous);
}
if (!$requestNext || !$iterDefender->valid()) {
return null;
}
$next = $iterDefender->current();
if (extractBattleOrder($next, $attacker) <= 0) {
return null;
}
$iterDefender->next();
return $next;
};
$events = [];
$conquered = processWar_NG(
$seed,
$attacker,
$getNextDefender,
$city,
static function (array $event) use (&$events): void {
$events[] = $event;
},
);
return [
'engine' => 'ref',
'seed' => $seed,
'conquered' => $conquered,
'attacker' => buildWarTraceUnitSnapshot($attacker),
'city' => buildWarTraceUnitSnapshot($city),
'finishedDefenders' => $finishedDefenders,
'events' => $events,
'rng' => $tracingRng->calls,
];
}
$fixturePath = $argv[1] ?? null;
if ($fixturePath === null || ($fixturePath !== '-' && !is_file($fixturePath))) {
fwrite(STDERR, "usage: php compare/battle_trace.php <fixture.json|->\n");
exit(2);
}
try {
$fixtureJson = $fixturePath === '-' ? stream_get_contents(STDIN) : file_get_contents($fixturePath);
$fixture = json_decode((string)$fixtureJson, true, flags: JSON_THROW_ON_ERROR);
echo json_encode(
comparisonRunBattle($fixture),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
@@ -1,23 +0,0 @@
{
"action": "AddGlobalBetray",
"args": [2, 1],
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{ "id": 1, "values": { "name": "배신0", "betray": 0 } },
{ "id": 2, "values": { "name": "배신1", "betray": 1 } },
{ "id": 3, "values": { "name": "배신2", "betray": 2 } }
]
},
"environment": {
"year": 194,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2, 3]
},
"compactOutput": true
}
@@ -1,79 +0,0 @@
{
"action": "AssignGeneralSpeciality",
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{
"id": 1,
"values": {
"name": "내정대상",
"nation": 1,
"age": 30,
"leadership": 40,
"strength": 45,
"intel": 80,
"special": "None",
"specage": 30,
"special2": "che_신산",
"specage2": 99,
"aux": {
"prev_types_special": ["che_경작"]
}
}
},
{
"id": 2,
"values": {
"name": "전투대상",
"nation": 1,
"age": 30,
"leadership": 80,
"strength": 75,
"intel": 40,
"special": "che_인덕",
"specage": 99,
"special2": "None",
"specage2": 30,
"dex1": 200,
"dex2": 10,
"dex3": 10,
"dex4": 10,
"dex5": 10,
"aux": {
"prev_types_special2": ["che_돌격"]
}
}
},
{
"id": 3,
"values": {
"name": "계승대상",
"nation": 2,
"age": 30,
"leadership": 50,
"strength": 50,
"intel": 50,
"special": "che_경작",
"specage": 99,
"special2": "None",
"specage2": 30,
"aux": {
"inheritSpecificSpecialWar": "che_의술",
"marker": 3
}
}
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2, 3]
},
"compactOutput": true
}
@@ -1,54 +0,0 @@
{
"action": "AutoDeleteInvader",
"compactOutput": true,
"args": [1],
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"resetDiplomacy": true,
"nation": [
{
"id": 1,
"values": {
"name": "ⓞ남만족",
"capital": 69,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_병가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "ⓞ남만대왕",
"nation": 1,
"city": 69,
"officer_level": 12,
"npc": 9
}
}
],
"event": [
{
"id": 990001,
"action": [["AutoDeleteInvader", 1]]
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"currentEventID": 990001
},
"observe": {
"generalIds": [1]
}
}
@@ -1,24 +0,0 @@
{
"action": "BlockScoutAction",
"args": [true],
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "level": 2, "scout": 0 } },
{ "id": 2, "values": { "name": "촉", "level": 2, "scout": 0 } }
],
"gameEnvironment": {
"block_change_scout": false
}
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"nationIds": [1, 2]
}
}
@@ -1,226 +0,0 @@
{
"action": "MonthlyBoundary",
"compactOutput": true,
"environment": {
"year": 190,
"month": 12,
"startyear": 190,
"turnterm": 10,
"starttime": "2026-07-25 00:00:00",
"turntime": "2026-07-25 02:00:00"
},
"setup": {
"syncEnvironment": true,
"world": {
"hiddenSeed": "monthly-boundary-core-catalog"
},
"gameEnvironment": {
"isunited": 1,
"refreshLimit": 3,
"block_change_scout": false
},
"resetCities": true,
"deleteOtherCities": true,
"keepCityIds": [1, 2],
"city": [
{
"id": 1,
"values": {
"name": "경계도시",
"level": 1,
"nation": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 50,
"trade": 100,
"def": 1000,
"def_max": 2000,
"wall": 1000,
"wall_max": 2000,
"officer_set": 1,
"state": 31,
"term": 1,
"supply": 1,
"front": 2,
"conflict": {},
"dead": 0
}
},
{
"id": 2,
"values": {
"name": "중립도시",
"level": 8,
"nation": 0,
"pop": 12000,
"pop_max": 22000,
"agri": 1200,
"agri_max": 2200,
"comm": 1200,
"comm_max": 2200,
"secu": 1200,
"secu_max": 2200,
"trust": 55,
"trade": 100,
"def": 1200,
"def_max": 2200,
"wall": 1200,
"wall_max": 2200,
"officer_set": 1,
"state": 0,
"term": 0,
"supply": 0,
"front": 0,
"conflict": {},
"dead": 0
}
}
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"name": "경계군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"age": 30,
"belong": 3,
"makelimit": 2,
"gold": 1000,
"rice": 1000,
"crew": 100,
"train": 80,
"atmos": 70,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 0,
"dedication": 0,
"dex1": 0,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0,
"personal": "None",
"special": "None",
"specage": 0,
"special2": "None",
"specage2": 0,
"betray": 0,
"bornyear": 160,
"deadyear": 250
}
},
{
"id": 2,
"values": {
"name": "경계재야",
"nation": 0,
"city": 2,
"officer_level": 0,
"npc": 0,
"age": 25,
"belong": 4,
"makelimit": 0,
"gold": 500,
"rice": 500,
"crew": 50,
"train": 60,
"atmos": 50,
"leadership": 40,
"strength": 40,
"intel": 40,
"experience": 0,
"dedication": 0,
"dex1": 0,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0,
"personal": "None",
"special": "None",
"specage": 0,
"special2": "None",
"specage2": 0,
"betray": 0,
"bornyear": 165,
"deadyear": 250
}
}
],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"nation": [
{
"id": 1,
"values": {
"name": "경계국",
"color": "#123456",
"capital": 1,
"gennum": 1,
"gold": 10000,
"rice": 20000,
"tech": 100,
"power": 10,
"level": 2,
"type": "che_유가",
"bill": 100,
"rate": 20,
"rate_tmp": 10,
"scout": 1,
"chief_set": 1,
"strategic_cmd_limit": 2,
"surlimit": 1,
"spy": "{}"
}
}
],
"resetDiplomacy": true,
"resetEvents": true,
"event": [
{
"id": 991001,
"target": "PRE_MONTH",
"priority": 1000,
"condition": true,
"action": [
["NoticeToHistoryLog", "aggregate-pre", 6]
]
},
{
"id": 991002,
"target": "MONTH",
"priority": 1000,
"condition": true,
"action": [
["ProcessIncome", "gold"],
["NewYear"],
["ResetOfficerLock"],
["RandomizeCityTradeRate"],
["ChangeCity", "all", {"pop": "+10", "trust": 60}],
["NoticeToHistoryLog", "aggregate-month", 6],
["DeleteEvent"]
]
}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1],
"cityIds": [1, 2],
"eventIds": [991001, 991002],
"accessGeneralIds": [1, 2]
}
}
@@ -1,34 +0,0 @@
{
"action": "NewYear",
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"age": 30,
"belong": 3
}
},
{
"id": 2,
"values": {
"nation": 0,
"age": 30,
"belong": 4
}
}
]
},
"observe": {
"generalIds": [1, 2]
}
}
@@ -1,11 +0,0 @@
{
"action": "NoticeToHistoryLog",
"args": ["<S>새해 알림</>", 6],
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {},
"observe": {}
}
@@ -1,75 +0,0 @@
{
"action": "ProcessIncome",
"args": ["gold"],
"environment": {
"year": 190,
"month": 7,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"officer_level": 5,
"npc": 0,
"gold": 1000,
"rice": 1000,
"dedication": 100
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 1,
"rate_tmp": 20
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 80,
"def": 500,
"def_max": 1000,
"wall": 500,
"wall_max": 1000
}
}
]
},
"observe": {
"generalIds": [1],
"nationIds": [1],
"cityIds": [1],
"nationEnvironmentKeys": ["prev_income_gold", "prev_income_rice"]
}
}
@@ -1,33 +0,0 @@
{
"action": "ResetOfficerLock",
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"nation": 1,
"officer_set": 1
}
}
],
"nation": [
{
"id": 1,
"values": {
"chief_set": 1
}
}
]
},
"observe": {
"cityIds": [1],
"nationIds": [1]
}
}
@@ -1,9 +0,0 @@
{
"action": "CreateAdminNPC",
"environment": {
"year": 193,
"month": 12,
"startyear": 190
},
"observe": {}
}
@@ -1,15 +0,0 @@
{
"action": "CreateManyNPC",
"args": [2, 0],
"environment": {
"year": 193,
"month": 5,
"startyear": 190,
"turnterm": 10,
"turntime": "0193-05-01 00:00:00"
},
"setup": {
"resetGenerals": true
},
"observe": {}
}
@@ -1,69 +0,0 @@
{
"action": "FinishNationBetting",
"args": [1],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "gennum": 1, "power": 100, "level": 2 } },
{ "id": 2, "values": { "name": "촉", "gennum": 1, "power": 300, "level": 0 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "owner": 2, "npc": 0 } }
],
"bettingInfo": [
{
"id": 1,
"type": "bettingNation",
"name": "천통국 예상",
"finished": false,
"selectCnt": 1,
"isExclusive": null,
"reqInheritancePoint": true,
"openYearMonth": 2400,
"closeYearMonth": 2424,
"candidates": [
{
"title": "위",
"info": "국력: 100<br>장수 수: 1<br>도시 수: 1",
"isHtml": true,
"aux": { "nation": 1 }
},
{
"title": "촉",
"info": "국력: 300<br>장수 수: 1<br>도시 수: 1",
"isHtml": true,
"aux": { "nation": 2 }
}
],
"winner": null
}
],
"bet": [
{ "betting_id": 1, "general_id": 0, "user_id": null, "betting_type": "[-1]", "amount": 100 },
{ "betting_id": 1, "general_id": 1, "user_id": 1, "betting_type": "[0]", "amount": 100 },
{ "betting_id": 1, "general_id": 2, "user_id": 2, "betting_type": "[1]", "amount": 100 }
],
"inheritancePrevious": {
"1": 900,
"2": 900
},
"syncEnvironment": true
},
"environment": {
"year": 202,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"ownerIds": [1, 2]
}
}
@@ -1,40 +0,0 @@
{
"action": "InvaderEnding",
"compactOutput": true,
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"setAllCitiesNation": 1,
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"gameEnvironment": {
"isunited": 1,
"refreshLimit": 3
},
"event": [
{
"id": 990002,
"action": [["InvaderEnding"]]
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"currentEventID": 990002
},
"observe": {}
}
@@ -1,108 +0,0 @@
{
"action": "InvaderLifecycle",
"args": [10, 150, 100, 20],
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"resetDiplomacy": true,
"resetEvents": true,
"city": [
{
"id": 1,
"values": {
"name": "기준도시",
"level": 3,
"nation": 1,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
},
{
"id": 48,
"values": {
"name": "남만",
"level": 4,
"nation": 0,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 1000,
"dedication": 1000,
"dex1": 10,
"dex2": 20,
"dex3": 30,
"dex4": 40,
"dex5": 50,
"turntime": "0200-01-01 00:05:00"
}
}
],
"gameEnvironment": {
"isunited": 0,
"refreshLimit": 3
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {}
}
@@ -1,42 +0,0 @@
{
"action": "LostUniqueItem",
"args": [0.2],
"environment": {
"year": 210,
"month": 1,
"startyear": 180
},
"setup": {
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"name": "갑",
"npc": 0,
"horse": "che_명마_12_옥란백용구",
"weapon": "che_무기_15_의천검",
"book": "che_서적_01_효경전",
"item": "che_저지_삼황내문"
}
},
{
"id": 2,
"values": {
"name": "을",
"npc": 1,
"horse": "che_명마_12_사륜거",
"weapon": "che_무기_01_단도",
"book": "None",
"item": "che_부적_태현청생부"
}
}
]
},
"observe": {
"generalIds": [1, 2]
}
}
@@ -1,74 +0,0 @@
{
"action": "MergeInheritPointRank",
"environment": {
"year": 210,
"month": 1,
"startyear": 180
},
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"clearInheritanceOwnerIds": [100],
"gameEnvironment": {
"isunited": 0
},
"general": [
{
"id": 1,
"values": {
"name": "갑",
"nation": 1,
"owner": 100,
"npc": 0,
"belong": 3,
"dex1": 1000,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
}
],
"inheritancePoints": [
{"ownerId": 100, "key": "previous", "value": 999},
{"ownerId": 100, "key": "lived_month", "value": 2},
{"ownerId": 100, "key": "max_domestic_critical", "value": 4},
{"ownerId": 100, "key": "active_action", "value": 15},
{"ownerId": 100, "key": "unifier", "value": 6},
{"ownerId": 100, "key": "tournament", "value": 7}
],
"rankData": [
{"generalId": 1, "type": "warnum", "value": 2},
{"generalId": 1, "type": "firenum", "value": 1},
{"generalId": 1, "type": "betwin", "value": 2},
{"generalId": 1, "type": "betgold", "value": 4000},
{"generalId": 1, "type": "betwingold", "value": 2000},
{"generalId": 1, "type": "inherit_earned_dyn", "value": 999},
{"generalId": 1, "type": "inherit_earned_act", "value": 11},
{"generalId": 1, "type": "inherit_earned", "value": 999},
{"generalId": 1, "type": "inherit_spent_dyn", "value": 9},
{"generalId": 1, "type": "inherit_spent", "value": 999}
]
},
"observe": {
"generalIds": [1],
"ownerIds": [100],
"inheritanceKeys": [
"previous",
"lived_month",
"max_domestic_critical",
"active_action",
"unifier",
"tournament"
],
"rankGeneralIds": [1],
"rankTypes": [
"inherit_earned",
"inherit_earned_act",
"inherit_earned_dyn",
"inherit_spent",
"inherit_spent_dyn"
]
}
}
@@ -1,75 +0,0 @@
{
"action": "NationBettingLifecycle",
"args": [1, 100],
"bettorGeneralIds": [1, 2],
"bettorOwnerIds": [1, 2],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{
"id": 1,
"values": {
"name": "위",
"color": "#111111",
"capital": 1,
"gennum": 1,
"gold": 1000,
"rice": 2000,
"tech": 100,
"power": 100,
"level": 2,
"type": "che_유가"
}
},
{
"id": 2,
"values": {
"name": "촉",
"color": "#222222",
"capital": 2,
"gennum": 1,
"gold": 3000,
"rice": 4000,
"tech": 200,
"power": 300,
"level": 2,
"type": "che_병가"
}
}
],
"resetCities": true,
"deleteOtherCities": true,
"keepCityIds": [1, 2],
"city": [
{ "id": 1, "values": { "name": "허창", "nation": 1, "level": 3 } },
{ "id": 2, "values": { "name": "성도", "nation": 2, "level": 3 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "city": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "city": 2, "owner": 2, "npc": 0 } }
],
"inheritancePrevious": {
"1": 1000,
"2": 1000
},
"rankData": [
{ "generalId": 1, "type": "inherit_spent_dyn", "value": 0 },
{ "generalId": 1, "type": "inherit_earned_act", "value": 0 },
{ "generalId": 2, "type": "inherit_spent_dyn", "value": 0 },
{ "generalId": 2, "type": "inherit_earned_act", "value": 0 }
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {}
}
@@ -1,77 +0,0 @@
{
"action": "NpcNationLifecycle",
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"name": "점유도시",
"level": 5,
"nation": 1,
"pop": 10001,
"pop_max": 50001,
"agri": 1001,
"agri_max": 5001,
"comm": 2001,
"comm_max": 6001,
"secu": 3001,
"secu_max": 7001,
"def": 4001,
"def_max": 8001,
"wall": 5001,
"wall_max": 9001
}
},
{
"id": 48,
"values": {
"name": "건국도시",
"level": 5,
"nation": 0,
"pop": 10048,
"pop_max": 50048,
"agri": 1048,
"agri_max": 5048,
"comm": 2048,
"comm_max": 6048,
"secu": 3048,
"secu_max": 7048,
"def": 4048,
"def_max": 8048,
"wall": 5048,
"wall_max": 9048
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 0,
"tech": 120,
"level": 2,
"type": "che_유가"
}
}
],
"gameEnvironment": {
"lastNPCTroopLeaderID": 40
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {}
}
@@ -1,65 +0,0 @@
{
"action": "OpenNationBetting",
"args": [1, 500],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{
"id": 1,
"values": {
"name": "위",
"color": "#111111",
"capital": 1,
"gennum": 1,
"gold": 1000,
"rice": 2000,
"tech": 100,
"power": 100,
"level": 2,
"type": "che_유가"
}
},
{
"id": 2,
"values": {
"name": "촉",
"color": "#222222",
"capital": 2,
"gennum": 1,
"gold": 3000,
"rice": 4000,
"tech": 200,
"power": 300,
"level": 2,
"type": "che_병가"
}
}
],
"resetCities": true,
"city": [
{ "id": 1, "values": { "nation": 1, "level": 3 } },
{ "id": 2, "values": { "nation": 2, "level": 3 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "city": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "city": 2, "owner": 2, "npc": 0 } }
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2],
"cityIds": [1, 2],
"nationIds": [1, 2]
}
}
@@ -1,178 +0,0 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-diplomacy-fixture"
},
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3, 4],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2, 3, 4],
"general": [
{
"id": 1,
"values": {
"name": "갑장",
"nation": 1,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 2,
"values": {
"name": "을장",
"nation": 2,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 3,
"values": {
"name": "병장",
"nation": 3,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 4,
"values": {
"name": "정장",
"nation": 4,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"gennum": 2,
"level": 1
}
},
{
"id": 2,
"values": {
"name": "을국",
"gennum": 1,
"level": 1
}
},
{
"id": 3,
"values": {
"name": "병국",
"gennum": 1,
"level": 1
}
},
{
"id": 4,
"values": {
"name": "정국",
"gennum": 1,
"level": 1
}
}
],
"resetDiplomacy": true,
"diplomacy": [
{
"fromNationId": 1,
"toNationId": 2,
"state": 1,
"term": 1,
"dead": 777
},
{
"fromNationId": 2,
"toNationId": 1,
"state": 1,
"term": 1,
"dead": 888
},
{
"fromNationId": 1,
"toNationId": 3,
"state": 0,
"term": 5,
"dead": 250
},
{
"fromNationId": 3,
"toNationId": 1,
"state": 0,
"term": 5,
"dead": 50
},
{
"fromNationId": 3,
"toNationId": 4,
"state": 0,
"term": 1,
"dead": 0
},
{
"fromNationId": 4,
"toNationId": 3,
"state": 0,
"term": 1,
"dead": 0
},
{
"fromNationId": 2,
"toNationId": 4,
"state": 7,
"term": 1,
"dead": 999
}
]
},
"observe": {
"generalIds": [1, 2, 3, 4],
"nationIds": [1, 2, 3, 4]
}
}
@@ -1,162 +0,0 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-nation-stats-fixture"
},
"syncEnvironment": true,
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"resetDiplomacy": true,
"general": [
{
"id": 1,
"values": {
"nation": 1,
"npc": 0,
"gold": 1000,
"rice": 2000,
"crew": 100,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200,
"dex1": 1000,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
},
{
"id": 2,
"values": {
"nation": 2,
"npc": 2,
"gold": 3000,
"rice": 4000,
"crew": 200,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400,
"dex1": 2000,
"dex2": 1000,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
}
],
"rankData": [
{"generalId": 1, "type": "killcrew_person", "value": 100},
{"generalId": 1, "type": "deathcrew_person", "value": 0},
{"generalId": 2, "type": "killcrew_person", "value": 0},
{"generalId": 2, "type": "deathcrew_person", "value": 100}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"gold": 10000,
"rice": 20000,
"tech": 100,
"level": 2,
"gennum": 9,
"power": 7
}
},
{
"id": 2,
"values": {
"name": "을국",
"gold": 2000,
"rice": 3000,
"tech": 50,
"level": 1,
"gennum": 8,
"power": 8
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"agri": 100,
"agri_max": 200,
"comm": 100,
"comm_max": 200,
"secu": 100,
"secu_max": 200,
"def": 100,
"def_max": 200,
"wall": 100,
"wall_max": 200
}
},
{
"id": 2,
"values": {
"name": "을성",
"nation": 2,
"level": 4,
"supply": 1,
"pop": 2000,
"pop_max": 4000,
"agri": 200,
"agri_max": 400,
"comm": 200,
"comm_max": 400,
"secu": 200,
"secu_max": 400,
"def": 200,
"def_max": 400,
"wall": 200,
"wall_max": 400
}
}
],
"nationEnvironment": [
{
"nationId": 1,
"key": "max_power",
"value": {
"maxPower": 999,
"maxCrew": 50,
"maxCities": ["옛도시", "옛도시2"]
}
},
{"nationId": 1, "key": "available_war_setting_cnt", "value": 1}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"cityIds": [1, 2],
"rankGeneralIds": [1, 2],
"rankTypes": ["killcrew_person", "deathcrew_person"],
"nationEnvironmentKeys": ["max_power", "available_war_setting_cnt"]
}
}
@@ -1,128 +0,0 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-tail-2"
},
"syncEnvironment": true,
"clearNeutralAuctions": true,
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"resetDiplomacy": true,
"gameEnvironment": {
"tournament": 0,
"tnmt_trig": true,
"tnmt_pattern": [0, 1, 2, 3],
"prev_winner": null
},
"general": [
{
"id": 1,
"values": {
"name": "갑장",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"gold": 5000,
"rice": 7000,
"crew": 100,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200
}
},
{
"id": 2,
"values": {
"name": "을장",
"nation": 2,
"city": 2,
"officer_level": 12,
"npc": 0,
"gold": 6000,
"rice": 8000,
"crew": 200,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"tech": 100,
"level": 2,
"gennum": 1,
"power": 7
}
},
{
"id": 2,
"values": {
"name": "을국",
"capital": 2,
"gold": 2000,
"rice": 3000,
"tech": 50,
"level": 1,
"gennum": 1,
"power": 8
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"front": 3,
"pop": 1000,
"pop_max": 2000
}
},
{
"id": 2,
"values": {
"name": "을성",
"nation": 2,
"level": 4,
"supply": 1,
"front": 3,
"pop": 2000,
"pop_max": 4000
}
}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"cityIds": [1, 2]
}
}
@@ -1,143 +0,0 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 195,
"month": 2,
"startyear": 193,
"seedYear": 195,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-wander-fixture"
},
"syncEnvironment": true,
"resetCities": true,
"city": [
{
"id": 1,
"values": {
"name": "방랑성",
"nation": 4,
"level": 4,
"front": 1
}
},
{
"id": 2,
"values": {
"name": "존속성",
"nation": 1,
"level": 4,
"front": 1
}
}
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{
"id": 1,
"values": {
"name": "방랑주",
"nation": 4,
"city": 1,
"officer_level": 12,
"npc": 2,
"gold": 2000,
"rice": 3000,
"crew": 100,
"makelimit": 0,
"belong": 7,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200
}
},
{
"id": 2,
"values": {
"name": "방랑객",
"nation": 4,
"city": 1,
"officer_level": 1,
"npc": 0,
"gold": 1500,
"rice": 4000,
"crew": 200,
"makelimit": 0,
"belong": 5,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400
}
},
{
"id": 3,
"values": {
"name": "존속장",
"nation": 1,
"city": 2,
"officer_level": 12,
"npc": 2,
"gold": 500,
"rice": 500,
"crew": 50,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 0,
"dedication": 0
}
}
],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 4],
"nation": [
{
"id": 1,
"values": {
"name": "존속국",
"gennum": 1,
"level": 1
}
},
{
"id": 4,
"values": {
"name": "방랑국",
"gennum": 2,
"level": 0
}
}
],
"resetDiplomacy": true,
"diplomacy": [
{
"fromNationId": 1,
"toNationId": 4,
"state": 2,
"term": 0,
"dead": 0
},
{
"fromNationId": 4,
"toNationId": 1,
"state": 2,
"term": 0,
"dead": 0
}
]
},
"observe": {
"generalIds": [1, 2, 3],
"nationIds": [1, 4],
"cityIds": [1, 2]
}
}
@@ -1,49 +0,0 @@
{
"action": "PreUpdateMonthly",
"environment": {
"year": 200,
"month": 12,
"startyear": 190
},
"setup": {
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{"id": 1, "values": {"makelimit": 2}},
{"id": 2, "values": {"makelimit": 0}}
],
"generalAccessLog": [
{"generalId": 1, "refreshScoreTotal": 101},
{"generalId": 2, "refreshScoreTotal": 1}
],
"nation": [
{
"id": 1,
"values": {
"rate": 35,
"rate_tmp": 10,
"strategic_cmd_limit": 2,
"surlimit": 1,
"spy": "{\"1\":1,\"2\":2}"
}
}
],
"city": [
{"id": 1, "values": {"state": 31, "term": 1, "conflict": {"1": 10}}},
{"id": 2, "values": {"state": 32, "term": 2, "conflict": {"1": 20}}},
{"id": 3, "values": {"state": 33, "term": 0, "conflict": {"1": 30}}},
{"id": 4, "values": {"state": 34, "term": 3, "conflict": {"1": 40}}},
{"id": 5, "values": {"state": 41, "term": 1, "conflict": {"1": 50}}},
{"id": 6, "values": {"state": 42, "term": 2, "conflict": {"1": 60}}},
{"id": 7, "values": {"state": 43, "term": 3, "conflict": {"1": 70}}}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1],
"cityIds": [1, 2, 3, 4, 5, 6, 7],
"accessGeneralIds": [1, 2]
}
}
@@ -1,76 +0,0 @@
{
"action": "ProcessIncome",
"args": ["gold"],
"environment": {
"year": 190,
"month": 7,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"officer_level": 5,
"npc": 0,
"gold": 1000,
"rice": 1000,
"dedication": 100
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 1,
"rate": 35,
"rate_tmp": 10
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 80,
"def": 500,
"def_max": 1000,
"wall": 500,
"wall_max": 1000
}
}
]
},
"observe": {
"generalIds": [1],
"nationIds": [1],
"cityIds": [1],
"nationEnvironmentKeys": ["prev_income_gold", "prev_income_rice"]
}
}
@@ -1,106 +0,0 @@
{
"action": "ProcessSemiAnnual",
"resource": "gold",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"nation": [
{
"id": 1,
"values": {
"name": "반기검증국",
"gold": 100001,
"rice": 7777,
"rate_tmp": 20,
"type": "che_도가"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
},
{
"id": 2,
"values": {
"nation": 1,
"supply": 0,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
},
{
"id": 3,
"values": {
"nation": 0,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"gold": 10001,
"rice": 8888
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 2, 3],
"nationIds": [1]
}
}
@@ -1,65 +0,0 @@
{
"action": "ProcessSemiAnnual",
"resource": "gold",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"nation": [
{
"id": 1,
"values": {
"name": "반기검증국",
"gold": 100001,
"rice": 7777,
"rate": 50,
"rate_tmp": 20,
"type": "che_중립"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"gold": 10001,
"rice": 8888
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1],
"nationIds": [1]
}
}
@@ -1,96 +0,0 @@
{
"action": "ProcessWarIncome",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"nation": [
{
"id": 1,
"values": {
"name": "전쟁수입국",
"gold": 1000,
"level": 1,
"type": "che_중립"
}
},
{
"id": 2,
"values": {
"name": "비활성국",
"gold": 2000,
"level": 0,
"type": "che_중립"
}
},
{
"id": 3,
"values": {
"name": "법가국",
"gold": 3000,
"level": 1,
"type": "che_법가"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 101
}
},
{
"id": 2,
"values": {
"nation": 1,
"supply": 0,
"pop": 1000,
"pop_max": 2000,
"dead": 999
}
},
{
"id": 3,
"values": {
"nation": 2,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 1000
}
},
{
"id": 4,
"values": {
"nation": 3,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 105
}
},
{
"id": 5,
"values": {
"nation": 0,
"supply": 1,
"pop": 999,
"pop_max": 1000,
"dead": 10
}
}
]
},
"observe": {
"cityIds": [1, 2, 3, 4, 5],
"nationIds": [1, 2, 3]
}
}
@@ -1,105 +0,0 @@
{
"action": "RaiseInvader",
"compactOutput": true,
"args": [10, 150, 100, 20],
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"city": [
{
"id": 1,
"values": {
"level": 3,
"nation": 1,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
},
{
"id": 48,
"values": {
"level": 4,
"nation": 0,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 1000,
"dedication": 1000,
"dex1": 10,
"dex2": 20,
"dex3": 30,
"dex4": 40,
"dex5": 50,
"turntime": "0200-01-01 00:05:00"
}
}
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 48],
"nationIds": [1]
}
}
@@ -1,77 +0,0 @@
{
"action": "RaiseNPCNation",
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"name": "점유도시",
"level": 5,
"nation": 1,
"pop": 10001,
"pop_max": 50001,
"agri": 1001,
"agri_max": 5001,
"comm": 2001,
"comm_max": 6001,
"secu": 3001,
"secu_max": 7001,
"def": 4001,
"def_max": 8001,
"wall": 5001,
"wall_max": 9001
}
},
{
"id": 48,
"values": {
"name": "건국도시",
"level": 5,
"nation": 0,
"pop": 10048,
"pop_max": 50048,
"agri": 1048,
"agri_max": 5048,
"comm": 2048,
"comm_max": 6048,
"secu": 3048,
"secu_max": 7048,
"def": 4048,
"def_max": 8048,
"wall": 5048,
"wall_max": 9048
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 120,
"level": 2,
"type": "che_유가"
}
}
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"cityIds": [1, 48],
"nationIds": [1]
}
}
@@ -1,35 +0,0 @@
{
"action": "RegNeutralNPC",
"args": [
0,
"등록재야",
null,
0,
33,
45,
55,
65,
180,
245,
null,
"무쌍",
""
],
"setup": {
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [],
"cityIds": [33],
"nationIds": [1]
}
}
-36
View File
@@ -1,36 +0,0 @@
{
"action": "RegNPC",
"args": [
0,
"등록장수",
null,
0,
33,
60,
50,
40,
7,
186,
240,
null,
"인덕",
"등록 대사"
],
"setup": {
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [],
"cityIds": [33],
"nationIds": [1]
}
}
@@ -1,24 +0,0 @@
{
"action": "UnblockScoutAction",
"args": [false],
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "level": 2, "scout": 1 } },
{ "id": 2, "values": { "name": "촉", "level": 2, "scout": 1 } }
],
"gameEnvironment": {
"block_change_scout": true
}
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"nationIds": [1, 2]
}
}
@@ -1,140 +0,0 @@
{
"action": "UpdateCitySupply",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"nation": [
{
"id": 99,
"values": {
"name": "보급검증국",
"color": "#777777",
"capital": 1,
"level": 1,
"type": "che_중립"
}
},
{
"id": 98,
"values": {
"name": "불일치수도국",
"color": "#888888",
"capital": 2,
"level": 1,
"type": "che_중립"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 99,
"supply": 0,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 50,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 9,
"values": {
"nation": 99,
"supply": 0,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 50,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 2,
"values": {
"nation": 99,
"supply": 1,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 33,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 3,
"values": {
"nation": 0,
"supply": 0,
"front": 0,
"trust": 50
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 99,
"city": 2,
"officer_level": 4,
"officer_city": 2,
"crew": 101,
"train": 51,
"atmos": 99
}
},
{
"id": 2,
"values": {
"nation": 99,
"city": 1,
"officer_level": 4,
"officer_city": 2,
"crew": 101,
"train": 51,
"atmos": 99
}
},
{
"id": 3,
"values": {
"nation": 98,
"city": 2,
"officer_level": 3,
"officer_city": 1,
"crew": 101,
"train": 51,
"atmos": 99
}
}
]
},
"observe": {
"generalIds": [1, 2, 3],
"cityIds": [1, 2, 3, 9],
"nationIds": [98, 99]
}
}
@@ -1,85 +0,0 @@
{
"action": "UpdateNationLevel",
"environment": {
"year": 193,
"month": 2,
"startyear": 190,
"killturn": 1000,
"turnterm": 10
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetUniqueOccupancy": true,
"syncEnvironment": true,
"clearNationTurnIds": [1],
"clearInheritanceOwnerIds": [990061],
"nation": [
{
"id": 1,
"values": {
"name": "작위검증국",
"color": "#777777",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 0,
"type": "che_중립",
"aux": {}
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"level": 4
}
},
{
"id": 2,
"values": {
"nation": 1,
"level": 4
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "조조",
"nation": 1,
"city": 1,
"officer_level": 12,
"owner": 990061,
"npc": 0,
"killturn": 1000,
"belong": 10,
"horse": "None",
"weapon": "None",
"book": "None",
"item": "None",
"aux": {}
}
},
{
"id": 2,
"values": {
"nation": 0,
"city": 2,
"officer_level": 1,
"npc": 0,
"killturn": 0
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 2],
"nationIds": [1],
"ownerIds": [990061]
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
$input = stream_get_contents(STDIN);
$keys = Json::decode($input);
if (!is_array($keys)) {
fwrite(STDERR, "Expected a JSON array of item keys.\n");
exit(2);
}
$result = [];
foreach ($keys as $key) {
if (!is_string($key)) {
continue;
}
$item = buildItemClass($key);
$result[$key] = [
'rawName' => $item->getRawName(),
'name' => $item->getName(),
'info' => $item->getInfo(),
'cost' => $item->getCost(),
'buyable' => $item->isBuyable(),
'consumable' => $item->isConsumable(),
'reqSecu' => $item->getReqSecu(),
];
}
echo Json::encode($result);
-1579
View File
@@ -1,1579 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
require_once __DIR__ . '/turn_state_snapshot.php';
/** @param array<string, mixed> $values */
function comparisonMonthlyPatch(string $table, int $id, array $values): void
{
$definitions = [
'city' => [
'idColumn' => 'city',
'allowed' => [
'name', 'level', 'nation', 'supply', 'front', 'pop', 'pop_max',
'agri', 'agri_max', 'comm', 'comm_max', 'secu', 'secu_max',
'trust', 'trade', 'def', 'def_max', 'wall', 'wall_max',
'officer_set', 'state', 'region', 'term', 'conflict', 'dead',
],
],
'nation' => [
'idColumn' => 'nation',
'allowed' => [
'name', 'color', 'capital', 'gennum', 'gold', 'rice', 'tech',
'power', 'level', 'type', 'bill', 'rate_tmp', 'scout', 'chief_set', 'aux',
'rate', 'strategic_cmd_limit', 'surlimit', 'spy',
],
],
'general' => [
'idColumn' => 'no',
'allowed' => [
'name', 'nation', 'city', 'officer_level', 'officer_city',
'crew', 'train', 'atmos', 'owner', 'npc', 'killturn', 'belong',
'gold', 'rice', 'horse', 'weapon', 'book', 'item', 'aux',
'leadership', 'strength', 'intel', 'experience', 'dedication',
'injury',
'dex1', 'dex2', 'dex3', 'dex4', 'dex5', 'personal', 'special',
'specage', 'special2', 'specage2', 'betray', 'age',
'bornyear', 'deadyear', 'turntime', 'makelimit',
],
],
];
$definition = $definitions[$table] ?? null;
if ($definition === null) {
throw new \InvalidArgumentException("unsupported setup table: {$table}");
}
$patch = [];
foreach ($values as $key => $value) {
if (!is_string($key) || !in_array($key, $definition['allowed'], true)) {
throw new \InvalidArgumentException("unsupported {$table} setup field");
}
if (in_array($key, ['conflict', 'aux'], true) && is_array($value)) {
$value = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
$patch[$key] = $value;
}
if ($table === 'nation') {
$patch += [
'nation' => $id,
'name' => "fixture-nation-{$id}",
'color' => '#777777',
'capital' => 0,
'gold' => 0,
'rice' => 0,
'level' => 1,
'type' => 'che_중립',
'aux' => '{}',
];
DB::db()->insertUpdate('nation', $patch, $patch);
} elseif ($patch !== []) {
DB::db()->update($table, $patch, "`{$definition['idColumn']}` = %i", $id);
}
}
/** @param array<string, mixed> $observe */
function comparisonMonthlyDetails(array $observe, int $worldHistoryAfterId): array
{
$db = DB::db();
$generals = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'no',
'nationId' => 'nation',
'cityId' => 'city',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'crew' => 'crew',
'train' => 'train',
'atmos' => 'atmos',
'gold' => 'gold',
'rice' => 'rice',
'injury' => 'injury',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'age' => 'age',
'specialDomestic' => 'special',
'specAge' => 'specage',
'specialWar' => 'special2',
'specAge2' => 'specage2',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'horse' => 'horse',
'weapon' => 'weapon',
'book' => 'book',
'item' => 'item',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'aux' => 'aux',
],
['aux'],
),
comparisonRowsById('general', 'no', comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds')),
);
$cities = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'city',
'nationId' => 'nation',
'state' => 'state',
'supplyState' => 'supply',
'frontState' => 'front',
'officerSet' => 'officer_set',
'term' => 'term',
'dead' => 'dead',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'trust' => 'trust',
'trade' => 'trade',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'conflict' => 'conflict',
],
['conflict'],
),
comparisonRowsById('city', 'city', comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds')),
);
$nations = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'nation',
'gold' => 'gold',
'rice' => 'rice',
'rate' => 'rate_tmp',
'baseRate' => 'rate',
'power' => 'power',
'generalCount' => 'gennum',
'tech' => 'tech',
'typeCode' => 'type',
'scout' => 'scout',
'strategicCommandLimit' => 'strategic_cmd_limit',
'surrenderLimit' => 'surlimit',
'spy' => 'spy',
'chiefSet' => 'chief_set',
],
['spy'],
),
comparisonRowsById('nation', 'nation', comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds')),
);
$worldHistory = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
],
),
$db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE id > %i ORDER BY id',
$worldHistoryAfterId,
),
);
$inheritanceKeys = $observe['inheritanceKeys'] ?? ['unifier'];
if (!is_array($inheritanceKeys) || array_filter($inheritanceKeys, static fn($key): bool => !is_string($key))) {
throw new \InvalidArgumentException('inheritanceKeys must be an array of strings');
}
$inheritancePoints = [];
foreach (comparisonIntegerList($observe['ownerIds'] ?? [], 'ownerIds') as $ownerId) {
$entry = ['ownerId' => $ownerId];
foreach ($inheritanceKeys as $key) {
$value = $db->queryFirstField(
'SELECT value FROM storage WHERE namespace = %s AND `key` = %s',
"inheritance_{$ownerId}",
$key,
);
$entry[$key] = $value === null ? 0 : (comparisonJsonValue($value)[0] ?? 0);
}
$inheritancePoints[] = $entry;
}
$rankGeneralIds = comparisonIntegerList($observe['rankGeneralIds'] ?? [], 'rankGeneralIds');
$rankTypes = $observe['rankTypes'] ?? [];
if (!is_array($rankTypes) || array_filter($rankTypes, static fn($type): bool => !is_string($type))) {
throw new \InvalidArgumentException('rankTypes must be an array of strings');
}
$rankData = (!$rankGeneralIds || !$rankTypes)
? []
: iterator_to_array($db->query(
'SELECT general_id AS generalId, nation_id AS nationId, `type`, `value` FROM rank_data WHERE general_id IN %li AND `type` IN %ls ORDER BY general_id, `type`',
$rankGeneralIds,
$rankTypes,
));
$generalAccessLogs = [];
$accessGeneralIds = comparisonIntegerList($observe['accessGeneralIds'] ?? [], 'accessGeneralIds');
if ($accessGeneralIds) {
$generalAccessLogs = iterator_to_array($db->query(
'SELECT general_id AS generalId, refresh_score_total AS refreshScoreTotal FROM general_access_log WHERE general_id IN %li ORDER BY general_id',
$accessGeneralIds,
));
}
$nationEnvironment = [];
$nationEnvironmentKeys = $observe['nationEnvironmentKeys'] ?? [];
if (
!is_array($nationEnvironmentKeys)
|| array_filter($nationEnvironmentKeys, static fn($key): bool => !is_string($key))
) {
throw new \InvalidArgumentException('nationEnvironmentKeys must be an array of strings');
}
foreach (comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds') as $nationId) {
$entry = ['nationId' => $nationId];
$storage = KVStorage::getStorage($db, $nationId, 'nation_env');
foreach ($nationEnvironmentKeys as $key) {
$entry[$key] = $storage->getValue($key);
}
$nationEnvironment[] = $entry;
}
return [
'generals' => $generals,
'cities' => $cities,
'nations' => $nations,
'worldHistory' => $worldHistory,
'inheritancePoints' => $inheritancePoints,
'rankData' => $rankData,
'generalAccessLogs' => $generalAccessLogs,
'nationEnvironment' => $nationEnvironment,
];
}
/**
* Exercise the dynamically-created invader events against one evolving database.
*
* @param array<string, mixed> $request
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonInvaderLifecycleTrace(
array $request,
array $environment,
int $generalIdBeforeAction,
int $nationIdBeforeAction,
int $eventIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$args = $request['args'] ?? [];
if (!is_array($args) || count($args) > 4) {
throw new \InvalidArgumentException('InvaderLifecycle args must have at most four entries');
}
$year = $environment['year'];
$month = $environment['month'];
$startYear = $environment['startyear'];
$raiseEnvironment = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
'turnterm' => $environment['turnterm'],
'turntime' => $environment['turntime'],
'show_img_level' => $environment['show_img_level'] ?? 3,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $environment['fiction'] ?? [0],
];
(new \sammo\Event\Action\RaiseInvader(...$args))->run($raiseEnvironment);
$invaderNationIds = array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction),
);
$existingNationId = (int)$db->queryFirstField(
'SELECT nation FROM nation WHERE nation <= %i ORDER BY nation LIMIT 1',
$nationIdBeforeAction,
);
$rulerIds = array_map(
'intval',
$db->queryFirstColumn(
'SELECT no FROM general WHERE nation IN %li AND officer_level = 12 ORDER BY nation',
$invaderNationIds,
),
);
if ($invaderNationIds === [] || $existingNationId <= 0 || count($rulerIds) !== count($invaderNationIds)) {
throw new \RuntimeException('InvaderLifecycle failed to create the expected entities');
}
$autoDeleteEvents = [];
$endingEventId = 0;
foreach ($db->query('SELECT id, action FROM event WHERE id > %i ORDER BY id', $eventIdBeforeAction) as $row) {
$action = comparisonJsonValue($row['action']);
$actionName = $action[0][0] ?? null;
if ($actionName === 'AutoDeleteInvader') {
$autoDeleteEvents[] = [
'eventId' => (int)$row['id'],
'nationId' => (int)($action[0][1] ?? 0),
];
} elseif ($actionName === 'InvaderEnding') {
$endingEventId = (int)$row['id'];
}
}
if (count($autoDeleteEvents) !== count($invaderNationIds) || $endingEventId <= 0) {
throw new \RuntimeException('InvaderLifecycle failed to create follow-up events');
}
$diplomacyRows = iterator_to_array($db->query(
'SELECT me, you, state, term FROM diplomacy WHERE (me IN %li AND you = %i) OR (me = %i AND you IN %li) ORDER BY me, you',
$invaderNationIds,
$existingNationId,
$existingNationId,
$invaderNationIds,
));
$diplomacyStates = array_values(array_unique(array_map(
static fn(array $row): string => "{$row['state']}:{$row['term']}",
$diplomacyRows,
)));
sort($diplomacyStates);
$generalCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i',
$nationId,
),
$invaderNationIds,
);
$diplomacyCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM diplomacy WHERE (me = %i AND you = %i) OR (me = %i AND you = %i)',
$nationId,
$existingNationId,
$existingNationId,
$nationId,
),
$invaderNationIds,
);
$gameStorage = KVStorage::getStorage($db, 'game_env');
$afterRaise = [
'createdNationCount' => count($invaderNationIds),
'createdGeneralCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general WHERE no > %i',
$generalIdBeforeAction,
),
'followUpEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id > %i',
$eventIdBeforeAction,
),
'generalCountsPerNation' => array_values(array_unique($generalCounts)),
'diplomacyCountsPerNation' => array_values(array_unique($diplomacyCounts)),
'diplomacyStates' => $diplomacyStates,
'isunited' => (int)$gameStorage->isunited,
'blockChangeScout' => (bool)$gameStorage->block_change_scout,
];
$warResults = [];
foreach ($autoDeleteEvents as $autoDeleteEvent) {
$warResult = (new \sammo\Event\Action\AutoDeleteInvader($autoDeleteEvent['nationId']))->run([
'year' => $year,
'month' => $month + 1,
'startyear' => $startYear,
'currentEventID' => $autoDeleteEvent['eventId'],
]);
$warResults[] = $warResult[1] ?? null;
}
$atWar = [
'results' => array_values(array_unique($warResults)),
'autoDeleteEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id IN %li',
array_column($autoDeleteEvents, 'eventId'),
),
];
$db->update(
'diplomacy',
['state' => 2, 'term' => 0],
'me IN %li OR you IN %li',
$invaderNationIds,
$invaderNationIds,
);
$peaceResults = [];
foreach ($autoDeleteEvents as $autoDeleteEvent) {
$peaceResult = (new \sammo\Event\Action\AutoDeleteInvader($autoDeleteEvent['nationId']))->run([
'year' => $year,
'month' => $month + 2,
'startyear' => $startYear,
'currentEventID' => $autoDeleteEvent['eventId'],
]);
$peaceResults[] = $peaceResult[1] ?? null;
}
$atPeace = [
'results' => array_values(array_unique($peaceResults)),
'autoDeleteEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id IN %li',
array_column($autoDeleteEvents, 'eventId'),
),
'rulerWanderTurnCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general_turn WHERE general_id IN %li AND action = %s',
$rulerIds,
'che_방랑',
),
'endingEventPresent' => (bool)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id = %i',
$endingEventId,
),
];
$invaderGeneralIds = array_map('intval', $db->queryFirstColumn(
'SELECT no FROM general WHERE nation IN %li',
$invaderNationIds,
));
if ($invaderGeneralIds !== []) {
$db->delete('general_turn', 'general_id IN %li', $invaderGeneralIds);
$db->delete('rank_data', 'general_id IN %li', $invaderGeneralIds);
$db->delete('troop', 'nation IN %li', $invaderNationIds);
$db->delete('general', 'no IN %li', $invaderGeneralIds);
}
$db->delete('nation_turn', 'nation_id IN %li', $invaderNationIds);
$db->delete('diplomacy', 'me IN %li OR you IN %li', $invaderNationIds, $invaderNationIds);
$db->delete('nation', 'nation IN %li', $invaderNationIds);
$db->update('city', ['nation' => $existingNationId], true);
$endingResult = (new \sammo\Event\Action\InvaderEnding())->run([
'year' => $year,
'month' => $month + 3,
'startyear' => $startYear,
'currentEventID' => $endingEventId,
]);
$gameStorage->resetCache();
$afterUserWin = [
'result' => $endingResult[1] ?? null,
'endingEventPresent' => (bool)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id = %i',
$endingEventId,
),
'isunited' => (int)$gameStorage->isunited,
'refreshLimit' => (int)$gameStorage->refreshLimit,
'logs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 AND year = %i AND month = %i ORDER BY id',
$worldHistoryIdBeforeAction,
$year,
$month + 3,
)),
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'InvaderLifecycle',
'phases' => [
'afterRaise' => $afterRaise,
'atWar' => $atWar,
'atPeace' => $atPeace,
'afterUserWin' => $afterUserWin,
],
];
}
/**
* Exercise NPC nation creation and the same-month troop leader follow-up.
*
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonNpcNationLifecycleTrace(
array $environment,
int $generalIdBeforeAction,
int $nationIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$actionEnvironment = [
'year' => $environment['year'],
'month' => $environment['month'],
'startyear' => $environment['startyear'],
'turnterm' => $environment['turnterm'],
'turntime' => $environment['turntime'],
'show_img_level' => $environment['show_img_level'] ?? 3,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $environment['fiction'] ?? [0],
];
$gameStorage = KVStorage::getStorage($db, 'game_env');
$leaderCounterBefore = (int)($gameStorage->lastNPCTroopLeaderID ?? 0);
(new \sammo\Event\Action\RaiseNPCNation())->run($actionEnvironment);
$createdNationIds = array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction),
);
if ($createdNationIds === []) {
throw new \RuntimeException('NpcNationLifecycle failed to create a nation');
}
$createdNationGeneralCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i',
$nationId,
),
$createdNationIds,
);
$afterRaise = [
'createdNationCount' => count($createdNationIds),
'createdGeneralCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general WHERE no > %i',
$generalIdBeforeAction,
),
'generalCountsPerCreatedNation' => array_values(array_unique($createdNationGeneralCounts)),
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
(new \sammo\Event\Action\ProvideNPCTroopLeader())->run($actionEnvironment);
$leaderIds = array_map(
'intval',
$db->queryFirstColumn('SELECT no FROM general WHERE no > %i AND npc = 5 ORDER BY no', $generalIdBeforeAction),
);
if ($leaderIds === []) {
throw new \RuntimeException('NpcNationLifecycle failed to create troop leaders');
}
$leaderNationCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i AND npc = 5',
$nationId,
),
array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE level > 0 ORDER BY nation'),
),
);
$gatherTurnCounts = array_map(
static fn(int $generalId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general_turn WHERE general_id = %i AND action = %s',
$generalId,
'che_집합',
),
$leaderIds,
);
$gameStorage->resetCache();
$afterProvide = [
'createdLeaderCount' => count($leaderIds),
'leaderCountsPerActiveNation' => array_values(array_unique($leaderNationCounts)),
'troopCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM troop WHERE troop_leader IN %li',
$leaderIds,
),
'gatherTurnCounts' => array_values(array_unique($gatherTurnCounts)),
'leaderCounterDelta' => (int)$gameStorage->lastNPCTroopLeaderID - $leaderCounterBefore,
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'NpcNationLifecycle',
'phases' => [
'afterRaise' => $afterRaise,
'afterProvide' => $afterProvide,
],
];
}
/**
* Exercise nation betting from monthly open through real user bets and settlement.
*
* @param array<string, mixed> $request
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonNationBettingLifecycleTrace(
array $request,
array $environment,
int $eventIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$args = $request['args'] ?? [];
if (!is_array($args) || count($args) > 2) {
throw new \InvalidArgumentException('NationBettingLifecycle args must have at most two entries');
}
$bettorGeneralIds = comparisonIntegerList(
$request['bettorGeneralIds'] ?? [],
'bettorGeneralIds',
);
$bettorOwnerIds = comparisonIntegerList(
$request['bettorOwnerIds'] ?? [],
'bettorOwnerIds',
);
if (count($bettorGeneralIds) !== 2 || count($bettorOwnerIds) !== 2) {
throw new \InvalidArgumentException(
'NationBettingLifecycle requires exactly two bettorGeneralIds and bettorOwnerIds',
);
}
$year = $environment['year'];
$month = $environment['month'];
$startYear = $environment['startyear'];
(new \sammo\Event\Action\OpenNationBetting(...$args))->run([
'year' => $year,
'month' => $month,
'startyear' => $startYear,
]);
$gameStorage = KVStorage::getStorage($db, 'game_env');
$gameStorage->resetCache();
$bettingId = (int)$gameStorage->last_betting_id;
$betting = new Betting($bettingId);
$bettingInfo = $betting->getInfo();
$candidateNationIds = array_map(
static fn(\sammo\DTO\SelectItem $candidate): int => (int)($candidate->aux['nation'] ?? 0),
$bettingInfo->candidates,
);
if (count($candidateNationIds) !== 2) {
throw new \RuntimeException('NationBettingLifecycle requires exactly two candidates');
}
$finishEventCount = (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id > %i AND action LIKE %s',
$eventIdBeforeAction,
"%FinishNationBetting%{$bettingId}%",
);
$afterOpen = [
'bettingId' => $bettingId,
'candidateNationIds' => $candidateNationIds,
'systemBonus' => (int)($db->queryFirstField(
'SELECT amount FROM ng_betting WHERE betting_id = %i AND general_id = 0',
$bettingId,
) ?? 0),
'finishEventCount' => $finishEventCount,
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
$betting->bet($bettorGeneralIds[0], $bettorOwnerIds[0], [1], 100);
$betting->bet($bettorGeneralIds[1], $bettorOwnerIds[1], [0], 100);
$afterBets = [
'bets' => array_map(
static fn(array $row): array => [
'generalId' => (int)$row['general_id'],
'ownerId' => $row['user_id'] === null ? null : (int)$row['user_id'],
'selectionKey' => $row['betting_type'],
'amount' => (int)$row['amount'],
],
iterator_to_array($db->query(
'SELECT general_id, user_id, betting_type, amount FROM ng_betting WHERE betting_id = %i ORDER BY general_id',
$bettingId,
)),
),
'inheritancePrevious' => array_map(
static function (int $ownerId): int {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
return is_array($value) ? (int)($value[0] ?? 0) : 0;
},
$bettorOwnerIds,
),
'spentRank' => array_map(
static fn(int $generalId): int => (int)(DB::db()->queryFirstField(
'SELECT value FROM rank_data WHERE general_id = %i AND type = %s',
$generalId,
\sammo\Enums\RankColumn::inherit_point_spent_dynamic->value,
) ?? 0),
$bettorGeneralIds,
),
];
$db->update('nation', ['level' => 0], 'nation = %i', $candidateNationIds[1]);
$finishResult = (new \sammo\Event\Action\FinishNationBetting($bettingId))->run([
'year' => $year,
'month' => $month + 1,
'startyear' => $startYear,
]);
$settledInfo = (new Betting($bettingId))->getInfo();
$afterFinish = [
'result' => $finishResult[1] ?? null,
'finished' => $settledInfo->finished,
'winner' => $settledInfo->winner,
'inheritancePrevious' => array_map(
static function (int $ownerId): int {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
return is_array($value) ? (int)($value[0] ?? 0) : 0;
},
$bettorOwnerIds,
),
'earnedRank' => array_map(
static fn(int $generalId): int => (int)(DB::db()->queryFirstField(
'SELECT value FROM rank_data WHERE general_id = %i AND type = %s',
$generalId,
\sammo\Enums\RankColumn::inherit_point_earned_by_action->value,
) ?? 0),
$bettorGeneralIds,
),
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'NationBettingLifecycle',
'phases' => [
'afterOpen' => $afterOpen,
'afterBets' => $afterBets,
'afterFinish' => $afterFinish,
],
];
}
function comparisonMonthlyEventTraceMain(): void
{
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
$actionName = $request['action'] ?? null;
$supportedActions = [
'UpdateCitySupply',
'UpdateNationLevel',
'RaiseDisaster',
'ProcessSemiAnnual',
'ProcessWarIncome',
'CreateAdminNPC',
'CreateManyNPC',
'RegNPC',
'RegNeutralNPC',
'RaiseNPCNation',
'NpcNationLifecycle',
'RaiseInvader',
'InvaderLifecycle',
'AutoDeleteInvader',
'InvaderEnding',
'ChangeCity',
'ProvideNPCTroopLeader',
'NationBettingLifecycle',
'OpenNationBetting',
'FinishNationBetting',
'BlockScoutAction',
'UnblockScoutAction',
'AssignGeneralSpeciality',
'AddGlobalBetray',
'LostUniqueItem',
'MergeInheritPointRank',
'ProcessIncome',
'NoticeToHistoryLog',
'NewYear',
'ResetOfficerLock',
'PreUpdateMonthly',
'PostUpdateMonthly',
'MonthlyBoundary',
];
if (!in_array($actionName, $supportedActions, true)) {
throw new \InvalidArgumentException('unsupported monthly action');
}
$setup = $request['setup'] ?? [];
if (!is_array($setup)) {
throw new \InvalidArgumentException('setup must be an object');
}
foreach (['city', 'nation', 'general'] as $table) {
$rows = $setup[$table] ?? [];
if (!is_array($rows)) {
throw new \InvalidArgumentException("setup.{$table} must be an array");
}
foreach ($rows as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || !is_array($row['values'] ?? null)) {
throw new \InvalidArgumentException("invalid setup.{$table} row");
}
comparisonMonthlyPatch($table, $row['id'], $row['values']);
}
}
if (($setup['resetCities'] ?? false) === true) {
DB::db()->query('UPDATE city SET nation = 0, level = 1');
foreach ($setup['city'] ?? [] as $row) {
comparisonMonthlyPatch('city', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherCities'] ?? false) === true) {
$keepCityIds = comparisonIntegerList($setup['keepCityIds'] ?? [], 'keepCityIds');
if ($keepCityIds === []) {
throw new \InvalidArgumentException('deleteOtherCities requires keepCityIds');
}
DB::db()->query('DELETE FROM city WHERE city NOT IN %li', $keepCityIds);
}
if (($setup['resetGenerals'] ?? false) === true) {
DB::db()->query(
'UPDATE general SET nation = 0, officer_level = 1, npc = 2, killturn = 0, horse = %s, weapon = %s, book = %s, item = %s',
'None',
'None',
'None',
'None',
);
foreach ($setup['general'] ?? [] as $row) {
comparisonMonthlyPatch('general', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherGenerals'] ?? false) === true) {
$keepGeneralIds = comparisonIntegerList($setup['keepGeneralIds'] ?? [], 'keepGeneralIds');
if (!$keepGeneralIds) {
throw new \InvalidArgumentException('deleteOtherGenerals requires keepGeneralIds');
}
DB::db()->query('DELETE FROM general_turn WHERE general_id NOT IN %li', $keepGeneralIds);
DB::db()->query('DELETE FROM rank_data WHERE general_id NOT IN %li', $keepGeneralIds);
DB::db()->query('DELETE FROM general WHERE no NOT IN %li', $keepGeneralIds);
}
if (($setup['resetNations'] ?? false) === true) {
DB::db()->query('UPDATE nation SET level = 0, gennum = 0, tech = 0');
foreach ($setup['nation'] ?? [] as $row) {
comparisonMonthlyPatch('nation', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherNations'] ?? false) === true) {
$keepNationIds = comparisonIntegerList($setup['keepNationIds'] ?? [], 'keepNationIds');
if (!$keepNationIds) {
throw new \InvalidArgumentException('deleteOtherNations requires keepNationIds');
}
DB::db()->query(
'DELETE FROM diplomacy WHERE me NOT IN %li OR you NOT IN %li',
$keepNationIds,
$keepNationIds,
);
DB::db()->query('DELETE FROM nation_turn WHERE nation_id NOT IN %li', $keepNationIds);
DB::db()->query('DELETE FROM nation WHERE nation NOT IN %li', $keepNationIds);
}
if (($setup['resetUniqueOccupancy'] ?? false) === true) {
DB::db()->delete('ng_auction', '`type` = %s AND finished = 0', 'uniqueItem');
DB::db()->query('DELETE FROM storage WHERE namespace LIKE %s', 'ut\\_%');
}
if (($setup['clearNeutralAuctions'] ?? false) === true) {
DB::db()->delete(
'ng_auction',
'`type` IN %ls AND host_general_id = 0',
['buyRice', 'sellRice'],
);
}
foreach (comparisonIntegerList($setup['clearNationTurnIds'] ?? [], 'clearNationTurnIds') as $nationId) {
DB::db()->delete('nation_turn', 'nation_id = %i', $nationId);
}
foreach (comparisonIntegerList($setup['clearInheritanceOwnerIds'] ?? [], 'clearInheritanceOwnerIds') as $ownerId) {
DB::db()->delete('storage', 'namespace = %s', "inheritance_{$ownerId}");
}
if (($setup['clearBetting'] ?? false) === true) {
DB::db()->query('DELETE FROM ng_betting');
DB::db()->query('DELETE FROM storage WHERE namespace = %s', 'betting');
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$gameStorage->setValue('last_betting_id', 0);
}
foreach ($setup['bettingInfo'] ?? [] as $rawBettingInfo) {
if (!is_array($rawBettingInfo) || !is_int($rawBettingInfo['id'] ?? null)) {
throw new \InvalidArgumentException('invalid setup.bettingInfo row');
}
KVStorage::getStorage(DB::db(), 'betting')->setValue(
"id_{$rawBettingInfo['id']}",
$rawBettingInfo,
);
}
foreach ($setup['bet'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['betting_id'] ?? null)
|| !is_int($row['general_id'] ?? null)
|| !is_string($row['betting_type'] ?? null)
|| !is_int($row['amount'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.bet row');
}
DB::db()->insert('ng_betting', [
'betting_id' => $row['betting_id'],
'general_id' => $row['general_id'],
'user_id' => $row['user_id'] ?? null,
'betting_type' => $row['betting_type'],
'amount' => $row['amount'],
]);
}
foreach ($setup['inheritancePrevious'] ?? [] as $ownerId => $value) {
if (!is_numeric($ownerId) || !is_int($value)) {
throw new \InvalidArgumentException('invalid setup.inheritancePrevious entry');
}
KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->setValue('previous', [$value, null]);
}
foreach ($setup['inheritancePoints'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['ownerId'] ?? null)
|| !is_string($row['key'] ?? null)
|| !is_numeric($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.inheritancePoints row');
}
KVStorage::getStorage(DB::db(), "inheritance_{$row['ownerId']}")->setValue(
$row['key'],
[$row['value'], null],
);
}
foreach ($setup['rankData'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_string($row['type'] ?? null)
|| !is_int($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.rankData row');
}
$nationId = (int)(DB::db()->queryFirstField(
'SELECT nation FROM general WHERE no = %i',
$row['generalId'],
) ?? 0);
DB::db()->insertUpdate('rank_data', [
'general_id' => $row['generalId'],
'nation_id' => $nationId,
'type' => $row['type'],
'value' => $row['value'],
], [
'nation_id' => $nationId,
'value' => $row['value'],
]);
}
foreach ($setup['generalAccessLog'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_int($row['refreshScoreTotal'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.generalAccessLog row');
}
DB::db()->insertUpdate('general_access_log', [
'general_id' => $row['generalId'],
'refresh_score_total' => $row['refreshScoreTotal'],
], [
'refresh_score_total' => $row['refreshScoreTotal'],
]);
}
foreach ($setup['nationEnvironment'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['nationId'] ?? null)
|| !is_string($row['key'] ?? null)
|| !array_key_exists('value', $row)
) {
throw new \InvalidArgumentException('invalid setup.nationEnvironment row');
}
KVStorage::getStorage(DB::db(), $row['nationId'], 'nation_env')->setValue(
$row['key'],
$row['value'],
);
}
if (($setup['resetDiplomacy'] ?? false) === true) {
DB::db()->query('DELETE FROM diplomacy');
}
foreach ($setup['diplomacy'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['fromNationId'] ?? null)
|| !is_int($row['toNationId'] ?? null)
|| !is_int($row['state'] ?? null)
|| !is_int($row['term'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.diplomacy row');
}
$diplomacyPatch = [
'me' => $row['fromNationId'],
'you' => $row['toNationId'],
'state' => $row['state'],
'term' => $row['term'],
'dead' => $row['dead'] ?? 0,
];
DB::db()->insertUpdate('diplomacy', $diplomacyPatch, $diplomacyPatch);
}
if (($setup['resetEvents'] ?? false) === true) {
DB::db()->query('DELETE FROM event');
}
foreach ($setup['event'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || !is_array($row['action'] ?? null)) {
throw new \InvalidArgumentException('invalid setup.event row');
}
$eventPatch = [
'id' => $row['id'],
'target' => $row['target'] ?? 'month',
'priority' => $row['priority'] ?? 1000,
'condition' => Json::encode($row['condition'] ?? true),
'action' => Json::encode($row['action']),
];
DB::db()->insertUpdate('event', $eventPatch, $eventPatch);
}
$gameEnvironmentPatch = $setup['gameEnvironment'] ?? [];
if (!is_array($gameEnvironmentPatch)) {
throw new \InvalidArgumentException('setup.gameEnvironment must be an object');
}
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$worldPatch = $setup['world'] ?? [];
if (!is_array($worldPatch)) {
throw new \InvalidArgumentException('setup.world must be an object');
}
if (array_key_exists('hiddenSeed', $worldPatch)) {
if (!is_string($worldPatch['hiddenSeed']) || $worldPatch['hiddenSeed'] === '') {
throw new \InvalidArgumentException('setup.world.hiddenSeed must be a non-empty string');
}
UniqueConst::$hiddenSeed = $worldPatch['hiddenSeed'];
}
foreach ([
'isunited',
'refreshLimit',
'block_change_scout',
'lastNPCTroopLeaderID',
'tournament',
'tnmt_trig',
'tnmt_pattern',
'prev_winner',
] as $key) {
if (array_key_exists($key, $gameEnvironmentPatch)) {
$gameStorage->setValue($key, $gameEnvironmentPatch[$key]);
}
}
if (($setup['setAllCitiesNation'] ?? null) !== null) {
if (!is_int($setup['setAllCitiesNation'])) {
throw new \InvalidArgumentException('setup.setAllCitiesNation must be an integer');
}
DB::db()->update('city', ['nation' => $setup['setAllCitiesNation']], true);
}
$db = DB::db();
$generalIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(no), 0) FROM general') ?? 0);
$nationIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(nation), 0) FROM nation') ?? 0);
$eventIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM event') ?? 0);
$auctionIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM ng_auction') ?? 0);
$snapshotRequest = ['observe' => $request['observe'] ?? []];
$snapshotRequest['observe']['logAfterId'] =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0);
$worldHistoryAfterId =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM world_history') ?? 0);
$before = comparisonTurnStateSnapshot($snapshotRequest);
$beforeDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
$environment = $request['environment'] ?? [];
if (!is_array($environment)) {
throw new \InvalidArgumentException('environment must be an object');
}
$year = $environment['year'] ?? null;
$month = $environment['month'] ?? null;
$startYear = $environment['startyear'] ?? null;
if (!is_int($year) || !is_int($month) || !is_int($startYear)) {
throw new \InvalidArgumentException('environment year, month, and startyear must be integers');
}
if (($setup['syncEnvironment'] ?? false) === true) {
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$environmentValues = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
'killturn' => $environment['killturn'] ?? $gameStorage->killturn,
'turnterm' => $environment['turnterm'] ?? $gameStorage->turnterm,
];
if (isset($environment['starttime'])) {
if (!is_string($environment['starttime']) || $environment['starttime'] === '') {
throw new \InvalidArgumentException('environment.starttime must be a non-empty string');
}
$environmentValues['starttime'] = $environment['starttime'];
}
foreach ($environmentValues as $key => $value) {
$gameStorage->setValue($key, $value);
}
if ($actionName === 'MonthlyBoundary') {
$before = comparisonTurnStateSnapshot($snapshotRequest);
$beforeDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
}
}
if ($actionName === 'InvaderLifecycle') {
foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) {
if (!array_key_exists($requiredEnvironmentKey, $environment)) {
throw new \InvalidArgumentException(
"InvaderLifecycle requires environment.{$requiredEnvironmentKey}",
);
}
}
echo json_encode(
comparisonInvaderLifecycleTrace(
$request,
$environment,
$generalIdBeforeAction,
$nationIdBeforeAction,
$eventIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
if ($actionName === 'NpcNationLifecycle') {
foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) {
if (!array_key_exists($requiredEnvironmentKey, $environment)) {
throw new \InvalidArgumentException(
"NpcNationLifecycle requires environment.{$requiredEnvironmentKey}",
);
}
}
echo json_encode(
comparisonNpcNationLifecycleTrace(
$environment,
$generalIdBeforeAction,
$nationIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
if ($actionName === 'NationBettingLifecycle') {
echo json_encode(
comparisonNationBettingLifecycleTrace(
$request,
$environment,
$eventIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
$actionClass = "\\sammo\\Event\\Action\\{$actionName}";
if (in_array($actionName, ['PreUpdateMonthly', 'PostUpdateMonthly', 'MonthlyBoundary'], true)) {
$action = null;
} elseif ($actionName === 'ProcessSemiAnnual') {
$resource = $request['resource'] ?? null;
if (!is_string($resource)) {
throw new \InvalidArgumentException('ProcessSemiAnnual requires resource');
}
$action = new $actionClass($resource);
} elseif (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseInvader', 'AutoDeleteInvader', 'ChangeCity', 'OpenNationBetting', 'FinishNationBetting', 'BlockScoutAction', 'UnblockScoutAction', 'AddGlobalBetray', 'LostUniqueItem', 'ProcessIncome', 'NoticeToHistoryLog'], true)) {
$args = $request['args'] ?? [];
if (!is_array($args)) {
throw new \InvalidArgumentException("{$actionName} args must be an array");
}
if ($actionName === 'CreateManyNPC' && count($args) > 2) {
throw new \InvalidArgumentException('CreateManyNPC args must have at most two entries');
}
if ($actionName === 'RaiseInvader' && count($args) > 4) {
throw new \InvalidArgumentException('RaiseInvader args must have at most four entries');
}
if ($actionName === 'ChangeCity' && count($args) !== 2) {
throw new \InvalidArgumentException('ChangeCity args must have exactly two entries');
}
if ($actionName === 'OpenNationBetting' && count($args) > 2) {
throw new \InvalidArgumentException('OpenNationBetting args must have at most two entries');
}
if ($actionName === 'FinishNationBetting' && count($args) !== 1) {
throw new \InvalidArgumentException('FinishNationBetting args must have exactly one entry');
}
if (in_array($actionName, ['BlockScoutAction', 'UnblockScoutAction'], true) && count($args) > 1) {
throw new \InvalidArgumentException("{$actionName} args must have at most one entry");
}
if ($actionName === 'AddGlobalBetray' && count($args) > 2) {
throw new \InvalidArgumentException('AddGlobalBetray args must have at most two entries');
}
if ($actionName === 'LostUniqueItem' && count($args) > 1) {
throw new \InvalidArgumentException('LostUniqueItem args must have at most one entry');
}
if ($actionName === 'ProcessIncome' && count($args) !== 1) {
throw new \InvalidArgumentException('ProcessIncome args must have exactly one entry');
}
if ($actionName === 'NoticeToHistoryLog' && (count($args) < 1 || count($args) > 2)) {
throw new \InvalidArgumentException('NoticeToHistoryLog args must have one or two entries');
}
$action = new $actionClass(...$args);
} else {
$action = new $actionClass();
}
$actionEnvironment = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
];
if (isset($environment['currentEventID'])) {
if (!is_int($environment['currentEventID'])) {
throw new \InvalidArgumentException('currentEventID must be an integer');
}
$actionEnvironment['currentEventID'] = $environment['currentEventID'];
}
if ($actionName === 'UpdateNationLevel') {
$killturn = $environment['killturn'] ?? null;
$turnterm = $environment['turnterm'] ?? null;
if (!is_int($killturn) || !is_int($turnterm) || $turnterm <= 0) {
throw new \InvalidArgumentException('UpdateNationLevel requires integer killturn and positive turnterm');
}
$actionEnvironment['killturn'] = $killturn;
$actionEnvironment['turnterm'] = $turnterm;
}
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'ProvideNPCTroopLeader'], true)) {
$turnterm = $environment['turnterm'] ?? null;
$turntime = $environment['turntime'] ?? null;
$showImgLevel = $environment['show_img_level'] ?? 3;
$fiction = $environment['fiction'] ?? [0];
if (
!is_int($turnterm)
|| $turnterm <= 0
|| !is_string($turntime)
|| !is_int($showImgLevel)
|| !is_array($fiction)
) {
throw new \InvalidArgumentException(
"{$actionName} requires positive turnterm, string turntime, integer show_img_level, and array fiction",
);
}
$actionEnvironment += [
'turnterm' => $turnterm,
'turntime' => $turntime,
'show_img_level' => $showImgLevel,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $fiction,
];
}
if ($actionName === 'PreUpdateMonthly') {
if (!preUpdateMonthly()) {
throw new \RuntimeException('preUpdateMonthly failed');
}
} elseif ($actionName === 'PostUpdateMonthly') {
$seedYear = $environment['seedYear'] ?? null;
$seedMonth = $environment['seedMonth'] ?? null;
if (!is_int($seedYear) || !is_int($seedMonth)) {
throw new \InvalidArgumentException('PostUpdateMonthly requires seedYear and seedMonth');
}
postUpdateMonthly(new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'monthly',
$seedYear,
$seedMonth,
))));
} elseif ($actionName === 'MonthlyBoundary') {
$turntime = $environment['turntime'] ?? null;
if (!is_string($turntime) || $turntime === '') {
throw new \InvalidArgumentException('MonthlyBoundary requires turntime');
}
$gameStorage = KVStorage::getStorage($db, 'game_env');
$seedYear = (int)$gameStorage->year;
$seedMonth = (int)$gameStorage->month;
TurnExecutionHelper::runEventHandler($db, $gameStorage, Enums\EventTarget::PreMonth);
if (!preUpdateMonthly()) {
throw new \RuntimeException('preUpdateMonthly failed');
}
turnDate($turntime);
$gameStorage->resetCache();
if ((int)$gameStorage->month === 1) {
checkStatistic();
}
TurnExecutionHelper::runEventHandler($db, $gameStorage, Enums\EventTarget::Month);
postUpdateMonthly(new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'monthly',
$seedYear,
$seedMonth,
))));
} else {
$action->run($actionEnvironment);
}
unset($action);
gc_collect_cycles();
$after = comparisonTurnStateSnapshot($snapshotRequest);
$afterDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
$createdGenerals = [];
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'ProvideNPCTroopLeader'], true)) {
foreach ($db->query('SELECT * FROM general WHERE no > %i ORDER BY no', $generalIdBeforeAction) as $row) {
$generalId = (int)$row['no'];
$turnRows = $db->query(
'SELECT turn_idx, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
);
$createdGenerals[] = comparisonPickRow(
$row,
[
'id' => 'no',
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'experience' => 'experience',
'dedication' => 'dedication',
'officerLevel' => 'officer_level',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'turnTime' => 'turntime',
'killturn' => 'killturn',
'age' => 'age',
'npcState' => 'npc',
'npcOriginalState' => 'npc_org',
'affinity' => 'affinity',
'personality' => 'personal',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'specAge' => 'specage',
'specAge2' => 'specage2',
'bornYear' => 'bornyear',
'deadYear' => 'deadyear',
'picture' => 'picture',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
],
) + [
'turnCount' => count($turnRows),
'turnActions' => array_values(array_unique(array_column($turnRows, 'action'))),
'rankCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM rank_data WHERE general_id = %i',
$generalId,
),
'nonZeroRankCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM rank_data WHERE general_id = %i AND value != 0',
$generalId,
),
'troop' => $db->queryFirstRow(
'SELECT troop_leader AS id, nation AS nationId, name FROM troop WHERE troop_leader = %i',
$generalId,
) ?: null,
];
}
}
$createdNations = [];
if (in_array($actionName, ['RaiseNPCNation', 'RaiseInvader'], true)) {
foreach ($db->query('SELECT * FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction) as $row) {
$nationId = (int)$row['nation'];
$createdNations[] = comparisonPickRow(
$row,
[
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'generalCount' => 'gennum',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'bill' => 'bill',
'rate' => 'rate',
'scout' => 'scout',
'war' => 'war',
'strategicCommandLimit' => 'strategic_cmd_limit',
'surrenderLimit' => 'surlimit',
'aux' => 'aux',
],
['aux'],
) + [
'nationTurnCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM nation_turn WHERE nation_id = %i',
$nationId,
),
'diplomacyCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM diplomacy WHERE me = %i OR you = %i',
$nationId,
$nationId,
),
];
}
}
$response = [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => $actionName,
'before' => $before,
'beforeDetails' => $beforeDetails,
'after' => $after,
'afterDetails' => $afterDetails,
];
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC'], true)) {
$response['createdGenerals'] = $createdGenerals;
}
if (in_array($actionName, ['RaiseNPCNation', 'RaiseInvader'], true)) {
$response['createdGenerals'] = $createdGenerals;
$response['createdNations'] = $createdNations;
}
if ($actionName === 'ProvideNPCTroopLeader') {
$response['createdGenerals'] = $createdGenerals;
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'lastNPCTroopLeaderID',
]);
}
if ($actionName === 'PreUpdateMonthly') {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'develcost',
]);
}
if ($actionName === 'PostUpdateMonthly') {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'tournament',
'tnmt_trig',
'tnmt_pattern',
'tnmt_type',
'tnmt_auto',
'phase',
]);
$response['createdNeutralAuctions'] = array_map(
static fn(array $row): array => [
'id' => (int)$row['id'],
'type' => $row['type'],
'target' => $row['target'],
'detail' => Json::decode($row['detail']),
],
iterator_to_array(
$db->query(
'SELECT id, `type`, target, detail FROM ng_auction WHERE id > %i AND host_general_id = 0 ORDER BY id',
$auctionIdBeforeAction,
),
),
);
}
if (in_array($actionName, ['OpenNationBetting', 'FinishNationBetting'], true)) {
$bettingStorage = KVStorage::getStorage(DB::db(), 'betting');
$response['betting'] = $bettingStorage->getAll();
$response['bets'] = iterator_to_array(
$db->query(
'SELECT betting_id AS bettingId, general_id AS generalId, user_id AS userId, betting_type AS selectionKey, amount FROM ng_betting ORDER BY id',
),
);
$response['createdEvents'] = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'targetCode' => 'target',
'priority' => 'priority',
'condition' => 'condition',
'action' => 'action',
],
['condition', 'action'],
),
iterator_to_array(
$db->query(
'SELECT id, target, priority, `condition`, action FROM event WHERE id > %i ORDER BY id',
$eventIdBeforeAction,
),
),
);
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'last_betting_id',
]);
$response['inheritancePrevious'] = [];
foreach (comparisonIntegerList($request['observe']['ownerIds'] ?? [], 'ownerIds') as $ownerId) {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
$response['inheritancePrevious'][(string)$ownerId] = is_array($value) ? ($value[0] ?? 0) : 0;
}
}
if (in_array($actionName, ['BlockScoutAction', 'UnblockScoutAction'], true)) {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'block_change_scout',
]);
}
if ($actionName === 'RaiseInvader') {
$response['createdEvents'] = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'targetCode' => 'target',
'priority' => 'priority',
'condition' => 'condition',
'action' => 'action',
],
['condition', 'action'],
),
iterator_to_array(
$db->query(
'SELECT id, target, priority, `condition`, action FROM event WHERE id > %i ORDER BY id',
$eventIdBeforeAction,
),
),
);
$response['createdDiplomacy'] = iterator_to_array(
$db->query(
'SELECT me AS fromNationId, you AS toNationId, state, term FROM diplomacy WHERE me > %i OR you > %i ORDER BY me, you',
$nationIdBeforeAction,
$nationIdBeforeAction,
),
);
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$response['gameEnvironment'] = $gameStorage->getValues([
'isunited',
'turnterm',
'block_change_scout',
'refreshLimit',
]);
}
if (in_array($actionName, ['AutoDeleteInvader', 'InvaderEnding'], true)) {
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$response['gameEnvironment'] = $gameStorage->getValues([
'isunited',
'turnterm',
'block_change_scout',
'refreshLimit',
]);
}
if (($request['compactOutput'] ?? false) === true) {
$observedGeneralTurns = [];
foreach (comparisonIntegerList($request['observe']['generalIds'] ?? [], 'generalIds') as $generalId) {
$observedGeneralTurns[(string)$generalId] = iterator_to_array(
$db->query(
'SELECT turn_idx AS turnIndex, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
),
);
}
$response = [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => $actionName,
'afterDetails' => $afterDetails,
'createdGeneralCount' => count($createdGenerals),
'firstCreatedGenerals' => array_slice($createdGenerals, 0, 3),
'createdNations' => $response['createdNations'] ?? [],
'createdEvents' => $response['createdEvents'] ?? [],
'createdDiplomacy' => $response['createdDiplomacy'] ?? [],
'gameEnvironment' => $response['gameEnvironment'] ?? [],
'logs' => $after['logs'],
'observedGeneralTurns' => $observedGeneralTurns,
'remainingEventIds' => array_map(
'intval',
$db->queryFirstColumn('SELECT id FROM event ORDER BY id'),
),
];
}
echo json_encode(
$response,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
}
comparisonMonthlyEventTraceMain();
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
$groups = [
'nation' => ['ActionNationType', 'ActionNationType'],
'domestic' => ['ActionSpecialDomestic', 'ActionSpecialDomestic'],
'war' => ['ActionSpecialWar', 'ActionSpecialWar'],
'personality' => ['ActionPersonality', 'ActionPersonality'],
];
$result = [];
foreach ($groups as $kind => [$directory, $namespace]) {
foreach (glob(__DIR__ . "/../sammo/{$directory}/*.php") ?: [] as $path) {
$key = basename($path, '.php');
if ($key === 'None' || str_starts_with($key, 'che_event_') || $key === 'che_거상') {
continue;
}
$className = "\\sammo\\{$namespace}\\{$key}";
$trait = new $className();
$result[$kind][$key] = [
'name' => $trait->getName(),
'info' => $trait->getInfo(),
];
}
ksort($result[$kind]);
}
echo Json::encode($result);
-789
View File
@@ -1,789 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\Enums\RankColumn;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
require_once __DIR__ . '/turn_state_snapshot.php';
final class TurnComparisonTracingRNG implements RNG
{
private int $sequence = 0;
/** @var list<array<string, mixed>> */
public array $calls = [];
public function __construct(private readonly RNG $inner)
{
}
public static function getMaxInt(): int
{
return LiteHashDRBG::getMaxInt();
}
public function nextBytes(int $bytes): string
{
$value = $this->inner->nextBytes($bytes);
$this->record('nextBytes', ['bytes' => $bytes], bin2hex($value));
return $value;
}
public function nextBits(int $bits): string
{
$value = $this->inner->nextBits($bits);
$this->record('nextBits', ['bits' => $bits], bin2hex($value));
return $value;
}
public function nextInt(?int $max = null): int
{
$value = $this->inner->nextInt($max);
$this->record('nextInt', ['maxInclusive' => $max], $value);
return $value;
}
public function nextFloat1(): float
{
$value = $this->inner->nextFloat1();
$this->record('nextFloat1', [], $value);
return $value;
}
private function record(string $operation, array $arguments, mixed $result): void
{
$this->calls[] = [
'seq' => $this->sequence++,
'operation' => $operation,
'arguments' => $arguments === [] ? (object)[] : $arguments,
'result' => $result,
];
}
}
/** @return array<string, mixed> */
function comparisonMappedPatch(array $row, array $mapping): array
{
$patch = [];
foreach ($mapping as $canonical => $legacy) {
if (array_key_exists($canonical, $row)) {
$patch[$legacy] = $row[$canonical];
}
}
return $patch;
}
function comparisonApplyTurnFixtureSetup(mixed $setup): void
{
if ($setup === null) {
return;
}
if (!is_array($setup)) {
throw new \InvalidArgumentException('setup must be an object');
}
$db = DB::db();
$world = $setup['world'] ?? null;
if ($world !== null) {
if (!is_array($world)) {
throw new \InvalidArgumentException('setup.world must be an object');
}
$game = KVStorage::getStorage($db, 'game_env');
foreach (['year', 'month', 'startyear', 'init_year', 'init_month'] as $key) {
$fixtureKey = match ($key) {
'startyear' => 'startYear',
'init_year' => 'initYear',
'init_month' => 'initMonth',
default => $key,
};
if (!array_key_exists($fixtureKey, $world)) {
continue;
}
$value = $world[$fixtureKey];
if (
!is_int($value)
|| $value < 1
|| (in_array($key, ['month', 'init_month'], true) && $value > 12)
) {
throw new \InvalidArgumentException("setup.world.{$fixtureKey} is invalid");
}
$game->setValue($key, $value);
}
$game->resetCache();
if (array_key_exists('hiddenSeed', $world)) {
$hiddenSeed = $world['hiddenSeed'];
if (!is_string($hiddenSeed) || $hiddenSeed === '' || strlen($hiddenSeed) > 256) {
throw new \InvalidArgumentException('setup.world.hiddenSeed is invalid');
}
UniqueConst::$hiddenSeed = $hiddenSeed;
}
if (array_key_exists('staticEventHandlers', $world)) {
$handlersByEvent = $world['staticEventHandlers'];
if (!is_array($handlersByEvent)) {
throw new \InvalidArgumentException('setup.world.staticEventHandlers must be an object');
}
foreach ($handlersByEvent as $eventType => $handlers) {
if (!is_string($eventType) || $eventType === '' || !is_array($handlers)) {
throw new \InvalidArgumentException('setup.world.staticEventHandlers is invalid');
}
foreach ($handlers as $handler) {
if (!is_string($handler) || $handler === '') {
throw new \InvalidArgumentException('setup.world.staticEventHandlers is invalid');
}
}
}
GameConst::$staticEventHandlers = $handlersByEvent;
}
}
foreach (['nations', 'cities', 'generals', 'troops', 'diplomacy'] as $collection) {
if (isset($setup[$collection]) && !is_array($setup[$collection])) {
throw new \InvalidArgumentException("setup.{$collection} must be an array");
}
}
$generalCooldowns = comparisonGeneralCooldownSelectors(
$setup['generalCooldowns'] ?? [],
'setup.generalCooldowns',
);
if ($generalCooldowns !== []) {
$nextExecuteStorage = KVStorage::getStorage($db, 'next_execute');
foreach ($setup['generalCooldowns'] as $entry) {
$nextAvailableTurn = $entry['nextAvailableTurn'] ?? null;
if (!is_int($nextAvailableTurn) || $nextAvailableTurn < 0) {
throw new \InvalidArgumentException(
'setup.generalCooldowns entries require a non-negative nextAvailableTurn',
);
}
$key = "next_execute_{$entry['generalId']}_{$entry['actionName']}";
$nextExecuteStorage->setValue($key, $nextAvailableTurn);
}
$nextExecuteStorage->resetCache();
}
if (isset($setup['randomFoundingCandidateCityIds'])) {
$candidateCityIds = comparisonIntegerList(
$setup['randomFoundingCandidateCityIds'],
'randomFoundingCandidateCityIds',
);
$db->update('city', ['level' => 4], '1 = 1');
if ($candidateCityIds !== []) {
$db->update('city', ['level' => 5], 'city IN %li', $candidateCityIds);
}
}
if (($setup['isolateWorld'] ?? false) === true) {
$generalIds = array_values(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['generals'] ?? [],
));
$nationIds = array_values(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['nations'] ?? [],
));
if ($generalIds === [] || $nationIds === []) {
throw new \InvalidArgumentException('isolateWorld requires generals and nations');
}
$generalTemplate = $db->queryFirstRow(
'SELECT * FROM general WHERE no IN %li ORDER BY no LIMIT 1',
$generalIds,
);
if ($generalTemplate === null) {
throw new \InvalidArgumentException('isolateWorld requires at least one existing fixture general');
}
$generalTemplateId = (int)$generalTemplate['no'];
$rankDataTemplate = $db->query(
'SELECT `type`, `value` FROM rank_data WHERE general_id = %i',
$generalTemplateId,
);
$db->delete('general_turn', 'general_id NOT IN %li', $generalIds);
$db->delete('rank_data', 'general_id NOT IN %li', $generalIds);
$db->delete('nation_turn', 'nation_id NOT IN %li', $nationIds);
$db->delete('troop', '1 = 1');
$db->delete('diplomacy', '1 = 1');
$db->delete('general', 'no NOT IN %li', $generalIds);
foreach ($generalIds as $generalId) {
if ($db->queryFirstField('SELECT no FROM general WHERE no = %i', $generalId) !== null) {
continue;
}
$newGeneral = $generalTemplate;
$newGeneral['no'] = $generalId;
$newGeneral['owner'] = 0;
$newGeneral['name'] = "fixture-general-{$generalId}";
$db->insert('general', $newGeneral);
}
foreach ($generalIds as $generalId) {
foreach ($rankDataTemplate as $rankRow) {
$rankData = [
'general_id' => $generalId,
'type' => $rankRow['type'],
'value' => $rankRow['value'],
];
$db->insertUpdate('rank_data', $rankData, $rankData);
}
}
$db->delete('nation', 'nation NOT IN %li', $nationIds);
$db->update('city', [
'nation' => 0,
'supply' => 0,
'front' => 0,
'state' => 0,
'term' => 0,
'conflict' => '{}',
'officer_set' => 0,
], '1 = 1');
}
foreach ($setup['nations'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.nations requires positive integer ids');
}
$data = comparisonMappedPatch($row, [
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'war' => 'war',
'diplomacyLimit' => 'surlimit',
'generalCount' => 'gennum',
'power' => 'power',
'capitalRevision' => 'capset',
'strategicCommandLimit' => 'strategic_cmd_limit',
]);
if (isset($row['meta'])) {
$data['aux'] = Json::encode($row['meta']);
}
$data += [
'name' => "fixture-nation-{$row['id']}",
'color' => '#777777',
'capital' => 0,
'gold' => 0,
'rice' => 0,
'level' => 1,
'type' => 'che_중립',
'aux' => '{}',
];
$db->insertUpdate('nation', $data, $data);
$nationStor = KVStorage::getStorage($db, $row['id'], 'nation_env');
if (($setup['isolateWorld'] ?? false) === true) {
$nationStor->deleteValue('recv_assist');
$nationStor->deleteValue('resp_assist');
}
if (array_key_exists('nationEnv', $row)) {
if (!is_array($row['nationEnv'])) {
throw new \InvalidArgumentException('setup.nations.nationEnv must be an object');
}
foreach ($row['nationEnv'] as $key => $value) {
if (!is_string($key) || $key === '') {
throw new \InvalidArgumentException('setup.nations.nationEnv keys must be non-empty strings');
}
$nationStor->setValue($key, $value);
}
$nationStor->resetCache();
}
if (array_key_exists('turnLastByOfficerLevel', $row)) {
$turnLastByOfficerLevel = $row['turnLastByOfficerLevel'];
if (!is_array($turnLastByOfficerLevel)) {
throw new \InvalidArgumentException('setup.nations.turnLastByOfficerLevel must be an object');
}
foreach ($turnLastByOfficerLevel as $officerLevel => $lastTurn) {
$officerLevel = filter_var($officerLevel, FILTER_VALIDATE_INT);
if (
$officerLevel === false
|| $officerLevel < 5
|| $officerLevel > 12
|| !is_array($lastTurn)
) {
throw new \InvalidArgumentException(
'setup.nations.turnLastByOfficerLevel requires officer levels 5 through 12'
);
}
$nationStor->setValue("turn_last_{$officerLevel}", $lastTurn);
}
$nationStor->resetCache();
}
}
if (($setup['isolateWorld'] ?? false) === true && ($setup['nations'] ?? []) !== []) {
$maxNationId = max(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['nations'],
));
$db->query('ALTER TABLE nation AUTO_INCREMENT = %i', $maxNationId + 1);
}
foreach ($setup['cities'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.cities requires positive integer ids');
}
$patch = comparisonMappedPatch($row, [
'nationId' => 'nation',
'level' => 'level',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'supplyState' => 'supply',
'frontState' => 'front',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'state' => 'state',
'term' => 'term',
'trust' => 'trust',
'trade' => 'trade',
'officerSet' => 'officer_set',
]);
if (array_key_exists('conflictEntries', $row)) {
if (!is_array($row['conflictEntries'])) {
throw new \InvalidArgumentException('setup.cities.conflictEntries must be an array');
}
$orderedConflict = [];
foreach ($row['conflictEntries'] as $entry) {
if (
!is_array($entry)
|| count($entry) !== 2
|| !is_int($entry[0] ?? null)
|| $entry[0] < 1
|| !is_numeric($entry[1] ?? null)
) {
throw new \InvalidArgumentException(
'setup.cities.conflictEntries requires [positive nation id, numeric value] pairs'
);
}
$orderedConflict[$entry[0]] = $entry[1];
}
$patch['conflict'] = Json::encode($orderedConflict);
} elseif (array_key_exists('conflict', $row)) {
if (!is_array($row['conflict'])) {
throw new \InvalidArgumentException('setup.cities.conflict must be an object');
}
$patch['conflict'] = Json::encode($row['conflict']);
}
if ($patch !== []) {
$db->update('city', $patch, 'city = %i', $row['id']);
}
}
foreach ($setup['generals'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.generals requires positive integer ids');
}
$patch = comparisonMappedPatch($row, [
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'troopId' => 'troop',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'leadershipExp' => 'leadership_exp',
'strengthExp' => 'strength_exp',
'intelExp' => 'intel_exp',
'experience' => 'experience',
'dedication' => 'dedication',
'expLevel' => 'explevel',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'belong' => 'belong',
'permission' => 'permission',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'injury' => 'injury',
'age' => 'age',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'specAge' => 'specage',
'specAge2' => 'specage2',
'killTurn' => 'killturn',
'npcState' => 'npc',
'blockState' => 'block',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'personality' => 'personal',
'itemHorse' => 'horse',
'itemWeapon' => 'weapon',
'itemBook' => 'book',
'itemExtra' => 'item',
]);
if (isset($row['meta'])) {
$patch['aux'] = Json::encode($row['meta']);
}
if (array_key_exists('penalty', $row)) {
if (!is_array($row['penalty'])) {
throw new \InvalidArgumentException('setup.generals.penalty must be an object');
}
$patch['penalty'] = Json::encode($row['penalty']);
}
if (isset($row['lastTurn']) && is_array($row['lastTurn'])) {
$patch['last_turn'] = Json::encode($row['lastTurn']);
}
if ($patch !== []) {
$db->update('general', $patch, 'no = %i', $row['id']);
}
if (array_key_exists('nationId', $row)) {
$db->update('rank_data', [
'nation_id' => $row['nationId'],
], 'general_id = %i', $row['id']);
}
}
foreach ($setup['rankData'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_string($row['type'] ?? null)
|| RankColumn::tryFrom($row['type']) === null
|| !is_int($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.rankData row');
}
$nationId = (int)($db->queryFirstField(
'SELECT nation FROM general WHERE no = %i',
$row['generalId'],
) ?? 0);
$db->insertUpdate('rank_data', [
'general_id' => $row['generalId'],
'nation_id' => $nationId,
'type' => $row['type'],
'value' => $row['value'],
], [
'nation_id' => $nationId,
'value' => $row['value'],
]);
}
if (($setup['isolateWorld'] ?? false) === true && ($setup['generals'] ?? []) !== []) {
$maxGeneralId = max(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['generals'],
));
$db->query('ALTER TABLE general AUTO_INCREMENT = %i', $maxGeneralId + 1);
}
foreach ($setup['troops'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['id'] ?? null)
|| !is_int($row['nationId'] ?? null)
|| !is_string($row['name'] ?? null)
|| $row['id'] < 1
|| $row['nationId'] < 1
|| $row['name'] === ''
) {
throw new \InvalidArgumentException('setup.troops requires positive id/nationId and non-empty name');
}
$data = [
'troop_leader' => $row['id'],
'nation' => $row['nationId'],
'name' => $row['name'],
];
$db->insertUpdate('troop', $data, $data);
}
foreach ($setup['diplomacy'] ?? [] as $row) {
if (
!is_array($row) ||
!is_int($row['fromNationId'] ?? null) ||
!is_int($row['toNationId'] ?? null) ||
$row['fromNationId'] < 1 ||
$row['toNationId'] < 1
) {
throw new \InvalidArgumentException('setup.diplomacy requires positive integer nation ids');
}
$data = comparisonMappedPatch($row, [
'fromNationId' => 'me',
'toNationId' => 'you',
'state' => 'state',
'term' => 'term',
'dead' => 'dead',
]);
$data += ['state' => 3, 'term' => 0, 'dead' => 0];
$db->insertUpdate('diplomacy', $data, $data);
}
}
function comparisonRunTurnCommand(array $request): array
{
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required');
}
$kind = $request['kind'] ?? null;
if (!in_array($kind, ['general', 'nation', 'instantNation', 'troopJoinStaticEvent'], true)) {
throw new \InvalidArgumentException(
'kind must be general, nation, instantNation, or troopJoinStaticEvent',
);
}
$actorGeneralId = $request['actorGeneralId'] ?? null;
$action = $request['action'] ?? null;
$args = $request['args'] ?? null;
$includeLifecycle = $request['includeLifecycle'] ?? false;
if (!is_int($actorGeneralId) || $actorGeneralId < 1) {
throw new \InvalidArgumentException('actorGeneralId must be a positive integer');
}
if (!is_string($action) || $action === '') {
throw new \InvalidArgumentException('action must be a non-empty string');
}
if (!is_bool($includeLifecycle)) {
throw new \InvalidArgumentException('includeLifecycle must be a boolean');
}
if ($includeLifecycle && $kind !== 'general') {
throw new \InvalidArgumentException('includeLifecycle currently supports general commands only');
}
comparisonApplyTurnFixtureSetup($request['setup'] ?? null);
if ($includeLifecycle) {
DB::db()->insertUpdate('general_turn', [
'general_id' => $actorGeneralId,
'turn_idx' => 0,
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
], [
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
]);
}
$snapshotRequest = ['observe' => $request['observe'] ?? []];
$before = comparisonTurnStateSnapshot($snapshotRequest);
$db = DB::db();
$gameStorage = KVStorage::getStorage($db, 'game_env');
$gameStorage->resetCache();
$environment = $gameStorage->getAll();
if ($kind === 'troopJoinStaticEvent') {
if ($action !== \sammo\API\Troop\JoinTroop::class) {
throw new \InvalidArgumentException('troopJoinStaticEvent requires the JoinTroop API action');
}
$troopId = is_array($args) ? ($args['troopID'] ?? null) : null;
if (!is_int($troopId) || $troopId < 1) {
throw new \InvalidArgumentException('troopJoinStaticEvent requires a positive integer troopID');
}
$general = GeneralLite::createObjFromDB(
$actorGeneralId,
['troop'],
Enums\GeneralLiteQueryMode::Lite,
);
if ($general === null) {
throw new \RuntimeException('장수 정보를 불러올 수 없습니다.');
}
if ($general->getVar('troop') != 0) {
throw new \RuntimeException('이미 부대에 소속되어 있습니다.');
}
$nationId = $general->getNationID();
if ($nationId == 0) {
throw new \RuntimeException('국가에 소속되어 있지 않습니다.');
}
$troopExists = $db->queryFirstField(
'SELECT `troop_leader` FROM `troop` WHERE `troop_leader` = %i AND `nation` = %i',
$troopId,
$nationId,
);
if (!$troopExists) {
throw new \RuntimeException('부대가 올바르지 않습니다.');
}
GameConst::$staticEventHandlers = [
\sammo\API\Troop\JoinTroop::class => ['event_부대탑승즉시이동'],
];
$general->setVar('troop', $troopId);
StaticEventHandler::handleEvent($general, null, \sammo\API\Troop\JoinTroop::class, [], $args);
$general->applyDB($db);
$after = comparisonTurnStateSnapshot($snapshotRequest);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => 'none',
'outcome' => ['completed' => true],
],
'before' => $before,
'after' => $after,
'rng' => [],
];
}
$general = General::createObjFromDB($actorGeneralId);
if ($kind === 'instantNation') {
$command = buildNationCommandClass($action, $general, $environment, new LastTurn(), $args);
if (!$command->hasFullConditionMet()) {
throw new \RuntimeException($command->getFailString());
}
$completed = $command->run(NoRNG::rngInstance());
$command->setNextAvailable();
$general->getLogger()->flush();
$after = comparisonTurnStateSnapshot($snapshotRequest);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => 'none',
'outcome' => [
'lastTurn' => (object)[],
'commandName' => $command->getName(),
'completed' => $completed,
],
],
'before' => $before,
'after' => $after,
'rng' => [],
];
}
$turn = new TurnExecutionHelper($general);
$general->increaseInheritancePoint(Enums\InheritanceKey::lived_month, 1);
$preprocessRng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'preprocess',
(int)$environment['year'],
(int)$environment['month'],
$actorGeneralId,
)));
$turn->preprocessCommand($preprocessRng, $environment);
$seedDomain = $kind === 'general' ? 'generalCommand' : 'nationCommand';
$seed = Util::simpleSerialize(
UniqueConst::$hiddenSeed,
$seedDomain,
(int)$environment['year'],
(int)$environment['month'],
$actorGeneralId,
$action,
);
$tracingRng = new TurnComparisonTracingRNG(new LiteHashDRBG($seed));
$rng = new RandUtil($tracingRng);
if ($kind === 'general' && $action === 'che_견문') {
\sammo\TextDecoration\SightseeingMessage::setComparisonRng($rng);
}
if ($kind === 'general') {
$previousLastTurn = $general->getLastTurn();
$previousLastTurnRaw = $previousLastTurn->toRaw();
$command = buildGeneralCommandClass($action, $general, $environment, $args);
$commandFullConditionMet = $command->hasFullConditionMet();
$resultTurn = $turn->processCommand($rng, $command, false);
} else {
$nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env');
$lastNationTurnKey = "turn_last_{$general->getVar('officer_level')}";
$previousLastTurn = LastTurn::fromRaw($nationStor->getValue($lastNationTurnKey));
$previousLastTurnRaw = $previousLastTurn->toRaw();
$command = buildNationCommandClass($action, $general, $environment, $previousLastTurn, $args);
$commandFullConditionMet = $command->hasFullConditionMet();
$resultTurn = $turn->processNationCommand($rng, $command);
$nationStor->setValue($lastNationTurnKey, $resultTurn->toRaw());
}
if ($includeLifecycle) {
pullGeneralCommand($general->getID());
$general->increaseVarWithLimit(
'myset',
GameConst::$incDefSettingChange,
null,
GameConst::$maxDefSettingChange,
);
$turn->updateTurnTime();
}
$general->getLogger()->flush();
$turn->applyDB();
unset($turn);
$after = comparisonTurnStateSnapshot($snapshotRequest);
$resultTurnRaw = $resultTurn->toRaw();
$resultTerm = (int)($resultTurnRaw['term'] ?? 0);
$preReqTurn = $command->getPreReqTurn();
$acceptScoutCompleted = (
$action === 'che_등용수락'
&& is_array($args)
&& is_int($args['destNationID'] ?? null)
&& $general->getNationID() === $args['destNationID']
);
$foundNationCompleted = (
in_array($action, ['che_건국', 'cr_건국', 'che_무작위건국'], true)
&& is_array($args)
&& is_string($args['nationName'] ?? null)
&& DB::db()->queryFirstField(
'SELECT name FROM nation WHERE nation = %i',
$general->getNationID(),
) === $args['nationName']
);
$completedByState = (
$action === 'che_접경귀환'
&& $tracingRng->calls !== []
) || $acceptScoutCompleted || $foundNationCompleted || (($resultTurnRaw['command'] ?? null) === $command->getName()
&& (
($preReqTurn === 0 && $resultTerm === 0)
|| (
$preReqTurn > 0
&& ($previousLastTurnRaw['command'] ?? null) === $command->getName()
&& ($previousLastTurnRaw['arg'] ?? null) === $command->getArg()
&& (int)($previousLastTurnRaw['term'] ?? 0) === $preReqTurn
)
));
$completed = $commandFullConditionMet && $completedByState;
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => $seedDomain,
'outcome' => [
'lastTurn' => $resultTurnRaw,
'commandName' => $command->getName(),
'completed' => $completed,
],
],
'before' => $before,
'after' => $after,
'rng' => $tracingRng->calls,
];
}
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonRunTurnCommand($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
-643
View File
@@ -1,643 +0,0 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\Enums\RankColumn;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
/** @return list<int> */
function comparisonIntegerList(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_int($entry) || $entry < 0) {
throw new \InvalidArgumentException("{$label} entries must be non-negative integers");
}
$result[$entry] = $entry;
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
function comparisonJsonValue(mixed $value): mixed
{
if (!is_string($value) || $value === '') {
return $value;
}
try {
return json_decode($value, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return $value;
}
}
function comparisonOptionalCode(mixed $value): ?string
{
if (!is_string($value) || $value === '' || $value === 'None') {
return null;
}
return $value;
}
/** @return list<array{generalId: int, actionName: string}> */
function comparisonGeneralCooldownSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$generalId = $entry['generalId'] ?? null;
$actionName = $entry['actionName'] ?? null;
if (!is_int($generalId) || $generalId < 1 || !is_string($actionName) || $actionName === '') {
throw new \InvalidArgumentException(
"{$label} entries require a positive generalId and non-empty actionName",
);
}
$result["{$generalId}:{$actionName}"] = [
'generalId' => $generalId,
'actionName' => $actionName,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @return list<array{nationId: int, actionName: string}> */
function comparisonNationCooldownSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$nationId = $entry['nationId'] ?? null;
$actionName = $entry['actionName'] ?? null;
if (!is_int($nationId) || $nationId < 1 || !is_string($actionName) || $actionName === '') {
throw new \InvalidArgumentException(
"{$label} entries require a positive nationId and non-empty actionName",
);
}
$result["{$nationId}:{$actionName}"] = [
'nationId' => $nationId,
'actionName' => $actionName,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @return list<array{fromNationId: int, toNationId: int}> */
function comparisonDiplomacyPairSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$fromNationId = $entry['fromNationId'] ?? null;
$toNationId = $entry['toNationId'] ?? null;
if (!is_int($fromNationId) || $fromNationId < 1 || !is_int($toNationId) || $toNationId < 1) {
throw new \InvalidArgumentException(
"{$label} entries require positive fromNationId and toNationId",
);
}
$result["{$fromNationId}:{$toNationId}"] = [
'fromNationId' => $fromNationId,
'toNationId' => $toNationId,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @param array<string, mixed> $row */
function comparisonPickRow(array $row, array $mapping, array $jsonKeys = []): array
{
$result = [];
foreach ($mapping as $canonical => $legacy) {
if (!array_key_exists($legacy, $row)) {
continue;
}
$value = $row[$legacy];
if (in_array($legacy, $jsonKeys, true)) {
$value = comparisonJsonValue($value);
}
$result[$canonical] = $value;
}
return $result;
}
/** @param list<int> $ids */
function comparisonRowsById(string $table, string $idColumn, array $ids): array
{
$db = DB::db();
$rows = [];
foreach ($ids as $id) {
$row = $db->queryFirstRow(
"SELECT * FROM `{$table}` WHERE `{$idColumn}` = %i",
$id,
);
if ($row !== null) {
$rows[] = $row;
}
}
return $rows;
}
function comparisonTurnStateSnapshot(array $request): array
{
$observe = $request['observe'] ?? [];
if (!is_array($observe)) {
throw new \InvalidArgumentException('observe must be an object');
}
$generalIds = comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds');
$cityIds = comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds');
$nationIds = comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds');
$logAfterId = $observe['logAfterId'] ?? 0;
$messageAfterId = $observe['messageAfterId'] ?? 0;
$includeNationHistoryLogs = $observe['includeNationHistoryLogs'] ?? false;
$includeGlobalHistoryLogs = $observe['includeGlobalHistoryLogs'] ?? false;
$generalCooldownSelectors = comparisonGeneralCooldownSelectors(
$observe['generalCooldowns'] ?? [],
'generalCooldowns',
);
$nationCooldownSelectors = comparisonNationCooldownSelectors(
$observe['nationCooldowns'] ?? [],
'nationCooldowns',
);
$diplomacyPairSelectors = comparisonDiplomacyPairSelectors(
$observe['diplomacyPairs'] ?? [],
'diplomacyPairs',
);
if (
!is_int($logAfterId)
|| $logAfterId < 0
|| !is_int($messageAfterId)
|| $messageAfterId < 0
) {
throw new \InvalidArgumentException('logAfterId and messageAfterId must be non-negative integers');
}
if (!is_bool($includeNationHistoryLogs)) {
throw new \InvalidArgumentException('includeNationHistoryLogs must be a boolean');
}
if (!is_bool($includeGlobalHistoryLogs)) {
throw new \InvalidArgumentException('includeGlobalHistoryLogs must be a boolean');
}
$db = DB::db();
$game = KVStorage::getStorage($db, 'game_env');
$game->resetCache();
$worldValues = $game->getValues([
'year',
'month',
'turnterm',
'turntime',
'isunited',
'scenario',
'init_year',
'init_month',
'develcost',
'killturn',
]);
$nextExecuteStorage = KVStorage::getStorage($db, 'next_execute');
$nextExecuteStorage->resetCache();
$generalCooldowns = array_map(
static function (array $selector) use ($nextExecuteStorage): array {
$key = "next_execute_{$selector['generalId']}_{$selector['actionName']}";
$value = $nextExecuteStorage->getValue($key);
return [
...$selector,
'nextAvailableTurn' => is_int($value) ? $value : null,
];
},
$generalCooldownSelectors,
);
$nationCooldowns = array_map(
static function (array $selector) use ($db): array {
$nationStorage = KVStorage::getStorage($db, $selector['nationId'], 'nation_env');
$nationStorage->resetCache();
$value = $nationStorage->getValue("next_execute_{$selector['actionName']}");
return [
...$selector,
'nextAvailableTurn' => is_int($value) ? $value : null,
];
},
$nationCooldownSelectors,
);
$generals = array_map(
static function (array $row): array {
$result = comparisonPickRow(
$row,
[
'id' => 'no',
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'troopId' => 'troop',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'experience' => 'experience',
'dedication' => 'dedication',
'expLevel' => 'explevel',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'belong' => 'belong',
'permission' => 'permission',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'injury' => 'injury',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'age' => 'age',
'npcState' => 'npc',
'turnTime' => 'turntime',
'recentWarTime' => 'recent_war',
'lastTurn' => 'last_turn',
'meta' => 'aux',
'penalty' => 'penalty',
'leadershipExp' => 'leadership_exp',
'strengthExp' => 'strength_exp',
'intelExp' => 'intel_exp',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'specAge' => 'specage',
'specAge2' => 'specage2',
'killTurn' => 'killturn',
'mySet' => 'myset',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'personality' => 'personal',
'itemHorse' => 'horse',
'itemWeapon' => 'weapon',
'itemBook' => 'book',
'itemExtra' => 'item',
],
['last_turn', 'aux', 'penalty'],
);
foreach ([
'specialDomestic',
'specialWar',
'personality',
'itemHorse',
'itemWeapon',
'itemBook',
'itemExtra',
] as $key) {
$result[$key] = comparisonOptionalCode($result[$key] ?? null);
}
$result['maxBelong'] = (int)($result['meta']['max_belong'] ?? 0);
$ownerId = (int)($row['owner'] ?? 0);
$activeActionPoints = 0;
if ($ownerId > 0) {
$inheritanceStorage = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}");
$inheritanceStorage->resetCache();
$stored = $inheritanceStorage->getValue('active_action');
if (is_array($stored) && (is_int($stored[0] ?? null) || is_float($stored[0] ?? null))) {
$activeActionPoints = $stored[0];
}
}
$result['hasOwner'] = $ownerId > 0;
$result['inheritActiveActionPoints'] = $activeActionPoints;
return $result;
},
comparisonRowsById('general', 'no', $generalIds),
);
$rankTypes = array_map(
static fn(RankColumn $column): string => $column->value,
RankColumn::cases(),
);
$rankData = $generalIds === []
? []
: iterator_to_array($db->query(
'SELECT general_id AS generalId, nation_id AS nationId, `type`, `value`'
. ' FROM rank_data WHERE general_id IN %li AND `type` IN %ls'
. ' ORDER BY general_id, `type`',
$generalIds,
$rankTypes,
));
$cities = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'city',
'name' => 'name',
'nationId' => 'nation',
'level' => 'level',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'supplyState' => 'supply',
'frontState' => 'front',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'state' => 'state',
'term' => 'term',
'trust' => 'trust',
'trade' => 'trade',
'conflict' => 'conflict',
'officerSet' => 'officer_set',
],
['conflict'],
),
comparisonRowsById('city', 'city', $cityIds),
);
$nations = array_map(
static function (array $row) use ($db): array {
$projected = comparisonPickRow(
$row,
[
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'generalCount' => 'gennum',
'power' => 'power',
'war' => 'war',
'diplomacyLimit' => 'surlimit',
'capitalRevision' => 'capset',
'strategicCommandLimit' => 'strategic_cmd_limit',
'meta' => 'aux',
],
['aux'],
);
$nationStor = KVStorage::getStorage($db, (int)$row['nation'], 'nation_env');
$receivedAssist = $nationStor->getValue('recv_assist');
if (is_array($receivedAssist) && $receivedAssist !== []) {
$projected['meta']['recv_assist'] = $receivedAssist;
}
$respondedAssist = $nationStor->getValue('resp_assist');
if (is_array($respondedAssist) && $respondedAssist !== []) {
$projected['meta']['resp_assist'] = $respondedAssist;
}
return $projected;
},
comparisonRowsById('nation', 'nation', $nationIds),
);
$diplomacyPairs = [];
foreach ($nationIds as $fromNationId) {
foreach ($nationIds as $toNationId) {
if ($fromNationId === $toNationId) {
continue;
}
$diplomacyPairs["{$fromNationId}:{$toNationId}"] = [
'fromNationId' => $fromNationId,
'toNationId' => $toNationId,
];
}
}
foreach ($diplomacyPairSelectors as $pair) {
$diplomacyPairs["{$pair['fromNationId']}:{$pair['toNationId']}"] = $pair;
}
ksort($diplomacyPairs, SORT_STRING);
$diplomacy = [];
foreach ($diplomacyPairs as $pair) {
$row = $db->queryFirstRow(
'SELECT me, you, state, term, dead FROM diplomacy WHERE me = %i AND you = %i',
$pair['fromNationId'],
$pair['toNationId'],
);
if ($row !== null) {
$diplomacy[] = comparisonPickRow($row, [
'fromNationId' => 'me',
'toNationId' => 'you',
'state' => 'state',
'term' => 'term',
'dead' => 'dead',
]);
}
}
$generalTurns = [];
foreach ($generalIds as $generalId) {
foreach ($db->query(
'SELECT general_id, turn_idx, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
) as $row) {
$generalTurns[] = comparisonPickRow(
$row,
['generalId' => 'general_id', 'turnIndex' => 'turn_idx', 'action' => 'action', 'args' => 'arg'],
['arg'],
);
}
}
$nationTurns = [];
foreach ($nationIds as $nationId) {
foreach ($db->query(
'SELECT nation_id, officer_level, turn_idx, action, arg FROM nation_turn WHERE nation_id = %i ORDER BY officer_level, turn_idx',
$nationId,
) as $row) {
$nationTurns[] = comparisonPickRow(
$row,
[
'nationId' => 'nation_id',
'officerLevel' => 'officer_level',
'turnIndex' => 'turn_idx',
'action' => 'action',
'args' => 'arg',
],
['arg'],
);
}
}
$logs = [];
if ($generalIds !== []) {
foreach ($db->query(
'SELECT id, general_id, log_type, year, month, text FROM general_record WHERE id > %i ORDER BY id',
$logAfterId,
) as $row) {
if ((int)$row['general_id'] !== 0 && !in_array((int)$row['general_id'], $generalIds, true)) {
continue;
}
$generalId = (int)$row['general_id'];
$category = (string)$row['log_type'];
if ($category === 'battle') {
$category = 'battle_detail';
} elseif ($generalId === 0) {
$category = 'summary';
}
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'generalId' => 'general_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => $generalId === 0 ? 'system' : 'general',
'category' => $category,
'nationId' => null,
];
}
}
if ($includeNationHistoryLogs && $nationIds !== []) {
foreach ($db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id IN %li ORDER BY id',
$nationIds,
) as $row) {
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => 'nation',
'category' => 'history',
'generalId' => null,
];
}
}
if ($includeGlobalHistoryLogs) {
foreach ($db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id = 0 ORDER BY id',
) as $row) {
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => 'system',
'category' => 'history',
'generalId' => null,
];
}
}
$messages = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'mailbox' => 'mailbox',
'type' => 'type',
'sourceId' => 'src',
'destinationId' => 'dest',
'createdAt' => 'time',
'payload' => 'message',
],
['message'],
),
$db->query(
'SELECT id, mailbox, type, src, dest, time, message FROM message WHERE id > %i ORDER BY id',
$messageAfterId,
),
);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'world' => [
'year' => (int)$worldValues['year'],
'month' => (int)$worldValues['month'],
'tickMinutes' => (int)$worldValues['turnterm'],
'turnTime' => (string)$worldValues['turntime'],
'isUnited' => (int)$worldValues['isunited'],
'scenarioId' => (int)$worldValues['scenario'],
'initYear' => (int)$worldValues['init_year'],
'initMonth' => (int)$worldValues['init_month'],
'develCost' => (int)$worldValues['develcost'],
'killTurn' => (int)$worldValues['killturn'],
'generalCooldowns' => $generalCooldowns,
'nationCooldowns' => $nationCooldowns,
],
'generals' => $generals,
'rankData' => $rankData,
'cities' => $cities,
'nations' => $nations,
'diplomacy' => $diplomacy,
'generalTurns' => $generalTurns,
'nationTurns' => $nationTurns,
'logs' => $logs,
'messages' => $messages,
'watermarks' => [
'logId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0),
'historyLogId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM world_history') ?? 0),
'messageId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM message') ?? 0),
],
];
}
function comparisonTurnStateSnapshotMain(): void
{
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonTurnStateSnapshot($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
}
if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
comparisonTurnStateSnapshotMain();
}
+48 -4
View File
@@ -923,6 +923,13 @@ function banner()
function addTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true) function addTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true)
{ {
if($turnterm < 0){
if($turnterm == -60){
return VarTurn60::fromDatetime(new \DateTimeImmutable(($date)))->addTurn($turn )->toDateStr($withFraction);
}
throw new \Exception("InvalidTurnTerm".$turnterm);
}
$date = new \DateTime($date); $date = new \DateTime($date);
$target = $turnterm * $turn; $target = $turnterm * $turn;
$date->add(new \DateInterval("PT{$target}M")); $date->add(new \DateInterval("PT{$target}M"));
@@ -934,6 +941,13 @@ function addTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true)
function subTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true) function subTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true)
{ {
if($turnterm < 0){
if($turnterm == -60){
return VarTurn60::fromDatetime(new \DateTimeImmutable(($date)))->addTurn($turn * -1)->toDateStr($withFraction);
}
throw new \Exception("InvalidTurnTerm".$turnterm);
}
$date = new \DateTime($date); $date = new \DateTime($date);
$target = $turnterm * $turn; $target = $turnterm * $turn;
$date->sub(new \DateInterval("PT{$target}M")); $date->sub(new \DateInterval("PT{$target}M"));
@@ -945,6 +959,14 @@ function subTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true)
function cutTurn($date, int $turnterm, bool $withFraction = true) function cutTurn($date, int $turnterm, bool $withFraction = true)
{ {
if($turnterm < 0){
if($turnterm == -60){
[$baseDate, ] = VarTurn60::fromDatetime(new \DateTimeImmutable(($date)))->cutTurn($withFraction);
return $baseDate;
}
throw new \Exception("InvalidTurnTerm".$turnterm);
}
$date = new \DateTime($date); $date = new \DateTime($date);
$baseDate = new \DateTime($date->format('Y-m-d')); $baseDate = new \DateTime($date->format('Y-m-d'));
@@ -963,6 +985,8 @@ function cutTurn($date, int $turnterm, bool $withFraction = true)
function cutDay($date, int $turnterm, bool $withFraction = true) function cutDay($date, int $turnterm, bool $withFraction = true)
{ {
assert($turnterm > 0);
$date = new \DateTime($date); $date = new \DateTime($date);
$baseDate = new \DateTime($date->format('Y-m-d')); $baseDate = new \DateTime($date->format('Y-m-d'));
@@ -1047,7 +1071,7 @@ function increaseRefresh($type = "", $cnt = 1)
$logDB = FileDB::db($logPath, $serverPath . '/../f_install/sql/api_log.sql'); $logDB = FileDB::db($logPath, $serverPath . '/../f_install/sql/api_log.sql');
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'local'; $ip = $_SERVER['REMOTE_ADDR'] ?? 'local';
$date = date('Y-m-d H:i:s'); $date = date('Y-m-d H:i:s');
$logDB->insert('api_log', [ $logDB->insert('api_log', [
@@ -1106,6 +1130,7 @@ function CheckOverhead()
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
[$turnterm, $refreshLimit] = $gameStor->getValuesAsArray(['turnterm', 'refreshLimit']); [$turnterm, $refreshLimit] = $gameStor->getValuesAsArray(['turnterm', 'refreshLimit']);
$turnterm = abs($turnterm);
$nextRefreshLimit = Util::round(pow($turnterm, 0.6) * 3) * GameConst::$refreshLimitCoef; $nextRefreshLimit = Util::round(pow($turnterm, 0.6) * 3) * GameConst::$refreshLimitCoef;
@@ -1153,7 +1178,7 @@ function timeover(): bool
list($turnterm, $turntime) = $gameStor->getValuesAsArray(['turnterm', 'turntime']); list($turnterm, $turntime) = $gameStor->getValuesAsArray(['turnterm', 'turntime']);
$diff = (new \DateTime())->getTimestamp() - (new \DateTime($turntime))->getTimestamp(); $diff = (new \DateTime())->getTimestamp() - (new \DateTime($turntime))->getTimestamp();
$t = min($turnterm, 5); $t = min(abs($turnterm), 5);
$term = $diff; $term = $diff;
if ($term >= $t || $term < 0) { if ($term >= $t || $term < 0) {
@@ -1175,6 +1200,12 @@ function checkDelay()
// 1턴이상 갱신 없었으면 서버 지연 // 1턴이상 갱신 없었으면 서버 지연
$term = $gameStor->turnterm; $term = $gameStor->turnterm;
if($term < 0){
//가변 턴에는 수행 안함!
return;
}
if ($term >= 20) { if ($term >= 20) {
$threshold = 1; $threshold = 1;
} else if ($term >= 10) { } else if ($term >= 10) {
@@ -1254,10 +1285,14 @@ function turnDate($curtime)
$admin = $gameStor->getValues(['startyear', 'starttime', 'turnterm', 'year', 'month']); $admin = $gameStor->getValues(['startyear', 'starttime', 'turnterm', 'year', 'month']);
$turn = $admin['starttime']; $turn = $admin['starttime'];
$curturn = cutTurn($curtime, $admin['turnterm']);
$term = $admin['turnterm']; $term = $admin['turnterm'];
if($term == -60){
$num = VarTurn60::calcTurnDiff(new \DateTimeImmutable($turn), new \DateTimeImmutable($curtime));
}
else{
$curturn = cutTurn($curtime, $admin['turnterm']);
$num = intdiv((strtotime($curturn) - strtotime($turn)), $term * 60); $num = intdiv((strtotime($curturn) - strtotime($turn)), $term * 60);
}
$date = $admin['startyear'] * 12; $date = $admin['startyear'] * 12;
$date += $num; $date += $num;
@@ -2208,6 +2243,10 @@ function getRandTurn(RandUtil $rng, $term, ?\DateTimeInterface $baseDateTime = n
throw new MustNotBeReachedException(); throw new MustNotBeReachedException();
} }
if($term == -60){
[, $term] = VarTurn60::fromDatetime($baseDateTime)->cutTurn();
}
$randSecond = $rng->nextRangeInt(0, 60 * $term - 1); $randSecond = $rng->nextRangeInt(0, 60 * $term - 1);
$randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수 $randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수
@@ -2223,6 +2262,11 @@ function getRandTurn2(RandUtil $rng, $term, ?\DateTimeInterface $baseDateTime =
} else { } else {
throw new MustNotBeReachedException(); throw new MustNotBeReachedException();
} }
if($term == -60){
[, $term] = VarTurn60::fromDatetime($baseDateTime)->cutTurn();
}
$randSecond = $rng->nextRangeInt(0, 60 * $term - 1); $randSecond = $rng->nextRangeInt(0, 60 * $term - 1);
$randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수 $randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수
+6 -1
View File
@@ -181,7 +181,12 @@ function info($type = 0)
$admin = $gameStor->getValues(['year', 'month', 'turnterm', 'maxgeneral']); $admin = $gameStor->getValues(['year', 'month', 'turnterm', 'maxgeneral']);
$termtype = "{$admin['turnterm']}분 턴"; $turnTermText = $admin['turnterm'].'분';
if($admin['turnterm'] < 0){
$turnTermText = "가변 {$admin['turnterm']}분";
}
$termtype = "{$turnTermText}분 턴";
$gencount = $db->queryFirstField('SELECT count(no) FROM general WHERE npc < 2'); $gencount = $db->queryFirstField('SELECT count(no) FROM general WHERE npc < 2');
$npccount = $db->queryFirstField('SELECT count(no) FROM general WHERE npc >= 2'); $npccount = $db->queryFirstField('SELECT count(no) FROM general WHERE npc >= 2');
+3 -1
View File
@@ -10,6 +10,7 @@ use sammo\Enums\InheritanceKey;
* @return int 토너먼트 초 단위 * @return int 토너먼트 초 단위
*/ */
function calcTournamentTerm(int $turnTerm): int{ function calcTournamentTerm(int $turnTerm): int{
$turnTerm = abs($turnTerm);
return Util::valueFit($turnTerm, 5, 120); return Util::valueFit($turnTerm, 5, 120);
} }
@@ -138,6 +139,7 @@ function processTournament()
function getTournamentTermText(int $turnTerm) function getTournamentTermText(int $turnTerm)
{ {
$turnTerm = abs($turnTerm);
$term = calcTournamentTerm($turnTerm); $term = calcTournamentTerm($turnTerm);
if ($term % 60 === 0) { if ($term % 60 === 0) {
@@ -342,7 +344,7 @@ function startBetting($type)
{ {
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
[$year, $month, $startyear, $turnterm] = $gameStor->getValuesAsArray(['year', 'month', 'startyear', 'turnterm']); [$year, $month, $startyear] = $gameStor->getValuesAsArray(['year', 'month', 'startyear']);
pushGlobalHistoryLog([ pushGlobalHistoryLog([
"<S>◆</>{$year}{$month}월:<B><b>【대회】</b></>우승자를 예상하는 <C>내기</>가 진행중입니다! 호사가의 참여를 기다립니다!" "<S>◆</>{$year}{$month}월:<B><b>【대회】</b></>우승자를 예상하는 <C>내기</>가 진행중입니다! 호사가의 참여를 기다립니다!"
], $year, $month); ], $year, $month);
+1
View File
@@ -54,6 +54,7 @@ if ($session->userGrade < 5 && !$allowReset) {
<label for="turnterm" class="col-sm-3 col-form-label">턴 시간(분)</label> <label for="turnterm" class="col-sm-3 col-form-label">턴 시간(분)</label>
<div class="col-sm-9"> <div class="col-sm-9">
<div id="turnterm" class="btn-group btn-group-toggle" data-bs-toggle="buttons"> <div id="turnterm" class="btn-group btn-group-toggle" data-bs-toggle="buttons">
<input type="radio" class="btn-check" name="turnterm" id="turnterm_var60" value="-60" checked><label for="turnterm_var60" class="btn btn-secondary">가변60</label>
<input type="radio" class="btn-check" name="turnterm" id="turnterm_120" value="120"><label for="turnterm_120" class="btn btn-secondary">120</label> <input type="radio" class="btn-check" name="turnterm" id="turnterm_120" value="120"><label for="turnterm_120" class="btn btn-secondary">120</label>
<input type="radio" class="btn-check" name="turnterm" id="turnterm_60" value="60" checked><label for="turnterm_60" class="btn btn-secondary">60</label> <input type="radio" class="btn-check" name="turnterm" id="turnterm_60" value="60" checked><label for="turnterm_60" class="btn btn-secondary">60</label>
<input type="radio" class="btn-check" name="turnterm" id="turnterm_30" value="30"><label for="turnterm_30" class="btn btn-secondary">30</label> <input type="radio" class="btn-check" name="turnterm" id="turnterm_30" value="30"><label for="turnterm_30" class="btn btn-secondary">30</label>
+1
View File
@@ -36,6 +36,7 @@ list(
$turnterm, $turnterm,
$npcmode $npcmode
) = $gameStor->getValuesAsArray(['maxgeneral', 'turnterm', 'npcmode']); ) = $gameStor->getValuesAsArray(['maxgeneral', 'turnterm', 'npcmode']);
$turnterm = abs($turnterm);
if($npcmode!=1){ if($npcmode!=1){
Json::die([ Json::die([
+1 -1
View File
@@ -121,7 +121,7 @@ $builder->setOwner($userID);
$builder->setOwnerName($userNick); $builder->setOwnerName($userNick);
$builder->setKillturn(5); $builder->setKillturn(5);
$builder->setNPCType(0); $builder->setNPCType(0);
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm'])); $builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * abs($env['turnterm'])));
$builder->fillRemainSpecAsZero($env); $builder->fillRemainSpecAsZero($env);
$builder->build($env); $builder->build($env);
$generalID = $builder->getGeneralID(); $generalID = $builder->getGeneralID();
+1 -1
View File
@@ -60,7 +60,7 @@ $me->setAuxVar('use_auto_nation_turn', $use_auto_nation_turn);
$me->setVar('tnmt', $tnmt); $me->setVar('tnmt', $tnmt);
if ($me->getNPCType() == 1 && $detachNPC) { if ($me->getNPCType() == 1 && $detachNPC) {
$turnterm = $gameStor->turnterm; $turnterm = abs($gameStor->turnterm);
if ($turnterm < 10) { if ($turnterm < 10) {
$targetKillTurn = 30 / $turnterm; $targetKillTurn = 30 / $turnterm;
+1
View File
@@ -39,6 +39,7 @@ list(
$npcmode, $npcmode,
$turnterm $turnterm
) = $gameStor->getValuesAsArray(['year', 'month', 'maxgeneral', 'npcmode', 'turnterm']); ) = $gameStor->getValuesAsArray(['year', 'month', 'maxgeneral', 'npcmode', 'turnterm']);
$turnterm = abs($turnterm);
if($npcmode!=2){ if($npcmode!=2){
Json::die([ Json::die([
-100
View File
@@ -230,7 +230,6 @@ function processWar_NG(
WarUnitGeneral $attacker, WarUnitGeneral $attacker,
callable $getNextDefender, callable $getNextDefender,
WarUnitCity $city, WarUnitCity $city,
?callable $trace = null,
): bool { ): bool {
$templates = new \League\Plates\Engine(__DIR__ . '/templates'); $templates = new \League\Plates\Engine(__DIR__ . '/templates');
@@ -246,26 +245,6 @@ function processWar_NG(
/** @var WarUnit */ /** @var WarUnit */
$defender = ($getNextDefender)(null, true); $defender = ($getNextDefender)(null, true);
$conquerCity = false; $conquerCity = false;
$traceSeq = 0;
$emitTrace = function (string $event, ?WarUnit $currentDefender, array $details = []) use (
$trace,
&$traceSeq,
$attacker,
$city
): void {
if ($trace === null) {
return;
}
$trace([
'seq' => $traceSeq++,
'event' => $event,
'attacker' => buildWarTraceUnitSnapshot($attacker),
'defender' => $currentDefender === null ? null : buildWarTraceUnitSnapshot($currentDefender),
'city' => buildWarTraceUnitSnapshot($city),
'details' => $details,
]);
};
$emitTrace('battle_start', $defender, ['seed' => $warSeed]);
$josaRo = JosaUtil::pick($city->getName(), '로'); $josaRo = JosaUtil::pick($city->getName(), '로');
$josaYi = JosaUtil::pick($attacker->getName(), '이'); $josaYi = JosaUtil::pick($attacker->getName(), '이');
@@ -298,7 +277,6 @@ function processWar_NG(
$logger->pushGlobalHistoryLog("<M><b>【패퇴】</b></><D><b>{$defender->getNationVar('name')}</b></>{$josaYi} 병량 부족으로 <G><b>{$defender->getName()}</b></>{$josaUl} 뺏기고 말았습니다."); $logger->pushGlobalHistoryLog("<M><b>【패퇴】</b></><D><b>{$defender->getNationVar('name')}</b></>{$josaYi} 병량 부족으로 <G><b>{$defender->getName()}</b></>{$josaUl} 뺏기고 말았습니다.");
$conquerCity = true; $conquerCity = true;
$emitTrace('supply_retreat', $defender);
break; break;
} }
} }
@@ -354,34 +332,18 @@ function processWar_NG(
$initCaller->merge($defender->getGeneral()->getBattleInitSkillTriggerList($defender)); $initCaller->merge($defender->getGeneral()->getBattleInitSkillTriggerList($defender));
$initCaller->fire($attacker->rng, [], [$attacker, $defender]); $initCaller->fire($attacker->rng, [], [$attacker, $defender]);
$emitTrace('opponent_initialized', $defender);
} }
$attacker->beginPhase(); $attacker->beginPhase();
$defender->beginPhase(); $defender->beginPhase();
if ($trace !== null) {
$emitTrace('phase_power', $defender, [
'attackerAttack' => $attacker->getComputedAttack(),
'attackerDefence' => $attacker->getComputedDefence(),
'attackerTrain' => $attacker->getComputedTrain(),
'attackerAtmos' => $attacker->getComputedAtmos(),
'defenderAttack' => $defender->getComputedAttack(),
'defenderDefence' => $defender->getComputedDefence(),
'defenderTrain' => $defender->getComputedTrain(),
'defenderAtmos' => $defender->getComputedAtmos(),
]);
}
$battleCaller = $attacker->getGeneral()->getBattlePhaseSkillTriggerList($attacker); $battleCaller = $attacker->getGeneral()->getBattlePhaseSkillTriggerList($attacker);
$battleCaller->merge($defender->getGeneral()->getBattlePhaseSkillTriggerList($defender)); $battleCaller->merge($defender->getGeneral()->getBattlePhaseSkillTriggerList($defender));
$battleCaller->fire($attacker->rng, [], [$attacker, $defender]); $battleCaller->fire($attacker->rng, [], [$attacker, $defender]);
$emitTrace('phase_triggered', $defender);
$deadDefender = $attacker->calcDamage(); $deadDefender = $attacker->calcDamage();
$deadAttacker = $defender->calcDamage(); $deadAttacker = $defender->calcDamage();
$rawDeadAttacker = $deadAttacker;
$rawDeadDefender = $deadDefender;
$attackerHP = $attacker->getHP(); $attackerHP = $attacker->getHP();
$defenderHP = $defender->getHP(); $defenderHP = $defender->getHP();
@@ -409,14 +371,6 @@ function processWar_NG(
$attacker->increaseKilled($deadDefender); $attacker->increaseKilled($deadDefender);
$defender->increaseKilled($deadAttacker); $defender->increaseKilled($deadAttacker);
$emitTrace('phase_damage', $defender, [
'rawDeadAttacker' => $rawDeadAttacker,
'rawDeadDefender' => $rawDeadDefender,
'deadAttacker' => $deadAttacker,
'deadDefender' => $deadDefender,
'attackerHpBefore' => $attackerHP,
'defenderHpBefore' => $defenderHP,
]);
if($defender->getPhase() < 0){ if($defender->getPhase() < 0){
$phaseNickname = '先'; $phaseNickname = '先';
@@ -440,10 +394,8 @@ function processWar_NG(
$attacker->addPhase(); $attacker->addPhase();
$defender->addPhase(); $defender->addPhase();
$emitTrace('phase_end', $defender);
if (!$attacker->continueWar($noRice)) { if (!$attacker->continueWar($noRice)) {
$emitTrace('attacker_stopped', $defender, ['noRice' => (bool)$noRice]);
$logWritten = true; $logWritten = true;
$attacker->logBattleResult(); $attacker->logBattleResult();
@@ -468,7 +420,6 @@ function processWar_NG(
} }
if (!$defender->continueWar($noRice)) { if (!$defender->continueWar($noRice)) {
$emitTrace('defender_stopped', $defender, ['noRice' => (bool)$noRice]);
$logWritten = true; $logWritten = true;
$attacker->logBattleResult(); $attacker->logBattleResult();
@@ -511,7 +462,6 @@ function processWar_NG(
$defender->finishBattle(); $defender->finishBattle();
$defender = ($getNextDefender)($defender, true); $defender = ($getNextDefender)($defender, true);
$emitTrace('opponent_switched', $defender);
if ($defender !== null && !($defender instanceof WarUnitGeneral)) { if ($defender !== null && !($defender instanceof WarUnitGeneral)) {
throw new \RuntimeException('다음 수비자를 받아오는데 실패'); throw new \RuntimeException('다음 수비자를 받아오는데 실패');
@@ -547,60 +497,10 @@ function processWar_NG(
} }
($getNextDefender)($defender, false); ($getNextDefender)($defender, false);
$emitTrace('battle_end', $defender, ['conquered' => $conquerCity]);
return $conquerCity; return $conquerCity;
} }
function buildWarTraceUnitSnapshot(WarUnit $unit): array
{
$snapshot = [
'kind' => $unit instanceof WarUnitGeneral ? 'general' : 'city',
'id' => $unit instanceof WarUnitGeneral
? $unit->getGeneral()->getID()
: $unit->getVar('city'),
'name' => $unit->getName(),
'isAttacker' => $unit->isAttacker(),
'crewTypeId' => $unit->getCrewType()->id,
'phase' => $unit->getPhase(),
'realPhase' => $unit->getRealPhase(),
'maxPhase' => $unit->getMaxPhase(),
'hp' => $unit->getHP(),
'rawWarPower' => $unit->getRawWarPower(),
'warPower' => $unit->getWarPower(),
'warPowerMultiplier' => $unit->getWarPowerMultiply(),
'killed' => $unit->getKilled(),
'dead' => $unit->getDead(),
'activatedSkills' => $unit->getActivatedSkillLog(),
];
if ($unit instanceof WarUnitGeneral) {
$general = $unit->getGeneral();
$snapshot['general'] = [
'crew' => $general->getVar('crew'),
'rice' => $general->getVar('rice'),
'train' => $general->getVar('train'),
'atmos' => $general->getVar('atmos'),
'injury' => $general->getVar('injury'),
'experience' => $general->getVar('experience'),
'dedication' => $general->getVar('dedication'),
'dex1' => $general->getVar('dex1'),
'dex2' => $general->getVar('dex2'),
'dex3' => $general->getVar('dex3'),
'dex4' => $general->getVar('dex4'),
'dex5' => $general->getVar('dex5'),
];
} else {
$snapshot['cityState'] = [
'defence' => $unit->getVar('def'),
'wall' => $unit->getVar('wall'),
'population' => $unit->getVar('pop'),
];
}
return $snapshot;
}
function DeleteConflict($nation) function DeleteConflict($nation)
{ {
$db = DB::db(); $db = DB::db();
+13 -4
View File
@@ -28,6 +28,7 @@ use sammo\UniqueConst;
use sammo\UserLogger; use sammo\UserLogger;
use sammo\Util; use sammo\Util;
use sammo\Validator; use sammo\Validator;
use sammo\VarTurn60;
use sammo\WebUtil; use sammo\WebUtil;
use function sammo\addTurn; use function sammo\addTurn;
@@ -355,20 +356,28 @@ class Join extends \sammo\BaseAPI
} }
if ($inheritTurntimeZone !== null) { if ($inheritTurntimeZone !== null) {
$inheritTurntime = $inheritTurntimeZone * $admin['turnterm']; $turnterm = abs($admin['turnterm']);
$inheritTurntime += $rng->nextRangeInt(0, Util::clamp($admin['turnterm'] - 1, 0)); $inheritTurntime = $inheritTurntimeZone * $turnterm;
$inheritTurntime += $rng->nextRangeInt(0, Util::clamp($turnterm - 1, 0));
$userLogger->push(sprintf("턴 시간 %02d:%02d 로 지정", intdiv($inheritTurntime, 60), $inheritTurntime % 60), "inheritPoint"); $userLogger->push(sprintf("턴 시간 %02d:%02d 로 지정", intdiv($inheritTurntime, 60), $inheritTurntime % 60), "inheritPoint");
$inheritTurntime += $rng->nextRangeInt(0, 999999) / 1000000; $inheritTurntime += $rng->nextRangeInt(0, 999999) / 1000000;
$turntime = new \DateTimeImmutable(cutTurn($admin['turntime'], $admin['turnterm'])); $turntime = new \DateTimeImmutable(cutTurn($admin['turntime'], $turnterm));
$turntime = $turntime->add(TimeUtil::secondsToDateInterval($inheritTurntime)); $turntime = $turntime->add(TimeUtil::secondsToDateInterval($inheritTurntime));
$turntime = TimeUtil::format($turntime, true); $turntime = TimeUtil::format($turntime, true);
} else { } else {
$turntime = getRandTurn($rng, $admin['turnterm'], new \DateTimeImmutable($admin['turntime'])); $turntime = getRandTurn($rng, abs($admin['turnterm']), new \DateTimeImmutable($admin['turntime']));
} }
if ($admin['turnterm'] == -60){
$baseObj = VarTurn60::fromDatetime(new \DateTimeImmutable($admin['turntime']));
$userTurnObj = VarTurn60::fromDatetime(new \DateTimeImmutable($turntime));
if($baseObj->turnIdx != $userTurnObj->turnIdx){
$turntime = $userTurnObj->addTurn(-1)->toDateStr();
}
}
$now = TimeUtil::now(true); $now = TimeUtil::now(true);
if ($now >= $turntime) { if ($now >= $turntime) {
@@ -62,7 +62,7 @@ class ResetTurnTime extends \sammo\BaseAPI
} }
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
$currTurnTime = new DateTimeImmutable($general->getTurnTime()); $currTurnTime = new DateTimeImmutable($general->getTurnTime());
+1 -1
View File
@@ -46,7 +46,7 @@ abstract class AbsFromUserPool extends AbsGeneralPool{
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$result = []; $result = [];
$validUntil = TimeUtil::nowAddMinutes(2 * $gameStor->turnterm); $validUntil = TimeUtil::nowAddMinutes(2 * abs($gameStor->turnterm));
while(count($result) < $pickCnt){ while(count($result) < $pickCnt){
$cand = $rng->choiceUsingWeightPair($pool); $cand = $rng->choiceUsingWeightPair($pool);
$poolID = $cand['id']; $poolID = $cand['id'];
+4 -4
View File
@@ -164,7 +164,7 @@ abstract class Auction
if ($date === null) { if ($date === null) {
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
$date = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( $date = $this->info->closeDate->add(TimeUtil::secondsToDateInterval(
max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60 max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60
)); ));
@@ -324,7 +324,7 @@ abstract class Auction
} }
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
if ($this->info->detail->availableLatestBidCloseDate !== null) { if ($this->info->detail->availableLatestBidCloseDate !== null) {
$extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval( $extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval(
@@ -437,7 +437,7 @@ abstract class Auction
$general->increaseVar($resType->value, -$morePoint); $general->increaseVar($resType->value, -$morePoint);
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
$extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval( $extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval(
max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60 max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60
)); ));
@@ -471,7 +471,7 @@ abstract class Auction
if ($highestBid->aux->tryExtendCloseDate) { if ($highestBid->aux->tryExtendCloseDate) {
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
//연장 요청이 있었다. //연장 요청이 있었다.
$extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval(
+7 -1
View File
@@ -58,7 +58,7 @@ abstract class AuctionBasicResource extends Auction
$now = new \DateTimeImmutable(); $now = new \DateTimeImmutable();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
$closeDate = $now->add(TimeUtil::secondsToDateInterval($closeTurnCnt * $turnTerm * 60)); $closeDate = $now->add(TimeUtil::secondsToDateInterval($closeTurnCnt * $turnTerm * 60));
$openResult = static::openAuction(new AuctionInfo( $openResult = static::openAuction(new AuctionInfo(
@@ -246,7 +246,13 @@ abstract class AuctionBasicResource extends Auction
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = $gameStor->getValue('turnterm');
if($turnTerm == -60){
$date = VarTurn60::fromDatetime(new \DateTimeImmutable())->addTurn(1)->toDateStr();
}
else{
$date = (new DateTimeImmutable())->add(TimeUtil::secondsToDateInterval($turnTerm * 60)); $date = (new DateTimeImmutable())->add(TimeUtil::secondsToDateInterval($turnTerm * 60));
}
$this->shrinkCloseDate($date); $this->shrinkCloseDate($date);
} }
+3 -2
View File
@@ -75,6 +75,7 @@ class AuctionUniqueItem extends Auction
$now = new DateTimeImmutable(); $now = new DateTimeImmutable();
[$turnTerm, $year, $month] = $gameStor->getValuesAsArray(['turnterm', 'year', 'month']); [$turnTerm, $year, $month] = $gameStor->getValuesAsArray(['turnterm', 'year', 'month']);
$turnTerm = abs($turnTerm);
$closeDate = $now->add(TimeUtil::secondsToDateInterval( $closeDate = $now->add(TimeUtil::secondsToDateInterval(
max(static::MIN_AUCTION_CLOSE_MINUTES, $turnTerm * static::COEFF_AUCTION_CLOSE_MINUTES) * 60 max(static::MIN_AUCTION_CLOSE_MINUTES, $turnTerm * static::COEFF_AUCTION_CLOSE_MINUTES) * 60
@@ -266,7 +267,7 @@ class AuctionUniqueItem extends Auction
} }
if ($availableEquipUniqueCnt <= 0) { if ($availableEquipUniqueCnt <= 0) {
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
//제한에 걸렸다면 자동 연장 //제한에 걸렸다면 자동 연장
$extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval(
max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_CNT) * 60 max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_CNT) * 60
@@ -314,7 +315,7 @@ class AuctionUniqueItem extends Auction
if (!$availableItemTypes) { if (!$availableItemTypes) {
if ($isExtendCloseDateRequired) { if ($isExtendCloseDateRequired) {
$turnTerm = $gameStor->getValue('turnterm'); $turnTerm = abs($gameStor->getValue('turnterm'));
//동일 부위 제한에 걸렸다면 자동 연장 //동일 부위 제한에 걸렸다면 자동 연장
$extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval(
max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) * 60 max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) * 60
@@ -200,7 +200,7 @@ class che_불가침제의 extends Command\NationCommand
$now = new \DateTime($date); $now = new \DateTime($date);
$validUntil = new \DateTime($date); $validUntil = new \DateTime($date);
$validMinutes = max(30, $env['turnterm'] * 3); $validMinutes = max(30, abs($env['turnterm']) * 3);
$validUntil->add(new \DateInterval("PT{$validMinutes}M")); $validUntil->add(new \DateInterval("PT{$validMinutes}M"));
$josaWa = JosaUtil::pick($nationName, '와'); $josaWa = JosaUtil::pick($nationName, '와');
@@ -149,7 +149,7 @@ class che_불가침파기제의 extends Command\NationCommand{
$now = new \DateTime($date); $now = new \DateTime($date);
$validUntil = new \DateTime($date); $validUntil = new \DateTime($date);
$validMinutes = max(30, $env['turnterm']*3); $validMinutes = max(30, abs($env['turnterm'])*3);
$validUntil->add(new \DateInterval("PT{$validMinutes}M")); $validUntil->add(new \DateInterval("PT{$validMinutes}M"));
$msg = new DiplomaticMessage( $msg = new DiplomaticMessage(
@@ -147,7 +147,7 @@ class che_종전제의 extends Command\NationCommand{
$now = new \DateTime($date); $now = new \DateTime($date);
$validUntil = new \DateTime($date); $validUntil = new \DateTime($date);
$validMinutes = max(30, $env['turnterm']*3); $validMinutes = max(30, abs($env['turnterm'])*3);
$validUntil->add(new \DateInterval("PT{$validMinutes}M")); $validUntil->add(new \DateInterval("PT{$validMinutes}M"));
$msg = new DiplomaticMessage( $msg = new DiplomaticMessage(
@@ -0,0 +1,204 @@
<?php
namespace sammo\Command\Nation;
use \sammo\DB;
use \sammo\Util;
use \sammo\JosaUtil;
use \sammo\General;
use \sammo\DummyGeneral;
use \sammo\ActionLogger;
use \sammo\GameConst;
use \sammo\LastTurn;
use \sammo\GameUnitConst;
use \sammo\Command;
use \sammo\Constraint\Constraint;
use \sammo\Constraint\ConstraintHelper;
use sammo\Enums\GeneralQueryMode;
use sammo\StaticEventHandler;
use function sammo\pullGeneralCommand;
use function sammo\pushGeneralCommand;
class che_행동지시 extends Command\NationCommand
{
static protected $actionName = '행동 지시';
static public $reqArg = true;
protected function argTest(): bool
{
if ($this->arg === null) {
return false;
}
if (!key_exists('isPull', $this->arg)) {
return false;
}
if (!key_exists('amount', $this->arg)) {
return false;
}
if (!key_exists('destGeneralID', $this->arg)) {
return false;
}
$isPull = $this->arg['isPull'];
$amount = $this->arg['amount'];
$destGeneralID = $this->arg['destGeneralID'];
if (!is_numeric($amount)) {
return false;
}
if ($amount <= 0) {
return false;
}
if (!is_bool($isPull)) {
return false;
}
if (!is_int($destGeneralID)) {
return false;
}
if ($destGeneralID <= 0) {
return false;
}
$amount = Util::clamp($amount, 1, 5);
$this->arg = [
'isPull' => $isPull,
'amount' => $amount,
'destGeneralID' => $destGeneralID
];
return true;
}
protected function init()
{
$general = $this->generalObj;
$this->setCity();
$this->setNation();
$this->minConditionConstraints = [
ConstraintHelper::NotBeNeutral(),
ConstraintHelper::OccupiedCity(),
ConstraintHelper::BeChief(),
ConstraintHelper::SuppliedCity(),
];
}
protected function initWithArg()
{
$destGeneral = General::createObjFromDB($this->arg['destGeneralID']);
$this->setDestGeneral($destGeneral);
if($this->arg['destGeneralID'] == $this->getGeneral()->getID()){
$this->fullConditionConstraints=[
ConstraintHelper::AlwaysFail('본인입니다')
];
return;
}
$this->fullConditionConstraints = [
ConstraintHelper::NotBeNeutral(),
ConstraintHelper::OccupiedCity(),
ConstraintHelper::BeChief(),
ConstraintHelper::SuppliedCity(),
ConstraintHelper::ExistsDestGeneral(),
ConstraintHelper::FriendlyDestGeneral()
];
}
public function getCost(): array
{
return [0, 0];
}
public function getPreReqTurn(): int
{
return 0;
}
public function getPostReqTurn(): int
{
return 0;
}
public function getBrief(): string
{
$isPull = $this->arg['isPull'];
$amount = $this->arg['amount'];
$actSpecificName = $isPull ? '당기기' : '미루기';
$destGeneral = $this->destGeneralObj;
return "{$destGeneral->getName()}{$amount}{$actSpecificName}";
}
public function run(\Sammo\RandUtil $rng): bool
{
if (!$this->hasFullConditionMet()) {
throw new \RuntimeException('불가능한 커맨드를 강제로 실행 시도');
}
$db = DB::db();
$general = $this->generalObj;
$date = $general->getTurnTime($general::TURNTIME_HM);
$isPull = $this->arg['isPull'];
$amount = $this->arg['amount'];
$destGeneral = $this->destGeneralObj;
if($isPull){
pullGeneralCommand($destGeneral->getID(), $amount);
}
else{
pushGeneralCommand($destGeneral->getID(), $amount);
}
$logger = $general->getLogger();
$actDestText = $isPull ? '당겼습니다.' : '미루었습니다.';
$actText = $isPull ? '당기도록' : '미루도록';
$destGeneral->getLogger()->pushGeneralActionLog("<Y>{$general->getName()}</>의 지시로 <C>{$amount}</>턴을 {$actDestText}", ActionLogger::PLAIN);
$logger->pushGeneralActionLog("<Y>{$destGeneral->getName()}</>에게 <C>{$amount}</>턴을 {$actText} 지시했습니다. <1>$date</>");
$this->setResultTurn(new LastTurn(static::getName(), $this->arg));
StaticEventHandler::handleEvent($this->generalObj, $this->destGeneralObj, $this::class, $this->env, $this->arg ?? []);
$general->applyDB($db);
$destGeneral->applyDB($db);
return true;
}
public function exportJSVars(): array
{
$db = DB::db();
$nationID = $this->getNationID();
$troops = Util::convertArrayToDict($db->query('SELECT * FROM troop WHERE nation=%i', $nationID), 'troop_leader');
$destRawGenerals = Util::convertArrayToDict($db->queryAllLists('SELECT no,name,officer_level,npc,gold,rice,leadership,strength,intel,city,crew,train,atmos,troop FROM general WHERE nation = %i ORDER BY npc,binary(name)', $nationID), 0);
if($destRawGenerals){
foreach ($db->queryAllLists(
'SELECT general_id, brief FROM general_turn WHERE general_id IN %li AND turn_idx = 0',
array_keys($destRawGenerals)
) as [$generalID, $brief]) {
if (!key_exists($generalID, $destRawGenerals)) {
continue;
}
$destRawGenerals[$generalID][] = $brief;
}
}
return [
'procRes' => [
'troops' => $troops,
'generals' => array_values($destRawGenerals),
'generalsKey' => ['no', 'name', 'officerLevel', 'npc', 'gold', 'rice', 'leadership', 'strength', 'intel', 'cityID', 'crew', 'train', 'atmos', 'troopID', 'turn0Brief'],
'cities' => \sammo\JSOptionsForCities(),
'amountGuide' => [1, 2, 3, 4, 5],
'minAmount' => 1,
'maxAmount' => 5,
]
];
}
}
+1 -1
View File
@@ -120,7 +120,7 @@ class RaiseInvader extends \sammo\Event\Action
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$gameStor->setValue('isunited', 1); $gameStor->setValue('isunited', 1);
$turnterm = $gameStor->turnterm; $turnterm = abs($gameStor->turnterm);
$generalCnt = $db->queryFirstField('SELECT count(*) FROM general'); $generalCnt = $db->queryFirstField('SELECT count(*) FROM general');
if ($npcEachCount < 0) { if ($npcEachCount < 0) {
+1 -1
View File
@@ -134,7 +134,7 @@ class UpdateNationLevel extends \sammo\Event\Action
if ($levelDiff) { if ($levelDiff) {
//유니크 아이템 하나 돌리자 //유니크 아이템 하나 돌리자
$targetKillTurn = $env['killturn']; $targetKillTurn = $env['killturn'];
$targetKillTurn -= 24 * 60 / $env['turnterm']; $targetKillTurn -= 24 * 60 / abs($env['turnterm']);
$nationGenIDList = $db->queryFirstColumn( $nationGenIDList = $db->queryFirstColumn(
'SELECT no FROM general WHERE nation = %i AND killturn >= %i AND npc < 2', 'SELECT no FROM general WHERE nation = %i AND killturn >= %i AND npc < 2',
$nation['nation'], $nation['nation'],
+1
View File
@@ -384,6 +384,7 @@ class GameConstBase
'che_포상', 'che_포상',
'che_몰수', 'che_몰수',
'che_부대탈퇴지시', 'che_부대탈퇴지시',
'che_행동지시',
], ],
'외교' => [ '외교' => [
'che_물자원조', 'che_물자원조',
+7
View File
@@ -283,6 +283,7 @@ class General extends GeneralBase implements iAction
} }
$recwar = new \DateTimeImmutable($this->getVar('recent_war')); $recwar = new \DateTimeImmutable($this->getVar('recent_war'));
$turnNow = new \DateTimeImmutable($this->getVar('turntime')); $turnNow = new \DateTimeImmutable($this->getVar('turntime'));
$secDiff = TimeUtil::DateIntervalToSeconds($recwar->diff($turnNow)); $secDiff = TimeUtil::DateIntervalToSeconds($recwar->diff($turnNow));
if ($secDiff <= 0) { if ($secDiff <= 0) {
@@ -290,6 +291,12 @@ class General extends GeneralBase implements iAction
return 0; return 0;
} }
if($turnTerm == -60){
$turnDiff = VarTurn60::calcTurnDiff($recwar, $turnNow);
$this->calcCache[$cacheKey] = $turnDiff;
return $turnDiff;
}
$result = intdiv(Util::toInt($secDiff), 60 * $turnTerm); $result = intdiv(Util::toInt($secDiff), 60 * $turnTerm);
$this->calcCache[$cacheKey] = $result; $this->calcCache[$cacheKey] = $result;
return $result; return $result;
+4 -4
View File
@@ -1980,7 +1980,7 @@ class GeneralAI
$db = DB::db(); $db = DB::db();
$nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env'); $nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env');
$turnTerm = $this->env['turnterm']; $turnTerm = abs($this->env['turnterm']);
//천도를 한턴 넣었다면 계속 넣는다. //천도를 한턴 넣었다면 계속 넣는다.
if ($lastTurn->getCommand() === '천도' && $lastTurn->getArg()['destCityID'] != $this->nation['capital']) { if ($lastTurn->getCommand() === '천도' && $lastTurn->getArg()['destCityID'] != $this->nation['capital']) {
@@ -3134,7 +3134,7 @@ class GeneralAI
$rawCity = $db->queryFirstRow('SELECT * FROM city WHERE city = %i', $this->general->getCityID()); $rawCity = $db->queryFirstRow('SELECT * FROM city WHERE city = %i', $this->general->getCityID());
$this->general->setRawCity($rawCity); $this->general->setRawCity($rawCity);
} }
if (in_array($this->general->getRawCity()['level'], [5, 6])) { if (!in_array($this->general->getRawCity()['level'], [5, 6])) {
return null; return null;
} }
} }
@@ -3715,7 +3715,7 @@ class GeneralAI
$this->updateInstance(); $this->updateInstance();
//특별 메세지 있는 경우 출력 //특별 메세지 있는 경우 출력
$term = $this->env['turnterm']; $term = abs($this->env['turnterm']);
if ($general->getVar('npcmsg') && $this->rng->nextBool(GameConst::$npcMessageFreqByDay * $term / (60 * 24))) { if ($general->getVar('npcmsg') && $this->rng->nextBool(GameConst::$npcMessageFreqByDay * $term / (60 * 24))) {
$src = new MessageTarget( $src = new MessageTarget(
$general->getID(), $general->getID(),
@@ -3985,7 +3985,7 @@ class GeneralAI
$userChiefCnt = 0; $userChiefCnt = 0;
$minUserKillturn = $this->env['killturn'] - Util::toInt(240 / $this->env['turnterm']); $minUserKillturn = $this->env['killturn'] - Util::toInt(240 / abs($this->env['turnterm']));
$minNPCKillturn = 36; $minNPCKillturn = 36;
foreach (Util::range($minChiefLevel, 12) as $chiefLevel) { foreach (Util::range($minChiefLevel, 12) as $chiefLevel) {
+11 -2
View File
@@ -245,7 +245,16 @@ class ResetHelper{
true true
); );
if($sync == 0) { if($turnterm == -60){
[$starttime, $yearPulled, $month] = VarTurn60::fromDatetime(new \DateTimeImmutable($turntime))->cutDay();
if($yearPulled){
$year = $startyear-1;
}
else{
$year = $startyear;
}
}
else if($sync == 0) {
// 현재 시간을 1월로 맞춤 // 현재 시간을 1월로 맞춤
$starttime = cutTurn($turntime, $turnterm); $starttime = cutTurn($turntime, $turnterm);
$month = 1; $month = 1;
@@ -261,7 +270,7 @@ class ResetHelper{
} }
} }
$killturn = 4800 / $turnterm; $killturn = 4800 / abs($turnterm);
if($npcmode == 1) { $killturn = intdiv($killturn, 3); } if($npcmode == 1) { $killturn = intdiv($killturn, 3); }
$develcost = ($year - $startyear + 10) * 2; $develcost = ($year - $startyear + 10) * 2;
+11 -9
View File
@@ -20,6 +20,17 @@ final class ServerTool
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$admin = $gameStor->getValues(['turntime', 'turnterm', 'year', 'startyear', 'month', 'isunited']); $admin = $gameStor->getValues(['turntime', 'turnterm', 'year', 'startyear', 'month', 'isunited']);
$oldunit = $admin['turnterm'] * 60;
$unit = $turnterm * 60;
if($unit == $oldunit){
return null;
}
if($oldunit < 0 || $unit < 0){
return 'variable_turnterm';
}
$reqGameLock = $admin['isunited'] != 2 && !$ignoreLock; $reqGameLock = $admin['isunited'] != 2 && !$ignoreLock;
$locked = false; $locked = false;
@@ -40,15 +51,6 @@ final class ServerTool
$locked = tryLock(); $locked = tryLock();
} }
$oldunit = $admin['turnterm'] * 60;
$unit = $turnterm * 60;
if($unit == $oldunit){
if($locked){
unlock();
}
return null;
}
$unitDiff = $unit / $oldunit; $unitDiff = $unit / $oldunit;
@@ -17,15 +17,6 @@ class SightseeingMessage{
const HeavyWounded = 0x2000; const HeavyWounded = 0x2000;
static protected $messages = null; static protected $messages = null;
static protected ?\sammo\RandUtil $comparisonRng = null;
public static function setComparisonRng(?\sammo\RandUtil $rng): void
{
if ($rng !== null && getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
throw new \RuntimeException('comparison RNG is test-only');
}
static::$comparisonRng = $rng;
}
protected static function getMessageList():array{ protected static function getMessageList():array{
/* [[속성, [ /* [[속성, [
@@ -113,13 +104,8 @@ class SightseeingMessage{
} }
public function pickAction():array{ public function pickAction():array{
if (static::$comparisonRng !== null) {
[$type, $texts] = static::$comparisonRng->choiceUsingWeightPair(static::$messages ?? []);
$text = static::$comparisonRng->choice($texts);
} else {
[$type, $texts] = Util::choiceRandomUsingWeightPair(static::$messages??[]); [$type, $texts] = Util::choiceRandomUsingWeightPair(static::$messages??[]);
$text = Util::choiceRandom($texts); $text = Util::choiceRandom($texts);
}
return [$type, $text]; return [$type, $text];
} }
+10 -1
View File
@@ -219,12 +219,21 @@ class TurnExecutionHelper
$nextTurnTimeBase = $general->getAuxVar('nextTurnTimeBase'); $nextTurnTimeBase = $general->getAuxVar('nextTurnTimeBase');
if($nextTurnTimeBase !== null){ if($nextTurnTimeBase !== null){
if($gameStor->turnterm == -60){
[$turntime, $nextTurnterm] = VarTurn60::fromDatetime(new \DateTimeImmutable($turntime))->cutTurn();
$turntimeObj = new \DateTimeImmutable($turntime);
$turntimeObj = $turntimeObj->add(TimeUtil::secondsToDateInterval($nextTurnTimeBase * $nextTurnterm / 60));
$turntime = TimeUtil::format($turntimeObj, true);
$general->setAuxVar('nextTurnTimeBase', null);
}
else {
$turntime = cutTurn($turntime, $gameStor->turnterm); $turntime = cutTurn($turntime, $gameStor->turnterm);
$turntimeObj = new \DateTimeImmutable($turntime); $turntimeObj = new \DateTimeImmutable($turntime);
$turntimeObj = $turntimeObj->add(TimeUtil::secondsToDateInterval($nextTurnTimeBase)); $turntimeObj = $turntimeObj->add(TimeUtil::secondsToDateInterval($nextTurnTimeBase));
$turntime = TimeUtil::format($turntimeObj, true); $turntime = TimeUtil::format($turntimeObj, true);
$general->setAuxVar('nextTurnTimeBase', null); $general->setAuxVar('nextTurnTimeBase', null);
} }
}
$general->setVar('turntime', $turntime); $general->setVar('turntime', $turntime);
} }
@@ -355,7 +364,7 @@ class TurnExecutionHelper
if (($autorun_user['limit_minutes'] ?? false) && $general->getNPCType() < 2 && $hasReservedTurn) { if (($autorun_user['limit_minutes'] ?? false) && $general->getNPCType() < 2 && $hasReservedTurn) {
$autorun_limit = Util::joinYearMonth($year, $month); $autorun_limit = Util::joinYearMonth($year, $month);
$autorun_limit += intdiv($autorun_user['limit_minutes'], $turnterm); $autorun_limit += intdiv($autorun_user['limit_minutes'], abs($turnterm));
$general->setAuxVar('autorun_limit', $autorun_limit); $general->setAuxVar('autorun_limit', $autorun_limit);
} }
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace sammo;
class VarTurn60
{
const MonthAdjustOffset = 2;
static array $min30ToTurn = [
[0, 0, 120], //00:00 2
[0, 30, 120], //00:30
[0, 60, 120], //01:00
[0, 90, 120], //01:30
[1, 0, 120], //02:00 3
[1, 30, 120], //02:30
[1, 60, 120], //03:00
[1, 90, 120], //03:30
[2, 0, 120], //04:00 4
[2, 30, 120], //04:30
[2, 60, 120], //05:00
[2, 90, 120], //05:30
[3, 0, 60], //06:00 5
[3, 30, 60], //06:30
[4, 0, 60], //07:00 6
[4, 30, 60], //07:30
[5, 0, 60], //08:00 7
[5, 30, 60], //08:30
[6, 0, 60], //09:00 8
[6, 30, 60], //09:30
[7, 0, 60], //10:00 9
[7, 30, 60], //10:30
[8, 0, 60], //11:00 10
[8, 30, 60], //11:30
[9, 0, 60], //12:00 11
[9, 30, 60], //12:30
[10, 0, 60], //13:00 0(12)
[10, 30, 60], //13:30
[11, 0, 60], //14:00 1(13)
[11, 30, 60], //14:30
[12, 0, 60], //15:00 2(14)
[12, 30, 60], //15:30
[13, 0, 60], //16:00 3(15)
[13, 30, 60], //16:30
[14, 0, 60], //17:00 4(16)
[14, 30, 60], //17:30
[15, 0, 60], //18:00 5(17)
[15, 30, 60], //18:30
[16, 0, 30], //19:00 6(18)
[17, 0, 30], //19:30 7(19)
[18, 0, 30], //20:00 8(20)
[19, 0, 30], //20:30 9(21)
[20, 0, 30], //21:00 10(22)
[21, 0, 30], //21:30 11(23)
[22, 0, 60], //22:00 0
[22, 30, 60], //22:30
[23, 0, 60], //23:00 1
[23, 30, 60], //23:30
//[24, 0, 60], //24:00 2
];
static array $turnToHM = [
[0, 0, 120], //0
[2, 0, 120], //1
[4, 0, 120], //2
[6, 0, 60], //3
[7, 0, 60], //4
[8, 0, 60], //5
[9, 0, 60], //6
[10, 0, 60], //7
[11, 0, 60], //8
[12, 0, 60], //9
[13, 0, 60], //10
[14, 0, 60], //11
[15, 0, 60], //12
[16, 0, 60], //13
[17, 0, 60], //14
[18, 0, 60], //15
[19, 0, 30], //16
[19, 30, 30],//17
[20, 0, 30], //18
[20, 30, 30],//19
[21, 0, 30], //20
[21, 30, 30],//21
[22, 0, 60], //22
[23, 0, 60], //23
//[24, 0, 60], //24(?)
];
function __construct(
public \DateTimeImmutable $baseDay,
public int $turnIdx, //0~23
public float $secOffset, ///turnTerm에 따라 최대치가 30*60, 60*60, 120*60 가변
)
{
}
static function fromDatetime(\DateTimeInterface $date): self {
$baseDay = new \DateTimeImmutable($date->format('Y-m-d'));
$totalSec = $date->getTimestamp() - $baseDay->getTimestamp();
$min30 = intdiv($totalSec, 60 * 30);
assert($min30 < 48);
[$turnIdx, $minOffset, ] = static::$min30ToTurn[$min30];
$secOffset = $totalSec - 60 * 30 * $min30 + $minOffset * 60;
return new static($baseDay, $turnIdx, $secOffset);
}
/// $b - $a 의 턴차
static function calcTurnDiff(\DateTimeInterface $a, \DateTimeInterface $b): int{
$aObj = static::fromDatetime($a);
$bObj = static::fromDatetime($b);
$resDays = $aObj->baseDay->diff($bObj->baseDay)->days; //놀랍게도 절대값
if($aObj->baseDay > $bObj->baseDay){
$resDays *= -1;
}
$res = $resDays * 24 + $bObj->turnIdx - $aObj->turnIdx;
return $res;
}
function cutTurn($withFraction = true): array{
[$hour, $minOffset, $turnTerm] = static::$turnToHM[$this->turnIdx];
$date = $this->baseDay->add(TimeUtil::secondsToDateInterval(($hour * 60 + $minOffset) * 60));
return [TimeUtil::format($date, $withFraction), $turnTerm];
}
function toDateStr($withFraction = true): string{
[$hour, $minOffset, ] = static::$turnToHM[$this->turnIdx];
$date = $this->baseDay->add(TimeUtil::secondsToDateInterval(($hour * 60 + $minOffset) * 60 + $this->secOffset));
return TimeUtil::format($date, $withFraction);
}
function cutDay($withFraction = true): array{
//고정 시간 기준 cutDay()의 이식
//상수 테이블이 이미 13:00에 1월이 되도록 맞춰져 있음.
//다만 20:00인 경우 8월이 아니라 9월이 됨 ^^;
$newMonth = ($this->turnIdx + static::MonthAdjustOffset) % 12 + 1;
$yearPulled = $newMonth > 3;
$obj = $yearPulled ? $this->addTurn(12): $this;
[$date, ] = $obj->addTurn(-($newMonth - 1))->cutTurn($withFraction);
return [$date, $yearPulled, $newMonth];
}
function addTurn(int $moreTurn): self{
$dayDiff = intdiv($moreTurn, 24);
$moreTurn %= 24;
$nextTurnIdx = $this->turnIdx + $moreTurn;
if($nextTurnIdx < 0){
$dayDiff -= 1;
$nextTurnIdx += 24;
}
else if($nextTurnIdx >= 24){
$dayDiff += 1;
$nextTurnIdx -= 24;
}
[, , $oldTurnTerm] = static::$turnToHM[$this->turnIdx];
[, , $nextTurnTerm] = static::$turnToHM[$nextTurnIdx];
$nextSecOffset = $this->secOffset;
if($oldTurnTerm != $nextTurnTerm){
$nextSecOffset *= $nextTurnTerm;
$nextSecOffset /= $oldTurnTerm;
}
if($dayDiff == 0){
return new static($this->baseDay, $nextTurnIdx, $nextSecOffset);
}
if($dayDiff > 0){
$nextBaseDay = $this->baseDay->add(new \DateInterval("P{$dayDiff}D"));
}
else{
$dayAbsDiff = abs($dayDiff);
$nextBaseDay = $this->baseDay->sub(new \DateInterval("P{$dayAbsDiff}D"));
}
return new static($nextBaseDay, $nextTurnIdx, $nextSecOffset);
}
}
+7
View File
@@ -81,6 +81,13 @@
["RaiseNPCNation"], ["RaiseNPCNation"],
["DeleteEvent"] ["DeleteEvent"]
], ],
[
"month", 1000,
true,
["ChangeCity", "all", {
"trade":100
}]
],
[ [
"month", 999, "month", 999,
["Date", "==", 181, 1], ["Date", "==", 181, 1],
-92
View File
@@ -1,92 +0,0 @@
{
"title":"【공백지】 요양 ",
"startYear":180,
"map":{
"mapName":"miniche"
},
"history":[
"<C>●</>180년 1월:<L><b>【이벤트】</b></>쉬었다 가는 깃수입니다."
],
"const": {
"joinRuinedNPCProp":0,
"npcBanMessageProb":0.005,
"defaultMaxGeneral": 800,
"allItems":{
"horse":{
"che_명마_01_노기":0, "che_명마_02_조랑":0, "che_명마_03_노새":0,
"che_명마_04_나귀":0, "che_명마_05_갈색마":0, "che_명마_06_흑색마":0,
"che_명마_07_백마" : 2, "che_명마_07_기주마" : 2, "che_명마_07_오환마" : 2, "che_명마_07_백상" : 2,
"che_명마_08_양주마" : 2, "che_명마_08_흉노마" : 2, "che_명마_09_과하마" : 2, "che_명마_09_의남백마" : 2,
"che_명마_10_대완마" : 2, "che_명마_10_옥추마" : 2, "che_명마_11_서량마" : 2, "che_명마_11_화종마" : 2,
"che_명마_12_사륜거" : 2, "che_명마_12_옥란백용구": 2, "che_명마_13_절영" : 2, "che_명마_13_적로" : 2,
"che_명마_14_적란마" : 2, "che_명마_14_조황비전" : 2, "che_명마_15_한혈마" : 2, "che_명마_15_적토마" : 2
},
"weapon":{
"che_무기_01_단도":0, "che_무기_02_단궁":0, "che_무기_03_단극":0,
"che_무기_04_목검":0, "che_무기_05_죽창":0, "che_무기_06_소부":0,
"che_무기_07_동추":2, "che_무기_07_철편":2, "che_무기_07_철쇄":2, "che_무기_07_맥궁":2,
"che_무기_08_유성추":2, "che_무기_08_철질여골":2, "che_무기_09_쌍철극":2, "che_무기_09_동호비궁":2,
"che_무기_10_삼첨도":2, "che_무기_10_대부":2, "che_무기_11_고정도":2, "che_무기_11_이광궁":2,
"che_무기_12_철척사모":2, "che_무기_12_칠성검":2, "che_무기_13_사모":2, "che_무기_13_양유기궁":2,
"che_무기_14_언월도":2, "che_무기_14_방천화극":2, "che_무기_15_청홍검":2, "che_무기_15_의천검":2
},
"book":{
"che_서적_01_효경전":0, "che_서적_02_회남자":0, "che_서적_03_변도론":0,
"che_서적_04_건상역주":0, "che_서적_05_여씨춘추":0, "che_서적_06_사민월령":0,
"che_서적_07_위료자":2, "che_서적_07_사마법":2, "che_서적_07_한서":2, "che_서적_07_논어":2,
"che_서적_08_전론":2, "che_서적_08_사기":2, "che_서적_09_장자":2, "che_서적_09_역경":2,
"che_서적_10_시경":2, "che_서적_10_구국론":2, "che_서적_11_상군서":2, "che_서적_11_춘추전":2,
"che_서적_12_산해경":2, "che_서적_12_맹덕신서":2, "che_서적_13_관자":2, "che_서적_13_병법24편":2,
"che_서적_14_한비자":2, "che_서적_14_오자병법":2, "che_서적_15_노자":2, "che_서적_15_손자병법":2
},
"item":{
"che_치료_환약":0, "che_저격_수극":0, "che_사기_탁주":0,
"che_훈련_청주":0, "che_계략_이추":0, "che_계략_향낭":0,
"event_전투특기_격노":0, "event_전투특기_견고":0, "event_전투특기_공성":0, "event_전투특기_궁병":0,
"event_전투특기_귀병":0, "event_전투특기_기병":0, "event_전투특기_돌격":0, "event_전투특기_무쌍":0,
"event_전투특기_반계":0, "event_전투특기_보병":0, "event_전투특기_신산":0, "event_전투특기_신중":0,
"event_전투특기_위압":0, "event_전투특기_의술":0, "event_전투특기_저격":0, "event_전투특기_집중":0,
"event_전투특기_징병":0, "event_전투특기_척사":0, "event_전투특기_필살":0, "event_전투특기_환술":0
}
}
},
"events":[
[
"month", 1000,
["or", ["Date", "==", null, 12], ["Date", "==", null, 6]],
["CreateManyNPC", 15, 10],
["DeleteEvent"]
],
[
"month", 1000,
["Date", "==", 181, 1],
["RaiseNPCNation"],
["DeleteEvent"]
],
[
"month", 999,
["Date", "==", 181, 1],
["OpenNationBetting", 4, 5000],
["OpenNationBetting", 1, 2000],
["DeleteEvent"]
],
[
"month", 1000,
["Date", "==", null, 1],
["ChangeCity", "all", {
"trade":100
}]
],
[
"month", 1000,
["Date", "==", null, 7],
["ChangeCity", "all", {
"trade":100
}]
]
]
}
+13 -1
View File
@@ -68,7 +68,7 @@ import { formatTime } from "@/util/formatTime";
import { mb_strwidth } from "@/util/mb_strwidth"; import { mb_strwidth } from "@/util/mb_strwidth";
import { parseTime } from "@/util/parseTime"; import { parseTime } from "@/util/parseTime";
import type { StoredActionsHelper } from "@/util/StoredActionsHelper"; import type { StoredActionsHelper } from "@/util/StoredActionsHelper";
import addMinutes from "date-fns/esm/addMinutes/index"; import { addMinutes } from "date-fns/addMinutes";
import { range } from "lodash-es"; import { range } from "lodash-es";
import { inject, onMounted, ref, type PropType } from "vue"; import { inject, onMounted, ref, type PropType } from "vue";
import VueTypes from "vue-types"; import VueTypes from "vue-types";
@@ -76,6 +76,7 @@ import DragSelect from "@/components/DragSelect.vue";
import { BButton } from "bootstrap-vue-next"; import { BButton } from "bootstrap-vue-next";
import { QueryActionHelper } from "@/util/QueryActionHelper"; import { QueryActionHelper } from "@/util/QueryActionHelper";
import type { ChiefResponse } from "@/defs/API/NationCommand"; import type { ChiefResponse } from "@/defs/API/NationCommand";
import { VarTurn60 } from "@/varTurn60";
const props = defineProps({ const props = defineProps({
style: VueTypes.object.isRequired, style: VueTypes.object.isRequired,
@@ -198,10 +199,21 @@ if (!props.officer || !props.officer.turnTime) {
} }
} else { } else {
const baseTurnTime = parseTime(props.officer.turnTime); const baseTurnTime = parseTime(props.officer.turnTime);
if(props.turnTerm == -60){
const baseTurnTimeObj = VarTurn60.fromDatetime(baseTurnTime);
for (const idx of range(props.officer.turn.length)) {
turnTimes.value.push(
formatTime(baseTurnTimeObj.addTurn(idx).toDate(), "HH:mm")
);
}
}
else{
for (const idx of range(props.officer.turn.length)) { for (const idx of range(props.officer.turn.length)) {
turnTimes.value.push( turnTimes.value.push(
formatTime(addMinutes(baseTurnTime, idx * props.turnTerm), props.turnTerm >= 5 ? "HH:mm" : "mm:ss") formatTime(addMinutes(baseTurnTime, idx * props.turnTerm), props.turnTerm >= 5 ? "HH:mm" : "mm:ss")
); );
} }
} }
}
</script> </script>
+2 -3
View File
@@ -310,14 +310,13 @@ declare const staticValues: {
import { reactive, ref, watch } from "vue"; import { reactive, ref, watch } from "vue";
import "@scss/game_bg.scss"; import "@scss/game_bg.scss";
import TopBackBar from "@/components/TopBackBar.vue"; import TopBackBar from "@/components/TopBackBar.vue";
import _ from "lodash-es"; import { sum } from "lodash-es";
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue"; import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
import { SammoAPI } from "./SammoAPI"; import { SammoAPI } from "./SammoAPI";
import { InheritResetStat, type inheritBuffType, type InheritPointLogItem } from "./defs/API/InheritAction"; import { InheritResetStat, type inheritBuffType, type InheritPointLogItem } from "./defs/API/InheritAction";
import * as JosaUtil from "@/util/JosaUtil"; import * as JosaUtil from "@/util/JosaUtil";
import { BButton } from "bootstrap-vue-next"; import { BButton } from "bootstrap-vue-next";
import { unwrap } from "./util/unwrap"; import { unwrap } from "./util/unwrap";
import { add as dateAdd } from 'date-fns';
const inheritanceViewText: Record<InheritanceViewType, { title: string; info: string }> = { const inheritanceViewText: Record<InheritanceViewType, { title: string; info: string }> = {
sum: { sum: {
@@ -428,7 +427,7 @@ const title = "유산 관리";
const items = ref( const items = ref(
(() => { (() => {
const totalPoint = Math.floor(_.sum(Object.values(staticValues.items))); const totalPoint = Math.floor(sum(Object.values(staticValues.items)));
const previousPoint = Math.floor(staticValues.items["previous"]); const previousPoint = Math.floor(staticValues.items["previous"]);
const newPoint = Math.floor(totalPoint - previousPoint); const newPoint = Math.floor(totalPoint - previousPoint);
const result: Record<InheritanceViewType, number> = { const result: Record<InheritanceViewType, number> = {
+1 -1
View File
@@ -485,7 +485,7 @@ watch(inheritCity, (newValue: undefined | number) => {
const inheritTurnTimeZone = ref<number>(); const inheritTurnTimeZone = ref<number>();
const turnTimeZoneList: string[] = (()=>{ const turnTimeZoneList: string[] = (()=>{
const result: string[] = []; const result: string[] = [];
const zoneSec = turnterm; // * 60 / 60 const zoneSec = Math.abs(turnterm); // * 60 / 60
let zoneCur = 0; let zoneCur = 0;
for(const idx of range(60)){ for(const idx of range(60)){
const zoneNext = zoneCur + zoneSec; const zoneNext = zoneCur + zoneSec;
+8 -1
View File
@@ -276,7 +276,7 @@ declare const staticValues: {
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import addMinutes from "date-fns/esm/addMinutes"; import { addMinutes } from "date-fns/addMinutes";
import { isString, range, trim } from "lodash-es"; import { isString, range, trim } from "lodash-es";
import queryString from "query-string"; import queryString from "query-string";
import { onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
@@ -297,6 +297,7 @@ import { QueryActionHelper } from "./util/QueryActionHelper";
import SimpleClock from "./components/SimpleClock.vue"; import SimpleClock from "./components/SimpleClock.vue";
import type { ReservedCommandResponse } from "./defs/API/Command"; import type { ReservedCommandResponse } from "./defs/API/Command";
import { unwrap } from "./util/unwrap"; import { unwrap } from "./util/unwrap";
import { VarTurn60 } from "./varTurn60";
defineExpose({ defineExpose({
updateCommandTable, updateCommandTable,
@@ -544,9 +545,15 @@ async function reloadCommandList() {
}); });
yearMonth += 1; yearMonth += 1;
if(result.turnTerm == -60){
nextTurnTime = VarTurn60.fromDatetime(nextTurnTime).addTurn(1).toDate();
}
else{
nextTurnTime = addMinutes(nextTurnTime, result.turnTerm); nextTurnTime = addMinutes(nextTurnTime, result.turnTerm);
} }
}
serverNow.value = parseTime(result.date); serverNow.value = parseTime(result.date);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import type { AutoLoginFailed, AutoLoginNonceResponse, AutoLoginResponse, LoginFailed, LoginResponse } from "./defs/API/Login"; import type { AutoLoginFailed, AutoLoginNonceResponse, AutoLoginResponse, LoginFailed, LoginResponse } from "./defs/API/Login";
import { APIPathGen } from "./util/APIPathGen"; import { APIPathGen } from "./util/APIPathGen";
import { callSammoAPI, extractHttpMethod, POST, type APICallT, type APITail, type InvalidResponse, type RawArgType, type ValidResponse } from "./util/callSammoAPI"; import { callSammoAPI, extractHttpMethod, GET, POST, type APICallT, type APITail, type InvalidResponse, type RawArgType, type ValidResponse } from "./util/callSammoAPI";
export type { ValidResponse, InvalidResponse }; export type { ValidResponse, InvalidResponse };
const apiRealPath = { const apiRealPath = {
@@ -18,7 +18,7 @@ const apiRealPath = {
hashedToken: string, hashedToken: string,
token_id: number, token_id: number,
}, AutoLoginResponse, AutoLoginFailed>, }, AutoLoginResponse, AutoLoginFailed>,
ReqNonce: POST as APICallT<undefined, AutoLoginNonceResponse, AutoLoginFailed> ReqNonce: GET as APICallT<undefined, AutoLoginNonceResponse, AutoLoginFailed>
}, },
} as const; } as const;
+9 -2
View File
@@ -256,7 +256,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import addMinutes from "date-fns/esm/addMinutes"; import { addMinutes } from "date-fns/addMinutes";
import queryString from "query-string"; import queryString from "query-string";
import { onMounted, ref, watch, type PropType, inject, type Ref } from "vue"; import { onMounted, ref, watch, type PropType, inject, type Ref } from "vue";
import { formatTime } from "@util/formatTime"; import { formatTime } from "@util/formatTime";
@@ -282,6 +282,7 @@ import { unwrap_err } from "@/util/unwrap_err";
import type { GameConstStore } from "@/GameConstStore"; import type { GameConstStore } from "@/GameConstStore";
import { postFilterNationCommandGen } from "@/utilGame/postFilterNationCommandGen"; import { postFilterNationCommandGen } from "@/utilGame/postFilterNationCommandGen";
import { unwrap } from "@/util/unwrap"; import { unwrap } from "@/util/unwrap";
import { VarTurn60 } from "@/varTurn60";
const toasts = unwrap(useToast()); const toasts = unwrap(useToast());
@@ -536,14 +537,20 @@ function updateCommandList() {
...obj, ...obj,
year, year,
month, month,
time: formatTime(nextTurnTime, props.turnTerm >= 5 ? "HH:mm" : "mm:ss"), time: formatTime(nextTurnTime, Math.abs(props.turnTerm) >= 5 ? "HH:mm" : "mm:ss"),
tooltip: tooltip.length == 0 ? undefined : tooltip.join("\n"), tooltip: tooltip.length == 0 ? undefined : tooltip.join("\n"),
style, style,
}); });
yearMonth += 1; yearMonth += 1;
if(props.turnTerm == -60){
nextTurnTime = VarTurn60.fromDatetime(nextTurnTime).addTurn(1).toDate();
}
else{
nextTurnTime = addMinutes(nextTurnTime, props.turnTerm); nextTurnTime = addMinutes(nextTurnTime, props.turnTerm);
} }
}
reservedCommandList.value = _reservedCommandList; reservedCommandList.value = _reservedCommandList;
updated.value = true; updated.value = true;
} }
+1 -1
View File
@@ -23,7 +23,7 @@
<AutorunInfo :autorunMode="globalInfo.autorunUser" /> <AutorunInfo :autorunMode="globalInfo.autorunUser" />
</div> </div>
<div class="s-border-t col py-2 col-8 col-lg-4 subYearMonth"> <div class="s-border-t col py-2 col-8 col-lg-4 subYearMonth">
현재: {{ globalInfo.year }} {{ globalInfo.month }} ({{ globalInfo.turnterm }} 서버) 현재: {{ globalInfo.year }} {{ globalInfo.month }} ({{ globalInfo.turnterm == -60 ? '가변 60' : globalInfo.turnterm }} 서버)
</div> </div>
<div class="s-border-t col py-2 col-4 col-lg-2 subOnlineUserCnt"> <div class="s-border-t col py-2 col-4 col-lg-2 subOnlineUserCnt">
전체 접속자 : {{ (globalInfo.onlineUserCnt ?? 0).toLocaleString() }} 전체 접속자 : {{ (globalInfo.onlineUserCnt ?? 0).toLocaleString() }}
+7 -1
View File
@@ -175,8 +175,9 @@ import { clamp } from "lodash-es";
import { formatCityName } from "@/utilGame/formatCityName"; import { formatCityName } from "@/utilGame/formatCityName";
import { isValidObjKey } from "@/utilGame/isValidObjKey"; import { isValidObjKey } from "@/utilGame/isValidObjKey";
import { calcInjury } from "@/utilGame/calcInjury"; import { calcInjury } from "@/utilGame/calcInjury";
import { addMinutes } from "date-fns/esm"; import { addMinutes } from "date-fns/addMinutes";
import type { GameIActionInfo } from "@/defs/GameObj"; import type { GameIActionInfo } from "@/defs/GameObj";
import { VarTurn60 } from "@/varTurn60";
const imagePath = window.pathConfig.gameImage; const imagePath = window.pathConfig.gameImage;
const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore")); const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore"));
const props = defineProps<{ const props = defineProps<{
@@ -278,8 +279,13 @@ watch(
() => { () => {
let turnTime = parseTime(general.value.turntime); let turnTime = parseTime(general.value.turntime);
if (turnTime.getTime() < props.lastExecuted.getTime()) { if (turnTime.getTime() < props.lastExecuted.getTime()) {
if(props.turnTerm == -60){
turnTime = VarTurn60.fromDatetime(turnTime).addTurn(1).toDate();
}
else {
turnTime = addMinutes(turnTime, props.turnTerm); turnTime = addMinutes(turnTime, props.turnTerm);
} }
}
nextExecuteMinute.value = Math.floor(clamp((turnTime.getTime() - props.lastExecuted.getTime()) / 60000, 0, 999)); nextExecuteMinute.value = Math.floor(clamp((turnTime.getTime() - props.lastExecuted.getTime()) / 60000, 0, 999));
}, },
{ immediate: true } { immediate: true }
+1 -1
View File
@@ -127,7 +127,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MsgItem, MsgTarget, MsgType } from "@/defs/API/Message"; import type { MsgItem, MsgTarget, MsgType } from "@/defs/API/Message";
import { parseTime } from "@/util/parseTime"; import { parseTime } from "@/util/parseTime";
import { differenceInMilliseconds, addMinutes } from "date-fns/esm"; import { addMinutes, differenceInMilliseconds } from "date-fns";
import { computed, onMounted, ref, toRef, watch, type ComputedRef, type Ref } from "vue"; import { computed, onMounted, ref, toRef, watch, type ComputedRef, type Ref } from "vue";
import linkifyStr from "linkify-string"; import linkifyStr from "linkify-string";
import { SammoAPI } from "@/SammoAPI"; import { SammoAPI } from "@/SammoAPI";
+1 -1
View File
@@ -93,7 +93,7 @@ $(function () {
const runAnalysis = async function () { const runAnalysis = async function () {
let realKillturn = killturn; let realKillturn = killturn;
if(autorun_user && autorun_user.limit_minutes){ if(autorun_user && autorun_user.limit_minutes){
realKillturn -= autorun_user.limit_minutes / turnterm; realKillturn -= autorun_user.limit_minutes / Math.abs(turnterm);
} }
const $content = $('#on_mover .content'); const $content = $('#on_mover .content');
try { try {
+8 -23
View File
@@ -37,16 +37,14 @@ function regNextToken(tokenInfo: [number, string]) {
function getToken(): [number, string] | undefined { function getToken(): [number, string] | undefined {
const trialToken = localStorage.getItem(LOGIN_TOKEN_KEY); const trialToken = localStorage.getItem(LOGIN_TOKEN_KEY);
if (!trialToken) { if (!trialToken) {
console.log('no token');
return; return;
} }
const tokenItems = JSON.parse(trialToken) as [number, [number, string], number]; const tokenItems = JSON.parse(trialToken) as [number, [number, string], string];
if (tokenItems[0] != TOKEN_VERSION) { if (tokenItems[0] != TOKEN_VERSION) {
console.log(tokenItems); console.log(tokenItems);
resetToken(); resetToken();
return; return;
} }
console.debug(localStorage.getItem(LOGIN_TOKEN_KEY));
const [, token,] = tokenItems; const [, token,] = tokenItems;
return token; return token;
} }
@@ -64,27 +62,19 @@ async function tryAutoLogin() {
const [tokenID, token] = tokenInfo; const [tokenID, token] = tokenInfo;
for (let attempt = 0; attempt < 2; attempt++) { const result = await SammoRootAPI.Login.ReqNonce(undefined, true);
const reqNonceStartAt = Date.now();
const nonceResult = await SammoRootAPI.Login.ReqNonce(undefined, true);
if (!nonceResult) { if (!result) {
//api 에러. //api 에러.
return; return;
} }
if (!nonceResult.result) { if (!result.result) {
resetToken(); resetToken();
return; return;
} }
const nonce = nonceResult.loginNonce; const nonce = result.loginNonce;
console.debug(
'try auto login with token',
tokenID,
`attempt:${attempt + 1}`,
`reqNonceElapsed:${Date.now() - reqNonceStartAt}ms`
);
const hashedToken = sha512(token + nonce); const hashedToken = sha512(token + nonce);
const loginResult = await SammoRootAPI.Login.LoginByToken({ const loginResult = await SammoRootAPI.Login.LoginByToken({
@@ -93,12 +83,6 @@ async function tryAutoLogin() {
}, true); }, true);
if (!loginResult.result) { if (!loginResult.result) {
if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) {
console.warn('auto login failed by procedure error. retrying once.');
await delay(150);
continue;
}
if (!loginResult.silent) { if (!loginResult.silent) {
alert(loginResult.reason); alert(loginResult.reason);
} }
@@ -110,8 +94,7 @@ async function tryAutoLogin() {
regNextToken(loginResult.nextToken); regNextToken(loginResult.nextToken);
} }
window.location.href = "./"; window.location.href = "./";
return;
}
} }
catch (e) { catch (e) {
if (isString(e)) { if (isString(e)) {
@@ -120,6 +103,8 @@ async function tryAutoLogin() {
console.error(e); console.error(e);
return; return;
} }
} }
function getOAuthToken(mode: string, scope_list?: string[] | string) { function getOAuthToken(mode: string, scope_list?: string[] | string) {
+1 -1
View File
@@ -147,7 +147,7 @@ const descriptor: NamedRules<InstallFormType> = {
turnterm: { turnterm: {
required: true, required: true,
type: "enum", type: "enum",
enum: [1, 2, 5, 10, 20, 30, 60, 120], enum: [1, 2, 5, 10, 20, 30, 60, 120, -60],
transform: parseInt, transform: parseInt,
}, },
sync: { sync: {
+3 -3
View File
@@ -6,7 +6,7 @@ import axios from 'axios';
import { convertFormData } from '@util/convertFormData'; import { convertFormData } from '@util/convertFormData';
import { isBrightColor } from "@util/isBrightColor"; import { isBrightColor } from "@util/isBrightColor";
import { unwrap } from '@util/unwrap'; import { unwrap } from '@util/unwrap';
import _, { isError, isString } from 'lodash-es'; import { isError, isString, last, trim } from 'lodash-es';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import { parseTime } from '@util/parseTime'; import { parseTime } from '@util/parseTime';
import { formatTime } from '@util/formatTime'; import { formatTime } from '@util/formatTime';
@@ -234,7 +234,7 @@ function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse {
if (!msgList || msgList.length == 0) { if (!msgList || msgList.length == 0) {
continue; continue;
} }
const lastMsg = unwrap(_.last(msgList)); const lastMsg = unwrap(last(msgList));
minMsgSeq[msgType] = Math.min(minMsgSeq[msgType], lastMsg.id); minMsgSeq[msgType] = Math.min(minMsgSeq[msgType], lastMsg.id);
} }
return obj; return obj;
@@ -605,7 +605,7 @@ function activateMessageForm() {
$msgSubmit.on('click', async function () { $msgSubmit.on('click', async function () {
const text = _.trim(unwrap_any<string>($msgInput.val())); const text = trim(unwrap_any<string>($msgInput.val()));
$msgInput.val('').trigger('focus'); $msgInput.val('').trigger('focus');
const targetMailbox = unwrap_any<string>($mailboxList.val()); const targetMailbox = unwrap_any<string>($mailboxList.val());
-1
View File
@@ -10,7 +10,6 @@ import { default as che_장수대상임관 } from "./che_장수대상임관.vue"
import { default as che_징병 } from "./che_징병.vue"; import { default as che_징병 } from "./che_징병.vue";
import { default as che_헌납 } from "./che_헌납.vue"; import { default as che_헌납 } from "./che_헌납.vue";
import { default as cr_건국 } from "./cr_건국.vue"; import { default as cr_건국 } from "./cr_건국.vue";
import { default as ProcessCity } from "../ProcessCity.vue"; import { default as ProcessCity } from "../ProcessCity.vue";
import { default as ProcessGeneralAmount } from "../ProcessGeneralAmount.vue"; import { default as ProcessGeneralAmount } from "../ProcessGeneralAmount.vue";
@@ -0,0 +1,118 @@
<template>
<TopBackBar v-model:searchable="searchable" :title="commandName" :type="procEntryMode" />
<div class="bg0">
<div>장수에게 턴을 당기거라 미루라고 지시합니다.</div>
<div class="row">
<div class="col-12 col-lg-5">
장수 :
<SelectGeneral
v-model="selectedGeneralID"
:cities="citiesMap"
:generals="generalList"
:textHelper="textHelpGeneral"
:searchable="searchable"
/>
</div>
<div class="col-2 col-lg-2">
:
<b-button-group>
<b-button :pressed="isPull" @click="isPull = true"> 당기기 </b-button>
<b-button :pressed="!isPull" @click="isPull = false"> 미루기 </b-button>
</b-button-group>
</div>
<div class="col-7 col-lg-3">
<SelectAmount v-model="amount" :amountGuide="amountGuide" :maxAmount="maxAmount" :minAmount="minAmount" />
</div>
<div class="col-3 col-lg-2 d-grid">
<b-button variant="primary" @click="submit">
{{ commandName }}
</b-button>
</div>
</div>
</div>
<BottomBar :title="commandName" :type="procEntryMode" />
</template>
<script lang="ts">
declare const procRes: {
distanceList: Record<number, number[]>;
cities: [number, string][];
generals: procGeneralRawItemList;
generalsKey: procGeneralKey[];
minAmount: number;
maxAmount: number;
amountGuide: number[];
};
declare const staticValues: {
commandName: string;
entryInfo: ["General" | "Nation", unknown];
};
</script>
<script setup lang="ts">
import SelectGeneral from "@/processing/SelectGeneral.vue";
import SelectAmount from "@/processing/SelectAmount.vue";
import { ref } from "vue";
import { unwrap } from "@/util/unwrap";
import type { Args } from "@/processing/args";
import TopBackBar from "@/components/TopBackBar.vue";
import BottomBar from "@/components/BottomBar.vue";
import {
convertGeneralList,
getProcSearchable,
type procGeneralItem,
type procGeneralKey,
type procGeneralRawItemList,
} from "@/processing/processingRes";
import { getNPCColor } from "@/utilGame";
const citiesMap = ref(
new Map<
number,
{
name: string;
info?: string;
}
>()
);
for (const [id, name] of procRes.cities) {
citiesMap.value.set(id, { name });
}
const generalList = convertGeneralList(procRes.generalsKey, procRes.generals);
const amount = ref(1);
const isPull = ref(true);
const selectedGeneralID = ref(generalList[0].no);
type procGeneralItemWithTurn0Brief = procGeneralItem & {
turn0Brief: string;
};
function textHelpGeneral(_gen: procGeneralItem): string {
const gen = _gen as procGeneralItemWithTurn0Brief;
const nameColor = getNPCColor(gen.npc);
const name = nameColor ? `<span style="color:${nameColor}">${gen.name}</span>` : gen.name;
return `${name} [${citiesMap.value.get(unwrap(gen.cityID))?.name}] (${gen.leadership}/${gen.strength}/${
gen.intel
}) (${gen.turn0Brief})`;
}
async function submit(e: Event) {
const event = new CustomEvent<Args>("customSubmit", {
detail: {
amount: amount.value,
isPull: isPull.value,
destGeneralID: selectedGeneralID.value,
},
});
unwrap(e.target).dispatchEvent(event);
}
const { commandName, entryInfo } = staticValues;
const searchable = getProcSearchable();
const procEntryMode: "chief" | "normal" = entryInfo[0] == "Nation" ? "chief" : "normal";
const { minAmount, maxAmount, amountGuide } = procRes;
</script>
+2
View File
@@ -3,6 +3,7 @@ import { default as che_국호변경 } from "./che_국호변경.vue";
import { default as che_물자원조 } from "./che_물자원조.vue"; import { default as che_물자원조 } from "./che_물자원조.vue";
import { default as che_불가침제의 } from "./che_불가침제의.vue"; import { default as che_불가침제의 } from "./che_불가침제의.vue";
import { default as che_피장파장 } from "./che_피장파장.vue"; import { default as che_피장파장 } from "./che_피장파장.vue";
import { default as che_행동지시 } from "./che_행동지시.vue";
import { default as cr_인구이동 } from "./cr_인구이동.vue"; import { default as cr_인구이동 } from "./cr_인구이동.vue";
@@ -33,6 +34,7 @@ export const commandMap: Record<string, typeof ProcessNation | typeof ProcessCit
che_피장파장, che_피장파장,
che_허보: ProcessCity, che_허보: ProcessCity,
cr_인구이동, cr_인구이동,
che_행동지시,
} }
/* /*
+1 -1
View File
@@ -13,7 +13,7 @@ const intArgs = [
] as const; ] as const;
const booleanArgs = [ const booleanArgs = [
'isGold', 'buyRice', 'isGold', 'buyRice', 'isPull',
] as const; ] as const;
const integerArrayArgs = [ const integerArrayArgs = [
+7 -1
View File
@@ -1,5 +1,11 @@
import {parseISO} from 'date-fns'; import {parseISO} from 'date-fns';
export function parseTime(dateString: string): Date{ export function parseTime(dateString: string): Date{
return parseISO(dateString); if(dateString === null){
console.warn('why null');
console.trace();
return new Date();
}
const tmp = parseISO(dateString);
return tmp;
} }
+1
View File
@@ -1,4 +1,5 @@
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
export function calcTournamentTerm(turnTerm: number): number{ export function calcTournamentTerm(turnTerm: number): number{
turnTerm = Math.abs(turnTerm);
return clamp(turnTerm, 5, 120); return clamp(turnTerm, 5, 120);
} }
+252
View File
@@ -0,0 +1,252 @@
import {
addSeconds,
addDays,
differenceInCalendarDays,
} from 'date-fns';
import {
toZonedTime,
fromZonedTime,
} from 'date-fns-tz';
import { formatTime } from './util/formatTime';
import { parse as parseDF, isValid } from 'date-fns';
export const SEOUL_TZ = 'Asia/Seoul';
// 문자열이 타임존/오프셋을 포함하는지 단순 판별 (ISO 형태 위주)
const TZ_OFFSET_RE = /([zZ]|[+-]\d{2}:?\d{2})$/;
function tryParseWithFormats(s: string): Date | null {
const fmts = [
"yyyy-MM-dd'T'HH:mm:ss", // 2025-10-31T09:30:00
'yyyy-MM-dd HH:mm:ss', // 2025-10-31 09:30:00
"yyyy-MM-dd'T'HH:mm", // 2025-10-31T09:30
'yyyy-MM-dd HH:mm', // 2025-10-31 09:30
'yyyy-MM-dd', // 2025-10-31
];
for (const fmt of fmts) {
const d = parseDF(s, fmt, new Date(0));
if (isValid(d)) return d;
}
return null;
}
/** /Date/number UTC Date
* - / Asia/Seoul
* - number는 epoch ms로 (epoch s면 *1000 )
*/
export function toUTC(input: string | Date | number): Date {
if (input instanceof Date) {
return new Date(input.getTime()); // 이미 절대시간
}
if (typeof input === 'number') {
return new Date(input); // epoch ms
}
const s = input.trim();
// 오프셋/타임존이 명시된 ISO라면 기본 파서로 (절대시간 유지)
if (TZ_OFFSET_RE.test(s)) {
const d = new Date(s);
if (isValid(d)) return d;
}
// 오프셋이 없는 문자열 → 서울 벽시계로 파싱해서 UTC로 변환
const parsedLocal = tryParseWithFormats(s) ?? new Date(s);
if (!isValid(parsedLocal)) {
throw new Error(`Unparsable date string: "${input}"`);
}
// parsedLocal는 "그 문자열 그대로의 Y/M/D h:m:s"를 들고 있음(타임존 無)
// 이를 Asia/Seoul 로컬로 간주해 UTC로 변환
const utc = fromZonedTime(parsedLocal, SEOUL_TZ);
return utc;
}
export class VarTurn60 {
static readonly TZ = 'Asia/Seoul';
static readonly MonthAdjustOffset = 2;
// [turnIdx, minOffset, turnTermMinutes]
static readonly min30ToTurn: Array<[number, number, number]> = [
[0, 0, 120],
[0, 30, 120],
[0, 60, 120],
[0, 90, 120],
[1, 0, 120],
[1, 30, 120],
[1, 60, 120],
[1, 90, 120],
[2, 0, 120],
[2, 30, 120],
[2, 60, 120],
[2, 90, 120],
[3, 0, 60],
[3, 30, 60],
[4, 0, 60],
[4, 30, 60],
[5, 0, 60],
[5, 30, 60],
[6, 0, 60],
[6, 30, 60],
[7, 0, 60],
[7, 30, 60],
[8, 0, 60],
[8, 30, 60],
[9, 0, 60],
[9, 30, 60],
[10, 0, 60],
[10, 30, 60],
[11, 0, 60],
[11, 30, 60],
[12, 0, 60],
[12, 30, 60],
[13, 0, 60],
[13, 30, 60],
[14, 0, 60],
[14, 30, 60],
[15, 0, 60],
[15, 30, 60],
[16, 0, 30],
[17, 0, 30],
[18, 0, 30],
[19, 0, 30],
[20, 0, 30],
[21, 0, 30],
[22, 0, 60],
[22, 30, 60],
[23, 0, 60],
[23, 30, 60],
];
// [hour, minOffset, turnTermMinutes] for turnIdx 0..23
static readonly turnToHM: Array<[number, number, number]> = [
[0, 0, 120],
[2, 0, 120],
[4, 0, 120],
[6, 0, 60],
[7, 0, 60],
[8, 0, 60],
[9, 0, 60],
[10, 0, 60],
[11, 0, 60],
[12, 0, 60],
[13, 0, 60],
[14, 0, 60],
[15, 0, 60],
[16, 0, 60],
[17, 0, 60],
[18, 0, 60],
[19, 0, 30],
[19, 30, 30],
[20, 0, 30],
[20, 30, 30],
[21, 0, 30],
[21, 30, 30],
[22, 0, 60],
[23, 0, 60],
];
constructor(
public readonly baseDayUTC: Date, // UTC 기준 하루의 00:00 (Seoul 기준)
public readonly turnIdx: number, // 0..23
public readonly secOffset: number // seconds within the turn
) { }
// ----- Utilities -----
/** date → 서울 자정 (UTC 기준 Date) */
private static toSeoulMidnightUTC(dateUTC: Date): Date {
const zoned = toZonedTime(dateUTC, this.TZ);
const midnightInSeoul = new Date(
zoned.getFullYear(), zoned.getMonth(), zoned.getDate(), 0, 0, 0, 0
);
return fromZonedTime(midnightInSeoul, this.TZ);
}
private static format(dateUTC: Date, withFraction = true): string {
const zoned = toZonedTime(dateUTC, this.TZ);
return formatTime(zoned, withFraction);
}
// ----- Core Logic -----
static fromDatetime(input: string | Date | number): VarTurn60 {
const utc = toUTC(input);
const baseDayUTC = this.toSeoulMidnightUTC(utc);
const zoned = toZonedTime(utc, this.TZ);
const totalSec = zoned.getHours() * 3600 + zoned.getMinutes() * 60 + zoned.getSeconds() + zoned.getMilliseconds() / 1000;
const min30 = Math.floor(totalSec / 1800);
if (min30 < 0 || min30 >= 48) throw new RangeError('half-hour index out of range');
const [turnIdx, minOffset] = this.min30ToTurn[min30];
const secOffset = totalSec - 1800 * min30 + minOffset * 60;
return new VarTurn60(baseDayUTC, turnIdx, secOffset);
}
static calcTurnDiff(a: string | Date | number, b: string | Date | number): number {
const aObj = this.fromDatetime(a);
const bObj = this.fromDatetime(b);
const dayDiff = differenceInCalendarDays(bObj.baseDayUTC, aObj.baseDayUTC);
return dayDiff * 24 + (bObj.turnIdx - aObj.turnIdx);
}
cutTurn(withFraction = true): [string, number] {
const [hour, minOffset, turnTerm] = VarTurn60.turnToHM[this.turnIdx];
const sec = (hour * 60 + minOffset) * 60;
const date = addSeconds(this.baseDayUTC, sec);
return [VarTurn60.format(date, withFraction), turnTerm];
}
toDate(): Date {
const [hour, minOffset] = VarTurn60.turnToHM[this.turnIdx];
const sec = (hour * 60 + minOffset) * 60 + this.secOffset;
const date = toZonedTime(addSeconds(this.baseDayUTC, sec), SEOUL_TZ);
return date;
}
toDateStr(withFraction = true): string {
const [hour, minOffset] = VarTurn60.turnToHM[this.turnIdx];
const sec = (hour * 60 + minOffset) * 60 + this.secOffset;
const date = addSeconds(this.baseDayUTC, sec);
return VarTurn60.format(date, withFraction);
}
cutDay(withFraction = true): [string, boolean, number] {
const newMonth = ((this.turnIdx + VarTurn60.MonthAdjustOffset) % 12) + 1;
const moved = this.addTurn(-(newMonth - 1));
const [dateStr] = moved.cutTurn(withFraction);
const yearPulled = newMonth > 3;
return [dateStr, yearPulled, newMonth];
}
addTurn(moreTurn: number): VarTurn60 {
let dayDiff = Math.trunc(moreTurn / 24);
moreTurn %= 24;
let nextTurnIdx = this.turnIdx + moreTurn;
if (nextTurnIdx < 0) {
dayDiff -= 1;
nextTurnIdx += 24;
} else if (nextTurnIdx >= 24) {
dayDiff += 1;
nextTurnIdx -= 24;
}
const [, , oldTurnTerm] = VarTurn60.turnToHM[this.turnIdx];
const [, , nextTurnTerm] = VarTurn60.turnToHM[nextTurnIdx];
let nextSecOffset = this.secOffset;
if (oldTurnTerm !== nextTurnTerm) {
nextSecOffset = (nextSecOffset * nextTurnTerm) / oldTurnTerm;
const cap = nextTurnTerm * 60 - 1;
if (nextSecOffset < 0) nextSecOffset = 0;
if (nextSecOffset > cap) nextSecOffset = cap;
}
const nextBaseDayUTC =
dayDiff === 0 ? this.baseDayUTC : addDays(this.baseDayUTC, dayDiff);
return new VarTurn60(nextBaseDayUTC, nextTurnIdx, nextSecOffset);
}
}
+26 -12
View File
@@ -52,7 +52,8 @@
"css-color-names": "^1.0.1", "css-color-names": "^1.0.1",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"cssnano": "^5.1.15", "cssnano": "^5.1.15",
"date-fns": "^2.29.3", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"denque": "^2.1.0", "denque": "^2.1.0",
"detect-it": "^4.0.1", "detect-it": "^4.0.1",
"downloadjs": "^1.4.7", "downloadjs": "^1.4.7",
@@ -3535,15 +3536,22 @@
"peer": true "peer": true
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.29.3", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"engines": { "license": "MIT",
"node": ">=0.11"
},
"funding": { "funding": {
"type": "opencollective", "type": "github",
"url": "https://opencollective.com/date-fns" "url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
} }
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -12089,9 +12097,15 @@
"peer": true "peer": true
}, },
"date-fns": { "date-fns": {
"version": "2.29.3", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"requires": {}
}, },
"debug": { "debug": {
"version": "4.3.4", "version": "4.3.4",
+2 -1
View File
@@ -65,7 +65,8 @@
"css-color-names": "^1.0.1", "css-color-names": "^1.0.1",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"cssnano": "^5.1.15", "cssnano": "^5.1.15",
"date-fns": "^2.29.3", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"denque": "^2.1.0", "denque": "^2.1.0",
"detect-it": "^4.0.1", "detect-it": "^4.0.1",
"downloadjs": "^1.4.7", "downloadjs": "^1.4.7",
+7 -72
View File
@@ -36,57 +36,6 @@ type ProcResult = {
lastExecuted?: string; lastExecuted?: string;
}; };
class CookieJar {
#cookies = new Map<string, string>();
getCookieHeader(): string | undefined {
if (this.#cookies.size === 0) {
return undefined;
}
return [...this.#cookies.entries()].map(([key, value]) => `${key}=${value}`).join("; ");
}
ingestSetCookie(setCookieHeaders: string[]): void {
for (const setCookie of setCookieHeaders) {
const cookiePart = setCookie.split(";", 1)[0]?.trim();
if (!cookiePart) {
continue;
}
const eqPos = cookiePart.indexOf("=");
if (eqPos <= 0) {
continue;
}
const key = cookiePart.slice(0, eqPos).trim();
const value = cookiePart.slice(eqPos + 1).trim();
if (!key) {
continue;
}
if (value === "") {
this.#cookies.delete(key);
continue;
}
this.#cookies.set(key, value);
}
}
}
function getSetCookieHeaders(headers: Headers): string[] {
const headerObj = headers as unknown as { getSetCookie?: () => string[] };
if (typeof headerObj.getSetCookie === "function") {
return headerObj.getSetCookie();
}
const fallback = headers.get("set-cookie");
if (!fallback) {
return [];
}
return [fallback];
}
function nowStr() { function nowStr() {
const d = new Date(); const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0"); const pad = (n: number) => n.toString().padStart(2, "0");
@@ -182,19 +131,13 @@ async function scanOnce(): Promise<ServerEntry[]> {
} }
// ============ 공개 접근 체크 ============ // ============ 공개 접근 체크 ============
async function isPublicReachable(signal?: AbortSignal, cookieJar?: CookieJar): Promise<boolean> { async function isPublicReachable(signal?: AbortSignal): Promise<boolean> {
if (!REQUIRE_PUBLIC) return true; if (!REQUIRE_PUBLIC) return true;
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), Math.min(5_000, FETCH_TIMEOUT_MS)); const timer = setTimeout(() => ctrl.abort(), Math.min(5_000, FETCH_TIMEOUT_MS));
const composite = anySignal([ctrl.signal, signal].filter(Boolean) as AbortSignal[]); const composite = anySignal([ctrl.signal, signal].filter(Boolean) as AbortSignal[]);
try { try {
const cookieHeader = cookieJar?.getCookieHeader(); const res = await fetch(webBase, { method: "GET", signal: composite });
const res = await fetch(webBase, {
method: "GET",
signal: composite,
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
cookieJar?.ingestSetCookie(getSetCookieHeaders(res.headers));
if (!res.ok) { log(`[PUBLIC] 응답 ${res.status} → 비공개로 간주`); return false; } if (!res.ok) { log(`[PUBLIC] 응답 ${res.status} → 비공개로 간주`); return false; }
return true; return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -207,20 +150,14 @@ async function isPublicReachable(signal?: AbortSignal, cookieJar?: CookieJar): P
} }
// ============ HTTP ============ // ============ HTTP ============
async function httpGetJson<T extends Json>(url: string, outerSignal?: AbortSignal, cookieJar?: CookieJar): Promise<T | null> { async function httpGetJson<T extends Json>(url: string, outerSignal?: AbortSignal): Promise<T | null> {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
const signal = anySignal([ctrl.signal, outerSignal].filter(Boolean) as AbortSignal[]); const signal = anySignal([ctrl.signal, outerSignal].filter(Boolean) as AbortSignal[]);
const t0 = performance.now(); const t0 = performance.now();
try { try {
const cookieHeader = cookieJar?.getCookieHeader(); const res = await fetch(url, { signal, cache: "no-store" });
const res = await fetch(url, {
signal,
cache: "no-store",
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
cookieJar?.ingestSetCookie(getSetCookieHeaders(res.headers));
const dt = Math.round(performance.now() - t0); const dt = Math.round(performance.now() - t0);
if (!res.ok) { log("HTTPError:", res.status, url); return null; } if (!res.ok) { log("HTTPError:", res.status, url); return null; }
try { try {
@@ -258,7 +195,6 @@ class ServerRunner {
#stopCtrl = new AbortController(); #stopCtrl = new AbortController();
#stopped = false; #stopped = false;
#lastAutoResetAt = 0; #lastAutoResetAt = 0;
#cookieJar = new CookieJar();
constructor(entry: ServerEntry) { constructor(entry: ServerEntry) {
this.#entry = entry; this.#entry = entry;
@@ -286,7 +222,7 @@ class ServerRunner {
while (!this.#stopCtrl.signal.aborted) { while (!this.#stopCtrl.signal.aborted) {
try { try {
// 공개 접근 체크 // 공개 접근 체크
const pub = await isPublicReachable(this.#stopCtrl.signal, publicCookieJar); const pub = await isPublicReachable(this.#stopCtrl.signal);
if (!pub) { await delay(15_000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } if (!pub) { await delay(15_000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; }
const hidden = await this.#entry.isHidden(); const hidden = await this.#entry.isHidden();
@@ -300,7 +236,7 @@ class ServerRunner {
if (this.#stopCtrl.signal.aborted) break; if (this.#stopCtrl.signal.aborted) break;
} }
log(`[${this.name()}] 닫힘 - (정렬) autoreset 호출 @ mm:00${AUTO_RESET_JITTER_MS ? `+${AUTO_RESET_JITTER_MS}ms` : ""}`); log(`[${this.name()}] 닫힘 - (정렬) autoreset 호출 @ mm:00${AUTO_RESET_JITTER_MS ? `+${AUTO_RESET_JITTER_MS}ms` : ""}`);
await httpGetJson<Json>(this.#entry.autoresetUrl, this.#stopCtrl.signal, this.#cookieJar); await httpGetJson<Json>(this.#entry.autoresetUrl, this.#stopCtrl.signal);
// 같은 분에 중복 호출 방지: 0초 직후 잠깐 쉬고 다음 루프는 자연스럽게 다음 분으로 정렬됨 // 같은 분에 중복 호출 방지: 0초 직후 잠깐 쉬고 다음 루프는 자연스럽게 다음 분으로 정렬됨
await delay(200, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); await delay(200, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ });
continue; continue;
@@ -308,7 +244,7 @@ class ServerRunner {
// 열림: proc // 열림: proc
const data = await httpGetJson<ProcResult>(this.#entry.procUrl, this.#stopCtrl.signal, this.#cookieJar); const data = await httpGetJson<ProcResult>(this.#entry.procUrl, this.#stopCtrl.signal);
if (!data) { if (!data) {
await delay(IF_LOCKED_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); await delay(IF_LOCKED_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ });
continue; continue;
@@ -412,7 +348,6 @@ class DaemonManager {
// ============ 메인 ============ // ============ 메인 ============
const manager = new DaemonManager(); const manager = new DaemonManager();
const publicCookieJar = new CookieJar();
async function main() { async function main() {
// 신호 처리: SIGINT/SIGTERM → graceful shutdown // 신호 처리: SIGINT/SIGTERM → graceful shutdown
-2
View File
@@ -13,8 +13,6 @@ use sammo\TimeUtil;
use sammo\Util; use sammo\Util;
use sammo\Validator; use sammo\Validator;
use function sammo\logError;
class LoginByToken extends LoginByID class LoginByToken extends LoginByID
{ {
static array $sensitiveArgs = ['hashedToken']; static array $sensitiveArgs = ['hashedToken'];
+1 -1
View File
@@ -73,7 +73,7 @@ class APIHelper
$logPath = "{$rootPath}/d_log/{$realYearMonth}_api_log.db"; $logPath = "{$rootPath}/d_log/{$realYearMonth}_api_log.db";
} }
$logDB = FileDB::db($logPath, __DIR__ . '/../../f_install/sql/api_log.sql'); $logDB = FileDB::db($logPath, __DIR__ . '/../../f_install/sql/api_log.sql');
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'local'; $ip = $_SERVER['REMOTE_ADDR'] ?? 'local';
$date = date('Y-m-d H:i:s'); $date = date('Y-m-d H:i:s');
//NOTE: array_merge([], {})의 상황이 가능함. //NOTE: array_merge([], {})의 상황이 가능함.
+1 -1
View File
@@ -272,7 +272,7 @@ class Session
return $this; return $this;
} }
$turnterm = $gameStor->turnterm; $turnterm = abs($gameStor->turnterm);
$isUnited = $gameStor->isunited != 0; $isUnited = $gameStor->isunited != 0;
$generalID = $general['no']; $generalID = $general['no'];
+19 -1
View File
@@ -947,13 +947,31 @@ class Util
return $flattened; return $flattened;
} }
/**
* Returns the IP address of the client.
*
* @param boolean $trust_proxy_headers Whether or not to trust the
* proxy headers HTTP_CLIENT_IP
* and HTTP_X_FORWARDED_FOR. ONLY
* use if your server is behind a
* proxy that sets these values
* @return string
*/
public static function get_client_ip($trust_proxy_headers = false) public static function get_client_ip($trust_proxy_headers = false)
{ {
if (!$trust_proxy_headers) { if (!$trust_proxy_headers) {
return $_SERVER['REMOTE_ADDR']; return $_SERVER['REMOTE_ADDR'];
} }
return $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'local'; if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
} }
/** /**