Add Liu Biao camp visit events

This commit is contained in:
2026-06-23 04:51:40 +09:00
parent 8f2479e5fc
commit c209107b43
5 changed files with 389 additions and 14 deletions

View File

@@ -36,12 +36,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Fourteenth battle Cao Cao break route, adding Sun Qian as a recruitable strategist and making expanded sortie selection matter before Liu Bei heads toward Yuan Shao
- Fifteenth battle Yuan Shao refuge road, pushing Liu Bei through Cao Cao's pursuit into Yuan Shao's camp while preserving the six-officer sortie roster
- Sixteenth battle Liu Biao refuge road, adding Zhao Yun as a recruitable cavalry officer and forcing a seven-officer roster into a six-officer sortie choice
- Liu Biao camp visit events with one-time local choices, persisted completion, bond/gold/item rewards, and Wolong clue preparation before Zhuge Liang's recruitment arc
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
- Flow verification script from title through the sixteenth battle victory, recruit sortie selection, and camp save state
- Flow verification script from title through the sixteenth battle victory, recruit sortie selection, Liu Biao visit rewards, and camp save state
## Next Steps
1. Build the Zhuge Liang recruitment chapter and add stronger between-battle visit/dialogue choices in Liu Biao's territory
1. Build the Zhuge Liang recruitment chapter using the Wolong clues gathered from Liu Biao camp visits
2. Add a chapter/progress view so long campaign arcs are easier to read between battles
3. Apply treasure equipment effects to damage, defense, recovery, and command rules
4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang

View File

