feat: add walkable Guangzong sortie camp

This commit is contained in:
2026-07-27 22:37:55 +09:00
parent 43e5533515
commit 9d67e0ae1d
17 changed files with 3519 additions and 558 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -757,24 +757,24 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
export const fourthBattleScenario: BattleScenarioDefinition = {
id: 'fourth-battle-guangzong-camp',
title: '광종 본영전',
victoryConditionLabel: '장각 격파',
victoryConditionLabel: '장각 본대 지휘 와해',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.',
'노식 중랑장의 포위망이 광종 본영을 묶었습니다. 의용군은 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨려야 합니다.',
'요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.',
'20턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.'
],
tacticalGuide: {
summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.',
route: '출진점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 진',
focus: '관우는 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 뒤에서 무리한 돌입을 막아야 합니다.',
roles: ['관우: 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 후방 지휘/마무리 판단'],
summary: '관군의 광종 포위망 가운데 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨리는 전투입니다.',
route: '동쪽 선봉 출진점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 지휘진',
focus: '관우는 동쪽 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 포위망에서 홀로 튀어나가지 않도록 진격 속도를 맞춰야 합니다.',
roles: ['관우: 동쪽 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 포위망 연계/후방 지휘'],
events: {
'enemy-posture': {
title: '광종 본영 진형',
lines: [
'장각 요새와 숲길을 겹쳐 본영 입구를 막고 있습니다.',
'중앙을 서두르기 전에 측면 궁병을 줄이면 장각에게 붙는 길이 훨씬 안정됩니다.'
'장각 본대는 요새와 숲길을 겹쳐 노식의 포위망 가운데 동쪽 입구를 막고 있습니다.',
'중앙을 서두르기 전에 측면 궁병을 줄이면 지휘진에 닿는 길이 훨씬 안정됩니다.'
]
},
'cavalry-approach': {
@@ -784,7 +784,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
'leader-wavering': {
title: '장각 본진 동요',
lines: [
'장각 주변 수비가 무너지면 황건 본 전체가 흔들립니다.',
'장각의 지휘병과 주변 수비가 무너지면 황건 본 전체가 흔들립니다.',
'유비를 안전하게 두고 관우와 장비를 붙여 끝을 보십시오.'
]
}
@@ -794,7 +794,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '장각 격파까지 전선이 무너지지 않도록 후방 지휘가 필요합니다.' },
{ unitId: 'liu-bei', role: 'support', reason: '장각 본대의 지휘가 무너질 때까지 동쪽 선봉과 관군 포위망을 잇는 후방 지휘가 필요합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '본영 정면의 요새 방어선을 뚫는 핵심 전열입니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '측면 궁병과 기병을 흔들어 본영 진입 시간을 줄입니다.' }
],
@@ -821,7 +821,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
{
id: 'leader',
kind: 'defeat-leader',
label: '장각 격파',
label: '장각 본대 지휘 와해',
rewardGold: 420,
unitId: 'guangzong-main-leader-zhang-jue'
},

View File

@@ -633,7 +633,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '광종 본영',
background: 'story-yellow-pursuit',
text: '전령의 길이 끊기자 황건 본대는 광종 들판에 진을 굳혔다. 노란 깃발이 겹겹이 서고, 장각의 이름이 병사들 사이에 낮게 울렸다.'
text: '전령의 길이 끊기자 노식 중랑장의 관군은 광종 포위망을 좁혔다. 황건 본대는 들판에 진을 굳혔고, 의용군에는 동쪽 숲 선봉을 맡으라는 군령이 내려왔다.'
},
{
id: 'fourth-liu-bei-resolve',
@@ -642,7 +642,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
background: 'story-yellow-pursuit',
speaker: '유비',
portrait: 'liuBei',
text: '장각을 치는 일은 한 사람을 꺾는 일이 아니다. 두려움에 몰린 백성들이 다시 길을 찾게 하는 일이다.'
text: '스승님의 큰 포위망을 흐트러뜨리지 않고 우리에게 맡겨진 동쪽 길을 열겠다. 한 사람의 목보다, 황건 본대가 더는 백성을 몰아세우지 못하게 지휘를 끊는 것이 먼저다.'
},
{
id: 'fourth-guan-yu-line',
@@ -651,7 +651,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
background: 'story-yellow-pursuit',
speaker: '관우',
portrait: 'guanYu',
text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 전열을 흐트러뜨리지 않고 본영의 깃발까지 밀고 들어가야 합니다.'
text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 동쪽 전열을 흐트러뜨리지 않고 지휘 깃발까지 밀고 들어가면 포위망 전체가 움직일 수 있습니다.'
},
{
id: 'fourth-zhang-fei-roar',
@@ -660,7 +660,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
background: 'story-yellow-pursuit',
speaker: '장비',
portrait: 'zhangFei',
text: '좋소! 광종의 누런 깃발을 오늘 모두 뽑아 버립시다. 뒤돌아볼 틈 없이 밀고 들어가겠소!'
text: '좋소! 형님 깃발이 보이는 거리에서 동쪽 숲길부터 열겠소. 우리가 지휘진을 흔들면 관군의 포위망도 한꺼번에 조여들 것이오!'
}
];
@@ -670,7 +670,7 @@ export const fourthBattleVictoryPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '황건적 토벌',
background: 'story-yellow-pursuit',
text: '광종의 들판에서 황건영이 무너졌다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머 관군과 의용군을 바라보았다.'
text: '의용군이 동쪽 지휘선을 끊자 관군의 포위망이 맞물렸고 광종의 황건 본대는 와해되었다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머 관군과 의용군을 바라보았다.'
},
{
id: 'fourth-victory-liu-bei',
@@ -681,12 +681,19 @@ export const fourthBattleVictoryPages: StoryPage[] = [
portrait: 'liuBei',
text: '난은 잠시 가라앉았지만, 천하가 바로 선 것은 아니다. 오늘의 공이 교만이 되지 않도록 마음을 다잡자.'
},
{
id: 'fourth-victory-lu-zhi-recalled',
bgm: 'battle-prep',
chapter: '뒤집힌 군령',
background: 'story-yellow-pursuit',
text: '승전 장부의 먹이 마르기도 전에 조정의 사자가 도착했다. 포위망을 이끌던 노식은 낙양으로 소환되고, 남은 군권에는 동탁의 이름이 올랐다. 세 형제는 전장의 승리만으로 어지러운 조정을 바로잡을 수 없음을 처음 실감했다.'
},
{
id: 'fourth-victory-next-era',
bgm: 'battle-prep',
chapter: '새로운 혼란',
background: 'story-yellow-pursuit',
text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 동탁의 폭정에 맞서 제후들이 격문을 돌리기 시작했고, 세 형제에게도 소식이 닿았다.'
text: '황건의 불길이 잦아든 뒤에도 동탁의 권세는 멈추지 않았다. 마침내 제후들이 폭정에 맞설 격문을 돌리기 시작했고, 광종에서 그 이름을 먼저 들었던 세 형제에게도 소식이 닿았다.'
}
];

View File

