From 8616a094e5e108b1a1ecbb371d84dddce9b94cea Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 05:56:45 +0000 Subject: [PATCH] test: add deterministic battle trace exporter --- hwe/compare/battle_trace.php | 213 +++++++++++++++++++++++++++++++++++ hwe/process_war.php | 100 ++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 hwe/compare/battle_trace.php diff --git a/hwe/compare/battle_trace.php b/hwe/compare/battle_trace.php new file mode 100644 index 00000000..3d71c8ae --- /dev/null +++ b/hwe/compare/battle_trace.php @@ -0,0 +1,213 @@ +> */ + 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 \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); +} diff --git a/hwe/process_war.php b/hwe/process_war.php index c7c84701..d1ba138e 100644 --- a/hwe/process_war.php +++ b/hwe/process_war.php @@ -230,6 +230,7 @@ function processWar_NG( WarUnitGeneral $attacker, callable $getNextDefender, WarUnitCity $city, + ?callable $trace = null, ): bool { $templates = new \League\Plates\Engine(__DIR__ . '/templates'); @@ -245,6 +246,26 @@ function processWar_NG( /** @var WarUnit */ $defender = ($getNextDefender)(null, true); $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(), '로'); $josaYi = JosaUtil::pick($attacker->getName(), '이'); @@ -277,6 +298,7 @@ function processWar_NG( $logger->pushGlobalHistoryLog("【패퇴】{$defender->getNationVar('name')}{$josaYi} 병량 부족으로 {$defender->getName()}{$josaUl} 뺏기고 말았습니다."); $conquerCity = true; + $emitTrace('supply_retreat', $defender); break; } } @@ -332,18 +354,34 @@ function processWar_NG( $initCaller->merge($defender->getGeneral()->getBattleInitSkillTriggerList($defender)); $initCaller->fire($attacker->rng, [], [$attacker, $defender]); + $emitTrace('opponent_initialized', $defender); } $attacker->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->merge($defender->getGeneral()->getBattlePhaseSkillTriggerList($defender)); $battleCaller->fire($attacker->rng, [], [$attacker, $defender]); + $emitTrace('phase_triggered', $defender); $deadDefender = $attacker->calcDamage(); $deadAttacker = $defender->calcDamage(); + $rawDeadAttacker = $deadAttacker; + $rawDeadDefender = $deadDefender; $attackerHP = $attacker->getHP(); $defenderHP = $defender->getHP(); @@ -371,6 +409,14 @@ function processWar_NG( $attacker->increaseKilled($deadDefender); $defender->increaseKilled($deadAttacker); + $emitTrace('phase_damage', $defender, [ + 'rawDeadAttacker' => $rawDeadAttacker, + 'rawDeadDefender' => $rawDeadDefender, + 'deadAttacker' => $deadAttacker, + 'deadDefender' => $deadDefender, + 'attackerHpBefore' => $attackerHP, + 'defenderHpBefore' => $defenderHP, + ]); if($defender->getPhase() < 0){ $phaseNickname = '先'; @@ -394,8 +440,10 @@ function processWar_NG( $attacker->addPhase(); $defender->addPhase(); + $emitTrace('phase_end', $defender); if (!$attacker->continueWar($noRice)) { + $emitTrace('attacker_stopped', $defender, ['noRice' => (bool)$noRice]); $logWritten = true; $attacker->logBattleResult(); @@ -420,6 +468,7 @@ function processWar_NG( } if (!$defender->continueWar($noRice)) { + $emitTrace('defender_stopped', $defender, ['noRice' => (bool)$noRice]); $logWritten = true; $attacker->logBattleResult(); @@ -462,6 +511,7 @@ function processWar_NG( $defender->finishBattle(); $defender = ($getNextDefender)($defender, true); + $emitTrace('opponent_switched', $defender); if ($defender !== null && !($defender instanceof WarUnitGeneral)) { throw new \RuntimeException('다음 수비자를 받아오는데 실패'); @@ -497,10 +547,60 @@ function processWar_NG( } ($getNextDefender)($defender, false); + $emitTrace('battle_end', $defender, ['conquered' => $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) { $db = DB::db();