@@ -294,7 +294,7 @@ try {
});
campScene.render();
});
await page.mouse.click(918, 38);
await page.mouse.click(956, 38);
await page.waitForTimeout(160);
await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true });
await page.mouse.click(1120, 245);
@@ -321,7 +321,7 @@ try {
throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`);
}
await page.mouse.click(830, 38);
await page.mouse.click(784, 38);
await page.waitForTimeout(120);
await page.mouse.click(974, 482);
await page.waitForTimeout(260);
@@ -1607,11 +1607,35 @@ try {
sixteenthCampState.campTitle !== '형주 의탁 후 군영' ||
sixteenthCampState.availableDialogueIds?.length !== 3 ||
!sixteenthCampState.availableDialogueIds.every((id) => id.endsWith('liu-biao-refuge')) ||
sixteenthCampState.availableVisitIds?.length !== 3 ||
!sixteenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhao-yun')
) {
throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue set and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`);
throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue/visit sets and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`);
}
await page.mouse.click(870, 38);
await page.waitForTimeout(160);
const sixteenthVisitTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
sixteenthVisitTabState?.activeTab !== 'visit' ||
!sixteenthVisitTabState.availableVisitIds?.includes('jingzhou-scholar-rumors') ||
sixteenthVisitTabState.completedAvailableVisits?.length !== 0
) {
throw new Error(`Expected Liu Biao camp visit tab to expose local visit choices: ${JSON.stringify(sixteenthVisitTabState)}`);
}
await page.mouse.click(974, 436);
await page.waitForTimeout(260);
const sixteenthVisitRewardState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
!sixteenthVisitRewardState.completedAvailableVisits?.includes('jingzhou-scholar-rumors') ||
!sixteenthVisitRewardState.campaign?.completedCampVisits?.includes('jingzhou-scholar-rumors') ||
(sixteenthVisitRewardState.campaign?.inventory?.['와룡 소문'] ?? 0) < 1
) {
throw new Error(`Expected completed visit to persist and award Wolong clue inventory: ${JSON.stringify(sixteenthVisitRewardState)}`);
}
await page.screenshot({ path: 'dist/verification-liu-biao-visits.png', fullPage: true });
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];

View File

@@ -2520,6 +2520,7 @@ export class BattleScene extends Phaser.Scene {
: undefined,
itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
});
}

View File

@@ -17,6 +17,7 @@ import {
type UnitData
} from '../data/scenario';
import {
applyCampVisitReward,
applyCampBondExp,
ensureCampaignRosterUnits,
getCampaignState,
@@ -28,7 +29,7 @@ import {
} from '../state/campaignState';
import { palette } from '../ui/palette';
type CampTab = 'status' | 'dialogue' | 'supplies';
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies';
type CampDialogue = {
id: string;
@@ -48,6 +49,26 @@ type CampDialogueChoice = {
rewardExp: number;
};
type CampVisitDefinition = {
id: string;
title: string;
location: string;
availableAfterBattleIds: string[];
description: string;
bondId?: string;
lines: string[];
choices: CampVisitChoice[];
};
type CampVisitChoice = {
id: string;
label: string;
response: string;
bondExp?: number;
gold?: number;
itemRewards?: string[];
};
type CampTabButtonView = {
tab: CampTab;
bg: Phaser.GameObjects.Rectangle;
@@ -1720,12 +1741,104 @@ const campDialogues: CampDialogue[] = [
}
];
const campVisits: CampVisitDefinition[] = [
{
id: 'jingzhou-scholar-rumors',
title: '형주 선비들의 모임',
location: '신야 객사',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__sun-qian',
description: '형주의 선비들이 모이는 객사에서 와룡과 봉추에 관한 소문을 모읍니다.',
lines: [
'손건: 형주의 선비들은 겉으로는 술과 시를 말하지만, 속으로는 천하의 흐름을 재고 있습니다.',
'유비: 그들의 말을 듣되 우리 뜻을 팔지는 말아야 하오. 와룡이라는 이름이 참이라면, 예로 찾아가야 하겠지.',
'객사의 선비들은 유비군이 오래 머물 손님인지, 천하를 논할 사람인지 조심스럽게 살핍니다.'
],
choices: [
{
id: 'ask-about-wolong',
label: '와룡의 거처를 묻는다',
response: '손건은 무리하게 캐묻지 않고 융중 근처의 초가와 제갈씨 집안에 관한 소문을 얻어 냈다.',
bondExp: 18,
itemRewards: ['와룡 소문 1']
},
{
id: 'share-liu-bei-intent',
label: '유비의 뜻을 조용히 전한다',
response: '손건은 유비가 형주의 땅보다 사람을 먼저 찾는다는 뜻을 전했고, 선비들의 경계가 조금 누그러졌다.',
bondExp: 20,
gold: 80
}
]
},
{
id: 'xinye-market-supplies',
title: '신야 장터 정비',
location: '신야 장터',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__mi-zhu',
description: '피난민과 상인이 뒤섞인 장터에서 다음 여정을 위한 군량과 약을 챙깁니다.',
lines: [
'미축: 형주의 장터는 물자가 많지만 값도 빠르게 오릅니다. 지금 무엇을 먼저 챙길지 정해야 합니다.',
'유비: 백성의 몫을 빼앗지 않는 선에서 군을 살릴 물자를 구하시오. 다음 길은 길어질 것이오.',
'상인들은 유비군의 명분을 믿고 값을 낮출지, 낯선 객군이라며 셈을 올릴지 눈치를 봅니다.'
],
choices: [
{
id: 'buy-field-medicine',
label: '상처약을 먼저 구한다',
response: '미축은 좋은 약재를 골라 상처약을 더 확보했고, 병사들은 긴 행군에 조금 안심했다.',
bondExp: 12,
itemRewards: ['상처약 2']
},
{
id: 'stock-camp-grain',
label: '군량을 넉넉히 산다',
response: '미축은 콩과 마른 양식을 챙기며 군자금을 아꼈고, 남은 돈으로 다음 협상 여지도 마련했다.',
bondExp: 10,
gold: 120,
itemRewards: ['콩 2']
}
]
},
{
id: 'refugee-village-patrol',
title: '피난민 마을 순행',
location: '형주 북쪽 마을',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__zhao-yun',
description: '조운과 함께 형주 북쪽 마을을 돌아보며 민심과 길목의 위험을 살핍니다.',
lines: [
'조운: 조조군을 피해 내려온 백성들이 마을 바깥에 모여 있습니다. 관문은 열렸지만 마음은 아직 닫혀 있습니다.',
'유비: 의탁한 몸이라도 백성을 외면할 수는 없소. 우리가 머무는 동안 폐가 아니라 힘이 되게 합시다.',
'피난민들은 유비의 이름을 듣고도 쉽게 다가오지 못하지만, 조운의 차분한 호위에 조금씩 안정을 찾습니다.'
],
choices: [
{
id: 'guard-refugee-road',
label: '피난길을 호위한다',
response: '조운은 피난민 행렬을 안전한 길로 이끌었고, 유비군의 깃발은 형주 북쪽 마을에 좋은 소문으로 남았다.',
bondExp: 22,
itemRewards: ['민심 기록 1']
},
{
id: 'listen-to-village-elders',
label: '마을 어른들의 말을 듣는다',
response: '유비는 마을 어른들의 걱정을 오래 들었고, 조운은 다음 행군에서 피해야 할 샛길을 지도에 표시했다.',
bondExp: 18,
itemRewards: ['탁주 1']
}
]
}
];
export class CampScene extends Phaser.Scene {
private campaign?: CampaignState;
private report?: FirstBattleReport;
private selectedUnitId = 'liu-bei';
private activeTab: CampTab = 'status';
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
private contentObjects: Phaser.GameObjects.GameObject[] = [];
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
@@ -1749,6 +1862,7 @@ export class CampScene extends Phaser.Scene {
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
soundDirector.playMusic('militia-theme');
const { width, height } = this.scale;
@@ -1789,6 +1903,7 @@ export class CampScene extends Phaser.Scene {
bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })),
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
};
}
@@ -1910,17 +2025,28 @@ export class CampScene extends Phaser.Scene {
return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
}
private availableCampVisits() {
const battleId = this.currentCampBattleId();
return campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId));
}
private completedAvailableVisits() {
const completed = this.completedCampVisits();
return this.availableCampVisits().filter((visit) => completed.includes(visit.id));
}
private renderStaticButtons() {
this.addTabButton('장수', 'status', 742, 38);
this.addTabButton('대화', 'dialogue', 830, 38);
this.addTabButton('정비', 'supplies', 918, 38);
this.addCommandButton('저장', 1002, 38, 76, () => {
this.addTabButton('장수', 'status', 698, 38);
this.addTabButton('대화', 'dialogue', 784, 38);
this.addTabButton('방문', 'visit', 870, 38);
this.addTabButton('정비', 'supplies', 956, 38);
this.addCommandButton('저장', 1044, 38, 76, () => {
soundDirector.playSelect();
this.campaign = saveCampaignState(getCampaignState());
this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
this.render();
});
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
this.addCommandButton('다음 이야기', 1160, 38, 142, () => {
soundDirector.playSelect();
this.showSortiePrep();
});
@@ -1993,6 +2119,11 @@ export class CampScene extends Phaser.Scene {
return;
}
if (this.activeTab === 'visit') {
this.renderVisitPanel();
return;
}
if (this.activeTab === 'supplies') {
this.renderSuppliesPanel();
return;
@@ -2467,11 +2598,13 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4);
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const availableVisits = this.availableCampVisits();
const completedVisits = this.completedAvailableVisits().length;
const inventory = this.inventoryLabels().join(', ');
const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).join(', ');
const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
private nextSortieBriefing() {
@@ -2490,6 +2623,8 @@ export class CampScene extends Phaser.Scene {
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const availableVisits = this.availableCampVisits();
const completedVisits = this.completedAvailableVisits().length;
const selected = this.selectedSortieUnits();
return [
{
@@ -2517,6 +2652,11 @@ export class CampScene extends Phaser.Scene {
complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
detail: `${completedDialogues}/${availableDialogues.length} 완료`
},
{
label: '현지 방문',
complete: availableVisits.length === 0 || completedVisits > 0,
detail: availableVisits.length === 0 ? '방문 없음' : `${completedVisits}/${availableVisits.length} 완료`
},
{
label: '장비 상태',
complete: this.visitedTabs.has('status'),
@@ -2816,6 +2956,112 @@ export class CampScene extends Phaser.Scene {
});
}
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)));
this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));
const visits = this.availableCampVisits();
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
}
if (visits.length === 0) {
const empty = this.track(this.add.rectangle(x + 24, y + 104, 780, 250, 0x0d141c, 0.92));
empty.setOrigin(0);
empty.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 48, y + 130, '방문 가능한 장소 없음', this.textStyle(20, '#f2e3bf', true)));
this.track(this.add.text(x + 48, y + 170, '현재 군영에서는 따로 방문할 장소가 없습니다. 장수 대화와 정비를 진행하십시오.', this.textStyle(14, '#9fb0bf')));
return;
}
visits.forEach((visit, index) => {
const completed = this.completedCampVisits().includes(visit.id);
const rowY = y + 96 + index * 66;
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));
row.setOrigin(0);
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
row.setInteractive({ useHandCursor: true });
row.on('pointerdown', () => {
soundDirector.playSelect();
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.renderSelectedVisit(x + 372, y + 96, 416, 300);
this.renderVisitPreparationSummary(x + 24, y + 322, 320);
}
private renderSelectedVisit(x: number, y: number, width: number, height: number) {
const visits = this.availableCampVisits();
const visit = visits.find((candidate) => candidate.id === this.selectedVisitId) ?? visits[0];
if (!visit) {
return;
}
const completed = this.completedCampVisits().includes(visit.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
this.track(this.add.text(x + 18, y + 14, visit.title, this.textStyle(20, '#f2e3bf', true)));
this.track(this.add.text(x + 18, y + 42, visit.description, {
...this.textStyle(13, '#d8b15f', true),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 2
}));
this.track(
this.add.text(x + 18, y + 88, visit.lines.join('\n\n'), {
...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4
})
);
if (completed) {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료', this.textStyle(15, '#a8ffd0', true)));
text.setOrigin(0.5);
return;
}
visit.choices.forEach((choice, index) => {
const choiceY = y + height - 80 + index * 42;
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
button.setStrokeStyle(1, palette.gold, 0.76);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
button.on('pointerdown', () => this.completeVisit(visit, choice));
const label = `${choice.label} ${this.visitRewardText(choice)}`;
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(13, '#f2e3bf', true)));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', () => this.completeVisit(visit, choice));
});
}
private renderVisitPreparationSummary(x: number, y: number, width: number) {
const visits = this.availableCampVisits();
const completed = this.completedAvailableVisits();
const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9));
bg.setOrigin(0);
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.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
}
private renderSuppliesPanel() {
const x = 394;
const y = 120;
@@ -3111,6 +3357,43 @@ export class CampScene extends Phaser.Scene {
return bonuses.join(' / ') || '특수 보정 없음';
}
private completeVisit(visit: CampVisitDefinition, choice: CampVisitChoice) {
if (this.completedCampVisits().includes(visit.id)) {
this.showCampNotice('이미 완료한 방문입니다.');
return;
}
const updated = applyCampVisitReward(visit.id, {
bondId: visit.bondId,
bondExp: choice.bondExp,
gold: choice.gold,
itemRewards: choice.itemRewards
});
if (updated) {
this.campaign = updated;
this.report = this.campaign.firstBattleReport ?? this.report;
}
soundDirector.playEffect('exp-gain', { volume: 0.26, stopAfterMs: 700 });
this.showVisitReward(choice);
this.render();
}
private showVisitReward(choice: CampVisitChoice) {
this.showCampNotice(`${choice.label} · ${this.visitRewardText(choice)}`);
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
}
private visitRewardText(choice: CampVisitChoice) {
const rewards = [
choice.bondExp ? `공명 +${choice.bondExp}` : '',
choice.gold ? `군자금 +${choice.gold}` : '',
...(choice.itemRewards ?? []).map((reward) => `${reward}`)
].filter(Boolean);
return rewards.join(' / ') || '정보 획득';
}
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
@@ -3270,6 +3553,13 @@ export class CampScene extends Phaser.Scene {
return this.report?.completedCampDialogues ?? [];
}
private completedCampVisits() {
if (this.campaign) {
return this.campaign.completedCampVisits;
}
return this.report?.completedCampVisits ?? [];
}
private inventoryLabels() {
const inventory = this.campaign?.inventory ?? {};
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
@@ -3336,6 +3626,7 @@ export class CampScene extends Phaser.Scene {
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
sortieRoster: this.sortieAllies().map((unit) => {
const summary = this.sortieUnitTacticalSummary(unit);
@@ -3361,6 +3652,8 @@ export class CampScene extends Phaser.Scene {
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
availableVisitIds: this.availableCampVisits().map((visit) => visit.id),
completedAvailableVisits: this.completedAvailableVisits().map((visit) => visit.id),
campaign: this.campaign
? {
step: this.campaign.step,
@@ -3373,7 +3666,8 @@ export class CampScene extends Phaser.Scene {
hp: unit.hp,
maxHp: unit.maxHp
})),
completedCampDialogues: this.campaign.completedCampDialogues
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits
}
: null,
report: this.report
@@ -3381,6 +3675,7 @@ export class CampScene extends Phaser.Scene {
rewardGold: this.report.rewardGold,
objectives: this.report.objectives,
completedCampDialogues: this.report.completedCampDialogues,
completedCampVisits: this.report.completedCampVisits,
bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))
}
: null

View File

@@ -33,6 +33,7 @@ export type FirstBattleReport = {
mvp?: CampMvpSnapshot;
itemRewards: string[];
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
};
@@ -114,6 +115,7 @@ export type CampaignState = {
inventory: Record<string, number>;
selectedSortieUnitIds: string[];
completedCampDialogues: string[];
completedCampVisits: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
latestBattleId?: string;
firstBattleReport?: FirstBattleReport;
@@ -164,6 +166,7 @@ export function createInitialCampaignState(): CampaignState {
inventory: {},
selectedSortieUnitIds: [],
completedCampDialogues: [],
completedCampVisits: [],
battleHistory: {}
};
}
@@ -244,8 +247,11 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
const completedCampVisits =
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
reportClone.completedCampDialogues = completedCampDialogues;
reportClone.completedCampVisits = completedCampVisits;
state.firstBattleReport = reportClone;
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
const victoryStep: CampaignStep = stepRule.victory;
@@ -255,6 +261,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
state.completedCampDialogues = completedCampDialogues;
state.completedCampVisits = completedCampVisits;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
state.battleHistory[battleId] = createBattleSettlement(reportClone);
@@ -298,6 +305,50 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
return cloneReport(state.firstBattleReport);
}
export function applyCampVisitReward(
visitId: string,
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }
) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
return undefined;
}
if (state.completedCampVisits.includes(visitId)) {
return cloneCampaignState(state);
}
state.completedCampVisits.push(visitId);
if (!state.firstBattleReport.completedCampVisits.includes(visitId)) {
state.firstBattleReport.completedCampVisits.push(visitId);
}
if (reward.bondId && reward.bondExp && reward.bondExp > 0) {
const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId);
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId);
if (campaignBond) {
advanceBond(campaignBond, reward.bondExp);
if (reportBond) {
reportBond.level = campaignBond.level;
reportBond.exp = campaignBond.exp;
reportBond.battleExp = campaignBond.battleExp;
}
}
}
if (reward.gold && reward.gold > 0) {
state.gold += reward.gold;
}
if (reward.itemRewards?.length) {
state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1);
}
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return cloneCampaignState(state);
}
export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) {
const state = ensureCampaignState();
let changed = false;
@@ -368,6 +419,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState {
normalized.roster = (normalized.roster ?? []).map(cloneUnit);
normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot);
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])];
normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])];
normalized.inventory = { ...(normalized.inventory ?? {}) };
normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])];
normalized.battleHistory = normalized.battleHistory ?? {};
@@ -501,7 +553,9 @@ function cloneCampaignState(state: CampaignState): CampaignState {
}
function cloneReport(report: FirstBattleReport): FirstBattleReport {
return JSON.parse(JSON.stringify(report)) as FirstBattleReport;
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])];
return cloned;
}
function cloneUnit(unit: UnitData): UnitData {