feat: add second-victory relief exploration
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Audio Sources
|
||||
|
||||
Downloaded from Pixabay on 2026-06-22, 2026-07-21, and 2026-07-22 and used under the Pixabay Content License.
|
||||
Downloaded from Pixabay on 2026-06-22, 2026-07-21, 2026-07-22, and
|
||||
2026-07-27 and used under the Pixabay Content License.
|
||||
|
||||
License reference: https://pixabay.com/service/license-summary/
|
||||
|
||||
@@ -29,6 +30,20 @@ The following files are generated deterministically by `scripts/generate-camp-au
|
||||
| `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps |
|
||||
| `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps |
|
||||
|
||||
## Daytime Exploration Ambience
|
||||
|
||||
The following source was downloaded from its official Pixabay detail page on
|
||||
2026-07-27 and is used under the Pixabay Content License linked above.
|
||||
|
||||
| Project file | Role | Pixabay title | Creator | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src/assets/audio/ambience/riverside-village-day-ambience.mp3` | Daytime water-and-bird bed for the northern-village ferry exploration | Stream Water with Birds | loswin23 | https://pixabay.com/sound-effects/nature-stream-water-with-birds-459511/ |
|
||||
|
||||
The 50-second source was reduced to a 32-second 44.1 kHz stereo MP3 loop. A
|
||||
two-second end-to-start equal-power blend avoids a hard seam, and the encoded
|
||||
track is balanced to -24.03 LUFS at 128 kbps. The final MP3 retains the title,
|
||||
creator, Pixabay Content License, and source URL in its metadata.
|
||||
|
||||
## Battlefield Ambience and Tactical Cues
|
||||
|
||||
The source files in this section were downloaded from their official Pixabay detail pages on 2026-07-21. They use the Pixabay Content License linked above.
|
||||
|
||||
@@ -145,9 +145,36 @@ RPG로 다듬는다.
|
||||
- 오래된 저장은 그대로 읽고, 오염된 관계·무장 참조는 제거하며, 전투
|
||||
저장 재개와 두 번째 전투 재도전에서도 보너스를 중복 가산하지 않는다.
|
||||
|
||||
다음 초반 묶음은 정보 수집·장비 구매·동료 대화 가운데 무엇을 먼저
|
||||
선택했는지가 출전 브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오는
|
||||
경로를 감사한다.
|
||||
### 완료한 6차 묶음: 첫 승리 뒤 직접 걷는 정찰
|
||||
|
||||
- 첫 승리 뒤 정적인 방문 카드 대신 유비를 직접 움직여 간옹에게 가고,
|
||||
강가 매복과 마을 구호 중 다음 추격전의 정찰 우선순위를 정한다.
|
||||
- 유비·간옹을 전투용 말이 아닌 별도의 SD 이동 인물로 보여 주고,
|
||||
대화창은 전신 대신 얼굴 중심 초상을 사용한다.
|
||||
- 목표 인물 한 번 클릭으로 장애물을 피해 대화 거리까지 이동하며, NPC는
|
||||
순간이동하지 않고 제자리에서 유비 쪽만 바라본다.
|
||||
- 선택은 공명·물자 보상에 그치지 않고 두 번째 전투의 배치 정보와 개전
|
||||
대사로 이어진다.
|
||||
- 이동, 거리 제한, 선택 저장, 중복 보상 방지, 군영 복귀를
|
||||
1920x1080 브라우저에서 자동 검증한다.
|
||||
|
||||
### 완료한 7차 묶음: 두 번째 승리의 현장 수습과 전투 회수
|
||||
|
||||
- 두 번째 승리 직후 북쪽 마을과 나루를 직접 걸으며 관우 → 사공 → 장비 →
|
||||
간옹 순서로 현장을 확인한다.
|
||||
- 첫 정찰에서 무엇을 택했는지와 실제로 마을 보호·신속 격파를 달성했는지
|
||||
함께 회고해, 성공을 거짓으로 칭찬하거나 실패를 잊지 않는다.
|
||||
- 세 현장 확인은 즉시 저장된다. 군영으로 돌아갔다가 다시 들어와도 다음
|
||||
조사 지점부터 이어지며 최종 방침을 중복 선택할 수 없다.
|
||||
- 마을 보급을 택하면 세 번째 전투에서 유비가 휴대 콩 1개를 더 받고,
|
||||
나루 추적을 택하면 배치 단계에서 전령 마원 한 명의 첫 움직임만 먼저
|
||||
확인한다.
|
||||
- 낮 강변 환경음, 지면별 발소리, 목표 완료의 시각·음향 신호를 함께
|
||||
사용해 소리를 듣지 못해도 진행 상태를 이해할 수 있게 한다.
|
||||
|
||||
다음 초반 묶음은 세 번째 군영부터 반복되는 방문·대화·상점 흐름을 감사해
|
||||
정보 수집, 장비 구매, 동료 대화 가운데 무엇을 먼저 택했는지가 출전
|
||||
브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오게 한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
@@ -203,8 +230,19 @@ RPG로 다듬는다.
|
||||
명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기
|
||||
- [Nintendo: Fire Emblem Three Houses 101](https://www.nintendo.com/au/news-and-articles/fire-emblem-three-houses-101/):
|
||||
자유 탐색·관계 대화와 출전 인원·장비·정찰을 한 전투 준비 순환으로 연결
|
||||
- [Supergiant Games: Hades FAQ](https://www.supergiantgames.com/blog/hades-faq/):
|
||||
반복 전투에서도 새 인물 사건과 이야기 상태가 계속 드러나는 거점 순환
|
||||
- [Supergiant Games: Hades v1.0 업데이트](https://www.supergiantgames.com/blog/hades-updates/):
|
||||
맥락별 대사·조사 지점·초상·환경음과 관계 사건의 조건·우선순위를 함께
|
||||
다듬어 반복 귀환의 변화를 유지
|
||||
- [Nintendo: Fire Emblem Engage 전투와 유대](https://www.nintendo.com/ph/switch/ayfn/system/battle.html):
|
||||
함께 싸우고 회복한 경험을 거점 대화와 다음 전투의 인접 지원으로 회수
|
||||
- [Xbox Accessibility Guideline 103](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/103):
|
||||
목표·상호작용·위험 같은 핵심 신호를 시각과 소리 가운데 하나에만 맡기지
|
||||
않고 보조 감각 채널로 함께 전달
|
||||
- [Xbox Accessibility Guideline 105](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/105):
|
||||
음악·효과·환경음을 분리 조절하고 중요한 정보음이 배경음에 묻히지 않게
|
||||
설계
|
||||
- [Darkest Dungeon Affliction System, Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-i-darkest-dungeon-s-i-affliction-system):
|
||||
원정의 결과가 귀환 뒤 인물별 준비 선택으로 이어지고 수치 결과는 명확히 공개
|
||||
- [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf):
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# Prologue exploration visual asset report
|
||||
# Exploration visual asset report
|
||||
|
||||
Generated on 2026-07-26 with the built-in image generation workflow. These are
|
||||
original project assets; no commercial-game screenshot, logo, or third-party
|
||||
character artwork is included in the runtime files.
|
||||
Generated on 2026-07-26 and extended on 2026-07-27 with the built-in image
|
||||
generation workflow. These are original project assets; no commercial-game
|
||||
screenshot, logo, or third-party character artwork is included in the runtime
|
||||
files.
|
||||
|
||||
## Runtime assets
|
||||
|
||||
| Role | Project assets | Runtime format |
|
||||
| --- | --- | --- |
|
||||
| Zhuo village and militia camp | `src/assets/images/exploration/prologue-village.webp`, `prologue-militia-camp.webp` | 1920×1080 opaque WebP |
|
||||
| Second-victory northern village and ferry | `src/assets/images/exploration/second-pursuit-village-ferry.webp` | 1920×1080 opaque WebP |
|
||||
| Exploration SD characters | `src/assets/images/exploration/characters/exploration-*.webp` | 3072×768 transparent WebP sprite sheets |
|
||||
| First-victory camp Jian Yong | `src/assets/images/exploration/characters/exploration-jian-yong.webp` | 3072×768 transparent WebP sprite sheet |
|
||||
| Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP |
|
||||
|
||||
The two backgrounds reserve the exact top, right, and bottom HUD regions used
|
||||
by the 1920×1080 desktop layout. The playable portion was aligned to the
|
||||
The three backgrounds reserve the exact top, right, and bottom HUD regions used
|
||||
by the 1920×1080 desktop layout. Each playable portion was aligned to the
|
||||
existing collision and interaction coordinate system before export.
|
||||
|
||||
Each exploration character sheet contains 192×192 frames: four direction rows
|
||||
@@ -38,6 +40,22 @@ notice board, peach orchard, and north gate. The camp variant required the
|
||||
north gate, command tent, scout tent, sleeping tent, supply area, training
|
||||
area, and central fire.
|
||||
|
||||
The second-victory relief variant used the first exploration background as a
|
||||
camera-and-style reference and the second battle map as a spatial reference.
|
||||
Its final built-in generation request was:
|
||||
|
||||
> Original late-Eastern-Han riverside village after victory, with a shallow
|
||||
> river and ferry to the left, a broad central recovery road and courtyard,
|
||||
> damaged farmhouses in the upper-right, a north road toward Guangzong, and a
|
||||
> southern camp exit. Leave readable open ground for six small SD actors and
|
||||
> the fixed desktop HUD, under clear warm late-afternoon light. No people,
|
||||
> text, UI, logo, watermark, modern object, copied character, or copyrighted
|
||||
> game design.
|
||||
|
||||
The generated master was converted to an opaque 1920×1080 WebP with
|
||||
`scripts/build-exploration-background.py`; only the optimized runtime asset is
|
||||
tracked.
|
||||
|
||||
### Dialogue portraits
|
||||
|
||||
> High-resolution realistic historical painting, late-Eastern-Han clothing,
|
||||
@@ -86,7 +104,7 @@ runtime sheet is shipped.
|
||||
|
||||
## Optimization and verification
|
||||
|
||||
- Backgrounds are limited to 2 MiB each and 4 MiB combined.
|
||||
- Backgrounds are limited to 2 MiB each and 6 MiB combined.
|
||||
- SD sheets use transparent WebP and remain under 0.6 MiB each.
|
||||
- New NPC portraits were converted from generated PNG masters to high-quality
|
||||
WebP, reducing the five-file total from about 11.6 MiB to about 1.3 MiB.
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs",
|
||||
"verify:first-pursuit-scout": "node scripts/verify-first-pursuit-scout-memory.mjs",
|
||||
"verify:first-pursuit-camp:browser": "node scripts/verify-first-pursuit-camp-exploration-browser.mjs",
|
||||
"verify:second-battle-relief": "node scripts/verify-second-battle-relief-exploration.mjs",
|
||||
"verify:second-battle-relief:browser": "node scripts/verify-second-battle-relief-exploration-browser.mjs",
|
||||
"verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs",
|
||||
"verify:exploration-backgrounds": "node scripts/verify-prologue-exploration-asset-data.mjs",
|
||||
"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",
|
||||
@@ -70,7 +73,7 @@
|
||||
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
|
||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp: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:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief: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",
|
||||
|
||||
60
scripts/build-exploration-background.py
Normal file
60
scripts/build-exploration-background.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Build an opaque, desktop-sized WebP exploration background."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--width", type=int, default=1920)
|
||||
parser.add_argument("--height", type=int, default=1080)
|
||||
parser.add_argument("--quality", type=int, default=88)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.width <= 0 or args.height <= 0:
|
||||
raise ValueError("Output dimensions must be positive.")
|
||||
if not 1 <= args.quality <= 100:
|
||||
raise ValueError("WebP quality must be between 1 and 100.")
|
||||
|
||||
output_path = args.output.resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with Image.open(args.input) as source:
|
||||
rgb_source = ImageOps.exif_transpose(source).convert("RGB")
|
||||
resized = ImageOps.fit(
|
||||
rgb_source,
|
||||
(args.width, args.height),
|
||||
method=Image.Resampling.LANCZOS,
|
||||
centering=(0.5, 0.5),
|
||||
)
|
||||
resized.save(
|
||||
output_path,
|
||||
format="WEBP",
|
||||
quality=args.quality,
|
||||
method=6,
|
||||
exact=False,
|
||||
)
|
||||
|
||||
with Image.open(output_path) as result:
|
||||
if result.size != (args.width, args.height) or result.mode != "RGB":
|
||||
raise RuntimeError(
|
||||
f"Unexpected output: {result.size[0]}x{result.size[1]} {result.mode}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Built {output_path} ({args.width}x{args.height}, "
|
||||
f"{output_path.stat().st_size} bytes)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
scripts/build-riverside-day-ambience.py
Normal file
101
scripts/build-riverside-day-ambience.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Prepare the licensed daytime riverside ambience used by exploration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://pixabay.com/sound-effects/"
|
||||
"nature-stream-water-with-birds-459511/"
|
||||
)
|
||||
TARGET_DURATION_SECONDS = 32
|
||||
SOURCE_OFFSET_SECONDS = 7
|
||||
LOOP_CROSSFADE_SECONDS = 2
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source_path = args.input.resolve()
|
||||
output_path = args.output.resolve()
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(source_path)
|
||||
|
||||
ffmpeg = os.environ.get("FFMPEG_PATH") or shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
raise RuntimeError("ffmpeg is required to build the ambience track.")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
loop_end = SOURCE_OFFSET_SECONDS + TARGET_DURATION_SECONDS
|
||||
head_end = SOURCE_OFFSET_SECONDS + LOOP_CROSSFADE_SECONDS
|
||||
body_start = head_end
|
||||
tail_start = loop_end
|
||||
tail_end = loop_end + LOOP_CROSSFADE_SECONDS
|
||||
filter_graph = (
|
||||
"[0:a]asplit=3[tailSource][headSource][bodySource];"
|
||||
f"[tailSource]atrim=start={tail_start}:end={tail_end},"
|
||||
f"asetpts=PTS-STARTPTS,afade=t=out:st=0:"
|
||||
f"d={LOOP_CROSSFADE_SECONDS}[tail];"
|
||||
f"[headSource]atrim=start={SOURCE_OFFSET_SECONDS}:end={head_end},"
|
||||
f"asetpts=PTS-STARTPTS,afade=t=in:st=0:"
|
||||
f"d={LOOP_CROSSFADE_SECONDS}[head];"
|
||||
"[tail][head]amix=inputs=2:normalize=0:duration=first[seam];"
|
||||
f"[bodySource]atrim=start={body_start}:end={loop_end},"
|
||||
"asetpts=PTS-STARTPTS[body];"
|
||||
"[seam][body]concat=n=2:v=0:a=1,"
|
||||
"loudnorm=I=-24:TP=-3:LRA=7[out]"
|
||||
)
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(source_path),
|
||||
"-filter_complex",
|
||||
filter_graph,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"2",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-metadata",
|
||||
"title=Riverside Village Day Ambience",
|
||||
"-metadata",
|
||||
"artist=loswin23",
|
||||
"-metadata",
|
||||
(
|
||||
"comment=Derived from Stream Water with Birds; "
|
||||
f"Pixabay Content License; {SOURCE_URL}"
|
||||
),
|
||||
str(output_path),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
if output_path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"Empty ambience output: {output_path}")
|
||||
print(
|
||||
f"Built {output_path} ({TARGET_DURATION_SECONDS}s target, "
|
||||
f"{output_path.stat().st_size} bytes)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,11 +8,15 @@ const sourceFilesToScan = [
|
||||
'src/game/audio/SoundDirector.ts',
|
||||
'src/game/scenes/BattleScene.ts',
|
||||
'src/game/scenes/CampScene.ts',
|
||||
'src/game/scenes/CampVisitExplorationScene.ts',
|
||||
'src/game/scenes/EndingScene.ts',
|
||||
'src/game/scenes/StoryScene.ts',
|
||||
'src/game/scenes/TitleScene.ts'
|
||||
];
|
||||
const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire'];
|
||||
const requiredDaytimeExplorationAmbienceKeys = [
|
||||
'riverside-village-day-ambience'
|
||||
];
|
||||
const requiredTacticalCueKeys = [
|
||||
'ally-turn',
|
||||
'enemy-turn',
|
||||
@@ -68,6 +72,14 @@ const battlefieldSourceRecords = [
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/'
|
||||
}
|
||||
];
|
||||
const riversideDaySourceRecord = {
|
||||
trackKey: 'riverside-village-day-ambience',
|
||||
projectFile:
|
||||
'src/assets/audio/ambience/riverside-village-day-ambience.mp3',
|
||||
source:
|
||||
'https://pixabay.com/sound-effects/nature-stream-water-with-birds-459511/',
|
||||
creator: 'loswin23'
|
||||
};
|
||||
const narrativeSourceRecord = {
|
||||
projectFile: 'src/assets/audio/sfx/story-page-turn.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/',
|
||||
@@ -130,10 +142,17 @@ try {
|
||||
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
|
||||
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
||||
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
||||
validateRequiredTrackKeys(
|
||||
errors,
|
||||
'daytime exploration ambience',
|
||||
ambienceTracks,
|
||||
requiredDaytimeExplorationAmbienceKeys
|
||||
);
|
||||
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys);
|
||||
validateBattlefieldSourceDocumentation(errors);
|
||||
validateRiversideDaySource(errors, ambienceTracks);
|
||||
validateNarrativeSourceDocumentation(errors);
|
||||
validateSemanticFeedbackSourceDocumentation(errors);
|
||||
validateMovementSourceDocumentation(errors);
|
||||
@@ -465,6 +484,70 @@ function linearGainDb(gain) {
|
||||
return 20 * Math.log10(Math.max(0.0001, gain));
|
||||
}
|
||||
|
||||
function validateRiversideDaySource(errors, ambienceTracks) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
const requiredDetails = [
|
||||
'2026-07-27',
|
||||
riversideDaySourceRecord.projectFile,
|
||||
riversideDaySourceRecord.source,
|
||||
'Stream Water with Birds',
|
||||
riversideDaySourceRecord.creator,
|
||||
'32-second',
|
||||
'44.1 kHz stereo MP3',
|
||||
'two-second end-to-start',
|
||||
'-24.03 LUFS',
|
||||
'128 kbps'
|
||||
];
|
||||
requiredDetails.forEach((detail) => {
|
||||
if (!docs.includes(detail)) {
|
||||
errors.push(
|
||||
`docs/audio-sources.md is missing daytime riverside detail "${detail}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!audioInspectionToolsAvailable(errors)) {
|
||||
return;
|
||||
}
|
||||
const url = ambienceTracks[riversideDaySourceRecord.trackKey];
|
||||
const path = url ? assetUrlToPath(url) : undefined;
|
||||
if (!path) {
|
||||
errors.push('The daytime riverside ambience must resolve to a project asset.');
|
||||
return;
|
||||
}
|
||||
const probe = probeAudio(path, errors, riversideDaySourceRecord.trackKey);
|
||||
if (!probe) {
|
||||
return;
|
||||
}
|
||||
if (probe.duration < 31.9 || probe.duration > 32.2) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} must remain a 32-second loop; ` +
|
||||
`received ${probe.duration.toFixed(3)} seconds`
|
||||
);
|
||||
}
|
||||
if (probe.sampleRate !== 44_100 || probe.channels !== 2) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} must be 44.1 kHz stereo; ` +
|
||||
`received ${probe.sampleRate} Hz/${probe.channels} channels`
|
||||
);
|
||||
}
|
||||
if (!probe.artist.includes(riversideDaySourceRecord.creator)) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} metadata must retain creator ` +
|
||||
`"${riversideDaySourceRecord.creator}"`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!probe.comment.includes('Pixabay Content License') ||
|
||||
!probe.comment.includes(riversideDaySourceRecord.source)
|
||||
) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} metadata must retain its Pixabay ` +
|
||||
'license and source URL'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBattlefieldSourceDocumentation(errors) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
if (!docs.includes('2026-07-21')) {
|
||||
|
||||
@@ -16,6 +16,10 @@ try {
|
||||
const { firstPursuitScoutVisitDefinition } = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutVisit.ts'
|
||||
);
|
||||
const { secondBattleReliefExplorationDefinition } =
|
||||
await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const scenarioData = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts');
|
||||
const itemRewardArrays = collectItemRewardArrays(source);
|
||||
@@ -26,9 +30,12 @@ try {
|
||||
const knownBondsById = collectKnownBondsById(battleScenarios, scenarioData);
|
||||
const dialogueEvents = collectCampEvents(source, 'campDialogues');
|
||||
const visitEvents = collectCampEvents(source, 'campVisits').map((event) =>
|
||||
materializeCanonicalFirstPursuitVisit(
|
||||
event,
|
||||
firstPursuitScoutVisitDefinition
|
||||
materializeCanonicalSecondBattleReliefVisit(
|
||||
materializeCanonicalFirstPursuitVisit(
|
||||
event,
|
||||
firstPursuitScoutVisitDefinition
|
||||
),
|
||||
secondBattleReliefExplorationDefinition
|
||||
)
|
||||
);
|
||||
const campaignStepReferences =
|
||||
@@ -355,6 +362,32 @@ function materializeCanonicalFirstPursuitVisit(event, definition) {
|
||||
};
|
||||
}
|
||||
|
||||
function materializeCanonicalSecondBattleReliefVisit(event, definition) {
|
||||
if (!event.body.includes('secondBattleReliefExplorationDefinition')) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const quote = (value) =>
|
||||
`'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
||||
const choiceSource = definition.choices
|
||||
.map(
|
||||
(choice) =>
|
||||
`{ id: ${quote(choice.id)}, bondId: ${quote(choice.bondId)}, ` +
|
||||
`bondExp: ${choice.bondExp}, ` +
|
||||
`itemRewards: [${choice.itemRewards.map(quote).join(', ')}] }`
|
||||
)
|
||||
.join(', ');
|
||||
const canonicalSource =
|
||||
`{ id: ${quote(definition.id)}, ` +
|
||||
'availableAfterBattleIds: [campBattleIds.second], ' +
|
||||
"availableDuringSteps: ['second-camp'], " +
|
||||
`choices: [${choiceSource}], `;
|
||||
return {
|
||||
...event,
|
||||
body: canonicalSource + event.body.slice(1)
|
||||
};
|
||||
}
|
||||
|
||||
function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) {
|
||||
const seenIds = new Map();
|
||||
let campaignStepReferenceCount = 0;
|
||||
@@ -433,7 +466,7 @@ function validateCampBondReferences(events, collectionName, knownBondsById, opti
|
||||
return;
|
||||
}
|
||||
bondReferenceCount += bondIdEntries.length;
|
||||
if (bondIdEntries.length > 1) {
|
||||
if (bondIdEntries.length > 1 && collectionName !== 'campVisits') {
|
||||
errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has multiple bondId properties`);
|
||||
}
|
||||
|
||||
@@ -469,10 +502,20 @@ function validateRewardBondLinks(events, collectionName, rewardPropertyName) {
|
||||
return;
|
||||
}
|
||||
rewardReferenceCount += rewardEntries.length;
|
||||
if (collectStringProperties(event.body, 'bondId').length === 0) {
|
||||
const bondIdCount = collectStringProperties(event.body, 'bondId').length;
|
||||
if (bondIdCount === 0) {
|
||||
errors.push(
|
||||
`${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ${rewardPropertyName} rewards but no bondId`
|
||||
);
|
||||
} else if (
|
||||
bondIdCount > 1 &&
|
||||
bondIdCount !== rewardEntries.length
|
||||
) {
|
||||
errors.push(
|
||||
`${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ` +
|
||||
`${rewardEntries.length} ${rewardPropertyName} rewards but ` +
|
||||
`${bondIdCount} per-choice bondIds`
|
||||
);
|
||||
}
|
||||
});
|
||||
return rewardReferenceCount;
|
||||
|
||||
@@ -80,7 +80,8 @@ try {
|
||||
verifyExplorationLayout(city, xuzhouCase.cityStayId);
|
||||
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png');
|
||||
|
||||
await verifyKeyboardMovement(page);
|
||||
const movementProbe = await verifyKeyboardMovement(page);
|
||||
await verifyPointerMovement(page, movementProbe);
|
||||
await verifyDistantInteractionIsBlocked(page);
|
||||
|
||||
const informationResult = await verifyInformationReward(
|
||||
@@ -126,7 +127,7 @@ try {
|
||||
|
||||
console.log(
|
||||
`Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard and world-pointer movement, distance-gated interaction, ` +
|
||||
'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' +
|
||||
'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.'
|
||||
);
|
||||
@@ -442,6 +443,42 @@ async function verifyKeyboardMovement(page) {
|
||||
})}`
|
||||
);
|
||||
assert.equal(after.player.moving, false);
|
||||
return {
|
||||
start: { x: before.player.x, y: before.player.y },
|
||||
end: { x: after.player.x, y: after.player.y }
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyPointerMovement(page, movementProbe) {
|
||||
await clickScenePoint(page, 'CityStayScene', movementProbe.start);
|
||||
await waitForPlayerAt(page, movementProbe.start);
|
||||
const returned = await readCity(page);
|
||||
assert(
|
||||
returned.player.x - movementProbe.end.x >= 85,
|
||||
`Expected world-pointer movement to return Liu Bei right: ${JSON.stringify({
|
||||
movementProbe,
|
||||
returned: returned.player
|
||||
})}`
|
||||
);
|
||||
assert(
|
||||
Math.abs(returned.player.y - movementProbe.start.y) <= 3,
|
||||
`Expected world-pointer movement to retain Y: ${JSON.stringify({
|
||||
movementProbe,
|
||||
returned: returned.player
|
||||
})}`
|
||||
);
|
||||
|
||||
await clickScenePoint(page, 'CityStayScene', movementProbe.end);
|
||||
await waitForPlayerAt(page, movementProbe.end);
|
||||
const restored = await readCity(page);
|
||||
assert(
|
||||
Math.abs(restored.player.x - movementProbe.end.x) <= 9 &&
|
||||
Math.abs(restored.player.y - movementProbe.end.y) <= 9,
|
||||
`Expected the pointer probe to restore the distant interaction position: ${JSON.stringify({
|
||||
expected: movementProbe.end,
|
||||
restored: restored.player
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyDistantInteractionIsBlocked(page) {
|
||||
@@ -1028,6 +1065,40 @@ async function clickSceneBounds(page, sceneKey, bounds) {
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function clickScenePoint(page, sceneKey, scenePoint) {
|
||||
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
|
||||
const canvas = document.querySelector('canvas');
|
||||
const canvasBounds = canvas?.getBoundingClientRect();
|
||||
if (!scene || !canvasBounds || !requestedPoint) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: canvasBounds.left +
|
||||
requestedPoint.x * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top +
|
||||
requestedPoint.y * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, {
|
||||
requestedSceneKey: sceneKey,
|
||||
requestedPoint: scenePoint
|
||||
});
|
||||
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y));
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function waitForPlayerAt(page, target) {
|
||||
await page.waitForFunction(({ x, y }) => {
|
||||
const player = window.__HEROS_DEBUG__?.cityStay?.()?.player;
|
||||
return Boolean(
|
||||
player &&
|
||||
Math.abs(player.x - x) <= 9 &&
|
||||
Math.abs(player.y - y) <= 9 &&
|
||||
player.moving === false
|
||||
);
|
||||
}, target);
|
||||
}
|
||||
|
||||
async function captureStableScreenshot(page, path) {
|
||||
await page.waitForTimeout(260);
|
||||
const loopSlept = await page.evaluate(() => {
|
||||
|
||||
@@ -4,12 +4,13 @@ import { join } from 'node:path';
|
||||
const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration');
|
||||
const expectedAssets = [
|
||||
'prologue-village.webp',
|
||||
'prologue-militia-camp.webp'
|
||||
'prologue-militia-camp.webp',
|
||||
'second-pursuit-village-ferry.webp'
|
||||
];
|
||||
const expectedWidth = 1920;
|
||||
const expectedHeight = 1080;
|
||||
const maximumAssetBytes = 2 * 1024 * 1024;
|
||||
const maximumTotalBytes = 4 * 1024 * 1024;
|
||||
const maximumTotalBytes = 6 * 1024 * 1024;
|
||||
const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i;
|
||||
const errors = [];
|
||||
let totalBytes = 0;
|
||||
@@ -53,7 +54,7 @@ if (totalBytes > maximumTotalBytes) {
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`);
|
||||
console.error(`Exploration background asset verification failed with ${errors.length} issue(s):`);
|
||||
errors.forEach((error) => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
@@ -90,7 +91,7 @@ function validateDirectoryCoverage(validationErrors) {
|
||||
if (!expectedRasterFiles.includes(fileName)) {
|
||||
validationErrors.push(
|
||||
`${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` +
|
||||
`only the two optimized runtime WebP backgrounds should be tracked here`
|
||||
`only the optimized runtime WebP backgrounds should be tracked here`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1129
scripts/verify-second-battle-relief-exploration-browser.mjs
Normal file
1129
scripts/verify-second-battle-relief-exploration-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
657
scripts/verify-second-battle-relief-exploration.mjs
Normal file
657
scripts/verify-second-battle-relief-exploration.mjs
Normal file
@@ -0,0 +1,657 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let rejectStorageWrites = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (rejectStorageWrites) {
|
||||
throw new Error('Simulated campaign storage write 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 dataModule = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const memoryModule = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefMemory.ts'
|
||||
);
|
||||
const actionModule = await server.ssrLoadModule(
|
||||
'/src/game/state/secondBattleReliefActions.ts'
|
||||
);
|
||||
const campaignModule = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
const { usableCatalog } = await server.ssrLoadModule(
|
||||
'/src/game/data/battleUsables.ts'
|
||||
);
|
||||
const explorationAssets = await server.ssrLoadModule(
|
||||
'/src/game/data/explorationCharacterAssets.ts'
|
||||
);
|
||||
const portraitAssets = await server.ssrLoadModule(
|
||||
'/src/game/data/portraitAssets.ts'
|
||||
);
|
||||
const firstScoutModule = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutMemory.ts'
|
||||
);
|
||||
|
||||
verifyCanonicalDefinition(
|
||||
dataModule,
|
||||
battleScenarios,
|
||||
usableCatalog,
|
||||
explorationAssets,
|
||||
portraitAssets
|
||||
);
|
||||
verifyBattleResultAndScoutReview(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
firstScoutModule
|
||||
);
|
||||
verifyTargetBattleMemories(dataModule, memoryModule);
|
||||
verifyGuardedPersistentProgress(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
actionModule,
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
);
|
||||
|
||||
console.log(
|
||||
'Second-battle relief exploration verification passed (canonical route data, ordered resumable objectives, first-scout result review, guarded rewards, and third-battle memory isolation).'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function verifyCanonicalDefinition(
|
||||
dataModule,
|
||||
battleScenarios,
|
||||
usableCatalog,
|
||||
explorationAssets,
|
||||
portraitAssets
|
||||
) {
|
||||
const definition =
|
||||
dataModule.secondBattleReliefExplorationDefinition;
|
||||
const source =
|
||||
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
||||
const target =
|
||||
battleScenarios[dataModule.secondBattleReliefTargetBattleId];
|
||||
assert(
|
||||
definition.id === dataModule.secondBattleReliefVisitId &&
|
||||
definition.sourceBattleId === source.id &&
|
||||
definition.targetBattleId === target.id,
|
||||
'The relief route must stay scoped from the second victory to the third battle.'
|
||||
);
|
||||
assert(
|
||||
source.objectives.some(
|
||||
(objective) =>
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.village
|
||||
) &&
|
||||
source.objectives.some(
|
||||
(objective) =>
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
||||
),
|
||||
'The relief memory must reference the authored village and quick-victory objectives.'
|
||||
);
|
||||
|
||||
const actorIds = new Set();
|
||||
definition.actors.forEach((actor) => {
|
||||
assert(!actorIds.has(actor.id), `Duplicate relief actor id ${actor.id}.`);
|
||||
actorIds.add(actor.id);
|
||||
assert(
|
||||
pointInside(actor, definition.movementBounds),
|
||||
`Actor ${actor.id} must stay inside the FHD exploration movement bounds.`
|
||||
);
|
||||
assert(
|
||||
explorationAssets.hasExplorationCharacterAsset(actor.textureKey),
|
||||
`Actor ${actor.id} must use an existing exploration character sheet.`
|
||||
);
|
||||
assert(
|
||||
typeof actor.portraitKey === 'string' &&
|
||||
portraitAssets.portraitAssetEntriesForKey(actor.portraitKey)
|
||||
.length > 0,
|
||||
`Actor ${actor.id} must use an existing face portrait in dialogue.`
|
||||
);
|
||||
assert(
|
||||
actor.dialogue.length >= 2 && actor.repeatDialogue.length >= 1,
|
||||
`Actor ${actor.id} must have first-time and repeat dialogue.`
|
||||
);
|
||||
});
|
||||
assert(
|
||||
pointInside(definition.player, definition.movementBounds) &&
|
||||
pointInside(definition.exit, definition.movementBounds),
|
||||
'The player start and camp exit must stay inside the movement bounds.'
|
||||
);
|
||||
|
||||
const objectiveIds = new Set();
|
||||
definition.objectives.forEach((objective, index) => {
|
||||
assert(
|
||||
!objectiveIds.has(objective.id),
|
||||
`Duplicate relief objective id ${objective.id}.`
|
||||
);
|
||||
assert(
|
||||
actorIds.has(objective.targetActorId),
|
||||
`Objective ${objective.id} must point to a canonical actor.`
|
||||
);
|
||||
objective.prerequisiteObjectiveIds.forEach((prerequisiteId) => {
|
||||
assert(
|
||||
objectiveIds.has(prerequisiteId),
|
||||
`Objective ${objective.id} prerequisite ${prerequisiteId} must appear earlier in the route.`
|
||||
);
|
||||
});
|
||||
assert(
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefObjectiveIds[index],
|
||||
`Objective ${objective.id} must retain the authored route order.`
|
||||
);
|
||||
objectiveIds.add(objective.id);
|
||||
});
|
||||
assert(
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
||||
) ===
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefObjectiveIds.slice(0, -1)
|
||||
),
|
||||
'Every field interaction before the final Jian Yong choice must be a persisted prerequisite.'
|
||||
);
|
||||
|
||||
assert(
|
||||
JSON.stringify(definition.choices.map(({ id }) => id)) ===
|
||||
JSON.stringify(dataModule.secondBattleReliefChoiceIds),
|
||||
'The two relief choices must remain canonical and ordered.'
|
||||
);
|
||||
definition.choices.forEach((choice) => {
|
||||
const effect = choice.thirdBattleEffect;
|
||||
assert(
|
||||
choice.bondExp > 0 &&
|
||||
choice.itemRewards.length === 1 &&
|
||||
/^.+\s+\d+$/.test(choice.itemRewards[0]),
|
||||
`Choice ${choice.id} must provide valid bond and item feedback.`
|
||||
);
|
||||
assert(
|
||||
target.objectives.some(
|
||||
(objective) => objective.id === effect.focusObjectiveId
|
||||
),
|
||||
`Choice ${choice.id} must focus an objective in the third battle.`
|
||||
);
|
||||
assert(
|
||||
target.units.some(
|
||||
(unit) =>
|
||||
unit.id === effect.recommendedUnitId &&
|
||||
unit.faction === 'ally'
|
||||
),
|
||||
`Choice ${choice.id} must recommend an allied third-battle unit.`
|
||||
);
|
||||
if (effect.kind === 'village-supply-line') {
|
||||
assert(
|
||||
usableCatalog[effect.carriedUsableId]?.command === 'item',
|
||||
'The village supply payoff must reference a real battle item.'
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
target.units.some(
|
||||
(unit) =>
|
||||
unit.id === effect.trackedEnemyUnitId &&
|
||||
unit.faction === 'enemy'
|
||||
),
|
||||
'The ferry intelligence payoff must track the authored third-battle leader.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function verifyBattleResultAndScoutReview(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
firstScoutModule
|
||||
) {
|
||||
const cases = [
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
||||
villageAchieved: true,
|
||||
quickAchieved: true,
|
||||
expectedFocus: 'river',
|
||||
expectedOutcome: 'confirmed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
||||
villageAchieved: true,
|
||||
quickAchieved: false,
|
||||
expectedFocus: 'river',
|
||||
expectedOutcome: 'missed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
||||
villageAchieved: true,
|
||||
quickAchieved: false,
|
||||
expectedFocus: 'village',
|
||||
expectedOutcome: 'confirmed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
||||
villageAchieved: false,
|
||||
quickAchieved: true,
|
||||
expectedFocus: 'village',
|
||||
expectedOutcome: 'missed'
|
||||
}
|
||||
];
|
||||
|
||||
cases.forEach((testCase) => {
|
||||
const campaign = literalCampaign(dataModule, {
|
||||
villageAchieved: testCase.villageAchieved,
|
||||
quickAchieved: testCase.quickAchieved,
|
||||
firstScoutVisitId: firstScoutModule.firstPursuitScoutVisitId,
|
||||
firstScoutChoiceId: testCase.choiceId
|
||||
});
|
||||
const context =
|
||||
memoryModule.resolveSecondBattleReliefContext(campaign);
|
||||
assert(
|
||||
context?.scoutReview.choiceId === testCase.choiceId &&
|
||||
context.scoutReview.focus === testCase.expectedFocus &&
|
||||
context.scoutReview.outcome === testCase.expectedOutcome,
|
||||
`The first scout choice must be reviewed against its matching second-battle result: ${JSON.stringify({
|
||||
testCase,
|
||||
context
|
||||
})}`
|
||||
);
|
||||
});
|
||||
|
||||
const unrecorded =
|
||||
memoryModule.resolveSecondBattleReliefContext(
|
||||
literalCampaign(dataModule, {
|
||||
villageAchieved: false,
|
||||
quickAchieved: false
|
||||
})
|
||||
);
|
||||
assert(
|
||||
unrecorded?.scoutReview.outcome === 'unrecorded',
|
||||
'Legacy saves without a first-scout marker must receive a safe unrecorded fallback.'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyTargetBattleMemories(dataModule, memoryModule) {
|
||||
dataModule.secondBattleReliefChoiceIds.forEach((choiceId) => {
|
||||
const campaign = literalCampaign(dataModule, {
|
||||
villageAchieved: choiceId ===
|
||||
'restore-northern-village',
|
||||
quickAchieved: choiceId === 'trace-ferry-messenger',
|
||||
reliefChoiceId: choiceId
|
||||
});
|
||||
const memory = memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
});
|
||||
assert(
|
||||
memory?.choiceId === choiceId &&
|
||||
memory.effect.readiness === 'reinforced' &&
|
||||
memory.effect.sourceObjectiveAchieved === true &&
|
||||
memory.openingLines.length === 3 &&
|
||||
memory.openingLines[0] === memory.campSummaryLine,
|
||||
`Choice ${choiceId} must resolve a reinforced third-battle memory.`
|
||||
);
|
||||
|
||||
const recoveredCampaign = literalCampaign(dataModule, {
|
||||
villageAchieved: false,
|
||||
quickAchieved: false,
|
||||
reliefChoiceId: choiceId
|
||||
});
|
||||
const recovered = memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: recoveredCampaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
});
|
||||
assert(
|
||||
recovered?.effect.readiness === 'recovered' &&
|
||||
recovered.effect.sourceObjectiveAchieved === false,
|
||||
`Choice ${choiceId} must turn a missed source objective into a clearly labelled recovery, not a false success.`
|
||||
);
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign,
|
||||
battleId: dataModule.secondBattleReliefSourceBattleId
|
||||
}) === undefined,
|
||||
`Choice ${choiceId} memory must not leak back into the source battle.`
|
||||
);
|
||||
});
|
||||
|
||||
const forged = literalCampaign(dataModule, {
|
||||
villageAchieved: true,
|
||||
quickAchieved: true,
|
||||
reliefChoiceId: 'forged-choice'
|
||||
});
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: forged,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
}) === undefined,
|
||||
'A forged relief choice must never produce a third-battle effect.'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyGuardedPersistentProgress(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
actionModule,
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
) {
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
assert(
|
||||
actionModule.recordSecondBattleReliefObjective(
|
||||
dataModule.secondBattleReliefObjectiveIds[0]
|
||||
).reason === 'invalid-campaign',
|
||||
'Relief progress must reject completion before the second victory camp.'
|
||||
);
|
||||
|
||||
prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved: true, quickAchieved: false }
|
||||
);
|
||||
assert(
|
||||
actionModule.recordSecondBattleReliefObjective(
|
||||
dataModule.secondBattleReliefObjectiveIds[1]
|
||||
).reason === 'prerequisites-incomplete',
|
||||
'The ferry testimony must not be recordable before the village check.'
|
||||
);
|
||||
assert(
|
||||
actionModule.completeSecondBattleReliefExploration(
|
||||
dataModule.secondBattleReliefChoiceIds[0]
|
||||
).reason === 'prerequisites-incomplete',
|
||||
'The final choice must remain locked until every field objective is persisted.'
|
||||
);
|
||||
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
||||
(objectiveId, index) => {
|
||||
const result =
|
||||
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
||||
assert(
|
||||
result.ok === true &&
|
||||
result.completedObjectiveIds.length === index + 1,
|
||||
`Objective ${objectiveId} must persist in authored order.`
|
||||
);
|
||||
}
|
||||
);
|
||||
const resumedProgress = actionModule.secondBattleReliefProgress();
|
||||
assert(
|
||||
resumedProgress.choiceReady === true &&
|
||||
JSON.stringify(resumedProgress.completedObjectiveIds) ===
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
||||
),
|
||||
'Reloadable campaign markers must reconstruct all completed field objectives.'
|
||||
);
|
||||
|
||||
const choice =
|
||||
dataModule.secondBattleReliefExplorationDefinition.choices[0];
|
||||
const inventoryBefore =
|
||||
campaignModule.getCampaignState().inventory;
|
||||
const completed =
|
||||
actionModule.completeSecondBattleReliefExploration(choice.id);
|
||||
assert(
|
||||
completed.ok === true &&
|
||||
completed.memory.choiceId === choice.id &&
|
||||
completed.memory.targetBattleId ===
|
||||
dataModule.secondBattleReliefTargetBattleId &&
|
||||
completed.campaign.completedCampVisits.includes(
|
||||
dataModule.secondBattleReliefVisitId
|
||||
) &&
|
||||
completed.campaign.campVisitChoiceIds[
|
||||
dataModule.secondBattleReliefVisitId
|
||||
] === choice.id,
|
||||
'A valid final choice must atomically save the route, reward, and third-battle memory.'
|
||||
);
|
||||
assertRewardApplied(choice, inventoryBefore, completed.campaign);
|
||||
const completedProgress = actionModule.secondBattleReliefProgress();
|
||||
assert(
|
||||
completedProgress.completed === true &&
|
||||
completedProgress.nextObjectiveId === null &&
|
||||
completedProgress.choiceReady === false,
|
||||
'A completed route must not keep advertising a stale final objective or another choice.'
|
||||
);
|
||||
assert(
|
||||
actionModule.completeSecondBattleReliefExploration(choice.id)
|
||||
.reason === 'already-completed',
|
||||
'The completed route must reject duplicate rewards.'
|
||||
);
|
||||
|
||||
prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved: false, quickAchieved: true }
|
||||
);
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
||||
(objectiveId) => {
|
||||
const result =
|
||||
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
||||
assert(result.ok === true, `Expected progress for ${objectiveId}.`);
|
||||
}
|
||||
);
|
||||
const beforeFailedSave = progressSnapshot(
|
||||
campaignModule.getCampaignState(),
|
||||
dataModule
|
||||
);
|
||||
let failedSave;
|
||||
rejectStorageWrites = true;
|
||||
try {
|
||||
failedSave = actionModule.completeSecondBattleReliefExploration(
|
||||
dataModule.secondBattleReliefChoiceIds[1]
|
||||
);
|
||||
} finally {
|
||||
rejectStorageWrites = false;
|
||||
}
|
||||
const afterFailedSave = progressSnapshot(
|
||||
campaignModule.getCampaignState(),
|
||||
dataModule
|
||||
);
|
||||
assert(
|
||||
failedSave?.ok === false &&
|
||||
failedSave.reason === 'save-unavailable' &&
|
||||
JSON.stringify(afterFailedSave) ===
|
||||
JSON.stringify(beforeFailedSave),
|
||||
`A failed save must restore the route, rewards, and memory markers: ${JSON.stringify({
|
||||
failedSave,
|
||||
beforeFailedSave,
|
||||
afterFailedSave
|
||||
})}`
|
||||
);
|
||||
|
||||
const finalCampaign = campaignModule.getCampaignState();
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: finalCampaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
}) === undefined,
|
||||
'A failed final write must not leave a ghost third-battle memory.'
|
||||
);
|
||||
}
|
||||
|
||||
function prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved, quickAchieved }
|
||||
) {
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
const scenario =
|
||||
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
||||
campaignModule.setFirstBattleReport({
|
||||
battleId: scenario.id,
|
||||
battleTitle: scenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: quickAchieved ? 18 : 27,
|
||||
rewardGold: scenario.baseVictoryGold,
|
||||
defeatedEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
totalEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
objectives: scenario.objectives.map((objective) => {
|
||||
const achieved = objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.village
|
||||
? villageAchieved
|
||||
: objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
||||
? quickAchieved
|
||||
: true;
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved,
|
||||
status: achieved ? 'done' : 'failed',
|
||||
detail: achieved ? '달성' : '미달',
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
}),
|
||||
units: scenario.units,
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...bond,
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-27T00:00:00.000Z'
|
||||
});
|
||||
const campaign = campaignModule.getCampaignState();
|
||||
assert(
|
||||
campaign.step === 'second-camp' &&
|
||||
campaign.latestBattleId === scenario.id,
|
||||
'The canonical second victory must route into second-camp.'
|
||||
);
|
||||
return campaign;
|
||||
}
|
||||
|
||||
function literalCampaign(
|
||||
dataModule,
|
||||
{
|
||||
villageAchieved,
|
||||
quickAchieved,
|
||||
reliefChoiceId,
|
||||
firstScoutVisitId,
|
||||
firstScoutChoiceId
|
||||
}
|
||||
) {
|
||||
const completedCampVisits = [];
|
||||
const campVisitChoiceIds = {};
|
||||
if (reliefChoiceId) {
|
||||
completedCampVisits.push(dataModule.secondBattleReliefVisitId);
|
||||
campVisitChoiceIds[dataModule.secondBattleReliefVisitId] =
|
||||
reliefChoiceId;
|
||||
}
|
||||
if (firstScoutVisitId && firstScoutChoiceId) {
|
||||
completedCampVisits.push(firstScoutVisitId);
|
||||
campVisitChoiceIds[firstScoutVisitId] = firstScoutChoiceId;
|
||||
}
|
||||
return {
|
||||
battleHistory: {
|
||||
[dataModule.secondBattleReliefSourceBattleId]: {
|
||||
battleId: dataModule.secondBattleReliefSourceBattleId,
|
||||
outcome: 'victory',
|
||||
turnNumber: quickAchieved ? 18 : 27,
|
||||
objectives: [
|
||||
{
|
||||
id: dataModule.secondBattleReliefSourceObjectiveIds.village,
|
||||
achieved: villageAchieved,
|
||||
status: villageAchieved ? 'done' : 'failed'
|
||||
},
|
||||
{
|
||||
id: dataModule.secondBattleReliefSourceObjectiveIds.quick,
|
||||
achieved: quickAchieved,
|
||||
status: quickAchieved ? 'done' : 'failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
completedCampVisits,
|
||||
campVisitChoiceIds
|
||||
};
|
||||
}
|
||||
|
||||
function assertRewardApplied(choice, inventoryBefore, campaign) {
|
||||
choice.itemRewards.forEach((reward) => {
|
||||
const match = /^(.+?)\s+(\d+)$/.exec(reward);
|
||||
assert(match, `Invalid reward label ${reward}.`);
|
||||
const [, label, amountText] = match;
|
||||
const amount = Number.parseInt(amountText, 10);
|
||||
assert(
|
||||
(campaign.inventory[label] ?? 0) ===
|
||||
(inventoryBefore[label] ?? 0) + amount,
|
||||
`Expected ${reward} to be granted exactly once.`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function progressSnapshot(campaign, dataModule) {
|
||||
const relevantIds = campaign.completedCampVisits.filter(
|
||||
(visitId) =>
|
||||
visitId === dataModule.secondBattleReliefVisitId ||
|
||||
visitId.startsWith(
|
||||
`${dataModule.secondBattleReliefVisitId}:objective:`
|
||||
)
|
||||
);
|
||||
return {
|
||||
completedCampVisits: relevantIds,
|
||||
reportCompletedCampVisits:
|
||||
campaign.firstBattleReport?.completedCampVisits.filter(
|
||||
(visitId) => relevantIds.includes(visitId)
|
||||
) ?? [],
|
||||
choiceId:
|
||||
campaign.campVisitChoiceIds[
|
||||
dataModule.secondBattleReliefVisitId
|
||||
] ?? null,
|
||||
inventory: { ...campaign.inventory },
|
||||
bonds: campaign.bonds.map(
|
||||
({ id, level, exp, battleExp }) => ({
|
||||
id,
|
||||
level,
|
||||
exp,
|
||||
battleExp
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function pointInside(point, bounds) {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.y >= bounds.y &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const checks = [
|
||||
'scripts/verify-first-battle-camp-followup.mjs',
|
||||
'scripts/verify-first-battle-camaraderie-memory.mjs',
|
||||
'scripts/verify-first-pursuit-scout-memory.mjs',
|
||||
'scripts/verify-second-battle-relief-exploration.mjs',
|
||||
'scripts/verify-prologue-exploration-asset-data.mjs',
|
||||
'scripts/verify-exploration-character-asset-data.mjs',
|
||||
'scripts/verify-prologue-dialogue-portrait-data.mjs',
|
||||
|
||||
BIN
src/assets/audio/ambience/riverside-village-day-ambience.mp3
Normal file
BIN
src/assets/audio/ambience/riverside-village-day-ambience.mp3
Normal file
Binary file not shown.
BIN
src/assets/images/exploration/second-pursuit-village-ferry.webp
Normal file
BIN
src/assets/images/exploration/second-pursuit-village-ferry.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
@@ -59,6 +59,7 @@ import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.m
|
||||
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
|
||||
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
|
||||
import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3';
|
||||
import riversideVillageDayAmbienceUrl from '../../assets/audio/ambience/riverside-village-day-ambience.mp3';
|
||||
|
||||
export const musicTracks = {
|
||||
'title-theme': titleThemeUrl,
|
||||
@@ -77,6 +78,7 @@ export const ambienceTracks = {
|
||||
'battle-fire': battleFireAmbienceUrl,
|
||||
'campfire-ambience': campfireAmbienceUrl,
|
||||
'river-night-ambience': riverNightAmbienceUrl,
|
||||
'riverside-village-day-ambience': riversideVillageDayAmbienceUrl,
|
||||
'mountain-wind-ambience': mountainWindAmbienceUrl,
|
||||
'nanzhong-night-ambience': nanzhongNightAmbienceUrl
|
||||
} as const;
|
||||
@@ -93,6 +95,7 @@ export const ambienceTrackGainCompensation = {
|
||||
'battle-fire': 0.72,
|
||||
'campfire-ambience': 1,
|
||||
'river-night-ambience': 1,
|
||||
'riverside-village-day-ambience': 1,
|
||||
'mountain-wind-ambience': 0.88,
|
||||
'nanzhong-night-ambience': 1
|
||||
} as const satisfies Record<keyof typeof ambienceTracks, number>;
|
||||
@@ -120,6 +123,7 @@ export const ambienceTrackIntegratedLoudnessLufs = {
|
||||
'battle-fire': -26.11,
|
||||
'campfire-ambience': -24.58,
|
||||
'river-night-ambience': -25.2,
|
||||
'riverside-village-day-ambience': -24.03,
|
||||
'mountain-wind-ambience': -24.9,
|
||||
'nanzhong-night-ambience': -24.43
|
||||
} as const satisfies Record<keyof typeof ambienceTracks, number>;
|
||||
|
||||
436
src/game/data/secondBattleReliefExploration.ts
Normal file
436
src/game/data/secondBattleReliefExploration.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { BattleScenarioId } from './battles';
|
||||
import type {
|
||||
ExplorationCharacterDirection,
|
||||
ExplorationCharacterTextureKey
|
||||
} from './explorationCharacterAssets';
|
||||
|
||||
export const secondBattleReliefVisitId =
|
||||
'second-pursuit-aftermath-relief';
|
||||
export const secondBattleReliefSourceBattleId =
|
||||
'second-battle-yellow-turban-pursuit' satisfies BattleScenarioId;
|
||||
export const secondBattleReliefTargetBattleId =
|
||||
'third-battle-guangzong-road' satisfies BattleScenarioId;
|
||||
|
||||
export const secondBattleReliefChoiceIds = [
|
||||
'restore-northern-village',
|
||||
'trace-ferry-messenger'
|
||||
] as const;
|
||||
export type SecondBattleReliefChoiceId =
|
||||
(typeof secondBattleReliefChoiceIds)[number];
|
||||
|
||||
export const secondBattleReliefObjectiveIds = [
|
||||
'reassure-northern-village',
|
||||
'question-ferry-witness',
|
||||
'inspect-messenger-trail',
|
||||
'set-guangzong-priority'
|
||||
] as const;
|
||||
export type SecondBattleReliefObjectiveId =
|
||||
(typeof secondBattleReliefObjectiveIds)[number];
|
||||
|
||||
export const secondBattleReliefPrerequisiteObjectiveIds =
|
||||
secondBattleReliefObjectiveIds.slice(0, -1) as readonly SecondBattleReliefObjectiveId[];
|
||||
|
||||
export const secondBattleReliefSourceObjectiveIds = {
|
||||
village: 'village',
|
||||
quick: 'quick'
|
||||
} as const;
|
||||
|
||||
export type SecondBattleReliefActorId =
|
||||
| 'guan-yu-village'
|
||||
| 'north-ferry-boatman'
|
||||
| 'zhang-fei-ferry'
|
||||
| 'jian-yong-records';
|
||||
|
||||
export type SecondBattleReliefDialogueLine = {
|
||||
readonly speaker: string;
|
||||
readonly text: string;
|
||||
readonly portraitKey?: string;
|
||||
};
|
||||
|
||||
export type SecondBattleReliefActor = {
|
||||
readonly id: SecondBattleReliefActorId;
|
||||
readonly name: string;
|
||||
readonly role: string;
|
||||
readonly textureKey: ExplorationCharacterTextureKey;
|
||||
readonly portraitKey?: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly direction: ExplorationCharacterDirection;
|
||||
readonly dialogue: readonly SecondBattleReliefDialogueLine[];
|
||||
readonly repeatDialogue: readonly SecondBattleReliefDialogueLine[];
|
||||
};
|
||||
|
||||
export type SecondBattleReliefObjective = {
|
||||
readonly id: SecondBattleReliefObjectiveId;
|
||||
readonly label: string;
|
||||
readonly detail: string;
|
||||
readonly targetActorId: SecondBattleReliefActorId;
|
||||
readonly prerequisiteObjectiveIds: readonly SecondBattleReliefObjectiveId[];
|
||||
readonly completionLine: string;
|
||||
};
|
||||
|
||||
type SecondBattleReliefEffectBase = {
|
||||
readonly id:
|
||||
| 'northern-village-supply-line'
|
||||
| 'guangzong-messenger-route';
|
||||
readonly targetBattleId: typeof secondBattleReliefTargetBattleId;
|
||||
readonly label: string;
|
||||
readonly summary: string;
|
||||
readonly openingLines: readonly [string, string];
|
||||
};
|
||||
|
||||
export type SecondBattleReliefThirdBattleEffect =
|
||||
| (SecondBattleReliefEffectBase & {
|
||||
readonly kind: 'village-supply-line';
|
||||
readonly focusObjectiveId: 'liu-bei';
|
||||
readonly recommendedUnitId: 'liu-bei';
|
||||
readonly recommendedRole: 'support';
|
||||
readonly carriedUsableId: 'bean';
|
||||
readonly carriedUsableCount: 1;
|
||||
})
|
||||
| (SecondBattleReliefEffectBase & {
|
||||
readonly kind: 'ferry-messenger-intel';
|
||||
readonly focusObjectiveId: 'leader';
|
||||
readonly recommendedUnitId: 'zhang-fei';
|
||||
readonly recommendedRole: 'flank';
|
||||
readonly trackedEnemyUnitId: 'guangzong-leader-ma-yuan';
|
||||
readonly revealTrackedRoute: true;
|
||||
});
|
||||
|
||||
export type SecondBattleReliefChoice = {
|
||||
readonly id: SecondBattleReliefChoiceId;
|
||||
readonly label: string;
|
||||
readonly response: string;
|
||||
readonly bondId: 'liu-bei__guan-yu' | 'liu-bei__jian-yong';
|
||||
readonly bondExp: number;
|
||||
readonly itemRewards: readonly [string];
|
||||
readonly thirdBattleEffect: SecondBattleReliefThirdBattleEffect;
|
||||
};
|
||||
|
||||
export type SecondBattleReliefExplorationDefinition = {
|
||||
readonly id: typeof secondBattleReliefVisitId;
|
||||
readonly title: string;
|
||||
readonly location: string;
|
||||
readonly sourceBattleId: typeof secondBattleReliefSourceBattleId;
|
||||
readonly targetBattleId: typeof secondBattleReliefTargetBattleId;
|
||||
readonly heading: string;
|
||||
readonly subtitle: string;
|
||||
readonly description: string;
|
||||
readonly introLines: readonly SecondBattleReliefDialogueLine[];
|
||||
readonly movementBounds: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly player: {
|
||||
readonly textureKey: 'exploration-liu-bei';
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly direction: ExplorationCharacterDirection;
|
||||
};
|
||||
readonly exit: {
|
||||
readonly id: 'camp-exit';
|
||||
readonly name: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
readonly actors: readonly SecondBattleReliefActor[];
|
||||
readonly objectives: readonly SecondBattleReliefObjective[];
|
||||
readonly choices: readonly SecondBattleReliefChoice[];
|
||||
};
|
||||
|
||||
export const secondBattleReliefExplorationDefinition = {
|
||||
id: secondBattleReliefVisitId,
|
||||
title: '북쪽 마을과 나루 수습',
|
||||
location: '탁현 북쪽 마을 · 강가 나루',
|
||||
sourceBattleId: secondBattleReliefSourceBattleId,
|
||||
targetBattleId: secondBattleReliefTargetBattleId,
|
||||
heading: '황건 잔당 추격전 뒤',
|
||||
subtitle: '마을의 상처를 살피고 나루에 남은 광종 전령의 흔적을 확인합니다.',
|
||||
description:
|
||||
'전투에서 확보한 북쪽 마을은 아직 수습이 끝나지 않았습니다. 주민의 안전과 나루의 증언을 직접 확인한 뒤 광종 구원로에서 무엇을 먼저 지킬지 정합니다.',
|
||||
introLines: [
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liu-bei-yellow-turban',
|
||||
text: '싸움은 끝났으나 백성이 집으로 돌아가고 나루가 다시 열려야 비로소 이 길을 지켰다 할 수 있소.'
|
||||
},
|
||||
{
|
||||
speaker: '간옹',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
text: '마을의 피해와 사공의 증언을 함께 적겠습니다. 그 기록이 광종으로 향할 다음 군령이 될 것입니다.'
|
||||
}
|
||||
],
|
||||
movementBounds: { x: 42, y: 108, width: 1404, height: 814 },
|
||||
player: {
|
||||
textureKey: 'exploration-liu-bei',
|
||||
x: 950,
|
||||
y: 840,
|
||||
direction: 'north'
|
||||
},
|
||||
exit: {
|
||||
id: 'camp-exit',
|
||||
name: '수습 현장을 떠나 군영으로 돌아가기',
|
||||
x: 790,
|
||||
y: 914
|
||||
},
|
||||
actors: [
|
||||
{
|
||||
id: 'guan-yu-village',
|
||||
name: '관우',
|
||||
role: '북쪽 마을 부상자 수습',
|
||||
textureKey: 'exploration-guan-yu',
|
||||
portraitKey: 'guan-yu-yellow-turban',
|
||||
x: 1050,
|
||||
y: 455,
|
||||
direction: 'west',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '관우',
|
||||
portraitKey: 'guan-yu-yellow-turban',
|
||||
text: '불은 껐지만 빼앗겼던 곡식과 다친 이들이 남았습니다. 전열을 거두기 전에 마을이 오늘 밤을 버틸 수 있게 해야 합니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liu-bei-yellow-turban',
|
||||
text: '군량을 셈하기 전에 백성의 솥부터 살핍시다. 여기서 얻은 승리가 다시 원망이 되어서는 안 되오.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '관우',
|
||||
portraitKey: 'guan-yu-yellow-turban',
|
||||
text: '마을 사람들의 귀환로는 열어 두었습니다. 이제 나루에서 놓친 흔적을 확인하십시오.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'north-ferry-boatman',
|
||||
name: '나루 사공',
|
||||
role: '전령 이동 목격자',
|
||||
textureKey: 'exploration-zhuo-villager',
|
||||
portraitKey: 'zhuo-villager-yellow-turban',
|
||||
x: 360,
|
||||
y: 470,
|
||||
direction: 'east',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '나루 사공',
|
||||
text: '두령이 쓰러지기 전, 누런 깃발을 감춘 기병 하나가 말을 배에 싣고 건넜습니다. 광종 쪽 물길을 잘 아는 자였습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liu-bei-yellow-turban',
|
||||
text: '사람을 더 쫓게 하지는 않겠소. 어느 배를 썼고 어디에 말발굽이 남았는지만 알려 주시오.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '나루 사공',
|
||||
text: '동쪽 말뚝에 묶였던 작은 배였습니다. 장비 장군이 그 주변의 자국을 살피고 있습니다.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'zhang-fei-ferry',
|
||||
name: '장비',
|
||||
role: '나루와 말발굽 흔적 확인',
|
||||
textureKey: 'exploration-zhang-fei',
|
||||
portraitKey: 'zhang-fei-yellow-turban',
|
||||
x: 540,
|
||||
y: 675,
|
||||
direction: 'east',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '장비',
|
||||
portraitKey: 'zhang-fei-yellow-turban',
|
||||
text: '사공 말이 맞소. 배 밑의 진흙과 말발굽이 광종 길로 이어집니다. 지금 뒤쫓으면 흔적을 더 잡을 수 있소.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liu-bei-yellow-turban',
|
||||
text: '흔적은 간옹에게 남기고 병사부터 모으시오. 빠른 추격과 마을 수습 중 무엇을 앞세울지는 함께 정하겠소.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '장비',
|
||||
portraitKey: 'zhang-fei-yellow-turban',
|
||||
text: '나루의 흔적은 표시해 두었소. 간옹에게 가면 바로 다음 명을 정할 수 있소.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'jian-yong-records',
|
||||
name: '간옹',
|
||||
role: '수습 기록과 광종 진군안',
|
||||
textureKey: 'exploration-jian-yong',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
x: 790,
|
||||
y: 690,
|
||||
direction: 'north',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '간옹',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
text: '마을 보급선을 세우면 광종 길의 후방이 단단해집니다. 나루 흔적을 좇으면 전령 마원의 퇴로를 먼저 읽을 수 있습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liu-bei-yellow-turban',
|
||||
text: '둘 다 버릴 수는 없으나, 이번 출정에서 먼저 세울 기준은 하나여야 하오. 기록에 남겨 군 전체가 따르게 합시다.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '간옹',
|
||||
portraitKey: 'jian-yong-campaign',
|
||||
text: '정한 방침은 광종 구원로 출진 장부에 옮겨 두었습니다.'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
objectives: [
|
||||
{
|
||||
id: 'reassure-northern-village',
|
||||
label: '북쪽 마을의 부상자와 귀환로 확인',
|
||||
detail: '관우에게 다가가 전투 뒤에도 남은 마을의 피해를 확인합니다.',
|
||||
targetActorId: 'guan-yu-village',
|
||||
prerequisiteObjectiveIds: [],
|
||||
completionLine: '북쪽 마을 주민이 돌아올 길과 오늘 밤의 식량을 확인했습니다.'
|
||||
},
|
||||
{
|
||||
id: 'question-ferry-witness',
|
||||
label: '나루 사공에게 전령의 이동 경로 질문',
|
||||
detail: '마을 수습을 확인한 뒤 사공에게 빠져나간 기병과 배의 흔적을 묻습니다.',
|
||||
targetActorId: 'north-ferry-boatman',
|
||||
prerequisiteObjectiveIds: ['reassure-northern-village'],
|
||||
completionLine: '광종 방향으로 빠져나간 황건 전령과 사용한 배를 확인했습니다.'
|
||||
},
|
||||
{
|
||||
id: 'inspect-messenger-trail',
|
||||
label: '장비와 나루의 말발굽 흔적 확인',
|
||||
detail: '사공의 증언을 토대로 장비가 표시한 물가의 흔적을 직접 확인합니다.',
|
||||
targetActorId: 'zhang-fei-ferry',
|
||||
prerequisiteObjectiveIds: [
|
||||
'reassure-northern-village',
|
||||
'question-ferry-witness'
|
||||
],
|
||||
completionLine: '배 밑 진흙과 말발굽이 광종 구원로로 이어진다는 사실을 확인했습니다.'
|
||||
},
|
||||
{
|
||||
id: 'set-guangzong-priority',
|
||||
label: '간옹과 광종 구원로의 우선 방침 결정',
|
||||
detail: '모은 기록을 간옹에게 전하고 마을 보급과 전령 추적 중 다음 출정의 우선 기준을 고릅니다.',
|
||||
targetActorId: 'jian-yong-records',
|
||||
prerequisiteObjectiveIds: [
|
||||
'reassure-northern-village',
|
||||
'question-ferry-witness',
|
||||
'inspect-messenger-trail'
|
||||
],
|
||||
completionLine: '현장 수습과 광종 출정 방침을 하나의 장부로 남겼습니다.'
|
||||
}
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'restore-northern-village',
|
||||
label: '북쪽 마을 보급선을 먼저 세운다',
|
||||
response:
|
||||
'관우가 마을의 귀환로를 지키고 간옹은 남은 곡식과 부상자 수를 장부에 적었습니다. 광종으로 향하는 군은 이 마을을 안전한 후방 보급점으로 삼습니다.',
|
||||
bondId: 'liu-bei__guan-yu',
|
||||
bondExp: 10,
|
||||
itemRewards: ['콩 1'],
|
||||
thirdBattleEffect: {
|
||||
id: 'northern-village-supply-line',
|
||||
kind: 'village-supply-line',
|
||||
targetBattleId: secondBattleReliefTargetBattleId,
|
||||
label: '북쪽 마을 보급선',
|
||||
summary: '유비의 후방 지휘와 회복 여지를 우선하는 광종 구원로 준비',
|
||||
focusObjectiveId: 'liu-bei',
|
||||
recommendedUnitId: 'liu-bei',
|
||||
recommendedRole: 'support',
|
||||
carriedUsableId: 'bean',
|
||||
carriedUsableCount: 1,
|
||||
openingLines: [
|
||||
'현장 수습 · 북쪽 마을 보급선이 광종 구원로의 후방과 이어집니다.',
|
||||
'유비를 후방 지원 위치에 두고, 마을에서 챙긴 콩을 출전 장비에 배정해 전열의 회복 여지를 남기십시오.'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'trace-ferry-messenger',
|
||||
label: '나루의 전령 흔적을 먼저 좇는다',
|
||||
response:
|
||||
'장비가 말발굽을 표시하고 간옹은 사공의 증언을 지도에 옮겼습니다. 광종 구원로에서는 전령 마원의 퇴로가 가장 먼저 확인해야 할 길이 됩니다.',
|
||||
bondId: 'liu-bei__jian-yong',
|
||||
bondExp: 10,
|
||||
itemRewards: ['상처약 1'],
|
||||
thirdBattleEffect: {
|
||||
id: 'guangzong-messenger-route',
|
||||
kind: 'ferry-messenger-intel',
|
||||
targetBattleId: secondBattleReliefTargetBattleId,
|
||||
label: '나루 전령로',
|
||||
summary: '전령 마원의 광종 방면 퇴로와 첫 이동을 우선 확인하는 준비',
|
||||
focusObjectiveId: 'leader',
|
||||
recommendedUnitId: 'zhang-fei',
|
||||
recommendedRole: 'flank',
|
||||
trackedEnemyUnitId: 'guangzong-leader-ma-yuan',
|
||||
revealTrackedRoute: true,
|
||||
openingLines: [
|
||||
'현장 수습 · 나루의 말발굽과 배 흔적이 전령 마원의 광종 퇴로와 이어집니다.',
|
||||
'장비를 좁은 길의 측면에 두고, 전령이 요새 뒤로 빠지기 전에 표시된 퇴로를 먼저 압박하십시오.'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
} as const satisfies SecondBattleReliefExplorationDefinition;
|
||||
|
||||
export function getSecondBattleReliefChoice(
|
||||
choiceId: string
|
||||
): SecondBattleReliefChoice | undefined {
|
||||
return secondBattleReliefExplorationDefinition.choices.find(
|
||||
(choice) => choice.id === choiceId
|
||||
);
|
||||
}
|
||||
|
||||
export function getSecondBattleReliefObjective(
|
||||
objectiveId: string
|
||||
): SecondBattleReliefObjective | undefined {
|
||||
return secondBattleReliefExplorationDefinition.objectives.find(
|
||||
(objective) => objective.id === objectiveId
|
||||
);
|
||||
}
|
||||
|
||||
export function isSecondBattleReliefChoiceId(
|
||||
value: unknown
|
||||
): value is SecondBattleReliefChoiceId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(secondBattleReliefChoiceIds as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function completedSecondBattleReliefPrerequisites(
|
||||
completedObjectiveIds: readonly string[]
|
||||
) {
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
return secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) =>
|
||||
completed.has(objectiveId)
|
||||
);
|
||||
}
|
||||
|
||||
export function secondBattleReliefObjectiveProgressId(
|
||||
objectiveId: SecondBattleReliefObjectiveId
|
||||
) {
|
||||
return `${secondBattleReliefVisitId}:objective:${objectiveId}`;
|
||||
}
|
||||
|
||||
export function completedSecondBattleReliefObjectiveIds(
|
||||
completedCampVisitIds: readonly string[]
|
||||
) {
|
||||
const completed = new Set(completedCampVisitIds);
|
||||
return secondBattleReliefObjectiveIds.filter((objectiveId) =>
|
||||
completed.has(secondBattleReliefObjectiveProgressId(objectiveId))
|
||||
);
|
||||
}
|
||||
246
src/game/data/secondBattleReliefMemory.ts
Normal file
246
src/game/data/secondBattleReliefMemory.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { BattleScenarioId } from './battles';
|
||||
import {
|
||||
firstPursuitScoutChoiceIds,
|
||||
firstPursuitScoutVisitId,
|
||||
isFirstPursuitScoutChoiceId,
|
||||
type FirstPursuitScoutChoiceId
|
||||
} from './firstPursuitScoutMemory';
|
||||
import {
|
||||
getSecondBattleReliefChoice,
|
||||
isSecondBattleReliefChoiceId,
|
||||
secondBattleReliefSourceBattleId,
|
||||
secondBattleReliefSourceObjectiveIds,
|
||||
secondBattleReliefTargetBattleId,
|
||||
secondBattleReliefVisitId,
|
||||
type SecondBattleReliefChoiceId,
|
||||
type SecondBattleReliefThirdBattleEffect
|
||||
} from './secondBattleReliefExploration';
|
||||
import type {
|
||||
CampaignBattleSettlement,
|
||||
CampaignState
|
||||
} from '../state/campaignState';
|
||||
|
||||
export type SecondBattleReliefCampaignState = Pick<
|
||||
CampaignState,
|
||||
'battleHistory' | 'completedCampVisits' | 'campVisitChoiceIds'
|
||||
>;
|
||||
|
||||
export type SecondBattleReliefScoutReview = {
|
||||
choiceId?: FirstPursuitScoutChoiceId;
|
||||
focus: 'river' | 'village' | 'unrecorded';
|
||||
outcome: 'confirmed' | 'missed' | 'unrecorded';
|
||||
line: string;
|
||||
};
|
||||
|
||||
export type SecondBattleReliefContext = {
|
||||
sourceBattleId: typeof secondBattleReliefSourceBattleId;
|
||||
sourceBattleTurn: number;
|
||||
villageObjectiveAchieved: boolean;
|
||||
quickObjectiveAchieved: boolean;
|
||||
scoutReview: SecondBattleReliefScoutReview;
|
||||
arrivalLine: string;
|
||||
};
|
||||
|
||||
export type ResolvedSecondBattleReliefEffect =
|
||||
SecondBattleReliefThirdBattleEffect & {
|
||||
readiness: 'reinforced' | 'recovered';
|
||||
sourceObjectiveAchieved: boolean;
|
||||
};
|
||||
|
||||
export type SecondBattleReliefMemory = {
|
||||
sourceBattleId: typeof secondBattleReliefSourceBattleId;
|
||||
targetBattleId: typeof secondBattleReliefTargetBattleId;
|
||||
visitId: typeof secondBattleReliefVisitId;
|
||||
choiceId: SecondBattleReliefChoiceId;
|
||||
choiceLabel: string;
|
||||
sourceBattleTurn: number;
|
||||
villageObjectiveAchieved: boolean;
|
||||
quickObjectiveAchieved: boolean;
|
||||
scoutReview: SecondBattleReliefScoutReview;
|
||||
effect: ResolvedSecondBattleReliefEffect;
|
||||
campSummaryLine: string;
|
||||
openingLines: [string, string, string];
|
||||
};
|
||||
|
||||
export function resolveSecondBattleReliefContext(
|
||||
campaign: SecondBattleReliefCampaignState
|
||||
): SecondBattleReliefContext | undefined {
|
||||
const settlement =
|
||||
campaign.battleHistory[secondBattleReliefSourceBattleId];
|
||||
if (!validSecondBattleVictory(settlement)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const villageObjectiveAchieved = objectiveAchieved(
|
||||
settlement,
|
||||
secondBattleReliefSourceObjectiveIds.village
|
||||
);
|
||||
const quickObjectiveAchieved = objectiveAchieved(
|
||||
settlement,
|
||||
secondBattleReliefSourceObjectiveIds.quick
|
||||
);
|
||||
const scoutReview = resolveFirstPursuitScoutReview(
|
||||
campaign,
|
||||
villageObjectiveAchieved,
|
||||
quickObjectiveAchieved
|
||||
);
|
||||
|
||||
return {
|
||||
sourceBattleId: secondBattleReliefSourceBattleId,
|
||||
sourceBattleTurn: settlement.turnNumber,
|
||||
villageObjectiveAchieved,
|
||||
quickObjectiveAchieved,
|
||||
scoutReview,
|
||||
arrivalLine: villageObjectiveAchieved
|
||||
? '전투에서 지킨 북쪽 마을은 주민이 돌아오기 시작했지만, 부상자와 빼앗긴 곡식은 아직 현장에 남아 있습니다.'
|
||||
: '두령은 쓰러졌지만 북쪽 마을 어귀를 온전히 지키지 못했습니다. 떠나기 전에 주민의 귀환로와 남은 위협부터 수습해야 합니다.'
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSecondBattleReliefMemory(options: {
|
||||
campaign: SecondBattleReliefCampaignState;
|
||||
battleId: BattleScenarioId;
|
||||
}): SecondBattleReliefMemory | undefined {
|
||||
if (options.battleId !== secondBattleReliefTargetBattleId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const context = resolveSecondBattleReliefContext(options.campaign);
|
||||
if (
|
||||
!context ||
|
||||
!options.campaign.completedCampVisits.includes(
|
||||
secondBattleReliefVisitId
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const choiceId =
|
||||
options.campaign.campVisitChoiceIds[secondBattleReliefVisitId];
|
||||
if (!isSecondBattleReliefChoiceId(choiceId)) {
|
||||
return undefined;
|
||||
}
|
||||
const choice = getSecondBattleReliefChoice(choiceId);
|
||||
if (!choice) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sourceObjectiveAchieved =
|
||||
choice.thirdBattleEffect.kind === 'village-supply-line'
|
||||
? context.villageObjectiveAchieved
|
||||
: context.quickObjectiveAchieved;
|
||||
const readiness = sourceObjectiveAchieved
|
||||
? 'reinforced'
|
||||
: 'recovered';
|
||||
const effect = cloneEffect(choice.thirdBattleEffect, {
|
||||
readiness,
|
||||
sourceObjectiveAchieved
|
||||
});
|
||||
const readinessLabel = readiness === 'reinforced'
|
||||
? '전투 성과를 온전히 이어받음'
|
||||
: '현장 수습으로 놓친 준비를 보완함';
|
||||
const campSummaryLine =
|
||||
`현장 수습 · ${effect.label} · ${readinessLabel}`;
|
||||
|
||||
return {
|
||||
sourceBattleId: secondBattleReliefSourceBattleId,
|
||||
targetBattleId: secondBattleReliefTargetBattleId,
|
||||
visitId: secondBattleReliefVisitId,
|
||||
choiceId,
|
||||
choiceLabel: choice.label,
|
||||
sourceBattleTurn: context.sourceBattleTurn,
|
||||
villageObjectiveAchieved: context.villageObjectiveAchieved,
|
||||
quickObjectiveAchieved: context.quickObjectiveAchieved,
|
||||
scoutReview: { ...context.scoutReview },
|
||||
effect,
|
||||
campSummaryLine,
|
||||
openingLines: [
|
||||
campSummaryLine,
|
||||
...effect.openingLines
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function validSecondBattleVictory(
|
||||
settlement?: CampaignBattleSettlement
|
||||
): settlement is CampaignBattleSettlement {
|
||||
return Boolean(
|
||||
settlement &&
|
||||
settlement.battleId === secondBattleReliefSourceBattleId &&
|
||||
settlement.outcome === 'victory'
|
||||
);
|
||||
}
|
||||
|
||||
function objectiveAchieved(
|
||||
settlement: CampaignBattleSettlement,
|
||||
objectiveId: string
|
||||
) {
|
||||
return settlement.objectives.some(
|
||||
(objective) =>
|
||||
objective.id === objectiveId &&
|
||||
objective.achieved === true &&
|
||||
objective.status !== 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFirstPursuitScoutReview(
|
||||
campaign: SecondBattleReliefCampaignState,
|
||||
villageObjectiveAchieved: boolean,
|
||||
quickObjectiveAchieved: boolean
|
||||
): SecondBattleReliefScoutReview {
|
||||
const choiceId =
|
||||
campaign.completedCampVisits.includes(firstPursuitScoutVisitId)
|
||||
? campaign.campVisitChoiceIds[firstPursuitScoutVisitId]
|
||||
: undefined;
|
||||
if (!isFirstPursuitScoutChoiceId(choiceId)) {
|
||||
return {
|
||||
focus: 'unrecorded',
|
||||
outcome: 'unrecorded',
|
||||
line: '이전 정찰 방침은 장부에 남아 있지 않습니다. 이번 현장 확인을 광종 진군의 첫 기록으로 삼습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
if (choiceId === firstPursuitScoutChoiceIds[0]) {
|
||||
return quickObjectiveAchieved
|
||||
? {
|
||||
choiceId,
|
||||
focus: 'river',
|
||||
outcome: 'confirmed',
|
||||
line: '간옹이 먼저 표시한 강가 매복로를 빠르게 끊어 나루의 전령 흔적이 선명하게 남았습니다.'
|
||||
}
|
||||
: {
|
||||
choiceId,
|
||||
focus: 'river',
|
||||
outcome: 'missed',
|
||||
line: '강가 매복로는 찾았지만 결착이 늦어 전령의 흔적이 흐려졌습니다. 사공의 증언으로 빠진 길을 보완해야 합니다.'
|
||||
};
|
||||
}
|
||||
|
||||
return villageObjectiveAchieved
|
||||
? {
|
||||
choiceId,
|
||||
focus: 'village',
|
||||
outcome: 'confirmed',
|
||||
line: '간옹이 먼저 표시한 마을 구호로를 지켜 주민의 귀환과 후방 보급을 바로 시작할 수 있습니다.'
|
||||
}
|
||||
: {
|
||||
choiceId,
|
||||
focus: 'village',
|
||||
outcome: 'missed',
|
||||
line: '마을 구호로를 먼저 표시했지만 전투에서 어귀를 온전히 지키지 못했습니다. 떠나기 전에 그 약속을 직접 수습해야 합니다.'
|
||||
};
|
||||
}
|
||||
|
||||
function cloneEffect(
|
||||
effect: SecondBattleReliefThirdBattleEffect,
|
||||
resolution: Pick<
|
||||
ResolvedSecondBattleReliefEffect,
|
||||
'readiness' | 'sourceObjectiveAchieved'
|
||||
>
|
||||
): ResolvedSecondBattleReliefEffect {
|
||||
return {
|
||||
...effect,
|
||||
...resolution,
|
||||
openingLines: [...effect.openingLines]
|
||||
} as ResolvedSecondBattleReliefEffect;
|
||||
}
|
||||
145
src/game/input/ExplorationInputController.ts
Normal file
145
src/game/input/ExplorationInputController.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
export type ExplorationMovementDirection = Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
|
||||
export type ExplorationInputControllerOptions = {
|
||||
captureNavigationKeys?: boolean;
|
||||
};
|
||||
|
||||
const interactionKeyCodes = [
|
||||
Phaser.Input.Keyboard.KeyCodes.E,
|
||||
Phaser.Input.Keyboard.KeyCodes.SPACE,
|
||||
Phaser.Input.Keyboard.KeyCodes.ENTER
|
||||
] as const;
|
||||
|
||||
const capturedNavigationKeyCodes = [
|
||||
Phaser.Input.Keyboard.KeyCodes.UP,
|
||||
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
||||
Phaser.Input.Keyboard.KeyCodes.LEFT,
|
||||
Phaser.Input.Keyboard.KeyCodes.RIGHT,
|
||||
Phaser.Input.Keyboard.KeyCodes.SPACE
|
||||
] as const;
|
||||
|
||||
export class ExplorationInputController {
|
||||
private readonly keyboard?: Phaser.Input.Keyboard.KeyboardPlugin;
|
||||
private readonly cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||
private readonly moveKeys?: {
|
||||
up: Phaser.Input.Keyboard.Key;
|
||||
down: Phaser.Input.Keyboard.Key;
|
||||
left: Phaser.Input.Keyboard.Key;
|
||||
right: Phaser.Input.Keyboard.Key;
|
||||
};
|
||||
private readonly interactKeys: Phaser.Input.Keyboard.Key[] = [];
|
||||
private readonly keyDownBindings: Array<{
|
||||
key: Phaser.Input.Keyboard.Key;
|
||||
callback: () => void;
|
||||
}> = [];
|
||||
private interactionQueued = false;
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
{ captureNavigationKeys = true }: ExplorationInputControllerOptions = {}
|
||||
) {
|
||||
const keyboard = scene.input.keyboard ?? undefined;
|
||||
this.keyboard = keyboard;
|
||||
if (!keyboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cursorKeys = keyboard.createCursorKeys();
|
||||
this.moveKeys = {
|
||||
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
|
||||
};
|
||||
interactionKeyCodes.forEach((keyCode) => {
|
||||
const key = keyboard.addKey(keyCode);
|
||||
this.onKeyDown(key, () => {
|
||||
this.interactionQueued = true;
|
||||
});
|
||||
this.interactKeys.push(key);
|
||||
});
|
||||
if (captureNavigationKeys) {
|
||||
keyboard.addCapture([...capturedNavigationKeyCodes]);
|
||||
}
|
||||
}
|
||||
|
||||
bindKey(keyCode: number, onDown: () => void) {
|
||||
const key = this.keyboard?.addKey(keyCode);
|
||||
if (key) {
|
||||
this.onKeyDown(key, onDown);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
onMovementKeyDown(
|
||||
callback: (direction: ExplorationMovementDirection) => void
|
||||
) {
|
||||
const bindings = [
|
||||
{ key: this.cursorKeys?.up, direction: { x: 0, y: -1 } },
|
||||
{ key: this.cursorKeys?.down, direction: { x: 0, y: 1 } },
|
||||
{ key: this.cursorKeys?.left, direction: { x: -1, y: 0 } },
|
||||
{ key: this.cursorKeys?.right, direction: { x: 1, y: 0 } },
|
||||
{ key: this.moveKeys?.up, direction: { x: 0, y: -1 } },
|
||||
{ key: this.moveKeys?.down, direction: { x: 0, y: 1 } },
|
||||
{ key: this.moveKeys?.left, direction: { x: -1, y: 0 } },
|
||||
{ key: this.moveKeys?.right, direction: { x: 1, y: 0 } }
|
||||
] as const;
|
||||
bindings.forEach(({ key, direction }) => {
|
||||
if (key) {
|
||||
this.onKeyDown(key, () => callback(direction));
|
||||
}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.keyDownBindings.forEach(({ key, callback }) => {
|
||||
key.off('down', callback);
|
||||
});
|
||||
this.keyDownBindings.length = 0;
|
||||
this.interactionQueued = false;
|
||||
}
|
||||
|
||||
discardInteractionRequest() {
|
||||
this.interactionQueued = false;
|
||||
}
|
||||
|
||||
consumeInteractionRequest() {
|
||||
const keyPressedThisFrame = this.interactKeys.some((key) =>
|
||||
Phaser.Input.Keyboard.JustDown(key)
|
||||
);
|
||||
const requested = this.interactionQueued || keyPressedThisFrame;
|
||||
this.interactionQueued = false;
|
||||
return requested;
|
||||
}
|
||||
|
||||
movementDirection() {
|
||||
const vector = new Phaser.Math.Vector2();
|
||||
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
|
||||
vector.x -= 1;
|
||||
}
|
||||
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
|
||||
vector.x += 1;
|
||||
}
|
||||
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
|
||||
vector.y -= 1;
|
||||
}
|
||||
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
|
||||
vector.y += 1;
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
private onKeyDown(
|
||||
key: Phaser.Input.Keyboard.Key,
|
||||
callback: () => void
|
||||
) {
|
||||
key.on('down', callback);
|
||||
this.keyDownBindings.push({ key, callback });
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ import {
|
||||
resolveFirstPursuitScoutMemory,
|
||||
type FirstPursuitScoutMemory
|
||||
} from '../data/firstPursuitScoutMemory';
|
||||
import {
|
||||
resolveSecondBattleReliefMemory,
|
||||
type SecondBattleReliefMemory
|
||||
} from '../data/secondBattleReliefMemory';
|
||||
import {
|
||||
evaluateSortieOrder,
|
||||
evaluateSortieOrderProgress,
|
||||
@@ -3898,6 +3902,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
||||
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
|
||||
private firstPursuitScoutMemory?: FirstPursuitScoutMemory;
|
||||
private secondBattleReliefMemory?: SecondBattleReliefMemory;
|
||||
private prologueVolunteerReassured = false;
|
||||
private coreResonancePursuitAttempts = 0;
|
||||
private coreResonancePursuitSuccesses = 0;
|
||||
@@ -4010,6 +4015,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
campaign,
|
||||
battleId: battleScenario.id
|
||||
});
|
||||
this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({
|
||||
campaign,
|
||||
battleId: battleScenario.id
|
||||
});
|
||||
this.prologueVolunteerReassured = campaign.completedTutorialIds.includes(
|
||||
prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
|
||||
);
|
||||
@@ -8594,6 +8603,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (this.firstPursuitScoutMemory) {
|
||||
return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`;
|
||||
}
|
||||
if (this.secondBattleReliefMemory) {
|
||||
return this.secondBattleReliefMemory.effect.kind === 'village-supply-line'
|
||||
? `${this.secondBattleReliefMemory.campSummaryLine}. 유비를 후방에 두면 마을에서 챙긴 콩 1개를 전투 중 사용할 수 있습니다.`
|
||||
: `${this.secondBattleReliefMemory.campSummaryLine}. 표시된 전령 마원의 첫 움직임을 보고 장비의 측면 출발 위치를 조정하세요.`;
|
||||
}
|
||||
return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.';
|
||||
}
|
||||
|
||||
@@ -8626,6 +8640,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
})();
|
||||
return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`;
|
||||
}
|
||||
if (this.secondBattleReliefMemory) {
|
||||
const strategySummary = this.launchSortieRecommendation
|
||||
? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`
|
||||
: (() => {
|
||||
const order = sortieOrderDefinition(this.launchSortieOrderId);
|
||||
return `첫 군령 · ${order.label} · ${order.summary}`;
|
||||
})();
|
||||
return `${this.secondBattleReliefMemory.campSummaryLine}\n${strategySummary}`;
|
||||
}
|
||||
if (this.launchSortieRecommendation) {
|
||||
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
|
||||
}
|
||||
@@ -9677,7 +9700,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.firstBattlePreparation.eligible &&
|
||||
this.firstBattlePreparation.scouting
|
||||
) ||
|
||||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true
|
||||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true ||
|
||||
(
|
||||
this.secondBattleReliefMemory?.effect.kind === 'ferry-messenger-intel' &&
|
||||
this.secondBattleReliefMemory.effect.revealTrackedRoute === true
|
||||
)
|
||||
) &&
|
||||
!this.battleOutcome
|
||||
) {
|
||||
@@ -9690,6 +9717,31 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private trackedDeploymentIntentEnemyId() {
|
||||
if (
|
||||
this.phase !== 'deployment' ||
|
||||
(
|
||||
this.firstBattlePreparation.eligible &&
|
||||
this.firstBattlePreparation.scouting
|
||||
) ||
|
||||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true ||
|
||||
this.secondBattleReliefMemory?.effect.kind !==
|
||||
'ferry-messenger-intel' ||
|
||||
this.secondBattleReliefMemory.effect.revealTrackedRoute !== true
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.secondBattleReliefMemory.effect.trackedEnemyUnitId;
|
||||
}
|
||||
|
||||
private enemyIntentForecastVisibleForUnit(unitId: string) {
|
||||
if (!this.enemyIntentForecastEnabled()) {
|
||||
return false;
|
||||
}
|
||||
const trackedEnemyId = this.trackedDeploymentIntentEnemyId();
|
||||
return !trackedEnemyId || trackedEnemyId === unitId;
|
||||
}
|
||||
|
||||
private enemyIntentCounterplayEnabled() {
|
||||
return isEnemyIntentCounterplayAvailable({
|
||||
phase: this.phase,
|
||||
@@ -9765,7 +9817,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const plans = this.currentEnemyActionPlans();
|
||||
const trackedEnemyId = this.trackedDeploymentIntentEnemyId();
|
||||
const plans = this.currentEnemyActionPlans().filter(
|
||||
(plan) => !trackedEnemyId || plan.enemy.id === trackedEnemyId
|
||||
);
|
||||
plans.forEach((plan) => this.enemyIntentForecasts.set(plan.enemy.id, plan));
|
||||
|
||||
plans
|
||||
@@ -17295,6 +17350,17 @@ export class BattleScene extends Phaser.Scene {
|
||||
guanYuStocks.set('salve', Math.min(2, (guanYuStocks.get('salve') ?? 0) + 1));
|
||||
}
|
||||
}
|
||||
if (this.secondBattleReliefMemory?.effect.kind === 'village-supply-line') {
|
||||
const { carriedUsableCount, carriedUsableId, recommendedUnitId } =
|
||||
this.secondBattleReliefMemory.effect;
|
||||
const carrierStocks = stocksByUnit.get(recommendedUnitId);
|
||||
if (carrierStocks) {
|
||||
carrierStocks.set(
|
||||
carriedUsableId,
|
||||
(carrierStocks.get(carriedUsableId) ?? 0) + carriedUsableCount
|
||||
);
|
||||
}
|
||||
}
|
||||
return stocksByUnit;
|
||||
}
|
||||
|
||||
@@ -17867,6 +17933,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
|
||||
: []),
|
||||
...(this.firstPursuitScoutMemory?.openingLines ?? []),
|
||||
...(this.secondBattleReliefMemory?.openingLines ?? []),
|
||||
...cityInformationLines,
|
||||
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
|
||||
...(battleScenario.id === 'first-battle-zhuo-commandery'
|
||||
@@ -18182,9 +18249,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
const { title, lines } = event;
|
||||
const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines());
|
||||
const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []);
|
||||
const secondBattleReliefLines = new Set(this.secondBattleReliefMemory?.openingLines ?? []);
|
||||
const doctrineLines = lines.filter((line) => (
|
||||
completedCityInformationLines.has(line) ||
|
||||
firstPursuitScoutLines.has(line) ||
|
||||
secondBattleReliefLines.has(line) ||
|
||||
line.startsWith('도원 공명') ||
|
||||
line.startsWith('핵심 공명') ||
|
||||
line.startsWith('공명 ') ||
|
||||
@@ -19420,6 +19489,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
campaign: getCampaignState(),
|
||||
battleId: battleScenario.id
|
||||
});
|
||||
this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({
|
||||
campaign: getCampaignState(),
|
||||
battleId: battleScenario.id
|
||||
});
|
||||
this.refreshLaunchCamaraderieMemoryBonus(getCampaignState());
|
||||
if (this.launchSortieResonanceBondId) {
|
||||
this.restoreCoreResonancePursuitStats(state.coreResonanceStats);
|
||||
@@ -27777,7 +27850,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
const terrainRule = getTerrainRule(terrain);
|
||||
const terrainValue = this.terrainEffectText(terrain, unit, 'card');
|
||||
|
||||
if (unit.faction === 'enemy' && this.enemyIntentForecastEnabled()) {
|
||||
if (
|
||||
unit.faction === 'enemy' &&
|
||||
this.enemyIntentForecastVisibleForUnit(unit.id)
|
||||
) {
|
||||
const plan = this.enemyIntentForecasts.get(unit.id) ?? this.buildEnemyActionPlan(unit);
|
||||
effects.push({
|
||||
icon: this.enemyActionPlanIcon(plan),
|
||||
@@ -29020,6 +29096,70 @@ export class BattleScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private secondBattleReliefMemoryDebugState() {
|
||||
const memory = this.secondBattleReliefMemory;
|
||||
if (!memory) {
|
||||
return {
|
||||
active: false,
|
||||
targetBattleId: 'third-battle-guangzong-road',
|
||||
choiceId: null,
|
||||
effectKind: null,
|
||||
openingLines: []
|
||||
};
|
||||
}
|
||||
|
||||
const effect = memory.effect;
|
||||
const villageSupply = effect.kind === 'village-supply-line'
|
||||
? {
|
||||
active: true,
|
||||
carrierUnitId: effect.recommendedUnitId,
|
||||
recommendedRole: effect.recommendedRole,
|
||||
itemId: effect.carriedUsableId,
|
||||
carriedCount: effect.carriedUsableCount,
|
||||
battleStock: this.itemStock(effect.recommendedUnitId, effect.carriedUsableId)
|
||||
}
|
||||
: {
|
||||
active: false
|
||||
};
|
||||
const ferryIntel = effect.kind === 'ferry-messenger-intel'
|
||||
? {
|
||||
active: true,
|
||||
recommendedUnitId: effect.recommendedUnitId,
|
||||
recommendedRole: effect.recommendedRole,
|
||||
trackedEnemyUnitId: effect.trackedEnemyUnitId,
|
||||
revealTrackedRoute: effect.revealTrackedRoute,
|
||||
forecastVisibleDuringDeployment:
|
||||
this.phase === 'deployment' &&
|
||||
this.enemyIntentForecastEnabled() &&
|
||||
this.enemyIntentForecasts.has(effect.trackedEnemyUnitId),
|
||||
trackedIntent: this.enemyIntentForecasts.has(effect.trackedEnemyUnitId)
|
||||
? this.enemyActionPlanDebugValue(this.enemyIntentForecasts.get(effect.trackedEnemyUnitId)!)
|
||||
: null
|
||||
}
|
||||
: {
|
||||
active: false
|
||||
};
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sourceBattleId: memory.sourceBattleId,
|
||||
targetBattleId: memory.targetBattleId,
|
||||
visitId: memory.visitId,
|
||||
choiceId: memory.choiceId,
|
||||
choiceLabel: memory.choiceLabel,
|
||||
campSummaryLine: memory.campSummaryLine,
|
||||
effectId: effect.id,
|
||||
effectKind: effect.kind,
|
||||
effectLabel: effect.label,
|
||||
readiness: effect.readiness,
|
||||
sourceObjectiveAchieved: effect.sourceObjectiveAchieved,
|
||||
focusObjectiveId: effect.focusObjectiveId,
|
||||
villageSupply,
|
||||
ferryIntel,
|
||||
openingLines: [...memory.openingLines]
|
||||
};
|
||||
}
|
||||
|
||||
private firstBattleVolunteerPromiseDebugState() {
|
||||
const line = volunteerPromiseLineForBattle(
|
||||
battleScenario.id,
|
||||
@@ -29170,6 +29310,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
battleHud: this.battleHudDebugState(),
|
||||
firstBattlePreparation: this.firstBattlePreparationDebugState(),
|
||||
firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(),
|
||||
secondBattleReliefMemory: this.secondBattleReliefMemoryDebugState(),
|
||||
firstBattleNarrativeMemory: {
|
||||
volunteerPromise: this.firstBattleVolunteerPromiseDebugState()
|
||||
},
|
||||
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
resolveFirstPursuitScoutMemory
|
||||
} from '../data/firstPursuitScoutMemory';
|
||||
import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';
|
||||
import {
|
||||
secondBattleReliefExplorationDefinition,
|
||||
secondBattleReliefSourceBattleId,
|
||||
secondBattleReliefVisitId
|
||||
} from '../data/secondBattleReliefExploration';
|
||||
import {
|
||||
findCityStayAfterBattle,
|
||||
type CityEquipmentOffer,
|
||||
@@ -231,6 +236,7 @@ import {
|
||||
collectCityStayInformation,
|
||||
purchaseCityStayEquipment
|
||||
} from '../state/cityStayActions';
|
||||
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
|
||||
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { palette } from '../ui/palette';
|
||||
@@ -300,6 +306,11 @@ type CampVisitChoice = {
|
||||
itemRewards?: string[];
|
||||
};
|
||||
|
||||
const directExplorationVisitIds = new Set([
|
||||
firstPursuitScoutVisitId,
|
||||
secondBattleReliefVisitId
|
||||
]);
|
||||
|
||||
type CampTabButtonView = {
|
||||
tab: CampTab;
|
||||
bg: Phaser.GameObjects.Rectangle;
|
||||
@@ -8410,6 +8421,26 @@ const campVisits: CampVisitDefinition[] = [
|
||||
itemRewards: [...choice.itemRewards]
|
||||
}))
|
||||
},
|
||||
{
|
||||
id: secondBattleReliefExplorationDefinition.id,
|
||||
title: secondBattleReliefExplorationDefinition.title,
|
||||
location: secondBattleReliefExplorationDefinition.location,
|
||||
availableAfterBattleIds: [secondBattleReliefSourceBattleId],
|
||||
availableDuringSteps: ['second-camp'],
|
||||
description: secondBattleReliefExplorationDefinition.description,
|
||||
lines: secondBattleReliefExplorationDefinition.introLines.map(
|
||||
(line) => `${line.speaker}: ${line.text}`
|
||||
),
|
||||
choices: secondBattleReliefExplorationDefinition.choices.map(
|
||||
(choice) => ({
|
||||
id: choice.id,
|
||||
label: choice.label,
|
||||
response: choice.response,
|
||||
bondExp: choice.bondExp,
|
||||
itemRewards: [...choice.itemRewards]
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'jingzhou-scholar-rumors',
|
||||
title: '형주 선비들의 모임',
|
||||
@@ -11389,6 +11420,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private campDialogueBodyText?: Phaser.GameObjects.Text;
|
||||
private selectedVisitId = campVisits[0].id;
|
||||
private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle;
|
||||
private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle;
|
||||
private equipmentInventoryPage = 0;
|
||||
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
|
||||
@@ -11514,6 +11546,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.campDialogueChoiceButtons = {};
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.firstPursuitExplorationButton = undefined;
|
||||
this.secondBattleReliefExplorationButton = undefined;
|
||||
this.dialogueObjects = [];
|
||||
this.sortieObjects = [];
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
@@ -11625,12 +11658,13 @@ export class CampScene extends Phaser.Scene {
|
||||
} else if (this.activeTab === 'city') {
|
||||
this.activeTab = 'status';
|
||||
}
|
||||
const pendingFirstPursuitScoutVisit = this.availableCampVisits().some(
|
||||
const pendingDirectExplorationVisit = this.availableCampVisits().find(
|
||||
(visit) =>
|
||||
visit.id === firstPursuitScoutVisitId &&
|
||||
directExplorationVisitIds.has(visit.id) &&
|
||||
!this.completedCampVisits().includes(visit.id)
|
||||
);
|
||||
if (!cityStay && pendingFirstPursuitScoutVisit && this.activeTab === 'status') {
|
||||
if (!cityStay && pendingDirectExplorationVisit) {
|
||||
this.selectedVisitId = pendingDirectExplorationVisit.id;
|
||||
this.activeTab = 'visit';
|
||||
}
|
||||
soundDirector.playSoundscape({
|
||||
@@ -23361,20 +23395,32 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visit.id === firstPursuitScoutVisitId) {
|
||||
if (directExplorationVisitIds.has(visit.id)) {
|
||||
const secondRelief = visit.id === secondBattleReliefVisitId;
|
||||
const reliefProgress = secondRelief && this.campaign
|
||||
? secondBattleReliefProgress(this.campaign)
|
||||
: undefined;
|
||||
const guide = this.track(this.add.rectangle(x + 18, y + height - 102, width - 36, 84, 0x151f2a, 0.96));
|
||||
guide.setOrigin(0);
|
||||
guide.setStrokeStyle(1, palette.blue, 0.62);
|
||||
this.track(this.add.text(
|
||||
x + 32,
|
||||
y + height - 90,
|
||||
'유비를 움직여 간옹에게 직접 말을 거세요.',
|
||||
secondRelief
|
||||
? reliefProgress?.choiceReady
|
||||
? '현장 확인 완료 · 간옹과 광종 진군 방침을 정하세요.'
|
||||
: reliefProgress && reliefProgress.completedObjectiveIds.length > 0
|
||||
? `유비를 움직여 현장 수습을 이어가세요 · ${Math.min(3, reliefProgress.completedObjectiveIds.length)}/3 기록됨`
|
||||
: '유비를 움직여 북쪽 마을과 나루를 직접 살피세요.'
|
||||
: '유비를 움직여 간옹에게 직접 말을 거세요.',
|
||||
this.textStyle(13, '#d4dce6', true)
|
||||
));
|
||||
this.track(this.add.text(
|
||||
x + 32,
|
||||
y + height - 68,
|
||||
'WASD·방향키 이동 · E/Space 대화',
|
||||
secondRelief
|
||||
? '관우 → 사공 → 장비 → 간옹 · 진행 자동 저장'
|
||||
: 'WASD·방향키 이동 · E/Space 대화',
|
||||
this.textStyle(11, '#9fb0bf')
|
||||
));
|
||||
|
||||
@@ -23389,12 +23435,22 @@ export class CampScene extends Phaser.Scene {
|
||||
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
button.on('pointerdown', openExploration);
|
||||
this.firstPursuitExplorationButton = button;
|
||||
if (secondRelief) {
|
||||
this.secondBattleReliefExplorationButton = button;
|
||||
} else {
|
||||
this.firstPursuitExplorationButton = button;
|
||||
}
|
||||
|
||||
const label = this.track(this.add.text(
|
||||
x + width / 2,
|
||||
buttonY,
|
||||
'정찰막으로 이동',
|
||||
secondRelief
|
||||
? reliefProgress?.choiceReady
|
||||
? '광종 진군 방침 결정'
|
||||
: reliefProgress && reliefProgress.completedObjectiveIds.length > 0
|
||||
? '현장 수습 이어가기'
|
||||
: '북쪽 마을·나루로 이동'
|
||||
: '정찰막으로 이동',
|
||||
this.textStyle(14, '#fff2b8', true)
|
||||
));
|
||||
label.setOrigin(0.5);
|
||||
@@ -24942,6 +24998,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.campDialogueChoiceButtons = {};
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.firstPursuitExplorationButton = undefined;
|
||||
this.secondBattleReliefExplorationButton = undefined;
|
||||
this.campRosterLayout = undefined;
|
||||
this.cityPanelBackground = undefined;
|
||||
this.cityExploreButton = undefined;
|
||||
@@ -25102,6 +25159,14 @@ export class CampScene extends Phaser.Scene {
|
||||
(visit) => visit.id === firstPursuitScoutVisitId
|
||||
);
|
||||
const firstPursuitScoutMemory = this.firstPursuitScoutMemoryForNextSortie();
|
||||
const secondBattleReliefVisit = this.availableCampVisits().find(
|
||||
(visit) => visit.id === secondBattleReliefVisitId
|
||||
);
|
||||
const secondReliefProgress = this.campaign
|
||||
? secondBattleReliefProgress(this.campaign)
|
||||
: undefined;
|
||||
const secondReliefChoiceId =
|
||||
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
|
||||
const availableCampDialogues = this.availableCampDialogues();
|
||||
const selectedCampDialogue = availableCampDialogues.find(
|
||||
(dialogue) => dialogue.id === this.selectedDialogueId
|
||||
@@ -25289,6 +25354,39 @@ export class CampScene extends Phaser.Scene {
|
||||
openingLines: firstPursuitScoutMemory ? [...firstPursuitScoutMemory.openingLines] : [],
|
||||
briefingSummaryBounds: this.sortieTextBoundsDebug(this.sortieBriefingScoutSummaryText)
|
||||
},
|
||||
secondBattleReliefExploration: {
|
||||
visitId: secondBattleReliefVisitId,
|
||||
sourceBattleId: secondBattleReliefSourceBattleId,
|
||||
available: Boolean(secondBattleReliefVisit),
|
||||
selected: this.selectedVisitId === secondBattleReliefVisitId,
|
||||
completed: Boolean(secondReliefProgress?.completed),
|
||||
mode: secondReliefProgress?.completed
|
||||
? 'completed'
|
||||
: secondBattleReliefVisit
|
||||
? secondReliefProgress &&
|
||||
secondReliefProgress.completedObjectiveIds.length > 0
|
||||
? 'resume-walkable-relief'
|
||||
: 'walkable-relief'
|
||||
: 'unavailable',
|
||||
explorationButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.secondBattleReliefExplorationButton
|
||||
),
|
||||
explorationInteractive: Boolean(
|
||||
this.secondBattleReliefExplorationButton?.input?.enabled
|
||||
),
|
||||
completedObjectiveIds:
|
||||
secondReliefProgress?.completedObjectiveIds ?? [],
|
||||
nextObjectiveId: secondReliefProgress?.nextObjectiveId ?? null,
|
||||
choiceReady: secondReliefProgress?.choiceReady ?? false,
|
||||
choiceId: secondReliefChoiceId,
|
||||
choices:
|
||||
secondBattleReliefVisit?.choices.map((choice) => ({
|
||||
id: choice.id,
|
||||
label: choice.label,
|
||||
response: choice.response,
|
||||
rewardText: this.visitRewardText(choice)
|
||||
})) ?? []
|
||||
},
|
||||
selectedDialogue: selectedCampDialogue
|
||||
? {
|
||||
id: selectedCampDialogue.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import {
|
||||
releaseUnitBaseSheetTextures,
|
||||
type UnitDirection
|
||||
} from '../data/unitAssets';
|
||||
import { ExplorationInputController } from '../input/ExplorationInputController';
|
||||
import {
|
||||
chooseCityStayResonance,
|
||||
collectCityStayInformation,
|
||||
@@ -137,19 +138,11 @@ export class CityStayScene extends Phaser.Scene {
|
||||
private loadingOverlay?: Phaser.GameObjects.Container;
|
||||
private transitionOverlay?: Phaser.GameObjects.Container;
|
||||
private completionToast?: Phaser.GameObjects.Container;
|
||||
private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||
private moveKeys?: {
|
||||
up: Phaser.Input.Keyboard.Key;
|
||||
down: Phaser.Input.Keyboard.Key;
|
||||
left: Phaser.Input.Keyboard.Key;
|
||||
right: Phaser.Input.Keyboard.Key;
|
||||
};
|
||||
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
|
||||
private explorationInput?: ExplorationInputController;
|
||||
private moveTarget?: Phaser.Math.Vector2;
|
||||
private ready = false;
|
||||
private navigationPending = false;
|
||||
private inputReadyAt = Number.POSITIVE_INFINITY;
|
||||
private interactionQueued = false;
|
||||
private stepIndex = 0;
|
||||
private lastStepAt = 0;
|
||||
private lastNotice = '';
|
||||
@@ -191,6 +184,8 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
this.moveTarget = undefined;
|
||||
this.explorationInput?.destroy();
|
||||
this.explorationInput = undefined;
|
||||
this.dialogueState = undefined;
|
||||
releaseUnitBaseSheetTextures(this);
|
||||
});
|
||||
@@ -215,13 +210,13 @@ export class CityStayScene extends Phaser.Scene {
|
||||
|
||||
update(time: number, delta: number) {
|
||||
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
|
||||
this.interactionQueued = false;
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shopPanel || this.choicePanel) {
|
||||
this.stopPlayerMovement();
|
||||
this.interactionQueued = false;
|
||||
this.explorationInput?.discardInteractionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,7 +433,7 @@ export class CityStayScene extends Phaser.Scene {
|
||||
this.ready = false;
|
||||
this.navigationPending = false;
|
||||
this.inputReadyAt = Number.POSITIVE_INFINITY;
|
||||
this.interactionQueued = false;
|
||||
this.explorationInput = undefined;
|
||||
this.stepIndex = 0;
|
||||
this.lastStepAt = 0;
|
||||
this.lastNotice = '';
|
||||
@@ -934,36 +929,19 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private setupInput() {
|
||||
const keyboard = this.input.keyboard;
|
||||
if (keyboard) {
|
||||
this.cursorKeys = keyboard.createCursorKeys();
|
||||
this.moveKeys = {
|
||||
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
|
||||
};
|
||||
this.interactKeys = [
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
|
||||
];
|
||||
this.interactKeys.forEach((key) => {
|
||||
key.on('down', () => {
|
||||
this.interactionQueued = true;
|
||||
});
|
||||
});
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC).on('down', () => this.handleEscape());
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE).on('down', () => this.chooseDialogueByIndex(0));
|
||||
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO).on('down', () => this.chooseDialogueByIndex(1));
|
||||
keyboard.addCapture([
|
||||
Phaser.Input.Keyboard.KeyCodes.UP,
|
||||
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
||||
Phaser.Input.Keyboard.KeyCodes.LEFT,
|
||||
Phaser.Input.Keyboard.KeyCodes.RIGHT,
|
||||
Phaser.Input.Keyboard.KeyCodes.SPACE
|
||||
]);
|
||||
}
|
||||
this.explorationInput = new ExplorationInputController(this)
|
||||
.bindKey(
|
||||
Phaser.Input.Keyboard.KeyCodes.ESC,
|
||||
() => this.handleEscape()
|
||||
)
|
||||
.bindKey(
|
||||
Phaser.Input.Keyboard.KeyCodes.ONE,
|
||||
() => this.chooseDialogueByIndex(0)
|
||||
)
|
||||
.bindKey(
|
||||
Phaser.Input.Keyboard.KeyCodes.TWO,
|
||||
() => this.chooseDialogueByIndex(1)
|
||||
);
|
||||
|
||||
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
||||
@@ -1065,27 +1043,14 @@ export class CityStayScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private consumeInteractionRequest() {
|
||||
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
|
||||
const requested = this.interactionQueued || keyPressedThisFrame;
|
||||
this.interactionQueued = false;
|
||||
return requested;
|
||||
return this.explorationInput?.consumeInteractionRequest() ?? false;
|
||||
}
|
||||
|
||||
private keyboardMovementDirection() {
|
||||
const vector = new Phaser.Math.Vector2();
|
||||
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
|
||||
vector.x -= 1;
|
||||
}
|
||||
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
|
||||
vector.x += 1;
|
||||
}
|
||||
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
|
||||
vector.y -= 1;
|
||||
}
|
||||
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
|
||||
vector.y += 1;
|
||||
}
|
||||
return vector;
|
||||
return (
|
||||
this.explorationInput?.movementDirection() ??
|
||||
new Phaser.Math.Vector2()
|
||||
);
|
||||
}
|
||||
|
||||
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {
|
||||
|
||||
258
src/game/state/secondBattleReliefActions.ts
Normal file
258
src/game/state/secondBattleReliefActions.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
completedSecondBattleReliefObjectiveIds,
|
||||
getSecondBattleReliefChoice,
|
||||
getSecondBattleReliefObjective,
|
||||
secondBattleReliefObjectiveIds,
|
||||
secondBattleReliefObjectiveProgressId,
|
||||
secondBattleReliefPrerequisiteObjectiveIds,
|
||||
secondBattleReliefSourceBattleId,
|
||||
secondBattleReliefTargetBattleId,
|
||||
secondBattleReliefVisitId,
|
||||
type SecondBattleReliefChoice,
|
||||
type SecondBattleReliefObjective,
|
||||
type SecondBattleReliefObjectiveId
|
||||
} from '../data/secondBattleReliefExploration';
|
||||
import {
|
||||
resolveSecondBattleReliefMemory,
|
||||
type SecondBattleReliefMemory
|
||||
} from '../data/secondBattleReliefMemory';
|
||||
import {
|
||||
applyCampVisitReward,
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
type SecondBattleReliefFailure =
|
||||
| 'invalid-campaign'
|
||||
| 'invalid-objective'
|
||||
| 'prerequisites-incomplete'
|
||||
| 'choice-required'
|
||||
| 'invalid-choice'
|
||||
| 'already-completed'
|
||||
| 'save-unavailable';
|
||||
|
||||
export type SecondBattleReliefObjectiveActionResult =
|
||||
| {
|
||||
ok: true;
|
||||
objective: SecondBattleReliefObjective;
|
||||
completedObjectiveIds: SecondBattleReliefObjectiveId[];
|
||||
campaign: CampaignState;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: SecondBattleReliefFailure;
|
||||
};
|
||||
|
||||
export type SecondBattleReliefCompletionResult =
|
||||
| {
|
||||
ok: true;
|
||||
choice: SecondBattleReliefChoice;
|
||||
memory: SecondBattleReliefMemory;
|
||||
campaign: CampaignState;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: SecondBattleReliefFailure;
|
||||
};
|
||||
|
||||
export function secondBattleReliefProgress(
|
||||
campaign: CampaignState = getCampaignState()
|
||||
) {
|
||||
const completedObjectiveIds =
|
||||
completedSecondBattleReliefObjectiveIds(
|
||||
campaign.completedCampVisits
|
||||
);
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
const visitCompleted = campaign.completedCampVisits.includes(
|
||||
secondBattleReliefVisitId
|
||||
);
|
||||
const nextObjective = visitCompleted
|
||||
? undefined
|
||||
: secondBattleReliefObjectiveIds
|
||||
.map((objectiveId) => getSecondBattleReliefObjective(objectiveId))
|
||||
.find((objective) =>
|
||||
objective &&
|
||||
!completed.has(objective.id) &&
|
||||
objective.prerequisiteObjectiveIds.every((objectiveId) =>
|
||||
completed.has(objectiveId)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
visitId: secondBattleReliefVisitId,
|
||||
completed: visitCompleted,
|
||||
completedObjectiveIds,
|
||||
nextObjectiveId: nextObjective?.id ?? null,
|
||||
choiceReady:
|
||||
!visitCompleted &&
|
||||
secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) =>
|
||||
completed.has(objectiveId)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function recordSecondBattleReliefObjective(
|
||||
objectiveId: string
|
||||
): SecondBattleReliefObjectiveActionResult {
|
||||
let campaign: CampaignState;
|
||||
try {
|
||||
campaign = getCampaignState();
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsSecondBattleRelief(campaign)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
if (
|
||||
campaign.completedCampVisits.includes(secondBattleReliefVisitId)
|
||||
) {
|
||||
return { ok: false, reason: 'already-completed' };
|
||||
}
|
||||
|
||||
const objective = getSecondBattleReliefObjective(objectiveId);
|
||||
if (!objective) {
|
||||
return { ok: false, reason: 'invalid-objective' };
|
||||
}
|
||||
if (objective.id === 'set-guangzong-priority') {
|
||||
return { ok: false, reason: 'choice-required' };
|
||||
}
|
||||
|
||||
const progress = secondBattleReliefProgress(campaign);
|
||||
if (progress.completedObjectiveIds.includes(objective.id)) {
|
||||
return {
|
||||
ok: true,
|
||||
objective,
|
||||
completedObjectiveIds: progress.completedObjectiveIds,
|
||||
campaign
|
||||
};
|
||||
}
|
||||
if (
|
||||
!objective.prerequisiteObjectiveIds.every((prerequisiteId) =>
|
||||
progress.completedObjectiveIds.includes(prerequisiteId)
|
||||
)
|
||||
) {
|
||||
return { ok: false, reason: 'prerequisites-incomplete' };
|
||||
}
|
||||
|
||||
const updated = persistReliefProgress(
|
||||
campaign,
|
||||
secondBattleReliefObjectiveProgressId(objective.id),
|
||||
{}
|
||||
);
|
||||
if (!updated) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
objective,
|
||||
completedObjectiveIds:
|
||||
completedSecondBattleReliefObjectiveIds(
|
||||
updated.completedCampVisits
|
||||
),
|
||||
campaign: updated
|
||||
};
|
||||
}
|
||||
|
||||
export function completeSecondBattleReliefExploration(
|
||||
choiceId: string
|
||||
): SecondBattleReliefCompletionResult {
|
||||
let campaign: CampaignState;
|
||||
try {
|
||||
campaign = getCampaignState();
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsSecondBattleRelief(campaign)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
if (
|
||||
campaign.completedCampVisits.includes(secondBattleReliefVisitId)
|
||||
) {
|
||||
return { ok: false, reason: 'already-completed' };
|
||||
}
|
||||
|
||||
const choice = getSecondBattleReliefChoice(choiceId);
|
||||
if (!choice) {
|
||||
return { ok: false, reason: 'invalid-choice' };
|
||||
}
|
||||
const progress = secondBattleReliefProgress(campaign);
|
||||
if (!progress.choiceReady) {
|
||||
return { ok: false, reason: 'prerequisites-incomplete' };
|
||||
}
|
||||
|
||||
const updated = persistReliefProgress(
|
||||
campaign,
|
||||
secondBattleReliefVisitId,
|
||||
{
|
||||
bondId: choice.bondId,
|
||||
bondExp: choice.bondExp,
|
||||
itemRewards: [...choice.itemRewards]
|
||||
},
|
||||
choice.id
|
||||
);
|
||||
if (!updated) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
const memory = resolveSecondBattleReliefMemory({
|
||||
campaign: updated,
|
||||
battleId: secondBattleReliefTargetBattleId
|
||||
});
|
||||
if (!memory) {
|
||||
restoreCampaignSnapshot(campaign);
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
choice,
|
||||
memory,
|
||||
campaign: updated
|
||||
};
|
||||
}
|
||||
|
||||
function campaignSupportsSecondBattleRelief(campaign: CampaignState) {
|
||||
const settlement =
|
||||
campaign.battleHistory[secondBattleReliefSourceBattleId];
|
||||
const report = campaign.firstBattleReport;
|
||||
return (
|
||||
campaign.step === 'second-camp' &&
|
||||
campaign.latestBattleId === secondBattleReliefSourceBattleId &&
|
||||
settlement?.battleId === secondBattleReliefSourceBattleId &&
|
||||
settlement.outcome === 'victory' &&
|
||||
report?.battleId === secondBattleReliefSourceBattleId &&
|
||||
report.outcome === 'victory'
|
||||
);
|
||||
}
|
||||
|
||||
function persistReliefProgress(
|
||||
snapshot: CampaignState,
|
||||
visitId: string,
|
||||
reward: {
|
||||
bondId?: string;
|
||||
bondExp?: number;
|
||||
itemRewards?: string[];
|
||||
},
|
||||
choiceId?: string
|
||||
) {
|
||||
try {
|
||||
return applyCampVisitReward(
|
||||
visitId,
|
||||
reward,
|
||||
choiceId
|
||||
);
|
||||
} catch {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCampaignSnapshot(snapshot: CampaignState) {
|
||||
try {
|
||||
setCampaignState(snapshot);
|
||||
} catch {
|
||||
// setCampaignState restores the in-memory snapshot before persistence.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user