@@ -0,0 +1,583 @@
import type {
ExplorationCharacterDirection,
ExplorationCharacterTextureKey
} from './explorationCharacterAssets';
import {
resolveThirdBattlePriorityReturnDialogue,
thirdBattleReturnSourceBattleId,
type ThirdBattlePriorityReturnDialogue,
type ThirdBattleReturnCampaignState,
type ThirdBattleReturnTargetDialogueId
} from './thirdBattleReturnDialogue';
export const thirdCampExplorationVisitId =
'third-guangzong-sortie-camp';
export const thirdCampExplorationSourceBattleId =
thirdBattleReturnSourceBattleId;
export const thirdCampExplorationTargetBattleId =
'fourth-battle-guangzong-camp';
export const thirdCampExplorationActivityIds = [
'information',
'equipment',
'companion'
] as const;
export type ThirdCampExplorationActivityId =
(typeof thirdCampExplorationActivityIds)[number];
export type ThirdCampExplorationDialogueLine = {
readonly speaker: string;
readonly text: string;
readonly portraitKey?: string;
};
export type ThirdCampCompanionDialogueChoice = {
readonly id: string;
readonly label: string;
readonly response: string;
readonly rewardExp: number;
};
export type ThirdCampCompanionDialogueDefinition = {
readonly id: ThirdBattleReturnTargetDialogueId;
readonly title: string;
readonly availableAfterBattleIds: readonly [
typeof thirdCampExplorationSourceBattleId
];
readonly unitIds: readonly [string, string];
readonly bondId: string;
readonly rewardExp: number;
readonly lines: readonly string[];
readonly choices: readonly ThirdCampCompanionDialogueChoice[];
};
export type ThirdCampExplorationActorId =
| 'zou-jing-operations'
| 'quartermaster-equipment'
| 'companion-guan-yu'
| 'companion-zhang-fei';
export type ThirdCampExplorationActor = {
readonly id: ThirdCampExplorationActorId;
readonly name: string;
readonly role: string;
readonly textureKey: ExplorationCharacterTextureKey;
readonly portraitKey?: string;
readonly x: number;
readonly y: number;
readonly direction: ExplorationCharacterDirection;
readonly kind: 'activity' | 'companion';
readonly activityId: ThirdCampExplorationActivityId;
readonly dialogue: readonly ThirdCampExplorationDialogueLine[];
readonly repeatDialogue: readonly ThirdCampExplorationDialogueLine[];
readonly completedDialogue: readonly ThirdCampExplorationDialogueLine[];
};
export type ThirdCampExplorationActivityDefinition = {
readonly id: ThirdCampExplorationActivityId;
readonly label: string;
readonly shortLabel: string;
readonly detail: string;
readonly targetActorIds: readonly ThirdCampExplorationActorId[];
readonly completionLine: string;
readonly worldCue: string;
};
export type ThirdCampExplorationDefinition = {
readonly id: typeof thirdCampExplorationVisitId;
readonly title: string;
readonly location: string;
readonly sourceBattleId: typeof thirdCampExplorationSourceBattleId;
readonly targetBattleId: typeof thirdCampExplorationTargetBattleId;
readonly heading: string;
readonly subtitle: string;
readonly description: string;
readonly background: {
readonly textureKey: 'third-guangzong-sortie-camp-background';
readonly source: 'original-guangzong-forward-camp';
};
readonly introLines: readonly ThirdCampExplorationDialogueLine[];
readonly movementBounds: {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
};
readonly player: {
readonly textureKey: 'exploration-liu-bei';
readonly x: number;
readonly y: number;
readonly direction: ExplorationCharacterDirection;
};
readonly exit: {
readonly id: 'camp-exit';
readonly name: string;
readonly x: number;
readonly y: number;
};
readonly actors: readonly ThirdCampExplorationActor[];
readonly activities: readonly ThirdCampExplorationActivityDefinition[];
readonly resultDialogue: ThirdBattlePriorityReturnDialogue;
readonly companionDialogue: ThirdCampCompanionDialogueDefinition;
};
export const thirdCampCompanionDialogues = [
{
id: 'liu-guan-after-guangzong-road',
title: '광종 길목의 침묵',
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 16,
lines: [
'유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
'관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
'유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
],
choices: [
{
id: 'lead-narrow-road',
label: '좁은 길의 선봉을 맡긴다',
response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
rewardExp: 7
},
{
id: 'keep-discipline',
label: '군율 유지를 당부한다',
response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
rewardExp: 5
}
]
},
{
id: 'liu-zhang-after-guangzong-road',
title: '강가의 큰소리',
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 16,
lines: [
'장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
'유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
'장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
],
choices: [
{
id: 'laugh-through',
label: '호방함으로 사기를 올린다',
response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
rewardExp: 6
},
{
id: 'guard-bridgehead',
label: '교두보 수비를 맡긴다',
response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
rewardExp: 5
}
]
},
{
id: 'guan-zhang-after-guangzong-road',
title: '험지의 호흡',
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 14,
lines: [
'관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
'장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
'관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
],
choices: [
{
id: 'measure-and-break',
label: '관우가 재고 장비가 뚫는다',
response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
rewardExp: 7
},
{
id: 'watch-each-other',
label: '서로의 후방을 살핀다',
response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
rewardExp: 5
}
]
}
] as const satisfies readonly ThirdCampCompanionDialogueDefinition[];
export function isThirdCampExplorationActivityId(
value: unknown
): value is ThirdCampExplorationActivityId {
return (
typeof value === 'string' &&
(thirdCampExplorationActivityIds as readonly string[]).includes(
value
)
);
}
export function thirdCampExplorationActivityProgressId(
activityId: ThirdCampExplorationActivityId
) {
return `${thirdCampExplorationVisitId}:activity:${activityId}`;
}
export function completedThirdCampExplorationActivityIds(
completedCampVisitIds: readonly string[]
) {
const completed = new Set(completedCampVisitIds);
return thirdCampExplorationActivityIds.filter((activityId) =>
completed.has(thirdCampExplorationActivityProgressId(activityId))
);
}
export function getThirdCampCompanionDialogue(
dialogueId: unknown
): ThirdCampCompanionDialogueDefinition | undefined {
if (typeof dialogueId !== 'string') {
return undefined;
}
return thirdCampCompanionDialogues.find(
(dialogue) => dialogue.id === dialogueId
);
}
export function resolveThirdCampCompanionDialogue(
campaign?: ThirdBattleReturnCampaignState | null
) {
const resultDialogue =
resolveThirdBattlePriorityReturnDialogue(campaign);
return resultDialogue
? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId)
: undefined;
}
export function resolveThirdCampExplorationDefinition(
campaign?: ThirdBattleReturnCampaignState | null
): ThirdCampExplorationDefinition | undefined {
const resultDialogue =
resolveThirdBattlePriorityReturnDialogue(campaign);
const companionDialogue = resultDialogue
? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId)
: undefined;
if (!resultDialogue || !companionDialogue) {
return undefined;
}
const companionActors = companionActorsFor(resultDialogue);
const equipment = equipmentCopyFor(resultDialogue);
const actors: ThirdCampExplorationActor[] = [
{
id: 'zou-jing-operations',
name: '추정',
role: '동쪽 삼림 선봉 작전',
textureKey: 'exploration-zou-jing',
portraitKey: 'zouJing',
x: 505,
y: 405,
direction: 'east',
kind: 'activity',
activityId: 'information',
dialogue: [
{
speaker: '추정',
portraitKey: 'zouJing',
text: '노식 중랑장의 주력은 광종을 넓게 포위한다. 우리는 동쪽 삼림을 지나 본영 바깥 선봉과 퇴로 표식을 먼저 확인한다. 적의 첫 움직임을 읽되 본대와 이어진 길은 비워 두게.'
},
{
speaker: '유비',
portraitKey: 'liuBei',
text: '본대보다 앞서되 고립되지는 않겠습니다. 적 선봉의 움직임과 우리 병사들이 돌아올 길을 한 작전판에 표시하겠습니다.'
}
],
repeatDialogue: [
{
speaker: '추정',
portraitKey: 'zouJing',
text: '본영 동쪽 선봉의 이동 표식은 작전판에 남아 있다. 출전 준비에서 다른 방침을 골라도 확인한 정보는 사라지지 않는다.'
}
],
completedDialogue: [
{
speaker: '추정',
portraitKey: 'zouJing',
text: '적 선봉 하나의 첫 움직임을 표시했다. 이 정보를 우선하면 배치 때 의도와 표식을 먼저 볼 수 있다.'
}
]
},
{
id: 'quartermaster-equipment',
name: '의용군 군수관',
role: '동림 선봉 병기·방호구',
textureKey: 'exploration-zhuo-quartermaster',
portraitKey: 'zhuoQuartermaster',
x: 360,
y: 745,
direction: 'east',
kind: 'activity',
activityId: 'equipment',
dialogue: equipment.dialogue,
repeatDialogue: equipment.repeatDialogue,
completedDialogue: equipment.completedDialogue
},
...companionActors
];
return {
id: thirdCampExplorationVisitId,
title: '광종 동림 출진 야영지',
location: '광종 동쪽 삼림 · 노식군 외곽 포위망',
sourceBattleId: thirdCampExplorationSourceBattleId,
targetBattleId: thirdCampExplorationTargetBattleId,
heading: '본영을 향한 세 가지 준비',
subtitle:
'노식군의 큰 포위망 안에서 의용군은 동쪽 삼림 선봉을 맡습니다. 필요한 준비만 확인하고 언제든 출진할 수 있습니다.',
description:
'작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마칠 때마다 그 준비가 자동으로 선택되고, 마지막으로 확인한 준비 하나만 다음 전투의 초반 효과가 됩니다.',
background: {
textureKey: 'third-guangzong-sortie-camp-background',
source: 'original-guangzong-forward-camp'
},
introLines: [
{
speaker: '추정',
portraitKey: 'zouJing',
text: '노식 중랑장의 본대가 광종 포위망을 좁히는 동안, 그대들의 의용군은 동쪽 삼림으로 나가 적 선봉과 본영 퇴로를 끊는다.'
},
{
speaker: '유비',
portraitKey: 'liuBei',
text: '병사들이 무엇을 준비했는지 직접 살핀 뒤 출전하겠습니다. 서두르되 준비를 의무처럼 붙잡지는 않겠습니다.'
}
],
movementBounds: { x: 42, y: 108, width: 1404, height: 814 },
player: {
textureKey: 'exploration-liu-bei',
x: 930,
y: 850,
direction: 'north'
},
exit: {
id: 'camp-exit',
name: '군영 출진 장부로 돌아가기',
x: 790,
y: 914
},
actors,
activities: [
{
id: 'information',
label: '추정과 본영 선봉 작전 확인',
shortLabel: '본영 정보',
detail:
'노식군의 포위망과 의용군의 동쪽 삼림 진입로를 겹쳐 적 선봉의 첫 움직임을 표시합니다.',
targetActorIds: ['zou-jing-operations'],
completionLine:
'작전판에 적 선봉의 의도와 동쪽 삼림 퇴로가 함께 표시되었습니다.',
worldCue: '작전판에 적 선봉·동림 퇴로 표식'
},
{
id: 'equipment',
label: '군수관과 선봉 방호구 정비',
shortLabel: '장비 정비',
detail: equipment.activityDetail,
targetActorIds: ['quartermaster-equipment'],
completionLine:
'보급 수레에 수선한 방호구와 응급 천이 출전 순서대로 실렸습니다.',
worldCue: '보급 수레에 방패·응급 천 적재'
},
{
id: 'companion',
label: `${companionDialogue.title} 되짚기`,
shortLabel: '동료 공명',
detail:
'지난 전투 결과에 가장 먼저 반응한 동료와 다음 돌파의 호흡을 정합니다.',
targetActorIds: companionActors.map((actor) => actor.id),
completionLine:
'대화에서 정한 짧은 신호가 다음 본영전의 초반 협공 약속으로 남았습니다.',
worldCue: '동료와 협공 신호 공유'
}
],
resultDialogue,
companionDialogue
};
}
function companionActorsFor(
resultDialogue: ThirdBattlePriorityReturnDialogue
): ThirdCampExplorationActor[] {
const dialogue = resultDialogue.lines.map(resultDialogueLine);
const completedDialogue = [
{
speaker: '유비',
portraitKey: 'liuBei',
text: '방금 맞춘 호흡은 출전 대열에도 전해 두었소. 다른 준비를 살펴도 이 약속은 남아 있을 것이오.'
}
];
if (resultDialogue.variantId === 'fort-recovery') {
return [
companionActor({
id: 'companion-guan-yu',
name: '관우',
role: '강가 요새 회복책',
textureKey: 'exploration-guan-yu',
portraitKey: 'guanYu',
x: 1075,
y: 510,
direction: 'west',
dialogue,
completedDialogue
})
];
}
if (resultDialogue.variantId === 'tempo-recovery') {
return [
companionActor({
id: 'companion-zhang-fei',
name: '장비',
role: '본영 돌파 호흡',
textureKey: 'exploration-zhang-fei',
portraitKey: 'zhangFei',
x: 1115,
y: 560,
direction: 'west',
dialogue,
completedDialogue
})
];
}
return [
companionActor({
id: 'companion-guan-yu',
name: '관우',
role: '동림 길목 판단',
textureKey: 'exploration-guan-yu',
portraitKey: 'guanYu',
x: 1035,
y: 500,
direction: 'east',
dialogue,
completedDialogue
}),
companionActor({
id: 'companion-zhang-fei',
name: '장비',
role: '동림 돌파 선봉',
textureKey: 'exploration-zhang-fei',
portraitKey: 'zhangFei',
x: 1240,
y: 535,
direction: 'west',
dialogue,
completedDialogue
})
];
}
function companionActor(
options: Omit<
ThirdCampExplorationActor,
'kind' | 'activityId' | 'repeatDialogue'
>
): ThirdCampExplorationActor {
return {
...options,
kind: 'companion',
activityId: 'companion',
repeatDialogue: options.completedDialogue
};
}
function resultDialogueLine(line: string): ThirdCampExplorationDialogueLine {
const separator = line.indexOf(':');
if (separator <= 0) {
return { speaker: '동료', text: line };
}
const speaker = line.slice(0, separator).trim();
return {
speaker,
text: line.slice(separator + 1).trim(),
...(portraitKeyForSpeaker(speaker)
? { portraitKey: portraitKeyForSpeaker(speaker) }
: {})
};
}
function portraitKeyForSpeaker(speaker: string) {
const portraitKeys: Readonly<Record<string, string>> = {
: 'liuBei',
: 'guanYu',
: 'zhangFei',
: 'zouJing',
'의용군 군수관': 'zhuoQuartermaster'
};
return portraitKeys[speaker];
}
function equipmentCopyFor(
resultDialogue: ThirdBattlePriorityReturnDialogue
) {
if (resultDialogue.fortAchieved) {
return {
activityDetail:
'강가 요새에서 회수한 온전한 방패와 마른 결속끈을 동쪽 삼림 선봉의 첫 피격에 맞춰 배분합니다.',
dialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '강가 요새를 지킨 덕에 마른 결속끈과 온전한 방패를 회수했습니다. 동쪽 숲의 낮은 가지에 걸리지 않게 방패끈부터 줄이겠습니다.'
},
{
speaker: '유비',
portraitKey: 'liuBei',
text: '첫 충돌을 버틴 뒤 곧바로 대열을 되찾을 수 있게 앞줄부터 나누어 주시오. 무거운 물자는 본대 수레에 남깁시다.'
}
],
repeatDialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '요새에서 회수한 방패와 응급 천은 선봉 순서대로 실었습니다. 다른 준비를 골라도 정비한 장비는 그대로 남습니다.'
}
],
completedDialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '첫 피격을 한 번 흘릴 방호구를 앞줄에 배분했습니다.'
}
]
} as const;
}
return {
activityDetail:
'강가 요새를 남긴 퇴각에서 젖고 상한 방호구를 골라 수선하고, 동쪽 삼림에서 첫 충돌을 버틸 최소 장비를 마련합니다.',
dialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '강가 요새를 두고 물러오며 방패끈이 젖고 갑옷 몇 벌이 상했습니다. 멀쩡한 조각을 골라 앞줄 방호구부터 다시 묶겠습니다.'
},
{
speaker: '유비',
portraitKey: 'liuBei',
text: '새 장비처럼 보이게 하는 것보다 첫 충돌에서 끊어지지 않는 것이 중요하오. 수선한 장비와 응급 천을 같은 수레에 실어 주시오.'
}
],
repeatDialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '젖은 끈은 모두 갈고 상한 방호구는 겹쳐 묶었습니다. 다른 준비를 골라도 수선한 장비는 그대로 남습니다.'
}
],
completedDialogue: [
{
speaker: '의용군 군수관',
portraitKey: 'zhuoQuartermaster',
text: '수선한 방호구가 첫 충돌을 한 번 버틸 만큼은 갖춰졌습니다.'
}
]
} as const;
}

