feat: resume exploration exactly and harden campaign saves
This commit is contained in:
@@ -349,7 +349,7 @@ RPG로 다듬는다.
|
||||
Canvas·WebGL의 1920x1080 CSS viewport, 100% 확대, DPR 1에서
|
||||
포인터·J·Esc 재열기, 카드 경계·비겹침·저장 불변을 자동 검증한다.
|
||||
|
||||
### 탐색 재개와 읽지 않은 도입 보존 (2026-07-28)
|
||||
### 완료한 17차 묶음: 저장한 순간으로 돌아가는 탐색 (2026-07-28)
|
||||
|
||||
- 불러오기는 허브가 아니라 저장한 지점으로 돌아와야 하고 대화 중 저장을
|
||||
막거나 허브로 돌려보내는 구현은 실패 사례라는
|
||||
@@ -371,9 +371,40 @@ RPG로 다듬는다.
|
||||
마지막 대사를 읽은 순간에만 완독 처리한다. 대화 도중 종료하면 다음
|
||||
재개에서 첫 줄부터 안전하게 다시 들려준다.
|
||||
|
||||
다음 묶음은 버전이 있는 `explorationCheckpoint`로 주인공 좌표·방향과
|
||||
대화 상대·대사 위치·선택창을 안정 ID로 보존하고, 선택·구매·공명 보상이
|
||||
재로드 경계에서도 정확히 한 번만 적용되는지 검증한다.
|
||||
- 버전이 있는 `explorationCheckpoint`를 도입해 세 막간 탐색, 서주·신야·성도
|
||||
체류, 프롤로그 마을·의용군 막사의 주인공 좌표와 방향을 정확히 보존한다.
|
||||
대화 상대와 대사 위치, 선택창, 상점, 아직 확정하지 않은 첫 군령 카드도
|
||||
문자열 원문이나 실행 함수를 저장하지 않고 안정 ID로 다시 구성한다.
|
||||
- 막사 세 곳·도시·프롤로그 두 장면마다 허용하는 대화·선택·상점 상태를
|
||||
따로 검사한다. 손상되거나 구버전인 보조 화면만 버리고 유효한 위치는
|
||||
살리며, 이미 다른 장소로 이동한 뒤 도착한 늦은 저장 요청은 기존 최신
|
||||
체크포인트와 저장 시각을 전혀 바꾸지 않는다.
|
||||
- 이동이 멈춘 때와 대화·선택·상점의 의미 있는 경계, 브라우저 숨김·종료
|
||||
직전에 체크포인트를 갱신한다. 타이틀 이어하기는 전투 저장 다음으로 이
|
||||
체크포인트를 우선해, 프롤로그 도입을 다시 거치거나 체류 허브로 밀려나는
|
||||
일을 막는다.
|
||||
- 캠프 선택과 공명 보상은 상태 변경·완료 표식·선택 직후 대화 체크포인트를
|
||||
한 번의 저장으로 확정한다. 도시의 반복 구매에는 의도 ID와 완료 영수증을
|
||||
붙여 같은 입력을 재시도해도 금전과 장비가 한 번만 바뀌게 했다. 이는
|
||||
재시도를 안전하게 만드는
|
||||
[AWS의 멱등 API 지침](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/)과
|
||||
변경 묶음이 전부 적용되거나 전부 취소되어야 한다는
|
||||
[W3C IndexedDB 트랜잭션 모델](https://www.w3.org/TR/IndexedDB/#transaction-concept)을
|
||||
로컬 저장 흐름에 맞게 적용한 것이다.
|
||||
- 전투 승전 정산을 같은 보고서로 다시 실행해도 이미 소비한 금전·장비를
|
||||
되살리거나 이후 바꾼 장비·공명·체류 위치를 과거로 되감지 않는다. 이미
|
||||
확정된 승리 뒤 늦게 들어온 패배 기록도 무시하며, 저장 실패 시 화면 속
|
||||
상태만 앞서 바뀌는 현상도 막았다. 활성 슬롯을 정본으로 먼저 저장하고
|
||||
구버전 호환 저장은 보조 사본으로 다뤄, 둘째 쓰기 실패가 유효한 진행을
|
||||
무효화하지 않게 했다.
|
||||
- Canvas·WebGL 각각 1920x1080 CSS viewport, 100% 확대, DPR 1에서 정확한
|
||||
위치·방향·대사 줄·선택창·상점·군령 카드 복원과 보상 1회성, 정상 퇴장
|
||||
시 체크포인트 제거를 자동 검증한다.
|
||||
|
||||
다음 묶음은 이야기 화면의 페이지 단위 재개와 남은 직접 상태 변경 경로를
|
||||
같은 저장 원칙으로 정리한다. 탐색 대사에는 문구나 순서가 바뀌어도 같은
|
||||
장면을 찾는 안정적인 `beatId`와 구버전 변환표를 더해, 긴 캠페인에서
|
||||
“중단해도 읽은 만큼만 이어지는” 경험을 전 구간으로 확장한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@
|
||||
"verify:narrative-continuity": "node scripts/verify-narrative-continuity.mjs",
|
||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
||||
"verify:campaign-exploration-checkpoints": "node scripts/verify-campaign-exploration-checkpoint-normalization.mjs",
|
||||
"verify:campaign-settlement-idempotency": "node scripts/verify-campaign-settlement-idempotency.mjs",
|
||||
"verify:camp-visit-resume": "node scripts/verify-camp-visit-resume-state.mjs",
|
||||
"verify:camp-visit-resume:browser": "node scripts/verify-camp-visit-resume-browser.mjs",
|
||||
"verify:camp-exploration-checkpoint:browser": "node scripts/verify-camp-exploration-checkpoint-browser.mjs",
|
||||
"verify:campaign-completion": "node scripts/verify-campaign-completion.mjs",
|
||||
"verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs",
|
||||
"verify:campaign-objectives": "node scripts/verify-campaign-objective-journal.mjs",
|
||||
@@ -39,7 +42,9 @@
|
||||
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
|
||||
"verify:city-stays": "node scripts/verify-city-stay-data.mjs",
|
||||
"verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs",
|
||||
"verify:city-purchase-idempotency": "node scripts/verify-city-purchase-idempotency.mjs",
|
||||
"verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs",
|
||||
"verify:city-stay-checkpoint:browser": "node scripts/verify-city-stay-checkpoint-browser.mjs",
|
||||
"verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs",
|
||||
"verify:xuzhou-stay-narrative": "node scripts/verify-xuzhou-stay-narrative-memory.mjs",
|
||||
"verify:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs",
|
||||
@@ -60,6 +65,8 @@
|
||||
"verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs",
|
||||
"verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs",
|
||||
"verify:prologue-village:browser": "node scripts/verify-prologue-village-browser.mjs",
|
||||
"verify:prologue-exploration-checkpoint:browser": "node scripts/verify-prologue-exploration-checkpoint-browser.mjs",
|
||||
"verify:exploration-checkpoints:browser": "pnpm run verify:camp-exploration-checkpoint:browser && pnpm run verify:city-stay-checkpoint:browser && pnpm run verify:prologue-exploration-checkpoint:browser",
|
||||
"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",
|
||||
@@ -90,7 +97,7 @@
|
||||
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.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:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && 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:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:exploration-checkpoints:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation: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",
|
||||
|
||||
1499
scripts/verify-camp-exploration-checkpoint-browser.mjs
Normal file
1499
scripts/verify-camp-exploration-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -82,15 +82,15 @@ try {
|
||||
verifyValidSetterPersistence(fixture);
|
||||
verifyNormalizationFailures(fixture);
|
||||
verifyCampaignStepClearsVisit(fixture);
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'victory');
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'defeat');
|
||||
verifySettledBattleReplayPreservesVisit(fixture, 'victory');
|
||||
verifySettledBattleReplayPreservesVisit(fixture, 'defeat');
|
||||
}
|
||||
|
||||
verifyCityMutualExclusion();
|
||||
verifyLegacySaveWithoutCampVisitField();
|
||||
|
||||
console.log(
|
||||
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.'
|
||||
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step clearing, settled battle replay preservation, city mutual exclusion, and legacy-save compatibility.'
|
||||
);
|
||||
|
||||
function verifyValidSetterPersistence(fixture) {
|
||||
@@ -271,23 +271,31 @@ try {
|
||||
);
|
||||
}
|
||||
|
||||
function verifyBattleRecordUpdateClearsVisit(fixture, outcome) {
|
||||
function verifySettledBattleReplayPreservesVisit(
|
||||
fixture,
|
||||
outcome
|
||||
) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
campaign.setActiveCampVisitId(fixture.visitId);
|
||||
|
||||
campaign.setFirstBattleReport(
|
||||
const replayedReport = campaign.setFirstBattleReport(
|
||||
battleReport(fixture.sourceBattleId, outcome)
|
||||
);
|
||||
assert.equal(
|
||||
replayedReport.outcome,
|
||||
'victory',
|
||||
`${fixture.visitId}: a settled ${outcome} replay must resolve to the existing victory.`
|
||||
);
|
||||
assert.equal(
|
||||
campaign.getCampaignState().activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.`
|
||||
fixture.visitId,
|
||||
`${fixture.visitId}: a settled ${outcome} replay must preserve the active visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
fixture.visitId,
|
||||
2,
|
||||
`${fixture.visitId}: ${outcome} battle-record persistence`
|
||||
`${fixture.visitId}: settled ${outcome} replay persistence`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
636
scripts/verify-campaign-exploration-checkpoint-normalization.mjs
Normal file
636
scripts/verify-campaign-exploration-checkpoint-normalization.mjs
Normal file
@@ -0,0 +1,636 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaign = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
const firstPursuit = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutMemory.ts'
|
||||
);
|
||||
const secondRelief = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const thirdCamp = await server.ssrLoadModule(
|
||||
'/src/game/data/thirdCampExploration.ts'
|
||||
);
|
||||
|
||||
const campFixtures = [
|
||||
{
|
||||
label: 'first camp',
|
||||
visitId: firstPursuit.firstPursuitScoutVisitId,
|
||||
sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId,
|
||||
step: 'first-camp',
|
||||
dialogueKeys: [
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'first-choice-response'
|
||||
],
|
||||
choiceModes: ['visit'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'second-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'third-companion' },
|
||||
{ type: 'shop', sourceNpcId: 'quartermaster' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'second camp',
|
||||
visitId: secondRelief.secondBattleReliefVisitId,
|
||||
sourceBattleId: secondRelief.secondBattleReliefSourceBattleId,
|
||||
step: 'second-camp',
|
||||
dialogueKeys: [
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'second-choice-response'
|
||||
],
|
||||
choiceModes: ['visit'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'city-resonance' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'third camp',
|
||||
visitId: thirdCamp.thirdCampExplorationVisitId,
|
||||
sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId,
|
||||
step: 'third-camp',
|
||||
dialogueKeys: [
|
||||
'third-intro',
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'third-companion-response'
|
||||
],
|
||||
choiceModes: ['third-companion'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'visit' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const routes = [
|
||||
...campFixtures.map((fixture) => ({
|
||||
label: fixture.label,
|
||||
state: () => validCampState(fixture),
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: fixture.visitId,
|
||||
dialogueKeys: fixture.dialogueKeys,
|
||||
choiceOverlays: fixture.choiceModes.map((mode) => ({
|
||||
type: 'choice',
|
||||
mode,
|
||||
sourceNpcId: `${fixture.label}-npc`
|
||||
})),
|
||||
shopOverlays: [],
|
||||
invalidOverlays: fixture.invalidOverlays
|
||||
})),
|
||||
{
|
||||
label: 'city',
|
||||
state: validCityState,
|
||||
scene: 'city-stay',
|
||||
contextId: 'xuzhou',
|
||||
dialogueKeys: [
|
||||
'city-interaction',
|
||||
'city-exit',
|
||||
'city-resonance-response'
|
||||
],
|
||||
choiceOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'city-resonance',
|
||||
sourceNpcId: 'city-companion'
|
||||
}
|
||||
],
|
||||
shopOverlays: [
|
||||
{
|
||||
type: 'shop',
|
||||
sourceNpcId: 'city-quartermaster'
|
||||
}
|
||||
],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'visit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'prologue village',
|
||||
state: validVillageState,
|
||||
scene: 'prologue-village',
|
||||
contextId: 'prologue-village',
|
||||
dialogueKeys: [
|
||||
'prologue-village-interaction',
|
||||
'prologue-village-oath'
|
||||
],
|
||||
choiceOverlays: [],
|
||||
shopOverlays: [],
|
||||
invalidOverlays: [
|
||||
{ type: 'choice', mode: 'city-resonance' },
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'prologue-camp-interaction',
|
||||
lineIndex: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'prologue militia camp',
|
||||
state: validMilitiaState,
|
||||
scene: 'prologue-militia-camp',
|
||||
contextId: 'prologue-militia-camp',
|
||||
dialogueKeys: [
|
||||
'prologue-camp-interaction',
|
||||
'prologue-camp-departure',
|
||||
'prologue-camp-command-response'
|
||||
],
|
||||
choiceOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
sourceNpcId: 'liu-bei'
|
||||
},
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
sourceNpcId: 'liu-bei',
|
||||
choiceId: 'mobile'
|
||||
}
|
||||
],
|
||||
shopOverlays: [],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
choiceId: 'unknown-order'
|
||||
},
|
||||
{ type: 'shop', sourceNpcId: 'camp-quartermaster' },
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'prologue-village-oath',
|
||||
lineIndex: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
verifyValidPolicyMatrix();
|
||||
verifyStoredInvalidOverlayPreservesPlayer();
|
||||
verifyInvalidSetterIsCompleteNoop();
|
||||
verifyStaleCampToCityRace();
|
||||
|
||||
console.log(
|
||||
'Verified exploration checkpoint normalization: complete per-context overlay policy matrix, stored invalid-overlay player preservation, invalid-setter state/timestamp/base+slot byte invariance, sortie-order choice validation, and stale camp-to-city write rejection.'
|
||||
);
|
||||
|
||||
function verifyValidPolicyMatrix() {
|
||||
for (const route of routes) {
|
||||
const overlays = [
|
||||
...route.dialogueKeys.map((dialogueKey) => ({
|
||||
type: 'dialogue',
|
||||
dialogueKey,
|
||||
sourceNpcId: `${route.label}-npc`,
|
||||
lineIndex: 4
|
||||
})),
|
||||
...route.choiceOverlays,
|
||||
...route.shopOverlays
|
||||
];
|
||||
|
||||
for (const overlay of overlays) {
|
||||
const checkpoint = checkpointFor(route, overlay);
|
||||
const loaded = loadRawState(
|
||||
withCheckpoint(route.state(), checkpoint)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loaded.explorationCheckpoint,
|
||||
checkpoint,
|
||||
`${route.label}: valid ${overlay.type} overlay must survive stored-save normalization.`
|
||||
);
|
||||
}
|
||||
|
||||
const checkpointWithoutOverlay = checkpointFor(route);
|
||||
const loadedWithoutOverlay = loadRawState(
|
||||
withCheckpoint(route.state(), checkpointWithoutOverlay)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loadedWithoutOverlay.explorationCheckpoint,
|
||||
checkpointWithoutOverlay,
|
||||
`${route.label}: an absent overlay must remain absent.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyStoredInvalidOverlayPreservesPlayer() {
|
||||
for (const route of routes) {
|
||||
for (const invalidOverlay of [
|
||||
...route.invalidOverlays,
|
||||
null,
|
||||
{ type: 'unknown-overlay' }
|
||||
]) {
|
||||
const rawCheckpoint = checkpointFor(
|
||||
route,
|
||||
invalidOverlay
|
||||
);
|
||||
const expectedCheckpoint = checkpointFor(route);
|
||||
const loaded = loadRawState(
|
||||
withCheckpoint(route.state(), rawCheckpoint)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loaded.explorationCheckpoint,
|
||||
expectedCheckpoint,
|
||||
`${route.label}: an incompatible stored overlay must be removed without losing the player checkpoint.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyInvalidSetterIsCompleteNoop() {
|
||||
const firstRoute = routes[0];
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
player: {
|
||||
x: -1,
|
||||
y: 512,
|
||||
direction: 'south'
|
||||
}
|
||||
},
|
||||
'invalid player coordinates'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
player: {
|
||||
x: 420,
|
||||
y: 512,
|
||||
direction: 'upward'
|
||||
}
|
||||
},
|
||||
'invalid player direction'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
checkpointFor(firstRoute, {
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'city-interaction',
|
||||
lineIndex: 0
|
||||
}),
|
||||
'context-incompatible overlay'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
contextId: campFixtures[1].visitId
|
||||
},
|
||||
'stale context'
|
||||
);
|
||||
|
||||
const militiaRoute = routes.find(
|
||||
({ scene }) => scene === 'prologue-militia-camp'
|
||||
);
|
||||
assert(militiaRoute);
|
||||
assertSetterNoop(
|
||||
militiaRoute,
|
||||
checkpointFor(militiaRoute, {
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
choiceId: 'unknown-order'
|
||||
}),
|
||||
'invalid sortie order choice id'
|
||||
);
|
||||
|
||||
const cityRoute = routes.find(
|
||||
({ scene }) => scene === 'city-stay'
|
||||
);
|
||||
assert(cityRoute);
|
||||
assertSetterNoop(
|
||||
cityRoute,
|
||||
checkpointFor(cityRoute, {
|
||||
type: 'choice',
|
||||
mode: 'city-resonance',
|
||||
choiceId: 'mobile'
|
||||
}),
|
||||
'unexpected choice id outside prologue command'
|
||||
);
|
||||
}
|
||||
|
||||
function assertSetterNoop(route, invalidCheckpoint, label) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(route.state());
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(
|
||||
checkpointFor(route, validOverlayFor(route))
|
||||
)
|
||||
);
|
||||
|
||||
const beforeState = campaign.getCampaignState();
|
||||
const beforeBase = storage.get(campaign.campaignStorageKey);
|
||||
const beforeSlot = storage.get(
|
||||
slotKey(beforeState.activeSaveSlot)
|
||||
);
|
||||
assert(beforeBase, `${route.label}: base fixture is missing.`);
|
||||
assert(beforeSlot, `${route.label}: slot fixture is missing.`);
|
||||
|
||||
const returned = campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(invalidCheckpoint)
|
||||
);
|
||||
const afterState = campaign.getCampaignState();
|
||||
|
||||
assert.deepEqual(
|
||||
returned,
|
||||
beforeState,
|
||||
`${route.label} ${label}: setter return must preserve the complete state.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
afterState,
|
||||
beforeState,
|
||||
`${route.label} ${label}: in-memory state and updatedAt must remain unchanged.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(campaign.campaignStorageKey),
|
||||
beforeBase,
|
||||
`${route.label} ${label}: base-save bytes must remain unchanged.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(slotKey(beforeState.activeSaveSlot)),
|
||||
beforeSlot,
|
||||
`${route.label} ${label}: slot-save bytes must remain unchanged.`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyStaleCampToCityRace() {
|
||||
const cityRoute = routes.find(
|
||||
({ scene }) => scene === 'city-stay'
|
||||
);
|
||||
assert(cityRoute);
|
||||
|
||||
storage.clear();
|
||||
campaign.setCampaignState(cityRoute.state());
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(
|
||||
checkpointFor(cityRoute, validOverlayFor(cityRoute))
|
||||
)
|
||||
);
|
||||
const beforeState = campaign.getCampaignState();
|
||||
const beforeBase = storage.get(campaign.campaignStorageKey);
|
||||
const beforeSlot = storage.get(
|
||||
slotKey(beforeState.activeSaveSlot)
|
||||
);
|
||||
|
||||
const staleCampCheckpoint = {
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: campFixtures[0].visitId,
|
||||
player: {
|
||||
x: 701,
|
||||
y: 601,
|
||||
direction: 'west'
|
||||
},
|
||||
overlay: {
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'visit-interaction',
|
||||
sourceNpcId: 'stale-camp-npc',
|
||||
lineIndex: 1
|
||||
}
|
||||
};
|
||||
const returned =
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
staleCampCheckpoint
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
returned,
|
||||
beforeState,
|
||||
'A stale camp pagehide write after city activation must return the unchanged city state.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
beforeState,
|
||||
'A stale camp pagehide write must not erase or replace the city checkpoint.'
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(campaign.campaignStorageKey),
|
||||
beforeBase,
|
||||
'A stale camp pagehide write must not change base-save bytes.'
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(slotKey(beforeState.activeSaveSlot)),
|
||||
beforeSlot,
|
||||
'A stale camp pagehide write must not change slot-save bytes.'
|
||||
);
|
||||
}
|
||||
|
||||
function validOverlayFor(route) {
|
||||
if (route.choiceOverlays.length > 0) {
|
||||
return route.choiceOverlays.at(-1);
|
||||
}
|
||||
return {
|
||||
type: 'dialogue',
|
||||
dialogueKey: route.dialogueKeys[0],
|
||||
sourceNpcId: `${route.label}-npc`,
|
||||
lineIndex: 1
|
||||
};
|
||||
}
|
||||
|
||||
function checkpointFor(route, overlay) {
|
||||
return {
|
||||
version: 1,
|
||||
scene: route.scene,
|
||||
contextId: route.contextId,
|
||||
player: {
|
||||
x: 420.25,
|
||||
y: 512.5,
|
||||
direction: 'south'
|
||||
},
|
||||
...(arguments.length >= 2 ? { overlay } : {}),
|
||||
savedAt: '2026-07-28T10:30:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function setterCheckpointFor(checkpoint) {
|
||||
const { savedAt: _savedAt, ...setterCheckpoint } = checkpoint;
|
||||
return setterCheckpoint;
|
||||
}
|
||||
|
||||
function withCheckpoint(state, checkpoint) {
|
||||
return {
|
||||
...state,
|
||||
explorationCheckpoint: clone(checkpoint)
|
||||
};
|
||||
}
|
||||
|
||||
function loadRawState(state) {
|
||||
storage.clear();
|
||||
const serialized = JSON.stringify(state);
|
||||
storage.set(campaign.campaignStorageKey, serialized);
|
||||
storage.set(
|
||||
slotKey(state.activeSaveSlot ?? 1),
|
||||
serialized
|
||||
);
|
||||
return campaign.loadCampaignState();
|
||||
}
|
||||
|
||||
function validCampState(fixture, slot = 2) {
|
||||
const report = battleReport(fixture.sourceBattleId);
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:00:00.000Z';
|
||||
state.step = fixture.step;
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 420;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = fixture.sourceBattleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[fixture.sourceBattleId]: battleSettlement(report)
|
||||
};
|
||||
state.activeCampVisitId = fixture.visitId;
|
||||
delete state.pendingAftermathBattleId;
|
||||
delete state.activeCityStayId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validCityState(slot = 2) {
|
||||
const battleId = 'seventh-battle-xuzhou-rescue';
|
||||
const report = battleReport(battleId);
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:05:00.000Z';
|
||||
state.step = 'seventh-camp';
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 640;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = battleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[battleId]: battleSettlement(report)
|
||||
};
|
||||
state.activeCityStayId = 'xuzhou';
|
||||
delete state.pendingAftermathBattleId;
|
||||
delete state.activeCampVisitId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validVillageState(slot = 2) {
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:10:00.000Z';
|
||||
state.step = 'prologue-town';
|
||||
state.activeSaveSlot = slot;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validMilitiaState(slot = 2) {
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:15:00.000Z';
|
||||
state.step = 'prologue-camp';
|
||||
state.activeSaveSlot = slot;
|
||||
return state;
|
||||
}
|
||||
|
||||
function battleReport(battleId) {
|
||||
const scenario = battleScenarios[battleId];
|
||||
assert(scenario, `Missing battle scenario fixture ${battleId}.`);
|
||||
const alliedUnits = scenario.units
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => clone(unit));
|
||||
return {
|
||||
battleId,
|
||||
battleTitle: scenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: 5,
|
||||
rewardGold: 100,
|
||||
defeatedEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
totalEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
objectives: [],
|
||||
units: alliedUnits,
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...clone(bond),
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: [],
|
||||
campaignRewards: {
|
||||
supplies: [],
|
||||
equipment: [],
|
||||
reputation: [],
|
||||
recruits: [],
|
||||
unlocks: []
|
||||
},
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-28T10:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function battleSettlement(report) {
|
||||
return {
|
||||
battleId: report.battleId,
|
||||
battleTitle: report.battleTitle,
|
||||
outcome: report.outcome,
|
||||
turnNumber: report.turnNumber,
|
||||
rewardGold: report.rewardGold,
|
||||
itemRewards: [...report.itemRewards],
|
||||
campaignRewards: clone(report.campaignRewards),
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
completedAt: report.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function slotKey(slot) {
|
||||
return `${campaign.campaignStorageKey}:slot-${slot}`;
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -887,6 +887,10 @@ try {
|
||||
preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
|
||||
preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong');
|
||||
storage.set(campaignStorageKey, JSON.stringify(preJianYongSave));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-${preJianYongSave.activeSaveSlot}`,
|
||||
JSON.stringify(preJianYongSave)
|
||||
);
|
||||
const repairedEarlyChoiceSave = loadCampaignState();
|
||||
const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave);
|
||||
assert(
|
||||
@@ -1152,6 +1156,10 @@ try {
|
||||
'unknown-battle'
|
||||
);
|
||||
storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-${corruptedFutureAcknowledgementSave.activeSaveSlot}`,
|
||||
JSON.stringify(corruptedFutureAcknowledgementSave)
|
||||
);
|
||||
const normalizedFutureAcknowledgementSave = loadCampaignState();
|
||||
assert(
|
||||
normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) &&
|
||||
@@ -1167,19 +1175,19 @@ try {
|
||||
});
|
||||
const revisedSettlement = getCampaignState();
|
||||
assert(
|
||||
revisedSettlement.gold === 140 &&
|
||||
revisedSettlement.inventory.Bean === 3 &&
|
||||
revisedSettlement.inventory.Wine === 1 &&
|
||||
revisedSettlement.inventory['Iron Sword'] === undefined &&
|
||||
revisedSettlement.inventory['Iron Armor'] === 1,
|
||||
`Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}`
|
||||
revisedSettlement.gold === 100 &&
|
||||
revisedSettlement.inventory.Bean === 2 &&
|
||||
revisedSettlement.inventory.Wine === undefined &&
|
||||
revisedSettlement.inventory['Iron Sword'] === 1 &&
|
||||
revisedSettlement.inventory['Iron Armor'] === undefined,
|
||||
`Expected a revised settled report to update history without replacing or resurrecting the already-committed economy: ${JSON.stringify(revisedSettlement)}`
|
||||
);
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left');
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right');
|
||||
const repeatedVisit = getCampaignState();
|
||||
assert(
|
||||
repeatedVisit.gold === 150 &&
|
||||
repeatedVisit.inventory.Bean === 5 &&
|
||||
repeatedVisit.gold === 110 &&
|
||||
repeatedVisit.inventory.Bean === 4 &&
|
||||
repeatedVisit.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' &&
|
||||
@@ -1198,8 +1206,8 @@ try {
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] });
|
||||
const visitResynced = getCampaignState();
|
||||
assert(
|
||||
visitResynced.gold === 150 &&
|
||||
visitResynced.inventory.Bean === 5 &&
|
||||
visitResynced.gold === 110 &&
|
||||
visitResynced.inventory.Bean === 4 &&
|
||||
visitResynced.completedCampVisits.includes('repeat-visit') &&
|
||||
visitResynced.firstBattleReport?.completedCampVisits.includes('repeat-visit'),
|
||||
`Expected report-only camp visit completion to resync without duplicate rewards: ${JSON.stringify(visitResynced)}`
|
||||
@@ -1207,7 +1215,7 @@ try {
|
||||
applyCampVisitReward('dirty-reward-visit', { itemRewards: ['', ' ', 'Bean -2', 'Bean +0', 'Bean x2'] });
|
||||
const dirtyRewardVisit = getCampaignState();
|
||||
assert(
|
||||
dirtyRewardVisit.inventory.Bean === 7 &&
|
||||
dirtyRewardVisit.inventory.Bean === 6 &&
|
||||
dirtyRewardVisit.inventory[''] === undefined &&
|
||||
dirtyRewardVisit.inventory['Bean -'] === undefined &&
|
||||
dirtyRewardVisit.completedCampVisits.includes('dirty-reward-visit'),
|
||||
@@ -1495,6 +1503,10 @@ try {
|
||||
corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support';
|
||||
corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1;
|
||||
storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-1`,
|
||||
JSON.stringify(corruptedContextualCommandState)
|
||||
);
|
||||
const normalizedContextualCommandState = loadCampaignState();
|
||||
assert(
|
||||
normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined &&
|
||||
@@ -1506,6 +1518,10 @@ try {
|
||||
`Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}`
|
||||
);
|
||||
storage.set(campaignStorageKey, validContextualCommandStateRaw);
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-1`,
|
||||
validContextualCommandStateRaw
|
||||
);
|
||||
loadCampaignState();
|
||||
|
||||
orderBattleReport.sortieOrder.progress[0].achieved = false;
|
||||
@@ -1556,14 +1572,14 @@ try {
|
||||
setFirstBattleReport(revisedOrderReport);
|
||||
const revisedOrderSettlement = getCampaignState();
|
||||
assert(
|
||||
revisedOrderSettlement.gold === 320 &&
|
||||
revisedOrderSettlement.inventory.Bean === 3 &&
|
||||
revisedOrderSettlement.inventory.Wine === 1 &&
|
||||
revisedOrderSettlement.inventory['Iron Sword'] === undefined &&
|
||||
revisedOrderSettlement.inventory['Iron Armor'] === 1 &&
|
||||
revisedOrderSettlement.gold === 280 &&
|
||||
revisedOrderSettlement.inventory.Bean === 2 &&
|
||||
revisedOrderSettlement.inventory.Wine === undefined &&
|
||||
revisedOrderSettlement.inventory['Iron Sword'] === 1 &&
|
||||
revisedOrderSettlement.inventory['Iron Armor'] === undefined &&
|
||||
revisedOrderSettlement.inventory['상처약'] === 1 &&
|
||||
revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1,
|
||||
`Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
|
||||
`Expected revised ordinary reward metadata to preserve the already-committed economy and separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
|
||||
);
|
||||
|
||||
setFirstBattleReport({
|
||||
@@ -1573,7 +1589,7 @@ try {
|
||||
});
|
||||
const secondOrderSameBattle = getCampaignState();
|
||||
assert(
|
||||
secondOrderSameBattle.gold === 480 &&
|
||||
secondOrderSameBattle.gold === 440 &&
|
||||
secondOrderSameBattle.inventory['상처약'] === 1 &&
|
||||
secondOrderSameBattle.inventory['탁주'] === 1 &&
|
||||
secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
|
||||
@@ -1597,7 +1613,7 @@ try {
|
||||
});
|
||||
const failedEliteReplay = getCampaignState();
|
||||
assert(
|
||||
failedEliteReplay.gold === 480 &&
|
||||
failedEliteReplay.gold === 440 &&
|
||||
failedEliteReplay.inventory['상처약'] === 1 &&
|
||||
failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
|
||||
failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false &&
|
||||
@@ -1624,7 +1640,7 @@ try {
|
||||
});
|
||||
const sameOrderDifferentBattle = getCampaignState();
|
||||
assert(
|
||||
sameOrderDifferentBattle.gold === 780 &&
|
||||
sameOrderDifferentBattle.gold === 740 &&
|
||||
sameOrderDifferentBattle.inventory['상처약'] === 2 &&
|
||||
sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) &&
|
||||
sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 &&
|
||||
@@ -1654,7 +1670,7 @@ try {
|
||||
const spentRewardReplay = getCampaignState();
|
||||
assert(
|
||||
spentRewardReplay.gold === 160 &&
|
||||
spentRewardReplay.inventory['탁주'] === 2 &&
|
||||
spentRewardReplay.inventory['탁주'] === 1 &&
|
||||
spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) &&
|
||||
spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true,
|
||||
`Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}`
|
||||
|
||||
584
scripts/verify-campaign-settlement-idempotency.mjs
Normal file
584
scripts/verify-campaign-settlement-idempotency.mjs
Normal file
@@ -0,0 +1,584 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let rejectCanonicalSlotWrites = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (
|
||||
rejectCanonicalSlotWrites &&
|
||||
key === 'heros-web:campaign-state:slot-1'
|
||||
) {
|
||||
throw new Error(
|
||||
'Simulated canonical campaign save failure.'
|
||||
);
|
||||
}
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaign = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
|
||||
const scenario =
|
||||
battleScenarios['first-battle-zhuo-commandery'];
|
||||
const report = {
|
||||
battleId: scenario.id,
|
||||
battleTitle: scenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: 4,
|
||||
rewardGold: 120,
|
||||
defeatedEnemies: 2,
|
||||
totalEnemies: 2,
|
||||
objectives: [],
|
||||
units: scenario.units.filter(
|
||||
(unit) => unit.faction === 'ally'
|
||||
),
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...bond,
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: ['Bean x2', 'Iron Sword 1'],
|
||||
campaignRewards: {
|
||||
supplies: ['Bean x2'],
|
||||
equipment: ['Iron Sword 1'],
|
||||
reputation: [],
|
||||
recruits: [],
|
||||
unlocks: []
|
||||
},
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-28T07:00:00.000Z'
|
||||
};
|
||||
|
||||
campaign.resetCampaignState();
|
||||
campaign.setFirstBattleReport(report);
|
||||
const firstSettlement = campaign.getCampaignState();
|
||||
const initialSettlementProgress =
|
||||
battleHistoryProgressOf(
|
||||
firstSettlement,
|
||||
scenario.id
|
||||
);
|
||||
assert.deepEqual(
|
||||
economyOf(firstSettlement),
|
||||
{
|
||||
gold: 120,
|
||||
inventory: {
|
||||
Bean: 2,
|
||||
'Iron Sword': 1
|
||||
}
|
||||
},
|
||||
'The first victory settlement must apply its ordinary economy exactly once.'
|
||||
);
|
||||
|
||||
campaign.setFirstBattleReport(report);
|
||||
const exactReplay = campaign.getCampaignState();
|
||||
assert.deepEqual(
|
||||
economyOf(exactReplay),
|
||||
economyOf(firstSettlement),
|
||||
'Replaying the exact same victory report must not duplicate gold or items.'
|
||||
);
|
||||
|
||||
campaign.completeCampaignAftermath(scenario.id);
|
||||
campaign.setActiveCampVisitId(
|
||||
'first-pursuit-scout-tent'
|
||||
);
|
||||
campaign.setCampaignExplorationCheckpoint({
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: 'first-pursuit-scout-tent',
|
||||
player: {
|
||||
x: 824,
|
||||
y: 716,
|
||||
direction: 'east'
|
||||
}
|
||||
});
|
||||
|
||||
const consumed = campaign.getCampaignState();
|
||||
const progressedUnit = consumed.roster[0];
|
||||
const progressedBond = consumed.bonds[0];
|
||||
assert(progressedUnit, 'Expected a settled allied unit.');
|
||||
assert(progressedBond, 'Expected a settled campaign bond.');
|
||||
consumed.gold = 0;
|
||||
consumed.inventory = {};
|
||||
progressedUnit.level = Math.min(
|
||||
99,
|
||||
progressedUnit.level + 1
|
||||
);
|
||||
progressedUnit.exp = 37;
|
||||
progressedUnit.equipment.weapon.level = Math.min(
|
||||
9,
|
||||
progressedUnit.equipment.weapon.level + 1
|
||||
);
|
||||
progressedUnit.equipment.weapon.exp = 3;
|
||||
progressedBond.level = Math.min(
|
||||
100,
|
||||
progressedBond.level + 1
|
||||
);
|
||||
progressedBond.exp = 41;
|
||||
progressedBond.battleExp += 11;
|
||||
campaign.setCampaignState(consumed);
|
||||
const consumedEconomy = economyOf(
|
||||
campaign.getCampaignState()
|
||||
);
|
||||
const progressedContext = progressAndContextOf(
|
||||
campaign.getCampaignState(),
|
||||
progressedUnit.id,
|
||||
progressedBond.id
|
||||
);
|
||||
|
||||
campaign.setFirstBattleReport(report);
|
||||
const consumedExactReplay = campaign.getCampaignState();
|
||||
assert.deepEqual(
|
||||
economyOf(consumedExactReplay),
|
||||
consumedEconomy,
|
||||
'Replaying a settled victory must not resurrect its already-consumed gold or items.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
progressAndContextOf(
|
||||
consumedExactReplay,
|
||||
progressedUnit.id,
|
||||
progressedBond.id
|
||||
),
|
||||
progressedContext,
|
||||
'Replaying a settled victory must preserve post-settlement unit, bond, navigation, and exploration progress.'
|
||||
);
|
||||
const consumedReplayProgress =
|
||||
reportAndSettlementProgressOf(
|
||||
consumedExactReplay,
|
||||
scenario.id
|
||||
);
|
||||
assert.deepEqual(
|
||||
consumedReplayProgress.firstBattleReport.units.find(
|
||||
(unit) => unit.id === progressedUnit.id
|
||||
),
|
||||
progressedContext.unit,
|
||||
'A settled victory replay must refresh firstBattleReport unit progress from the live roster.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
consumedReplayProgress.firstBattleReport.bonds.find(
|
||||
(bond) => bond.id === progressedBond.id
|
||||
),
|
||||
progressedContext.bond,
|
||||
'A settled victory replay must refresh firstBattleReport bond progress from the live campaign bonds.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
consumedReplayProgress.battleHistory,
|
||||
initialSettlementProgress,
|
||||
'A settled victory replay must preserve the original battleHistory unit and bond snapshots.'
|
||||
);
|
||||
|
||||
campaign.setFirstBattleReport({
|
||||
...report,
|
||||
rewardGold: 200,
|
||||
itemRewards: ['Bean x5', 'Iron Armor 1'],
|
||||
campaignRewards: {
|
||||
supplies: ['Bean x5'],
|
||||
equipment: ['Iron Armor 1'],
|
||||
reputation: [],
|
||||
recruits: [],
|
||||
unlocks: []
|
||||
},
|
||||
createdAt: '2026-07-28T07:05:00.000Z'
|
||||
});
|
||||
const revisedReplay = campaign.getCampaignState();
|
||||
assert.deepEqual(
|
||||
economyOf(revisedReplay),
|
||||
consumedEconomy,
|
||||
'Revising an already-settled victory report must update history without replacing or resurrecting consumed economy.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
progressAndContextOf(
|
||||
revisedReplay,
|
||||
progressedUnit.id,
|
||||
progressedBond.id
|
||||
),
|
||||
progressedContext,
|
||||
'Revising a settled victory report must not rewind live unit, bond, navigation, or exploration state.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
reportAndSettlementProgressOf(
|
||||
revisedReplay,
|
||||
scenario.id
|
||||
),
|
||||
consumedReplayProgress,
|
||||
'Revising a settled victory report must preserve both firstBattleReport and battleHistory unit/bond progress.'
|
||||
);
|
||||
|
||||
const beforeStaleDefeat = campaign.getCampaignState();
|
||||
const storedBeforeStaleDefeat = new Map(storage);
|
||||
const staleDefeatResult = campaign.setFirstBattleReport({
|
||||
...report,
|
||||
outcome: 'defeat',
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
createdAt: '2026-07-28T07:06:00.000Z'
|
||||
});
|
||||
assert.equal(
|
||||
staleDefeatResult.outcome,
|
||||
'victory',
|
||||
'A late defeat callback must resolve to the already-settled victory report.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
beforeStaleDefeat,
|
||||
'A late defeat callback must not rewind a settled victory step, active exploration context, roster, bonds, or economy.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBeforeStaleDefeat.entries()],
|
||||
'A rejected late defeat callback must not write a replacement settlement.'
|
||||
);
|
||||
|
||||
const reportlessSettlement =
|
||||
campaign.getCampaignState();
|
||||
delete reportlessSettlement.firstBattleReport;
|
||||
campaign.setCampaignState(reportlessSettlement);
|
||||
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'settled victory report recovery',
|
||||
() => campaign.setFirstBattleReport(report)
|
||||
);
|
||||
|
||||
const beforeReportlessDefeat =
|
||||
campaign.getCampaignState();
|
||||
const storedBeforeReportlessDefeat =
|
||||
new Map(storage);
|
||||
const reportlessDefeatResult =
|
||||
campaign.setFirstBattleReport({
|
||||
...report,
|
||||
outcome: 'defeat',
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
createdAt: '2026-07-28T07:07:00.000Z'
|
||||
});
|
||||
assert.equal(
|
||||
reportlessDefeatResult.outcome,
|
||||
'victory',
|
||||
'A late defeat callback must still resolve to victory when the settled battle report is missing.'
|
||||
);
|
||||
assert.equal(
|
||||
reportlessDefeatResult.rewardGold,
|
||||
beforeReportlessDefeat.battleHistory[
|
||||
scenario.id
|
||||
].rewardGold,
|
||||
'A reportless late defeat must recover its return value from the authoritative victory settlement.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
beforeReportlessDefeat,
|
||||
'A reportless late defeat must leave in-memory campaign state unchanged.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBeforeReportlessDefeat.entries()],
|
||||
'A reportless late defeat must leave canonical and mirror storage unchanged.'
|
||||
);
|
||||
|
||||
const recoveredReport =
|
||||
campaign.setFirstBattleReport(report);
|
||||
const recoveredSettlement =
|
||||
campaign.getCampaignState();
|
||||
assert.equal(
|
||||
recoveredReport.outcome,
|
||||
'victory',
|
||||
'A settled victory replay must restore a missing firstBattleReport.'
|
||||
);
|
||||
assert.equal(
|
||||
recoveredSettlement.firstBattleReport?.outcome,
|
||||
'victory',
|
||||
'The recovered firstBattleReport must persist the settled victory outcome.'
|
||||
);
|
||||
assert.equal(
|
||||
recoveredSettlement.firstBattleReport?.rewardGold,
|
||||
revisedReplay.battleHistory[
|
||||
scenario.id
|
||||
].rewardGold,
|
||||
'Recovered report rewards must come from the authoritative settlement rather than stale replay input.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
economyOf(recoveredSettlement),
|
||||
consumedEconomy,
|
||||
'Recovering a missing settled victory report must not resurrect consumed economy.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
reportAndSettlementProgressOf(
|
||||
recoveredSettlement,
|
||||
scenario.id
|
||||
),
|
||||
consumedReplayProgress,
|
||||
'Recovering a missing settled victory report must preserve firstBattleReport and battleHistory unit/bond progress.'
|
||||
);
|
||||
|
||||
const beforeMissingBond =
|
||||
campaign.getCampaignState();
|
||||
const storedBeforeMissingBond = new Map(storage);
|
||||
const missingBondResult =
|
||||
campaign.applyCampVisitReward(
|
||||
'verify-missing-bond-visit',
|
||||
{
|
||||
bondId: 'verify-missing-bond',
|
||||
bondExp: 10,
|
||||
gold: 90,
|
||||
itemRewards: ['Bean x3']
|
||||
},
|
||||
'verify-missing-bond-choice'
|
||||
);
|
||||
const afterMissingBond =
|
||||
campaign.getCampaignState();
|
||||
|
||||
assert.equal(
|
||||
missingBondResult,
|
||||
undefined,
|
||||
'A camp visit reward that requests an unavailable bond must fail.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
completionAndEconomyOf(afterMissingBond),
|
||||
completionAndEconomyOf(beforeMissingBond),
|
||||
'A missing requested bond must leave visit completion, choice history, gold, and inventory unchanged.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBeforeMissingBond.entries()],
|
||||
'A rejected missing-bond reward must not persist any partial state.'
|
||||
);
|
||||
|
||||
const rollbackFixture = campaign.getCampaignState();
|
||||
const rollbackBond = rollbackFixture.bonds.find(
|
||||
(bond) =>
|
||||
bond.unitIds.every((unitId) =>
|
||||
rollbackFixture.roster.some(
|
||||
(unit) => unit.id === unitId
|
||||
)
|
||||
)
|
||||
);
|
||||
assert(
|
||||
rollbackBond,
|
||||
'Expected a roster-backed bond for persistence rollback verification.'
|
||||
);
|
||||
rollbackBond.level = 30;
|
||||
rollbackBond.exp = 0;
|
||||
rollbackFixture.selectedSortieUnitIds = [
|
||||
...rollbackBond.unitIds
|
||||
];
|
||||
campaign.setCampaignState(rollbackFixture);
|
||||
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'sortie resonance selection',
|
||||
() =>
|
||||
campaign.setCampaignSortieResonanceSelection(
|
||||
scenario.id,
|
||||
rollbackBond.id
|
||||
)
|
||||
);
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'reserve training focus',
|
||||
() =>
|
||||
campaign.setCampaignReserveTrainingFocus(
|
||||
'class-practice'
|
||||
)
|
||||
);
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'reserve training assignment',
|
||||
() =>
|
||||
campaign.setCampaignReserveTrainingAssignment(
|
||||
rollbackFixture.roster[0].id,
|
||||
'bond-practice'
|
||||
)
|
||||
);
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'victory reward notice dismissal',
|
||||
() =>
|
||||
campaign.dismissCampaignVictoryRewardNotice(
|
||||
scenario.id
|
||||
)
|
||||
);
|
||||
const rollbackRecruit = structuredClone(
|
||||
rollbackFixture.roster[0]
|
||||
);
|
||||
rollbackRecruit.id = 'verify-rollback-recruit';
|
||||
rollbackRecruit.name = 'Rollback Recruit';
|
||||
assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
'roster expansion',
|
||||
() =>
|
||||
campaign.ensureCampaignRosterUnits([
|
||||
rollbackRecruit
|
||||
])
|
||||
);
|
||||
|
||||
console.log(
|
||||
'Campaign settlement idempotency verification passed: settled victory replays preserve report/history progress and active context, missing reports recover safely, stale defeats are ignored, missing-bond camp rewards are atomic, and canonical save failures leave memory and storage unchanged.'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function economyOf(state) {
|
||||
return {
|
||||
gold: state.gold,
|
||||
inventory: { ...state.inventory }
|
||||
};
|
||||
}
|
||||
|
||||
function completionAndEconomyOf(state) {
|
||||
return {
|
||||
gold: state.gold,
|
||||
inventory: { ...state.inventory },
|
||||
completedCampVisits: [
|
||||
...state.completedCampVisits
|
||||
],
|
||||
reportCompletedCampVisits: [
|
||||
...(state.firstBattleReport
|
||||
?.completedCampVisits ?? [])
|
||||
],
|
||||
campVisitChoiceIds: {
|
||||
...state.campVisitChoiceIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function progressAndContextOf(state, unitId, bondId) {
|
||||
const unit = state.roster.find(
|
||||
(candidate) => candidate.id === unitId
|
||||
);
|
||||
const bond = state.bonds.find(
|
||||
(candidate) => candidate.id === bondId
|
||||
);
|
||||
return {
|
||||
step: state.step,
|
||||
latestBattleId: state.latestBattleId ?? null,
|
||||
pendingAftermathBattleId:
|
||||
state.pendingAftermathBattleId ?? null,
|
||||
activeCampVisitId: state.activeCampVisitId ?? null,
|
||||
activeCityStayId: state.activeCityStayId ?? null,
|
||||
explorationCheckpoint:
|
||||
state.explorationCheckpoint ?? null,
|
||||
unit: unit
|
||||
? {
|
||||
id: unit.id,
|
||||
level: unit.level,
|
||||
exp: unit.exp,
|
||||
equipment: structuredClone(unit.equipment)
|
||||
}
|
||||
: null,
|
||||
bond: bond
|
||||
? {
|
||||
id: bond.id,
|
||||
level: bond.level,
|
||||
exp: bond.exp,
|
||||
battleExp: bond.battleExp
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function reportAndSettlementProgressOf(
|
||||
state,
|
||||
battleId
|
||||
) {
|
||||
return {
|
||||
firstBattleReport: {
|
||||
units: unitProgressOf(
|
||||
state.firstBattleReport?.units ?? []
|
||||
),
|
||||
bonds: bondProgressOf(
|
||||
state.firstBattleReport?.bonds ?? []
|
||||
)
|
||||
},
|
||||
battleHistory: battleHistoryProgressOf(
|
||||
state,
|
||||
battleId
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function battleHistoryProgressOf(state, battleId) {
|
||||
const settlement = state.battleHistory[battleId];
|
||||
return {
|
||||
units: unitProgressOf(settlement?.units ?? []),
|
||||
bonds: bondProgressOf(settlement?.bonds ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
function unitProgressOf(units) {
|
||||
return units
|
||||
.map((unit) => ({
|
||||
id: unit.id ?? unit.unitId,
|
||||
level: unit.level,
|
||||
exp: unit.exp,
|
||||
equipment: structuredClone(unit.equipment)
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function bondProgressOf(bonds) {
|
||||
return bonds
|
||||
.map((bond) => ({
|
||||
id: bond.id,
|
||||
level: bond.level,
|
||||
exp: bond.exp,
|
||||
battleExp: bond.battleExp
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function assertFailedCampaignMutationIsAtomic(
|
||||
campaign,
|
||||
label,
|
||||
mutate
|
||||
) {
|
||||
const before = campaign.getCampaignState();
|
||||
const storedBefore = new Map(storage);
|
||||
rejectCanonicalSlotWrites = true;
|
||||
try {
|
||||
assert.throws(
|
||||
mutate,
|
||||
/Simulated canonical campaign save failure/,
|
||||
`${label} must surface the canonical save failure.`
|
||||
);
|
||||
} finally {
|
||||
rejectCanonicalSlotWrites = false;
|
||||
}
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
before,
|
||||
`${label} must not leave ghost in-memory state after a failed canonical save.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBefore.entries()],
|
||||
`${label} must not mutate canonical or mirror storage after a failed canonical save.`
|
||||
);
|
||||
}
|
||||
@@ -290,7 +290,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
|
||||
const first = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:1'
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.purchaseNumber, 1);
|
||||
@@ -315,7 +316,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
|
||||
const second = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:2'
|
||||
);
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.purchaseNumber, 2);
|
||||
@@ -352,7 +354,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
rejectStorageWrites = true;
|
||||
const failed = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:failed'
|
||||
);
|
||||
rejectStorageWrites = false;
|
||||
assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' });
|
||||
|
||||
275
scripts/verify-city-purchase-idempotency.mjs
Normal file
275
scripts/verify-city-purchase-idempotency.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let rejectBaseMirrorWrites = false;
|
||||
let rejectSlotWrites = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (
|
||||
rejectSlotWrites &&
|
||||
key === 'heros-web:campaign-state:slot-1'
|
||||
) {
|
||||
throw new Error('Simulated canonical slot failure.');
|
||||
}
|
||||
if (
|
||||
rejectBaseMirrorWrites &&
|
||||
key === 'heros-web:campaign-state'
|
||||
) {
|
||||
throw new Error('Simulated compatibility mirror failure.');
|
||||
}
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaignModule = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const actionModule = await server.ssrLoadModule(
|
||||
'/src/game/state/cityStayActions.ts'
|
||||
);
|
||||
const { itemInventoryLabel } = await server.ssrLoadModule(
|
||||
'/src/game/data/battleItems.ts'
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
const initial = cityCampaign(campaignModule, 5000);
|
||||
campaignModule.setCampaignState(initial);
|
||||
|
||||
const label = itemInventoryLabel('iron-spear');
|
||||
const intentId = 'verify:xuzhou:iron-spear:1';
|
||||
const first = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.replayed, false);
|
||||
assert.equal(first.purchaseNumber, 1);
|
||||
assert.equal(first.campaign.gold, 4380);
|
||||
assert.equal(first.campaign.inventory[label], 1);
|
||||
assert.deepEqual(
|
||||
first.campaign.cityEquipmentPurchaseReceipts[intentId],
|
||||
{
|
||||
version: 1,
|
||||
intentId,
|
||||
cityStayId: 'xuzhou',
|
||||
offerId: 'city-xuzhou-iron-spear',
|
||||
purchaseNumber: 1,
|
||||
completedAt:
|
||||
first.campaign.cityEquipmentPurchaseReceipts[intentId]
|
||||
.completedAt
|
||||
}
|
||||
);
|
||||
|
||||
const replay = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(replay.ok, true);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.purchaseNumber, 1);
|
||||
assert.equal(replay.campaign.gold, 4380);
|
||||
assert.equal(replay.campaign.inventory[label], 1);
|
||||
assert.equal(
|
||||
replay.campaign.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
],
|
||||
1
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-lamellar-armor',
|
||||
intentId
|
||||
),
|
||||
{ ok: false, reason: 'intent-conflict' }
|
||||
);
|
||||
|
||||
const reloaded = campaignModule.loadCampaignState(1);
|
||||
assert.equal(reloaded.gold, 4380);
|
||||
const replayAfterReload =
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(replayAfterReload.ok, true);
|
||||
assert.equal(replayAfterReload.replayed, true);
|
||||
assert.equal(replayAfterReload.campaign.gold, 4380);
|
||||
assert.equal(replayAfterReload.campaign.inventory[label], 1);
|
||||
|
||||
const fullInventory = structuredClone(
|
||||
replayAfterReload.campaign
|
||||
);
|
||||
fullInventory.inventory[label] =
|
||||
campaignModule.campaignInventoryCap(label);
|
||||
campaignModule.setCampaignState(fullInventory);
|
||||
assert.deepEqual(
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:xuzhou:iron-spear:full'
|
||||
),
|
||||
{ ok: false, reason: 'inventory-full' }
|
||||
);
|
||||
const afterFull = campaignModule.getCampaignState();
|
||||
assert.equal(afterFull.gold, 4380);
|
||||
assert.equal(
|
||||
afterFull.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
],
|
||||
1
|
||||
);
|
||||
|
||||
const bounded = structuredClone(afterFull);
|
||||
bounded.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
] = 99;
|
||||
bounded.cityEquipmentPurchaseReceipts =
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 140 }, (_, index) => {
|
||||
const receiptIntentId =
|
||||
`verify:bounded:${String(index).padStart(3, '0')}`;
|
||||
return [
|
||||
receiptIntentId,
|
||||
{
|
||||
version: 1,
|
||||
intentId: receiptIntentId,
|
||||
cityStayId: 'xuzhou',
|
||||
offerId: 'city-xuzhou-iron-spear',
|
||||
purchaseNumber: 1,
|
||||
completedAt: new Date(
|
||||
Date.UTC(2026, 0, 1, 0, 0, index)
|
||||
).toISOString()
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
const normalized = campaignModule.setCampaignState(bounded);
|
||||
assert.equal(
|
||||
Object.keys(
|
||||
normalized.cityEquipmentPurchaseReceipts
|
||||
).length,
|
||||
128
|
||||
);
|
||||
assert(
|
||||
normalized.cityEquipmentPurchaseReceipts[
|
||||
'verify:bounded:139'
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
normalized.cityEquipmentPurchaseReceipts[
|
||||
'verify:bounded:000'
|
||||
],
|
||||
undefined
|
||||
);
|
||||
|
||||
const beforeMirrorFailure =
|
||||
campaignModule.getCampaignState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
rejectBaseMirrorWrites = true;
|
||||
const slotCanonical = campaignModule.setCampaignState({
|
||||
...beforeMirrorFailure,
|
||||
gold: 4321
|
||||
});
|
||||
rejectBaseMirrorWrites = false;
|
||||
assert.equal(slotCanonical.gold, 4321);
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
storage.get(
|
||||
`${campaignModule.campaignStorageKey}:slot-1`
|
||||
)
|
||||
).gold,
|
||||
4321
|
||||
);
|
||||
assert.notEqual(
|
||||
JSON.parse(
|
||||
storage.get(campaignModule.campaignStorageKey)
|
||||
).gold,
|
||||
4321
|
||||
);
|
||||
assert.equal(
|
||||
campaignModule.loadCampaignState().gold,
|
||||
4321,
|
||||
'Default loading must prefer the newest canonical slot over a stale base mirror.'
|
||||
);
|
||||
|
||||
const rollbackSeed = cityCampaign(
|
||||
campaignModule,
|
||||
5000
|
||||
);
|
||||
campaignModule.setCampaignState(rollbackSeed);
|
||||
const beforeSlotFailure =
|
||||
campaignModule.getCampaignState();
|
||||
const storedBeforeSlotFailure = new Map(storage);
|
||||
rejectSlotWrites = true;
|
||||
const rejectedPurchase =
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:xuzhou:slot-failure'
|
||||
);
|
||||
rejectSlotWrites = false;
|
||||
assert.deepEqual(rejectedPurchase, {
|
||||
ok: false,
|
||||
reason: 'save-unavailable'
|
||||
});
|
||||
assert.deepEqual(
|
||||
campaignModule.getCampaignState(),
|
||||
beforeSlotFailure,
|
||||
'A failed canonical write must not mutate in-memory gold, inventory, counts, or receipts.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBeforeSlotFailure.entries()],
|
||||
'A failed canonical write must leave both stored copies unchanged.'
|
||||
);
|
||||
|
||||
console.log(
|
||||
'City purchase idempotency verification passed (single-charge replay, intent conflict rejection, persisted receipts, inventory cap protection, bounded receipt normalization, canonical slot ordering, newest-save loading, and failed-write rollback).'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function cityCampaign(campaignModule, gold) {
|
||||
const state = campaignModule.createInitialCampaignState();
|
||||
state.step = 'seventh-camp';
|
||||
state.latestBattleId = 'seventh-battle-xuzhou-rescue';
|
||||
state.activeCityStayId = 'xuzhou';
|
||||
state.gold = gold;
|
||||
state.battleHistory[
|
||||
'seventh-battle-xuzhou-rescue'
|
||||
] = {
|
||||
battleId: 'seventh-battle-xuzhou-rescue',
|
||||
battleTitle: 'Xuzhou rescue',
|
||||
outcome: 'victory',
|
||||
turnNumber: 18,
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
completedAt: new Date().toISOString()
|
||||
};
|
||||
return state;
|
||||
}
|
||||
1252
scripts/verify-city-stay-checkpoint-browser.mjs
Normal file
1252
scripts/verify-city-stay-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1196
scripts/verify-prologue-exploration-checkpoint-browser.mjs
Normal file
1196
scripts/verify-prologue-exploration-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ const checks = [
|
||||
'scripts/verify-flow-segment-data.mjs',
|
||||
'scripts/verify-campaign-flow-data.mjs',
|
||||
'scripts/verify-campaign-save-normalization.mjs',
|
||||
'scripts/verify-campaign-exploration-checkpoint-normalization.mjs',
|
||||
'scripts/verify-campaign-settlement-idempotency.mjs',
|
||||
'scripts/verify-camp-visit-resume-state.mjs',
|
||||
'scripts/verify-campaign-completion.mjs',
|
||||
'scripts/verify-campaign-presentation-profiles.mjs',
|
||||
@@ -25,6 +27,7 @@ const checks = [
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-city-stay-data.mjs',
|
||||
'scripts/verify-city-equipment-preparation.mjs',
|
||||
'scripts/verify-city-purchase-idempotency.mjs',
|
||||
'scripts/verify-xuzhou-stay-battle-payoff.mjs',
|
||||
'scripts/verify-xuzhou-stay-narrative-memory.mjs',
|
||||
'scripts/verify-prologue-village-data.mjs',
|
||||
|
||||
@@ -128,6 +128,7 @@ try {
|
||||
delete legacySave.acknowledgedVictoryRewardCategories;
|
||||
delete legacySave.dismissedVictoryRewardNoticeBattleIds;
|
||||
values.set(campaignStorageKey, JSON.stringify(legacySave));
|
||||
values.set(`${campaignStorageKey}:slot-${legacySave.activeSaveSlot}`, JSON.stringify(legacySave));
|
||||
const migratedLegacySave = loadCampaignState();
|
||||
assert(
|
||||
migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) &&
|
||||
@@ -150,6 +151,7 @@ try {
|
||||
'unknown-battle'
|
||||
];
|
||||
values.set(campaignStorageKey, JSON.stringify(partialSave));
|
||||
values.set(`${campaignStorageKey}:slot-${partialSave.activeSaveSlot}`, JSON.stringify(partialSave));
|
||||
const normalizedPartialSave = loadCampaignState();
|
||||
assert(
|
||||
JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) &&
|
||||
|
||||
@@ -223,6 +223,7 @@ import {
|
||||
acknowledgeCampaignVictoryReward,
|
||||
applyCampVisitReward,
|
||||
applyCampBondExp,
|
||||
campaignInventoryCap,
|
||||
campaignVictoryRewardPresentation,
|
||||
campaignSortiePresetIds,
|
||||
campaignReserveTrainingFocusDefinitions,
|
||||
@@ -24572,8 +24573,17 @@ export class CampScene extends Phaser.Scene {
|
||||
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 inventoryLabel = itemInventoryLabel(item.id);
|
||||
const owned = campaign.inventory[inventoryLabel] ?? 0;
|
||||
const purchaseCount =
|
||||
campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0;
|
||||
const inventoryFull =
|
||||
owned >= campaignInventoryCap(inventoryLabel);
|
||||
const purchaseLimitReached = purchaseCount >= 99;
|
||||
const canBuy =
|
||||
campaign.gold >= offer.price &&
|
||||
!inventoryFull &&
|
||||
!purchaseLimitReached;
|
||||
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);
|
||||
@@ -24599,15 +24609,30 @@ export class CampScene extends Phaser.Scene {
|
||||
const buttonText = this.track(this.add.text(
|
||||
x + width - 66,
|
||||
rowY + 24,
|
||||
canBuy ? '구매' : '부족',
|
||||
canBuy
|
||||
? '구매'
|
||||
: inventoryFull
|
||||
? '가득'
|
||||
: purchaseLimitReached
|
||||
? '한도'
|
||||
: '부족',
|
||||
this.textStyle(12, canBuy ? '#fff2b8' : '#7f8994', true)
|
||||
));
|
||||
buttonText.setOrigin(0.5);
|
||||
if (canBuy) {
|
||||
const purchaseIntentId =
|
||||
this.createCityPurchaseIntentId(offer.id);
|
||||
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));
|
||||
button.on(
|
||||
'pointerdown',
|
||||
() =>
|
||||
this.buyCityEquipment(
|
||||
offer,
|
||||
purchaseIntentId
|
||||
)
|
||||
);
|
||||
}
|
||||
this.cityEquipmentOfferViews.push({ offerId: offer.id, itemId: offer.itemId, buyButton: button });
|
||||
});
|
||||
@@ -24712,6 +24737,12 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showCampNotice('이미 완료된 공명 대화입니다.');
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'save-unavailable') {
|
||||
this.showCampNotice(
|
||||
'공명 선택을 저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
@@ -24722,13 +24753,20 @@ export class CampScene extends Phaser.Scene {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private buyCityEquipment(offer: CityEquipmentOffer) {
|
||||
private buyCityEquipment(
|
||||
offer: CityEquipmentOffer,
|
||||
purchaseIntentId: string
|
||||
) {
|
||||
const cityStay = this.currentCityStay();
|
||||
if (!cityStay) {
|
||||
this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.');
|
||||
return;
|
||||
}
|
||||
const result = purchaseCityStayEquipment(cityStay.id, offer.id);
|
||||
const result = purchaseCityStayEquipment(
|
||||
cityStay.id,
|
||||
offer.id,
|
||||
purchaseIntentId
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'restricted-item') {
|
||||
this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.');
|
||||
@@ -24738,19 +24776,54 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showCampNotice('군자금이 부족합니다.');
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'inventory-full') {
|
||||
this.showCampNotice('이 장비는 더 보관할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'purchase-limit-reached') {
|
||||
this.showCampNotice('이 도시의 구매 한도에 도달했습니다.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.reason === 'intent-conflict' ||
|
||||
result.reason === 'invalid-purchase-intent'
|
||||
) {
|
||||
this.showCampNotice(
|
||||
'구매 요청을 확인할 수 없습니다. 다시 선택해 주세요.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.campaign = result.campaign;
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
soundDirector.playSelect();
|
||||
if (!result.replayed) {
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
this.showCampNotice(
|
||||
`${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
||||
result.replayed
|
||||
? `${result.cityStay.city.name} 시장 · ${result.item.name} 구매 기록을 확인했습니다.`
|
||||
: `${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private createCityPurchaseIntentId(offerId: string) {
|
||||
const randomPart =
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 12)}`;
|
||||
return [
|
||||
'camp-city-purchase',
|
||||
this.currentCityStay()?.id ?? 'unknown',
|
||||
offerId,
|
||||
randomPart
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private renderVisitPanel() {
|
||||
const x = 394;
|
||||
const y = 120;
|
||||
|
||||
@@ -51,8 +51,12 @@ import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import {
|
||||
getCampaignState,
|
||||
saveCampaignState,
|
||||
setCampaignExplorationCheckpoint,
|
||||
setActiveCampVisitId,
|
||||
type CampaignCampVisitId
|
||||
type CampaignCampVisitId,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignExplorationDialogueKey
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions';
|
||||
@@ -151,8 +155,9 @@ type DialogueLine = {
|
||||
type DialogueState = {
|
||||
lines: DialogueLine[];
|
||||
lineIndex: number;
|
||||
onComplete?: () => void;
|
||||
onComplete?: () => boolean | void;
|
||||
sourceNpcId?: ExplorationActorId | 'camp-exit';
|
||||
dialogueKey: CampaignExplorationDialogueKey;
|
||||
};
|
||||
|
||||
type ChoiceView = {
|
||||
@@ -360,6 +365,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private visualMotionReduced = false;
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
private checkpointLastPlayerSignature = '';
|
||||
private checkpointRestoring = false;
|
||||
private readonly persistCheckpointOnPageHide = () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
};
|
||||
private readonly persistCheckpointWhenHidden = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('CampVisitExplorationScene');
|
||||
@@ -404,16 +419,33 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
create() {
|
||||
this.resetRuntimeState();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
window.removeEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.clearNavigationTarget();
|
||||
this.explorationInput?.destroy();
|
||||
this.explorationInput = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.closeChoicePanel();
|
||||
this.closeChoicePanel(false);
|
||||
releaseExplorationCharacterTextureKeys(
|
||||
this,
|
||||
this.currentCharacterTextureKeys()
|
||||
@@ -460,6 +492,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.createDialoguePanel();
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
const restoredCheckpoint =
|
||||
this.restoreExplorationCheckpoint();
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshActorMarkers();
|
||||
@@ -480,27 +514,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
ambienceVolume: 0.12
|
||||
}
|
||||
);
|
||||
if (this.isThirdCampVisit() && this.thirdCampIntroPending) {
|
||||
const definition = this.thirdCampDefinition();
|
||||
this.startDialogue(
|
||||
definition ? [...definition.introLines] : [],
|
||||
() => {
|
||||
const completion = completeThirdCampExplorationIntro();
|
||||
if (!completion.ok) {
|
||||
this.showCompletionToast(
|
||||
'도입 기록을 저장하지 못했습니다. 다음 방문 때 다시 들려드릴게요.',
|
||||
'error',
|
||||
2400
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.showCompletionToast(
|
||||
'세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.',
|
||||
'guide',
|
||||
2100
|
||||
);
|
||||
}
|
||||
);
|
||||
if (restoredCheckpoint.overlayRestored) {
|
||||
this.lastNotice = '저장한 탐험 지점에서 이어왔습니다.';
|
||||
} else if (this.isThirdCampVisit() && this.thirdCampIntroPending) {
|
||||
this.startThirdCampIntroDialogue();
|
||||
} else {
|
||||
this.showCompletionToast(
|
||||
this.isThirdCampVisit()
|
||||
@@ -867,6 +884,14 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
campaign.thirdCampPreparationSelection
|
||||
? { ...campaign.thirdCampPreparationSelection }
|
||||
: null,
|
||||
explorationCheckpoint:
|
||||
campaign.explorationCheckpoint
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
campaign.explorationCheckpoint
|
||||
)
|
||||
)
|
||||
: null,
|
||||
inventory: { ...campaign.inventory }
|
||||
},
|
||||
blockers: this.blockers.map((blocker) =>
|
||||
@@ -1182,6 +1207,409 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.stepIndex = 0;
|
||||
this.lastStepAt = 0;
|
||||
this.lastNotice = '';
|
||||
this.checkpointLastPlayerSignature = '';
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
|
||||
private persistExplorationCheckpoint(force = false) {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.checkpointRestoring ||
|
||||
!this.player
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const campaign = getCampaignState();
|
||||
if (campaign.activeCampVisitId !== this.visitId) {
|
||||
return;
|
||||
}
|
||||
const overlay: CampaignExplorationCheckpoint['overlay'] =
|
||||
this.dialogueState
|
||||
? {
|
||||
type: 'dialogue',
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
...(this.dialogueState.sourceNpcId
|
||||
? {
|
||||
sourceNpcId:
|
||||
this.dialogueState.sourceNpcId
|
||||
}
|
||||
: {}),
|
||||
lineIndex: this.dialogueState.lineIndex
|
||||
}
|
||||
: this.choicePanel
|
||||
? {
|
||||
type: 'choice',
|
||||
mode: this.choicePanelMode,
|
||||
...(this.choiceSourceNpcId
|
||||
? { sourceNpcId: this.choiceSourceNpcId }
|
||||
: {})
|
||||
}
|
||||
: undefined;
|
||||
const checkpoint: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
> = {
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: this.visitId as CampaignCampVisitId,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
...(overlay ? { overlay } : {})
|
||||
};
|
||||
const signature = JSON.stringify(checkpoint);
|
||||
if (!force && signature === this.checkpointLastPlayerSignature) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCampaignExplorationCheckpoint(checkpoint);
|
||||
this.checkpointLastPlayerSignature = signature;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the camp exploration checkpoint.',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private explorationDialogueCheckpoint(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
sourceNpcId?: string
|
||||
) {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: this.visitId as CampaignCampVisitId,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
overlay: {
|
||||
type: 'dialogue',
|
||||
dialogueKey,
|
||||
...(sourceNpcId ? { sourceNpcId } : {}),
|
||||
lineIndex: 0
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private explorationPositionCheckpoint() {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: this.visitId as CampaignCampVisitId,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private restoreExplorationCheckpoint() {
|
||||
const checkpoint = getCampaignState().explorationCheckpoint;
|
||||
if (
|
||||
!checkpoint ||
|
||||
checkpoint.scene !== 'camp-visit-exploration' ||
|
||||
checkpoint.contextId !== this.visitId ||
|
||||
!this.player
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return {
|
||||
positionRestored: false,
|
||||
overlayRestored: false
|
||||
};
|
||||
}
|
||||
|
||||
this.checkpointRestoring = true;
|
||||
let positionRestored = false;
|
||||
let overlayRestored = false;
|
||||
try {
|
||||
if (
|
||||
this.canPlayerOccupy(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
)
|
||||
) {
|
||||
this.player.setPosition(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
);
|
||||
this.playerDirection = checkpoint.player.direction;
|
||||
this.syncPlayerView();
|
||||
this.setPlayerMoving(false);
|
||||
positionRestored = true;
|
||||
}
|
||||
overlayRestored = checkpoint.overlay
|
||||
? this.restoreCheckpointOverlay(checkpoint.overlay)
|
||||
: false;
|
||||
} finally {
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return { positionRestored, overlayRestored };
|
||||
}
|
||||
|
||||
private restoreCheckpointOverlay(
|
||||
overlay: CampaignExplorationCheckpoint['overlay']
|
||||
) {
|
||||
if (!overlay) {
|
||||
return false;
|
||||
}
|
||||
if (overlay.type === 'shop') {
|
||||
return false;
|
||||
}
|
||||
if (overlay.type === 'choice') {
|
||||
if (
|
||||
overlay.mode !== 'visit' &&
|
||||
overlay.mode !== 'third-companion'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
overlay.mode === 'visit' &&
|
||||
(
|
||||
this.visitComplete() ||
|
||||
(
|
||||
this.isSecondReliefVisit() &&
|
||||
!secondBattleReliefProgress().choiceReady
|
||||
)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
overlay.mode === 'third-companion' &&
|
||||
thirdCampExplorationProgress().completedActivityIds.includes(
|
||||
'companion'
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceView = overlay.sourceNpcId
|
||||
? this.actorViews.get(overlay.sourceNpcId)
|
||||
: undefined;
|
||||
if (sourceView) {
|
||||
this.faceCharactersForConversation(sourceView);
|
||||
}
|
||||
this.openChoicePanel(
|
||||
overlay.mode,
|
||||
overlay.sourceNpcId
|
||||
);
|
||||
return Boolean(this.choicePanel);
|
||||
}
|
||||
|
||||
if (overlay.dialogueKey === 'third-intro') {
|
||||
if (
|
||||
!this.isThirdCampVisit() ||
|
||||
!this.thirdCampIntroPending
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.startThirdCampIntroDialogue(overlay.lineIndex);
|
||||
} else if (
|
||||
overlay.dialogueKey === 'visit-interaction'
|
||||
) {
|
||||
if (!overlay.sourceNpcId) {
|
||||
return false;
|
||||
}
|
||||
const target = this.interactionTargetById(
|
||||
overlay.sourceNpcId
|
||||
);
|
||||
if (!target || target.kind !== 'actor') {
|
||||
return false;
|
||||
}
|
||||
this.interactWithTarget(target);
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else if (overlay.dialogueKey === 'camp-exit') {
|
||||
this.interactWithExit();
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else {
|
||||
this.startChoiceResponseDialogue(
|
||||
overlay.dialogueKey,
|
||||
overlay.sourceNpcId,
|
||||
overlay.lineIndex
|
||||
);
|
||||
}
|
||||
return Boolean(
|
||||
this.dialogueState?.dialogueKey ===
|
||||
overlay.dialogueKey
|
||||
);
|
||||
}
|
||||
|
||||
private restoreDialogueLine(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
lineIndex: number
|
||||
) {
|
||||
const dialogue = this.dialogueState;
|
||||
if (!dialogue || dialogue.dialogueKey !== dialogueKey) {
|
||||
return;
|
||||
}
|
||||
dialogue.lineIndex = Phaser.Math.Clamp(
|
||||
Math.floor(lineIndex),
|
||||
0,
|
||||
dialogue.lines.length - 1
|
||||
);
|
||||
this.renderDialogueLine();
|
||||
}
|
||||
|
||||
private startThirdCampIntroDialogue(initialLineIndex = 0) {
|
||||
const definition = this.thirdCampDefinition();
|
||||
this.startDialogue(
|
||||
definition ? [...definition.introLines] : [],
|
||||
() => {
|
||||
const completion = completeThirdCampExplorationIntro(
|
||||
this.explorationPositionCheckpoint()
|
||||
);
|
||||
if (!completion.ok) {
|
||||
this.showCompletionToast(
|
||||
'도입 기록을 저장하지 못했습니다. 다음 방문 때 다시 들려드릴게요.',
|
||||
'error',
|
||||
2400
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.showCompletionToast(
|
||||
'세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.',
|
||||
'guide',
|
||||
2100
|
||||
);
|
||||
return true;
|
||||
},
|
||||
undefined,
|
||||
'third-intro',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private startChoiceResponseDialogue(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
sourceNpcId: string | undefined,
|
||||
initialLineIndex: number
|
||||
) {
|
||||
const campaign = getCampaignState();
|
||||
if (dialogueKey === 'first-choice-response') {
|
||||
const choiceId =
|
||||
campaign.campVisitChoiceIds[firstPursuitScoutVisitId];
|
||||
const choice = choiceId
|
||||
? getFirstPursuitScoutVisitChoice(choiceId)
|
||||
: undefined;
|
||||
if (!choice) {
|
||||
return;
|
||||
}
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '간옹',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
text: choice.response.replace(/^간옹:\s*/, '')
|
||||
},
|
||||
{
|
||||
speaker: '정찰 기록',
|
||||
text: `${choice.label} · 공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`
|
||||
}
|
||||
],
|
||||
() =>
|
||||
this.showCompletionToast(
|
||||
'정찰 기록이 출진 준비와 다음 전투에 반영됩니다.'
|
||||
),
|
||||
sourceNpcId ?? 'jian-yong',
|
||||
dialogueKey,
|
||||
initialLineIndex
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dialogueKey === 'second-choice-response') {
|
||||
const choiceId =
|
||||
campaign.campVisitChoiceIds[secondBattleReliefVisitId];
|
||||
const choice = choiceId
|
||||
? getSecondBattleReliefChoice(choiceId)
|
||||
: undefined;
|
||||
if (!choice) {
|
||||
return;
|
||||
}
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '간옹',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
text: choice.response
|
||||
},
|
||||
{
|
||||
speaker: '현장 수습 기록',
|
||||
text: `${choice.label} · 공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`
|
||||
}
|
||||
],
|
||||
() =>
|
||||
this.showCompletionToast(
|
||||
'수습 방침이 광종 구원로의 출진 준비에 반영됩니다.'
|
||||
),
|
||||
sourceNpcId ?? 'jian-yong-records',
|
||||
dialogueKey,
|
||||
initialLineIndex
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dialogueKey !== 'third-companion-response') {
|
||||
return;
|
||||
}
|
||||
const dialogue =
|
||||
resolveThirdCampCompanionDialogue(campaign);
|
||||
const choiceId = dialogue
|
||||
? campaign.campDialogueChoiceIds[dialogue.id]
|
||||
: undefined;
|
||||
const choice = dialogue?.choices.find(
|
||||
(candidate) => candidate.id === choiceId
|
||||
);
|
||||
const activity = this.thirdCampDefinition()?.activities.find(
|
||||
(candidate) => candidate.id === 'companion'
|
||||
);
|
||||
if (!dialogue || !choice || !activity) {
|
||||
return;
|
||||
}
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '출진 약속',
|
||||
text: choice.response
|
||||
},
|
||||
{
|
||||
speaker: '공명 기록',
|
||||
text: `${choice.label} · 공명 +${rewardExp} · 군영 장부에서 초반 협공 보너스로 선택 가능`
|
||||
}
|
||||
],
|
||||
() =>
|
||||
this.showCompletionToast(
|
||||
activity.completionLine,
|
||||
'success',
|
||||
2100
|
||||
),
|
||||
sourceNpcId,
|
||||
dialogueKey,
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private visitAvailable() {
|
||||
@@ -2899,6 +3327,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
if (!this.player) {
|
||||
return;
|
||||
}
|
||||
const wasMoving = this.playerMoving;
|
||||
applyExplorationCharacterMotion(
|
||||
this.player,
|
||||
playerTextureKey,
|
||||
@@ -2907,6 +3336,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.visualMotionReduced
|
||||
);
|
||||
this.playerMoving = moving;
|
||||
if (
|
||||
wasMoving &&
|
||||
!moving &&
|
||||
!this.checkpointRestoring
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlayerMovement() {
|
||||
@@ -2932,6 +3368,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.player.setPosition(x, y);
|
||||
this.syncPlayerView();
|
||||
this.stopPlayerMovement();
|
||||
if (!this.checkpointRestoring) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
}
|
||||
|
||||
private safeApproachPosition(targetX: number, targetY: number) {
|
||||
@@ -3029,7 +3468,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
[...lines],
|
||||
() => {
|
||||
this.restoreActorDirection(view);
|
||||
this.completeThirdCampActivity(activityId);
|
||||
return this.completeThirdCampActivity(activityId);
|
||||
},
|
||||
view.definition.id
|
||||
);
|
||||
@@ -3039,7 +3478,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
activityId: ThirdCampExplorationActivityId
|
||||
) {
|
||||
const before = thirdCampExplorationProgress();
|
||||
const result = recordThirdCampExplorationActivity(activityId);
|
||||
const result = recordThirdCampExplorationActivity(
|
||||
activityId,
|
||||
this.explorationPositionCheckpoint()
|
||||
);
|
||||
if (!result.ok) {
|
||||
const message =
|
||||
result.reason === 'choice-required'
|
||||
@@ -3050,7 +3492,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
? '현재 군영에서는 이 준비를 진행할 수 없습니다.'
|
||||
: '준비 기록을 저장하지 못했습니다.';
|
||||
this.showCompletionToast(message, 'error');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const newlyCompleted =
|
||||
@@ -3073,6 +3515,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
'success',
|
||||
newlyCompleted ? 2100 : 1750
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private interactWithReliefActor(view: ExplorationActorView) {
|
||||
@@ -3145,7 +3588,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
dialogueLines,
|
||||
() => {
|
||||
this.restoreActorDirection(view);
|
||||
this.completeReliefObjective(objective.id);
|
||||
return this.completeReliefObjective(objective.id);
|
||||
},
|
||||
view.definition.id
|
||||
);
|
||||
@@ -3176,7 +3619,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
private completeReliefObjective(
|
||||
objectiveId: SecondBattleReliefObjectiveId
|
||||
) {
|
||||
const result = recordSecondBattleReliefObjective(objectiveId);
|
||||
const result = recordSecondBattleReliefObjective(
|
||||
objectiveId,
|
||||
this.explorationPositionCheckpoint()
|
||||
);
|
||||
if (!result.ok) {
|
||||
const message =
|
||||
result.reason === 'prerequisites-incomplete'
|
||||
@@ -3187,7 +3633,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.showCompletionToast(message, 'error');
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshActorMarkers();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
@@ -3197,6 +3643,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.refreshActorMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
this.showCompletionToast(result.objective.completionLine, 'success', 1900);
|
||||
return true;
|
||||
}
|
||||
|
||||
private interactWithJianYong(view: ExplorationActorView) {
|
||||
@@ -3271,8 +3718,11 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
|
||||
private startDialogue(
|
||||
lines: DialogueLine[],
|
||||
onComplete?: () => void,
|
||||
sourceNpcId?: DialogueState['sourceNpcId']
|
||||
onComplete?: () => boolean | void,
|
||||
sourceNpcId?: DialogueState['sourceNpcId'],
|
||||
dialogueKey: CampaignExplorationDialogueKey =
|
||||
'visit-interaction',
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
if (
|
||||
lines.length === 0 ||
|
||||
@@ -3284,15 +3734,21 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.stopPlayerMovement();
|
||||
this.dialogueState = {
|
||||
lines,
|
||||
lineIndex: 0,
|
||||
lineIndex: Phaser.Math.Clamp(
|
||||
Math.floor(initialLineIndex),
|
||||
0,
|
||||
lines.length - 1
|
||||
),
|
||||
onComplete,
|
||||
sourceNpcId
|
||||
sourceNpcId,
|
||||
dialogueKey
|
||||
};
|
||||
this.dialoguePanel?.setVisible(true);
|
||||
this.promptBackground?.setVisible(false);
|
||||
this.promptText?.setVisible(false);
|
||||
soundDirector.playSelect();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private renderDialogueLine() {
|
||||
@@ -3350,13 +3806,17 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
dialogue.lineIndex += 1;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
const onComplete = dialogue.onComplete;
|
||||
this.dialogueState = undefined;
|
||||
this.dialoguePanel?.setVisible(false);
|
||||
this.stopPlayerMovement();
|
||||
onComplete?.();
|
||||
const checkpointCommitted = onComplete?.() === true;
|
||||
if (!checkpointCommitted) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
@@ -3371,6 +3831,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.restoreActorDirection(view);
|
||||
}
|
||||
}
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
@@ -3520,6 +3981,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.choicePanelMode = mode;
|
||||
this.choiceSourceNpcId = sourceNpcId;
|
||||
this.choiceReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private chooseChoiceByIndex(index: number) {
|
||||
@@ -3571,7 +4033,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = completeFirstPursuitScoutVisit(choice.id);
|
||||
const result = completeFirstPursuitScoutVisit(
|
||||
choice.id,
|
||||
this.explorationDialogueCheckpoint(
|
||||
'first-choice-response',
|
||||
'jian-yong'
|
||||
)
|
||||
);
|
||||
if (!result.ok) {
|
||||
this.closeChoicePanel();
|
||||
const message =
|
||||
@@ -3587,7 +4055,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.closeChoicePanel();
|
||||
this.closeChoicePanel(false);
|
||||
this.lastNotice = `정찰 완료 · ${result.choice.label}`;
|
||||
soundDirector.playEffect('bond-resonance', {
|
||||
volume: 0.28,
|
||||
@@ -3611,7 +4079,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.showCompletionToast(
|
||||
'정찰 기록이 출진 준비와 다음 전투에 반영됩니다.'
|
||||
),
|
||||
'jian-yong'
|
||||
'jian-yong',
|
||||
'first-choice-response'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3622,7 +4091,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = completeSecondBattleReliefExploration(choice.id);
|
||||
const result = completeSecondBattleReliefExploration(
|
||||
choice.id,
|
||||
this.explorationDialogueCheckpoint(
|
||||
'second-choice-response',
|
||||
'jian-yong-records'
|
||||
)
|
||||
);
|
||||
if (!result.ok) {
|
||||
this.closeChoicePanel();
|
||||
const message =
|
||||
@@ -3640,7 +4115,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.closeChoicePanel();
|
||||
this.closeChoicePanel(false);
|
||||
this.lastNotice = `수습 완료 · ${result.choice.label}`;
|
||||
soundDirector.playEffect('bond-resonance', {
|
||||
volume: 0.28,
|
||||
@@ -3664,7 +4139,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.showCompletionToast(
|
||||
'수습 방침이 광종 구원로의 출진 준비에 반영됩니다.'
|
||||
),
|
||||
'jian-yong-records'
|
||||
'jian-yong-records',
|
||||
'second-choice-response'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3686,7 +4162,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
getCampaignState().completedCampDialogues.includes(dialogue.id)
|
||||
);
|
||||
const sourceNpcId = this.choiceSourceNpcId;
|
||||
const result = completeThirdCampCompanionActivity(choice.id);
|
||||
const result = completeThirdCampCompanionActivity(
|
||||
choice.id,
|
||||
this.explorationDialogueCheckpoint(
|
||||
'third-companion-response',
|
||||
sourceNpcId
|
||||
)
|
||||
);
|
||||
if (!result.ok) {
|
||||
this.closeChoicePanel();
|
||||
const message =
|
||||
@@ -3704,7 +4186,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.closeChoicePanel();
|
||||
this.closeChoicePanel(false);
|
||||
this.lastNotice = '동료 공명 · 보너스 후보 해금';
|
||||
if (alreadyCompleted) {
|
||||
soundDirector.playSelect();
|
||||
@@ -3736,11 +4218,12 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
'success',
|
||||
2100
|
||||
),
|
||||
sourceNpcId
|
||||
sourceNpcId,
|
||||
'third-companion-response'
|
||||
);
|
||||
}
|
||||
|
||||
private closeChoicePanel() {
|
||||
private closeChoicePanel(persistCheckpoint = true) {
|
||||
this.choicePanel?.destroy();
|
||||
this.choicePanel = undefined;
|
||||
this.choicePanelBounds = undefined;
|
||||
@@ -3748,6 +4231,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
this.choicePanelMode = 'visit';
|
||||
this.choiceSourceNpcId = undefined;
|
||||
this.choiceReadyAt = Number.POSITIVE_INFINITY;
|
||||
if (persistCheckpoint) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
@@ -3799,6 +4285,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
],
|
||||
() => this.returnToCamp(),
|
||||
'camp-exit',
|
||||
'camp-exit'
|
||||
);
|
||||
return;
|
||||
@@ -3823,6 +4310,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
],
|
||||
() => this.returnToCamp(),
|
||||
'camp-exit',
|
||||
'camp-exit'
|
||||
);
|
||||
return;
|
||||
@@ -3840,6 +4328,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
}
|
||||
],
|
||||
() => this.returnToCamp(),
|
||||
'camp-exit',
|
||||
'camp-exit'
|
||||
);
|
||||
return;
|
||||
@@ -3858,24 +4347,36 @@ export class CampVisitExplorationScene extends Phaser.Scene {
|
||||
if (!this.sys.isActive()) {
|
||||
return;
|
||||
}
|
||||
const previousActiveCampVisitId =
|
||||
getCampaignState().activeCampVisitId;
|
||||
setActiveCampVisitId();
|
||||
void startGameScene(this, 'CampScene').catch((error) => {
|
||||
console.error('Failed to return from camp exploration.', error);
|
||||
setActiveCampVisitId(
|
||||
previousActiveCampVisitId ??
|
||||
(this.visitId as CampaignCampVisitId)
|
||||
);
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.showCompletionToast(
|
||||
'군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
'error'
|
||||
);
|
||||
this.refreshInteractionPrompt();
|
||||
});
|
||||
this.persistExplorationCheckpoint(true);
|
||||
const previousCampaign = getCampaignState();
|
||||
void (async () => {
|
||||
try {
|
||||
setActiveCampVisitId();
|
||||
await startGameScene(this, 'CampScene');
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to return from camp exploration.',
|
||||
error
|
||||
);
|
||||
try {
|
||||
saveCampaignState(previousCampaign);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
'Failed to restore the camp exploration save after navigation failure.',
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.showCompletionToast(
|
||||
'군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
'error'
|
||||
);
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,13 @@ import {
|
||||
selectCityStayEquipmentForCamp
|
||||
} from '../state/cityStayActions';
|
||||
import {
|
||||
campaignInventoryCap,
|
||||
getCampaignState,
|
||||
saveCampaignState,
|
||||
setCampaignExplorationCheckpoint,
|
||||
setActiveCityStayId,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignExplorationDialogueKey,
|
||||
type CampaignState
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
@@ -137,8 +142,9 @@ type CityDialogueLine = {
|
||||
type DialogueState = {
|
||||
lines: CityDialogueLine[];
|
||||
lineIndex: number;
|
||||
onComplete?: () => void;
|
||||
onComplete?: () => boolean | void;
|
||||
sourceTargetId?: string;
|
||||
dialogueKey: CampaignExplorationDialogueKey;
|
||||
};
|
||||
|
||||
type ShopOfferView = {
|
||||
@@ -188,8 +194,10 @@ export class CityStayScene extends Phaser.Scene {
|
||||
private shopPanel?: Phaser.GameObjects.Container;
|
||||
private shopOfferViews: ShopOfferView[] = [];
|
||||
private shopNotice = '';
|
||||
private shopSourceNpcId?: string;
|
||||
private choicePanel?: Phaser.GameObjects.Container;
|
||||
private choiceViews: ChoiceView[] = [];
|
||||
private choiceSourceNpcId?: string;
|
||||
private transitionOverlay?: Phaser.GameObjects.Container;
|
||||
private completionToast?: Phaser.GameObjects.Container;
|
||||
private explorationInput?: ExplorationInputController;
|
||||
@@ -212,6 +220,16 @@ export class CityStayScene extends Phaser.Scene {
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
private checkpointLastSignature = '';
|
||||
private checkpointRestoring = false;
|
||||
private readonly persistCheckpointOnPageHide = () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
};
|
||||
private readonly persistCheckpointWhenHidden = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('CityStayScene');
|
||||
@@ -258,6 +276,14 @@ export class CityStayScene extends Phaser.Scene {
|
||||
create() {
|
||||
this.resetRuntimeState();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.markCityStayActive();
|
||||
this.drawCity();
|
||||
this.drawHud();
|
||||
@@ -270,6 +296,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.createDialoguePanel();
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
this.restoreExplorationCheckpoint();
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshProgressHud();
|
||||
this.refreshActorMarkers();
|
||||
@@ -283,14 +310,25 @@ export class CityStayScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
window.removeEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.clearNavigationTarget(true);
|
||||
this.explorationInput?.destroy();
|
||||
this.explorationInput = undefined;
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.closeChoicePanel(false);
|
||||
this.closeShop(false);
|
||||
releaseExplorationCharacterTextureKeys(
|
||||
this,
|
||||
this.characterTextureKeys()
|
||||
@@ -358,6 +396,11 @@ export class CityStayScene extends Phaser.Scene {
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
activeCityStayId: campaign.activeCityStayId ?? null,
|
||||
explorationCheckpoint: campaign.explorationCheckpoint
|
||||
? JSON.parse(
|
||||
JSON.stringify(campaign.explorationCheckpoint)
|
||||
)
|
||||
: null,
|
||||
background: {
|
||||
textureKey:
|
||||
this.backgroundImage?.texture.key ??
|
||||
@@ -469,6 +512,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
dialogue: this.dialogueState
|
||||
? {
|
||||
active: true,
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
|
||||
lineIndex: this.dialogueState.lineIndex,
|
||||
totalLines: this.dialogueState.lines.length,
|
||||
@@ -488,6 +532,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
: {
|
||||
active: false,
|
||||
dialogueKey: null,
|
||||
speaker: null,
|
||||
lineIndex: null,
|
||||
totalLines: 0,
|
||||
@@ -505,6 +550,15 @@ export class CityStayScene extends Phaser.Scene {
|
||||
offers: this.cityStay.equipmentOffers.map((offer) => {
|
||||
const item = getItem(offer.itemId);
|
||||
const view = this.shopOfferViews.find((candidate) => candidate.offerId === offer.id);
|
||||
const inventoryLabel = itemInventoryLabel(item.id);
|
||||
const owned =
|
||||
campaign.inventory[inventoryLabel] ?? 0;
|
||||
const inventoryCapacity =
|
||||
campaignInventoryCap(inventoryLabel);
|
||||
const purchaseCount =
|
||||
campaign.cityEquipmentPurchaseCounts?.[
|
||||
offer.id
|
||||
] ?? 0;
|
||||
return {
|
||||
id: offer.id,
|
||||
itemId: item.id,
|
||||
@@ -513,9 +567,14 @@ export class CityStayScene extends Phaser.Scene {
|
||||
bonusText: this.itemBonusText(item),
|
||||
iconKind: item.slot,
|
||||
price: offer.price,
|
||||
owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0,
|
||||
purchaseCount: campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0,
|
||||
canBuy: campaign.gold >= offer.price,
|
||||
owned,
|
||||
inventoryCapacity,
|
||||
inventoryFull: owned >= inventoryCapacity,
|
||||
purchaseCount,
|
||||
canBuy:
|
||||
campaign.gold >= offer.price &&
|
||||
owned < inventoryCapacity &&
|
||||
purchaseCount < 99,
|
||||
interactive: Boolean(view?.interactive),
|
||||
buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null,
|
||||
equipCtaBounds: view?.equipButton
|
||||
@@ -601,8 +660,10 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.shopPanel = undefined;
|
||||
this.shopOfferViews = [];
|
||||
this.shopNotice = '';
|
||||
this.shopSourceNpcId = undefined;
|
||||
this.choicePanel = undefined;
|
||||
this.choiceViews = [];
|
||||
this.choiceSourceNpcId = undefined;
|
||||
this.moveTarget = undefined;
|
||||
this.moveWaypoints = [];
|
||||
this.moveTargetId = undefined;
|
||||
@@ -620,12 +681,304 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.stepIndex = 0;
|
||||
this.lastStepAt = 0;
|
||||
this.lastNotice = '';
|
||||
this.checkpointLastSignature = '';
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
|
||||
private markCityStayActive() {
|
||||
this.campaign = setActiveCityStayId(this.cityStay.id);
|
||||
}
|
||||
|
||||
private persistExplorationCheckpoint(force = false) {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.checkpointRestoring ||
|
||||
!this.player
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const campaign = getCampaignState();
|
||||
if (campaign.activeCityStayId !== this.cityStay.id) {
|
||||
return;
|
||||
}
|
||||
const shopSourceNpcId =
|
||||
this.shopSourceNpcId ??
|
||||
this.profile.actors.find(
|
||||
(actor) => actor.kind === 'market'
|
||||
)?.id;
|
||||
const overlay: CampaignExplorationCheckpoint['overlay'] =
|
||||
this.dialogueState
|
||||
? {
|
||||
type: 'dialogue',
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
...(this.dialogueState.sourceTargetId
|
||||
? {
|
||||
sourceNpcId:
|
||||
this.dialogueState.sourceTargetId
|
||||
}
|
||||
: {}),
|
||||
lineIndex: this.dialogueState.lineIndex
|
||||
}
|
||||
: this.choicePanel
|
||||
? {
|
||||
type: 'choice',
|
||||
mode: 'city-resonance',
|
||||
...(this.choiceSourceNpcId
|
||||
? { sourceNpcId: this.choiceSourceNpcId }
|
||||
: {})
|
||||
}
|
||||
: this.shopPanel && shopSourceNpcId
|
||||
? {
|
||||
type: 'shop',
|
||||
sourceNpcId: shopSourceNpcId
|
||||
}
|
||||
: undefined;
|
||||
const checkpoint: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
> = {
|
||||
version: 1,
|
||||
scene: 'city-stay',
|
||||
contextId: this.cityStay.id,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
...(overlay ? { overlay } : {})
|
||||
};
|
||||
const signature = JSON.stringify(checkpoint);
|
||||
if (!force && signature === this.checkpointLastSignature) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCampaignExplorationCheckpoint(checkpoint);
|
||||
this.checkpointLastSignature = signature;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the city stay checkpoint.',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private explorationDialogueCheckpoint(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
sourceNpcId?: string
|
||||
) {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'city-stay',
|
||||
contextId: this.cityStay.id,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
overlay: {
|
||||
type: 'dialogue',
|
||||
dialogueKey,
|
||||
...(sourceNpcId ? { sourceNpcId } : {}),
|
||||
lineIndex: 0
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private explorationPositionCheckpoint() {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'city-stay',
|
||||
contextId: this.cityStay.id,
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private restoreExplorationCheckpoint() {
|
||||
const checkpoint = getCampaignState().explorationCheckpoint;
|
||||
if (
|
||||
!checkpoint ||
|
||||
checkpoint.scene !== 'city-stay' ||
|
||||
checkpoint.contextId !== this.cityStay.id ||
|
||||
!this.player
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return {
|
||||
positionRestored: false,
|
||||
overlayRestored: false
|
||||
};
|
||||
}
|
||||
|
||||
this.checkpointRestoring = true;
|
||||
let positionRestored = false;
|
||||
let overlayRestored = false;
|
||||
try {
|
||||
if (
|
||||
this.canPlayerOccupy(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
)
|
||||
) {
|
||||
this.player.setPosition(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
);
|
||||
this.playerDirection = checkpoint.player.direction;
|
||||
this.syncPlayerView();
|
||||
this.setPlayerMoving(false);
|
||||
positionRestored = true;
|
||||
}
|
||||
overlayRestored = checkpoint.overlay
|
||||
? this.restoreCheckpointOverlay(checkpoint.overlay)
|
||||
: false;
|
||||
} finally {
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return { positionRestored, overlayRestored };
|
||||
}
|
||||
|
||||
private restoreCheckpointOverlay(
|
||||
overlay: CampaignExplorationCheckpoint['overlay']
|
||||
) {
|
||||
if (!overlay) {
|
||||
return false;
|
||||
}
|
||||
if (overlay.type === 'shop') {
|
||||
const view = this.actors.get(overlay.sourceNpcId);
|
||||
if (!view || view.definition.kind !== 'market') {
|
||||
return false;
|
||||
}
|
||||
this.faceCharactersForConversation(view);
|
||||
this.openShop(view.definition.id);
|
||||
return Boolean(this.shopPanel);
|
||||
}
|
||||
if (overlay.type === 'choice') {
|
||||
if (
|
||||
overlay.mode !== 'city-resonance' ||
|
||||
this.dialogueComplete()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceNpcId =
|
||||
overlay.sourceNpcId ?? this.companionActor().id;
|
||||
const view = this.actors.get(sourceNpcId);
|
||||
if (view) {
|
||||
this.faceCharactersForConversation(view);
|
||||
}
|
||||
this.openChoicePanel(sourceNpcId);
|
||||
return Boolean(this.choicePanel);
|
||||
}
|
||||
|
||||
if (overlay.dialogueKey === 'city-interaction') {
|
||||
if (!overlay.sourceNpcId) {
|
||||
return false;
|
||||
}
|
||||
const target = this.interactionTargetByIdOrKind(
|
||||
overlay.sourceNpcId
|
||||
);
|
||||
if (!target || target.kind === 'exit') {
|
||||
return false;
|
||||
}
|
||||
this.interactWithTarget(target);
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else if (overlay.dialogueKey === 'city-exit') {
|
||||
this.interactWithExit();
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else if (
|
||||
overlay.dialogueKey === 'city-resonance-response'
|
||||
) {
|
||||
this.startResonanceResponseDialogue(
|
||||
overlay.sourceNpcId,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
this.dialogueState?.dialogueKey ===
|
||||
overlay.dialogueKey
|
||||
);
|
||||
}
|
||||
|
||||
private restoreDialogueLine(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
lineIndex: number
|
||||
) {
|
||||
const dialogue = this.dialogueState;
|
||||
if (!dialogue || dialogue.dialogueKey !== dialogueKey) {
|
||||
return;
|
||||
}
|
||||
dialogue.lineIndex = Phaser.Math.Clamp(
|
||||
Math.floor(lineIndex),
|
||||
0,
|
||||
dialogue.lines.length - 1
|
||||
);
|
||||
this.renderDialogueLine();
|
||||
}
|
||||
|
||||
private startResonanceResponseDialogue(
|
||||
sourceNpcId?: string,
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
const campaign = getCampaignState();
|
||||
const dialogue = this.cityStay.dialogue;
|
||||
const choiceId =
|
||||
campaign.campDialogueChoiceIds[dialogue.id];
|
||||
const choice = dialogue.choices.find(
|
||||
(candidate) => candidate.id === choiceId
|
||||
);
|
||||
const companion = this.companionActor();
|
||||
if (!choice) {
|
||||
return;
|
||||
}
|
||||
const companionView = this.actors.get(
|
||||
sourceNpcId ?? companion.id
|
||||
);
|
||||
if (companionView) {
|
||||
this.faceCharactersForConversation(companionView);
|
||||
}
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: companion.name,
|
||||
portraitKey: this.actorPortraitKey(companion),
|
||||
text: choice.response
|
||||
},
|
||||
{
|
||||
speaker: '공명',
|
||||
text: `${this.cityDialogueUnitNames()} · 공명 +${dialogue.rewardExp + choice.rewardExp}`
|
||||
}
|
||||
],
|
||||
() =>
|
||||
this.showCompletionToast('동료 공명 대화 완료'),
|
||||
sourceNpcId ?? companion.id,
|
||||
'city-resonance-response',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private currentBackground() {
|
||||
return cityBackgrounds[this.cityStay.id];
|
||||
}
|
||||
@@ -1910,6 +2263,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
if (!this.player) {
|
||||
return;
|
||||
}
|
||||
const wasMoving = this.playerMoving;
|
||||
applyExplorationCharacterMotion(
|
||||
this.player,
|
||||
playerTextureKey,
|
||||
@@ -1918,6 +2272,13 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.visualMotionReduced
|
||||
);
|
||||
this.playerMoving = moving;
|
||||
if (
|
||||
wasMoving &&
|
||||
!moving &&
|
||||
!this.checkpointRestoring
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlayerMovement() {
|
||||
@@ -1942,6 +2303,9 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.player.setPosition(x, y);
|
||||
this.syncPlayerView();
|
||||
this.stopPlayerMovement();
|
||||
if (!this.checkpointRestoring) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
}
|
||||
|
||||
private safeApproachPosition(targetX: number, targetY: number) {
|
||||
@@ -1982,7 +2346,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
if (target.kind === 'market') {
|
||||
this.openShop();
|
||||
this.openShop(view.definition.id);
|
||||
return;
|
||||
}
|
||||
this.interactWithCompanion(view);
|
||||
@@ -2029,7 +2393,8 @@ export class CityStayScene extends Phaser.Scene {
|
||||
private completeInformation() {
|
||||
const result = collectCityStayInformation(
|
||||
this.cityStay.id,
|
||||
this.cityStay.information.id
|
||||
this.cityStay.information.id,
|
||||
this.explorationPositionCheckpoint()
|
||||
);
|
||||
if (!result.ok) {
|
||||
const message = result.reason === 'already-completed'
|
||||
@@ -2037,7 +2402,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
: '정보를 장부에 기록하지 못했습니다. 저장 상태를 확인해 주세요.';
|
||||
this.showCompletionToast(message, false);
|
||||
this.refreshProgressHud();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
this.campaign = result.campaign;
|
||||
this.lastNotice = `정보 확보 · ${result.information.title}`;
|
||||
@@ -2045,6 +2410,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`);
|
||||
this.refreshProgressHud();
|
||||
this.refreshActorMarkers();
|
||||
return true;
|
||||
}
|
||||
|
||||
private interactWithCompanion(view: CityActorView) {
|
||||
@@ -2085,7 +2451,9 @@ export class CityStayScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private openChoicePanel() {
|
||||
private openChoicePanel(
|
||||
sourceNpcId = this.companionActor().id
|
||||
) {
|
||||
if (this.choicePanel || this.navigationPending) {
|
||||
return;
|
||||
}
|
||||
@@ -2167,6 +2535,8 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}).setOrigin(1, 0.5);
|
||||
objects.push(cancel);
|
||||
this.choicePanel = this.add.container(0, 0, objects).setDepth(3200);
|
||||
this.choiceSourceNpcId = sourceNpcId;
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private chooseDialogueByIndex(index: number) {
|
||||
@@ -2191,7 +2561,16 @@ export class CityStayScene extends Phaser.Scene {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
|
||||
const sourceNpcId =
|
||||
this.choiceSourceNpcId ?? this.companionActor().id;
|
||||
const result = chooseCityStayResonance(
|
||||
this.cityStay.id,
|
||||
choice.id,
|
||||
this.explorationDialogueCheckpoint(
|
||||
'city-resonance-response',
|
||||
sourceNpcId
|
||||
)
|
||||
);
|
||||
if (!result.ok) {
|
||||
this.closeChoicePanel();
|
||||
const message = result.reason === 'already-completed'
|
||||
@@ -2205,40 +2584,32 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
this.campaign = result.campaign;
|
||||
this.lastNotice = `공명 +${result.rewardExp} · ${result.choice.label}`;
|
||||
this.closeChoicePanel();
|
||||
this.closeChoicePanel(false);
|
||||
soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 });
|
||||
this.refreshProgressHud();
|
||||
this.refreshActorMarkers();
|
||||
const companion = this.companionActor();
|
||||
const companionView = this.actors.get(companion.id);
|
||||
if (companionView) {
|
||||
this.faceCharactersForConversation(companionView);
|
||||
}
|
||||
this.startDialogue([
|
||||
{
|
||||
speaker: companion.name,
|
||||
portraitKey: this.actorPortraitKey(
|
||||
companion
|
||||
),
|
||||
text: choice.response
|
||||
},
|
||||
{
|
||||
speaker: '공명',
|
||||
text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}`
|
||||
}
|
||||
], () => this.showCompletionToast('동료 공명 대화 완료'), companion.id);
|
||||
this.startResonanceResponseDialogue(sourceNpcId);
|
||||
this.autoDialogueInputReadyAt =
|
||||
this.time.now + inputCarryoverGuardMs;
|
||||
}
|
||||
|
||||
private closeChoicePanel() {
|
||||
private closeChoicePanel(persistCheckpoint = true) {
|
||||
this.restoreActorDirection(this.choiceSourceNpcId);
|
||||
this.choicePanel?.destroy();
|
||||
this.choicePanel = undefined;
|
||||
this.choiceViews = [];
|
||||
this.choiceSourceNpcId = undefined;
|
||||
if (persistCheckpoint) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
private openShop() {
|
||||
private openShop(
|
||||
sourceNpcId = this.profile.actors.find(
|
||||
(actor) => actor.kind === 'market'
|
||||
)?.id
|
||||
) {
|
||||
if (this.shopPanel || this.navigationPending) {
|
||||
return;
|
||||
}
|
||||
@@ -2246,6 +2617,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.completionToast?.destroy();
|
||||
this.completionToast = undefined;
|
||||
this.shopNotice = '';
|
||||
this.shopSourceNpcId = sourceNpcId;
|
||||
soundDirector.playEffect('cart-roll', {
|
||||
volume: 0.14,
|
||||
rate: 0.96,
|
||||
@@ -2253,6 +2625,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
});
|
||||
this.renderShopPanel();
|
||||
this.refreshInteractionPrompt();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private renderShopPanel() {
|
||||
@@ -2309,9 +2682,17 @@ export class CityStayScene extends Phaser.Scene {
|
||||
|
||||
this.cityStay.equipmentOffers.forEach((offer, index) => {
|
||||
const item = getItem(offer.itemId);
|
||||
const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0;
|
||||
const inventoryLabel = itemInventoryLabel(item.id);
|
||||
const owned = campaign.inventory[inventoryLabel] ?? 0;
|
||||
const inventoryCapacity =
|
||||
campaignInventoryCap(inventoryLabel);
|
||||
const purchaseCount = campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0;
|
||||
const canBuy = campaign.gold >= offer.price;
|
||||
const inventoryFull = owned >= inventoryCapacity;
|
||||
const purchaseLimitReached = purchaseCount >= 99;
|
||||
const canBuy =
|
||||
campaign.gold >= offer.price &&
|
||||
!inventoryFull &&
|
||||
!purchaseLimitReached;
|
||||
const rowX = 230;
|
||||
const rowY = 300 + index * 180;
|
||||
const purchasedHere = purchaseCount > 0;
|
||||
@@ -2389,7 +2770,15 @@ export class CityStayScene extends Phaser.Scene {
|
||||
const buttonLabel = this.add.text(
|
||||
purchaseButtonX,
|
||||
rowY + 73,
|
||||
canBuy ? (purchasedHere ? '추가 구매' : '구매') : '군자금 부족',
|
||||
canBuy
|
||||
? purchasedHere
|
||||
? '추가 구매'
|
||||
: '구매'
|
||||
: inventoryFull
|
||||
? '보관 한도'
|
||||
: purchaseLimitReached
|
||||
? '구매 한도'
|
||||
: '군자금 부족',
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: canBuy ? (purchasedHere ? '18px' : '21px') : '16px',
|
||||
@@ -2398,10 +2787,15 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
if (canBuy) {
|
||||
const purchaseIntentId =
|
||||
this.createPurchaseIntentId(offer.id);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99));
|
||||
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99));
|
||||
button.on('pointerdown', () => this.buyOffer(offer));
|
||||
button.on(
|
||||
'pointerdown',
|
||||
() => this.buyOffer(offer, purchaseIntentId)
|
||||
);
|
||||
}
|
||||
let equipButton: Phaser.GameObjects.Rectangle | undefined;
|
||||
let equipButtonLabel: Phaser.GameObjects.Text | undefined;
|
||||
@@ -2464,12 +2858,29 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.shopPanel = this.add.container(0, 0, objects).setDepth(3100);
|
||||
}
|
||||
|
||||
private buyOffer(offer: CityEquipmentOffer) {
|
||||
const result = purchaseCityStayEquipment(this.cityStay.id, offer.id);
|
||||
private buyOffer(
|
||||
offer: CityEquipmentOffer,
|
||||
purchaseIntentId: string
|
||||
) {
|
||||
const result = purchaseCityStayEquipment(
|
||||
this.cityStay.id,
|
||||
offer.id,
|
||||
purchaseIntentId
|
||||
);
|
||||
const message = result.ok
|
||||
? `${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
||||
? result.replayed
|
||||
? `${result.item.name} 구매 기록을 확인했습니다.`
|
||||
: `${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
||||
: result.reason === 'insufficient-gold'
|
||||
? '군자금이 부족합니다.'
|
||||
: result.reason === 'inventory-full'
|
||||
? '이 장비는 더 보관할 수 없습니다.'
|
||||
: result.reason === 'purchase-limit-reached'
|
||||
? '이 도시에서 허용된 구매 횟수에 도달했습니다.'
|
||||
: result.reason === 'intent-conflict'
|
||||
? '이전 구매 요청과 충돌했습니다. 다시 선택해 주세요.'
|
||||
: result.reason === 'invalid-purchase-intent'
|
||||
? '구매 요청을 확인할 수 없습니다. 다시 선택해 주세요.'
|
||||
: result.reason === 'restricted-item'
|
||||
? '보물 장비는 일반 시장에서 거래할 수 없습니다.'
|
||||
: '현재 시장에서는 살 수 없는 장비입니다.';
|
||||
@@ -2477,12 +2888,32 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.lastNotice = message;
|
||||
if (result.ok) {
|
||||
this.campaign = result.campaign;
|
||||
soundDirector.playEffect('reward-reveal', { volume: 0.22, stopAfterMs: 720 });
|
||||
if (!result.replayed) {
|
||||
soundDirector.playEffect('reward-reveal', {
|
||||
volume: 0.22,
|
||||
stopAfterMs: 720
|
||||
});
|
||||
}
|
||||
} else {
|
||||
soundDirector.playEffect('objective-failure', { volume: 0.16, stopAfterMs: 620 });
|
||||
}
|
||||
this.renderShopPanel();
|
||||
this.refreshProgressHud();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private createPurchaseIntentId(offerId: string) {
|
||||
const randomPart =
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 12)}`;
|
||||
return [
|
||||
'city-purchase',
|
||||
this.cityStay.id,
|
||||
offerId,
|
||||
randomPart
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private renderShopEquipmentIcon(
|
||||
@@ -2524,11 +2955,16 @@ export class CityStayScene extends Phaser.Scene {
|
||||
return [graphics];
|
||||
}
|
||||
|
||||
private closeShop() {
|
||||
private closeShop(persistCheckpoint = true) {
|
||||
this.restoreActorDirection(this.shopSourceNpcId);
|
||||
this.shopPanel?.destroy();
|
||||
this.shopPanel = undefined;
|
||||
this.shopOfferViews = [];
|
||||
this.shopNotice = '';
|
||||
this.shopSourceNpcId = undefined;
|
||||
if (persistCheckpoint) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
@@ -2561,7 +2997,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
speaker: '길잡이',
|
||||
text: '군영으로 돌아갑니다.'
|
||||
}
|
||||
], () => this.returnToCamp(), 'camp-exit');
|
||||
], () => this.returnToCamp(), 'camp-exit', 'city-exit');
|
||||
return;
|
||||
}
|
||||
this.returnToCamp();
|
||||
@@ -2601,19 +3037,38 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
this.navigationPending = true;
|
||||
this.stopPlayerMovement();
|
||||
const previousActiveCityStayId = getCampaignState().activeCityStayId;
|
||||
this.campaign = setActiveCityStayId();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
const previousCampaign = getCampaignState();
|
||||
this.showTransitionOverlay();
|
||||
|
||||
void startGameScene(this, 'CampScene', data).catch((error) => {
|
||||
console.error('Failed to return from the city stay.', error);
|
||||
this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id);
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.showCompletionToast('군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', false);
|
||||
this.refreshInteractionPrompt();
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
this.campaign = setActiveCityStayId();
|
||||
await startGameScene(this, 'CampScene', data);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to return from the city stay.',
|
||||
error
|
||||
);
|
||||
try {
|
||||
this.campaign = saveCampaignState(previousCampaign);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
'Failed to restore the city stay save after navigation failure.',
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.showCompletionToast(
|
||||
'군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
false
|
||||
);
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private showTransitionOverlay() {
|
||||
@@ -2646,19 +3101,33 @@ export class CityStayScene extends Phaser.Scene {
|
||||
|
||||
private startDialogue(
|
||||
lines: CityDialogueLine[],
|
||||
onComplete?: () => void,
|
||||
sourceTargetId?: string
|
||||
onComplete?: () => boolean | void,
|
||||
sourceTargetId?: string,
|
||||
dialogueKey: CampaignExplorationDialogueKey =
|
||||
'city-interaction',
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
if (!lines.length || this.navigationPending || this.shopPanel || this.choicePanel) {
|
||||
return;
|
||||
}
|
||||
this.stopPlayerMovement();
|
||||
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceTargetId };
|
||||
this.dialogueState = {
|
||||
lines,
|
||||
lineIndex: Phaser.Math.Clamp(
|
||||
Math.floor(initialLineIndex),
|
||||
0,
|
||||
lines.length - 1
|
||||
),
|
||||
onComplete,
|
||||
sourceTargetId,
|
||||
dialogueKey
|
||||
};
|
||||
this.dialoguePanel?.setVisible(true);
|
||||
this.promptBackground?.setVisible(false);
|
||||
this.promptText?.setVisible(false);
|
||||
soundDirector.playSelect();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private renderDialogueLine() {
|
||||
@@ -2728,6 +3197,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
dialogue.lineIndex += 1;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2737,7 +3207,10 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.dialoguePanel?.setVisible(false);
|
||||
this.stopPlayerMovement();
|
||||
this.restoreActorDirection(sourceTargetId);
|
||||
onComplete?.();
|
||||
const checkpointCommitted = onComplete?.() === true;
|
||||
if (!checkpointCommitted) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
@@ -2747,6 +3220,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.dialoguePanel?.setVisible(false);
|
||||
this.stopPlayerMovement();
|
||||
this.restoreActorDirection(sourceTargetId);
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ import {
|
||||
hasCompletedCampaignTutorial,
|
||||
markCampaignStep,
|
||||
prologueMilitiaCampCampaignTutorialIds,
|
||||
saveCampaignState,
|
||||
setCampaignExplorationCheckpoint,
|
||||
setCampaignSortieOrderSelection,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignExplorationDialogueKey,
|
||||
type CampaignTutorialId
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
@@ -76,6 +80,18 @@ const campDialoguePortraitKeys = [
|
||||
'zouJing',
|
||||
'zhuoYoungVolunteer'
|
||||
] as const;
|
||||
const commandPartyPositions = {
|
||||
guanYu: {
|
||||
x: 650,
|
||||
y: 420,
|
||||
direction: 'north' as const
|
||||
},
|
||||
zhangFei: {
|
||||
x: 890,
|
||||
y: 420,
|
||||
direction: 'north' as const
|
||||
}
|
||||
};
|
||||
const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei'];
|
||||
const campNpcTextureKeyById = {
|
||||
quartermaster: 'exploration-zhuo-quartermaster',
|
||||
@@ -126,8 +142,9 @@ type CampNpcView = {
|
||||
type DialogueState = {
|
||||
lines: PrologueVillageDialogueLine[];
|
||||
lineIndex: number;
|
||||
onComplete?: () => void;
|
||||
onComplete?: () => boolean | void;
|
||||
sourceNpcId?: string;
|
||||
dialogueKey: CampaignExplorationDialogueKey;
|
||||
};
|
||||
|
||||
type ObjectiveRowView = {
|
||||
@@ -231,6 +248,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
private checkpointLastSignature = '';
|
||||
private checkpointRestoring = false;
|
||||
private readonly persistCheckpointOnPageHide = () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
};
|
||||
private readonly persistCheckpointWhenHidden = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('PrologueMilitiaCampScene');
|
||||
@@ -262,6 +289,14 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.resetRuntimeState();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
this.prepareCampaignProgress();
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.drawCamp();
|
||||
this.drawHud();
|
||||
this.createLoadingOverlay();
|
||||
@@ -275,13 +310,22 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
window.removeEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.clearNavigationTarget(true);
|
||||
this.dialogueState = undefined;
|
||||
this.campaignObjectiveJournal?.destroy();
|
||||
this.campaignObjectiveJournal = undefined;
|
||||
this.closeCommandChoice();
|
||||
this.closeCommandChoice(false);
|
||||
this.stopNpcMovement();
|
||||
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
||||
this.releasePresentationTextures();
|
||||
@@ -295,21 +339,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.loadingOverlay = undefined;
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
const restoredCheckpoint =
|
||||
this.restoreExplorationCheckpoint();
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
|
||||
if (this.firstVisit) {
|
||||
this.startDialogue([
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.'
|
||||
}
|
||||
], () => {
|
||||
completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.entered);
|
||||
});
|
||||
if (
|
||||
this.firstVisit &&
|
||||
!restoredCheckpoint.overlayRestored
|
||||
) {
|
||||
this.startCampIntroDialogue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +410,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
campaignStep: campaign.step,
|
||||
explorationCheckpoint:
|
||||
campaign.explorationCheckpoint ?? null,
|
||||
requiredTextures: characterTextureKeys.map((key) => ({
|
||||
key,
|
||||
ready: this.textures.exists(key)
|
||||
@@ -503,6 +546,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
|
||||
lineIndex: this.dialogueState.lineIndex,
|
||||
totalLines: this.dialogueState.lines.length,
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
sourceNpcId: this.dialogueState.sourceNpcId ?? null,
|
||||
portraitTextureKey: this.dialoguePortrait?.visible
|
||||
? this.dialoguePortrait.texture.key
|
||||
@@ -516,6 +560,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
speaker: null,
|
||||
lineIndex: -1,
|
||||
totalLines: 0,
|
||||
dialogueKey: null,
|
||||
sourceNpcId: null,
|
||||
portraitTextureKey: null,
|
||||
portraitVisible: false,
|
||||
@@ -663,6 +708,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.interactionQueued = false;
|
||||
this.stepIndex = 0;
|
||||
this.lastStepAt = 0;
|
||||
this.checkpointLastSignature = '';
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
|
||||
private prepareCampaignProgress() {
|
||||
@@ -675,6 +722,305 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
private persistExplorationCheckpoint(force = false) {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.checkpointRestoring ||
|
||||
!this.player
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const campaign = getCampaignState();
|
||||
if (
|
||||
campaign.step !== 'prologue-camp' ||
|
||||
campaign.completedTutorialIds.includes(
|
||||
prologueMilitiaCampCampaignTutorialIds.complete
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const overlay: CampaignExplorationCheckpoint['overlay'] =
|
||||
this.dialogueState
|
||||
? {
|
||||
type: 'dialogue',
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
...(this.dialogueState.sourceNpcId
|
||||
? {
|
||||
sourceNpcId:
|
||||
this.dialogueState.sourceNpcId
|
||||
}
|
||||
: {}),
|
||||
lineIndex: this.dialogueState.lineIndex
|
||||
}
|
||||
: this.commandChoicePanel
|
||||
? {
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
sourceNpcId: 'zou-jing',
|
||||
...(this.pendingCommandOrderId
|
||||
? {
|
||||
choiceId:
|
||||
this.pendingCommandOrderId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
: undefined;
|
||||
const checkpoint: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
> = {
|
||||
version: 1,
|
||||
scene: 'prologue-militia-camp',
|
||||
contextId: 'prologue-militia-camp',
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
...(overlay ? { overlay } : {})
|
||||
};
|
||||
const signature = JSON.stringify(checkpoint);
|
||||
if (
|
||||
!force &&
|
||||
signature === this.checkpointLastSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCampaignExplorationCheckpoint(checkpoint);
|
||||
this.checkpointLastSignature = signature;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue militia camp checkpoint.',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private explorationPositionCheckpoint() {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'prologue-militia-camp',
|
||||
contextId: 'prologue-militia-camp',
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private restoreExplorationCheckpoint() {
|
||||
const checkpoint =
|
||||
getCampaignState().explorationCheckpoint;
|
||||
if (
|
||||
!checkpoint ||
|
||||
checkpoint.scene !==
|
||||
'prologue-militia-camp' ||
|
||||
checkpoint.contextId !==
|
||||
'prologue-militia-camp' ||
|
||||
!this.player
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return {
|
||||
positionRestored: false,
|
||||
overlayRestored: false
|
||||
};
|
||||
}
|
||||
|
||||
this.checkpointRestoring = true;
|
||||
let positionRestored = false;
|
||||
let overlayRestored = false;
|
||||
try {
|
||||
if (
|
||||
this.canPlayerOccupy(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
)
|
||||
) {
|
||||
this.player.setPosition(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
);
|
||||
this.playerDirection = checkpoint.player.direction;
|
||||
this.syncPlayerView();
|
||||
this.setPlayerMoving(false);
|
||||
positionRestored = true;
|
||||
}
|
||||
overlayRestored = checkpoint.overlay
|
||||
? this.restoreCheckpointOverlay(checkpoint.overlay)
|
||||
: false;
|
||||
} finally {
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return { positionRestored, overlayRestored };
|
||||
}
|
||||
|
||||
private restoreCheckpointOverlay(
|
||||
overlay: CampaignExplorationCheckpoint['overlay']
|
||||
) {
|
||||
if (!overlay) {
|
||||
return false;
|
||||
}
|
||||
if (overlay.type === 'choice') {
|
||||
if (
|
||||
overlay.mode !== 'prologue-command' ||
|
||||
!this.allRequiredObjectivesComplete()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.restoreCommandPartyPositions();
|
||||
this.openFirstCommandChoice();
|
||||
const pendingChoice =
|
||||
overlay.choiceId
|
||||
? prologueMilitiaCampCommandChoices.find(
|
||||
(choice) =>
|
||||
choice.orderId === overlay.choiceId
|
||||
)
|
||||
: undefined;
|
||||
if (pendingChoice) {
|
||||
this.previewFirstCommand(
|
||||
pendingChoice.orderId,
|
||||
true
|
||||
);
|
||||
}
|
||||
return Boolean(this.commandChoicePanel);
|
||||
}
|
||||
if (overlay.type !== 'dialogue') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
overlay.dialogueKey ===
|
||||
'prologue-camp-command-response'
|
||||
) {
|
||||
const selection =
|
||||
getCampaignState().sortieOrderSelection;
|
||||
const choice =
|
||||
selection?.battleId ===
|
||||
'first-battle-zhuo-commandery'
|
||||
? prologueMilitiaCampCommandChoices.find(
|
||||
(entry) =>
|
||||
entry.orderId === selection.orderId
|
||||
)
|
||||
: undefined;
|
||||
if (!choice) {
|
||||
return false;
|
||||
}
|
||||
this.restoreCommandPartyPositions();
|
||||
this.startCommandResponseDialogue(
|
||||
choice,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else if (
|
||||
overlay.dialogueKey ===
|
||||
'prologue-camp-departure'
|
||||
) {
|
||||
if (!this.allRequiredObjectivesComplete()) {
|
||||
return false;
|
||||
}
|
||||
this.restoreCommandPartyPositions();
|
||||
const commander = this.npcViews.get('zou-jing');
|
||||
if (!commander) {
|
||||
return false;
|
||||
}
|
||||
this.startDepartureDialogue(
|
||||
commander,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else if (
|
||||
overlay.dialogueKey ===
|
||||
'prologue-camp-interaction'
|
||||
) {
|
||||
if (overlay.sourceNpcId === 'camp-intro') {
|
||||
if (!this.firstVisit) {
|
||||
return false;
|
||||
}
|
||||
this.startCampIntroDialogue(overlay.lineIndex);
|
||||
} else if (overlay.sourceNpcId) {
|
||||
const view = this.npcViews.get(
|
||||
overlay.sourceNpcId
|
||||
);
|
||||
if (
|
||||
!view ||
|
||||
(view.definition.departure &&
|
||||
this.allRequiredObjectivesComplete())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.interactWithNpc(view);
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
this.dialogueState?.dialogueKey ===
|
||||
overlay.dialogueKey
|
||||
);
|
||||
}
|
||||
|
||||
private restoreDialogueLine(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
lineIndex: number
|
||||
) {
|
||||
const dialogue = this.dialogueState;
|
||||
if (!dialogue || dialogue.dialogueKey !== dialogueKey) {
|
||||
return;
|
||||
}
|
||||
dialogue.lineIndex = Phaser.Math.Clamp(
|
||||
Math.floor(lineIndex),
|
||||
0,
|
||||
dialogue.lines.length - 1
|
||||
);
|
||||
this.renderDialogueLine();
|
||||
}
|
||||
|
||||
private startCampIntroDialogue(initialLineIndex = 0) {
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.'
|
||||
}
|
||||
],
|
||||
() => {
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
prologueMilitiaCampCampaignTutorialIds.entered,
|
||||
{
|
||||
explorationCheckpoint:
|
||||
this.explorationPositionCheckpoint()
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue militia camp introduction.',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'camp-intro',
|
||||
'prologue-camp-interaction',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private drawCamp() {
|
||||
this.cameras.main.setBackgroundColor('#1a241e');
|
||||
if (this.textures.exists(campBackgroundKey)) {
|
||||
@@ -1984,6 +2330,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
if (!this.player) {
|
||||
return;
|
||||
}
|
||||
const wasMoving = this.playerMoving;
|
||||
applyExplorationCharacterMotion(
|
||||
this.player,
|
||||
playerTextureKey,
|
||||
@@ -1992,6 +2339,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.visualMotionReduced
|
||||
);
|
||||
this.playerMoving = moving;
|
||||
if (wasMoving && !moving) {
|
||||
this.persistExplorationCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlayerMovement() {
|
||||
@@ -2015,6 +2365,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.player.setPosition(x, y);
|
||||
this.syncPlayerView();
|
||||
this.stopPlayerMovement();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private safeApproachPosition(targetX: number, targetY: number) {
|
||||
@@ -2048,11 +2399,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
this.gatherCommandParty(() => {
|
||||
this.startDialogue(
|
||||
view.definition.dialogue,
|
||||
() => this.openFirstCommandChoice(),
|
||||
view.definition.id
|
||||
);
|
||||
this.startDepartureDialogue(view);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2068,30 +2415,75 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
: view.definition.dialogue;
|
||||
this.startDialogue(dialogue, () => {
|
||||
if (view.definition.objectiveId && !completed) {
|
||||
this.completeObjective(view.definition.objectiveId);
|
||||
return this.completeObjective(
|
||||
view.definition.objectiveId
|
||||
);
|
||||
}
|
||||
if (optionalTutorialId && !completed) {
|
||||
completeCampaignTutorial(optionalTutorialId);
|
||||
this.showCompletionToast('젊은 의병이 약속을 기억합니다.');
|
||||
try {
|
||||
completeCampaignTutorial(optionalTutorialId, {
|
||||
explorationCheckpoint:
|
||||
this.explorationPositionCheckpoint()
|
||||
});
|
||||
this.showCompletionToast(
|
||||
'젊은 의병이 약속을 기억합니다.'
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the optional militia camp dialogue.',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, view.definition.id);
|
||||
}
|
||||
|
||||
private startDepartureDialogue(
|
||||
view: CampNpcView,
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
this.faceCharactersForConversation(view);
|
||||
this.startDialogue(
|
||||
view.definition.dialogue,
|
||||
() => this.openFirstCommandChoice(),
|
||||
view.definition.id,
|
||||
'prologue-camp-departure',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private startDialogue(
|
||||
lines: PrologueVillageDialogueLine[],
|
||||
onComplete?: () => void,
|
||||
sourceNpcId?: string
|
||||
onComplete?: () => boolean | void,
|
||||
sourceNpcId?: string,
|
||||
dialogueKey: CampaignExplorationDialogueKey =
|
||||
'prologue-camp-interaction',
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
if (!lines.length || this.navigationPending) {
|
||||
return;
|
||||
}
|
||||
this.stopPlayerMovement();
|
||||
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId };
|
||||
this.dialogueState = {
|
||||
lines,
|
||||
lineIndex: Phaser.Math.Clamp(
|
||||
Math.floor(initialLineIndex),
|
||||
0,
|
||||
lines.length - 1
|
||||
),
|
||||
onComplete,
|
||||
sourceNpcId,
|
||||
dialogueKey
|
||||
};
|
||||
this.dialoguePanel?.setVisible(true);
|
||||
this.promptBackground?.setVisible(false);
|
||||
this.promptText?.setVisible(false);
|
||||
soundDirector.playSelect();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private renderDialogueLine() {
|
||||
@@ -2151,27 +2543,46 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
dialogue.lineIndex += 1;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
const onComplete = dialogue.onComplete;
|
||||
this.dialogueState = undefined;
|
||||
this.dialoguePanel?.setVisible(false);
|
||||
this.stopPlayerMovement();
|
||||
onComplete?.();
|
||||
const checkpointCommitted = onComplete?.() === true;
|
||||
if (!checkpointCommitted) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
private completeObjective(objectiveId: PrologueMilitiaCampRequiredObjectiveId) {
|
||||
if (this.isObjectiveComplete(objectiveId)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
objectiveTutorialIds[objectiveId],
|
||||
{
|
||||
explorationCheckpoint:
|
||||
this.explorationPositionCheckpoint()
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue militia camp objective.',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
||||
this.syncPreparationFeedback(objectiveId);
|
||||
soundDirector.playObjectiveAchieved();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
const objective = prologueMilitiaCampObjectiveDefinitions.find((entry) => entry.id === objectiveId);
|
||||
this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private openFirstCommandChoice() {
|
||||
@@ -2418,6 +2829,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.commandChoiceReadyAt = this.time.now + 260;
|
||||
this.refreshFirstCommandChoice();
|
||||
soundDirector.playSelect();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private chooseFirstCommandByIndex(index: number) {
|
||||
@@ -2444,6 +2856,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.pendingCommandOrderId = choice.orderId;
|
||||
this.refreshFirstCommandChoice();
|
||||
soundDirector.playSelect();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2485,14 +2898,23 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
|
||||
setCampaignSortieOrderSelection('first-battle-zhuo-commandery', choice.orderId);
|
||||
this.inputReadyAt = this.time.now + 260;
|
||||
this.closeCommandChoice();
|
||||
this.closeCommandChoice(false);
|
||||
soundDirector.playObjectiveAchieved();
|
||||
this.startCommandResponseDialogue(choice);
|
||||
return true;
|
||||
}
|
||||
|
||||
private startCommandResponseDialogue(
|
||||
choice: PrologueMilitiaCampCommandChoice,
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
this.startDialogue(
|
||||
choice.reaction,
|
||||
() => this.finishCamp(),
|
||||
`first-command-${choice.orderId}`
|
||||
`first-command-${choice.orderId}`,
|
||||
'prologue-camp-command-response',
|
||||
initialLineIndex
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelFirstCommandChoice() {
|
||||
@@ -2510,7 +2932,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
return true;
|
||||
}
|
||||
|
||||
private closeCommandChoice() {
|
||||
private closeCommandChoice(persistCheckpoint = true) {
|
||||
this.commandChoicePanel?.destroy();
|
||||
this.commandChoicePanel = undefined;
|
||||
this.commandChoicePanelBounds = undefined;
|
||||
@@ -2519,6 +2941,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
this.commandChoiceConfirmText = undefined;
|
||||
this.pendingCommandOrderId = undefined;
|
||||
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
|
||||
if (persistCheckpoint) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
}
|
||||
|
||||
private finishCamp() {
|
||||
@@ -2537,26 +2962,54 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete);
|
||||
this.refreshObjectiveHud();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
const previousCampaign = getCampaignState();
|
||||
this.navigationPending = true;
|
||||
this.stopPlayerMovement();
|
||||
soundDirector.playObjectiveAchieved();
|
||||
this.showTransitionOverlay();
|
||||
|
||||
void startGameScene(this, 'StoryScene', {
|
||||
pages: prologueDeparturePages(),
|
||||
nextScene: 'BattleScene',
|
||||
nextSceneData: { battleId: 'first-battle-zhuo-commandery' },
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
}).catch((error) => {
|
||||
console.error('Failed to continue from the prologue militia camp.', error);
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.refreshInteractionPrompt();
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
prologueMilitiaCampCampaignTutorialIds.complete,
|
||||
{
|
||||
clearExplorationCheckpointScene:
|
||||
'prologue-militia-camp'
|
||||
}
|
||||
);
|
||||
this.refreshObjectiveHud();
|
||||
await startGameScene(this, 'StoryScene', {
|
||||
pages: prologueDeparturePages(),
|
||||
nextScene: 'BattleScene',
|
||||
nextSceneData: {
|
||||
battleId: 'first-battle-zhuo-commandery'
|
||||
},
|
||||
presentationBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to continue from the prologue militia camp.',
|
||||
error
|
||||
);
|
||||
try {
|
||||
saveCampaignState(previousCampaign);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
'Failed to restore the prologue militia camp save after navigation failure.',
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private showTransitionOverlay() {
|
||||
@@ -2750,16 +3203,24 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
const movements = [
|
||||
{
|
||||
npcId: 'guan-yu',
|
||||
waypoints: [{ x: 650, y: 420 }],
|
||||
direction: 'north' as const
|
||||
waypoints: [
|
||||
{
|
||||
x: commandPartyPositions.guanYu.x,
|
||||
y: commandPartyPositions.guanYu.y
|
||||
}
|
||||
],
|
||||
direction: commandPartyPositions.guanYu.direction
|
||||
},
|
||||
{
|
||||
npcId: 'zhang-fei',
|
||||
waypoints: [
|
||||
{ x: 890, y: 560 },
|
||||
{ x: 890, y: 420 }
|
||||
{
|
||||
x: commandPartyPositions.zhangFei.x,
|
||||
y: commandPartyPositions.zhangFei.y
|
||||
}
|
||||
],
|
||||
direction: 'north' as const
|
||||
direction: commandPartyPositions.zhangFei.direction
|
||||
}
|
||||
].filter(({ npcId }) => this.npcViews.has(npcId));
|
||||
if (movements.length === 0) {
|
||||
@@ -2889,6 +3350,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
private restoreCommandPartyPositions() {
|
||||
this.setNpcPosition(
|
||||
'guan-yu',
|
||||
commandPartyPositions.guanYu.x,
|
||||
commandPartyPositions.guanYu.y,
|
||||
commandPartyPositions.guanYu.direction
|
||||
);
|
||||
this.setNpcPosition(
|
||||
'zhang-fei',
|
||||
commandPartyPositions.zhangFei.x,
|
||||
commandPartyPositions.zhangFei.y,
|
||||
commandPartyPositions.zhangFei.direction
|
||||
);
|
||||
}
|
||||
|
||||
private isObjectiveComplete(objectiveId: PrologueMilitiaCampRequiredObjectiveId) {
|
||||
return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
hasCompletedCampaignTutorial,
|
||||
markCampaignStep,
|
||||
prologueVillageCampaignTutorialIds,
|
||||
saveCampaignState,
|
||||
setCampaignExplorationCheckpoint,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignExplorationDialogueKey,
|
||||
type CampaignTutorialId
|
||||
} from '../state/campaignState';
|
||||
import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal';
|
||||
@@ -133,8 +137,9 @@ type VillageNpcView = {
|
||||
type DialogueState = {
|
||||
lines: PrologueVillageDialogueLine[];
|
||||
lineIndex: number;
|
||||
onComplete?: () => void;
|
||||
onComplete?: () => boolean | void;
|
||||
sourceNpcId?: string;
|
||||
dialogueKey: CampaignExplorationDialogueKey;
|
||||
};
|
||||
|
||||
type InteractionTarget =
|
||||
@@ -250,6 +255,16 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
private visualMotionReduced = false;
|
||||
private ownedPresentationTextureKeys = new Set<string>();
|
||||
private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay;
|
||||
private checkpointLastSignature = '';
|
||||
private checkpointRestoring = false;
|
||||
private readonly persistCheckpointOnPageHide = () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
};
|
||||
private readonly persistCheckpointWhenHidden = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('PrologueVillageScene');
|
||||
@@ -281,6 +296,14 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.resetRuntimeState();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
this.prepareCampaignProgress();
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.drawVillage();
|
||||
this.drawHud();
|
||||
this.createLoadingOverlay();
|
||||
@@ -294,9 +317,18 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
this.oathGatherPending = false;
|
||||
window.removeEventListener(
|
||||
'pagehide',
|
||||
this.persistCheckpointOnPageHide
|
||||
);
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.persistCheckpointWhenHidden
|
||||
);
|
||||
this.clearNavigationTarget(true);
|
||||
this.queuedMovementIntent = undefined;
|
||||
this.dialogueState = undefined;
|
||||
@@ -315,21 +347,18 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.loadingOverlay = undefined;
|
||||
this.ready = true;
|
||||
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
||||
const restoredCheckpoint =
|
||||
this.restoreExplorationCheckpoint();
|
||||
this.createCampaignObjectiveJournal();
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshNpcMarkers();
|
||||
this.refreshInteractionPrompt();
|
||||
|
||||
if (this.firstVisit) {
|
||||
this.startDialogue([
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
|
||||
}
|
||||
], () => {
|
||||
completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered);
|
||||
});
|
||||
if (
|
||||
this.firstVisit &&
|
||||
!restoredCheckpoint.overlayRestored
|
||||
) {
|
||||
this.startVillageIntroDialogue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +420,8 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
visualMotionReduced: this.visualMotionReduced,
|
||||
viewport: { width: this.scale.width, height: this.scale.height },
|
||||
campaignStep: campaign.step,
|
||||
explorationCheckpoint:
|
||||
campaign.explorationCheckpoint ?? null,
|
||||
campaignObjectiveJournal: {
|
||||
snapshot: resolveCampaignObjectiveJournal(campaign),
|
||||
view: this.campaignObjectiveJournal?.getDebugState() ?? null
|
||||
@@ -505,6 +536,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
|
||||
lineIndex: this.dialogueState.lineIndex,
|
||||
totalLines: this.dialogueState.lines.length,
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
sourceNpcId: this.dialogueState.sourceNpcId ?? null,
|
||||
portraitTextureKey: this.dialoguePortrait?.visible
|
||||
? this.dialoguePortrait.texture.key
|
||||
@@ -518,6 +550,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
speaker: null,
|
||||
lineIndex: null,
|
||||
totalLines: 0,
|
||||
dialogueKey: null,
|
||||
sourceNpcId: null,
|
||||
portraitTextureKey: null,
|
||||
portraitVisible: false,
|
||||
@@ -617,6 +650,8 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.oathGatherPending = false;
|
||||
this.stepIndex = 0;
|
||||
this.lastStepAt = 0;
|
||||
this.checkpointLastSignature = '';
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
|
||||
private prepareCampaignProgress() {
|
||||
@@ -629,6 +664,244 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
private persistExplorationCheckpoint(force = false) {
|
||||
if (
|
||||
!this.ready ||
|
||||
this.navigationPending ||
|
||||
this.checkpointRestoring ||
|
||||
!this.player
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const campaign = getCampaignState();
|
||||
if (
|
||||
campaign.step !== 'prologue-town' ||
|
||||
campaign.completedTutorialIds.includes(
|
||||
prologueVillageCampaignTutorialIds.complete
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const overlay: CampaignExplorationCheckpoint['overlay'] =
|
||||
this.dialogueState
|
||||
? {
|
||||
type: 'dialogue',
|
||||
dialogueKey: this.dialogueState.dialogueKey,
|
||||
...(this.dialogueState.sourceNpcId
|
||||
? {
|
||||
sourceNpcId:
|
||||
this.dialogueState.sourceNpcId
|
||||
}
|
||||
: {}),
|
||||
lineIndex: this.dialogueState.lineIndex
|
||||
}
|
||||
: undefined;
|
||||
const checkpoint: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
> = {
|
||||
version: 1,
|
||||
scene: 'prologue-village',
|
||||
contextId: 'prologue-village',
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
},
|
||||
...(overlay ? { overlay } : {})
|
||||
};
|
||||
const signature = JSON.stringify(checkpoint);
|
||||
if (
|
||||
!force &&
|
||||
signature === this.checkpointLastSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCampaignExplorationCheckpoint(checkpoint);
|
||||
this.checkpointLastSignature = signature;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue village checkpoint.',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private explorationPositionCheckpoint() {
|
||||
if (!this.player) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
scene: 'prologue-village',
|
||||
contextId: 'prologue-village',
|
||||
player: {
|
||||
x: this.player.x,
|
||||
y: this.player.y,
|
||||
direction: this.playerDirection
|
||||
}
|
||||
} satisfies Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>;
|
||||
}
|
||||
|
||||
private restoreExplorationCheckpoint() {
|
||||
const checkpoint =
|
||||
getCampaignState().explorationCheckpoint;
|
||||
if (
|
||||
!checkpoint ||
|
||||
checkpoint.scene !== 'prologue-village' ||
|
||||
checkpoint.contextId !== 'prologue-village' ||
|
||||
!this.player
|
||||
) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return {
|
||||
positionRestored: false,
|
||||
overlayRestored: false
|
||||
};
|
||||
}
|
||||
|
||||
this.checkpointRestoring = true;
|
||||
let positionRestored = false;
|
||||
let overlayRestored = false;
|
||||
try {
|
||||
if (
|
||||
this.canPlayerOccupy(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
)
|
||||
) {
|
||||
this.player.setPosition(
|
||||
checkpoint.player.x,
|
||||
checkpoint.player.y
|
||||
);
|
||||
this.playerDirection = checkpoint.player.direction;
|
||||
this.syncPlayerView();
|
||||
this.setPlayerMoving(false);
|
||||
positionRestored = true;
|
||||
}
|
||||
overlayRestored = checkpoint.overlay
|
||||
? this.restoreCheckpointOverlay(checkpoint.overlay)
|
||||
: false;
|
||||
} finally {
|
||||
this.checkpointRestoring = false;
|
||||
}
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return { positionRestored, overlayRestored };
|
||||
}
|
||||
|
||||
private restoreCheckpointOverlay(
|
||||
overlay: CampaignExplorationCheckpoint['overlay']
|
||||
) {
|
||||
if (!overlay || overlay.type !== 'dialogue') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
overlay.dialogueKey ===
|
||||
'prologue-village-oath'
|
||||
) {
|
||||
if (
|
||||
!this.allRequiredObjectivesComplete() ||
|
||||
hasCompletedCampaignTutorial(
|
||||
prologueVillageCampaignTutorialIds.complete
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.startOathDialogue(overlay.lineIndex);
|
||||
return Boolean(
|
||||
this.dialogueState?.dialogueKey ===
|
||||
overlay.dialogueKey
|
||||
);
|
||||
}
|
||||
if (
|
||||
overlay.dialogueKey !==
|
||||
'prologue-village-interaction'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (overlay.sourceNpcId === 'village-intro') {
|
||||
if (!this.firstVisit) {
|
||||
return false;
|
||||
}
|
||||
this.startVillageIntroDialogue(overlay.lineIndex);
|
||||
} else if (overlay.sourceNpcId === 'make-oath') {
|
||||
if (this.allRequiredObjectivesComplete()) {
|
||||
return false;
|
||||
}
|
||||
this.startLockedOathDialogue(overlay.lineIndex);
|
||||
} else if (overlay.sourceNpcId) {
|
||||
const target = this.interactionTargetById(
|
||||
overlay.sourceNpcId
|
||||
);
|
||||
if (!target || target.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
this.interactWithTarget(target);
|
||||
this.restoreDialogueLine(
|
||||
overlay.dialogueKey,
|
||||
overlay.lineIndex
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
this.dialogueState?.dialogueKey ===
|
||||
overlay.dialogueKey
|
||||
);
|
||||
}
|
||||
|
||||
private restoreDialogueLine(
|
||||
dialogueKey: CampaignExplorationDialogueKey,
|
||||
lineIndex: number
|
||||
) {
|
||||
const dialogue = this.dialogueState;
|
||||
if (!dialogue || dialogue.dialogueKey !== dialogueKey) {
|
||||
return;
|
||||
}
|
||||
dialogue.lineIndex = Phaser.Math.Clamp(
|
||||
Math.floor(lineIndex),
|
||||
0,
|
||||
dialogue.lines.length - 1
|
||||
);
|
||||
this.renderDialogueLine();
|
||||
}
|
||||
|
||||
private startVillageIntroDialogue(initialLineIndex = 0) {
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
|
||||
}
|
||||
],
|
||||
() => {
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
prologueVillageCampaignTutorialIds.entered,
|
||||
{
|
||||
explorationCheckpoint:
|
||||
this.explorationPositionCheckpoint()
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue village introduction.',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'village-intro',
|
||||
'prologue-village-interaction',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private drawVillage() {
|
||||
this.cameras.main.setBackgroundColor('#28372a');
|
||||
if (this.textures.exists(villageBackgroundKey)) {
|
||||
@@ -1818,6 +2091,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
if (!this.player) {
|
||||
return;
|
||||
}
|
||||
const wasMoving = this.playerMoving;
|
||||
applyExplorationCharacterMotion(
|
||||
this.player,
|
||||
playerTextureKey,
|
||||
@@ -1826,6 +2100,9 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.visualMotionReduced
|
||||
);
|
||||
this.playerMoving = moving;
|
||||
if (wasMoving && !moving) {
|
||||
this.persistExplorationCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlayerMovement() {
|
||||
@@ -1849,6 +2126,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.player.setPosition(x, y);
|
||||
this.syncPlayerView();
|
||||
this.stopPlayerMovement();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private gatherOathParty(onComplete: () => void) {
|
||||
@@ -2068,31 +2346,48 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
: view.definition.dialogue;
|
||||
this.startDialogue(dialogue, () => {
|
||||
if (view.definition.objectiveId && unlocked && !completed) {
|
||||
this.completeObjective(view.definition.objectiveId);
|
||||
return this.completeObjective(
|
||||
view.definition.objectiveId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}, view.definition.id);
|
||||
}
|
||||
|
||||
private interactWithOath() {
|
||||
if (!this.allRequiredObjectivesComplete()) {
|
||||
const remaining = prologueVillageObjectiveDefinitions
|
||||
.filter((objective) => objective.id !== 'make-oath' && !this.isObjectiveComplete(objective.id))
|
||||
.map((objective) => objective.shortLabel)
|
||||
.join(' · ');
|
||||
this.startDialogue([
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.`
|
||||
}
|
||||
]);
|
||||
this.startLockedOathDialogue();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gatherOathParty(() => this.startOathDialogue());
|
||||
}
|
||||
|
||||
private startOathDialogue() {
|
||||
private startLockedOathDialogue(initialLineIndex = 0) {
|
||||
const remaining = prologueVillageObjectiveDefinitions
|
||||
.filter(
|
||||
(objective) =>
|
||||
objective.id !== 'make-oath' &&
|
||||
!this.isObjectiveComplete(objective.id)
|
||||
)
|
||||
.map((objective) => objective.shortLabel)
|
||||
.join(' · ');
|
||||
this.startDialogue(
|
||||
[
|
||||
{
|
||||
speaker: '유비',
|
||||
textureKey: 'unit-liu-bei',
|
||||
text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.`
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
'make-oath',
|
||||
'prologue-village-interaction',
|
||||
initialLineIndex
|
||||
);
|
||||
}
|
||||
|
||||
private startOathDialogue(initialLineIndex = 0) {
|
||||
this.startDialogue([
|
||||
{
|
||||
speaker: '장비',
|
||||
@@ -2114,24 +2409,42 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
textureKey: 'unit-zhang-fei',
|
||||
text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!'
|
||||
}
|
||||
], () => this.finishVillage(), 'make-oath');
|
||||
],
|
||||
() => this.finishVillage(),
|
||||
'make-oath',
|
||||
'prologue-village-oath',
|
||||
initialLineIndex);
|
||||
}
|
||||
|
||||
private startDialogue(
|
||||
lines: PrologueVillageDialogueLine[],
|
||||
onComplete?: () => void,
|
||||
sourceNpcId?: string
|
||||
onComplete?: () => boolean | void,
|
||||
sourceNpcId?: string,
|
||||
dialogueKey: CampaignExplorationDialogueKey =
|
||||
'prologue-village-interaction',
|
||||
initialLineIndex = 0
|
||||
) {
|
||||
if (!lines.length || this.navigationPending) {
|
||||
return;
|
||||
}
|
||||
this.stopPlayerMovement();
|
||||
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId };
|
||||
this.dialogueState = {
|
||||
lines,
|
||||
lineIndex: Phaser.Math.Clamp(
|
||||
Math.floor(initialLineIndex),
|
||||
0,
|
||||
lines.length - 1
|
||||
),
|
||||
onComplete,
|
||||
sourceNpcId,
|
||||
dialogueKey
|
||||
};
|
||||
this.dialoguePanel?.setVisible(true);
|
||||
this.promptBackground?.setVisible(false);
|
||||
this.promptText?.setVisible(false);
|
||||
soundDirector.playSelect();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
|
||||
private renderDialogueLine() {
|
||||
@@ -2191,6 +2504,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
dialogue.lineIndex += 1;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.renderDialogueLine();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2198,15 +2512,32 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.dialogueState = undefined;
|
||||
this.dialoguePanel?.setVisible(false);
|
||||
this.stopPlayerMovement();
|
||||
onComplete?.();
|
||||
const checkpointCommitted = onComplete?.() === true;
|
||||
if (!checkpointCommitted) {
|
||||
this.persistExplorationCheckpoint(true);
|
||||
}
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
|
||||
private completeObjective(objectiveId: PrologueVillageRequiredObjectiveId) {
|
||||
if (this.isObjectiveComplete(objectiveId)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
objectiveTutorialIds[objectiveId],
|
||||
{
|
||||
explorationCheckpoint:
|
||||
this.explorationPositionCheckpoint()
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save the prologue village objective.',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
||||
if (objectiveId === 'meet-zhang-fei') {
|
||||
this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei);
|
||||
} else if (objectiveId === 'meet-guan-yu') {
|
||||
@@ -2225,6 +2556,7 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
this.refreshNpcMarkers();
|
||||
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
|
||||
this.showCompletionToast(`${objective?.shortLabel ?? '준비'} 완료`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private finishVillage() {
|
||||
@@ -2236,25 +2568,51 @@ export class PrologueVillageScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
completeCampaignTutorial(prologueVillageCampaignTutorialIds.complete);
|
||||
this.refreshObjectiveHud();
|
||||
this.persistExplorationCheckpoint(true);
|
||||
const previousCampaign = getCampaignState();
|
||||
this.navigationPending = true;
|
||||
this.stopPlayerMovement();
|
||||
soundDirector.playObjectiveAchieved();
|
||||
this.showTransitionOverlay();
|
||||
|
||||
void startGameScene(this, 'StoryScene', {
|
||||
pages: prologueBrotherhoodPages(),
|
||||
nextScene: 'PrologueMilitiaCampScene',
|
||||
presentationBattleId: 'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
}).catch((error) => {
|
||||
console.error('Failed to continue from the prologue village.', error);
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.refreshInteractionPrompt();
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
completeCampaignTutorial(
|
||||
prologueVillageCampaignTutorialIds.complete,
|
||||
{
|
||||
clearExplorationCheckpointScene:
|
||||
'prologue-village'
|
||||
}
|
||||
);
|
||||
this.refreshObjectiveHud();
|
||||
await startGameScene(this, 'StoryScene', {
|
||||
pages: prologueBrotherhoodPages(),
|
||||
nextScene: 'PrologueMilitiaCampScene',
|
||||
presentationBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
presentationStage: 'story'
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to continue from the prologue village.',
|
||||
error
|
||||
);
|
||||
try {
|
||||
saveCampaignState(previousCampaign);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
'Failed to restore the prologue village save after navigation failure.',
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
this.navigationPending = false;
|
||||
this.transitionOverlay?.destroy();
|
||||
this.transitionOverlay = undefined;
|
||||
this.refreshObjectiveHud();
|
||||
this.refreshInteractionPrompt();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private showTransitionOverlay() {
|
||||
|
||||
@@ -1358,6 +1358,33 @@ export class TitleScene extends Phaser.Scene {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const explorationCheckpoint = campaign.explorationCheckpoint;
|
||||
if (
|
||||
explorationCheckpoint?.scene === 'camp-visit-exploration' &&
|
||||
campaign.activeCampVisitId
|
||||
) {
|
||||
await this.navigateTo('CampVisitExplorationScene', {
|
||||
visitId: campaign.activeCampVisitId
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
explorationCheckpoint?.scene === 'city-stay' &&
|
||||
campaign.activeCityStayId
|
||||
) {
|
||||
await this.navigateTo('CityStayScene', {
|
||||
cityStayId: campaign.activeCityStayId
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (explorationCheckpoint?.scene === 'prologue-village') {
|
||||
await this.navigateTo('PrologueVillageScene');
|
||||
return;
|
||||
}
|
||||
if (explorationCheckpoint?.scene === 'prologue-militia-camp') {
|
||||
await this.navigateTo('PrologueMilitiaCampScene');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldReviewThirdCampPreparationBeforeRetry(campaign)
|
||||
) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,11 @@ import {
|
||||
import {
|
||||
applyCampBondExp,
|
||||
applyCampVisitReward,
|
||||
campaignInventoryCap,
|
||||
getCampaignState,
|
||||
normalizeCityEquipmentPurchaseIntentId,
|
||||
saveCampaignState,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignState,
|
||||
type FirstBattleReport
|
||||
} from './campaignState';
|
||||
@@ -58,7 +61,8 @@ export type CityResonanceActionResult =
|
||||
'invalid-city' |
|
||||
'invalid-action' |
|
||||
'already-completed' |
|
||||
'bond-unavailable'
|
||||
'bond-unavailable' |
|
||||
'save-unavailable'
|
||||
>;
|
||||
|
||||
export type CityEquipmentPurchaseResult =
|
||||
@@ -68,13 +72,18 @@ export type CityEquipmentPurchaseResult =
|
||||
offer: CityEquipmentOffer;
|
||||
item: ItemDefinition;
|
||||
purchaseNumber: number;
|
||||
replayed: boolean;
|
||||
campaign: CampaignState;
|
||||
}
|
||||
| CityStayActionFailure<
|
||||
'invalid-city' |
|
||||
'invalid-action' |
|
||||
'invalid-purchase-intent' |
|
||||
'intent-conflict' |
|
||||
'restricted-item' |
|
||||
'insufficient-gold' |
|
||||
'inventory-full' |
|
||||
'purchase-limit-reached' |
|
||||
'save-unavailable'
|
||||
>;
|
||||
|
||||
@@ -96,7 +105,11 @@ export type CityEquipmentEquipIntentResult =
|
||||
|
||||
export function collectCityStayInformation(
|
||||
cityStayId: CityStayId,
|
||||
actionId: string
|
||||
actionId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): CityInformationActionResult {
|
||||
const cityStay = canonicalCityStay(cityStayId);
|
||||
if (!cityStay) {
|
||||
@@ -114,11 +127,24 @@ export function collectCityStayInformation(
|
||||
return { ok: false, reason: 'already-completed' };
|
||||
}
|
||||
|
||||
const updated = applyCampVisitReward(
|
||||
cityStay.information.id,
|
||||
{ itemRewards: [cityStay.information.itemReward] },
|
||||
'city-information-gathered'
|
||||
);
|
||||
let updated: CampaignState | undefined;
|
||||
try {
|
||||
updated = applyCampVisitReward(
|
||||
cityStay.information.id,
|
||||
{ itemRewards: [cityStay.information.itemReward] },
|
||||
'city-information-gathered',
|
||||
{
|
||||
...(postActionCheckpoint
|
||||
? {
|
||||
explorationCheckpoint:
|
||||
postActionCheckpoint
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!updated) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
@@ -133,7 +159,11 @@ export function collectCityStayInformation(
|
||||
|
||||
export function chooseCityStayResonance(
|
||||
cityStayId: CityStayId,
|
||||
actionId: string
|
||||
actionId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): CityResonanceActionResult {
|
||||
const cityStay = canonicalCityStay(cityStayId);
|
||||
if (!cityStay) {
|
||||
@@ -155,7 +185,22 @@ export function chooseCityStayResonance(
|
||||
}
|
||||
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
const report = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id);
|
||||
let report: FirstBattleReport | undefined;
|
||||
try {
|
||||
report = applyCampBondExp(
|
||||
dialogue.id,
|
||||
dialogue.bondId,
|
||||
rewardExp,
|
||||
choice.id,
|
||||
{
|
||||
...(postActionCheckpoint
|
||||
? { explorationCheckpoint: postActionCheckpoint }
|
||||
: {})
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!report) {
|
||||
return { ok: false, reason: 'bond-unavailable' };
|
||||
}
|
||||
@@ -173,7 +218,8 @@ export function chooseCityStayResonance(
|
||||
|
||||
export function purchaseCityStayEquipment(
|
||||
cityStayId: CityStayId,
|
||||
actionId: string
|
||||
actionId: string,
|
||||
purchaseIntentId: string
|
||||
): CityEquipmentPurchaseResult {
|
||||
const cityStay = canonicalCityStay(cityStayId);
|
||||
if (!cityStay) {
|
||||
@@ -189,11 +235,37 @@ export function purchaseCityStayEquipment(
|
||||
}
|
||||
const { offer, item } = canonicalOffer;
|
||||
|
||||
const intentId =
|
||||
normalizeCityEquipmentPurchaseIntentId(purchaseIntentId);
|
||||
if (!intentId) {
|
||||
return { ok: false, reason: 'invalid-purchase-intent' };
|
||||
}
|
||||
|
||||
const snapshot = getCampaignState();
|
||||
const previousReceipt =
|
||||
snapshot.cityEquipmentPurchaseReceipts[intentId];
|
||||
if (previousReceipt) {
|
||||
if (
|
||||
previousReceipt.cityStayId !== cityStay.id ||
|
||||
previousReceipt.offerId !== offer.id
|
||||
) {
|
||||
return { ok: false, reason: 'intent-conflict' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
cityStay,
|
||||
offer,
|
||||
item,
|
||||
purchaseNumber: previousReceipt.purchaseNumber,
|
||||
replayed: true,
|
||||
campaign: snapshot
|
||||
};
|
||||
}
|
||||
|
||||
if (item.rank !== 'common') {
|
||||
return { ok: false, reason: 'restricted-item' };
|
||||
}
|
||||
|
||||
const snapshot = getCampaignState();
|
||||
if (!campaignSupportsCityStay(snapshot, cityStay)) {
|
||||
return { ok: false, reason: 'invalid-city' };
|
||||
}
|
||||
@@ -202,13 +274,21 @@ export function purchaseCityStayEquipment(
|
||||
}
|
||||
|
||||
const label = itemInventoryLabel(item.id);
|
||||
if (
|
||||
(snapshot.inventory[label] ?? 0) >=
|
||||
campaignInventoryCap(label)
|
||||
) {
|
||||
return { ok: false, reason: 'inventory-full' };
|
||||
}
|
||||
const purchaseCounts = normalizeCityEquipmentPurchaseCounts(
|
||||
snapshot.cityEquipmentPurchaseCounts
|
||||
);
|
||||
const purchaseNumber = Math.min(
|
||||
99,
|
||||
(purchaseCounts[offer.id] ?? 0) + 1
|
||||
);
|
||||
const previousPurchaseCount = purchaseCounts[offer.id] ?? 0;
|
||||
if (previousPurchaseCount >= 99) {
|
||||
return { ok: false, reason: 'purchase-limit-reached' };
|
||||
}
|
||||
const purchaseNumber = previousPurchaseCount + 1;
|
||||
const completedAt = new Date().toISOString();
|
||||
const updated: CampaignState = {
|
||||
...snapshot,
|
||||
gold: snapshot.gold - offer.price,
|
||||
@@ -219,6 +299,17 @@ export function purchaseCityStayEquipment(
|
||||
cityEquipmentPurchaseCounts: {
|
||||
...purchaseCounts,
|
||||
[offer.id]: purchaseNumber
|
||||
},
|
||||
cityEquipmentPurchaseReceipts: {
|
||||
...snapshot.cityEquipmentPurchaseReceipts,
|
||||
[intentId]: {
|
||||
version: 1,
|
||||
intentId,
|
||||
cityStayId: cityStay.id,
|
||||
offerId: offer.id,
|
||||
purchaseNumber,
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
};
|
||||
const persisted = persistPurchase(updated, snapshot);
|
||||
@@ -232,6 +323,7 @@ export function purchaseCityStayEquipment(
|
||||
offer,
|
||||
item,
|
||||
purchaseNumber,
|
||||
replayed: false,
|
||||
campaign: persisted
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
applyCampVisitReward,
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
@@ -32,7 +33,11 @@ export type FirstPursuitScoutActionResult =
|
||||
};
|
||||
|
||||
export function completeFirstPursuitScoutVisit(
|
||||
choiceId: string
|
||||
choiceId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): FirstPursuitScoutActionResult {
|
||||
let campaign: CampaignState;
|
||||
try {
|
||||
@@ -69,7 +74,15 @@ export function completeFirstPursuitScoutVisit(
|
||||
bondExp: choice.bondExp,
|
||||
itemRewards: [...choice.itemRewards]
|
||||
},
|
||||
choice.id
|
||||
choice.id,
|
||||
{
|
||||
...(postActionCheckpoint
|
||||
? {
|
||||
explorationCheckpoint:
|
||||
postActionCheckpoint
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
applyCampVisitReward,
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
@@ -93,7 +94,11 @@ export function secondBattleReliefProgress(
|
||||
}
|
||||
|
||||
export function recordSecondBattleReliefObjective(
|
||||
objectiveId: string
|
||||
objectiveId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): SecondBattleReliefObjectiveActionResult {
|
||||
let campaign: CampaignState;
|
||||
try {
|
||||
@@ -138,7 +143,9 @@ export function recordSecondBattleReliefObjective(
|
||||
const updated = persistReliefProgress(
|
||||
campaign,
|
||||
secondBattleReliefObjectiveProgressId(objective.id),
|
||||
{}
|
||||
{},
|
||||
undefined,
|
||||
postActionCheckpoint
|
||||
);
|
||||
if (!updated) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
@@ -156,7 +163,11 @@ export function recordSecondBattleReliefObjective(
|
||||
}
|
||||
|
||||
export function completeSecondBattleReliefExploration(
|
||||
choiceId: string
|
||||
choiceId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): SecondBattleReliefCompletionResult {
|
||||
let campaign: CampaignState;
|
||||
try {
|
||||
@@ -190,7 +201,8 @@ export function completeSecondBattleReliefExploration(
|
||||
bondExp: choice.bondExp,
|
||||
itemRewards: [...choice.itemRewards]
|
||||
},
|
||||
choice.id
|
||||
choice.id,
|
||||
postActionCheckpoint
|
||||
);
|
||||
if (!updated) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
@@ -235,13 +247,25 @@ function persistReliefProgress(
|
||||
bondExp?: number;
|
||||
itemRewards?: string[];
|
||||
},
|
||||
choiceId?: string
|
||||
choiceId?: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
) {
|
||||
try {
|
||||
return applyCampVisitReward(
|
||||
visitId,
|
||||
reward,
|
||||
choiceId
|
||||
choiceId,
|
||||
{
|
||||
...(postActionCheckpoint
|
||||
? {
|
||||
explorationCheckpoint:
|
||||
postActionCheckpoint
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
applyCampBondExp,
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignExplorationCheckpoint,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
@@ -144,7 +145,12 @@ export function hasSeenThirdCampExplorationIntro(
|
||||
);
|
||||
}
|
||||
|
||||
export function completeThirdCampExplorationIntro():
|
||||
export function completeThirdCampExplorationIntro(
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
):
|
||||
ThirdCampExplorationIntroCompletionResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
@@ -154,20 +160,26 @@ export function completeThirdCampExplorationIntro():
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
|
||||
const updated = withVisitMarkers(snapshot, [
|
||||
const markerUpdated = withVisitMarkers(snapshot, [
|
||||
thirdCampExplorationVisitId,
|
||||
thirdCampExplorationIntroSeenId
|
||||
]);
|
||||
if (
|
||||
updated.completedCampVisits.length ===
|
||||
snapshot.completedCampVisits.length
|
||||
) {
|
||||
const changed =
|
||||
markerUpdated.completedCampVisits.length !==
|
||||
snapshot.completedCampVisits.length;
|
||||
if (!changed && !postActionCheckpoint) {
|
||||
return { ok: true, campaign: snapshot, changed: false };
|
||||
}
|
||||
|
||||
const persisted = persistCampaign(updated, snapshot);
|
||||
const persisted = persistCampaign(
|
||||
withExplorationCheckpoint(
|
||||
markerUpdated,
|
||||
postActionCheckpoint
|
||||
),
|
||||
snapshot
|
||||
);
|
||||
return persisted
|
||||
? { ok: true, campaign: persisted, changed: true }
|
||||
? { ok: true, campaign: persisted, changed }
|
||||
: { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
@@ -207,7 +219,11 @@ export function enterThirdCampExploration():
|
||||
}
|
||||
|
||||
export function recordThirdCampExplorationActivity(
|
||||
priorityId: string
|
||||
priorityId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): ThirdCampExplorationActivityResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
@@ -238,7 +254,9 @@ export function recordThirdCampExplorationActivity(
|
||||
|
||||
const persisted = persistActivityCompletion(
|
||||
snapshot,
|
||||
priorityId
|
||||
priorityId,
|
||||
snapshot,
|
||||
postActionCheckpoint
|
||||
);
|
||||
if (!persisted) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
@@ -257,7 +275,11 @@ export function recordThirdCampExplorationActivity(
|
||||
}
|
||||
|
||||
export function completeThirdCampCompanionActivity(
|
||||
choiceId: string
|
||||
choiceId: string,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
): ThirdCampCompanionActivityResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
@@ -282,29 +304,40 @@ export function completeThirdCampCompanionActivity(
|
||||
|
||||
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) {
|
||||
let report;
|
||||
try {
|
||||
report = applyCampBondExp(
|
||||
dialogue.id,
|
||||
dialogue.bondId,
|
||||
rewardExp,
|
||||
choice.id,
|
||||
{
|
||||
completedVisitIds: [
|
||||
thirdCampExplorationVisitId,
|
||||
thirdCampExplorationActivityProgressId(
|
||||
'companion'
|
||||
)
|
||||
],
|
||||
...(postActionCheckpoint
|
||||
? {
|
||||
explorationCheckpoint:
|
||||
postActionCheckpoint
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
const persisted = persistActivityCompletion(
|
||||
afterDialogue,
|
||||
'companion',
|
||||
snapshot
|
||||
);
|
||||
if (!report) {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return { ok: false, reason: 'bond-unavailable' };
|
||||
}
|
||||
const persisted = readCampaignSnapshot();
|
||||
if (!persisted) {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
@@ -361,7 +394,11 @@ function hasCompletedCompanionActivity(campaign: CampaignState) {
|
||||
function persistActivityCompletion(
|
||||
current: CampaignState,
|
||||
activityId: ThirdCampExplorationActivityId,
|
||||
rollbackSnapshot: CampaignState = current
|
||||
rollbackSnapshot: CampaignState = current,
|
||||
postActionCheckpoint?: Omit<
|
||||
CampaignExplorationCheckpoint,
|
||||
'savedAt'
|
||||
>
|
||||
) {
|
||||
const activityMarker =
|
||||
thirdCampExplorationActivityProgressId(activityId);
|
||||
@@ -374,7 +411,13 @@ function persistActivityCompletion(
|
||||
acknowledgedVictoryRewardCategories:
|
||||
acknowledgedRewardCategoryForActivity(updated, activityId)
|
||||
};
|
||||
return persistCampaign(updated, rollbackSnapshot);
|
||||
return persistCampaign(
|
||||
withExplorationCheckpoint(
|
||||
updated,
|
||||
postActionCheckpoint
|
||||
),
|
||||
rollbackSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
function acknowledgedRewardCategoryForActivity(
|
||||
@@ -428,27 +471,26 @@ function withVisitMarkers(
|
||||
};
|
||||
}
|
||||
|
||||
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 withExplorationCheckpoint(
|
||||
campaign: CampaignState,
|
||||
checkpoint:
|
||||
| Omit<CampaignExplorationCheckpoint, 'savedAt'>
|
||||
| undefined
|
||||
): CampaignState {
|
||||
if (!checkpoint) {
|
||||
return campaign;
|
||||
}
|
||||
return {
|
||||
...campaign,
|
||||
explorationCheckpoint: {
|
||||
...checkpoint,
|
||||
player: { ...checkpoint.player },
|
||||
...(checkpoint.overlay
|
||||
? { overlay: { ...checkpoint.overlay } }
|
||||
: {}),
|
||||
savedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readCampaignSnapshot() {
|
||||
|
||||
Reference in New Issue
Block a user