feat: add city stay exploration and resonance

This commit is contained in:
2026-07-24 00:32:46 +09:00
parent e06b15ce7a
commit b7c90c9452
9 changed files with 2034 additions and 34 deletions

View File

@@ -33,6 +33,8 @@
"verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs",
"verify:sortie-roster-history": "node scripts/verify-sortie-roster-history.mjs",
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
"verify:city-stays": "node scripts/verify-city-stay-data.mjs",
"verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs",
"verify:camp-skins": "node scripts/verify-camp-skin-data.mjs",
"verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs",
"verify:sound-director": "node scripts/verify-sound-director.mjs",
@@ -59,7 +61,7 @@
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
"verify:release": "node scripts/verify-release-candidate.mjs",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs",

View File

@@ -98,6 +98,8 @@ function validateCampInteractionUxGuards() {
const steppedNavigationMethod = extractPrivateMethod(source, 'startCampNavigationWithCampaignStep');
const startStoryMethod = extractPrivateMethod(source, 'startVictoryStory');
const saveMethod = extractPrivateMethod(source, 'saveCampToSlot');
const wolongLeadMethod = extractPrivateMethod(source, 'hasWolongAudienceLead');
const renderBondListMethod = extractPrivateMethod(source, 'renderBondList');
let checkedGuardCount = 0;
expectCampUx(
@@ -192,6 +194,21 @@ function validateCampInteractionUxGuards() {
);
checkedGuardCount += 1;
expectCampUx(
startStoryMethod.includes("flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()") &&
wolongLeadMethod.includes("completedVisits.includes('jingzhou-scholar-rumors')") &&
wolongLeadMethod.includes("completedVisits.includes('refugee-village-patrol')"),
'the Wolong sortie gate must accept a completed information visit regardless of which visit reward choice was taken'
);
checkedGuardCount += 1;
expectCampUx(
renderBondListMethod.includes('dialogue.id === this.selectedDialogueId') &&
renderBondListMethod.includes('[selectedBond, ...relatedBonds.filter'),
'the resonance list must keep the currently selected dialogue bond visible when more than four bonds are available'
);
checkedGuardCount += 1;
return checkedGuardCount;
}

View File

@@ -0,0 +1,849 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const xuzhouBattleId = 'seventh-battle-xuzhou-rescue';
const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road';
const xuzhouCityStayId = 'xuzhou';
const seedGold = 5000;
const additionalCityVisualCases = [
{
cityStayId: 'xinye',
cityName: '신야',
afterBattleId: 'seventeenth-battle-wolong-visit-road',
nextBattleId: 'eighteenth-battle-bowang-ambush',
battleTitle: '와룡 방문로',
campStep: 'seventeenth-camp',
partnerId: 'zhuge-liang',
partnerName: '제갈량',
bondId: 'liu-bei__zhuge-liang',
bondTitle: '삼고초려',
screenshotPath: 'dist/verification-city-xinye-information.png'
},
{
cityStayId: 'chengdu',
cityName: '성도',
afterBattleId: 'thirty-third-battle-chengdu-surrender',
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
battleTitle: '성도 항복',
campStep: 'thirty-third-camp',
partnerId: 'huang-quan',
partnerName: '황권',
bondId: 'liu-bei__huang-quan',
bondTitle: '익주의 신뢰',
screenshotPath: 'dist/verification-city-chengdu-information.png'
}
];
const targetUrl = withDebugOptions(
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'
);
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0'
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
await openCleanCamp(page);
await assertDesktopViewport(page);
await seedXuzhouCityStay(page);
await restartCamp(page);
const initialState = await waitForXuzhouCityStay(page);
verifyCityStayLayout(initialState);
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png');
assert.equal(
initialState.victoryRewardAcknowledgement.visible,
false,
'The seeded victory-reward notice must not obstruct the city-stay flow.'
);
const informationResult = await verifyInformationReward(page, initialState);
const marketResult = await verifyMarketPurchase(page);
const dialogueResult = await verifyCompanionDialogue(page);
await restartCamp(page);
const restoredState = await waitForXuzhouCityStay(page);
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines);
await verifyAdditionalCityLayouts(page);
assert.deepEqual(
pageErrors,
[],
`Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}`
);
console.log(
`Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` +
'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' +
'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' +
'and information, market, and resonance layouts remaining readable at the desktop baseline.'
);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
function withDebugOptions(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
return parsed.toString();
}
async function openCleanCamp(page) {
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.report?.battleId
);
}, undefined, { timeout: 90000 });
}
async function seedXuzhouCityStay(page) {
const result = await page.evaluate(({ battleId, battleTitle, gold }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') {
return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' };
}
const now = new Date().toISOString();
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
const liuBei = templateUnit
? {
...structuredClone(templateUnit),
id: 'liu-bei',
name: '유비'
}
: null;
const xuzhouBond = {
id: 'liu-bei__mi-zhu',
unitIds: ['liu-bei', 'mi-zhu'],
title: '서주 후원',
level: 42,
exp: 0,
battleExp: 0,
description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.'
};
const report = {
...scene.report,
battleId,
battleTitle,
outcome: 'victory',
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
rewardGold: 0,
itemRewards: [],
objectives: [],
units: liuBei ? [liuBei] : [],
bonds: [xuzhouBond],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: now
};
delete report.campaignRewards;
scene.campaign.step = 'seventh-camp';
scene.campaign.gold = gold;
scene.campaign.latestBattleId = battleId;
scene.campaign.roster = liuBei ? [liuBei] : [];
scene.campaign.bonds = [xuzhouBond];
scene.campaign.firstBattleReport = report;
scene.campaign.battleHistory = {
[battleId]: {
battleId,
battleTitle,
outcome: 'victory',
turnNumber: report.turnNumber,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [],
bonds: [],
completedAt: now
}
};
scene.campaign.completedCampDialogues = [];
scene.campaign.completedCampVisits = [];
scene.campaign.campDialogueChoiceIds = {};
scene.campaign.campVisitChoiceIds = {};
scene.campaign.acknowledgedVictoryRewardBattleIds = [battleId];
scene.campaign.acknowledgedVictoryRewardCategories = {};
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId];
delete scene.campaign.pendingAftermathBattleId;
scene.report = report;
scene.persistSortieSelection();
return {
saved: true,
latestBattleId: scene.campaign.latestBattleId,
reportBattleId: scene.campaign.firstBattleReport?.battleId,
gold: scene.campaign.gold
};
}, {
battleId: xuzhouBattleId,
battleTitle: '서주 구원전',
gold: seedGold
});
assert.deepEqual(
result,
{
saved: true,
latestBattleId: xuzhouBattleId,
reportBattleId: xuzhouBattleId,
gold: seedGold
},
`Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}`
);
}
async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) {
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction((expectedId) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.cityStay?.id === expectedId &&
camp?.cityStay?.active === true
);
}, expectedCityStayId, { timeout: 90000 });
}
async function verifyAdditionalCityLayouts(page) {
for (const visualCase of additionalCityVisualCases) {
await openCampAboveBattle(page);
const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold });
assert.deepEqual(
seedResult,
{
saved: true,
latestBattleId: visualCase.afterBattleId,
reportBattleId: visualCase.afterBattleId,
cityStayId: visualCase.cityStayId
},
`Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}`
);
await restartCamp(page, visualCase.cityStayId);
const state = await waitForCityStay(page, visualCase.cityStayId);
verifyCityStayLayout(state);
assert.equal(state.cityStay.name, visualCase.cityName);
assert.equal(state.cityStay.information.completed, false);
assert.equal(state.cityStay.dialogue.completed, false);
await captureStableScreenshot(page, visualCase.screenshotPath);
await selectCityLocation(page, 'market');
const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(marketState.cityStay.market.offers.length, 3);
marketState.cityStay.market.offers.forEach((offer) => {
assert(offer.buyBounds && offer.interactive && offer.canBuy);
assertBoundsInside(
offer.buyBounds,
marketState.cityStay.panelBounds,
`${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.`
);
});
assertNoOverlappingBounds(
marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })),
`${visualCase.cityName} market offer buttons`
);
await captureStableScreenshot(
page,
visualCase.screenshotPath.replace('-information.png', '-market.png')
);
await selectCityLocation(page, 'dialogue');
const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(dialogueState.cityStay.dialogue.choices.length, 2);
dialogueState.cityStay.dialogue.choices.forEach((choice) => {
assert(choice.bounds && choice.interactive);
assertBoundsInside(
choice.bounds,
dialogueState.cityStay.panelBounds,
`${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.`
);
});
assertNoOverlappingBounds(
dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })),
`${visualCase.cityName} resonance choice buttons`
);
await captureStableScreenshot(
page,
visualCase.screenshotPath.replace('-information.png', '-dialogue.png')
);
await selectCityLocation(page, 'information');
const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const informationResult = await verifyInformationReward(page, functionalInitialState);
const marketResult = await verifyMarketPurchase(page, null);
const dialogueResult = await verifyCompanionDialogue(page, null);
await restartCamp(page, visualCase.cityStayId);
const restoredState = await waitForCityStay(page, visualCase.cityStayId);
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
await verifyNextBattleBriefing(
page,
restoredState.cityStay.information.briefingLines,
visualCase.nextBattleId,
visualCase.cityName
);
}
}
async function openCampAboveBattle(page) {
await page.evaluate(async () => {
const game = window.__HEROS_GAME__;
if (!game || !window.__HEROS_DEBUG__) {
throw new Error('The game debug API is unavailable while opening CampScene.');
}
if (game.scene.isActive('BattleScene')) {
game.scene.stop('BattleScene');
}
await window.__HEROS_DEBUG__.goToCamp();
game.scene.bringToTop('CampScene');
});
await page.waitForFunction(() => (
!window.__HEROS_GAME__?.scene.isActive('BattleScene') &&
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene'
), undefined, { timeout: 90000 });
}
async function seedCityStayPreview(page, visualCase) {
return page.evaluate((config) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') {
return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' };
}
const now = new Date().toISOString();
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
if (!templateUnit) {
return { saved: false, reason: 'No unit template is available for the city preview.' };
}
const liuBei = {
...structuredClone(templateUnit),
id: 'liu-bei',
name: '유비'
};
const partner = {
...structuredClone(templateUnit),
id: config.partnerId,
name: config.partnerName
};
const bond = {
id: config.bondId,
unitIds: ['liu-bei', config.partnerId],
title: config.bondTitle,
level: 42,
exp: 0,
battleExp: 0,
description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.`
};
const report = {
...scene.report,
battleId: config.afterBattleId,
battleTitle: config.battleTitle,
outcome: 'victory',
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [liuBei, partner],
bonds: [bond],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: now
};
delete report.campaignRewards;
scene.campaign.step = config.campStep;
scene.campaign.gold = config.gold;
scene.campaign.latestBattleId = config.afterBattleId;
scene.campaign.roster = [liuBei, partner];
scene.campaign.bonds = [bond];
scene.campaign.firstBattleReport = report;
scene.campaign.battleHistory = {
[config.afterBattleId]: {
battleId: config.afterBattleId,
battleTitle: config.battleTitle,
outcome: 'victory',
turnNumber: report.turnNumber,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [],
bonds: [],
completedAt: now
}
};
scene.campaign.completedCampDialogues = [];
scene.campaign.completedCampVisits = [];
scene.campaign.campDialogueChoiceIds = {};
scene.campaign.campVisitChoiceIds = {};
scene.campaign.acknowledgedVictoryRewardBattleIds = [config.afterBattleId];
scene.campaign.acknowledgedVictoryRewardCategories = {};
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId];
delete scene.campaign.pendingAftermathBattleId;
scene.report = report;
scene.persistSortieSelection();
const cityStayId = scene.currentCityStay?.()?.id ?? null;
return {
saved: true,
latestBattleId: scene.campaign.latestBattleId,
reportBattleId: scene.campaign.firstBattleReport?.battleId,
cityStayId
};
}, visualCase);
}
async function waitForCityStay(page, expectedId) {
await page.waitForFunction((cityStayId) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
camp?.scene === 'CampScene' &&
camp?.cityStay?.id === cityStayId &&
camp?.cityStay?.active === true &&
camp?.cityStay?.panelBounds &&
camp?.campTabs?.length === 7
);
}, expectedId, { timeout: 90000 });
return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
}
async function waitForXuzhouCityStay(page) {
return waitForCityStay(page, xuzhouCityStayId);
}
function verifyCityStayLayout(state) {
assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.');
assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.');
assert(
state.campTabs.every((tab) => tab.bounds && tab.interactive),
`Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}`
);
assertNoOverlappingBounds(
state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })),
'CampScene tabs'
);
const sceneBounds = state.campSkin?.sceneBounds ?? {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
};
assertBoundsInside(
state.cityStay.panelBounds,
sceneBounds,
`The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.`
);
state.campTabs.forEach((tab) => {
assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`);
});
}
async function verifyInformationReward(page, initialState) {
const information = initialState.cityStay.information;
assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.');
assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.');
const reward = parseInventoryReward(information.itemReward);
const beforeCount = initialState.campaign.inventory[reward.label] ?? 0;
await clickSceneBounds(page, 'CampScene', information.actionBounds);
await page.waitForFunction(({ visitId, label, expectedCount }) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
camp?.cityStay?.information?.id === visitId &&
camp.cityStay.information.completed === true &&
(camp.campaign?.inventory?.[label] ?? 0) === expectedCount
);
}, {
visitId: information.id,
label: reward.label,
expectedCount: beforeCount + reward.amount
});
const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const cityStay = scene?.currentCityStay?.();
if (!scene || !cityStay) {
throw new Error('Xuzhou city stay disappeared before the idempotency probe.');
}
scene.gatherCityInformation(cityStay);
});
await page.waitForTimeout(100);
const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(
afterRepeatedClaim.campaign.inventory[reward.label] ?? 0,
afterFirstClaim.campaign.inventory[reward.label] ?? 0,
'Calling the completed information action again must not duplicate its inventory reward.'
);
assert.equal(
afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length,
1,
'The information completion record must remain unique.'
);
return {
id: information.id,
inventoryLabel: reward.label,
inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0
};
}
async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') {
await selectCityLocation(page, 'market');
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (screenshotPath) {
await captureStableScreenshot(page, screenshotPath);
}
const offer = before.cityStay.market.offers[0];
assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.');
const goldBefore = before.cityStay.market.gold;
const ownedBefore = offer.owned;
await clickSceneBounds(page, 'CampScene', offer.buyBounds);
await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => {
const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market;
const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId);
return market?.gold === expectedGold && currentOffer?.owned === expectedOwned;
}, {
offerId: offer.id,
expectedGold: goldBefore - offer.price,
expectedOwned: ownedBefore + 1
});
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id);
assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.');
assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.');
return {
offerId: offer.id,
itemId: offer.itemId,
itemName: offer.itemName,
gold: after.cityStay.market.gold,
owned: purchasedOffer.owned
};
}
async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') {
await selectCityLocation(page, 'dialogue');
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (screenshotPath) {
await captureStableScreenshot(page, screenshotPath);
}
const choice = before.cityStay.dialogue.choices[0];
assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.');
await clickSceneBounds(page, 'CampScene', choice.bounds);
await page.waitForTimeout(150);
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id &&
after.cityStay.dialogue.completed === true &&
after.cityStay.dialogue.choiceId === choice.id,
`The companion choice did not complete synchronously: ${JSON.stringify({
before: before.cityStay.dialogue,
after: after?.cityStay?.dialogue,
campaignBonds: after?.report?.bonds
})}`
);
assert.equal(
after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length,
1,
'The companion dialogue completion record must be unique.'
);
assert.equal(
after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id],
choice.id,
'The selected companion response must be saved by choice ID.'
);
return {
id: before.cityStay.dialogue.id,
choiceId: choice.id
};
}
async function selectCityLocation(page, locationId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const location = state.cityStay.locations.find((candidate) => candidate.id === locationId);
assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`);
await clickSceneBounds(page, 'CampScene', location.bounds);
await page.waitForFunction((expectedId) => (
window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId
), locationId);
}
function verifyRestoredOutcomes(state, information, market, dialogue) {
assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.');
assert.equal(
state.campaign.inventory[information.inventoryLabel] ?? 0,
information.inventoryCount,
'The one-time information reward count must survive a CampScene restart.'
);
const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId);
assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.');
assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.');
assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.');
assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.');
assert.equal(
state.campaign.campDialogueChoiceIds[dialogue.id],
dialogue.choiceId,
'The persisted companion choice history must match the selected response.'
);
}
async function verifyNextBattleBriefing(
page,
expectedBriefingLines,
nextBattleId = xiaopeiBattleId,
cityName = '서주'
) {
await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), nextBattleId);
await page.waitForFunction((battleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('BattleScene') &&
battle?.scene === 'BattleScene' &&
battle?.battleId === battleId &&
battle?.cityInformation
);
}, nextBattleId, { timeout: 90000 });
let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`);
assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`);
assert.deepEqual(
battle.cityInformation.briefingLines,
expectedBriefingLines,
`${nextBattleId} must expose the exact authored ${cityName} briefing lines.`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.showOpeningBattleEvent?.();
});
await page.waitForFunction(() => {
const battleState = window.__HEROS_DEBUG__?.battle();
return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0;
});
battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const openingEvent = battle.activeBattleEvent?.key === 'opening'
? battle.activeBattleEvent
: battle.pendingBattleEvents?.find((event) => event.key === 'opening');
const briefingRecordedInLog = expectedBriefingLines.every((line) => (
battle.battleLog?.some((entry) => entry.includes(line))
));
assert(
(openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog,
`The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({
openingEvent,
battleLog: battle.battleLog?.slice(-4)
})}`
);
}
function assertNoOverlappingBounds(entries, label) {
for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
const left = entries[leftIndex];
const right = entries[rightIndex];
const overlapWidth = Math.min(left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width) -
Math.max(left.bounds.x, right.bounds.x);
const overlapHeight = Math.min(left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height) -
Math.max(left.bounds.y, right.bounds.y);
assert(
overlapWidth <= 0 || overlapHeight <= 0,
`${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify({ left, right })}`
);
}
}
}
function assertBoundsInside(bounds, container, message) {
const epsilon = 0.01;
assert(
bounds.x >= container.x - epsilon &&
bounds.y >= container.y - epsilon &&
bounds.x + bounds.width <= container.x + container.width + epsilon &&
bounds.y + bounds.height <= container.y + container.height + epsilon,
`${message} ${JSON.stringify({ bounds, container })}`
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined
), undefined, { timeout: 90000 });
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
visualScale: window.visualViewport?.scale ?? 1,
canvas: canvas ? { width: canvas.width, height: canvas.height } : null
};
});
assert.deepEqual(
viewport,
{
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
visualScale: 1,
canvas: {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
}
},
'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.'
);
}
async function clickSceneBounds(page, sceneKey, bounds) {
const point = await page.evaluate(({ requestedSceneKey, requestedBounds }) => {
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedBounds) {
return null;
}
return {
x: canvasBounds.left +
(requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width,
y: canvasBounds.top +
(requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height
};
}, {
requestedSceneKey: sceneKey,
requestedBounds: bounds
});
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`);
await page.mouse.click(point.x, point.y);
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(300);
const loopSlept = await page.evaluate(() => {
const loop = window.__HEROS_GAME__?.loop;
if (!loop || typeof loop.sleep !== 'function') {
return false;
}
loop.sleep();
return true;
});
await page.waitForTimeout(50);
try {
await page.screenshot({ path, fullPage: true });
} finally {
if (loopSlept) {
await page.evaluate(() => window.__HEROS_GAME__?.loop.wake());
await page.waitForTimeout(50);
}
}
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
[
'node_modules/vite/bin/vite.js',
'--host',
'127.0.0.1',
'--port',
parsed.port || '41795',
'--strictPort'
],
{
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
);
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseInventoryReward(reward) {
const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward);
assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`);
return { label: match[1], amount: Number(match[2]) };
}

View File

@@ -0,0 +1,252 @@
import { createServer } from 'vite';
const expectedCityStayIds = ['xuzhou', 'xinye', 'chengdu'];
const errors = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const {
cityStayDefinitions,
findCityStayAfterBattle,
findCityStayBeforeBattle,
getCityStayDefinition
} = await server.ssrLoadModule('/src/game/data/cityStays.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
validateLookupHelpers(
cityStayDefinitions,
getCityStayDefinition,
findCityStayAfterBattle,
findCityStayBeforeBattle
);
if (errors.length > 0) {
throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
console.log(
`Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.`
);
} finally {
await server.close();
}
function validateCityStayCollection(definitions, battleScenarios, itemCatalog) {
if (!Array.isArray(definitions) || definitions.length !== 3) {
errors.push(`cityStayDefinitions must contain exactly 3 entries (found ${definitions?.length ?? 'invalid'})`);
return;
}
const actualIds = definitions.map((definition) => definition.id);
if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) {
errors.push(`city stay ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`);
}
const battleOrder = Object.keys(battleScenarios);
const seenCityIds = new Set();
const seenVisitIds = new Set();
const seenDialogueIds = new Set();
const seenChoiceIds = new Set();
const seenOfferIds = new Set();
definitions.forEach((definition, index) => {
const context = `cityStayDefinitions[${index}]/${definition?.id ?? 'unknown'}`;
assertUniqueNonEmptyString(definition?.id, seenCityIds, `${context}: id`);
validateCityMeta(definition?.city, context);
validateBattleLink(definition, context, battleScenarios, battleOrder);
validateInformationVisit(definition?.information, context, seenVisitIds);
validateDialogue(definition?.dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds);
validateEquipmentOffers(definition?.equipmentOffers, context, itemCatalog, seenOfferIds);
});
}
function validateCityMeta(city, context) {
if (!city || typeof city !== 'object') {
errors.push(`${context}: city metadata is missing`);
return;
}
['name', 'region', 'title', 'description'].forEach((field) => {
assertNonEmptyString(city[field], `${context}: city.${field}`);
});
}
function validateBattleLink(definition, context, battleScenarios, battleOrder) {
const afterScenario = battleScenarios[definition.afterBattleId];
const nextScenario = battleScenarios[definition.nextBattleId];
if (!afterScenario) {
errors.push(`${context}: unknown afterBattleId "${definition.afterBattleId}"`);
}
if (!nextScenario) {
errors.push(`${context}: unknown nextBattleId "${definition.nextBattleId}"`);
}
if (!afterScenario || !nextScenario) {
return;
}
const afterIndex = battleOrder.indexOf(definition.afterBattleId);
const nextIndex = battleOrder.indexOf(definition.nextBattleId);
if (afterIndex < 0 || nextIndex !== afterIndex + 1) {
errors.push(
`${context}: next battle must immediately follow after battle (${definition.afterBattleId} -> ${definition.nextBattleId})`
);
}
if (afterScenario.campaignReward?.unlockBattleId !== definition.nextBattleId) {
errors.push(
`${context}: after battle unlock "${afterScenario.campaignReward?.unlockBattleId ?? 'none'}" does not match nextBattleId`
);
}
}
function validateInformationVisit(information, context, seenVisitIds) {
if (!information || typeof information !== 'object') {
errors.push(`${context}: information visit is missing`);
return;
}
assertUniqueNonEmptyString(information.id, seenVisitIds, `${context}: information.id`);
['title', 'location', 'description', 'itemReward'].forEach((field) => {
assertNonEmptyString(information[field], `${context}: information.${field}`);
});
if (!Array.isArray(information.briefingLines) || information.briefingLines.length < 1 || information.briefingLines.length > 2) {
errors.push(`${context}: information.briefingLines must contain 1 or 2 lines`);
} else {
information.briefingLines.forEach((line, lineIndex) => {
assertNonEmptyString(line, `${context}: information.briefingLines[${lineIndex}]`);
});
}
if (!/^(.*?)\s+([1-9]\d*)$/.test(information.itemReward ?? '')) {
errors.push(`${context}: information.itemReward must end with a positive amount`);
}
}
function validateDialogue(dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds) {
if (!dialogue || typeof dialogue !== 'object') {
errors.push(`${context}: resonance dialogue is missing`);
return;
}
assertUniqueNonEmptyString(dialogue.id, seenDialogueIds, `${context}: dialogue.id`);
assertNonEmptyString(dialogue.title, `${context}: dialogue.title`);
assertNonEmptyString(dialogue.bondId, `${context}: dialogue.bondId`);
assertPositiveInteger(dialogue.rewardExp, `${context}: dialogue.rewardExp`);
if (
!Array.isArray(dialogue.unitIds) ||
dialogue.unitIds.length !== 2 ||
!dialogue.unitIds.every((unitId) => typeof unitId === 'string' && unitId.trim().length > 0) ||
dialogue.unitIds[0] === dialogue.unitIds[1]
) {
errors.push(`${context}: dialogue.unitIds must contain two distinct unit ids`);
}
if (!Array.isArray(dialogue.lines) || dialogue.lines.length === 0) {
errors.push(`${context}: dialogue.lines must not be empty`);
} else {
dialogue.lines.forEach((line, lineIndex) => {
assertNonEmptyString(line, `${context}: dialogue.lines[${lineIndex}]`);
});
}
if (!Array.isArray(dialogue.choices) || dialogue.choices.length !== 2) {
errors.push(`${context}: dialogue.choices must contain exactly 2 entries`);
} else {
dialogue.choices.forEach((choice, choiceIndex) => {
const choiceContext = `${context}: dialogue.choices[${choiceIndex}]`;
assertUniqueNonEmptyString(choice.id, seenChoiceIds, `${choiceContext}.id`);
assertNonEmptyString(choice.label, `${choiceContext}.label`);
assertNonEmptyString(choice.response, `${choiceContext}.response`);
assertPositiveInteger(choice.rewardExp, `${choiceContext}.rewardExp`);
});
}
const nextScenario = battleScenarios[definition.nextBattleId];
const bond = nextScenario?.bonds?.find((candidate) => candidate.id === dialogue.bondId);
if (!bond) {
errors.push(`${context}: dialogue bond "${dialogue.bondId}" is not available in the next battle`);
} else if (
!Array.isArray(dialogue.unitIds) ||
dialogue.unitIds.length !== 2 ||
!dialogue.unitIds.every((unitId) => bond.unitIds.includes(unitId))
) {
errors.push(
`${context}: dialogue unit ids ${JSON.stringify(dialogue.unitIds)} do not match bond ${JSON.stringify(bond.unitIds)}`
);
}
}
function validateEquipmentOffers(offers, context, itemCatalog, seenOfferIds) {
if (!Array.isArray(offers) || offers.length !== 3) {
errors.push(`${context}: equipmentOffers must contain exactly 3 entries`);
return;
}
const cityItemIds = new Set();
offers.forEach((offer, offerIndex) => {
const offerContext = `${context}: equipmentOffers[${offerIndex}]`;
assertUniqueNonEmptyString(offer?.id, seenOfferIds, `${offerContext}.id`);
assertNonEmptyString(offer?.itemId, `${offerContext}.itemId`);
assertPositiveInteger(offer?.price, `${offerContext}.price`);
if (cityItemIds.has(offer?.itemId)) {
errors.push(`${offerContext}: duplicate city item "${offer?.itemId}"`);
}
cityItemIds.add(offer?.itemId);
const item = itemCatalog[offer?.itemId];
if (!item) {
errors.push(`${offerContext}: unknown equipment item "${offer?.itemId}"`);
return;
}
if (item.rank !== 'common') {
errors.push(`${offerContext}: item "${offer.itemId}" must have common rank (found ${item.rank})`);
}
if (offer.slot !== item.slot) {
errors.push(`${offerContext}: slot "${offer.slot}" does not match catalog slot "${item.slot}"`);
}
});
}
function validateLookupHelpers(
definitions,
getCityStayDefinition,
findCityStayAfterBattle,
findCityStayBeforeBattle
) {
definitions.forEach((definition) => {
if (getCityStayDefinition(definition.id) !== definition) {
errors.push(`getCityStayDefinition("${definition.id}") did not return the canonical definition`);
}
if (findCityStayAfterBattle(definition.afterBattleId) !== definition) {
errors.push(`findCityStayAfterBattle("${definition.afterBattleId}") did not return ${definition.id}`);
}
if (findCityStayBeforeBattle(definition.nextBattleId) !== definition) {
errors.push(`findCityStayBeforeBattle("${definition.nextBattleId}") did not return ${definition.id}`);
}
});
}
function assertUniqueNonEmptyString(value, seen, context) {
assertNonEmptyString(value, context);
if (typeof value !== 'string' || value.trim().length === 0) {
return;
}
if (seen.has(value)) {
errors.push(`${context}: duplicate value "${value}"`);
}
seen.add(value);
}
function assertNonEmptyString(value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
}
}
function assertPositiveInteger(value, context) {
if (!Number.isInteger(value) || value <= 0) {
errors.push(`${context} must be a positive integer (found ${value})`);
}
}

View File

@@ -235,7 +235,11 @@ try {
await clickBattleDeploymentStart(page);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.battleId === 'first-battle-zhuo-commandery' && state?.phase === 'idle' && state?.triggeredBattleEvents?.includes('opening');
return (
state?.battleId === 'first-battle-zhuo-commandery' &&
state?.phase === 'idle' &&
state?.activeBattleEvent?.key === 'opening'
);
}, undefined, { timeout: 30000 });
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
await assertCanvasPainted(page, 'first battle');
@@ -3829,7 +3833,16 @@ try {
);
await advanceSortiePrepStep(page, 'loadout');
await clickLegacyUi(page, 1116, 656);
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit', { startDeployment: false });
await clickBattleDeploymentStart(page);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.battleId === 'second-battle-yellow-turban-pursuit' &&
state?.phase === 'idle' &&
state?.activeBattleEvent?.key === 'opening'
);
}, undefined, { timeout: 30000 });
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'second battle from first camp');

View File

@@ -21,6 +21,7 @@ const checks = [
'scripts/verify-battle-scenario-data.mjs',
'scripts/verify-narrative-continuity.mjs',
'scripts/verify-camp-reward-data.mjs',
'scripts/verify-city-stay-data.mjs',
'scripts/verify-victory-reward-acknowledgements.mjs',
'scripts/verify-camp-skin-data.mjs',
'scripts/verify-camp-soundscape-data.mjs',

233
src/game/data/cityStays.ts Normal file
View File

@@ -0,0 +1,233 @@
import type { EquipmentSlot } from './battleItems';
import type { BattleScenarioId } from './battles';
export type CityStayId = 'xuzhou' | 'xinye' | 'chengdu';
export type CityStayMeta = {
name: string;
region: string;
title: string;
description: string;
};
export type CityInformationVisit = {
id: string;
title: string;
location: string;
description: string;
briefingLines: readonly [string] | readonly [string, string];
itemReward: string;
};
export type CityResonanceDialogueChoice = {
id: string;
label: string;
response: string;
rewardExp: number;
};
export type CityResonanceDialogue = {
id: string;
title: string;
unitIds: readonly [string, string];
bondId: string;
rewardExp: number;
lines: readonly string[];
choices: readonly [CityResonanceDialogueChoice, CityResonanceDialogueChoice];
};
export type CityEquipmentOffer = {
id: string;
itemId: string;
slot: EquipmentSlot;
price: number;
};
export type CityStayDefinition = {
id: CityStayId;
city: CityStayMeta;
afterBattleId: BattleScenarioId;
nextBattleId: BattleScenarioId;
information: CityInformationVisit;
dialogue: CityResonanceDialogue;
equipmentOffers: readonly [CityEquipmentOffer, CityEquipmentOffer, CityEquipmentOffer];
};
export const cityStayDefinitions: readonly CityStayDefinition[] = [
{
id: 'xuzhou',
city: {
name: '서주',
region: '서주성',
title: '서주의 첫 성내 체류',
description: '구원전 직후 성내의 소문과 보급 사정을 살피고, 새로 합류한 미축과 다음 방위선을 준비합니다.'
},
afterBattleId: 'seventh-battle-xuzhou-rescue',
nextBattleId: 'eighth-battle-xiaopei-supply-road',
information: {
id: 'city-xuzhou-xiaopei-ledger',
title: '소패 보급로 장부 확인',
location: '서주 관청',
description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.',
briefingLines: [
'소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.',
'습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.'
],
itemReward: '소패 보급로 장부 1'
},
dialogue: {
id: 'city-xuzhou-liu-mi-supply-trust',
title: '서주의 창고를 맡기다',
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 12,
lines: [
'미축: 성을 구한 기세만으로는 서주를 지킬 수 없습니다. 상인과 창고지기의 신뢰를 함께 묶어야 합니다.',
'유비: 백성의 곡식을 군의 공으로 돌리지 않겠소. 자중의 장부를 믿고 다음 길을 맡기겠소.'
],
choices: [
{
id: 'city-xuzhou-share-storehouse-duty',
label: '창고와 백성의 몫을 함께 지킨다',
response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.',
rewardExp: 10
},
{
id: 'city-xuzhou-trust-mi-zhu-ledger',
label: '보급 판단을 미축에게 맡긴다',
response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.',
rewardExp: 12
}
]
},
equipmentOffers: [
{ id: 'city-xuzhou-iron-spear', itemId: 'iron-spear', slot: 'weapon', price: 620 },
{ id: 'city-xuzhou-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 680 },
{ id: 'city-xuzhou-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 360 }
]
},
{
id: 'xinye',
city: {
name: '신야',
region: '형주 북부',
title: '신야의 출진 준비',
description: '융중 방문을 마친 뒤 신야의 객사와 장터를 돌며 박망파의 지형을 확인하고 제갈량의 첫 지휘를 준비합니다.'
},
afterBattleId: 'seventeenth-battle-wolong-visit-road',
nextBattleId: 'eighteenth-battle-bowang-ambush',
information: {
id: 'city-xinye-bowang-scout-map',
title: '박망파 숲길 정찰',
location: '신야 객사',
description: '나무꾼과 피난민이 기억하는 숲길을 정찰표와 맞추어 화공 매복에 쓸 수 있는 좁은 길을 찾습니다.',
briefingLines: [
'박망파 중앙 숲길은 적을 깊이 끌어들인 뒤 화공으로 퇴로를 끊기 좋은 지형입니다.',
'서쪽 진영에서 성급히 밀지 말고 조운의 기동과 관우·장비의 길목 차단을 맞추어야 합니다.'
],
itemReward: '박망파 매복 정찰도 1'
},
dialogue: {
id: 'city-xinye-liu-zhuge-first-command',
title: '와룡의 첫 군령',
unitIds: ['liu-bei', 'zhuge-liang'],
bondId: 'liu-bei__zhuge-liang',
rewardExp: 14,
lines: [
'제갈량: 하후돈의 기세를 정면에서 꺾기보다, 그 기세가 스스로 숲 안에 갇히게 해야 합니다.',
'유비: 군사께 첫 지휘를 맡기겠소. 장수들이 계책의 뜻까지 이해하도록 내가 곁에서 힘을 보태겠소.'
],
choices: [
{
id: 'city-xinye-explain-strategy-to-officers',
label: '장수들에게 계책의 뜻을 설명한다',
response: '제갈량은 유비가 장수들의 신뢰를 먼저 묶어 주자, 매복 순서와 퇴로까지 차분히 풀어 설명했습니다.',
rewardExp: 12
},
{
id: 'city-xinye-entrust-first-command',
label: '첫 군령을 온전히 맡긴다',
response: '제갈량은 유비의 결단을 받아 들고 박망파의 바람과 불길을 한 장의 작전도로 정리했습니다.',
rewardExp: 14
}
]
},
equipmentOffers: [
{ id: 'city-xinye-short-bow', itemId: 'short-bow', slot: 'weapon', price: 840 },
{ id: 'city-xinye-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 960 },
{ id: 'city-xinye-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 520 }
]
},
{
id: 'chengdu',
city: {
name: '성도',
region: '익주',
title: '성도 수습과 북문 준비',
description: '항복 직후 성도의 관청과 시장을 안정시키고 황권에게 가맹관과 한중 방면의 사정을 듣습니다.'
},
afterBattleId: 'thirty-third-battle-chengdu-surrender',
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
information: {
id: 'city-chengdu-jiameng-route-ledger',
title: '가맹관 산길 장부 대조',
location: '성도 임시 관청',
description: '황권이 보관한 북문 장부와 상단의 이동 기록을 대조해 가맹관으로 향하는 산길과 보급 거점을 확인합니다.',
briefingLines: [
'가맹관은 좁은 산길과 보급로가 맞물려 서량 기병의 첫 돌격을 흘려낼 자리가 제한됩니다.',
'황권을 지키며 척후선과 보급로를 안정시키면 마초에게 유비군의 군율을 보여 줄 수 있습니다.'
],
itemReward: '가맹관 산길 장부 1'
},
dialogue: {
id: 'city-chengdu-liu-huang-quan-order',
title: '항복 뒤의 첫 약속',
unitIds: ['liu-bei', 'huang-quan'],
bondId: 'liu-bei__huang-quan',
rewardExp: 16,
lines: [
'황권: 성문은 열렸지만 익주의 마음까지 열린 것은 아닙니다. 북문으로 군을 움직일수록 성도의 질서를 먼저 보여야 합니다.',
'유비: 항복한 이를 의심으로 묶지 않겠소. 공형의 판단으로 성도와 가맹관 사이의 길을 함께 세워 주시오.'
],
choices: [
{
id: 'city-chengdu-preserve-local-order',
label: '익주의 기존 질서를 존중한다',
response: '황권은 성도 관리와 창고지기를 함부로 바꾸지 않겠다는 약속을 듣고 북문 장부를 내놓았습니다.',
rewardExp: 12
},
{
id: 'city-chengdu-appoint-huang-quan-guide',
label: '황권에게 북문 준비를 맡긴다',
response: '황권은 유비의 신뢰를 받아 가맹관의 길과 서량 기병의 움직임을 직접 정리하겠다고 답했습니다.',
rewardExp: 14
}
]
},
equipmentOffers: [
{ id: 'city-chengdu-western-spear', itemId: 'western-cavalry-spear', slot: 'weapon', price: 1880 },
{ id: 'city-chengdu-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 1640 },
{ id: 'city-chengdu-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 920 }
]
}
];
const cityStayDefinitionById = new Map(cityStayDefinitions.map((definition) => [definition.id, definition]));
const cityStayDefinitionByAfterBattleId = new Map(
cityStayDefinitions.map((definition) => [definition.afterBattleId, definition])
);
const cityStayDefinitionByNextBattleId = new Map(
cityStayDefinitions.map((definition) => [definition.nextBattleId, definition])
);
export function getCityStayDefinition(id: CityStayId): CityStayDefinition {
return cityStayDefinitionById.get(id)!;
}
export function findCityStayAfterBattle(battleId: BattleScenarioId): CityStayDefinition | undefined {
return cityStayDefinitionByAfterBattleId.get(battleId);
}
export function findCityStayBeforeBattle(battleId: BattleScenarioId): CityStayDefinition | undefined {
return cityStayDefinitionByNextBattleId.get(battleId);
}

View File

@@ -29,6 +29,7 @@ import {
resolveCampaignPresentationSoundscape
} from '../data/campaignPresentationProfiles';
import { getSortieFlow } from '../data/campaignFlow';
import { findCityStayBeforeBattle } from '../data/cityStays';
import {
enemyIntentOpeningGuide,
isEnemyIntentCounterplayAvailable,
@@ -17555,9 +17556,39 @@ export class BattleScene extends Phaser.Scene {
return false;
}
private cityInformationDebugState(campaign: CampaignState = getCampaignState()) {
const cityStay = findCityStayBeforeBattle(battleScenario.id);
const completed = Boolean(
cityStay &&
campaign.completedCampVisits.includes(cityStay.information.id)
);
return {
available: Boolean(cityStay),
completed,
cityId: cityStay?.id ?? null,
name: cityStay?.city.name ?? null,
visitId: cityStay?.information.id ?? null,
briefingLines: cityStay ? [...cityStay.information.briefingLines] : []
};
}
private completedCityInformationBriefingLines(campaign: CampaignState = getCampaignState()) {
const cityInformation = this.cityInformationDebugState(campaign);
if (!cityInformation.completed || !cityInformation.name) {
return [];
}
return [
`도시 정보 · ${cityInformation.name}`,
...cityInformation.briefingLines
];
}
private showOpeningBattleEvent() {
const guide = battleScenario.tacticalGuide;
const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines;
const cityInformationLines = this.completedCityInformationBriefingLines();
if (battleScenario.sortie) {
const roleLines = this.sortieOpeningLines();
const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세';
@@ -17566,6 +17597,7 @@ export class BattleScene extends Phaser.Scene {
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
: []),
...cityInformationLines,
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
...(battleScenario.id === 'first-battle-zhuo-commandery'
? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택']
@@ -17574,7 +17606,10 @@ export class BattleScene extends Phaser.Scene {
], { priority: 'critical' });
return;
}
this.triggerBattleEvent('opening', '작전 목표', objectiveLines, { priority: 'critical' });
this.triggerBattleEvent('opening', '작전 목표', [
...objectiveLines,
...cityInformationLines
], { priority: 'critical' });
}
private triggerTacticalEvent(key: BattleTacticalGuideEventKey, fallbackTitle: string, fallbackLines: string[]) {
@@ -17724,7 +17759,11 @@ export class BattleScene extends Phaser.Scene {
}
const maxBodyLines = 3;
const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42));
const cityInformationIndex = lines.findIndex((line) => line.startsWith('도시 정보 ·'));
const bannerLines = cityInformationIndex >= maxBodyLines
? [lines[0], ...lines.slice(cityInformationIndex, cityInformationIndex + 2)]
: lines;
const bodyLines = bannerLines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42));
const width = 540;
const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126);
const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2);
@@ -17871,7 +17910,9 @@ export class BattleScene extends Phaser.Scene {
private showSortieDoctrineBanner(event: BattleSavePendingEvent) {
const { title, lines } = event;
const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines());
const doctrineLines = lines.filter((line) => (
completedCityInformationLines.has(line) ||
line.startsWith('도원 공명') ||
line.startsWith('공명 ') ||
line.startsWith('적 의도') ||
@@ -28677,6 +28718,7 @@ export class BattleScene extends Phaser.Scene {
battleId: battleScenario.id,
battleTitle: battleScenario.title,
environment: this.battleEnvironmentDebugState(),
cityInformation: this.cityInformationDebugState(campaign),
victoryConditionLabel: battleScenario.victoryConditionLabel,
defeatConditionLabel: battleScenario.defeatConditionLabel,
selectedSortieUnitIds: effectiveSortieUnitIds,
@@ -29051,7 +29093,7 @@ export class BattleScene extends Phaser.Scene {
active: Boolean(battleScenario.sortie),
battleId: battleScenario.id,
coveredRoleCount: groups.filter((group) => group.units.length > 0).length,
openingBannerShown: this.triggeredBattleEvents.has('opening'),
openingBannerShown: this.activeBattleEvent?.key === 'opening' || this.triggeredBattleEvents.has('opening'),
roles: groups.map(({ role, definition, units }) => ({
role,
label: definition.label,
@@ -29115,7 +29157,10 @@ export class BattleScene extends Phaser.Scene {
fullCoverage: this.firstPursuitTrinityConfigured(),
trinityActive: this.firstPursuitTrinityActive(),
trinitySupportRange: this.firstPursuitTrinityActive() ? 2 : 1,
openingBannerShown: this.isFirstPursuitRoleEffectBattle() && this.triggeredBattleEvents.has('opening'),
openingBannerShown: this.isFirstPursuitRoleEffectBattle() && (
this.activeBattleEvent?.key === 'opening' ||
this.triggeredBattleEvents.has('opening')
),
roles: this.firstPursuitRoleAssignments().map(({ role, unit, definition }) => ({
unitId: unit.id,
name: unit.name,

View File

@@ -10,6 +10,7 @@ import {
equipmentSlots,
findItemByName,
getItem,
itemInventoryLabel,
type EquipmentSlot,
type ItemDefinition
} from '../data/battleItems';
@@ -23,6 +24,12 @@ import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow, type SortieFlow } from '../data/campaignFlow';
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
type CityResonanceDialogueChoice,
type CityStayDefinition
} from '../data/cityStays';
import {
campaignPortraitKeysByUnitId,
portraitAssetEntriesForKey,
@@ -217,7 +224,20 @@ const campNoticeBaseHoldMs = 1800;
const campNoticeCharacterHoldMs = 55;
const campNoticeParagraphPauseMs = 500;
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress';
type CampTab = 'status' | 'city' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress';
type CityStayLocationId = 'information' | 'market' | 'dialogue';
type CityStayLocationView = {
id: CityStayLocationId;
background: Phaser.GameObjects.Rectangle;
};
type CityEquipmentOfferView = {
offerId: string;
itemId: string;
buyButton: Phaser.GameObjects.Rectangle;
};
type CampDialogue = {
id: string;
@@ -11319,6 +11339,12 @@ export class CampScene extends Phaser.Scene {
private campRosterPage = 0;
private campRosterLayout?: CampRosterLayout;
private activeTab: CampTab = 'status';
private selectedCityLocation: CityStayLocationId = 'information';
private cityPanelBackground?: Phaser.GameObjects.Rectangle;
private cityLocationViews: CityStayLocationView[] = [];
private cityInformationActionButton?: Phaser.GameObjects.Rectangle;
private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = [];
private cityEquipmentOfferViews: CityEquipmentOfferView[] = [];
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
private equipmentInventoryPage = 0;
@@ -11432,6 +11458,11 @@ export class CampScene extends Phaser.Scene {
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityLocationViews = [];
this.cityInformationActionButton = undefined;
this.cityDialogueChoiceButtons = [];
this.cityEquipmentOfferViews = [];
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -11527,6 +11558,19 @@ export class CampScene extends Phaser.Scene {
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
const cityStay = this.currentCityStay();
if (cityStay) {
const informationComplete = this.completedCampVisits().includes(cityStay.information.id);
const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id);
this.activeTab = 'city';
this.selectedCityLocation = !informationComplete
? 'information'
: !dialogueComplete
? 'dialogue'
: 'market';
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
}
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
@@ -12495,6 +12539,10 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
const cityStay = this.currentCityStay();
if (cityStay) {
return `${cityStay.city.name} 성내 체류`;
}
if (this.campaign?.step === 'shu-han-foundation-camp') {
return '촉한 건국 후 군영';
}
@@ -12709,6 +12757,24 @@ export class CampScene extends Phaser.Scene {
return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id;
}
private currentCityStay() {
if (this.retrySortieBattleId) {
return undefined;
}
return findCityStayAfterBattle(this.currentCampBattleId() as BattleScenarioId);
}
private hasPendingCityStayActions() {
const cityStay = this.currentCityStay();
return Boolean(
cityStay &&
(
!this.completedCampVisits().includes(cityStay.information.id) ||
!this.completedCampDialogues().includes(cityStay.dialogue.id)
)
);
}
private availableCampDialogues() {
const battleId = this.currentCampBattleId();
const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
@@ -12741,12 +12807,22 @@ export class CampScene extends Phaser.Scene {
}
private renderStaticButtons() {
this.addTabButton('장수', 'status', 576, 38);
this.addTabButton('대화', 'dialogue', 650, 38);
this.addTabButton('방문', 'visit', 724, 38);
this.addTabButton('보급', 'supplies', 798, 38);
this.addTabButton('장비', 'equipment', 872, 38);
this.addTabButton('연표', 'progress', 946, 38);
if (this.currentCityStay()) {
this.addTabButton('장수', 'status', 566, 38, 60, 14);
this.addTabButton('성내', 'city', 630, 38, 60, 14);
this.addTabButton('대화', 'dialogue', 694, 38, 60, 14);
this.addTabButton('방문', 'visit', 758, 38, 60, 14);
this.addTabButton('보급', 'supplies', 822, 38, 60, 14);
this.addTabButton('장비', 'equipment', 886, 38, 60, 14);
this.addTabButton('연표', 'progress', 950, 38, 60, 14);
} else {
this.addTabButton('장수', 'status', 576, 38);
this.addTabButton('대화', 'dialogue', 650, 38);
this.addTabButton('방문', 'visit', 724, 38);
this.addTabButton('보급', 'supplies', 798, 38);
this.addTabButton('장비', 'equipment', 872, 38);
this.addTabButton('연표', 'progress', 946, 38);
}
this.addCommandButton('저장', 1030, 38, 76, () => {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
@@ -13638,8 +13714,8 @@ export class CampScene extends Phaser.Scene {
this.startVictoryStory();
}
private addTabButton(label: string, tab: CampTab, x: number, y: number) {
const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, 76, 34, 0x182431, 0.94));
private addTabButton(label: string, tab: CampTab, x: number, y: number, width = 76, fontSize = 16) {
const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 34, 0x182431, 0.94));
bg.setStrokeStyle(1, palette.blue, 0.62);
bg.setInteractive({ useHandCursor: true });
@@ -13654,14 +13730,15 @@ export class CampScene extends Phaser.Scene {
const text = this.scaleLegacyCampUi(this.add.text(x, y, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
fontSize: `${fontSize}px`,
color: '#f2e3bf',
fontStyle: '700'
}));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
const newBadge = tab === 'supplies' || tab === 'equipment'
? this.scaleLegacyCampUi(this.add.text(x + 29, y - 17, 'NEW', {
const badgeLabel = tab === 'city' ? '새' : width < 70 ? 'N' : 'NEW';
const newBadge = tab === 'city' || tab === 'supplies' || tab === 'equipment'
? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, {
...this.textStyle(9, '#fff2b8', true),
backgroundColor: '#8a3f25',
padding: { x: 4, y: 2 }
@@ -13693,6 +13770,7 @@ export class CampScene extends Phaser.Scene {
indicator.setVisible(active || hovered);
indicator.setAlpha(active ? 1 : 0.66);
newBadge?.setVisible(Boolean(
(tab === 'city' && this.hasPendingCityStayActions()) ||
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
@@ -13741,6 +13819,11 @@ export class CampScene extends Phaser.Scene {
this.renderUnitColumn();
this.renderReportPanel();
if (this.activeTab === 'city') {
this.renderCityStayPanel();
return;
}
if (this.activeTab === 'dialogue') {
this.renderDialoguePanel();
return;
@@ -19899,7 +19982,7 @@ export class CampScene extends Phaser.Scene {
return;
}
if (flow.nextBattleId === campBattleIds.seventeenth && this.wolongClueCount() <= 0) {
if (flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()) {
this.hideSortiePrep();
this.activeTab = 'visit';
this.render();
@@ -22318,8 +22401,8 @@ export class CampScene extends Phaser.Scene {
const dialogues = this.availableCampDialogues();
const compactDialogueList = dialogues.length > 3;
const dialogueRowGap = compactDialogueList ? 44 : 64;
const dialogueRowHeight = compactDialogueList ? 36 : 48;
const dialogueRowGap = compactDialogueList ? 35 : 64;
const dialogueRowHeight = compactDialogueList ? 30 : 48;
if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
}
@@ -22337,9 +22420,9 @@ export class CampScene extends Phaser.Scene {
this.selectedDialogueId = dialogue.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), dialogue.title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf')));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 17 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 9 : 12, '#9fb0bf')));
});
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
@@ -22409,10 +22492,20 @@ export class CampScene extends Phaser.Scene {
}
private renderBondList(x: number, y: number, width: number) {
const bonds = this.currentBonds();
const dialogues = this.availableCampDialogues();
const relatedBondIds = new Set(dialogues.map((dialogue) => dialogue.bondId));
const relatedBonds = this.currentBonds().filter((bond) => relatedBondIds.has(bond.id));
const selectedBondId = dialogues.find((dialogue) => dialogue.id === this.selectedDialogueId)?.bondId;
const selectedBond = relatedBonds.find((bond) => bond.id === selectedBondId);
const orderedBonds = selectedBond
? [selectedBond, ...relatedBonds.filter((bond) => bond.id !== selectedBond.id)]
: relatedBonds.length > 0
? relatedBonds
: this.currentBonds();
const bonds = orderedBonds.slice(0, 4);
const compact = bonds.length > 3;
const rowGap = compact ? 25 : 36;
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
this.track(this.add.text(x, y, relatedBonds.length > 0 ? '공명 · 현재 대화' : '공명', this.textStyle(18, '#f2e3bf', true)));
bonds.forEach((bond, index) => {
const rowY = y + (compact ? 28 : 32) + index * rowGap;
const first = this.unitName(bond.unitIds[0]);
@@ -22422,13 +22515,409 @@ export class CampScene extends Phaser.Scene {
});
}
private renderCityStayPanel() {
const cityStay = this.currentCityStay();
if (!cityStay) {
this.activeTab = 'status';
this.render();
return;
}
const x = 394;
const y = 120;
const width = 828;
const height = 444;
const informationComplete = this.completedCampVisits().includes(cityStay.information.id);
const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.92));
this.cityPanelBackground = bg;
bg.setOrigin(0);
bg.setStrokeStyle(1, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.68);
this.track(this.add.text(x + 24, y + 18, cityStay.city.title, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 51, cityStay.city.description, {
...this.textStyle(13, '#d4dce6'),
wordWrap: { width: 600, useAdvancedWrap: true }
}));
const progress = this.track(this.add.text(
x + width - 24,
y + 23,
`체류 기록 ${Number(informationComplete) + Number(dialogueComplete)}/2`,
this.textStyle(13, informationComplete && dialogueComplete ? '#a8ffd0' : '#d8b15f', true)
));
progress.setOrigin(1, 0);
const locations: Array<{
id: CityStayLocationId;
mark: string;
title: string;
subtitle: string;
status: string;
complete: boolean;
}> = [
{
id: 'information',
mark: '첩',
title: '정보 수집',
subtitle: cityStay.information.location,
status: informationComplete ? '다음 전투 정보 확보' : '새 정보 확인 가능',
complete: informationComplete
},
{
id: 'market',
mark: '시',
title: '시장과 대장간',
subtitle: `${cityStay.equipmentOffers.length}종 장비`,
status: `군자금 ${this.campaign?.gold ?? 0}`,
complete: false
},
{
id: 'dialogue',
mark: '공',
title: '동료와 대화',
subtitle: this.cityDialogueUnitNames(cityStay),
status: dialogueComplete ? '공명 대화 완료' : '선택에 따라 공명 상승',
complete: dialogueComplete
}
];
this.cityLocationViews = locations.map((location, index) => {
const cardX = x + 24 + index * 266;
const cardY = y + 86;
const selected = this.selectedCityLocation === location.id;
const card = this.track(this.add.rectangle(
cardX,
cardY,
248,
70,
selected ? 0x283d50 : 0x151f2a,
selected ? 0.98 : 0.9
));
card.setOrigin(0);
card.setStrokeStyle(
selected ? 2 : 1,
location.complete ? palette.green : selected ? palette.gold : palette.blue,
selected ? 0.88 : 0.5
);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => {
if (this.selectedCityLocation !== location.id) {
card.setFillStyle(0x213343, 0.96);
}
});
card.on('pointerout', () => {
if (this.selectedCityLocation !== location.id) {
card.setFillStyle(0x151f2a, 0.9);
}
});
card.on('pointerdown', () => {
soundDirector.playSelect();
this.selectedCityLocation = location.id;
this.render();
});
const mark = this.track(this.add.circle(cardX + 29, cardY + 35, 18, 0x0d141c, 0.96));
mark.setStrokeStyle(1, location.complete ? palette.green : palette.gold, 0.72);
const markText = this.track(this.add.text(cardX + 29, cardY + 35, location.mark, this.textStyle(15, '#f2e3bf', true)));
markText.setOrigin(0.5);
this.track(this.add.text(cardX + 56, cardY + 10, location.title, this.textStyle(15, '#f2e3bf', true)));
this.track(this.add.text(cardX + 56, cardY + 31, this.compactText(location.subtitle, 20), this.textStyle(11, '#9fb0bf')));
this.track(this.add.text(
cardX + 56,
cardY + 49,
location.status,
this.textStyle(10, location.complete ? '#a8ffd0' : selected ? '#e8c978' : '#d4dce6', true)
));
return { id: location.id, background: card };
});
const detailX = x + 24;
const detailY = y + 172;
const detailWidth = width - 48;
const detailHeight = 248;
if (this.selectedCityLocation === 'market') {
this.renderCityMarket(cityStay, detailX, detailY, detailWidth, detailHeight);
return;
}
if (this.selectedCityLocation === 'dialogue') {
this.renderCityDialogue(cityStay, detailX, detailY, detailWidth, detailHeight);
return;
}
this.renderCityInformation(cityStay, detailX, detailY, detailWidth, detailHeight);
}
private renderCityInformation(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const information = cityStay.information;
const completed = this.completedCampVisits().includes(information.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54);
this.track(this.add.text(x + 20, y + 14, information.title, this.textStyle(19, '#f2e3bf', true)));
this.track(this.add.text(x + 20, y + 43, information.description, {
...this.textStyle(13, '#d4dce6'),
wordWrap: { width: width - 40, useAdvancedWrap: true },
lineSpacing: 2
}));
if (completed) {
const reveal = this.track(this.add.rectangle(x + 20, y + 91, width - 40, 102, 0x17231d, 0.96));
reveal.setOrigin(0);
reveal.setStrokeStyle(1, palette.green, 0.68);
this.track(this.add.text(x + 34, y + 103, `확보한 정보 · ${cityStay.city.name}`, this.textStyle(13, '#a8ffd0', true)));
this.track(this.add.text(x + 34, y + 128, information.briefingLines.join('\n'), {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 68, useAdvancedWrap: true },
lineSpacing: 4
}));
this.track(this.add.text(
x + 20,
y + height - 29,
`다음 전투 브리핑에 반영 · 기록 ${information.itemReward}`,
this.textStyle(12, '#d8b15f', true)
));
return;
}
this.track(this.add.text(
x + 20,
y + 103,
'관청 문서와 현지 증언을 대조하면 다음 전투의 지형과 적 움직임이 브리핑에 추가됩니다.',
{
...this.textStyle(13, '#9fb0bf'),
wordWrap: { width: width - 40, useAdvancedWrap: true }
}
));
const action = this.track(this.add.rectangle(x + width / 2, y + height - 36, 260, 38, 0x4a371d, 0.98));
this.cityInformationActionButton = action;
action.setStrokeStyle(2, palette.gold, 0.92);
action.setInteractive({ useHandCursor: true });
action.on('pointerover', () => action.setFillStyle(0x654c25, 0.99));
action.on('pointerout', () => action.setFillStyle(0x4a371d, 0.98));
action.on('pointerdown', () => this.gatherCityInformation(cityStay));
const label = this.track(this.add.text(
x + width / 2,
y + height - 36,
'성내 정보 수집',
this.textStyle(14, '#fff2b8', true)
));
label.setOrigin(0.5);
}
private renderCityMarket(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const campaign = this.campaign ?? getCampaignState();
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.52);
this.track(this.add.text(x + 20, y + 14, `${cityStay.city.name} 시장 · 군자금 ${campaign.gold}`, this.textStyle(19, '#f2e3bf', true)));
this.track(this.add.text(x + width - 20, y + 18, '일반 장비 · 구매 즉시 장비 탭에서 사용', this.textStyle(11, '#9fb0bf', true))).setOrigin(1, 0);
this.cityEquipmentOfferViews = [];
cityStay.equipmentOffers.forEach((offer, index) => {
const item = getItem(offer.itemId);
const rowY = y + 50 + index * 60;
const canBuy = campaign.gold >= offer.price;
const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0;
const row = this.track(this.add.rectangle(x + 20, rowY, width - 40, 48, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
row.setOrigin(0);
row.setStrokeStyle(1, canBuy ? palette.blue : 0x53606c, canBuy ? 0.52 : 0.3);
const iconKey = `item-${item.id}-micro`;
if (this.textures.exists(iconKey)) {
const iconFrame = this.track(this.add.rectangle(x + 42, rowY + 24, 34, 34, 0x0a1017, 0.94));
iconFrame.setStrokeStyle(1, palette.gold, 0.54);
const icon = this.track(this.add.image(x + 42, rowY + 24, iconKey));
icon.setDisplaySize(this.campUiLength(25), this.campUiLength(25));
}
this.track(this.add.text(x + 68, rowY + 7, `${item.name} · ${equipmentSlotLabels[item.slot]}`, this.textStyle(14, canBuy ? '#f2e3bf' : '#7f8994', true)));
this.track(this.add.text(
x + 68,
rowY + 27,
`${this.itemBonusText(item)} · 보유 ${owned}`,
this.textStyle(11, canBuy ? '#9fb0bf' : '#68737d')
));
this.track(this.add.text(x + width - 156, rowY + 16, `${offer.price}`, this.textStyle(13, canBuy ? '#d8b15f' : '#7f8994', true)));
const button = this.track(this.add.rectangle(x + width - 66, rowY + 24, 72, 30, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.98 : 0.72));
button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.78 : 0.36);
const buttonText = this.track(this.add.text(
x + width - 66,
rowY + 24,
canBuy ? '구매' : '부족',
this.textStyle(12, canBuy ? '#fff2b8' : '#7f8994', true)
));
buttonText.setOrigin(0.5);
if (canBuy) {
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98));
button.on('pointerdown', () => this.buyCityEquipment(offer));
}
this.cityEquipmentOfferViews.push({ offerId: offer.id, itemId: offer.itemId, buyButton: button });
});
}
private renderCityDialogue(
cityStay: CityStayDefinition,
x: number,
y: number,
width: number,
height: number
) {
const dialogue = cityStay.dialogue;
const completed = this.completedCampDialogues().includes(dialogue.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54);
this.track(this.add.text(x + 20, y + 14, dialogue.title, this.textStyle(19, '#f2e3bf', true)));
const people = this.track(this.add.text(x + width - 20, y + 18, this.cityDialogueUnitNames(cityStay), this.textStyle(12, '#d8b15f', true)));
people.setOrigin(1, 0);
this.track(this.add.text(x + 20, y + 48, dialogue.lines.join('\n'), {
...this.textStyle(12, '#d4dce6'),
wordWrap: { width: width - 40, useAdvancedWrap: true },
lineSpacing: 4
}));
if (completed) {
const choiceId = this.campaign?.campDialogueChoiceIds[dialogue.id];
const choice = dialogue.choices.find((candidate) => candidate.id === choiceId);
const memory = this.track(this.add.rectangle(x + 20, y + height - 80, width - 40, 60, 0x17231d, 0.96));
memory.setOrigin(0);
memory.setStrokeStyle(1, palette.green, 0.7);
this.track(this.add.text(
x + 34,
y + height - 69,
choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료',
this.textStyle(12, '#a8ffd0', true)
));
this.track(this.add.text(
x + 34,
y + height - 45,
choice ? this.compactText(choice.response, 88) : '이 선택은 저장되어 다시 보상을 받을 수 없습니다.',
{
...this.textStyle(11, '#d4dce6'),
wordWrap: { width: width - 68, useAdvancedWrap: true }
}
));
return;
}
this.cityDialogueChoiceButtons = dialogue.choices.map((choice, index) => {
const buttonWidth = (width - 54) / 2;
const buttonX = x + 20 + buttonWidth / 2 + index * (buttonWidth + 14);
const buttonY = y + height - 37;
const button = this.track(this.add.rectangle(buttonX, buttonY, buttonWidth, 42, 0x1a2630, 0.98));
button.setStrokeStyle(1, palette.gold, 0.78);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98));
button.on('pointerdown', () => this.completeCityDialogue(cityStay, choice));
const label = this.track(this.add.text(
buttonX,
buttonY,
`${choice.label} · 공명 +${dialogue.rewardExp + choice.rewardExp}`,
this.textStyle(12, '#fff2b8', true)
));
label.setOrigin(0.5);
return button;
});
}
private cityDialogueUnitNames(cityStay: CityStayDefinition) {
const names = cityStay.dialogue.unitIds.map((unitId) => (
this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId
));
return names.join(' · ');
}
private gatherCityInformation(cityStay: CityStayDefinition) {
if (this.completedCampVisits().includes(cityStay.information.id)) {
this.showCampNotice('이미 확보한 정보입니다.');
return;
}
const updated = applyCampVisitReward(
cityStay.information.id,
{ itemRewards: [cityStay.information.itemReward] },
'city-information-gathered'
);
if (!updated) {
this.showCampNotice('정보를 장부에 기록하지 못했습니다. 전투 결과 저장 상태를 확인해 주세요.');
return;
}
this.campaign = updated;
this.report = updated.firstBattleReport ?? this.report;
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 });
this.showCampNotice(
`정보 확보 · ${cityStay.information.title}\n다음 전투 브리핑에 ${cityStay.city.name} 현지 정보가 추가됩니다.`
);
this.render();
}
private completeCityDialogue(cityStay: CityStayDefinition, choice: CityResonanceDialogueChoice) {
const dialogue = cityStay.dialogue;
if (this.completedCampDialogues().includes(dialogue.id)) {
this.showCampNotice('이미 완료된 공명 대화입니다.');
return;
}
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id);
if (!updated) {
this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.');
return;
}
this.report = updated;
this.campaign = getCampaignState();
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
this.showCampNotice(`공명 +${rewardExp} · ${choice.label}\n${choice.response}`);
this.render();
}
private buyCityEquipment(offer: CityEquipmentOffer) {
const cityStay = this.currentCityStay();
if (!cityStay || !cityStay.equipmentOffers.some((candidate) => candidate.id === offer.id)) {
this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.');
return;
}
const item = getItem(offer.itemId);
const campaign = this.campaign ?? getCampaignState();
if (item.rank !== 'common') {
this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.');
return;
}
if (campaign.gold < offer.price) {
this.showCampNotice('군자금이 부족합니다.');
return;
}
campaign.gold -= offer.price;
const label = itemInventoryLabel(item.id);
campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1;
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
this.showCampNotice(`${cityStay.city.name} 시장 · ${item.name} 구입 · 군자금 -${offer.price}`);
this.render();
}
private renderVisitPanel() {
const x = 394;
const y = 120;
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.track(this.add.text(x + 24, y + 22, '형주 방문', this.textStyle(24, '#f2e3bf', true)));
const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지';
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));
const visits = this.availableCampVisits();
@@ -22445,11 +22934,14 @@ export class CampScene extends Phaser.Scene {
return;
}
const compactVisitList = visits.length > 3;
const visitRowGap = compactVisitList ? 54 : 66;
const visitRowHeight = compactVisitList ? 42 : 50;
visits.forEach((visit, index) => {
const completed = this.completedCampVisits().includes(visit.id);
const rowY = y + 96 + index * 66;
const rowY = y + 96 + index * visitRowGap;
const selected = this.selectedVisitId === visit.id;
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 50, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
const row = this.track(this.add.rectangle(x + 24, rowY, 320, visitRowHeight, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
row.setOrigin(0);
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
row.setInteractive({ useHandCursor: true });
@@ -22458,8 +22950,8 @@ export class CampScene extends Phaser.Scene {
this.selectedVisitId = visit.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + 8, visit.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + 29, visit.location, this.textStyle(12, '#9fb0bf')));
this.track(this.add.text(x + 38, rowY + (compactVisitList ? 5 : 8), visit.title, this.textStyle(compactVisitList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + (compactVisitList ? 23 : 29), visit.location, this.textStyle(compactVisitList ? 10 : 12, '#9fb0bf')));
});
this.renderSelectedVisit(x + 372, y + 96, 416, 300);
@@ -22537,8 +23029,11 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true)));
this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
const insightCount = this.wolongClueCount();
this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth;
const informationLabel = isWolongSearch
? `와룡 단서 ${this.hasWolongAudienceLead() ? 1 : 0}/1`
: `현지 기록 ${completed.length}`;
this.track(this.add.text(x + 14, y + 66, `${informationLabel} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
}
private renderEquipmentPanel() {
@@ -23931,6 +24426,15 @@ export class CampScene extends Phaser.Scene {
return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
}
private hasWolongAudienceLead() {
const completedVisits = this.completedCampVisits();
return (
this.wolongClueCount() > 0 ||
completedVisits.includes('jingzhou-scholar-rumors') ||
completedVisits.includes('refugee-village-patrol')
);
}
private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
track.setOrigin(0);
@@ -24025,6 +24529,11 @@ export class CampScene extends Phaser.Scene {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityLocationViews = [];
this.cityInformationActionButton = undefined;
this.cityDialogueChoiceButtons = [];
this.cityEquipmentOfferViews = [];
this.equipmentPanelBackground = undefined;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
@@ -24164,6 +24673,13 @@ export class CampScene extends Phaser.Scene {
const equipmentSwapCandidateItem = this.pendingEquipmentSwap
? getItem(this.pendingEquipmentSwap.candidateItemId)
: undefined;
const cityStay = this.currentCityStay();
const cityInformationComplete = cityStay
? this.completedCampVisits().includes(cityStay.information.id)
: false;
const cityDialogueComplete = cityStay
? this.completedCampDialogues().includes(cityStay.dialogue.id)
: false;
return {
scene: this.scene.key,
victoryRewardAcknowledgement: {
@@ -24220,6 +24736,76 @@ export class CampScene extends Phaser.Scene {
strokeColor: button.bg.strokeColor,
lineWidth: button.bg.lineWidth
})),
cityStay: cityStay
? {
available: true,
id: cityStay.id,
name: cityStay.city.name,
region: cityStay.city.region,
afterBattleId: cityStay.afterBattleId,
nextBattleId: cityStay.nextBattleId,
active: this.activeTab === 'city',
selectedLocation: this.selectedCityLocation,
panelBounds: this.sortieObjectBoundsDebug(this.cityPanelBackground),
pendingActions: Number(!cityInformationComplete) + Number(!cityDialogueComplete),
locations: this.cityLocationViews.map((view) => ({
id: view.id,
selected: this.selectedCityLocation === view.id,
bounds: this.sortieObjectBoundsDebug(view.background),
interactive: Boolean(view.background.input?.enabled)
})),
information: {
id: cityStay.information.id,
completed: cityInformationComplete,
itemReward: cityStay.information.itemReward,
briefingLines: [...cityStay.information.briefingLines],
actionBounds: this.sortieObjectBoundsDebug(this.cityInformationActionButton),
actionInteractive: Boolean(this.cityInformationActionButton?.input?.enabled)
},
dialogue: {
id: cityStay.dialogue.id,
bondId: cityStay.dialogue.bondId,
completed: cityDialogueComplete,
choiceId: this.campaign?.campDialogueChoiceIds[cityStay.dialogue.id] ?? null,
choices: cityStay.dialogue.choices.map((choice, index) => ({
id: choice.id,
label: choice.label,
rewardExp: cityStay.dialogue.rewardExp + choice.rewardExp,
bounds: this.sortieObjectBoundsDebug(this.cityDialogueChoiceButtons[index]),
interactive: Boolean(this.cityDialogueChoiceButtons[index]?.input?.enabled)
}))
},
market: {
gold: this.campaign?.gold ?? 0,
offers: cityStay.equipmentOffers.map((offer) => {
const item = getItem(offer.itemId);
const view = this.cityEquipmentOfferViews.find((candidate) => candidate.offerId === offer.id);
return {
id: offer.id,
itemId: offer.itemId,
itemName: item.name,
price: offer.price,
owned: this.campaign?.inventory[itemInventoryLabel(item.id)] ?? 0,
canBuy: (this.campaign?.gold ?? 0) >= offer.price,
buyBounds: this.sortieObjectBoundsDebug(view?.buyButton),
interactive: Boolean(view?.buyButton.input?.enabled)
};
})
}
}
: {
available: false,
id: null,
name: null,
active: false,
selectedLocation: null,
panelBounds: null,
pendingActions: 0,
locations: [],
information: null,
dialogue: null,
market: null
},
campRoster: this.campRosterLayout
? {
...this.campRosterLayout,
@@ -25115,7 +25701,9 @@ export class CampScene extends Phaser.Scene {
}, {} as UnitData['equipment'])
})),
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits
completedCampVisits: this.campaign.completedCampVisits,
campDialogueChoiceIds: this.campaign.campDialogueChoiceIds,
campVisitChoiceIds: this.campaign.campVisitChoiceIds
}
: null,
report: this.report