View File

@@ -2,6 +2,11 @@ import {
resolveThirdBattlePriorityReturnDialogue,
thirdBattleReturnSourceBattleId
} from './thirdBattleReturnDialogue';
import {
completedThirdCampExplorationActivityIds,
thirdCampExplorationVisitId,
type ThirdCampExplorationActivityId
} from './thirdCampExploration';
export const thirdCampPreparationSourceBattleId =
thirdBattleReturnSourceBattleId;
@@ -135,6 +140,24 @@ export const thirdCampPreparationPriorities: readonly
] as const;
export type ThirdCampPreparationEvidence =
| {
kind: 'exploration-activity';
battleId: typeof thirdCampPreparationSourceBattleId;
visitId: typeof thirdCampExplorationVisitId;
activityId: Exclude<
ThirdCampExplorationActivityId,
'companion'
>;
}
| {
kind: 'exploration-activity';
battleId: typeof thirdCampPreparationSourceBattleId;
visitId: typeof thirdCampExplorationVisitId;
activityId: 'companion';
dialogueId: string;
bondId: string;
unitIds: [string, string];
}
| {
kind: 'victory-reward-category';
battleId: typeof thirdCampPreparationSourceBattleId;
@@ -208,6 +231,7 @@ export type ThirdCampPreparationCampaignState = {
>;
firstBattleReport?: ThirdCampPreparationBattleRecord | null;
completedCampDialogues?: readonly unknown[];
completedCampVisits?: readonly unknown[];
thirdCampPreparationSelection?: {
sourceBattleId?: unknown;
targetBattleId?: unknown;
@@ -288,15 +312,19 @@ export function resolveThirdCampPreparationAvailability(
}));
}
const informationEvidence = acknowledgedRewardEvidence(
campaign,
'unlocks'
);
const equipmentEvidence = acknowledgedRewardEvidence(
campaign,
'equipment'
);
const companionEvidence = completedCompanionEvidence(campaign);
const informationEvidence =
completedExplorationEvidence(campaign, 'information') ??
selectedLegacyRewardEvidence(campaign, 'information', 'unlocks');
const equipmentEvidence =
completedExplorationEvidence(campaign, 'equipment') ??
selectedLegacyRewardEvidence(
campaign,
'equipment',
'equipment'
);
const companionEvidence =
completedExplorationEvidence(campaign, 'companion') ??
completedCompanionEvidence(campaign);
const evidenceByPriority: Partial<
Record<ThirdCampPreparationPriorityId, ThirdCampPreparationEvidence>
> = {
@@ -515,6 +543,70 @@ function acknowledgedRewardEvidence(
: undefined;
}
function selectedLegacyRewardEvidence(
campaign: ThirdCampPreparationCampaignState | null | undefined,
priorityId: Extract<
ThirdCampPreparationPriorityId,
'information' | 'equipment'
>,
category: 'unlocks' | 'equipment'
) {
const selection = campaign?.thirdCampPreparationSelection;
if (
selection?.sourceBattleId !==
thirdCampPreparationSourceBattleId ||
selection.targetBattleId !==
thirdCampPreparationTargetBattleId ||
selection.priorityId !== priorityId
) {
return undefined;
}
return acknowledgedRewardEvidence(campaign, category);
}
function completedExplorationEvidence(
campaign: ThirdCampPreparationCampaignState | null | undefined,
activityId: ThirdCampExplorationActivityId
): Extract<
ThirdCampPreparationEvidence,
{ kind: 'exploration-activity' }
> | undefined {
const completedActivityIds =
completedThirdCampExplorationActivityIds(
arrayValue(campaign?.completedCampVisits).filter(
(visitId): visitId is string => typeof visitId === 'string'
)
);
if (!completedActivityIds.includes(activityId)) {
return undefined;
}
if (activityId !== 'companion') {
return {
kind: 'exploration-activity',
battleId: thirdCampPreparationSourceBattleId,
visitId: thirdCampExplorationVisitId,
activityId
};
}
const dialogue =
resolveThirdBattlePriorityReturnDialogue(campaign);
const bond = dialogue
? companionBondByDialogueId[dialogue.targetDialogueId]
: undefined;
return dialogue && bond
? {
kind: 'exploration-activity',
battleId: thirdCampPreparationSourceBattleId,
visitId: thirdCampExplorationVisitId,
activityId: 'companion',
dialogueId: dialogue.targetDialogueId,
bondId: bond.bondId,
unitIds: [...bond.unitIds]
}
: undefined;
}
function completedCompanionEvidence(
campaign?: ThirdCampPreparationCampaignState | null
): Extract<
@@ -548,21 +640,33 @@ function resolveEffect(
): ResolvedThirdCampPreparationEffect | undefined {
if (
effect.kind === 'marked-vanguard-intel' &&
evidence.kind === 'victory-reward-category' &&
evidence.category === 'unlocks'
(
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'information') ||
(evidence.kind === 'victory-reward-category' &&
evidence.category === 'unlocks')
)
) {
return { ...effect };
}
if (
effect.kind === 'prepared-equipment-guard' &&
evidence.kind === 'victory-reward-category' &&
evidence.category === 'equipment'
(
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'equipment') ||
(evidence.kind === 'victory-reward-category' &&
evidence.category === 'equipment')
)
) {
return { ...effect };
}
if (
effect.kind === 'return-bond-opening' &&
evidence.kind === 'priority-return-dialogue'
(
evidence.kind === 'priority-return-dialogue' ||
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'companion')
)
) {
return {
...effect,
@@ -604,7 +708,11 @@ function clonePriority(
function cloneEvidence(
evidence: ThirdCampPreparationEvidence
): ThirdCampPreparationEvidence {
if (evidence.kind === 'priority-return-dialogue') {
if (
evidence.kind === 'priority-return-dialogue' ||
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'companion')
) {
return {
...evidence,
unitIds: [...evidence.unitIds]

View File

@@ -49,6 +49,11 @@ import {
thirdBattleReturnCampStep,
thirdBattleReturnSourceBattleId
} from '../data/thirdBattleReturnDialogue';
import {
resolveThirdCampExplorationDefinition,
thirdCampCompanionDialogues,
thirdCampExplorationVisitId
} from '../data/thirdCampExploration';
import {
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
@@ -256,9 +261,11 @@ import {
} from '../state/cityStayActions';
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
import {
clearThirdCampPreparationPriority,
setThirdCampPreparationPriority,
thirdCampPreparationProgress
} from '../state/thirdCampPreparationActions';
import { thirdCampExplorationProgress } from '../state/thirdCampExplorationActions';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
@@ -331,7 +338,8 @@ type CampVisitChoice = {
const directExplorationVisitIds = new Set([
firstPursuitScoutVisitId,
secondBattleReliefVisitId
secondBattleReliefVisitId,
thirdCampExplorationVisitId
]);
type CampTabButtonView = {
@@ -1327,7 +1335,7 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
'fourth-battle-guangzong-camp': {
maxUnits: 3,
recommended: [
{ unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' },
{ unitId: 'liu-bei', reason: '장각 본대의 지휘를 무너뜨릴 때까지 전선을 이끕니다.' },
{ unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' },
{ unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' }
],
@@ -3124,87 +3132,13 @@ const campDialogues: CampDialogue[] = [
}
]
},
{
id: 'liu-guan-after-guangzong-road',
title: '광종 길목의 침묵',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 16,
lines: [
'유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
'관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
'유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
],
choices: [
{
id: 'lead-narrow-road',
label: '좁은 길의 선봉을 맡긴다',
response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
rewardExp: 7
},
{
id: 'keep-discipline',
label: '군율 유지를 당부한다',
response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
rewardExp: 5
}
]
},
{
id: 'liu-zhang-after-guangzong-road',
title: '강가의 큰소리',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 16,
lines: [
'장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
'유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
'장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
],
choices: [
{
id: 'laugh-through',
label: '호방함으로 사기를 올린다',
response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
rewardExp: 6
},
{
id: 'guard-bridgehead',
label: '교두보 수비를 맡긴다',
response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
rewardExp: 5
}
]
},
{
id: 'guan-zhang-after-guangzong-road',
title: '험지의 호흡',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 14,
lines: [
'관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
'장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
'관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
],
choices: [
{
id: 'measure-and-break',
label: '관우가 재고 장비가 뚫는다',
response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
rewardExp: 7
},
{
id: 'watch-each-other',
label: '서로의 후방을 살핀다',
response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
rewardExp: 5
}
]
},
...thirdCampCompanionDialogues.map((dialogue) => ({
...dialogue,
availableAfterBattleIds: [...dialogue.availableAfterBattleIds],
unitIds: [...dialogue.unitIds] as [string, string],
lines: [...dialogue.lines],
choices: dialogue.choices.map((choice) => ({ ...choice }))
})),
{
id: 'liu-guan-after-zhang-jue',
title: '황건의 깃발이 꺾인 밤',
@@ -8508,6 +8442,20 @@ const campVisits: CampVisitDefinition[] = [
})
)
},
{
id: thirdCampExplorationVisitId,
title: '광종 동림 출진 야영지',
location: '광종 동쪽 삼림 · 노식군 외곽 포위망',
availableAfterBattleIds: [thirdCampPreparationSourceBattleId],
availableDuringSteps: [thirdBattleReturnCampStep],
description:
'광종 본영으로 나가기 전, 작전판·보급 수레·먼저 돌아온 동료를 직접 찾아 준비합니다.',
lines: [
'추정: 노식 중랑장의 본대가 포위망을 좁히는 동안 의용군은 동쪽 삼림 선봉을 맡는다.',
'유비: 필요한 준비를 직접 살피되, 준비를 의무처럼 붙잡지는 않겠습니다.'
],
choices: []
},
{
id: 'jingzhou-scholar-rumors',
title: '형주 선비들의 모임',
@@ -11489,6 +11437,7 @@ export class CampScene extends Phaser.Scene {
private selectedVisitId = campVisits[0].id;
private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle;
private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle;
private thirdCampExplorationButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPage = 0;
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
@@ -11550,6 +11499,7 @@ export class CampScene extends Phaser.Scene {
private thirdCampPreparationBrowserOpen = false;
private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationClearButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
@@ -11636,6 +11586,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.secondBattleReliefExplorationButton = undefined;
this.thirdCampExplorationButton = undefined;
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -11763,10 +11714,16 @@ export class CampScene extends Phaser.Scene {
? 'dialogue'
: 'market';
}
const thirdExplorationProgress =
this.campaign.step === thirdBattleReturnCampStep
? thirdCampExplorationProgress(this.campaign)
: undefined;
const pendingDirectExplorationVisit = this.availableCampVisits().find(
(visit) =>
directExplorationVisitIds.has(visit.id) &&
!this.completedCampVisits().includes(visit.id)
(visit.id === thirdCampExplorationVisitId
? !thirdExplorationProgress?.entered
: !this.completedCampVisits().includes(visit.id))
);
if (!cityStay && pendingDirectExplorationVisit) {
this.selectedVisitId = pendingDirectExplorationVisit.id;
@@ -13180,7 +13137,34 @@ export class CampScene extends Phaser.Scene {
private availableCampVisits() {
const battleId = this.currentCampBattleId();
return this.filterCampEventsByStep(campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId)));
return this.filterCampEventsByStep(
campVisits
.filter((visit) => visit.availableAfterBattleIds.includes(battleId))
.map((visit) => this.campVisitWithThirdCampExploration(visit))
);
}
private campVisitWithThirdCampExploration(
visit: CampVisitDefinition
): CampVisitDefinition {
if (visit.id !== thirdCampExplorationVisitId) {
return visit;
}
const definition = resolveThirdCampExplorationDefinition(
this.campaign
);
if (!definition) {
return visit;
}
return {
...visit,
title: definition.title,
location: definition.location,
description: definition.description,
lines: definition.introLines.map(
(line) => `${line.speaker}: ${line.text}`
)
};
}
private completedAvailableVisits() {
@@ -14667,7 +14651,7 @@ export class CampScene extends Phaser.Scene {
const commandTitle = thirdCampPreparation
? thirdCampPreparation.ready
? `출진 우선 · ${thirdCampPreparation.selectedLabel}`
: '출진 우선 · 하나를 선택'
: '출진 우선 · 보너스 없음'
: firstBattleFollowup
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
: '지휘관 판단';
@@ -14677,7 +14661,7 @@ export class CampScene extends Phaser.Scene {
);
const commandSummary = thirdCampPreparation
? selectedPreparation?.priority.summary ??
'실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.'
'야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.'
: firstBattleFollowup
? firstBattleFollowup.achieved
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
@@ -14700,7 +14684,7 @@ export class CampScene extends Phaser.Scene {
this.renderThirdCampPreparationToggle(
x + width - 86,
commandY + 51,
thirdCampPreparation.ready ? '준비 변경' : '준비 선택',
thirdCampPreparation.ready ? '준비 변경' : '보너스 선택',
depth + 2
);
} else if (firstBattleFollowup) {
@@ -14787,13 +14771,13 @@ export class CampScene extends Phaser.Scene {
background.setStrokeStyle(1, palette.gold, 0.72);
this.trackSortie(
this.add.text(x + 18, y + 14, '이번 출진의 우선 준비', this.textStyle(19, '#f2e3bf', true))
this.add.text(x + 18, y + 14, '이번 출진의 선택 보너스', this.textStyle(19, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(
this.add.text(
x + 18,
y + 41,
'실제로 확인한 활동 하나를 광종 본영전 첫 3턴에 반영합니다.',
'야영지에서 완료한 활동 중 하나만 첫 3턴에 반영합니다. 미선택 출진도 가능합니다.',
this.textStyle(10, '#9fb0bf')
)
).setDepth(depth + 1);
@@ -14960,37 +14944,75 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 18,
y + height - 25,
'권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.',
this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true)
progress.ready
? '선택 사항 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.'
: '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.',
{
...this.textStyle(
10,
progress.ready ? '#a8ffd0' : '#9fb0bf',
true
),
wordWrap: {
width: progress.ready ? width - 158 : width - 36,
useAdvancedWrap: true
}
}
)
).setDepth(depth + 1);
if (progress.selectedPriorityId) {
const clearX = x + width - 70;
const clearY = y + height - 24;
const clear = this.trackSortie(
this.add.rectangle(clearX, clearY, 112, 26, 0x1a2630, 0.96)
);
clear.setDepth(depth + 2);
clear.setStrokeStyle(1, palette.blue, 0.72);
clear.setInteractive({ useHandCursor: true });
clear.on('pointerover', () => clear.setFillStyle(0x283947, 1));
clear.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96));
clear.on('pointerdown', () => this.clearThirdCampPreparationSelection());
this.thirdCampPreparationClearButton = clear;
const clearLabel = this.trackSortie(
this.add.text(
clearX,
clearY,
'선택 해제',
this.textStyle(10, '#d4dce6', true)
)
);
clearLabel.setOrigin(0.5);
clearLabel.setDepth(depth + 3);
clearLabel.setInteractive({ useHandCursor: true });
clearLabel.on('pointerover', () => clear.setFillStyle(0x283947, 1));
clearLabel.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96));
clearLabel.on(
'pointerdown',
() => this.clearThirdCampPreparationSelection()
);
}
}
private thirdCampPreparationUnavailableLabel(
reason?: ThirdCampPreparationUnavailableReason
) {
if (reason === 'report-review-required') {
return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.';
return '야영지에서 추정과 작전판을 먼저 확인하면 선택할 수 있습니다.';
}
if (reason === 'equipment-change-required') {
return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.';
return '야영지 보급 수레에서 선봉 방호구를 정비하면 선택할 수 있습니다.';
}
if (reason === 'priority-dialogue-required') {
return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.';
return '야영지에서 먼저 돌아온 동료와 결과 대화를 나누면 선택할 수 있습니다.';
}
return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.';
}
private thirdCampPreparationLockedActionLabel(
priorityId: ThirdCampPreparationPriorityId
_priorityId: ThirdCampPreparationPriorityId
) {
if (priorityId === 'equipment') {
return '장비 보기';
}
if (priorityId === 'companion') {
return '대화하기';
}
return '보고 확인';
return '야영지로';
}
private selectThirdCampPreparationPriority(
@@ -15014,41 +15036,35 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
}
private openThirdCampPreparationActivity(
priorityId: ThirdCampPreparationPriorityId
) {
if (priorityId === 'equipment') {
this.thirdCampPreparationBrowserOpen = false;
this.hideSortiePrep();
this.activeTab = 'equipment';
this.render();
this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.');
private clearThirdCampPreparationSelection() {
const result = clearThirdCampPreparationPriority();
if (!result.ok) {
this.showCampNotice(
'현재 진행에서는 선택 보너스를 해제할 수 없습니다.'
);
return;
}
if (priorityId === 'companion') {
const priorityDialogue = this.thirdBattlePriorityReturnDialogue();
if (!priorityDialogue) {
this.showCampNotice('현재 승리 기록에서 우선 귀환 대화를 확인할 수 없습니다.');
return;
}
this.thirdCampPreparationBrowserOpen = false;
this.hideSortiePrep();
this.activeTab = 'dialogue';
this.selectedDialogueId = priorityDialogue.targetDialogueId;
this.render();
this.showCampNotice('표시된 우선 귀환 대화를 마치면 동료 공명을 선택할 수 있습니다.');
return;
}
this.acknowledgePendingVictoryRewardCategories(['unlocks']);
this.campaign = getCampaignState();
this.campaign = result.campaign;
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.');
this.showCampNotice(
result.changed
? '선택 보너스를 해제했습니다. 보너스 없이 출진할 수 있습니다.'
: '현재 선택된 출진 보너스가 없습니다.'
);
this.showSortiePrep();
}
private openThirdCampPreparationActivity(
_priorityId: ThirdCampPreparationPriorityId
) {
soundDirector.playSelect();
this.thirdCampPreparationBrowserOpen = false;
this.startCampNavigation('CampVisitExplorationScene', {
visitId: thirdCampExplorationVisitId
});
}
private openFirstBattleFollowupOrderBrowser() {
if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) {
return;
@@ -20863,10 +20879,10 @@ export class CampScene extends Phaser.Scene {
...(thirdCampPreparation
? [{
label: '출진 우선 준비',
complete: thirdCampPreparation.ready,
complete: true,
detail: thirdCampPreparation.ready
? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영`
: '정보·장비·동료 중 완료한 활동 하나 선택',
: '미선택 · 보너스 없이 출진 가능',
priority: 'recommended' as const
}]
: []),
@@ -21242,6 +21258,7 @@ export class CampScene extends Phaser.Scene {
this.firstBattleFollowupSummaryText = undefined;
this.thirdCampPreparationToggleButton = undefined;
this.thirdCampPreparationCloseButton = undefined;
this.thirdCampPreparationClearButton = undefined;
this.thirdCampPreparationCardViews = [];
this.sortiePortraitRosterLayout = undefined;
this.sortieRosterToggleBounds = [];
@@ -24087,10 +24104,19 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
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();
const hasThirdCampExploration = visits.some(
(visit) => visit.id === thirdCampExplorationVisitId
);
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(
x + 24,
y + 56,
hasThirdCampExploration
? '야영지는 언제든 다시 들어갈 수 있습니다. 모든 활동은 선택 사항이며 보너스 없이 바로 출진해도 됩니다.'
: '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.',
this.textStyle(14, '#d4dce6')
));
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
}
@@ -24153,6 +24179,11 @@ export class CampScene extends Phaser.Scene {
})
);
if (visit.id === thirdCampExplorationVisitId) {
this.renderThirdCampExplorationVisit(x, y, width, height);
return;
}
if (completed) {
const chosen = this.recordedVisitChoice(visit);
if (chosen) {
@@ -24255,6 +24286,105 @@ export class CampScene extends Phaser.Scene {
});
}
private renderThirdCampExplorationVisit(
x: number,
y: number,
width: number,
height: number
) {
const exploration = this.campaign
? thirdCampExplorationProgress(this.campaign)
: undefined;
const preparation = this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const completedCount = exploration?.completedActivityCount ?? 0;
const activityCount = exploration?.activities.length ?? 3;
const guide = this.track(
this.add.rectangle(
x + 18,
y + height - 104,
width - 36,
86,
0x151f2a,
0.96
)
);
guide.setOrigin(0);
guide.setStrokeStyle(
1,
completedCount > 0 ? palette.green : palette.blue,
0.66
);
this.track(
this.add.text(
x + 32,
y + height - 92,
`준비 활동 ${completedCount}/${activityCount} · ${
preparation?.selectedLabel
? `선택 보너스: ${preparation.selectedLabel}`
: '보너스 미선택'
}`,
this.textStyle(
12,
preparation?.selectedLabel ? '#a8ffd0' : '#d4dce6',
true
)
)
);
this.track(
this.add.text(
x + 32,
y + height - 70,
'세 활동은 모두 선택 사항 · 군영으로 돌아와 보너스 없이 출진 가능',
this.textStyle(10, '#9fb0bf')
)
);
const buttonY = y + height - 34;
const button = this.track(
this.add.rectangle(
x + width / 2,
buttonY,
width - 64,
34,
0x4a371d,
0.98
)
);
button.setStrokeStyle(2, palette.gold, 0.94);
button.setInteractive({ useHandCursor: true });
const openExploration = () => {
soundDirector.playSelect();
this.startCampNavigation('CampVisitExplorationScene', {
visitId: thirdCampExplorationVisitId
});
};
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
button.on('pointerdown', openExploration);
this.thirdCampExplorationButton = button;
const ctaLabel = !exploration?.entered
? '야영지 탐색 시작'
: completedCount < activityCount
? '야영지 탐색 계속'
: '야영지 다시 둘러보기';
const label = this.track(
this.add.text(
x + width / 2,
buttonY,
ctaLabel,
this.textStyle(14, '#fff2b8', true)
)
);
label.setOrigin(0.5);
label.setInteractive({ useHandCursor: true });
label.on('pointerover', () => button.setFillStyle(0x654c25, 1));
label.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
label.on('pointerdown', openExploration);
}
private renderVisitPreparationSummary(x: number, y: number, width: number) {
const visits = this.availableCampVisits();
const completed = this.completedAvailableVisits();
@@ -24262,6 +24392,40 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 14, y + 12, '현지 준비 · 다음 전투 반영', this.textStyle(17, '#f2e3bf', true)));
if (
visits.some((visit) => visit.id === thirdCampExplorationVisitId) &&
this.campaign
) {
const exploration = thirdCampExplorationProgress(this.campaign);
const preparation = thirdCampPreparationProgress(this.campaign);
this.track(
this.add.text(
x + 14,
y + 40,
`야영지 활동 ${exploration.completedActivityCount}/${exploration.activities.length}`,
this.textStyle(
13,
exploration.completedActivityCount > 0 ? '#a8ffd0' : '#d4dce6',
true
)
)
);
this.track(
this.add.text(
x + 14,
y + 66,
preparation.selectedLabel
? `선택 보너스 · ${preparation.selectedLabel}`
: '보너스 미선택 · 그대로 출진 가능',
this.textStyle(
13,
preparation.selectedLabel ? '#a8ffd0' : '#9fb0bf',
true
)
)
);
return;
}
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 isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth;
const informationLabel = isWolongSearch
@@ -26063,6 +26227,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.secondBattleReliefExplorationButton = undefined;
this.thirdCampExplorationButton = undefined;
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityExploreButton = undefined;
@@ -26234,6 +26399,12 @@ export class CampScene extends Phaser.Scene {
: undefined;
const secondReliefChoiceId =
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
const thirdCampExplorationVisit = this.availableCampVisits().find(
(visit) => visit.id === thirdCampExplorationVisitId
);
const thirdCampExploration = this.campaign
? thirdCampExplorationProgress(this.campaign)
: undefined;
const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice()
? thirdCampPreparationProgress(this.campaign)
: undefined;
@@ -26484,6 +26655,12 @@ export class CampScene extends Phaser.Scene {
closeButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.thirdCampPreparationCloseButton
),
clearButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.thirdCampPreparationClearButton
),
clearAvailable: Boolean(
thirdCampPreparation.selectedPriorityId
),
priorities: thirdCampPreparation.availability.map((entry) => {
const view = this.thirdCampPreparationCardViews.find(
(candidate) => candidate.priorityId === entry.priority.id
@@ -26523,6 +26700,8 @@ export class CampScene extends Phaser.Scene {
browserOpen: false,
toggleButtonBounds: null,
closeButtonBounds: null,
clearButtonBounds: null,
clearAvailable: false,
priorities: []
},
firstPursuitScoutMemory: {
@@ -26586,6 +26765,42 @@ export class CampScene extends Phaser.Scene {
rewardText: this.visitRewardText(choice)
})) ?? []
},
thirdCampExploration: {
visitId: thirdCampExplorationVisitId,
available: Boolean(thirdCampExplorationVisit),
selected:
this.selectedVisitId === thirdCampExplorationVisitId,
entered: thirdCampExploration?.entered ?? false,
completedActivityIds:
thirdCampExploration?.completedActivityIds ?? [],
completedActivityCount:
thirdCampExploration?.completedActivityCount ?? 0,
selectedPriorityId:
thirdCampExploration?.selectedPriorityId ?? null,
ready: thirdCampExploration?.ready ?? false,
mode: !thirdCampExplorationVisit
? 'unavailable'
: !thirdCampExploration?.entered
? 'explore'
: (thirdCampExploration.completedActivityCount ?? 0) <
(thirdCampExploration.activities.length || 3)
? 'continue'
: 'revisit',
explorationButtonBounds:
this.sortieInteractiveObjectBoundsDebug(
this.thirdCampExplorationButton
),
explorationInteractive: Boolean(
this.thirdCampExplorationButton?.input?.enabled
),
activities:
thirdCampExploration?.activities.map((activity) => ({
id: activity.id,
label: activity.label,
completed: activity.completed,
selected: activity.selected
})) ?? []
},
selectedDialogue: selectedCampDialogue
? {
id: selectedCampDialogue.id,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,463 @@
import {
completedThirdCampExplorationActivityIds,
isThirdCampExplorationActivityId,
resolveThirdCampCompanionDialogue,
resolveThirdCampExplorationDefinition,
thirdCampExplorationActivityProgressId,
thirdCampExplorationSourceBattleId,
thirdCampExplorationVisitId,
type ThirdCampCompanionDialogueChoice,
type ThirdCampCompanionDialogueDefinition,
type ThirdCampExplorationActivityDefinition,
type ThirdCampExplorationActivityId
} from '../data/thirdCampExploration';
import {
getThirdCampPreparationPriority,
hasThirdCampPreparationSourceVictory,
resolveThirdCampPreparationMemory,
resolveThirdCampPreparationSelection,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationMemory,
type ThirdCampPreparationPriorityDefinition
} from '../data/thirdCampPreparation';
import {
applyCampBondExp,
getCampaignState,
setCampaignState,
type CampaignState
} from './campaignState';
export type ThirdCampExplorationFailure =
| 'invalid-campaign'
| 'invalid-activity'
| 'choice-required'
| 'invalid-choice'
| 'bond-unavailable'
| 'save-unavailable';
export type ThirdCampExplorationEntryResult =
| {
ok: true;
campaign: CampaignState;
changed: boolean;
}
| {
ok: false;
reason: Extract<
ThirdCampExplorationFailure,
'invalid-campaign' | 'save-unavailable'
>;
};
export type ThirdCampExplorationActivityResult =
| {
ok: true;
activity: ThirdCampExplorationActivityDefinition;
priority: ThirdCampPreparationPriorityDefinition;
memory: ThirdCampPreparationMemory;
campaign: CampaignState;
changed: boolean;
}
| {
ok: false;
reason: Extract<
ThirdCampExplorationFailure,
| 'invalid-campaign'
| 'invalid-activity'
| 'choice-required'
| 'save-unavailable'
>;
};
export type ThirdCampCompanionActivityResult =
| {
ok: true;
activity: ThirdCampExplorationActivityDefinition;
dialogue: ThirdCampCompanionDialogueDefinition;
choice: ThirdCampCompanionDialogueChoice;
rewardExp: number;
memory: ThirdCampPreparationMemory;
campaign: CampaignState;
changed: boolean;
}
| {
ok: false;
reason: Extract<
ThirdCampExplorationFailure,
| 'invalid-campaign'
| 'invalid-choice'
| 'bond-unavailable'
| 'save-unavailable'
>;
};
export function thirdCampExplorationProgress(
campaign: CampaignState = getCampaignState()
) {
const definition =
resolveThirdCampExplorationDefinition(campaign);
const completedActivityIds =
completedThirdCampExplorationActivityIds(
campaign.completedCampVisits
);
const completed = new Set(completedActivityIds);
const selection = resolveThirdCampPreparationSelection(campaign);
return {
visitId: thirdCampExplorationVisitId,
entered: campaign.completedCampVisits.includes(
thirdCampExplorationVisitId
),
completedActivityIds,
completedActivityCount: completedActivityIds.length,
activities:
definition?.activities.map((activity) => ({
...activity,
targetActorIds: [...activity.targetActorIds],
completed: completed.has(activity.id),
selected: selection?.priorityId === activity.id
})) ?? [],
selectedPriorityId: selection?.priorityId ?? null,
ready: selection?.selectable === true
};
}
export function enterThirdCampExploration():
ThirdCampExplorationEntryResult {
const snapshot = readCampaignSnapshot();
if (!snapshot) {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsThirdCampExploration(snapshot)) {
return { ok: false, reason: 'invalid-campaign' };
}
const markerIds = [thirdCampExplorationVisitId];
const companionDialogue =
resolveThirdCampCompanionDialogue(snapshot);
if (
companionDialogue &&
snapshot.completedCampDialogues.includes(companionDialogue.id)
) {
markerIds.push(
thirdCampExplorationActivityProgressId('companion')
);
}
const updated = withVisitMarkers(snapshot, markerIds);
if (
updated.completedCampVisits.length ===
snapshot.completedCampVisits.length
) {
return { ok: true, campaign: snapshot, changed: false };
}
const persisted = persistCampaign(updated, snapshot);
return persisted
? { ok: true, campaign: persisted, changed: true }
: { ok: false, reason: 'save-unavailable' };
}
export function recordThirdCampExplorationActivity(
priorityId: string
): ThirdCampExplorationActivityResult {
const snapshot = readCampaignSnapshot();
if (!snapshot) {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsThirdCampExploration(snapshot)) {
return { ok: false, reason: 'invalid-campaign' };
}
if (!isThirdCampExplorationActivityId(priorityId)) {
return { ok: false, reason: 'invalid-activity' };
}
const definition =
resolveThirdCampExplorationDefinition(snapshot);
const activity = definition?.activities.find(
(candidate) => candidate.id === priorityId
);
const priority = getThirdCampPreparationPriority(priorityId);
if (!activity || !priority) {
return { ok: false, reason: 'invalid-activity' };
}
if (
priorityId === 'companion' &&
!hasCompletedCompanionActivity(snapshot)
) {
return { ok: false, reason: 'choice-required' };
}
const persisted = persistActivitySelection(
snapshot,
priorityId
);
if (!persisted) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = selectedMemory(persisted, priorityId);
if (!memory) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
activity,
priority,
memory,
campaign: persisted,
changed:
!snapshot.completedCampVisits.includes(
thirdCampExplorationActivityProgressId(priorityId)
) ||
snapshot.thirdCampPreparationSelection?.priorityId !== priorityId
};
}
export function completeThirdCampCompanionActivity(
choiceId: string
): ThirdCampCompanionActivityResult {
const snapshot = readCampaignSnapshot();
if (!snapshot) {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsThirdCampExploration(snapshot)) {
return { ok: false, reason: 'invalid-campaign' };
}
const definition =
resolveThirdCampExplorationDefinition(snapshot);
const activity = definition?.activities.find(
(candidate) => candidate.id === 'companion'
);
const dialogue = resolveThirdCampCompanionDialogue(snapshot);
const choice = dialogue?.choices.find(
(candidate) => candidate.id === choiceId
);
if (!activity || !dialogue || !choice) {
return { ok: false, reason: 'invalid-choice' };
}
const dialogueAlreadyCompleted =
snapshot.completedCampDialogues.includes(dialogue.id);
if (!dialogueAlreadyCompleted) {
const report = applyCompanionBondReward(
snapshot,
dialogue,
choice
);
if (!report) {
return { ok: false, reason: 'bond-unavailable' };
}
}
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const afterDialogue = readCampaignSnapshot();
if (!afterDialogue) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
const persisted = persistActivitySelection(
afterDialogue,
'companion',
snapshot
);
if (!persisted) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = selectedMemory(persisted, 'companion');
if (!memory) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
activity,
dialogue,
choice,
rewardExp,
memory,
campaign: persisted,
changed:
!dialogueAlreadyCompleted ||
!snapshot.completedCampVisits.includes(
thirdCampExplorationActivityProgressId('companion')
) ||
snapshot.thirdCampPreparationSelection?.priorityId !==
'companion'
};
}
function campaignSupportsThirdCampExploration(
campaign: CampaignState
) {
const settlement =
campaign.battleHistory[thirdCampExplorationSourceBattleId];
const report = campaign.firstBattleReport;
return (
campaign.step === 'third-camp' &&
campaign.latestBattleId ===
thirdCampExplorationSourceBattleId &&
settlement?.battleId ===
thirdCampExplorationSourceBattleId &&
settlement.outcome === 'victory' &&
report?.battleId === thirdCampExplorationSourceBattleId &&
report.outcome === 'victory' &&
hasThirdCampPreparationSourceVictory(campaign) &&
Boolean(resolveThirdCampExplorationDefinition(campaign))
);
}
function hasCompletedCompanionActivity(campaign: CampaignState) {
if (
campaign.completedCampVisits.includes(
thirdCampExplorationActivityProgressId('companion')
)
) {
return true;
}
const dialogue = resolveThirdCampCompanionDialogue(campaign);
return Boolean(
dialogue &&
campaign.completedCampDialogues.includes(dialogue.id)
);
}
function persistActivitySelection(
current: CampaignState,
activityId: ThirdCampExplorationActivityId,
rollbackSnapshot: CampaignState = current
) {
const activityMarker =
thirdCampExplorationActivityProgressId(activityId);
let updated = withVisitMarkers(current, [
thirdCampExplorationVisitId,
activityMarker
]);
updated = {
...updated,
acknowledgedVictoryRewardCategories:
acknowledgedRewardCategoryForActivity(updated, activityId),
thirdCampPreparationSelection: {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId: activityId
}
};
return persistCampaign(updated, rollbackSnapshot);
}
function acknowledgedRewardCategoryForActivity(
campaign: CampaignState,
activityId: ThirdCampExplorationActivityId
) {
const category: 'unlocks' | 'equipment' | undefined =
activityId === 'information'
? 'unlocks'
: activityId === 'equipment'
? 'equipment'
: undefined;
if (!category) {
return campaign.acknowledgedVictoryRewardCategories;
}
const previous =
campaign.acknowledgedVictoryRewardCategories[
thirdCampExplorationSourceBattleId
] ?? [];
return {
...campaign.acknowledgedVictoryRewardCategories,
[thirdCampExplorationSourceBattleId]: Array.from(
new Set([...previous, category])
)
};
}
function withVisitMarkers(
campaign: CampaignState,
markerIds: readonly string[]
): CampaignState {
const completedCampVisits = Array.from(
new Set([...campaign.completedCampVisits, ...markerIds])
);
return {
...campaign,
completedCampVisits,
...(campaign.firstBattleReport
? {
firstBattleReport: {
...campaign.firstBattleReport,
completedCampVisits: Array.from(
new Set([
...campaign.firstBattleReport.completedCampVisits,
...markerIds
])
)
}
}
: {})
};
}
function selectedMemory(
campaign: CampaignState,
activityId: ThirdCampExplorationActivityId
) {
const memory = resolveThirdCampPreparationMemory({
campaign,
battleId: thirdCampPreparationTargetBattleId
});
return memory?.priorityId === activityId ? memory : undefined;
}
function applyCompanionBondReward(
snapshot: CampaignState,
dialogue: ThirdCampCompanionDialogueDefinition,
choice: ThirdCampCompanionDialogueChoice
) {
try {
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const report = applyCampBondExp(
dialogue.id,
dialogue.bondId,
rewardExp,
choice.id
);
if (!report) {
restoreCampaignSnapshot(snapshot);
}
return report;
} catch {
restoreCampaignSnapshot(snapshot);
return undefined;
}
}
function readCampaignSnapshot() {
try {
return getCampaignState();
} catch {
return undefined;
}
}
function persistCampaign(
updated: CampaignState,
rollbackSnapshot: CampaignState
) {
try {
return setCampaignState(updated);
} catch {
restoreCampaignSnapshot(rollbackSnapshot);
return undefined;
}
}
function restoreCampaignSnapshot(snapshot: CampaignState) {
try {
setCampaignState(snapshot);
} catch {
// setCampaignState restores the in-memory snapshot before persistence.
}
}