feat: add city stay exploration and resonance
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
849
scripts/verify-city-stay-browser.mjs
Normal file
849
scripts/verify-city-stay-browser.mjs
Normal 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]) };
|
||||
}
|
||||
252
scripts/verify-city-stay-data.mjs
Normal file
252
scripts/verify-city-stay-data.mjs
Normal 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})`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
233
src/game/data/cityStays.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user