From 23f734b35c61a7451e722c5736f72c8c4e6d6674 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 27 Jun 2026 05:56:45 +0900 Subject: [PATCH] QA representative battles and tune balance --- package.json | 3 +- scripts/qa-representative-battles.mjs | 624 ++++++++++++++++++++++++++ src/game/data/scenario.ts | 54 +-- src/game/scenes/BattleScene.ts | 80 +++- 4 files changed, 723 insertions(+), 38 deletions(-) create mode 100644 scripts/qa-representative-battles.mjs diff --git a/package.json b/package.json index 9ff2cf8..0a54499 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "generate:audio": "node scripts/generate-bgm.mjs", "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", - "verify:flow": "node scripts/verify-flow.mjs" + "verify:flow": "node scripts/verify-flow.mjs", + "qa:representative": "node scripts/qa-representative-battles.mjs" }, "dependencies": { "phaser": "^3.90.0" diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs new file mode 100644 index 0000000..e24914e --- /dev/null +++ b/scripts/qa-representative-battles.mjs @@ -0,0 +1,624 @@ +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; + +const targetUrl = process.env.QA_URL ?? 'http://localhost:5173/'; +const headless = process.env.QA_HEADLESS !== '0'; +const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); +const pnpmCommand = process.env.PNPM_CMD ?? 'pnpm'; + +const allRepresentativeBattles = [ + { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, + { + no: 7, + id: 'seventh-battle-xuzhou-rescue', + selected: ['liu-bei', 'guan-yu', 'zhang-fei'], + targets: ['xuzhou-leader-xiahou-dun'], + protected: ['liu-bei'] + }, + { + no: 18, + id: 'eighteenth-battle-bowang-ambush', + selected: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian'], + targets: ['bowang-leader-xiahou-dun'], + protected: ['liu-bei'] + }, + { + no: 27, + id: 'twenty-seventh-battle-yizhou-relief-road', + selected: ['liu-bei', 'zhuge-liang', 'huang-zhong', 'wei-yan', 'zhao-yun', 'mi-zhu'], + targets: ['yizhou-leader-yang-huai'], + protected: ['liu-bei', 'zhuge-liang'] + }, + { + no: 37, + id: 'thirty-seventh-battle-hanzhong-decisive', + selected: ['liu-bei', 'huang-zhong', 'fa-zheng', 'wang-ping', 'ma-chao', 'ma-dai', 'zhuge-liang'], + targets: ['hanzhong-leader-cao-cao'], + protected: ['liu-bei', 'wang-ping'] + }, + { + no: 45, + id: 'forty-fifth-battle-yiling-vanguard', + selected: ['liu-bei', 'zhang-fei', 'zhao-yun', 'ma-chao', 'zhuge-liang', 'huang-quan', 'ma-liang'], + targets: ['yiling-leader-lu-xun'], + protected: ['liu-bei'] + }, + { + no: 55, + id: 'fifty-fifth-battle-northern-qishan-road', + selected: ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'], + targets: ['northern-first-leader-cao-zhen'], + protected: ['zhuge-liang'] + }, + { + no: 58, + id: 'fifty-eighth-battle-qishan-retreat', + selected: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-quan', 'ma-liang'], + targets: ['northern-fourth-leader-sima-yi', 'northern-fourth-officer-zhang-he'], + protected: ['zhuge-liang'] + }, + { + no: 66, + id: 'sixty-sixth-battle-wuzhang-final', + selected: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'], + targets: ['northern-twelfth-leader-sima-yi', 'northern-twelfth-officer-zhang-he', 'northern-twelfth-officer-guo-huai'], + protected: ['zhuge-liang'] + } +]; + +const requestedBattles = new Set( + (process.env.QA_BATTLES ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +); +const representativeBattles = + requestedBattles.size > 0 + ? allRepresentativeBattles.filter((battle) => requestedBattles.has(String(battle.no)) || requestedBattles.has(battle.id)) + : allRepresentativeBattles; + +let serverProcess; + +try { + serverProcess = await ensureLocalServer(targetUrl); + const browser = await chromium.launch({ headless }); + + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const results = []; + + for (const battle of representativeBattles) { + await setupBattle(page, battle); + results.push(await playBattleWithoutDebugVictory(page, battle)); + } + + const failed = results.filter((result) => result.outcome !== 'victory'); + console.table( + results.map((result) => ({ + no: result.no, + outcome: result.outcome, + turn: result.finalTurn, + alliesAlive: result.alliesAlive, + enemiesAlive: result.enemiesAlive, + itemsUsed: result.itemsUsed, + supports: result.supports, + objectives: `${result.objectivesAchieved}/${result.objectiveCount}` + })) + ); + + if (failed.length > 0) { + console.dir( + failed.map((result) => ({ + no: result.no, + id: result.id, + outcome: result.outcome, + protected: result.protectedStatus, + targets: result.targetStatus, + allies: result.allyStatus, + enemies: result.enemyStatus, + log: result.battleLogTail + })), + { depth: null } + ); + throw new Error(`Representative battle QA failed: ${failed.map((result) => `${result.no}:${result.id}`).join(', ')}`); + } + + console.log(`Representative battle QA passed without debug victory for ${results.length} battles at ${targetUrl}`); + } finally { + await browser.close(); + } +} finally { + if (serverProcess) { + serverProcess.kill(); + } +} + +async function setupBattle(page, battle) { + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); + await page.evaluate((selected) => { + const now = new Date().toISOString(); + const state = { + version: 1, + updatedAt: now, + step: 'first-battle', + activeSaveSlot: 1, + gold: 999999, + roster: [], + bonds: [], + inventory: {}, + selectedSortieUnitIds: selected, + reserveTrainingFocus: 'balanced', + completedCampDialogues: [], + completedCampVisits: [], + battleHistory: {} + }; + window.localStorage.clear(); + window.localStorage.setItem('heros-web:campaign-state', JSON.stringify(state)); + window.localStorage.setItem('heros-web:campaign-state:slot-1', JSON.stringify(state)); + }, battle.selected); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); + await page.evaluate((battleId) => { + const game = window.__HEROS_GAME__; + for (const scene of game.scene.getScenes(true)) { + game.scene.stop(scene.scene.key); + } + game.scene.start('BattleScene', { battleId }); + }, battle.id); + await page.waitForFunction( + (battleId) => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === battleId && state?.phase === 'idle' && state?.mapBackgroundReady === true; + }, + battle.id, + { timeout: 90000 } + ); +} + +async function playBattleWithoutDebugVictory(page, battle) { + return page.evaluate( + async ({ no, id, maxRounds, targets, protected: protectedUnitIds }) => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scene = window.__HEROS_GAME__.scene.getScene('BattleScene'); + const primaryTargetId = targets[0]; + const targetIds = new Set(targets); + const protectedIds = new Set(protectedUnitIds); + const metrics = { + no, + id, + outcome: null, + finalTurn: 0, + alliesAlive: 0, + enemiesAlive: 0, + itemsUsed: 0, + supports: 0, + allyAttacks: 0, + enemyAttacks: 0, + objectivesAchieved: 0, + objectiveCount: 0, + protectedStatus: [], + targetStatus: [], + allyStatus: [], + enemyStatus: [], + battleLogTail: [] + }; + + const state = () => window.__HEROS_DEBUG__.battle(); + const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const unitRef = (unitId) => scene.debugUnitById(unitId); + const liveUnits = (faction) => + state() + .units.filter((unit) => unit.faction === faction && unit.hp > 0) + .map((unit) => unitRef(unit.id)) + .filter(Boolean); + const actedUnitIds = () => new Set(state().actedUnitIds ?? []); + const syncUnit = (unit) => { + scene.positionUnitView?.(unit); + scene.updateMiniMap?.(); + }; + const resolveOutcome = () => scene.resolveBattleOutcomeIfNeeded?.(); + + function withUnitAt(unit, tile, callback) { + const original = { x: unit.x, y: unit.y }; + unit.x = tile.x; + unit.y = tile.y; + try { + return callback(); + } finally { + unit.x = original.x; + unit.y = original.y; + } + } + + function priorityForTarget(enemy) { + if (enemy.id === primaryTargetId) { + return 5200; + } + if (targetIds.has(enemy.id)) { + return 3400; + } + if (enemy.id.includes('leader')) { + return 1200; + } + if (enemy.id.includes('officer')) { + return 720; + } + return 0; + } + + function focusTarget(unit) { + const aliveTargets = targets.map((targetId) => unitRef(targetId)).filter((target) => target && target.hp > 0); + if (aliveTargets.length > 0) { + return aliveTargets.sort((a, b) => distance(unit, a) - distance(unit, b))[0]; + } + return liveUnits('enemy').sort((a, b) => priorityForTarget(b) - priorityForTarget(a) || distance(unit, a) - distance(unit, b))[0]; + } + + function attackChoice(unit) { + return liveUnits('enemy') + .filter((enemy) => scene.canAttack(unit, enemy)) + .map((enemy) => { + const preview = scene.combatPreview(unit, enemy, 'attack'); + const kill = preview.damage >= enemy.hp; + const priority = priorityForTarget(enemy); + return { + target: enemy, + preview, + score: priority + preview.damage * 10 + (kill ? 760 : 0) - enemy.hp - distance(unit, enemy) * 4 + }; + }) + .sort((a, b) => b.score - a.score)[0]; + } + + function incomingThreat(unit, tile) { + return withUnitAt(unit, tile, () => { + const threats = []; + const nearbyEnemies = liveUnits('enemy') + .sort((a, b) => distance(a, tile) - distance(b, tile)) + .slice(0, 10); + for (const enemy of nearbyEnemies) { + const original = { x: enemy.x, y: enemy.y }; + const behavior = scene.enemyBehavior(enemy); + const target = scene.chooseEnemyTarget(enemy, behavior); + if (target) { + if (!scene.canAttack(enemy, target) && behavior !== 'hold') { + const destination = scene.chooseApproachTile(enemy, target); + if (destination) { + enemy.x = destination.x; + enemy.y = destination.y; + } + } + if (scene.canAttack(enemy, unit)) { + threats.push(scene.combatPreview(enemy, unit, 'attack').damage); + } + } + enemy.x = original.x; + enemy.y = original.y; + } + return threats.sort((a, b) => b - a).slice(0, 3).reduce((total, damage) => total + damage, 0); + }); + } + + function shouldPressProtected(unit) { + if (!protectedIds.has(unit.id)) { + return false; + } + const focus = focusTarget(unit); + const enemies = liveUnits('enemy'); + const vanguardInPosition = liveUnits('ally').some( + (ally) => ally.id !== unit.id && !protectedIds.has(ally.id) && (!focus || distance(ally, focus) <= 10) + ); + return ( + !vanguardInPosition || + (focus && targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * 0.3)) + ); + } + + function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) { + if (!protectedIds.has(unit.id)) { + return true; + } + const pressProtected = shouldPressProtected(unit); + const isTarget = targetIds.has(attack.target.id); + const kill = attack.preview.damage >= attack.target.hp; + if (isTarget && (threat <= Math.floor(unit.hp * (pressProtected ? 0.7 : 0.42)) || unit.hp >= Math.ceil(unit.maxHp * 0.8))) { + return true; + } + if (kill && threat <= Math.floor(unit.hp * (pressProtected ? 0.5 : 0.32))) { + return true; + } + return threat <= Math.floor(unit.hp * (pressProtected ? 0.28 : 0.18)); + } + + function canPressProtectedTile(unit, tile, focus, currentDistance) { + const threat = incomingThreat(unit, tile); + if (threat <= Math.floor(unit.hp * 0.55)) { + return true; + } + const enemies = liveUnits('enemy'); + return ( + unit.hp >= Math.ceil(unit.maxHp * 0.95) && + distance(tile, focus) < currentDistance && + (enemies.length <= 8 || currentDistance <= 8 || (targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * 0.5))) + ); + } + + function chooseMove(unit) { + const tiles = [{ x: unit.x, y: unit.y, cost: 0 }, ...scene.reachableTiles(unit)]; + const pressProtected = shouldPressProtected(unit); + const attackingTiles = tiles + .map((tile) => + withUnitAt(unit, tile, () => { + const attack = attackChoice(unit); + return attack ? { tile, attack, threat: protectedIds.has(unit.id) ? incomingThreat(unit, tile) : 0 } : undefined; + }) + ) + .filter(Boolean); + + const preferredAttackingTiles = protectedIds.has(unit.id) + ? attackingTiles.filter((entry) => canTakeAttack(unit, entry.attack, entry.threat)) + : attackingTiles; + + if (preferredAttackingTiles.length > 0 && (!protectedIds.has(unit.id) || unit.hp >= Math.ceil(unit.maxHp * 0.45))) { + return preferredAttackingTiles.sort((a, b) => { + const threatWeight = protectedIds.has(unit.id) ? (pressProtected ? 4 : 8) : 2; + const aScore = a.attack.score + (targetIds.has(a.attack.target.id) ? 2400 : 1350) - a.threat * threatWeight; + const bScore = b.attack.score + (targetIds.has(b.attack.target.id) ? 2400 : 1350) - b.threat * threatWeight; + return bScore - aScore; + })[0].tile; + } + + if (!protectedIds.has(unit.id) || pressProtected) { + const focus = focusTarget(unit); + if (focus) { + const currentDistance = distance(unit, focus); + const pressureTile = tiles + .filter((tile) => distance(tile, focus) < currentDistance) + .filter((tile) => !pressProtected || canPressProtectedTile(unit, tile, focus, currentDistance)) + .sort((a, b) => distance(a, focus) - distance(b, focus) || (a.cost ?? 0) - (b.cost ?? 0))[0]; + if (pressureTile) { + return pressureTile; + } + } + } + + let best = { tile: tiles[0], score: Number.NEGATIVE_INFINITY }; + + for (const tile of tiles) { + const score = withUnitAt(unit, tile, () => { + const attack = attackChoice(unit); + const focus = attack?.target ?? focusTarget(unit); + const isProtected = protectedIds.has(unit.id); + const shouldPress = isProtected && shouldPressProtected(unit); + const approach = focus ? -distance(unit, focus) * (isProtected ? (shouldPress ? 42 : 5) : 36) : 0; + const attackScore = attack ? attack.score + (targetIds.has(attack.target.id) ? 2400 : 1350) : approach; + const allySupport = liveUnits('ally').filter((ally) => ally.id !== unit.id && distance(unit, ally) <= 2).length * 16; + const threat = incomingThreat(unit, tile); + const nearestEnemyDistance = liveUnits('enemy') + .map((enemy) => distance(unit, enemy)) + .sort((a, b) => a - b)[0] ?? 99; + const protectedFrontlinePenalty = isProtected && !shouldPress && nearestEnemyDistance <= 5 ? (6 - nearestEnemyDistance) * 1800 : 0; + const fragilePenalty = unit.hp - threat <= Math.max(5, Math.floor(unit.maxHp * 0.18)) ? (isProtected ? (shouldPress ? 420 : 900) : 260) : 0; + const threatWeight = isProtected ? (shouldPress ? 5 : 12) : 4; + return attackScore + allySupport - threat * threatWeight - fragilePenalty - protectedFrontlinePenalty - (tile.cost ?? 0) * 2; + }); + + if (score > best.score) { + best = { tile, score }; + } + } + + return best.tile; + } + + function bestSupportAction(user) { + const injured = liveUnits('ally') + .filter((ally) => ally.hp < ally.maxHp) + .sort((a, b) => b.maxHp - b.hp - (a.maxHp - a.hp)); + const options = []; + + for (const command of ['item', 'strategy']) { + for (const usable of scene.availableUsables(user, command)) { + if (usable.effect !== 'heal') { + continue; + } + for (const target of injured) { + if (!scene.canUseSupportCommand(user, target, usable)) { + continue; + } + const missing = target.maxHp - target.hp; + const amount = scene.supportHealAmount(user, target, usable); + const urgent = target.hp <= Math.ceil(target.maxHp * 0.45); + if (!urgent && amount < 8 && missing < 10) { + continue; + } + options.push({ usable, target, score: amount * 12 + missing * 4 + (urgent ? 180 : 0) + (usable.command === 'item' ? 15 : 0) }); + } + } + } + + return options.sort((a, b) => b.score - a.score)[0]; + } + + function performSupport(user, support) { + if (support.usable.command === 'item' && !scene.consumeItem(user.id, support.usable.id)) { + return false; + } + scene.resolveSupportAction(user, support.target, support.usable); + scene.finishUnitAction(user, `QA support ${user.id} -> ${support.target.id}`); + metrics.supports += 1; + if (support.usable.command === 'item') { + metrics.itemsUsed += 1; + } + resolveOutcome(); + return true; + } + + function performAttack(attacker, target, side) { + const result = scene.resolveAttack(attacker, target); + result.counter = scene.resolveCounterAttack(result); + if (side === 'ally') { + scene.finishUnitAction(attacker, `QA attack ${attacker.id} -> ${target.id}`); + metrics.allyAttacks += 1; + } else { + metrics.enemyAttacks += 1; + } + resolveOutcome(); + } + + for (let round = 0; round < maxRounds; round += 1) { + let snapshot = state(); + if (snapshot.battleOutcome) { + break; + } + + const acted = actedUnitIds(); + const allies = snapshot.units + .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !acted.has(unit.id)) + .sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp); + + for (const allyState of allies) { + snapshot = state(); + if (snapshot.battleOutcome) { + break; + } + const unit = unitRef(allyState.id); + if (!unit || unit.hp <= 0 || actedUnitIds().has(unit.id)) { + continue; + } + + const urgentSupport = bestSupportAction(unit); + if (urgentSupport && (unit.hp <= Math.ceil(unit.maxHp * 0.48) || urgentSupport.target.hp <= Math.ceil(urgentSupport.target.maxHp * 0.4))) { + if (performSupport(unit, urgentSupport)) { + await sleep(1); + continue; + } + } + + scene.selectedUnit = unit; + scene.phase = 'moving'; + const tile = chooseMove(unit); + scene.moveSelectedUnit(tile.x, tile.y); + syncUnit(unit); + + const attack = attackChoice(unit); + if (attack && canTakeAttack(unit, attack)) { + performAttack(unit, attack.target, 'ally'); + } else { + const support = bestSupportAction(unit); + if (!support || !performSupport(unit, support)) { + scene.completeUnitAction(unit, `QA wait ${unit.id}`); + } + } + + await sleep(1); + } + + snapshot = state(); + if (snapshot.battleOutcome) { + break; + } + + scene.activeFaction = 'enemy'; + for (const enemyState of snapshot.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0)) { + if (state().battleOutcome) { + break; + } + const enemy = unitRef(enemyState.id); + if (!enemy || enemy.hp <= 0) { + continue; + } + + const behavior = scene.enemyBehavior(enemy); + let target = scene.chooseEnemyTarget(enemy, behavior); + if (!target) { + continue; + } + + if (!scene.canAttack(enemy, target) && behavior !== 'hold') { + const destination = scene.chooseApproachTile(enemy, target); + if (destination) { + enemy.x = destination.x; + enemy.y = destination.y; + syncUnit(enemy); + } + } + + target = scene.chooseTargetInRange(enemy) ?? target; + if (target && scene.canAttack(enemy, target)) { + performAttack(enemy, target, 'enemy'); + } + } + + if (state().battleOutcome) { + break; + } + + scene.beginNextAllyTurn(); + await sleep(1); + } + + const finalState = state(); + metrics.outcome = finalState.battleOutcome; + metrics.finalTurn = finalState.turnNumber; + metrics.alliesAlive = finalState.units.filter((unit) => unit.faction === 'ally' && unit.hp > 0).length; + metrics.enemiesAlive = finalState.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; + metrics.objectiveCount = finalState.objectives.length; + metrics.objectivesAchieved = finalState.objectives.filter((objective) => objective.achieved).length; + metrics.protectedStatus = protectedUnitIds.map((unitId) => { + const unit = finalState.units.find((candidate) => candidate.id === unitId); + return unit ? `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}` : `${unitId}:missing`; + }); + metrics.targetStatus = targets.map((unitId) => { + const unit = finalState.units.find((candidate) => candidate.id === unitId); + return unit ? `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}` : `${unitId}:missing`; + }); + metrics.allyStatus = finalState.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}`); + metrics.enemyStatus = finalState.units + .filter((unit) => unit.faction === 'enemy' && unit.hp > 0) + .map((unit) => `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}`); + metrics.battleLogTail = finalState.battleLog.slice(-8); + return metrics; + }, + { no: battle.no, id: battle.id, maxRounds, targets: battle.targets, protected: battle.protected } + ); +} + +async function ensureLocalServer(url) { + if (await isServerReachable(url)) { + return undefined; + } + + const child = spawn(pnpmCommand, ['dev'], { + cwd: process.cwd(), + shell: true, + stdio: 'ignore', + windowsHide: true + }); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await isServerReachable(url)) { + return child; + } + await sleep(500); + } + + child.kill(); + throw new Error(`Could not start dev server at ${url}`); +} + +async function isServerReachable(url) { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 2be22d6..b8ce9c1 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -4381,8 +4381,8 @@ export const seventhBattleMap: BattleMap = { ['forest', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], ['plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'road', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain'], ['road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'road', 'road', 'road', 'road', 'road', 'road', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'], - ['road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], - ['plain', 'road', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], + ['road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], + ['plain', 'road', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], ['plain', 'road', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'plain'], ['plain', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'hill', 'plain', 'plain'], ['camp', 'road', 'road', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain'], @@ -18505,8 +18505,8 @@ const sixtySixthEnemySpecs: ReadonlyArray< ['northern-twelfth-officer-wuzhang', '오장원 전진장', '전진 진영 무장', 'rebelLeader', 252, 2000, 472, 90, 84], ['northern-twelfth-officer-ledger', '장부 차단장', '장부 차단 무장', 'rebelLeader', 252, 2000, 472, 111, 74], ['northern-twelfth-officer-guo-huai', '곽회', '봉화 차단 무장', 'rebelLeader', 252, 1980, 468, 122, 70], - ['northern-twelfth-officer-zhang-he', '장합', '추격 총대장', 'rebelLeader', 253, 2040, 478, 127, 34], - ['northern-twelfth-leader-sima-yi', '사마의', '오장원 총지휘관', 'rebelLeader', 254, 2100, 486, 124, 46] + ['northern-twelfth-officer-zhang-he', '장합', '추격 총대장', 'rebelLeader', 253, 2040, 478, 116, 44], + ['northern-twelfth-leader-sima-yi', '사마의', '오장원 총지휘관', 'rebelLeader', 254, 2100, 486, 124, 52] ]; export const sixtySixthBattleUnits: UnitData[] = [ @@ -20559,10 +20559,10 @@ function createEighteenthBattleTerrain(): TerrainType[][] { if ((x >= 9 && x <= 11 && y >= 18 && y <= 20) || (x >= 24 && x <= 26 && y >= 13 && y <= 15)) { return 'village'; } - if ((x === 18 || x === 19) && y >= 1 && y <= 23) { + if ((x === 18 || x === 19) && y >= 1 && y <= 23 && y !== 15 && y !== 18) { return 'river'; } - if ((x === 27 || x === 28) && y >= 4 && y <= 21) { + if ((x === 27 || x === 28) && y >= 4 && y <= 21 && y !== 15) { return 'river'; } if ( @@ -22956,14 +22956,6 @@ function createFiftyFourthBattleTerrain(): TerrainType[][] { function createFiftyFifthBattleTerrain(): TerrainType[][] { return Array.from({ length: 92 }, (_, y) => Array.from({ length: 110 }, (_, x): TerrainType => { - if ( - (x >= 0 && x <= 109 && y >= 84) || - (x >= 72 && x <= 78 && y >= 0 && y <= 86) || - (x >= 4 && x <= 32 && y >= 75 && y <= 91) || - (x >= 84 && x <= 104 && y >= 67 && y <= 84) - ) { - return 'river'; - } if ( (x >= 11 && x <= 105 && y >= 71 && y <= 77) || (x >= 31 && x <= 83 && y >= 48 && y <= 56) || @@ -22977,6 +22969,14 @@ function createFiftyFifthBattleTerrain(): TerrainType[][] { ) { return 'road'; } + if ( + (x >= 0 && x <= 109 && y >= 84) || + (x >= 72 && x <= 78 && y >= 0 && y <= 86) || + (x >= 4 && x <= 32 && y >= 75 && y <= 91) || + (x >= 84 && x <= 104 && y >= 67 && y <= 84) + ) { + return 'river'; + } if ( (x >= 18 && x <= 80 && y >= 24 && y <= 82) || (x >= 59 && x <= 104 && y >= 31 && y <= 78) || @@ -23186,13 +23186,6 @@ function createFiftySeventhBattleTerrain(): TerrainType[][] { function createFiftyEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 98 }, (_, y) => Array.from({ length: 116 }, (_, x): TerrainType => { - if ( - (x >= 36 && x <= 43 && y >= 0 && y <= 97) || - (x >= 64 && x <= 71 && y >= 18 && y <= 97) || - (x >= 0 && x <= 115 && y >= 88) - ) { - return 'river'; - } if ( (x >= 6 && x <= 76 && y >= 82 && y <= 87) || (x >= 18 && x <= 88 && y >= 70 && y <= 78) || @@ -23205,6 +23198,13 @@ function createFiftyEighthBattleTerrain(): TerrainType[][] { ) { return 'road'; } + if ( + (x >= 36 && x <= 43 && y >= 0 && y <= 97) || + (x >= 64 && x <= 71 && y >= 18 && y <= 97) || + (x >= 0 && x <= 115 && y >= 88) + ) { + return 'river'; + } if ( (x >= 0 && x <= 38 && y >= 55 && y <= 86) || (x >= 46 && x <= 78 && y >= 67 && y <= 88) || @@ -23815,12 +23815,6 @@ function createSixtyFifthBattleTerrain(): TerrainType[][] { function createSixtySixthBattleTerrain(): TerrainType[][] { return Array.from({ length: 114 }, (_, y) => Array.from({ length: 132 }, (_, x): TerrainType => { - if ( - (x >= 0 && x <= 131 && y >= 22 && y <= 32) || - (x >= 4 && x <= 131 && y >= 38 && y <= 48) - ) { - return 'river'; - } if ( (x >= 4 && x <= 95 && y >= 99 && y <= 107) || (x >= 24 && x <= 114 && y >= 87 && y <= 97) || @@ -23834,6 +23828,12 @@ function createSixtySixthBattleTerrain(): TerrainType[][] { ) { return 'road'; } + if ( + (x >= 0 && x <= 131 && y >= 22 && y <= 32) || + (x >= 4 && x <= 131 && y >= 38 && y <= 48) + ) { + return 'river'; + } if ( (x >= 0 && x <= 44 && y >= 70 && y <= 104) || (x >= 47 && x <= 90 && y >= 83 && y <= 105) || diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b965e2b..62c58c0 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1407,6 +1407,7 @@ const unitStrategyIds: Record = { 'jian-yong': ['aid', 'encourage', 'fireTactic'], 'mi-zhu': ['aid', 'encourage'], 'sun-qian': ['aid', 'encourage', 'fireTactic'], + 'zhao-yun': ['encourage'], 'zhuge-liang': ['aid', 'encourage', 'fireTactic'], 'ma-liang': ['aid', 'encourage', 'fireTactic'], 'yi-ji': ['aid', 'encourage'], @@ -1415,7 +1416,14 @@ const unitStrategyIds: Record = { 'wei-yan': ['roar'], 'pang-tong': ['aid', 'encourage', 'fireTactic'], 'fa-zheng': ['aid', 'encourage', 'fireTactic'], - 'wu-yi': ['encourage'] + 'wu-yi': ['encourage'], + 'yan-yan': ['encourage'], + 'li-yan': ['aid', 'encourage'], + 'huang-quan': ['aid', 'encourage'], + 'ma-chao': ['roar', 'encourage'], + 'ma-dai': ['encourage'], + 'wang-ping': ['aid', 'encourage'], + 'jiang-wei': ['aid', 'encourage', 'fireTactic'] }; const initialItemStocks: Record> = { @@ -1423,18 +1431,25 @@ const initialItemStocks: Record> = { 'guan-yu': { bean: 1 }, 'zhang-fei': { bean: 1, wine: 1 }, 'jian-yong': { bean: 1 }, - 'mi-zhu': { bean: 2, salve: 1 }, + 'mi-zhu': { bean: 3, salve: 2 }, 'sun-qian': { bean: 1, salve: 1 }, - 'zhao-yun': { bean: 1, wine: 1 }, - 'zhuge-liang': { bean: 1, wine: 1 }, - 'ma-liang': { bean: 1, salve: 1 }, + 'zhao-yun': { bean: 2, salve: 1, wine: 1 }, + 'zhuge-liang': { bean: 2, salve: 1, wine: 1 }, + 'ma-liang': { bean: 2, salve: 2 }, 'yi-ji': { bean: 2, salve: 1 }, 'gong-zhi': { bean: 2 }, - 'huang-zhong': { bean: 1, wine: 1 }, + 'huang-zhong': { bean: 2, salve: 1, wine: 1 }, 'wei-yan': { bean: 2 }, 'pang-tong': { bean: 1, salve: 1, wine: 1 }, - 'fa-zheng': { bean: 1, salve: 1, wine: 1 }, - 'wu-yi': { bean: 2, wine: 1 } + 'fa-zheng': { bean: 2, salve: 2, wine: 1 }, + 'wu-yi': { bean: 2, wine: 1 }, + 'yan-yan': { bean: 2 }, + 'li-yan': { bean: 2, salve: 2 }, + 'huang-quan': { bean: 2, salve: 2 }, + 'ma-chao': { bean: 2, salve: 1, wine: 1 }, + 'ma-dai': { bean: 2, salve: 1, wine: 1 }, + 'wang-ping': { bean: 3, salve: 1 }, + 'jiang-wei': { bean: 2, salve: 1, wine: 1 } }; const attackRangeByClass: Partial> = { @@ -2728,6 +2743,49 @@ function cloneBattleBond(bond: BattleBond): BattleBond { }; } +function scenarioReadinessAnchor() { + return ( + initialBattleUnits.find((unit) => unit.id === leaderUnitId && unit.faction === 'enemy') ?? + initialBattleUnits + .filter((unit) => unit.faction === 'enemy') + .sort((a, b) => b.level - a.level || b.maxHp - a.maxHp || b.attack - a.attack)[0] + ); +} + +function applyScenarioAllyReadiness(unit: UnitData) { + if (unit.faction !== 'ally') { + return unit; + } + + const anchor = scenarioReadinessAnchor(); + if (!anchor) { + return unit; + } + + const enemyCount = initialBattleUnits.filter((candidate) => candidate.faction === 'enemy').length; + const densityFactor = 1 + Math.max(0, enemyCount - 18) * 0.035; + const attackDensityFactor = 1 + Math.max(0, enemyCount - 18) * 0.018; + const hpRatio = unit.maxHp > 0 ? unit.hp / unit.maxHp : 1; + const minimumLevel = Math.max(unit.level, Math.min(99, Math.round(anchor.level * 0.72))); + const minimumMaxHp = Math.max(unit.maxHp, Math.round(anchor.maxHp * 1.15 * densityFactor)); + const minimumAttack = Math.max(unit.attack, Math.round(anchor.attack * 0.72 * attackDensityFactor)); + const equipmentLevelFloor = Math.max(1, Math.min(9, Math.floor(anchor.level / 28) + 1)); + const next = cloneUnitData(unit); + + next.level = minimumLevel; + next.maxHp = minimumMaxHp; + next.hp = minimumMaxHp > unit.maxHp ? Math.max(unit.hp, Math.ceil(minimumMaxHp * hpRatio)) : Math.min(unit.hp, minimumMaxHp); + next.attack = minimumAttack; + (Object.keys(next.stats) as Array).forEach((key) => { + next.stats[key] = Math.max(next.stats[key], Math.round(anchor.stats[key] * 0.78)); + }); + equipmentSlots.forEach((slot) => { + next.equipment[slot].level = Math.max(next.equipment[slot].level, equipmentLevelFloor); + }); + + return next; +} + function configureBattleScenario(scenario: BattleScenarioDefinition) { battleScenario = scenario; battleMap = scenario.map; @@ -2939,7 +2997,7 @@ export class BattleScene extends Phaser.Scene { const hasSelection = selected.size > 0; const nextUnits = initialBattleUnits .filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || forcedAllyIds.has(unit.id)) - .map((unit) => this.applyCampaignUnitProgress(unit, campaign)); + .map((unit) => applyScenarioAllyReadiness(this.applyCampaignUnitProgress(unit, campaign))); battleUnits.splice(0, battleUnits.length, ...nextUnits); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); initialBattleUnits = battleUnits.map(cloneUnitData); @@ -5546,7 +5604,9 @@ export class BattleScene extends Phaser.Scene { : getItem(user.equipment.accessory.itemId).id === 'peach-charm' ? 2 : 0; - return Math.min(target.maxHp - target.hp, Math.max(1, usable.power + bonus + equipmentBonus)); + const flatAmount = usable.power + bonus + equipmentBonus; + const percentFloor = Math.ceil(target.maxHp * (usable.command === 'item' ? 0.24 : 0.18)); + return Math.min(target.maxHp - target.hp, Math.max(1, flatAmount, percentFloor)); } private combatEquipmentEffect(