QA representative battles and tune balance

This commit is contained in:
2026-06-27 05:56:45 +09:00
parent d10cd6319d
commit 23f734b35c
4 changed files with 723 additions and 38 deletions

View File

@@ -10,7 +10,8 @@
"generate:audio": "node scripts/generate-bgm.mjs", "generate:audio": "node scripts/generate-bgm.mjs",
"generate:sfx": "node scripts/generate-sfx.mjs", "generate:sfx": "node scripts/generate-sfx.mjs",
"preview": "vite preview --host 0.0.0.0", "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": { "dependencies": {
"phaser": "^3.90.0" "phaser": "^3.90.0"

View File

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

View File

@@ -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'], ['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'], ['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', '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'], ['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', 'river', 'river', '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', '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'], ['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'], ['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-wuzhang', '오장원 전진장', '전진 진영 무장', 'rebelLeader', 252, 2000, 472, 90, 84],
['northern-twelfth-officer-ledger', '장부 차단장', '장부 차단 무장', 'rebelLeader', 252, 2000, 472, 111, 74], ['northern-twelfth-officer-ledger', '장부 차단장', '장부 차단 무장', 'rebelLeader', 252, 2000, 472, 111, 74],
['northern-twelfth-officer-guo-huai', '곽회', '봉화 차단 무장', 'rebelLeader', 252, 1980, 468, 122, 70], ['northern-twelfth-officer-guo-huai', '곽회', '봉화 차단 무장', 'rebelLeader', 252, 1980, 468, 122, 70],
['northern-twelfth-officer-zhang-he', '장합', '추격 총대장', 'rebelLeader', 253, 2040, 478, 127, 34], ['northern-twelfth-officer-zhang-he', '장합', '추격 총대장', 'rebelLeader', 253, 2040, 478, 116, 44],
['northern-twelfth-leader-sima-yi', '사마의', '오장원 총지휘관', 'rebelLeader', 254, 2100, 486, 124, 46] ['northern-twelfth-leader-sima-yi', '사마의', '오장원 총지휘관', 'rebelLeader', 254, 2100, 486, 124, 52]
]; ];
export const sixtySixthBattleUnits: UnitData[] = [ 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)) { if ((x >= 9 && x <= 11 && y >= 18 && y <= 20) || (x >= 24 && x <= 26 && y >= 13 && y <= 15)) {
return 'village'; 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'; return 'river';
} }
if ((x === 27 || x === 28) && y >= 4 && y <= 21) { if ((x === 27 || x === 28) && y >= 4 && y <= 21 && y !== 15) {
return 'river'; return 'river';
} }
if ( if (
@@ -22956,14 +22956,6 @@ function createFiftyFourthBattleTerrain(): TerrainType[][] {
function createFiftyFifthBattleTerrain(): TerrainType[][] { function createFiftyFifthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 92 }, (_, y) => return Array.from({ length: 92 }, (_, y) =>
Array.from({ length: 110 }, (_, x): TerrainType => { 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 ( if (
(x >= 11 && x <= 105 && y >= 71 && y <= 77) || (x >= 11 && x <= 105 && y >= 71 && y <= 77) ||
(x >= 31 && x <= 83 && y >= 48 && y <= 56) || (x >= 31 && x <= 83 && y >= 48 && y <= 56) ||
@@ -22977,6 +22969,14 @@ function createFiftyFifthBattleTerrain(): TerrainType[][] {
) { ) {
return 'road'; 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 ( if (
(x >= 18 && x <= 80 && y >= 24 && y <= 82) || (x >= 18 && x <= 80 && y >= 24 && y <= 82) ||
(x >= 59 && x <= 104 && y >= 31 && y <= 78) || (x >= 59 && x <= 104 && y >= 31 && y <= 78) ||
@@ -23186,13 +23186,6 @@ function createFiftySeventhBattleTerrain(): TerrainType[][] {
function createFiftyEighthBattleTerrain(): TerrainType[][] { function createFiftyEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 98 }, (_, y) => return Array.from({ length: 98 }, (_, y) =>
Array.from({ length: 116 }, (_, x): TerrainType => { 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 ( if (
(x >= 6 && x <= 76 && y >= 82 && y <= 87) || (x >= 6 && x <= 76 && y >= 82 && y <= 87) ||
(x >= 18 && x <= 88 && y >= 70 && y <= 78) || (x >= 18 && x <= 88 && y >= 70 && y <= 78) ||
@@ -23205,6 +23198,13 @@ function createFiftyEighthBattleTerrain(): TerrainType[][] {
) { ) {
return 'road'; 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 ( if (
(x >= 0 && x <= 38 && y >= 55 && y <= 86) || (x >= 0 && x <= 38 && y >= 55 && y <= 86) ||
(x >= 46 && x <= 78 && y >= 67 && y <= 88) || (x >= 46 && x <= 78 && y >= 67 && y <= 88) ||
@@ -23815,12 +23815,6 @@ function createSixtyFifthBattleTerrain(): TerrainType[][] {
function createSixtySixthBattleTerrain(): TerrainType[][] { function createSixtySixthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 114 }, (_, y) => return Array.from({ length: 114 }, (_, y) =>
Array.from({ length: 132 }, (_, x): TerrainType => { 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 ( if (
(x >= 4 && x <= 95 && y >= 99 && y <= 107) || (x >= 4 && x <= 95 && y >= 99 && y <= 107) ||
(x >= 24 && x <= 114 && y >= 87 && y <= 97) || (x >= 24 && x <= 114 && y >= 87 && y <= 97) ||
@@ -23834,6 +23828,12 @@ function createSixtySixthBattleTerrain(): TerrainType[][] {
) { ) {
return 'road'; return 'road';
} }
if (
(x >= 0 && x <= 131 && y >= 22 && y <= 32) ||
(x >= 4 && x <= 131 && y >= 38 && y <= 48)
) {
return 'river';
}
if ( if (
(x >= 0 && x <= 44 && y >= 70 && y <= 104) || (x >= 0 && x <= 44 && y >= 70 && y <= 104) ||
(x >= 47 && x <= 90 && y >= 83 && y <= 105) || (x >= 47 && x <= 90 && y >= 83 && y <= 105) ||

View File

@@ -1407,6 +1407,7 @@ const unitStrategyIds: Record<string, string[]> = {
'jian-yong': ['aid', 'encourage', 'fireTactic'], 'jian-yong': ['aid', 'encourage', 'fireTactic'],
'mi-zhu': ['aid', 'encourage'], 'mi-zhu': ['aid', 'encourage'],
'sun-qian': ['aid', 'encourage', 'fireTactic'], 'sun-qian': ['aid', 'encourage', 'fireTactic'],
'zhao-yun': ['encourage'],
'zhuge-liang': ['aid', 'encourage', 'fireTactic'], 'zhuge-liang': ['aid', 'encourage', 'fireTactic'],
'ma-liang': ['aid', 'encourage', 'fireTactic'], 'ma-liang': ['aid', 'encourage', 'fireTactic'],
'yi-ji': ['aid', 'encourage'], 'yi-ji': ['aid', 'encourage'],
@@ -1415,7 +1416,14 @@ const unitStrategyIds: Record<string, string[]> = {
'wei-yan': ['roar'], 'wei-yan': ['roar'],
'pang-tong': ['aid', 'encourage', 'fireTactic'], 'pang-tong': ['aid', 'encourage', 'fireTactic'],
'fa-zheng': ['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<string, Record<string, number>> = { const initialItemStocks: Record<string, Record<string, number>> = {
@@ -1423,18 +1431,25 @@ const initialItemStocks: Record<string, Record<string, number>> = {
'guan-yu': { bean: 1 }, 'guan-yu': { bean: 1 },
'zhang-fei': { bean: 1, wine: 1 }, 'zhang-fei': { bean: 1, wine: 1 },
'jian-yong': { bean: 1 }, 'jian-yong': { bean: 1 },
'mi-zhu': { bean: 2, salve: 1 }, 'mi-zhu': { bean: 3, salve: 2 },
'sun-qian': { bean: 1, salve: 1 }, 'sun-qian': { bean: 1, salve: 1 },
'zhao-yun': { bean: 1, wine: 1 }, 'zhao-yun': { bean: 2, salve: 1, wine: 1 },
'zhuge-liang': { bean: 1, wine: 1 }, 'zhuge-liang': { bean: 2, salve: 1, wine: 1 },
'ma-liang': { bean: 1, salve: 1 }, 'ma-liang': { bean: 2, salve: 2 },
'yi-ji': { bean: 2, salve: 1 }, 'yi-ji': { bean: 2, salve: 1 },
'gong-zhi': { bean: 2 }, 'gong-zhi': { bean: 2 },
'huang-zhong': { bean: 1, wine: 1 }, 'huang-zhong': { bean: 2, salve: 1, wine: 1 },
'wei-yan': { bean: 2 }, 'wei-yan': { bean: 2 },
'pang-tong': { bean: 1, salve: 1, wine: 1 }, 'pang-tong': { bean: 1, salve: 1, wine: 1 },
'fa-zheng': { bean: 1, salve: 1, wine: 1 }, 'fa-zheng': { bean: 2, salve: 2, wine: 1 },
'wu-yi': { bean: 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<Record<UnitClassKey, number>> = { const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
@@ -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<keyof UnitStats>).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) { function configureBattleScenario(scenario: BattleScenarioDefinition) {
battleScenario = scenario; battleScenario = scenario;
battleMap = scenario.map; battleMap = scenario.map;
@@ -2939,7 +2997,7 @@ export class BattleScene extends Phaser.Scene {
const hasSelection = selected.size > 0; const hasSelection = selected.size > 0;
const nextUnits = initialBattleUnits const nextUnits = initialBattleUnits
.filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || forcedAllyIds.has(unit.id)) .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); battleUnits.splice(0, battleUnits.length, ...nextUnits);
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
initialBattleUnits = battleUnits.map(cloneUnitData); initialBattleUnits = battleUnits.map(cloneUnitData);
@@ -5546,7 +5604,9 @@ export class BattleScene extends Phaser.Scene {
: getItem(user.equipment.accessory.itemId).id === 'peach-charm' : getItem(user.equipment.accessory.itemId).id === 'peach-charm'
? 2 ? 2
: 0; : 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( private combatEquipmentEffect(