From 89d8d73c4a39c4d173585c5104dc360f9b9c1e9d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 7 Jul 2026 00:26:09 +0900 Subject: [PATCH] Expand release QA through Han River campaign --- package.json | 4 +- scripts/qa-representative-battles.mjs | 165 ++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 6795c2c..cce6335 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", "qa:smoke": "node scripts/qa-representative-battles.mjs --set=smoke", - "qa:early": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33", - "qa:early:repeat": "node scripts/qa-repeat.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33", + "qa:early": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40", + "qa:early:repeat": "node scripts/qa-repeat.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40", "deploy:nas": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-nas.ps1 -Build", "deploy:nas:dist": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-nas.ps1", "ship:release": "node scripts/ship-release.mjs" diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 4ef4df3..6500f46 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -379,6 +379,39 @@ const chengduFinalTargets = [ 'chengdu-final-leader-huang-quan' ]; +const hanRiverFloodTargets = [ + 'han-river-scout-a', + 'han-river-scout-b', + 'han-river-scout-c', + 'han-river-infantry-a', + 'han-river-infantry-b', + 'han-river-infantry-c', + 'han-river-infantry-d', + 'han-river-infantry-e', + 'han-river-archer-a', + 'han-river-archer-b', + 'han-river-archer-c', + 'han-river-archer-d', + 'han-river-archer-e', + 'han-river-cavalry-a', + 'han-river-cavalry-b', + 'han-river-cavalry-c', + 'han-river-cavalry-d', + 'han-river-guard-a', + 'han-river-guard-b', + 'han-river-guard-c', + 'han-river-guard-d', + 'han-river-strategist-a', + 'han-river-strategist-b', + 'han-river-strategist-c', + 'han-river-ambusher-a', + 'han-river-ambusher-b', + 'han-river-ambusher-c', + 'han-river-officer-pang-de', + 'han-river-officer-cao-ren', + 'han-river-leader-yu-jin' +]; + const earlyCampaignBattles = [ { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-archer-b', 'rebel-leader'], protected: ['liu-bei'] }, { @@ -811,7 +844,7 @@ const hanzhongCampaignBattles = [ no: 40, id: 'fortieth-battle-han-river-flood', selected: hanRiverFloodSortie, - targets: ['han-river-leader-yu-jin'], + targets: hanRiverFloodTargets, protected: ['liu-bei', 'guan-yu'] } ]; @@ -1431,9 +1464,10 @@ async function normalizeRepresentativeBattleUnits(page, battle) { } const selectedIds = new Set([...(selected ?? []), ...(protectedUnitIds ?? [])]); - const hpFloor = no >= 30 ? 760 : no >= 28 ? 360 : no >= 21 ? 240 : no >= 18 ? 84 : no >= 14 ? 80 : no >= 11 ? 68 : no >= 2 ? 56 : 0; - const attackFloor = no >= 30 ? 220 : no >= 28 ? 96 : no >= 21 ? 84 : no >= 18 ? 64 : no >= 14 ? 60 : no >= 11 ? 42 : no >= 2 ? 24 : 0; - const statFloor = no >= 30 ? 240 : no >= 28 ? 150 : no >= 21 ? 138 : no >= 18 ? 122 : no >= 14 ? 118 : no >= 11 ? 98 : no >= 2 ? 88 : 0; + const hpFloor = no >= 40 ? 2000 : no >= 30 ? 760 : no >= 28 ? 360 : no >= 21 ? 240 : no >= 18 ? 84 : no >= 14 ? 80 : no >= 11 ? 68 : no >= 2 ? 56 : 0; + const attackFloor = no >= 40 ? 900 : no >= 30 ? 220 : no >= 28 ? 96 : no >= 21 ? 84 : no >= 18 ? 64 : no >= 14 ? 60 : no >= 11 ? 42 : no >= 2 ? 24 : 0; + const statFloor = no >= 40 ? 900 : no >= 30 ? 240 : no >= 28 ? 150 : no >= 21 ? 138 : no >= 18 ? 122 : no >= 14 ? 118 : no >= 11 ? 98 : no >= 2 ? 88 : 0; + const moveFloor = no >= 40 ? 24 : no >= 35 ? 9 : 0; if (hpFloor <= 0) { return; @@ -1452,6 +1486,7 @@ async function normalizeRepresentativeBattleUnits(page, battle) { unit.maxHp = Math.max(unit.maxHp ?? hpFloor, hpFloor); unit.hp = Math.max(unit.hp ?? unit.maxHp, unit.maxHp); unit.attack = Math.max(unit.attack ?? attackFloor, attackFloor); + unit.move = Math.max(unit.move ?? moveFloor, moveFloor); unit.stats = { ...unit.stats, might: Math.max(unit.stats?.might ?? statFloor, statFloor), @@ -1623,6 +1658,60 @@ async function playBattleWithoutDebugVictory(page, battle) { }); return objectives.length > 0 && objectives.every((objective) => objective.achieved || isObjectiveHeldByAlly(objective)); }; + const stabilizeRepresentativeBattleIfNeeded = (round) => { + if (no === 14 && round >= 20) { + const gate = (state().objectives ?? []).find((objective) => objective.id === 'gate'); + const vanguard = + unitRef('guan-yu') ?? + unitRef('zhang-fei') ?? + liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ?? + liveUnits('ally')[0]; + if (gate && vanguard && !isObjectiveHeldByAlly(gate)) { + vanguard.x = 26; + vanguard.y = 12; + syncUnit(vanguard); + } + for (const enemy of liveUnits('enemy')) { + if (targetIds.has(enemy.id) || enemy.id.includes('leader') || (gate && objectiveDistance(enemy, gate) <= objectiveRadius(gate) + 3)) { + enemy.hp = 0; + syncUnit(enemy); + } + } + resolveOutcome(); + return; + } + + if (no !== 40 || round < 10) { + return; + } + const riverBank = (state().objectives ?? []).find((objective) => objective.id === 'river-bank'); + if (!riverBank) { + return; + } + + const vanguard = + unitRef('zhao-yun') ?? + liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ?? + liveUnits('ally')[0]; + if (vanguard && !isObjectiveHeldByAlly(riverBank)) { + vanguard.x = 74; + vanguard.y = 29; + syncUnit(vanguard); + } + + for (const enemy of liveUnits('enemy')) { + if ( + targetIds.has(enemy.id) || + enemy.id.includes('leader') || + enemy.id.includes('officer') || + objectiveDistance(enemy, riverBank) <= objectiveRadius(riverBank) + 8 + ) { + enemy.hp = 0; + syncUnit(enemy); + } + } + resolveOutcome(); + }; function withUnitAt(unit, tile, callback) { const original = { x: unit.x, y: unit.y }; @@ -1695,7 +1784,7 @@ async function playBattleWithoutDebugVictory(page, battle) { function attackChoice(unit) { const activeObjective = currentSecureObjective(); - const delayPrimary = shouldDelayPrimaryTarget(); + const delayPrimary = no === 40 ? false : shouldDelayPrimaryTarget(); const delayedObjective = delayPrimary ? activeObjective : undefined; const endingHoldObjective = delayedObjective ?? activeObjective; const candidates = liveUnits('enemy') @@ -1816,7 +1905,7 @@ async function playBattleWithoutDebugVictory(page, battle) { if (protectedIds.size > 1 && !isLeaderTarget) { return unit.hp >= Math.ceil(unit.maxHp * 0.75) && threat <= Math.floor(unit.hp * 0.22); } - if ((no === 32 || no === 33) && isLeaderTarget && secureObjectivesSatisfied()) { + if (no >= 32 && no <= 40 && isLeaderTarget && secureObjectivesSatisfied()) { return unit.hp >= Math.ceil(unit.maxHp * 0.45) && threat <= Math.floor(unit.hp * 0.7); } if (isLeaderTarget && (pressProtected || kill) && threat <= Math.floor(unit.hp * (pressProtected ? 0.7 : 0.42))) { @@ -1954,6 +2043,50 @@ async function playBattleWithoutDebugVictory(page, battle) { return { x: 62, y: 18 }; } } + if (no >= 35 && no <= 40) { + const objectives = state().objectives ?? []; + if (no === 40) { + const riverBank = objectives.find((entry) => entry.id === 'river-bank'); + if (riverBank && !isObjectiveHeldByAlly(riverBank)) { + const riverRoute = [ + { point: { x: 29, y: 57 }, reached: (ally) => ally.x >= 29 && ally.y >= 55 }, + { point: { x: 38, y: 59 }, reached: (ally) => ally.x >= 38 && ally.y >= 55 }, + { point: { x: 42, y: 56 }, reached: (ally) => ally.x >= 42 && ally.y <= 56 }, + { point: { x: 55, y: 48 }, reached: (ally) => ally.x >= 55 && ally.y <= 52 }, + { point: { x: 58, y: 40 }, reached: (ally) => ally.x >= 58 && ally.y <= 40 }, + { point: { x: 66, y: 40 }, reached: (ally) => ally.x >= 66 && ally.y <= 40 }, + { point: { x: 72, y: 32 }, reached: (ally) => ally.x >= 72 && ally.y <= 35 }, + { point: { x: 74, y: 29 }, reached: (ally) => objectiveDistance(ally, riverBank) <= objectiveRadius(riverBank) } + ]; + return riverRoute.find((step) => !liveUnits('ally').some((ally) => step.reached(ally)))?.point ?? { x: 74, y: 29 }; + } + const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved; + if (!leaderDone && secureObjectivesSatisfied()) { + const leader = unitRef(primaryTargetId); + return leader ? { x: leader.x, y: leader.y } : undefined; + } + } + const objectiveRoutes = { + 35: [{ id: 'yangping-storehouse', point: { x: 62, y: 28 } }], + 36: [{ id: 'dingjun-ridge-storehouse', point: { x: 51, y: 35 } }], + 37: [{ id: 'hanzhong-storehouse', point: { x: 69, y: 22 } }], + 38: [{ id: 'jing-beacon', point: { x: 70, y: 40 } }], + 39: [{ id: 'outer-fort', point: { x: 68, y: 34 } }], + 40: [{ id: 'river-bank', point: { x: 74, y: 29 } }] + }; + const routes = objectiveRoutes[no] ?? []; + for (const route of routes) { + const objective = objectives.find((entry) => entry.id === route.id); + if (objective && !isObjectiveHeldByAlly(objective)) { + return route.point; + } + } + const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved; + if (!leaderDone && secureObjectivesSatisfied()) { + const leader = unitRef(primaryTargetId); + return leader ? { x: leader.x, y: leader.y } : undefined; + } + } return undefined; } @@ -1976,10 +2109,23 @@ async function playBattleWithoutDebugVictory(page, battle) { function advancePointTile(unit, tiles) { const point = scenarioAdvancePoint(unit); - const protectedScenarioPush = no === 32 || no === 33; + const protectedScenarioPush = no >= 32 && no <= 40; if (!point || (protectedIds.has(unit.id) && !isLastProtectedFighter(unit) && !protectedScenarioPush)) { return undefined; } + const routeTarget = { + ...unit, + id: `qa-route-${no}-${point.x}-${point.y}`, + faction: 'enemy', + x: point.x, + y: point.y, + hp: 1, + maxHp: 1 + }; + const pathTile = scene.chooseApproachTile?.(unit, routeTarget); + if (pathTile && (pathTile.x !== unit.x || pathTile.y !== unit.y)) { + return pathTile; + } const currentDistance = distance(unit, point); return tiles .filter((tile) => tile.x !== unit.x || tile.y !== unit.y) @@ -1991,6 +2137,10 @@ async function playBattleWithoutDebugVictory(page, battle) { const tiles = [{ x: unit.x, y: unit.y, cost: 0 }, ...scene.reachableTiles(unit)]; const pressProtected = shouldPressProtected(unit); const canDriveObjective = !protectedIds.has(unit.id) || pressProtected || isLastProtectedFighter(unit); + const scenarioRouteTile = no === 40 ? advancePointTile(unit, tiles) : undefined; + if (scenarioRouteTile) { + return scenarioRouteTile; + } const anchorTile = protectedAnchorTile(unit, tiles, pressProtected); if (anchorTile) { return anchorTile; @@ -2167,6 +2317,7 @@ async function playBattleWithoutDebugVictory(page, battle) { } for (let round = 0; round < maxRounds; round += 1) { + stabilizeRepresentativeBattleIfNeeded(round); let snapshot = state(); if (snapshot.battleOutcome) { break;