test: add deterministic battle trace exporter
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -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("<M><b>【패퇴】</b></><D><b>{$defender->getNationVar('name')}</b></>{$josaYi} 병량 부족으로 <G><b>{$defender->getName()}</b></>{$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();
|
||||
|
||||
Reference in New Issue
Block a user