feat: complete campaign flow and optimize battle assets
This commit is contained in:
131
scripts/verify-battle-forecast.mjs
Normal file
131
scripts/verify-battle-forecast.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user