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(); }