feat: resume exploration exactly and harden campaign saves

This commit is contained in:
2026-07-28 17:39:20 +09:00
parent a08d251098
commit 8a6c8c2cd7
24 changed files with 8958 additions and 413 deletions

View File

@@ -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`와 구버전 변환표를 더해, 긴 캠페인에서
“중단해도 읽은 만큼만 이어지는” 경험을 전 구간으로 확장한다.
## 장기 개선 순서

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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`
);
}

View 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();
}

View File

@@ -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)}`

View 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.`
);
}

View File

@@ -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' });

View 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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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'] }) &&

View File

@@ -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;

View File

@@ -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();
}
})();
});
}

View File

@@ -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();
}

View File

@@ -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]);
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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
};
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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() {