$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', '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 $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', '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, ]; } 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', 'ProcessSemiAnnual', 'ProcessWarIncome', 'CreateAdminNPC', 'CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'AutoDeleteInvader', 'InvaderEnding', 'ChangeCity', 'ProvideNPCTroopLeader', '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); } } $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();