Files
heros_web/scripts/verify-battle-forecast.mjs

132 lines
5.9 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, hmr: false },
appType: 'custom'
});
try {
const {
buildCombatExchangeProjection,
compareMoveIntentRisk,
summarizeIntentRisk
} = await server.ssrLoadModule('/src/game/data/battleForecast.ts');
const before = [
{ enemyId: 'e1', kind: 'attack', targetId: 'ally', damage: 20, hitRate: 80 },
{ enemyId: 'e2', kind: 'usable', targetId: 'ally', damage: 10, hitRate: 100 },
{ enemyId: 'e3', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 }
];
const afterSaferMove = [
{ enemyId: 'e1', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 },
{ enemyId: 'e2', kind: 'attack', targetId: 'other', damage: 10, hitRate: 100 },
{ enemyId: 'e3', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 }
];
assert.deepEqual(
summarizeIntentRisk(before, 'ally', 25),
{
immediateCount: 2,
approachCount: 1,
expectedDamage: 26,
rawDamage: 30,
criticalAdjustedMaxDamage: 30,
lethal: true,
immediateEnemyIds: ['e1', 'e2']
},
'Intent risk must distinguish immediate damage from approaching enemies.'
);
const safer = compareMoveIntentRisk(before, afterSaferMove, 'ally', 25);
assert.equal(safer.tone, 'safer', 'Breaking an immediate attack line must be marked safer.');
assert.equal(safer.immediateDelta, -2, 'Move preview must report removed immediate attackers.');
assert.equal(safer.expectedDamageDelta, -26, 'Move preview must report the selected unit damage delta.');
assert.equal(safer.retargetedAwayCount, 1, 'Move preview must report enemies redirected away from the mover.');
assert.equal(safer.neutralizedImmediateCount, 1, 'Move preview must report an immediate attack converted to approach.');
const afterRiskierMove = [
...before,
{ enemyId: 'e4', kind: 'attack', targetId: 'ally', damage: 12, hitRate: 75 }
];
const riskier = compareMoveIntentRisk(before, afterRiskierMove, 'ally', 25);
assert.equal(riskier.tone, 'riskier', 'A new incoming attack must be marked riskier.');
assert.equal(riskier.immediateDelta, 1);
assert.equal(riskier.expectedDamageDelta, 9);
assert.equal(riskier.retargetedToCount, 1);
const criticalIntent = summarizeIntentRisk(
[{ enemyId: 'critical-enemy', kind: 'attack', targetId: 'ally', damage: 11, hitRate: 100, criticalRate: 20 }],
'ally',
15
);
assert.equal(criticalIntent.expectedDamage, 12, 'Intent expected damage must include critical probability.');
assert.equal(criticalIntent.criticalAdjustedMaxDamage, 17, 'Intent maximum damage must match the real 1.5x rounded critical rule.');
assert.equal(criticalIntent.lethal, true, 'A possible critical defeat must be exposed as lethal risk.');
const exchange = buildCombatExchangeProjection({
attackerHp: 34,
defenderHp: 30,
attackDamage: 18,
attackHitRate: 90,
attackCriticalRate: 10,
counter: { damage: 12, hitRate: 80, criticalRate: 5 }
});
assert.equal(exchange.defenderHpAfterHit, 12);
assert.equal(exchange.attackerHpAfterCounterHit, 22);
assert.equal(exchange.counterCondition, 'if-defender-survives');
assert.equal(exchange.attackLethalOnHit, false);
assert.equal(exchange.counterLethalOnHit, false);
assert.ok(exchange.attackExpectedDamage > exchange.counterExpectedDamage);
const lethalExchange = buildCombatExchangeProjection({
attackerHp: 10,
defenderHp: 16,
attackDamage: 18,
attackHitRate: 90,
attackCriticalRate: 10,
counter: { damage: 12, hitRate: 80, criticalRate: 5 }
});
assert.equal(lethalExchange.defenderHpAfterHit, 0);
assert.equal(lethalExchange.counterCondition, 'on-primary-miss', 'A lethal hit must explain that only a miss permits counterattack.');
assert.equal(lethalExchange.counterLethalOnHit, true, 'Counter forecast must expose attacker defeat risk.');
const criticalCounterExchange = buildCombatExchangeProjection({
attackerHp: 15,
defenderHp: 30,
attackDamage: 8,
attackHitRate: 100,
attackCriticalRate: 0,
counter: { damage: 11, hitRate: 90, criticalRate: 20 }
});
assert.equal(criticalCounterExchange.attackerHpAfterCounterHit, 4);
assert.equal(criticalCounterExchange.attackerHpAfterCounterCriticalHit, 0);
assert.equal(criticalCounterExchange.counterLethalOnHit, false);
assert.equal(criticalCounterExchange.counterLethalOnCritical, true, 'Counter forecast must expose critical-only defeat risk.');
const noCounter = buildCombatExchangeProjection({
attackerHp: 20,
defenderHp: 20,
attackDamage: 8,
attackHitRate: 85,
attackCriticalRate: 5
});
assert.equal(noCounter.counterCondition, 'none');
assert.equal(noCounter.attackerHpAfterCounterHit, 20);
assert.equal(noCounter.counterExpectedDamage, 0);
const battleSceneSource = await readFile(new URL('../src/game/scenes/BattleScene.ts', import.meta.url), 'utf8');
assert.match(battleSceneSource, /moveIntentRiskForTile\(unit, tile\.x, tile\.y\)/, 'Move hover must invoke intent comparison.');
assert.match(battleSceneSource, /handleBattleKeyboardNavigation\(event\)/, 'Keyboard navigation must remain wired.');
assert.match(battleSceneSource, /combatPreview\(preview\.defender, preview\.attacker, 'attack', undefined, true\)/, 'Exchange forecast must reuse counter combat rules.');
assert.match(battleSceneSource, /combatExchangeCounterMetric\(exchange\)/, 'Target cards must show numeric counter data.');
assert.match(battleSceneSource, /criticalRate: plan\.preview\?\.criticalRate \?\? 0/, 'Move risk snapshots must retain enemy critical rates.');
assert.match(battleSceneSource, /counterLethalOnCritical/, 'Counter UI must surface critical-only defeat risk.');
console.log('Verified critical-aware move-intent deltas, exchange projections, keyboard wiring, and counter-rule reuse.');
} finally {
await server.close();
}