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