import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules';
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import {
  caoBreakRecruitBonds,
  caoBreakRecruitUnits,
  chengduPressureRecruitBonds,
  chengduPressureRecruitUnits,
  chengduSurrenderRecruitBonds,
  chengduSurrenderRecruitUnits,
  changshaRecruitBonds,
  changshaRecruitUnits,
  firstBattleBonds,
  firstBattleUnits,
  fuPassRecruitBonds,
  fuPassRecruitUnits,
  guiyangRecruitBonds,
  guiyangRecruitUnits,
  hanzhongOpeningRecruitBonds,
  hanzhongOpeningRecruitUnits,
  hanzhongMainRecruitBonds,
  hanzhongMainRecruitUnits,
  hanzhongScoutRecruitBonds,
  hanzhongScoutRecruitUnits,
  jingzhouRecruitBonds,
  jingzhouRecruitUnits,
  liuBiaoRecruitBonds,
  liuBiaoRecruitUnits,
  luoCastleProperRecruitBonds,
  luoCastleProperRecruitUnits,
  luoCastleRecruitBonds,
  luoCastleRecruitUnits,
  wulingRecruitBonds,
  wulingRecruitUnits,
  xuzhouRecruitBonds,
  xuzhouRecruitUnits,
  yizhouRecruitBonds,
  yizhouRecruitUnits,
  zhugeRecruitBonds,
  zhugeRecruitUnits,
  type PortraitKey,
  type UnitData
} from '../data/scenario';
import {
  applyCampVisitReward,
  applyCampBondExp,
  ensureCampaignRosterUnits,
  getCampaignState,
  getFirstBattleReport,
  markCampaignStep,
  saveCampaignState,
  type CampaignState,
  type FirstBattleReport
} from '../state/campaignState';
import { palette } from '../ui/palette';

type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'progress';

type CampDialogue = {
  id: string;
  title: string;
  availableAfterBattleIds: string[];
  unitIds: [string, string];
  bondId: string;
  rewardExp: number;
  lines: string[];
  choices: CampDialogueChoice[];
};

type CampDialogueChoice = {
  id: string;
  label: string;
  response: string;
  rewardExp: number;
};

type CampVisitDefinition = {
  id: string;
  title: string;
  location: string;
  availableAfterBattleIds: string[];
  description: string;
  bondId?: string;
  lines: string[];
  choices: CampVisitChoice[];
};

type CampVisitChoice = {
  id: string;
  label: string;
  response: string;
  bondExp?: number;
  gold?: number;
  itemRewards?: string[];
};

type CampTabButtonView = {
  tab: CampTab;
  bg: Phaser.GameObjects.Rectangle;
  text: Phaser.GameObjects.Text;
};

type CampSupplyDefinition = {
  label: string;
  title: string;
  description: string;
  healHp: number;
  bondExp: number;
};

type MerchantItemDefinition = {
  label: string;
  title: string;
  description: string;
  price: number;
};

type SortieChecklistItem = {
  label: string;
  complete: boolean;
  detail: string;
};

type SortieUnitTacticalSummary = {
  statLine: string;
  equipmentLine: string;
  bondLine: string;
  terrainLine: string;
};

type SortieTerrainCounts = Partial<Record<TerrainType, number>>;

type SortieRecommendation = {
  unitId: string;
  reason: string;
};

type SortieRuleDefinition = {
  maxUnits: number;
  recommended: SortieRecommendation[];
  note: string;
};

type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve';

type SortieFormationSlot = {
  role: SortieFormationRole;
  label: string;
  description: string;
  unitNames: string[];
};

type SortiePlanSummary = {
  selectedCount: number;
  maxCount: number;
  terrainScore: number;
  terrainGrade: string;
  activeBondCount: number;
  recruitedCount: number;
  selectedRecruitedCount: number;
  reserveCount: number;
  recommendedSelectedCount: number;
  recommendedTotal: number;
  deploymentLine: string;
  recommendationLine: string;
  recruitedLine: string;
  reserveLine: string;
  objectiveLine: string;
  formationSlots: SortieFormationSlot[];
  warnings: string[];
};

type CampaignTimelineChapter = {
  id: string;
  title: string;
  period: string;
  description: string;
  battleIds: BattleScenarioId[];
  nextHints: string[];
};

const portraitKeys: Record<PortraitKey, string> = {
  liuBei: 'portrait-liu-bei',
  guanYu: 'portrait-guan-yu',
  zhangFei: 'portrait-zhang-fei'
};

const portraitByUnitId: Record<string, PortraitKey> = {
  'liu-bei': 'liuBei',
  'guan-yu': 'guanYu',
  'zhang-fei': 'zhangFei'
};

const campSupplies: CampSupplyDefinition[] = [
  {
    label: '콩',
    title: '콩',
    description: '선택 장수의 병력을 12 회복합니다.',
    healHp: 12,
    bondExp: 0
  },
  {
    label: '탁주',
    title: '탁주',
    description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
    healHp: 8,
    bondExp: 2
  },
  {
    label: '상처약',
    title: '상처약',
    description: '선택 장수의 병력을 22 회복합니다.',
    healHp: 22,
    bondExp: 0
  }
];

const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));

const merchantItems: MerchantItemDefinition[] = [
  {
    label: '콩',
    title: '콩',
    description: '값싸고 가벼운 회복 도구입니다.',
    price: 40
  },
  {
    label: '탁주',
    title: '탁주',
    description: '병력 회복과 작은 공명 상승에 유용합니다.',
    price: 70
  },
  {
    label: '상처약',
    title: '상처약',
    description: '큰 부상을 빠르게 회복합니다.',
    price: 110
  }
];

const campaignTimelineChapters: CampaignTimelineChapter[] = [
  {
    id: 'yellow-turban',
    title: '황건적 토벌',
    period: '의용군의 시작',
    description: '탁현에서 뜻을 세운 세 형제가 황건적을 토벌하며 이름과 명분을 얻는 초반 장입니다.',
    battleIds: [
      'first-battle-zhuo-commandery',
      'second-battle-yellow-turban-pursuit',
      'third-battle-guangzong-road',
      'fourth-battle-guangzong-camp'
    ],
    nextHints: ['의용군 명성', '세 형제 공명', '황건 토벌 공적']
  },
  {
    id: 'anti-dong-gongsun',
    title: '반동탁과 공손찬 의탁',
    period: '난세의 큰 흐름',
    description: '반동탁 연합의 바깥에서 공손찬 진영의 신뢰를 얻고, 서주 원군 요청으로 이어지는 장입니다.',
    battleIds: ['fifth-battle-sishui-vanguard', 'sixth-battle-jieqiao-relief'],
    nextHints: ['공손찬 신뢰', '북방 전선', '서주 원군 요청']
  },
  {
    id: 'xuzhou-lubu',
    title: '서주 인수와 여포의 그림자',
    period: '얻고 잃는 땅',
    description: '도겸에게 서주를 맡고, 여포를 의탁시킨 뒤 내부 불안과 배신으로 서주를 잃는 장입니다.',
    battleIds: [
      'seventh-battle-xuzhou-rescue',
      'eighth-battle-xiaopei-supply-road',
      'ninth-battle-xuzhou-gate-night-raid',
      'tenth-battle-xuzhou-breakout'
    ],
    nextHints: ['간옹·미축 합류', '서주 민심', '여포와의 긴장']
  },
  {
    id: 'cao-refuge-xiapi',
    title: '조조 의탁과 하비 결전',
    period: '의탁 속의 경계',
    description: '조조에게 몸을 의탁하지만 독자 명분을 잃지 않으며, 하비에서 여포의 운명을 마무리하는 장입니다.',
    battleIds: [
      'eleventh-battle-xudu-refuge-road',
      'twelfth-battle-xiapi-outer-siege',
      'thirteenth-battle-xiapi-final'
    ],
    nextHints: ['조조군 감시', '하비 포위', '여포 격파']
  },
  {
    id: 'break-cao-yuan',
    title: '조조 이탈과 원소 의탁',
    period: '다시 북쪽으로',
    description: '조조의 장막을 벗어나 원소에게 향하지만, 객군의 한계와 다음 의탁의 필요를 확인하는 장입니다.',
    battleIds: ['fourteenth-battle-cao-break', 'fifteenth-battle-yuan-refuge-road'],
    nextHints: ['손건 합류', '원소 접선', '객군의 불안']
  },
  {
    id: 'liu-biao-wolong',
    title: '유표 의탁과 제갈량 영입',
    period: '와룡을 찾아',
    description: '형주에 머물며 조운이 재합류하고, 와룡의 단서를 좇아 제갈량을 얻는 전환점입니다.',
    battleIds: ['sixteenth-battle-liu-biao-refuge', 'seventeenth-battle-wolong-visit-road'],
    nextHints: ['조운 재합류', '와룡 단서', '천하삼분']
  },
  {
    id: 'red-cliffs',
    title: '적벽대전',
    period: '강동의 바람',
    description: '제갈량의 계책과 강동의 힘이 맞물려 조조의 대군을 막아서는 큰 전환 장입니다.',
    battleIds: [
      'eighteenth-battle-bowang-ambush',
      'nineteenth-battle-changban-refuge',
      'twentieth-battle-jiangdong-envoy',
      'twenty-first-battle-red-cliffs-vanguard',
      'twenty-second-battle-red-cliffs-fire'
    ],
    nextHints: ['강동 사절', '화공 준비', '조조 남하']
  },
  {
    id: 'jing-yi',
    title: '형주·익주 확보',
    period: '근본을 세우다',
    description: '형주와 익주를 기반으로 삼아 유비군이 유랑군에서 국가의 뿌리로 바뀌는 장입니다.',
    battleIds: [
      'twenty-third-battle-jingzhou-south-entry',
      'twenty-fourth-battle-guiyang-persuasion',
      'twenty-fifth-battle-wuling-mountain-road',
      'twenty-sixth-battle-changsha-veteran',
      'twenty-seventh-battle-yizhou-relief-road',
      'twenty-eighth-battle-fu-pass-entry',
      'twenty-ninth-battle-luo-outer-wall',
      'thirtieth-battle-luofeng-ambush',
      'thirty-first-battle-luo-main-gate',
      'thirty-second-battle-mianzhu-gate',
      'thirty-third-battle-chengdu-surrender'
    ],
    nextHints: ['형주 방위', '익주 진입', '성도 항복']
  },
  {
    id: 'shu-han',
    title: '촉한 건국',
    period: '한실을 잇는 깃발',
    description: '여러 전장을 지나 촉한을 세우고 북벌을 바라보는 장기 목표입니다.',
    battleIds: [
      'thirty-fourth-battle-jiameng-pass',
      'thirty-fifth-battle-yangping-scout',
      'thirty-sixth-battle-dingjun-vanguard'
    ],
    nextHints: ['왕업 선언', '오호대장군', '북벌 준비']
  }
];

const campBattleIds = {
  first: defaultBattleScenario.id,
  second: 'second-battle-yellow-turban-pursuit',
  third: 'third-battle-guangzong-road',
  fourth: 'fourth-battle-guangzong-camp',
  fifth: 'fifth-battle-sishui-vanguard',
  sixth: 'sixth-battle-jieqiao-relief',
  seventh: 'seventh-battle-xuzhou-rescue',
  eighth: 'eighth-battle-xiaopei-supply-road',
  ninth: 'ninth-battle-xuzhou-gate-night-raid',
  tenth: 'tenth-battle-xuzhou-breakout',
  eleventh: 'eleventh-battle-xudu-refuge-road',
  twelfth: 'twelfth-battle-xiapi-outer-siege',
  thirteenth: 'thirteenth-battle-xiapi-final',
  fourteenth: 'fourteenth-battle-cao-break',
  fifteenth: 'fifteenth-battle-yuan-refuge-road',
  sixteenth: 'sixteenth-battle-liu-biao-refuge',
  seventeenth: 'seventeenth-battle-wolong-visit-road',
  eighteenth: 'eighteenth-battle-bowang-ambush',
  nineteenth: 'nineteenth-battle-changban-refuge',
  twentieth: 'twentieth-battle-jiangdong-envoy',
  twentyFirst: 'twenty-first-battle-red-cliffs-vanguard',
  twentySecond: 'twenty-second-battle-red-cliffs-fire',
  twentyThird: 'twenty-third-battle-jingzhou-south-entry',
  twentyFourth: 'twenty-fourth-battle-guiyang-persuasion',
  twentyFifth: 'twenty-fifth-battle-wuling-mountain-road',
  twentySixth: 'twenty-sixth-battle-changsha-veteran',
  twentySeventh: 'twenty-seventh-battle-yizhou-relief-road',
  twentyEighth: 'twenty-eighth-battle-fu-pass-entry',
  twentyNinth: 'twenty-ninth-battle-luo-outer-wall',
  thirtieth: 'thirtieth-battle-luofeng-ambush',
  thirtyFirst: 'thirty-first-battle-luo-main-gate',
  thirtySecond: 'thirty-second-battle-mianzhu-gate',
  thirtyThird: 'thirty-third-battle-chengdu-surrender',
  thirtyFourth: 'thirty-fourth-battle-jiameng-pass',
  thirtyFifth: 'thirty-fifth-battle-yangping-scout',
  thirtySixth: 'thirty-sixth-battle-dingjun-vanguard'
} as const;

const requiredSortieUnitIds = new Set(['liu-bei']);
const foundingSortieUnitIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']);
const maxSortieUnits = 6;
const sortieFormationSlotDefinitions: Omit<SortieFormationSlot, 'unitNames'>[] = [
  { role: 'front', label: '전열', description: '길목을 막고 반격을 받아냄' },
  { role: 'flank', label: '돌파', description: '측면 기동과 추격 담당' },
  { role: 'support', label: '후원', description: '원거리, 책략, 보급 지원' },
  { role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' }
];

const defaultSortieRule: SortieRuleDefinition = {
  maxUnits: maxSortieUnits,
  recommended: [
    { unitId: 'liu-bei', reason: '패배 조건인 주장이며 공명 중심입니다.' }
  ],
  note: '유비 생존을 중심에 두고 전열, 돌파, 후원을 고르게 배치하십시오.'
};

const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefinition>> = {
  'second-battle-yellow-turban-pursuit': {
    maxUnits: 3,
    recommended: [
      { unitId: 'liu-bei', reason: '마을 안정과 지휘를 맡습니다.' },
      { unitId: 'guan-yu', reason: '숲길 전열을 안정적으로 밀 수 있습니다.' },
      { unitId: 'zhang-fei', reason: '잔당 추격과 막타 돌파에 강합니다.' }
    ],
    note: '첫 추격전은 세 형제의 공명과 기본 진형을 익히는 전장입니다.'
  },
  'third-battle-guangzong-road': {
    maxUnits: 3,
    recommended: [
      { unitId: 'liu-bei', reason: '강가 길목에서 부대를 묶어 줍니다.' },
      { unitId: 'guan-yu', reason: '요새와 숲의 방어선을 버팁니다.' },
      { unitId: 'zhang-fei', reason: '좁은 길의 적 전령을 빠르게 압박합니다.' }
    ],
    note: '강과 요새가 많은 길목이라 전열 유지와 빠른 추격이 모두 필요합니다.'
  },
  'fourth-battle-guangzong-camp': {
    maxUnits: 3,
    recommended: [
      { unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' },
      { unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' }
    ],
    note: '황건 본영전은 세 형제 전원 출전으로 공명 피해와 전선 안정성을 확보하십시오.'
  },
  'fifth-battle-sishui-vanguard': {
    maxUnits: 3,
    recommended: [
      { unitId: 'liu-bei', reason: '반동탁 연합에서 이름을 세울 주장입니다.' },
      { unitId: 'guan-yu', reason: '전초 요새 주변을 안정적으로 장악합니다.' },
      { unitId: 'zhang-fei', reason: '호진의 전열을 빠르게 흔듭니다.' }
    ],
    note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.'
  },
  'sixth-battle-jieqiao-relief': {
    maxUnits: 3,
    recommended: [
      { unitId: 'liu-bei', reason: '공손찬 원군로의 신뢰를 지켜야 합니다.' },
      { unitId: 'guan-yu', reason: '강가와 진영 지형에서 방어 적성이 좋습니다.' },
      { unitId: 'zhang-fei', reason: '원소군 별동대 추격에 적합합니다.' }
    ],
    note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다.'
  },
  'seventh-battle-xuzhou-rescue': {
    maxUnits: 4,
    recommended: [
      { unitId: 'liu-bei', reason: '서주 원군 요청의 중심 인물입니다.' },
      { unitId: 'guan-yu', reason: '하후돈 선봉을 받아낼 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '조조군 기병을 압박할 돌파 역할입니다.' }
    ],
    note: '서주 구원전부터 출전 여유가 생깁니다. 전열을 유지하면서 한 명을 상황에 맞게 더 넣으십시오.'
  },
  'eighth-battle-xiaopei-supply-road': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '서주 방위선의 사기를 붙잡습니다.' },
      { unitId: 'jian-yong', reason: '보급로 전투에서 책략 보조가 빛납니다.' },
      { unitId: 'mi-zhu', reason: '소패 보급과 회복 운용에 어울립니다.' }
    ],
    note: '새로 합류한 서주 인재를 시험하기 좋은 전장입니다.'
  },
  'ninth-battle-xuzhou-gate-night-raid': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '성문을 되찾는 정치적 명분의 중심입니다.' },
      { unitId: 'guan-yu', reason: '야습 방어와 성문 전열 유지에 강합니다.' },
      { unitId: 'mi-zhu', reason: '성내 보급 안정에 도움을 줍니다.' }
    ],
    note: '성문 야습은 전열 장악과 보급 유지가 함께 필요합니다.'
  },
  'tenth-battle-xuzhou-breakout': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '탈출전의 패배 조건입니다.' },
      { unitId: 'guan-yu', reason: '추격대를 막아 퇴로를 엽니다.' },
      { unitId: 'zhang-fei', reason: '실책을 만회할 돌파와 반격 담당입니다.' },
      { unitId: 'mi-zhu', reason: '피난민과 보급을 보존합니다.' }
    ],
    note: '서주 탈출전은 주력과 보급 담당을 함께 데려가 병력 손실을 줄이는 편성이 좋습니다.'
  },
  'eleventh-battle-xudu-refuge-road': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '조조의 시험을 직접 받아야 합니다.' },
      { unitId: 'guan-yu', reason: '기령의 주력과 맞설 전열 핵심입니다.' },
      { unitId: 'jian-yong', reason: '감시 속 군율과 책략 보조를 맡습니다.' },
      { unitId: 'mi-zhu', reason: '허도 입성 전 보급 안정에 유리합니다.' }
    ],
    note: '허도 입성로는 조조군의 감시를 받는 시험입니다. 군율과 보급을 보여줄 무장을 챙기십시오.'
  },
  'twelfth-battle-xiapi-outer-siege': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '조조 휘하 작전에서도 유비군의 명분을 지켜야 합니다.' },
      { unitId: 'zhang-fei', reason: '여포군 기병 돌파를 막고 둑길을 압박합니다.' },
      { unitId: 'jian-yong', reason: '조조군 감시 속 명분과 보고 문서를 조율합니다.' },
      { unitId: 'mi-zhu', reason: '강가 포위전의 보급과 회복 운용에 유리합니다.' }
    ],
    note: '하비 외곽전은 강줄기와 둑길이 전장을 나눕니다. 돌파와 보급, 조조군 보고까지 함께 고려하십시오.'
  },
  'thirteenth-battle-xiapi-final': {
    maxUnits: 5,
    recommended: [
      { unitId: 'liu-bei', reason: '여포의 운명을 두고 조조 앞에서 말해야 할 주장입니다.' },
      { unitId: 'guan-yu', reason: '성문 요새의 전열을 안정적으로 밀어낼 수 있습니다.' },
      { unitId: 'zhang-fei', reason: '여포군 기병의 돌파를 받아치고 반격하는 역할입니다.' },
      { unitId: 'jian-yong', reason: '여포 처분과 조조의 시선을 둘러싼 명분 조율에 필요합니다.' }
    ],
    note: '하비 결전은 전열과 돌파가 강해야 하지만, 전후 조조와의 긴장을 생각하면 책사 한 명을 챙기는 편성이 좋습니다.'
  },
  'fourteenth-battle-cao-break': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '조조 휘하를 벗어나는 명분과 생존 조건의 중심입니다.' },
      { unitId: 'guan-yu', reason: '관문 수비병을 정면에서 밀어낼 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '길목으로 돌진하는 조조군 기병을 받아치는 역할입니다.' },
      { unitId: 'sun-qian', reason: '문서와 민심을 정리해 서주 관문 확보 목표에 힘을 보탭니다.' },
      { unitId: 'mi-zhu', reason: '보급과 회복 도구 운용으로 장기전을 안정시킵니다.' }
    ],
    note: '차주의 관문은 길과 강이 겹쳐 전열, 기동, 책사, 보급이 모두 필요합니다. 새로 합류한 손건도 출전 후보로 살펴보십시오.'
  },
  'fifteenth-battle-yuan-refuge-road': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '원소 진영에 의탁할 명분과 생존 조건을 모두 짊어집니다.' },
      { unitId: 'guan-yu', reason: '채양의 수비선을 정면에서 무너뜨릴 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '추격 기병의 돌파를 받아치고 북상로를 벌리는 데 강합니다.' },
      { unitId: 'jian-yong', reason: '원소 사자와의 접선 명분을 조율하고 책략 지원을 맡습니다.' },
      { unitId: 'sun-qian', reason: '문서와 외교 판단으로 접선지 확보 목표에 직접 힘을 보탭니다.' }
    ],
    note: '원소 의탁로는 추격 기병과 고지 궁병이 함께 압박합니다. 전열을 두껍게 세우되 책사와 보급 장수도 빼지 않는 편이 안정적입니다.'
  },
  'sixteenth-battle-liu-biao-refuge': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '형주 의탁 명분과 패배 조건의 중심입니다.' },
      { unitId: 'zhao-yun', reason: '새로 합류한 백마 기병으로 강가 길목과 추격 기병을 빠르게 끊을 수 있습니다.' },
      { unitId: 'guan-yu', reason: '관문 수비대를 정면에서 밀어낼 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '중앙 길목의 추격 기병을 받아치고 돌파하는 역할입니다.' },
      { unitId: 'sun-qian', reason: '형주 접선 문서와 의탁 명분을 정리해 관문 확보 목표에 힘을 보탭니다.' },
      { unitId: 'mi-zhu', reason: '큰 맵의 장기전을 대비한 보급과 회복 운용에 유리합니다.' }
    ],
    note: '유표 의탁로부터는 합류 무장이 출전 제한보다 많아집니다. 조운의 기동력을 시험할지, 책사와 보급을 두껍게 둘지 직접 고르십시오.'
  },
  'seventeenth-battle-wolong-visit-road': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '와룡을 직접 찾아가는 주장입니다.' },
      { unitId: 'zhao-yun', reason: '산길 척후와 기병을 빠르게 끊는 기동 역할입니다.' },
      { unitId: 'guan-yu', reason: '초가 앞 수비병을 정면에서 밀어낼 전열 핵심입니다.' },
      { unitId: 'zhang-fei', reason: '좁은 길목을 뚫고 채순을 압박하는 돌파 역할입니다.' },
      { unitId: 'sun-qian', reason: '와룡 방문의 예와 명분을 정리하는 책사 역할입니다.' },
      { unitId: 'mi-zhu', reason: '산길 장기전에 필요한 보급과 회복 운용을 맡습니다.' }
    ],
    note: '와룡 단서를 모은 뒤 융중으로 향합니다. 출전 제한보다 무장이 많으니 조운의 기동, 책사의 지원, 보급 중 무엇을 중시할지 선택하십시오.'
  },
  'eighteenth-battle-bowang-ambush': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '박망파 매복전의 패배 조건이자 군의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '이번 전투의 핵심인 매복 계책과 화공 판단을 맡습니다.' },
      { unitId: 'zhao-yun', reason: '조조군 기병을 끊고 숲길 측면을 빠르게 흔듭니다.' },
      { unitId: 'guan-yu', reason: '좁은 길목에서 하후돈 친위를 받아낼 전열입니다.' },
      { unitId: 'zhang-fei', reason: '매복 후 역습으로 적 선봉을 밀어내는 돌파 역할입니다.' },
      { unitId: 'sun-qian', reason: '강동 사절로 이어질 명분과 전후 보고를 정리합니다.' }
    ],
    note: '제갈량의 첫 실전 계책입니다. 책략 담당을 포함해 전열, 기동, 보급 중 무엇을 남길지 결정해야 합니다.'
  },
  'nineteenth-battle-changban-refuge': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '피난 행렬의 중심이며 패배 조건입니다.' },
      { unitId: 'zhao-yun', reason: '피난로를 오가며 흩어진 병력과 백성을 붙잡는 핵심 기동 장수입니다.' },
      { unitId: 'zhang-fei', reason: '장판교와 좁은 길목에서 추격 기병을 늦추는 역할입니다.' },
      { unitId: 'zhuge-liang', reason: '피난로 판단과 강동 사절 명분을 전투 뒤로 연결합니다.' },
      { unitId: 'guan-yu', reason: '다리목 전열과 친위 보병을 받아낼 안정적인 전열입니다.' },
      { unitId: 'sun-qian', reason: '강동에 보낼 기록과 외교 명분을 정리합니다.' }
    ],
    note: '장판파는 출전 제한보다 역할이 많은 전장입니다. 조운과 장비를 중심으로 기동, 전열, 책략, 보급 중 무엇을 우선할지 직접 선택하십시오.'
  },
  'twentieth-battle-jiangdong-envoy': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '사절을 보내는 결단과 패배 조건의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '강동 설득의 핵심 인물이며 반드시 보호해야 할 사절입니다.' },
      { unitId: 'sun-qian', reason: '문서와 외교 명분을 지니고 있어 이번 전투의 생존 목표입니다.' },
      { unitId: 'zhao-yun', reason: '추격 기병을 끊고 사절의 측면을 지키는 데 좋습니다.' },
      { unitId: 'guan-yu', reason: '강나루 보병과 포구 수비대를 받아낼 전열 핵심입니다.' },
      { unitId: 'mi-zhu', reason: '사절 보급과 장기전 회복 운용에 유리합니다.' }
    ],
    note: '강동 사절로는 전투력만으로 고르기 어렵습니다. 제갈량과 손건을 보호하면서 전열, 기동, 보급 중 무엇을 남길지 선택하십시오.'
  },
  'twenty-first-battle-red-cliffs-vanguard': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '손유 동맹의 명분이자 패배 조건의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '바람과 화공을 잇는 계책 담당이며 반드시 생존해야 합니다.' },
      { unitId: 'zhao-yun', reason: '강안의 기병과 척후를 빠르게 끊어 포구를 안정시킵니다.' },
      { unitId: 'guan-yu', reason: '배다리와 포구 전열에서 적 수군을 받아낼 핵심 전열입니다.' },
      { unitId: 'zhang-fei', reason: '좁은 강변 길목을 밀어붙이며 적 보병을 흔드는 돌파 역할입니다.' },
      { unitId: 'sun-qian', reason: '강동 회담 기록과 동맹 명분을 전투 뒤 보고로 이어 줍니다.' }
    ],
    note: '적벽 전초전은 첫 강상 전투입니다. 유비와 제갈량을 중심으로 전열, 기동, 외교 기록 중 어느 축을 두껍게 할지 고르십시오.'
  },
  'twenty-second-battle-red-cliffs-fire': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '적벽 결전의 명분과 패배 조건의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '동남풍과 화공 시점을 판단하는 핵심 책략 담당입니다.' },
      { unitId: 'guan-yu', reason: '갑판 보병과 본선 친위를 정면에서 받아낼 전열입니다.' },
      { unitId: 'zhao-yun', reason: '화선을 노리는 강안 기병과 척후를 빠르게 끊습니다.' },
      { unitId: 'zhang-fei', reason: '배다리와 좁은 갑판 길을 밀어붙이는 돌파 역할입니다.' },
      { unitId: 'mi-zhu', reason: '화공전의 장기 피해를 버티기 위한 회복과 보급을 맡습니다.' }
    ],
    note: '적벽 화공전은 강 위의 결전입니다. 제갈량의 책략과 전열 유지, 화선 접안로 확보를 중심으로 여섯 명을 고르십시오.'
  },
  'twenty-third-battle-jingzhou-south-entry': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '형주 남부 진입의 명분이자 패배 조건의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '마을을 해치지 않고 길목을 여는 작전 핵심입니다.' },
      { unitId: 'guan-yu', reason: '형도영의 보병 전열을 정면에서 받아낼 주력입니다.' },
      { unitId: 'zhao-yun', reason: '척후와 기병을 빠르게 끊어 마을을 안정시키기 좋습니다.' },
      { unitId: 'sun-qian', reason: '마량과 형주 선비층으로 이어질 외교 명분을 정리합니다.' },
      { unitId: 'mi-zhu', reason: '긴 남부 길목에서 회복과 보급을 맡기 좋습니다.' }
    ],
    note: '형주 남부 진입전은 마을 안정과 길목 돌파가 동시에 필요한 전장입니다. 마량은 승리 후 합류하므로 이번 전투 뒤 다음 출전 후보로 늘어납니다.'
  },
  'twenty-fourth-battle-guiyang-persuasion': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '계양 설득의 명분이자 패배 조건의 중심입니다.' },
      { unitId: 'ma-liang', reason: '계양 토호와 마을 사정을 읽는 핵심 문사이며 반드시 생존해야 합니다.' },
      { unitId: 'zhuge-liang', reason: '성채를 압박하면서도 항복 명분을 유지하는 작전 핵심입니다.' },
      { unitId: 'zhao-yun', reason: '산길 척후와 기병을 빠르게 끊어 마을을 안정시키기 좋습니다.' },
      { unitId: 'guan-yu', reason: '조범 친위대와 성문 보병을 받아낼 주력 전열입니다.' },
      { unitId: 'sun-qian', reason: '이적 합류로 이어질 외교 문안과 항복 조건을 정리합니다.' }
    ],
    note: '계양 설득전은 마량이 처음으로 출전 압박에 들어오는 전장입니다. 아홉 명 중 여섯 명만 고르며, 문사와 무장의 균형을 직접 선택하십시오.'
  },
  'twenty-fifth-battle-wuling-mountain-road': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '무릉 산길 확보의 명분이자 패배 조건의 중심입니다.' },
      { unitId: 'ma-liang', reason: '무릉 산길과 마을 이름을 읽는 현지 정보의 핵심입니다.' },
      { unitId: 'yi-ji', reason: '공지의 귀순과 마을 설득으로 이어질 예법과 문안을 맡습니다.' },
      { unitId: 'zhuge-liang', reason: '좁은 산길에서 적 방어선을 끊는 작전 핵심입니다.' },
      { unitId: 'zhao-yun', reason: '긴 산길을 빠르게 넘어 척후와 기병을 끊기 좋습니다.' },
      { unitId: 'zhang-fei', reason: '협곡 보병 전열을 밀어붙이는 돌파 역할입니다.' }
    ],
    note: '무릉 산길 확보전은 열 명 중 여섯 명만 고르는 첫 전장입니다. 마량과 이적을 모두 데려가면 설득은 강해지지만, 전열 무장 한 명을 줄여야 합니다.'
  },
  'twenty-sixth-battle-changsha-veteran': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '장사 노장을 예우하고 한현을 물러나게 할 명분의 중심입니다.' },
      { unitId: 'gong-zhi', reason: '무릉에서 얻은 현지 정보로 장사 접근로와 성 안 소문을 읽습니다.' },
      { unitId: 'yi-ji', reason: '황충을 예우하고 항복 조건을 문장으로 다듬는 핵심 문사입니다.' },
      { unitId: 'zhuge-liang', reason: '한현의 의심과 성문 방어선을 끊는 작전 핵심입니다.' },
      { unitId: 'guan-yu', reason: '황충과 무인의 예를 나누며 전열을 받아낼 주력입니다.' },
      { unitId: 'zhao-yun', reason: '장사 성문 주변 기병과 척후를 빠르게 정리하기 좋습니다.' }
    ],
    note: '장사 노장 대면전은 열한 명 중 여섯 명만 고르는 전장입니다. 황충과 위연은 승리 후 합류하므로, 이번에는 예우와 돌파의 균형을 먼저 준비하십시오.'
  },
  'twenty-seventh-battle-yizhou-relief-road': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '익주 원군 요청의 명분이자 패배 조건의 중심입니다.' },
      { unitId: 'zhuge-liang', reason: '원군이라는 명분과 관문 돌파 계책을 함께 잡는 핵심 책사입니다.' },
      { unitId: 'huang-zhong', reason: '산길과 성루의 궁병을 안정적으로 견제할 노련한 원거리 장수입니다.' },
      { unitId: 'wei-yan', reason: '좁은 관문 전열을 빠르게 흔들 돌파 역할입니다.' },
      { unitId: 'zhao-yun', reason: '긴 원군로에서 척후와 기병을 끊는 기동 핵심입니다.' },
      { unitId: 'mi-zhu', reason: '큰 로스터 전투에서 회복 도구와 보급 안정성이 빛납니다.' }
    ],
    note: '익주 원군로는 열세 명 중 여섯 명만 고르는 전장입니다. 황충·위연을 시험할지, 기존 책사와 보급을 두껍게 둘지 직접 선택하십시오.'
  },
  'twenty-eighth-battle-fu-pass-entry': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '부수관 진입 명분과 패배 조건의 중심입니다.' },
      { unitId: 'pang-tong', reason: '부수관 관문을 흔드는 기책 담당이며 이번 전투의 새 선택 축입니다.' },
      { unitId: 'zhuge-liang', reason: '관문 돌파와 보급선을 함께 보는 큰 판의 핵심입니다.' },
      { unitId: 'wei-yan', reason: '좁은 관문 전열을 빠르게 흔들 돌파 역할입니다.' },
      { unitId: 'huang-zhong', reason: '성루 궁병과 산비탈 적을 안정적으로 견제합니다.' },
      { unitId: 'zhao-yun', reason: '협곡 기병과 복병을 빠르게 끊는 기동 핵심입니다.' }
    ],
    note: '부수관 진입전은 열네 명 중 여섯 명만 고르는 전장입니다. 방통을 데려가면 계책은 강해지지만, 보급이나 전열 후보를 한 명 줄여야 합니다.'
  },
  'twenty-ninth-battle-luo-outer-wall': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '낙성 외곽 마을을 안정시키는 명분의 중심입니다.' },
      { unitId: 'fa-zheng', reason: '법정은 익주 내부 사정을 알고 있어 오의 설득과 성문 정보에 강합니다.' },
      { unitId: 'pang-tong', reason: '방통의 계책은 낙성으로 들어가는 좁은 길과 매복을 읽는 데 필요합니다.' },
      { unitId: 'zhuge-liang', reason: '장임의 방어선과 낙봉파로 이어지는 다음 전장을 동시에 살핍니다.' },
      { unitId: 'huang-zhong', reason: '낙성 외곽의 궁병과 고지 방어선을 안정적으로 견제합니다.' },
      { unitId: 'zhao-yun', reason: '넓어진 외곽 맵에서 정찰과 기병 대응을 맡기 좋습니다.' }
    ],
    note: '낙성 외곽전은 열다섯 명 중 여섯 명만 출전합니다. 법정을 데려가면 설득과 정보가 강해지고, 무장을 더 넣으면 전열이 단단해지는 선택지가 생깁니다.'
  },
  'thirtieth-battle-luofeng-ambush': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '유비가 살아 남아야 익주 백성에게 명분을 이어갈 수 있습니다.' },
      { unitId: 'pang-tong', reason: '낙봉파 매복을 읽는 핵심 책사이며 생존 조건의 중심입니다.' },
      { unitId: 'wu-yi', reason: '오의는 낙성 안쪽 길과 고지 매복 위치를 알고 있는 새 합류 장수입니다.' },
      { unitId: 'fa-zheng', reason: '법정은 익주 장수들의 심리와 장임의 방어 습관을 꿰뚫습니다.' },
      { unitId: 'zhao-yun', reason: '좁은 길에서 급히 방통을 보호하거나 궁병을 끊는 기동력이 좋습니다.' },
      { unitId: 'huang-zhong', reason: '고지 궁병과 매복 부대를 안정적으로 견제하는 원거리 전력입니다.' }
    ],
    note: '낙봉파 매복전은 열여섯 명 중 여섯 명만 출전합니다. 오의가 새로 후보에 들어오며, 방통 생존과 매복 대응 때문에 책사와 기동 무장 사이의 선택이 더 중요해집니다.'
  },
  'thirty-first-battle-luo-main-gate': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '낙성 본성의 항복 명분과 패배 조건의 중심입니다.' },
      { unitId: 'pang-tong', reason: '성문 돌파와 퇴로 확보를 동시에 계산하는 핵심 책사입니다.' },
      { unitId: 'fa-zheng', reason: '익주 장수들의 마음을 읽고 엄안 설득의 말길을 여는 역할입니다.' },
      { unitId: 'wu-yi', reason: '낙성 내부 지형과 성문 방어 습관을 아는 새 합류 장수입니다.' },
      { unitId: 'huang-zhong', reason: '성벽 궁병과 노장 엄안의 원거리 압박에 대응하기 좋습니다.' },
      { unitId: 'zhang-fei', reason: '성문 앞 친위대를 짧게 무너뜨리는 돌파력이 필요합니다.' }
    ],
    note: '낙성 본성 공략은 열여섯 명 중 여섯 명만 출전합니다. 설득을 노리면 책사와 오의를, 빠른 돌파를 노리면 장비와 황충 같은 전투 무장을 우선하십시오.'
  },
  'thirty-second-battle-mianzhu-gate': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '성도 앞 항복 명분과 패배 조건의 중심입니다.' },
      { unitId: 'yan-yan', reason: '엄안의 첫 출전입니다. 이엄과 익주 장수들을 설득하는 증거가 됩니다.' },
      { unitId: 'fa-zheng', reason: '성도 수비장들의 심리와 면죽관 창고 사정을 읽는 책사입니다.' },
      { unitId: 'wu-yi', reason: '익주 안쪽 길과 관문 배치를 아는 새 합류 장수입니다.' },
      { unitId: 'pang-tong', reason: '무리한 돌파 대신 항복 권고와 퇴로 확보를 동시에 계산합니다.' },
      { unitId: 'huang-zhong', reason: '관문 궁병과 고지 수비를 안정적으로 견제합니다.' }
    ],
    note: '면죽관 압박전은 열일곱 명 중 여섯 명만 출전합니다. 엄안이 새로 후보에 들어오며, 이엄을 설득하려면 익주 출신 장수와 책사를 함께 고르는 편이 좋습니다.'
  },
  'thirty-third-battle-chengdu-surrender': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '성도 항복 권고와 익주 확보 명분의 중심입니다.' },
      { unitId: 'li-yan', reason: '이엄의 첫 출전입니다. 성도 수비장들에게 유비군의 군율을 증언할 수 있습니다.' },
      { unitId: 'zhuge-liang', reason: '항복 조건과 익주 수습책을 동시에 설계하는 핵심 책사입니다.' },
      { unitId: 'fa-zheng', reason: '유장 진영의 속사정과 황권의 신중한 판단을 읽는 역할입니다.' },
      { unitId: 'yan-yan', reason: '엄안의 항복 선례가 황권과 익주 장수들의 마음을 흔듭니다.' },
      { unitId: 'wu-yi', reason: '성도 외곽 길과 창고 위치를 아는 현지 장수입니다.' }
    ],
    note: '성도 항복 권고전은 열여덟 명 중 여섯 명만 출전합니다. 새로 합류한 이엄을 데려가면 설득과 생존 목표가 맞물리고, 책사 비중을 늘리면 항복 명분이 강해집니다.'
  },
  'thirty-fourth-battle-jiameng-pass': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '가맹관에서 마초를 받아들일 명분과 패배 조건의 중심입니다.' },
      { unitId: 'huang-quan', reason: '황권의 첫 출전입니다. 익주 북문과 한중 길을 해석하는 현지 참모입니다.' },
      { unitId: 'zhao-yun', reason: '서량 기병의 돌격을 기동으로 받아내고 역습하기 좋습니다.' },
      { unitId: 'zhang-fei', reason: '마초의 돌격을 정면에서 받아낼 전열 압박이 필요합니다.' },
      { unitId: 'fa-zheng', reason: '마초가 장로에게 마음을 다 주지 못한 틈을 읽는 책사입니다.' },
      { unitId: 'huang-zhong', reason: '산허리 궁병과 기병 돌격을 안정적으로 견제합니다.' }
    ],
    note: '가맹관 마초 대면전은 열아홉 명 중 여섯 명만 출전합니다. 황권을 데려가면 한중 길 해석과 생존 목표가 맞물리고, 기병 대응 무장을 고르는 압박이 커집니다.'
  },
  'thirty-fifth-battle-yangping-scout': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '한중 전선의 첫 명분과 패배 조건의 중심입니다.' },
      { unitId: 'ma-chao', reason: '마초의 첫 아군 출전입니다. 서량 기병으로 양평관 선봉을 흔들 수 있습니다.' },
      { unitId: 'huang-quan', reason: '양평관 지형과 창고 확보 목표를 읽는 현지 참모입니다.' },
      { unitId: 'zhuge-liang', reason: '한중 본전으로 이어질 전선 설계와 적 책사 견제가 필요합니다.' },
      { unitId: 'zhao-yun', reason: '마초가 너무 깊게 들어가지 않도록 기병 후속을 안정시킵니다.' },
      { unitId: 'fa-zheng', reason: '장로군의 내부 동요와 염포의 책략을 읽는 데 강합니다.' }
    ],
    note: '양평관 정찰전은 스무 명 중 여섯 명만 출전합니다. 마초가 첫 아군 출전 후보로 들어오며, 기병 선봉과 책사 후원을 동시에 고르는 압박이 생깁니다.'
  },
  'thirty-sixth-battle-dingjun-vanguard': {
    maxUnits: 6,
    recommended: [
      { unitId: 'liu-bei', reason: '정군산 전선의 명분과 패배 조건의 중심입니다.' },
      { unitId: 'huang-zhong', reason: '정군산 전초전의 핵심 장수입니다. 고지 압박과 생존 목표가 맞물립니다.' },
      { unitId: 'fa-zheng', reason: '황충의 고지 돌파 타이밍과 하후연 선봉의 허점을 읽는 책사입니다.' },
      { unitId: 'ma-chao', reason: '서쪽 능선을 흔들어 하후연의 시선을 분산시킬 수 있습니다.' },
      { unitId: 'ma-dai', reason: '마초의 첫 돌파 뒤 후속 기병과 퇴로를 안정시키는 새 합류 장수입니다.' },
      { unitId: 'zhuge-liang', reason: '한중 본전으로 이어질 산악 전선과 왕평 설득을 설계합니다.' }
    ],
    note: '정군산 전초전은 스물한 명 중 여섯 명만 출전합니다. 황충과 법정의 고지 압박, 마초·마대의 우회 기병, 제갈량의 큰 전선 설계를 모두 담을지 고민하게 됩니다.'
  }
};

const campDialogues: CampDialogue[] = [
  {
    id: 'liu-guan-after-first-battle',
    title: '의리를 다지는 밤',
    availableAfterBattleIds: [campBattleIds.first],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 14,
    lines: [
      '유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.',
      '관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.',
      '유비: 우리의 맹세가 전장에서 더 단단해지는구려.'
    ],
    choices: [
      {
        id: 'trust-vanguard',
        label: '앞길을 맡긴다',
        response: '관우는 말없이 고개를 숙였다. 다음 전장에서도 형님의 길을 열겠다는 결의가 깊어졌다.',
        rewardExp: 6
      },
      {
        id: 'protect-people',
        label: '백성 보호를 당부한다',
        response: '관우는 칼끝보다 백성의 숨결을 먼저 살피겠다고 답했다. 두 사람의 뜻이 한층 가까워졌다.',
        rewardExp: 4
      }
    ]
  },
  {
    id: 'liu-zhang-after-first-battle',
    title: '거친 용기를 붙잡다',
    availableAfterBattleIds: [campBattleIds.first],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 14,
    lines: [
      '장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!',
      '유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.',
      '장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.'
    ],
    choices: [
      {
        id: 'temper-courage',
        label: '용맹을 다독인다',
        response: '장비는 답답하다는 듯 웃었지만, 형님의 말에 맞춰 창을 거두는 법도 익히겠다고 했다.',
        rewardExp: 5
      },
      {
        id: 'lead-charge',
        label: '선봉을 약속한다',
        response: '장비의 눈빛이 밝아졌다. 믿고 맡긴다는 말은 그에게 무엇보다 큰 격려였다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'guan-zhang-after-first-battle',
    title: '칼과 창의 약속',
    availableAfterBattleIds: [campBattleIds.first],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 12,
    lines: [
      '관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.',
      '장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?',
      '관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.'
    ],
    choices: [
      {
        id: 'cover-flanks',
        label: '서로의 측면을 맡긴다',
        response: '칼과 창이 같은 호흡으로 움직일 길을 찾았다. 두 맹장의 공명이 단단해졌다.',
        rewardExp: 6
      },
      {
        id: 'compete-boldly',
        label: '누가 먼저 베는지 겨룬다',
        response: '장비가 크게 웃고 관우도 미소를 감추지 못했다. 경쟁심마저 전장의 힘이 되었다.',
        rewardExp: 4
      }
    ]
  },
  {
    id: 'liu-guan-after-pursuit',
    title: '추격 뒤의 절제',
    availableAfterBattleIds: [campBattleIds.second],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 15,
    lines: [
      '유비: 잔당을 쫓는 싸움일수록 칼끝이 흐려지기 쉽소. 오늘 운장의 판단이 군을 지켰소.',
      '관우: 형님이 백성의 길을 먼저 살피라 하셨기에 무리한 추격을 멈출 수 있었습니다.',
      '유비: 이기되 어지럽히지 않는 것, 그것이 우리가 지켜야 할 싸움이오.'
    ],
    choices: [
      {
        id: 'steady-line',
        label: '전열의 절제를 칭찬한다',
        response: '관우는 전공보다 전열의 안정이 먼저라는 말을 마음에 새겼다.',
        rewardExp: 6
      },
      {
        id: 'entrust-village',
        label: '마을 수비를 맡긴다',
        response: '관우는 다음 싸움에서도 백성이 있는 곳을 먼저 지키겠다고 답했다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'liu-zhang-after-pursuit',
    title: '성급함과 선봉',
    availableAfterBattleIds: [campBattleIds.second],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 15,
    lines: [
      '장비: 형님, 추격전에서는 한 발만 더 빨랐다면 놈들을 몽땅 묶어 둘 수 있었습니다.',
      '유비: 익덕의 빠른 창은 큰 힘이오. 다만 혼자 먼저 닿으면 뒤따르는 병사들이 흔들리오.',
      '장비: 좋소. 다음엔 앞서 달리되, 형님 깃발이 보이는 거리 안에서 치겠습니다.'
    ],
    choices: [
      {
        id: 'controlled-charge',
        label: '통제된 돌격을 약속받는다',
        response: '장비는 선봉의 힘과 군율이 함께 가야 한다는 말을 받아들였다.',
        rewardExp: 6
      },
      {
        id: 'praise-speed',
        label: '빠른 결단을 칭찬한다',
        response: '믿음을 받은 장비는 다음 전장에서 더 날카로운 선봉이 되겠다고 웃었다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'guan-zhang-after-pursuit',
    title: '길목을 나누다',
    availableAfterBattleIds: [campBattleIds.second],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 13,
    lines: [
      '관우: 익덕, 네가 길목을 막아 준 덕에 궁병들이 숨을 돌렸다.',
      '장비: 운장 형님이 옆을 눌러 주니 제가 마음 놓고 앞을 칠 수 있었지요.',
      '관우: 다음에도 누가 먼저 나서든, 남은 한 사람이 뒤를 끊어 주면 된다.'
    ],
    choices: [
      {
        id: 'split-roads',
        label: '길목 분담을 정한다',
        response: '두 사람은 좁은 길에서 서로의 창끝과 칼끝이 부딪히지 않을 간격을 익혔다.',
        rewardExp: 6
      },
      {
        id: 'bold-competition',
        label: '전공을 겨루게 둔다',
        response: '가벼운 경쟁심이 두 맹장의 호흡을 더 뜨겁게 만들었다.',
        rewardExp: 4
      }
    ]
  },
  {
    id: 'liu-guan-after-guangzong-road',
    title: '광종 길목의 침묵',
    availableAfterBattleIds: [campBattleIds.third],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 16,
    lines: [
      '유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
      '관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
      '유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
    ],
    choices: [
      {
        id: 'lead-narrow-road',
        label: '좁은 길의 선봉을 맡긴다',
        response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
        rewardExp: 7
      },
      {
        id: 'keep-discipline',
        label: '군율 유지를 당부한다',
        response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'liu-zhang-after-guangzong-road',
    title: '강가의 큰소리',
    availableAfterBattleIds: [campBattleIds.third],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 16,
    lines: [
      '장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
      '유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
      '장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
    ],
    choices: [
      {
        id: 'laugh-through',
        label: '호방함으로 사기를 올린다',
        response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
        rewardExp: 6
      },
      {
        id: 'guard-bridgehead',
        label: '교두보 수비를 맡긴다',
        response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'guan-zhang-after-guangzong-road',
    title: '험지의 호흡',
    availableAfterBattleIds: [campBattleIds.third],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 14,
    lines: [
      '관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
      '장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
      '관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
    ],
    choices: [
      {
        id: 'measure-and-break',
        label: '관우가 재고 장비가 뚫는다',
        response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
        rewardExp: 7
      },
      {
        id: 'watch-each-other',
        label: '서로의 후방을 살핀다',
        response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'liu-guan-after-zhang-jue',
    title: '황건의 깃발이 꺾인 밤',
    availableAfterBattleIds: [campBattleIds.fourth],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 18,
    lines: [
      '유비: 장각이 쓰러졌으나 천하가 바로 맑아진 것은 아니오.',
      '관우: 큰 난이 끝난 뒤에도 작은 탐욕은 남습니다. 형님의 뜻이 흔들리지 않게 제가 곁에 있겠습니다.',
      '유비: 운장과 익덕이 있으니, 더 큰 혼란에도 나아갈 용기가 생기오.'
    ],
    choices: [
      {
        id: 'prepare-larger-chaos',
        label: '더 큰 혼란을 준비한다',
        response: '관우는 반동탁의 소문을 들으며 다시 칼을 정비했다.',
        rewardExp: 7
      },
      {
        id: 'honor-fallen-people',
        label: '희생된 백성을 기린다',
        response: '두 사람은 전공보다 백성의 상처를 먼저 기억하기로 했다.',
        rewardExp: 6
      }
    ]
  },
  {
    id: 'liu-zhang-after-zhang-jue',
    title: '끝난 싸움, 남은 분노',
    availableAfterBattleIds: [campBattleIds.fourth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 18,
    lines: [
      '장비: 장각을 꺾었는데도 속이 시원하지만은 않습니다. 아직 백성을 괴롭히는 놈들이 많습니다.',
      '유비: 그 분노가 백성을 향하지 않고 악한 자에게만 향하도록 붙잡아야 하오.',
      '장비: 형님이 길을 가리키면, 제 창은 그 길 밖으로 나가지 않겠습니다.'
    ],
    choices: [
      {
        id: 'temper-anger',
        label: '분노를 다스리게 한다',
        response: '장비는 분노를 창끝에만 싣고 마음에는 남기지 않겠다고 했다.',
        rewardExp: 7
      },
      {
        id: 'promise-next-vanguard',
        label: '다음 선봉을 약속한다',
        response: '장비는 반동탁의 길에서도 가장 먼저 적진을 흔들겠다고 웃었다.',
        rewardExp: 6
      }
    ]
  },
  {
    id: 'guan-zhang-after-zhang-jue',
    title: '맹장들의 전공',
    availableAfterBattleIds: [campBattleIds.fourth],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 16,
    lines: [
      '장비: 운장 형님, 오늘은 제가 더 크게 적진을 흔든 것 같습니다.',
      '관우: 전공을 다투는 마음이 백성을 구하는 데 쓰인다면 나쁠 것이 없다.',
      '장비: 그럼 다음 전장에서도 누가 더 크게 이름을 떨치는지 봅시다!'
    ],
    choices: [
      {
        id: 'clean-rivalry',
        label: '깨끗한 경쟁을 인정한다',
        response: '두 맹장은 웃으며 전공 경쟁을 다음 전장의 활력으로 삼았다.',
        rewardExp: 6
      },
      {
        id: 'brothers-before-glory',
        label: '명성보다 형제를 앞세운다',
        response: '관우와 장비는 전공보다 서로의 생존을 먼저 지키기로 했다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-guan-after-sishui',
    title: '공손찬의 깃발 아래',
    availableAfterBattleIds: [campBattleIds.fifth],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 18,
    lines: [
      '유비: 공손찬의 진영은 크고, 제후들의 생각은 저마다 다르오. 우리 뜻을 잃지 않는 것이 더 어려워졌소.',
      '관우: 큰 깃발 아래 설수록 작은 의리를 더 단단히 붙잡아야 합니다.',
      '유비: 운장의 말이 옳소. 남의 군영에 있어도 우리가 지킬 길은 변하지 않소.'
    ],
    choices: [
      {
        id: 'hold-oath-in-coalition',
        label: '연합 속에서도 맹세를 지킨다',
        response: '관우는 도원에서 맺은 뜻을 낯선 군영에서도 흐리지 않겠다고 답했다.',
        rewardExp: 7
      },
      {
        id: 'observe-warlords',
        label: '제후들의 속내를 살핀다',
        response: '관우는 싸움만이 아니라 사람의 뜻을 살피는 눈도 필요하다고 받아들였다.',
        rewardExp: 6
      }
    ]
  },
  {
    id: 'liu-zhang-after-sishui',
    title: '낯선 군영의 술잔',
    availableAfterBattleIds: [campBattleIds.fifth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 18,
    lines: [
      '장비: 형님, 공손찬 군영엔 말도 많고 술도 많고, 괜히 시비 걸 놈들도 많습니다.',
      '유비: 익덕의 기개는 좋으나, 지금은 싸워야 할 적과 참아야 할 말을 구분해야 하오.',
      '장비: 하하, 형님이 그렇게 말하면 술잔은 들되 주먹은 참아 보겠습니다.'
    ],
    choices: [
      {
        id: 'restrain-in-camp',
        label: '군영 안의 절제를 당부한다',
        response: '장비는 시비보다 형님의 체면을 먼저 지키겠다고 약속했다.',
        rewardExp: 7
      },
      {
        id: 'trust-his-honesty',
        label: '솔직함을 믿어 준다',
        response: '믿음을 받은 장비는 낯선 제후들 사이에서도 형님의 편에 서겠다고 웃었다.',
        rewardExp: 6
      }
    ]
  },
  {
    id: 'guan-zhang-after-sishui',
    title: '연합군의 두 맹장',
    availableAfterBattleIds: [campBattleIds.fifth],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 16,
    lines: [
      '관우: 이곳에는 이름난 장수도 많고, 허세뿐인 자도 많다.',
      '장비: 그럼 우리가 전장에서 보여 주면 되지요. 말보다 칼과 창이 빠릅니다.',
      '관우: 다만 형님의 이름이 먼저 서야 한다. 우리의 무공은 그 뜻을 받치는 것이니.'
    ],
    choices: [
      {
        id: 'raise-liu-bei-name',
        label: '유비의 이름을 세운다',
        response: '두 사람은 자신의 전공보다 유비의 명성을 먼저 드러내기로 뜻을 모았다.',
        rewardExp: 7
      },
      {
        id: 'test-famous-generals',
        label: '이름난 장수들을 의식한다',
        response: '장비의 승부욕과 관우의 침착함이 묘한 균형을 이루었다.',
        rewardExp: 5
      }
    ]
  },
  {
    id: 'liu-guan-after-jieqiao',
    title: '서주로 향하는 뜻',
    availableAfterBattleIds: [campBattleIds.sixth],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 20,
    lines: [
      '유비: 공손찬 장군의 은혜는 크나, 서주의 백성이 위태롭다는 소식을 들으니 마음이 편치 않소.',
      '관우: 의탁은 몸을 잠시 맡기는 일이고, 의리는 마음이 향하는 곳을 따르는 일입니다.',
      '유비: 운장의 말이 나를 깨우는구려. 도겸 공의 청을 외면하지 않겠소.'
    ],
    choices: [
      {
        id: 'ask-permission',
        label: '공손찬에게 허락을 구한다',
        response: '관우는 예를 갖추어 떠나는 것이 유비의 이름을 더 높인다고 답했다.',
        rewardExp: 8
      },
      {
        id: 'put-people-first',
        label: '백성을 먼저 생각한다',
        response: '두 사람은 새 주인의 눈치보다 고통받는 백성을 먼저 보겠다고 뜻을 모았다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-zhang-after-jieqiao',
    title: '떠나는 술잔',
    availableAfterBattleIds: [campBattleIds.sixth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 20,
    lines: [
      '장비: 형님, 북방 군영도 나쁘진 않았지만 서주 백성이 부른다면 가야지요.',
      '유비: 익덕은 떠나는 길에서도 성급히 다투지 말아야 하오. 우리는 도움을 주러 가는 것이오.',
      '장비: 알겠습니다. 술잔은 비우고, 창은 백성을 괴롭히는 자에게만 겨누겠습니다.'
    ],
    choices: [
      {
        id: 'steady-departure',
        label: '차분한 출발을 당부한다',
        response: '장비는 떠나는 순간의 소란도 형님의 이름에 흠이 될 수 있음을 받아들였다.',
        rewardExp: 7
      },
      {
        id: 'promise-xuzhou-vanguard',
        label: '서주 선봉을 맡긴다',
        response: '장비는 서주 길에서도 가장 앞에서 백성을 지키겠다며 창을 움켜쥐었다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'guan-zhang-after-jieqiao',
    title: '새 길의 두 기둥',
    availableAfterBattleIds: [campBattleIds.sixth],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 18,
    lines: [
      '관우: 익덕, 서주로 가면 우리 힘만으로는 감당하기 어려운 일도 많을 것이다.',
      '장비: 그러니 더더욱 형님 곁을 단단히 지켜야지요. 운장 형님이 오른쪽이면 저는 왼쪽입니다.',
      '관우: 좋다. 길이 바뀌어도 형님을 받치는 두 기둥은 흔들리지 않는다.'
    ],
    choices: [
      {
        id: 'two-pillars',
        label: '좌우의 역할을 정한다',
        response: '두 사람은 유비의 좌우에서 전선과 군심을 함께 지키기로 했다.',
        rewardExp: 8
      },
      {
        id: 'guard-the-oath',
        label: '도원 맹세를 되새긴다',
        response: '낯선 서주 길 앞에서도 도원의 맹세가 세 사람의 중심이 되었다.',
        rewardExp: 6
      }
    ]
  },
  {
    id: 'liu-guan-after-xuzhou',
    title: '서주의 무게',
    availableAfterBattleIds: [campBattleIds.seventh],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 22,
    lines: [
      '유비: 도겸 공은 서주를 맡아 달라 하나, 내 그릇이 백성을 감당할 수 있을지 두렵소.',
      '관우: 형님께서 두려워하시는 까닭은 땅이 아니라 백성을 먼저 보기 때문입니다. 그래서 맡을 수 있는 일입니다.',
      '유비: 운장의 말처럼 두려움이 의를 잊지 않게 하는 끈이라면, 그 끈을 놓지 않겠소.'
    ],
    choices: [
      {
        id: 'accept-responsibility',
        label: '책임을 피하지 않는다',
        response: '관우는 유비가 명분보다 책임을 먼저 말하는 것을 보고 깊이 고개를 끄덕였다.',
        rewardExp: 9
      },
      {
        id: 'listen-to-xuzhou',
        label: '서주 사람들의 뜻을 듣는다',
        response: '두 사람은 성급히 자리를 받기보다 백성의 마음을 먼저 살피기로 했다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhang-after-xuzhou',
    title: '성급함을 다스리다',
    availableAfterBattleIds: [campBattleIds.seventh],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 22,
    lines: [
      '장비: 형님, 서주가 우리를 부른다면 시원하게 맡으면 되지 않습니까?',
      '유비: 익덕, 기뻐할 일만은 아니오. 성 하나를 맡는다는 것은 그 성의 굶주림과 두려움도 함께 맡는 일이오.',
      '장비: 그렇다면 제가 먼저 군량과 길목을 지키겠습니다. 백성이 굶는 일부터 막아야겠지요.'
    ],
    choices: [
      {
        id: 'guard-supplies',
        label: '군량 길목을 맡긴다',
        response: '장비는 싸움보다 지키는 일이 더 어렵다는 말을 마음에 새겼다.',
        rewardExp: 8
      },
      {
        id: 'calm-the-temper',
        label: '분노보다 절제를 당부한다',
        response: '유비의 당부에 장비는 주먹을 풀고, 백성을 놀라게 하지 않겠다고 답했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'guan-zhang-after-xuzhou',
    title: '새 군영의 좌우',
    availableAfterBattleIds: [campBattleIds.seventh],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 20,
    lines: [
      '관우: 서주는 넓고 사람의 마음은 아직 흩어져 있다. 우리가 먼저 흔들리면 형님의 짐이 더 무거워질 것이다.',
      '장비: 걱정 마시오. 운장 형님이 군율을 세우면, 나는 군심을 붙잡겠습니다.',
      '관우: 좋다. 이제 우리는 전장에서뿐 아니라 군영에서도 형님의 좌우가 되어야 한다.'
    ],
    choices: [
      {
        id: 'divide-camp-duties',
        label: '군율과 군심을 나눈다',
        response: '관우와 장비는 서로의 강점을 인정하며 새 군영을 안정시키기로 했다.',
        rewardExp: 8
      },
      {
        id: 'train-new-recruits',
        label: '새 병사를 함께 훈련한다',
        response: '두 장수의 훈련 방식은 달랐지만, 병사들은 그 사이에서 빠르게 자리를 잡았다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-xuzhou',
    title: '오랜 벗의 조언',
    availableAfterBattleIds: [campBattleIds.seventh],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 18,
    lines: [
      '간옹: 현덕, 서주를 맡는 일은 칼을 드는 일보다 더 무겁네. 백성은 말보다 먼저 밥과 길을 보네.',
      '유비: 헌화, 내가 그 무게를 감당할 수 있을까 두렵소.',
      '간옹: 두려움을 아는 사람이면 적어도 함부로 빼앗지는 않겠지. 그러니 먼저 사람을 살피게.'
    ],
    choices: [
      {
        id: 'listen-before-rule',
        label: '먼저 백성의 말을 듣는다',
        response: '간옹은 유비가 급히 명분을 세우기보다 서주의 목소리를 먼저 듣겠다고 하자 고개를 끄덕였다.',
        rewardExp: 8
      },
      {
        id: 'appoint-practical-aide',
        label: '실무를 맡아 달라 한다',
        response: '간옹은 투덜대면서도 군영 장부와 사람들의 청원을 살피겠다고 나섰다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-mi-zhu-after-xuzhou',
    title: '서주의 군량',
    availableAfterBattleIds: [campBattleIds.seventh],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '미축: 유 장군, 서주의 창고는 아직 온전하지만 오래 버티려면 상인과 호족의 마음을 함께 묶어야 합니다.',
      '유비: 백성에게 더 짐을 지우고 싶지는 않소. 다른 길이 있겠소?',
      '미축: 사사로운 이익을 줄이고 공적인 신뢰를 세우면 길은 열립니다. 제가 그 일을 맡겠습니다.'
    ],
    choices: [
      {
        id: 'protect-grain',
        label: '군량을 백성과 나눈다',
        response: '미축은 군량을 무기로 삼지 않겠다는 유비의 뜻을 확인하고 서주의 상단을 설득하기로 했다.',
        rewardExp: 8
      },
      {
        id: 'secure-supply-route',
        label: '보급로 정비를 부탁한다',
        response: '미축은 곧장 장부를 펼쳐 길목과 창고를 다시 묶는 계획을 세웠다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-mi-zhu-after-xiaopei',
    title: '지켜낸 보급로',
    availableAfterBattleIds: [campBattleIds.eighth],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 20,
    lines: [
      '미축: 소패 보급로는 지켰지만, 서주는 아직 안정되었다고 보기 어렵습니다. 창고를 지켜도 사람의 마음이 흩어지면 다시 흔들립니다.',
      '유비: 군량은 칼보다 조용하지만, 백성을 살리는 힘이 있소. 그 일을 계속 맡아 주시오.',
      '미축: 맡겨 주신다면 상단과 호족을 설득해 서주의 숨통을 조금 더 넓히겠습니다.'
    ],
    choices: [
      {
        id: 'trust-supply-network',
        label: '보급망을 믿고 맡긴다',
        response: '미축은 유비가 실무를 믿고 맡기자 서주의 창고와 상단을 더 단단히 묶겠다고 답했다.',
        rewardExp: 8
      },
      {
        id: 'share-grain-openly',
        label: '군량 배분을 투명하게 한다',
        response: '유비가 백성 앞에서 군량 배분을 숨기지 않겠다고 하자 미축의 표정이 한결 밝아졌다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-xiaopei',
    title: '여포를 맞을 것인가',
    availableAfterBattleIds: [campBattleIds.eighth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 20,
    lines: [
      '간옹: 여포를 받아들이면 의롭다는 말은 들을 걸세. 하지만 사람들은 그 뒤에 칼집이 몇 개인지도 세어 볼 거야.',
      '유비: 곤궁한 이를 외면할 수는 없소. 그러나 서주 백성의 근심도 가볍게 여길 수 없소.',
      '간옹: 그래서 선택에는 늘 값을 치르는 법이지. 적어도 그 값을 알고 받아들이게.'
    ],
    choices: [
      {
        id: 'accept-with-guard',
        label: '경계를 세우고 맞는다',
        response: '간옹은 유비가 의와 경계를 함께 세우겠다고 하자, 그나마 현실적인 대답이라고 웃었다.',
        rewardExp: 9
      },
      {
        id: 'ask-people-first',
        label: '백성의 불안을 먼저 살핀다',
        response: '간옹은 서주 사람들의 두려움을 먼저 살피겠다는 말에 고개를 끄덕였다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'guan-zhang-after-xiaopei',
    title: '성문을 지키는 두 장수',
    availableAfterBattleIds: [campBattleIds.eighth],
    unitIds: ['guan-yu', 'zhang-fei'],
    bondId: 'guan-yu__zhang-fei',
    rewardExp: 20,
    lines: [
      '관우: 여포가 온다면 군율이 먼저 흔들릴 것이다. 성문과 술자리를 모두 조심해야 한다.',
      '장비: 왜 나를 보며 말하시오? 하지만 알겠소. 이번에는 먼저 소리치기보다 성문부터 붙잡겠소.',
      '관우: 형님의 뜻을 지키려면 우리부터 흔들리지 않아야 한다.'
    ],
    choices: [
      {
        id: 'guard-gates',
        label: '성문 경계를 강화한다',
        response: '관우와 장비는 성문 근무와 순찰을 나누어 맡기로 했다.',
        rewardExp: 8
      },
      {
        id: 'restrain-temper',
        label: '장비의 성급함을 다잡는다',
        response: '장비는 툴툴대면서도 이번만큼은 술보다 군율을 먼저 보겠다고 약속했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-zhang-after-xuzhou-gate',
    title: '성문 뒤의 후회',
    availableAfterBattleIds: [campBattleIds.ninth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 22,
    lines: [
      '장비: 형님, 성문은 지켰지만 조표 같은 자들이 움직인 것 자체가 제 불찰입니다.',
      '유비: 익덕, 성급함은 네 강점이자 약점이다. 하지만 후회로 멈추면 다음 성문도 지키지 못한다.',
      '장비: 다음에는 술도, 분노도, 먼저 앞세우지 않겠습니다.'
    ],
    choices: [
      {
        id: 'trust-reform',
        label: '다시 맡기되 군율을 세운다',
        response: '장비는 꾸짖음보다 믿음을 받은 것에 더 무겁게 고개를 숙였다.',
        rewardExp: 10
      },
      {
        id: 'share-command',
        label: '관우와 함께 성문을 맡긴다',
        response: '장비는 운장 형님과 함께라면 이번 실수를 씻어 보이겠다고 답했다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-xuzhou-gate',
    title: '의와 현실 사이',
    availableAfterBattleIds: [campBattleIds.ninth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 22,
    lines: [
      '간옹: 현덕, 여포를 들인 일은 이제 말 한마디로 되돌릴 수 없네. 사람들은 이미 네 의와 그의 칼을 함께 보고 있어.',
      '유비: 의로 시작한 일이 백성을 위태롭게 한다면, 나는 무엇을 지켜야 하오?',
      '간옹: 그래서 더더욱 눈을 뜨고 있어야지. 의도 잠들면 남의 칼집이 된다네.'
    ],
    choices: [
      {
        id: 'watch-lu-bu',
        label: '여포의 움직임을 감시한다',
        response: '간옹은 마침내 현실적인 지시가 나왔다며 사람을 붙이겠다고 했다.',
        rewardExp: 10
      },
      {
        id: 'protect-civilians-first',
        label: '백성 피난로를 먼저 정한다',
        response: '간옹은 서주를 잃더라도 사람을 잃지 않겠다는 말에 한숨 섞인 미소를 지었다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-mi-after-xuzhou-gate',
    title: '성문과 창고',
    availableAfterBattleIds: [campBattleIds.ninth],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '미축: 성문이 흔들리면 창고도 오래 버티지 못합니다. 군량을 지키는 길과 성문을 지키는 길은 다르지 않습니다.',
      '유비: 그렇다면 창고의 문서와 수레를 성문 수비와 함께 움직입시다. 길이 끊겨도 백성에게 갈 몫은 남겨 두어야 하오.',
      '미축: 그 뜻이면 충분합니다. 다음 혼란에 대비하겠습니다.'
    ],
    choices: [
      {
        id: 'move-grain-carts',
        label: '군량 수레를 재배치한다',
        response: '미축은 군량 수레를 성문 안쪽 길목으로 옮겨 비상시에 대비했다.',
        rewardExp: 8
      },
      {
        id: 'guard-ledgers',
        label: '창고 장부를 보호한다',
        response: '관우는 장부를 지키는 일도 전열을 지키는 일이라며 병사를 배치했다.',
        rewardExp: 7
      }
    ]
  },
  {
    id: 'liu-zhang-after-xuzhou-loss',
    title: '잃은 성 앞에서',
    availableAfterBattleIds: [campBattleIds.tenth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 24,
    lines: [
      '장비: 형님, 서주를 잃은 죄는 모두 제게 있습니다. 제 목을 베어 군율을 세우십시오.',
      '유비: 익덕의 목을 베면 서주가 돌아오느냐. 살아서 다시 지키는 것이 네 벌이다.',
      '장비: 다시는 술과 분노가 창끝보다 먼저 나가지 않게 하겠습니다.'
    ],
    choices: [
      {
        id: 'forgive-and-command',
        label: '용서하되 군율을 맡긴다',
        response: '장비는 꾸지람보다 무거운 신뢰를 받고, 다음 전장에서는 먼저 자신을 다스리겠다고 맹세했다.',
        rewardExp: 11
      },
      {
        id: 'make-amends-through-action',
        label: '공으로 갚게 한다',
        response: '장비는 서주를 잃은 분노를 다음 싸움의 방패로 삼겠다고 답했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-guan-after-xuzhou-loss',
    title: '흩어지지 않는 전열',
    availableAfterBattleIds: [campBattleIds.tenth],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 22,
    lines: [
      '관우: 성은 잃었으나 형님의 깃발 아래 모인 마음은 아직 남았습니다.',
      '유비: 운장, 내가 의를 앞세워 사람을 들였고 그 의가 백성을 흔들었다.',
      '관우: 의는 버릴 것이 아니라 더 단단히 세울 것입니다. 이번 패배가 그 기둥이 되게 하십시오.'
    ],
    choices: [
      {
        id: 'hold-the-banner',
        label: '깃발을 다시 세운다',
        response: '관우는 흩어진 병사들이 볼 수 있도록 유비군의 깃발을 다시 높이 세웠다.',
        rewardExp: 10
      },
      {
        id: 'protect-the-people',
        label: '피난민을 먼저 모은다',
        response: '관우는 패전의 행군에서도 백성을 버리지 않는 것이 형님의 길이라며 고개를 끄덕였다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-xuzhou-loss',
    title: '허도로 가는 말',
    availableAfterBattleIds: [campBattleIds.tenth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 20,
    lines: [
      '간옹: 현덕, 조조에게 몸을 의탁하는 일은 달갑지 않겠지. 하지만 지금은 그 그늘을 지나야 다음 길이 열린다네.',
      '유비: 조조의 그늘이 너무 짙어 내 뜻까지 가리면 어찌하오?',
      '간옹: 그때를 대비해 사람과 말을 잃지 말아야지. 뜻도 살아 있는 사람에게 붙는 법이네.'
    ],
    choices: [
      {
        id: 'send-envoys',
        label: '먼저 사자를 보낸다',
        response: '간옹은 허도로 향할 말을 고르고, 유비군이 굴복이 아닌 임시 의탁임을 전할 문장을 다듬었다.',
        rewardExp: 9
      },
      {
        id: 'hide-strength',
        label: '병력 규모를 감춘다',
        response: '간옹은 조조가 보는 숫자와 실제로 보존할 숫자를 나누어 생각해야 한다고 조언했다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-guan-after-cao-cao-refuge',
    title: '남의 깃발 아래',
    availableAfterBattleIds: [campBattleIds.eleventh],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 22,
    lines: [
      '관우: 조조의 깃발 아래 서 있으니 병사들의 눈빛이 조심스러워졌습니다.',
      '유비: 운장, 깃발은 잠시 빌릴 수 있어도 뜻은 빌릴 수 없다. 우리가 지켜야 할 선을 잊지 맙시다.',
      '관우: 형님의 뜻이 흔들리지 않는다면, 남의 진영에서도 우리의 칼은 길을 잃지 않을 것입니다.'
    ],
    choices: [
      {
        id: 'keep-oath-visible',
        label: '도원의 뜻을 분명히 한다',
        response: '관우는 병사들에게 조조의 명을 따르되 유비군의 군율을 잃지 말라고 전했다.',
        rewardExp: 10
      },
      {
        id: 'watch-cao-cao-camp',
        label: '조조 진영의 움직임을 살핀다',
        response: '관우는 지나친 경계가 오히려 의심을 부른다며 조용한 감시를 맡았다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-cao-cao-refuge',
    title: '웃음 뒤의 문장',
    availableAfterBattleIds: [campBattleIds.eleventh],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 20,
    lines: [
      '간옹: 조조는 웃으며 맞았지만, 그 웃음은 사람을 묶는 줄이기도 하네.',
      '유비: 그 줄을 끊으면 목이 조이고, 그대로 두면 발이 묶일 것이오.',
      '간옹: 그래서 말과 문서를 조심해야지. 지금은 충심을 보이되, 떠날 길의 이름을 잊지 않는 때라네.'
    ],
    choices: [
      {
        id: 'write-carefully',
        label: '문서를 조심스럽게 정리한다',
        response: '간옹은 조조에게 보일 문서와 유비군 내부 문서를 따로 정리했다.',
        rewardExp: 9
      },
      {
        id: 'hear-rumors',
        label: '허도 소문을 모은다',
        response: '간옹은 말 많은 술자리와 시장길에서 조조 진영의 분위기를 살피기로 했다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-mi-after-cao-cao-refuge',
    title: '빌린 창고',
    availableAfterBattleIds: [campBattleIds.eleventh],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '미축: 조조군의 창고를 빌리더라도 우리 병사의 식량 장부는 따로 남겨 두겠습니다.',
      '유비: 군량 하나에도 마음이 묶이는 법이오. 빌린 것은 빌린 것이라 분명히 해야 하오.',
      '미축: 그렇다면 보급은 받되, 유비군이 스스로 버틸 양도 함께 모아 두겠습니다.'
    ],
    choices: [
      {
        id: 'separate-ledgers',
        label: '장부를 분리한다',
        response: '미축은 조조군 보급과 유비군 예비 군량을 따로 기록해 훗날의 발목을 줄였다.',
        rewardExp: 8
      },
      {
        id: 'save-refugee-grain',
        label: '피난민 군량을 남긴다',
        response: '미축은 병사 몫을 줄이지 않으면서도 피난민에게 갈 곡식을 별도로 숨겨 두었다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhang-after-xiapi-outer',
    title: '창끝을 누르는 법',
    availableAfterBattleIds: [campBattleIds.twelfth],
    unitIds: ['liu-bei', 'zhang-fei'],
    bondId: 'liu-bei__zhang-fei',
    rewardExp: 22,
    lines: [
      '장비: 형님, 여포 놈의 깃발이 눈앞에 있는데도 창을 참는 일이 쉽지 않았습니다.',
      '유비: 참았기에 포위망이 무너지지 않았다. 익덕의 창은 분노보다 늦게 나갈 때 더 무겁소.',
      '장비: 다음에는 여포의 말발굽보다 먼저 제 마음을 묶어 두겠습니다.'
    ],
    choices: [
      {
        id: 'praise-restraint',
        label: '참은 공을 칭찬한다',
        response: '장비는 호통보다 뜻밖의 칭찬에 더 깊이 고개를 숙였고, 다음 싸움에서도 전열을 지키겠다고 다짐했다.',
        rewardExp: 10
      },
      {
        id: 'assign-dike-front',
        label: '둑길 전열을 맡긴다',
        response: '장비는 자신이 앞에서 버티면 여포군 기병도 쉽게 뚫지 못할 것이라며 창대를 움켜쥐었다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-xiapi-outer',
    title: '공적을 쓰는 붓',
    availableAfterBattleIds: [campBattleIds.twelfth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 20,
    lines: [
      '간옹: 오늘 공적은 조조의 장부에도 오르겠지만, 그 장부가 우리 목줄이 되지 않게 써야 하네.',
      '유비: 공을 세워도 빚이 되고, 물러서도 의심이 되는 형국이오.',
      '간옹: 그래서 말의 끝을 남겨야지. 우리는 조조를 위해서만 싸운 것이 아니라 서주 백성을 위해 싸웠다고.'
    ],
    choices: [
      {
        id: 'record-people-first',
        label: '백성 보호를 먼저 기록한다',
        response: '간옹은 전공 보고의 첫 줄에 피난민과 보급로 보호를 적어 유비군의 명분을 분명히 했다.',
        rewardExp: 9
      },
      {
        id: 'keep-private-notes',
        label: '별도 기록을 남긴다',
        response: '간옹은 조조에게 보일 보고와 훗날 유비군이 기억할 기록을 따로 남겼다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-mi-after-xiapi-outer',
    title: '물가의 보급선',
    availableAfterBattleIds: [campBattleIds.twelfth],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '미축: 강가 포위전은 싸움보다 군량이 먼저 흔들립니다. 조조군 배급만 믿으면 우리 병사 마음도 함께 묶입니다.',
      '유비: 독자 보급이 곧 독자적인 마음이오. 작은 양이라도 우리가 스스로 마련할 길을 남깁시다.',
      '미축: 그렇다면 하비 외곽에서 거둔 전리품은 공용과 예비로 나누어 두겠습니다.'
    ],
    choices: [
      {
        id: 'split-siege-supplies',
        label: '보급을 둘로 나눈다',
        response: '미축은 조조군 공용 보급과 유비군 예비 보급을 나누어, 훗날 떠날 수 있는 여지를 남겼다.',
        rewardExp: 8
      },
      {
        id: 'secure-river-cart',
        label: '강가 수레를 확보한다',
        response: '미축은 강가에서 쓸 수레와 배를 따로 챙겨 다음 포위전의 군량 흐름을 안정시켰다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-guan-after-lu-bu-fall',
    title: '무용과 믿음',
    availableAfterBattleIds: [campBattleIds.thirteenth],
    unitIds: ['liu-bei', 'guan-yu'],
    bondId: 'liu-bei__guan-yu',
    rewardExp: 24,
    lines: [
      '관우: 여포의 무용은 천하가 알았으나, 끝내 누구도 오래 믿게 하지 못했습니다.',
      '유비: 힘만으로 길을 열 수는 있어도 사람 마음을 머물게 하지는 못하오.',
      '관우: 그렇다면 형님의 길은 더 무거워질 것입니다. 저 또한 그 길을 지키겠습니다.'
    ],
    choices: [
      {
        id: 'honor-trust-over-force',
        label: '믿음의 무게를 말한다',
        response: '관우는 여포의 말로를 힘의 패배가 아니라 믿음의 패배로 새기며 결의를 다졌다.',
        rewardExp: 10
      },
      {
        id: 'prepare-for-cao-watch',
        label: '조조의 시선을 경계한다',
        response: '관우는 칼을 거두는 때도 전투라 여기며 조조의 경계를 살피겠다고 답했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-jian-yong-after-lu-bu-fall',
    title: '말 한마디의 무게',
    availableAfterBattleIds: [campBattleIds.thirteenth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 22,
    lines: [
      '간옹: 현덕, 여포가 사라졌으니 조조는 이제 우리를 더 오래 바라볼 걸세.',
      '유비: 오늘 한 말이 내일의 의심이 될 수 있겠군.',
      '간옹: 그러니 말은 짧게, 뜻은 길게 남기세. 떠날 명분은 오늘부터 쌓아야 하네.'
    ],
    choices: [
      {
        id: 'keep-words-measured',
        label: '말을 아끼게 한다',
        response: '간옹은 조조 앞에서 드러낼 말과 속으로 남길 뜻을 분리해 두었다.',
        rewardExp: 9
      },
      {
        id: 'collect-leaving-cause',
        label: '떠날 명분을 모은다',
        response: '간옹은 조조 휘하에서 겪는 작은 어긋남도 훗날의 기록으로 남기기 시작했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-mi-after-lu-bu-fall',
    title: '남겨 둘 군량',
    availableAfterBattleIds: [campBattleIds.thirteenth],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 20,
    lines: [
      '미축: 하비의 보급품은 조조군 장부로 들어가겠지만, 우리 병사들이 떠날 때 쓸 몫도 필요합니다.',
      '유비: 떠날 길을 생각하는 것이 불충으로 보일 수도 있소.',
      '미축: 뜻을 지키려면 길도 있어야 합니다. 군량은 의리를 움직이게 하는 바퀴입니다.'
    ],
    choices: [
      {
        id: 'set-aside-march-grain',
        label: '행군 군량을 남긴다',
        response: '미축은 장부 밖으로 새지 않는 선에서 유비군의 독자 행군 물자를 확보했다.',
        rewardExp: 9
      },
      {
        id: 'protect-xu-refugees',
        label: '서주 피난민을 챙긴다',
        response: '미축은 하비 뒤편의 피난민에게 갈 식량을 남겨 유비군의 명분을 조용히 지켰다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-sun-after-cao-break',
    title: '명분의 문서',
    availableAfterBattleIds: [campBattleIds.fourteenth],
    unitIds: ['liu-bei', 'sun-qian'],
    bondId: 'liu-bei__sun-qian',
    rewardExp: 22,
    lines: [
      '손건: 주공, 차주의 관문은 열렸으나 조조의 눈은 더 날카로워질 것입니다. 이제부터는 말 한마디와 문서 한 장이 모두 칼날입니다.',
      '유비: 힘으로만 길을 뚫는다면 백성의 마음을 잃겠지. 그대의 글이 우리 군의 숨길을 열어 주었소.',
      '손건: 그렇다면 다음 길목에서는 싸움보다 먼저 명분을 세우겠습니다. 원소에게 가는 길도 그냥 피난이 되어서는 안 됩니다.'
    ],
    choices: [
      {
        id: 'write-for-people',
        label: '백성을 앞세운 격문을 쓴다',
        response: '손건은 유비군이 조조를 떠나는 이유를 백성의 안위와 서주의 책임으로 정리해 각 진영에 보낼 문안을 준비했다.',
        rewardExp: 10
      },
      {
        id: 'write-for-alliance',
        label: '원소에게 보낼 명분을 다듬는다',
        response: '손건은 북상하는 길의 위험과 원소 진영에서 필요한 예법을 정리하며 유비가 다음 의탁을 준비하도록 도왔다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'jian-sun-after-cao-break',
    title: '말과 글의 길',
    availableAfterBattleIds: [campBattleIds.fourteenth],
    unitIds: ['jian-yong', 'sun-qian'],
    bondId: 'jian-yong__sun-qian',
    rewardExp: 20,
    lines: [
      '간옹: 손 선생, 글은 곧지만 세상은 구부러져 있소. 너무 반듯하게 쓰면 칼보다 먼저 부러질 때도 있지.',
      '손건: 그렇기에 간옹 님의 말이 필요합니다. 글이 문을 두드리면, 말은 그 문을 열게 만들 수 있으니까요.',
      '간옹: 좋소. 그럼 나는 웃으며 시간을 벌 테니, 선생은 그 틈에 빠져나갈 길을 문서로 만들구려.'
    ],
    choices: [
      {
        id: 'soften-message',
        label: '문장을 부드럽게 고친다',
        response: '간옹은 손건의 문서에 숨 쉴 틈을 더했고, 손건은 그 말투 속에서도 뜻이 흐려지지 않게 문장을 다시 세웠다.',
        rewardExp: 9
      },
      {
        id: 'prepare-messenger-route',
        label: '사자 동선을 짠다',
        response: '두 사람은 관문과 역참을 피해 갈 사자 동선을 나누어 적의 감시망을 늦게 흔들 계획을 세웠다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-mi-after-cao-break',
    title: '떠나는 군량',
    availableAfterBattleIds: [campBattleIds.fourteenth],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '미축: 주공, 북쪽 길은 멀고 조조군의 추격은 빠릅니다. 군량을 아끼면 병사가 지치고, 풀어 쓰면 다음 진영에 닿기 전 바닥날 수 있습니다.',
      '유비: 백성에게서 억지로 거둘 수는 없소. 우리가 떠나는 길 또한 의로워야 하오.',
      '미축: 그래서 서주의 상단을 나누어 보내겠습니다. 병사에게는 당장의 밥을, 백성에게는 우리가 돌아올 이유를 남기겠습니다.'
    ],
    choices: [
      {
        id: 'protect-refugee-supplies',
        label: '피난민 몫을 남긴다',
        response: '미축은 유비군의 짐을 줄이는 대신 피난민에게 남길 곡식을 따로 묶어, 떠나는 길의 민심을 보존했다.',
        rewardExp: 8
      },
      {
        id: 'stock-northern-road',
        label: '북상 보급을 숨겨 둔다',
        response: '미축은 상단의 표식을 숨긴 짐수레를 북쪽 길에 먼저 보내, 추격을 피해 움직일 여지를 만들었다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-jian-after-yuan-refuge',
    title: '큰 깃발 아래의 말',
    availableAfterBattleIds: [campBattleIds.fifteenth],
    unitIds: ['liu-bei', 'jian-yong'],
    bondId: 'liu-bei__jian-yong',
    rewardExp: 22,
    lines: [
      '간옹: 원소의 장막은 크지만, 그 안의 사람들 말도 그만큼 큽니다. 주공의 이름이 오래 머물수록 여러 귀에 다르게 들릴 것입니다.',
      '유비: 우리가 의탁한 것은 몸이지 뜻이 아니오. 그러나 그 뜻을 지키려면 지금은 말 한마디도 조심해야 하겠지.',
      '간옹: 그렇다면 제가 먼저 가벼운 말로 무거운 시선을 흩뜨리겠습니다. 주공께서는 백성을 향한 뜻만 흐리지 마십시오.'
    ],
    choices: [
      {
        id: 'measure-words-in-yuan-camp',
        label: '말을 낮추고 뜻을 숨긴다',
        response: '간옹은 원소 진영의 술자리와 군막 사이를 오가며 유비군의 속내가 쉽게 드러나지 않도록 말을 다듬었다.',
        rewardExp: 9
      },
      {
        id: 'keep-liu-bei-name-clean',
        label: '유비의 명분을 조용히 남긴다',
        response: '간옹은 크게 외치지 않는 방식으로 유비가 조조를 떠난 이유와 백성을 살핀 행적을 진영 곳곳에 흘렸다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-sun-after-yuan-refuge',
    title: '의탁의 문서',
    availableAfterBattleIds: [campBattleIds.fifteenth],
    unitIds: ['liu-bei', 'sun-qian'],
    bondId: 'liu-bei__sun-qian',
    rewardExp: 22,
    lines: [
      '손건: 원소에게 보낼 문서는 이미 통과했지만, 머무는 동안의 문서가 더 중요합니다. 작은 약속이 나중에 큰 족쇄가 될 수 있습니다.',
      '유비: 지금 얻은 쉼터가 훗날 발목을 잡아서는 안 되겠지. 우리가 받은 은혜는 잊지 않되, 길을 잃지는 말아야 하오.',
      '손건: 그 뜻을 문서마다 남기겠습니다. 주공의 군은 원소의 사병이 아니라, 난세를 건너는 의로운 객군으로 남아야 합니다.'
    ],
    choices: [
      {
        id: 'write-guest-army-status',
        label: '객군의 지위를 분명히 한다',
        response: '손건은 유비군의 지위를 원소 진영의 객군으로 적어, 명령 체계에 휩쓸릴 여지를 줄였다.',
        rewardExp: 10
      },
      {
        id: 'record-cao-pursuit-proof',
        label: '조조 추격의 증거를 남긴다',
        response: '손건은 채양 추격대의 움직임을 기록해 조조와의 긴장이 실제였음을 원소 진영에 설득할 근거로 남겼다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'zhang-mi-after-yuan-refuge',
    title: '낯선 군영의 밥',
    availableAfterBattleIds: [campBattleIds.fifteenth],
    unitIds: ['zhang-fei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 18,
    lines: [
      '장비: 원소군 밥은 양은 많아도 속이 편치 않소. 남의 군량을 먹으니 창끝까지 남의 눈치를 보는 기분이오.',
      '미축: 그래서 우리 몫의 장부를 따로 두려 합니다. 받은 것은 받은 대로, 쓴 것은 쓴 대로 남겨야 훗날 떳떳하게 떠날 수 있습니다.',
      '장비: 좋소. 그럼 나는 병사들이 주눅 들지 않게 술 대신 목소리를 나눠 주겠소. 배가 차도 기가 죽으면 싸움은 못 하니까.'
    ],
    choices: [
      {
        id: 'separate-supply-ledger',
        label: '보급 장부를 따로 둔다',
        response: '미축은 유비군의 군량 장부를 따로 정리해 원소 진영에 빚이 쌓이는 일을 줄였고, 장비는 병사들에게 그 뜻을 크게 알렸다.',
        rewardExp: 8
      },
      {
        id: 'raise-camp-spirits',
        label: '병사들의 사기를 다독인다',
        response: '장비는 낯선 군영에서 움츠러든 병사들을 불러 세워 웃음과 호통으로 사기를 끌어올렸고, 미축은 작은 술과 밥을 나눴다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhao-after-liu-biao-refuge',
    title: '다시 맡긴 창',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    unitIds: ['liu-bei', 'zhao-yun'],
    bondId: 'liu-bei__zhao-yun',
    rewardExp: 24,
    lines: [
      '조운: 형주 관문까지 함께 달리며 확신했습니다. 주공의 깃발은 몸을 숨기는 깃발이 아니라, 다시 백성을 모으는 깃발입니다.',
      '유비: 자룡이 돌아온 것은 내게 큰 힘이오. 다만 우리는 아직 남의 땅에 의탁한 몸이니, 창끝만큼 마음도 곧아야 하오.',
      '조운: 그렇다면 제 창은 앞길을 열고, 제 마음은 주공의 뜻이 흐트러지지 않게 지키겠습니다.'
    ],
    choices: [
      {
        id: 'trust-zhao-yun-vanguard',
        label: '선봉을 맡긴다',
        response: '유비는 조운에게 다음 행군의 선봉을 맡겼고, 조운은 조용히 말고삐를 잡으며 유비군의 앞길을 정찰했다.',
        rewardExp: 10
      },
      {
        id: 'ask-zhao-yun-people',
        label: '백성의 길을 살핀다',
        response: '조운은 형주 주변의 피난민 길목을 살펴 유비군이 머무는 동안 백성에게 폐가 되지 않을 길을 찾아냈다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'guan-zhao-after-liu-biao-refuge',
    title: '두 충의의 눈',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    unitIds: ['guan-yu', 'zhao-yun'],
    bondId: 'guan-yu__zhao-yun',
    rewardExp: 20,
    lines: [
      '관우: 자룡의 창끝은 빠르되 가볍지 않군. 적을 쫓을 때도 군의 중심을 잃지 않았소.',
      '조운: 운장께서 전열을 잡아 주셨기에 제가 옆길을 열 수 있었습니다. 충의는 앞뒤가 다르지 않다는 것을 배웠습니다.',
      '관우: 그렇다면 다음 전장에서도 서로의 빈틈을 맡깁시다. 주공의 길을 지키는 데는 칼과 창이 다르지 않소.'
    ],
    choices: [
      {
        id: 'train-sword-spear-timing',
        label: '칼과 창의 호흡을 맞춘다',
        response: '관우와 조운은 짧은 훈련으로 돌파와 엄호의 순서를 맞췄고, 병사들은 두 장수의 호흡을 보고 사기를 얻었다.',
        rewardExp: 9
      },
      {
        id: 'share-guard-watch',
        label: '야간 경계를 나눈다',
        response: '두 사람은 밤 경계를 나누어 섰고, 형주 군영의 낯선 시선 속에서도 유비군의 군율은 흔들리지 않았다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-sun-after-liu-biao-refuge',
    title: '객장의 예',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    unitIds: ['liu-bei', 'sun-qian'],
    bondId: 'liu-bei__sun-qian',
    rewardExp: 18,
    lines: [
      '손건: 유표가 주공을 예로 맞이했지만, 형주 안의 사람들은 저마다 주공의 이름을 다르게 저울질할 것입니다.',
      '유비: 손님의 예를 받되 주인의 마음을 어지럽히지는 말아야 하오. 그러나 백성을 돌볼 뜻까지 숨길 수는 없소.',
      '손건: 그래서 말과 문서의 결을 나누겠습니다. 겉으로는 예를 지키고, 안으로는 뜻을 이어갈 사람을 찾겠습니다.'
    ],
    choices: [
      {
        id: 'write-guest-etiquette',
        label: '객장의 예를 분명히 한다',
        response: '손건은 유비군의 군영 예절과 방문 절차를 정리해 형주 관리들의 경계심을 낮췄다.',
        rewardExp: 8
      },
      {
        id: 'listen-to-jingzhou-scholars',
        label: '형주 선비들의 말을 듣는다',
        response: '손건은 형주 선비들의 소문을 모아 융중의 와룡이라는 이름을 유비에게 조심스럽게 전했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-zhuge-after-wolong-visit',
    title: '초가에서 열린 큰 그림',
    availableAfterBattleIds: [campBattleIds.seventeenth],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 28,
    lines: [
      '제갈량: 주공께서 세 번이나 초가를 찾으신 것은 한 사람을 얻기 위함이 아니라, 백성을 살릴 길을 묻기 위함이라 보았습니다.',
      '유비: 나는 아직 작은 군영 하나도 온전히 지키기 어려운 몸이오. 그러나 선생의 말은 우리 군이 가야 할 길을 눈앞에 펼쳤소.',
      '제갈량: 그 길은 서두르면 흐트러지고, 늦추면 빼앗깁니다. 이제부터는 뜻과 때를 함께 살피겠습니다.'
    ],
    choices: [
      {
        id: 'share-three-kingdoms-plan',
        label: '천하삼분의 뜻을 묻는다',
        response: '제갈량은 형주와 익주, 그리고 북벌의 때를 차분히 설명했고, 유비는 그 말을 군영의 첫 방침으로 삼았다.',
        rewardExp: 12
      },
      {
        id: 'entrust-camp-strategy',
        label: '군영의 계책을 맡긴다',
        response: '유비는 제갈량에게 군영의 큰 판단을 맡겼고, 공명은 병사와 장수의 이름부터 조용히 살피기 시작했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhao-zhuge-after-wolong-visit',
    title: '말발굽과 부채',
    availableAfterBattleIds: [campBattleIds.seventeenth],
    unitIds: ['zhao-yun', 'zhuge-liang'],
    bondId: 'zhao-yun__zhuge-liang',
    rewardExp: 22,
    lines: [
      '조운: 선생의 계책은 멀리 보되, 실제 길목의 먼지까지 살피는군요. 제 말이 달릴 곳을 미리 본 듯했습니다.',
      '제갈량: 자룡의 창은 빠르지만 앞서 나가도 군의 마음을 놓치지 않습니다. 그래서 빠른 구원이 가능해집니다.',
      '조운: 그렇다면 다음 전장에서는 선생의 지시에 맞춰, 가장 필요한 곳에 먼저 닿겠습니다.'
    ],
    choices: [
      {
        id: 'map-relief-routes',
        label: '구원로를 함께 살핀다',
        response: '제갈량은 지형 위에 구원로를 표시했고, 조운은 말이 지나갈 수 있는 폭과 위험한 샛길을 덧붙였다.',
        rewardExp: 10
      },
      {
        id: 'coordinate-feint-charge',
        label: '유인 돌격을 맞춘다',
        response: '조운은 일부러 빈틈을 보이는 돌격법을 익혔고, 제갈량은 그 틈에 적을 몰아넣는 순서를 정했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'guan-zhuge-after-wolong-visit',
    title: '의와 계책의 첫 인사',
    availableAfterBattleIds: [campBattleIds.seventeenth],
    unitIds: ['guan-yu', 'zhuge-liang'],
    bondId: 'guan-yu__zhuge-liang',
    rewardExp: 20,
    lines: [
      '관우: 선생의 말은 크고 깊으나, 전장에서는 한 걸음의 늦음도 군을 잃게 합니다. 그 점을 잊지 않으셨으면 하오.',
      '제갈량: 운장께서 전열을 지키는 까닭도 결국 주공의 뜻을 잃지 않기 위함입니다. 저는 그 의를 가벼이 여기지 않겠습니다.',
      '관우: 그렇다면 나 또한 선생의 계책을 먼저 듣겠소. 의가 길을 잃지 않게 해 주시오.'
    ],
    choices: [
      {
        id: 'align-duty-and-plan',
        label: '의와 계책을 맞춘다',
        response: '관우는 제갈량의 병력 운용을 듣고 전열의 기준을 새로 세웠고, 제갈량은 관우가 지켜야 할 선을 존중했다.',
        rewardExp: 9
      },
      {
        id: 'promise-clear-orders',
        label: '명령의 기준을 분명히 한다',
        response: '제갈량은 전장 명령의 우선순위를 문서로 정리했고, 관우는 그 분명함을 보고 조용히 고개를 끄덕였다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhuge-after-bowang-ambush',
    title: '불길 뒤의 큰길',
    availableAfterBattleIds: [campBattleIds.eighteenth],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 30,
    lines: [
      '유비: 오늘 박망파에서 선생의 계책이 아니었다면, 조조군의 선봉을 작은 군영으로 막지 못했을 것이오.',
      '제갈량: 주공께서 의심하지 않고 맡기셨기에 장수들도 움직였습니다. 계책은 사람의 마음이 모일 때 비로소 전장이 됩니다.',
      '유비: 그렇다면 다음에는 더 큰 마음을 모아야겠소. 강동과 손잡는 길도 선생과 함께 열겠소.'
    ],
    choices: [
      {
        id: 'trust-grand-strategy',
        label: '큰 계책을 계속 맡긴다',
        response: '유비는 제갈량에게 다음 외교와 군사 판단을 함께 맡겼고, 군영 안의 의심은 크게 줄어들었다.',
        rewardExp: 12
      },
      {
        id: 'share-command-with-generals',
        label: '장수들에게 계책을 설명하게 한다',
        response: '제갈량은 장수들 앞에서 박망파의 순서를 차분히 되짚었고, 다음 명령은 더 빠르게 받아들여질 준비가 되었다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhang-zhuge-after-bowang-ambush',
    title: '호통과 부채',
    availableAfterBattleIds: [campBattleIds.eighteenth],
    unitIds: ['zhang-fei', 'zhuge-liang'],
    bondId: 'zhang-fei__zhuge-liang',
    rewardExp: 20,
    lines: [
      '장비: 군사 양반, 오늘은 인정하겠소. 불길이 오르니 적들이 제 창보다 먼저 겁을 먹더군.',
      '제갈량: 익덕 장군의 창이 뒤에서 기다린다는 것을 적도 알았기 때문입니다. 계책만 있고 칼끝이 없으면 적은 물러나지 않습니다.',
      '장비: 좋소. 다음에도 먼저 설명만 해 주시오. 내가 어디서 소리쳐야 적이 더 잘 넘어지는지만 알면 됩니다.'
    ],
    choices: [
      {
        id: 'plan-zhang-fei-ambush-call',
        label: '매복 호령을 맞춘다',
        response: '제갈량은 장비가 뛰쳐나올 때와 물러설 때를 정했고, 장비는 드물게 끝까지 듣고 고개를 끄덕였다.',
        rewardExp: 9
      },
      {
        id: 'praise-bold-charge',
        label: '돌파의 때를 칭찬한다',
        response: '장비는 계책 안에서도 자신의 용맹이 필요하다는 말에 크게 웃었고, 다음 전장에서도 군사의 손짓을 보겠다고 했다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'sun-zhuge-after-bowang-ambush',
    title: '강동으로 보낼 말',
    availableAfterBattleIds: [campBattleIds.eighteenth],
    unitIds: ['sun-qian', 'zhuge-liang'],
    bondId: 'sun-qian__zhuge-liang',
    rewardExp: 22,
    lines: [
      '손건: 박망파의 승리는 작은 군이 큰 군을 늦출 수 있다는 증거입니다. 강동에 보낼 말에도 힘이 생겼습니다.',
      '제갈량: 강동은 말보다 형세를 볼 것입니다. 조조가 멈추지 않는다는 사실과, 주공이 백성을 버리지 않는다는 사실을 함께 전해야 합니다.',
      '손건: 그렇다면 문서는 제가 다듬고, 큰 논리는 선생께서 세우십시오. 말과 계책이 같은 방향을 보아야 합니다.'
    ],
    choices: [
      {
        id: 'draft-jiangdong-letter',
        label: '강동 서신을 초안한다',
        response: '손건은 제갈량의 형세 판단을 바탕으로 강동에 보낼 첫 서신의 골격을 잡았다.',
        rewardExp: 10
      },
      {
        id: 'record-cao-vanguard-delay',
        label: '조조 선봉 지연을 기록한다',
        response: '박망파에서 벌어들인 시간을 기록으로 남기자, 유비군의 작은 승리가 다음 외교의 근거가 되었다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-zhao-after-changban-refuge',
    title: '백마가 지킨 길',
    availableAfterBattleIds: [campBattleIds.nineteenth],
    unitIds: ['liu-bei', 'zhao-yun'],
    bondId: 'liu-bei__zhao-yun',
    rewardExp: 30,
    lines: [
      '유비: 자룡, 장판파의 먼지 속에서도 그대의 창은 백성을 향해 길을 열었소.',
      '조운: 주공께서 백성을 버리지 않으셨기에 저도 물러서지 않을 수 있었습니다.',
      '유비: 오늘의 공은 전공보다 무겁소. 다음 길에서도 그 마음을 함께 지켜 주시오.'
    ],
    choices: [
      {
        id: 'entrust-refugee-guard',
        label: '피난 행렬 호위를 맡긴다',
        response: '조운은 말없이 창을 세웠고, 유비군의 피난길은 더 단단한 호위를 얻었다.',
        rewardExp: 12
      },
      {
        id: 'praise-white-horse-loyalty',
        label: '백마의 의리를 칭찬한다',
        response: '주공의 말에 조운은 깊이 고개를 숙였다. 흩어진 군영 안에서도 두 사람의 신뢰가 또렷해졌다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhang-zhuge-after-changban-refuge',
    title: '다리목의 호령',
    availableAfterBattleIds: [campBattleIds.nineteenth],
    unitIds: ['zhang-fei', 'zhuge-liang'],
    bondId: 'zhang-fei__zhuge-liang',
    rewardExp: 22,
    lines: [
      '장비: 군사, 다리목에서 한 번 호통치니 놈들이 눈치를 보더군. 이번엔 계책이 아니라 목청이 이긴 것 아니오?',
      '제갈량: 익덕 장군의 호령이 먹힌 것은 설 곳을 정확히 골랐기 때문입니다. 그 자리까지 가는 길도 계책의 일부입니다.',
      '장비: 하하, 그렇다면 다음에도 어디서 소리치면 되는지만 알려 주시오. 나머지는 내 창이 알아서 하겠소.'
    ],
    choices: [
      {
        id: 'fix-bridge-ambush-signals',
        label: '다리목 신호를 정한다',
        response: '제갈량은 장비가 나서야 할 순간을 짧은 깃발 신호로 정했고, 장비는 복잡한 설명보다 마음에 든다고 웃었다.',
        rewardExp: 9
      },
      {
        id: 'let-roar-lead',
        label: '호령을 먼저 세운다',
        response: '장비의 호령이 적의 발을 묶고, 제갈량의 손짓이 그 뒤를 받치는 새 호흡이 생겼다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhuge-after-changban-refuge',
    title: '피난길과 강동',
    availableAfterBattleIds: [campBattleIds.nineteenth],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 28,
    lines: [
      '유비: 오늘 지킨 것은 길 하나였지만, 그 길 위의 사람들은 천하보다 가벼울 수 없었소.',
      '제갈량: 바로 그 점이 강동에 전할 말입니다. 조조의 힘만이 아니라 주공이 무엇을 지키려 하는지를 보여야 합니다.',
      '유비: 그렇다면 손권에게 갈 말도 이 피난길에서 시작해야겠구려.'
    ],
    choices: [
      {
        id: 'write-refugee-road-record',
        label: '피난로 기록을 남긴다',
        response: '제갈량은 장판파의 피난로와 조조군의 추격 속도를 함께 적어 강동 사절 문서의 근거로 삼았다.',
        rewardExp: 11
      },
      {
        id: 'align-people-and-strategy',
        label: '백성과 대계를 함께 본다',
        response: '유비와 제갈량은 백성을 지키는 마음과 천하를 보는 계책이 서로 멀지 않음을 확인했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'sun-zhuge-after-jiangdong-envoy',
    title: '강동으로 보낼 논리',
    availableAfterBattleIds: [campBattleIds.twentieth],
    unitIds: ['sun-qian', 'zhuge-liang'],
    bondId: 'sun-qian__zhuge-liang',
    rewardExp: 28,
    lines: [
      '손건: 문서는 갖추었습니다. 하지만 강동은 글보다 사람의 눈빛을 먼저 볼 것입니다.',
      '제갈량: 그러니 말의 순서가 중요합니다. 조조의 위협, 주공의 뜻, 강동이 얻을 계산을 차례로 놓아야 합니다.',
      '손건: 그 순서라면 두려움과 이익, 명분이 한 문장 안에서 다투지 않겠습니다.'
    ],
    choices: [
      {
        id: 'sharpen-envoy-argument',
        label: '설득의 순서를 다듬는다',
        response: '손건은 제갈량의 큰 논리를 짧은 문장으로 정리했고, 강동 회담의 첫 마디가 또렷해졌다.',
        rewardExp: 11
      },
      {
        id: 'prepare-sun-quan-questions',
        label: '손권의 질문을 예상한다',
        response: '두 사람은 손권이 물을 병력, 보급, 퇴로 문제를 하나씩 적었고, 사절 문서는 훨씬 실전적인 답안을 품게 되었다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-zhuge-after-jiangdong-envoy',
    title: '적벽을 보는 눈',
    availableAfterBattleIds: [campBattleIds.twentieth],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 30,
    lines: [
      '유비: 강동으로 가는 길은 열렸지만, 손권의 마음까지 열린 것은 아니오.',
      '제갈량: 그래서 주공의 뜻이 필요합니다. 우리는 땅을 빼앗으러 가는 것이 아니라 조조를 막을 길을 함께 찾으러 갑니다.',
      '유비: 그 말을 들으니 적벽의 불길도 결국 사람을 지키기 위한 불길이어야 하겠구려.'
    ],
    choices: [
      {
        id: 'entrust-red-cliffs-parley',
        label: '강동 회담을 맡긴다',
        response: '유비는 제갈량에게 강동 회담의 전권을 맡겼고, 군영의 장수들도 다음 명령의 방향을 이해하기 시작했다.',
        rewardExp: 12
      },
      {
        id: 'anchor-strategy-in-people',
        label: '백성의 뜻을 기준으로 삼는다',
        response: '제갈량은 유비의 말을 회담의 중심 문장으로 남겼다. 전략은 숫자만이 아니라 지켜야 할 사람에서 출발했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhao-zhuge-after-jiangdong-envoy',
    title: '사절을 지킨 창',
    availableAfterBattleIds: [campBattleIds.twentieth],
    unitIds: ['zhao-yun', 'zhuge-liang'],
    bondId: 'zhao-yun__zhuge-liang',
    rewardExp: 22,
    lines: [
      '조운: 사절을 지키는 길은 적장을 베는 길보다 더 조심스러웠습니다.',
      '제갈량: 자룡 장군의 창이 길을 흔들림 없이 붙들었기에 문서도 사람도 강나루에 닿았습니다.',
      '조운: 다음에도 지켜야 할 사람이 있다면 제 창은 그쪽으로 먼저 향하겠습니다.'
    ],
    choices: [
      {
        id: 'guard-envoy-flank',
        label: '사절 측면 호위를 맡긴다',
        response: '조운은 사절단이 움직일 때 어느 길을 먼저 살필지 정했고, 제갈량은 그 기동력을 믿고 더 과감한 동선을 그렸다.',
        rewardExp: 9
      },
      {
        id: 'map-river-crossing',
        label: '강나루 길을 표시한다',
        response: '조운이 본 강변의 샛길이 지도에 더해지자, 다음 장강 전장으로 향할 발판이 생겼다.',
        rewardExp: 8
      }
    ]
  },
  {
    id: 'liu-zhuge-after-red-cliffs-vanguard',
    title: '첫 강안 전선',
    availableAfterBattleIds: [campBattleIds.twentyFirst],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 32,
    lines: [
      '유비: 강가에 첫 전선을 세우고 보니, 조조의 대군이 얼마나 큰 물결인지 더 분명히 보이는구려.',
      '제갈량: 그래서 더더욱 작은 승리가 필요했습니다. 오늘의 포구는 화공을 논할 자리이자 병사들이 믿을 첫 발판입니다.',
      '유비: 불길을 일으키더라도 그 끝에는 백성이 숨 쉴 길이 있어야 하오. 그 기준만은 잊지 맙시다.'
    ],
    choices: [
      {
        id: 'hold-river-line',
        label: '강안 전선을 굳힌다',
        response: '제갈량은 유비의 뜻을 전선 배치도 첫머리에 적었고, 적벽의 싸움이 단순한 불길이 아님을 분명히 했다.',
        rewardExp: 12
      },
      {
        id: 'protect-civilians-before-fire',
        label: '화공 전 피난로를 살핀다',
        response: '유비는 포구 주변 피난로를 먼저 열게 했고, 제갈량은 그 길을 화공 계획의 제약이 아니라 기준으로 삼았다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'zhao-zhuge-after-red-cliffs-vanguard',
    title: '강안의 흰 창',
    availableAfterBattleIds: [campBattleIds.twentyFirst],
    unitIds: ['zhao-yun', 'zhuge-liang'],
    bondId: 'zhao-yun__zhuge-liang',
    rewardExp: 24,
    lines: [
      '조운: 강가 길목은 말이 마음껏 달릴 수 없었습니다. 그래도 적 척후가 전선을 흔들기 전에는 끊을 수 있었습니다.',
      '제갈량: 자룡 장군이 먼저 강안의 빈틈을 막아 주었기에 포구 수비대가 흐트러지지 않았습니다.',
      '조운: 다음 싸움에서도 군사의 깃발이 가리키는 빈틈부터 메우겠습니다.'
    ],
    choices: [
      {
        id: 'mark-river-flanks',
        label: '강안 측면로를 표시한다',
        response: '조운은 말을 돌릴 수 있는 좁은 길을 지도에 남겼고, 제갈량은 그 길을 다음 화공 엄호선으로 삼았다.',
        rewardExp: 10
      },
      {
        id: 'guard-strategist-route',
        label: '군사의 이동로를 호위한다',
        response: '제갈량의 이동로가 조운의 창끝 아래 정리되자, 전선 뒤쪽의 책략 운용이 한결 안정되었다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'sun-zhuge-after-red-cliffs-vanguard',
    title: '동맹군의 기록',
    availableAfterBattleIds: [campBattleIds.twentyFirst],
    unitIds: ['sun-qian', 'zhuge-liang'],
    bondId: 'sun-qian__zhuge-liang',
    rewardExp: 28,
    lines: [
      '손건: 오늘의 승리는 작지만, 강동에 보여 줄 기록으로는 충분히 무겁습니다.',
      '제갈량: 맞습니다. 유비군이 전선을 세웠고, 강동은 불을 준비할 시간을 얻었습니다. 기록은 그 균형을 보여야 합니다.',
      '손건: 그렇다면 전공보다 동맹의 호흡을 먼저 적겠습니다. 함께 이긴 기록이어야 다음 명령도 흔들리지 않겠습니다.'
    ],
    choices: [
      {
        id: 'write-allied-record',
        label: '동맹 전과를 함께 적는다',
        response: '손건은 유비군과 강동군의 역할을 나누어 적었고, 제갈량은 그 기록을 주유에게 보낼 논거로 다듬었다.',
        rewardExp: 11
      },
      {
        id: 'summarize-fire-prep',
        label: '화공 준비 상황을 요약한다',
        response: '전초전의 포구, 배, 바람 기록이 한 장의 보고로 묶였고, 다음 적벽 계책의 설득력이 더해졌다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-zhuge-after-red-cliffs-fire',
    title: '불길 뒤의 대계',
    availableAfterBattleIds: [campBattleIds.twentySecond],
    unitIds: ['liu-bei', 'zhuge-liang'],
    bondId: 'liu-bei__zhuge-liang',
    rewardExp: 34,
    lines: [
      '유비: 적벽의 불길은 조조를 물렸지만, 우리에게 머물 땅이 생긴 것은 아니오.',
      '제갈량: 그래서 이제 싸움의 목적이 바뀝니다. 물러나는 조조를 보며 형주 남부의 길을 열어야 합니다.',
      '유비: 백성이 다시 떠돌지 않게 하려면, 다음 걸음은 전공보다 근본을 세우는 일이겠구려.'
    ],
    choices: [
      {
        id: 'plan-jingzhou-foundation',
        label: '형주 근거지 계획을 세운다',
        response: '제갈량은 형주 남부의 군현과 민심을 나누어 적었고, 유비는 처음으로 머물 땅의 조건을 분명히 말했다.',
        rewardExp: 13
      },
      {
        id: 'temper-victory-pride',
        label: '승리의 들뜸을 경계한다',
        response: '유비는 큰 승리 뒤의 방심을 경계했고, 제갈량은 그 말을 다음 작전의 첫 원칙으로 삼았다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'guan-zhuge-after-red-cliffs-fire',
    title: '불길을 지킨 전열',
    availableAfterBattleIds: [campBattleIds.twentySecond],
    unitIds: ['guan-yu', 'zhuge-liang'],
    bondId: 'guan-yu__zhuge-liang',
    rewardExp: 26,
    lines: [
      '관우: 불길이 번질 때 병사들이 흔들렸으나, 전열을 무너뜨리지는 않았습니다.',
      '제갈량: 운장 장군의 침착함이 있었기에 화선의 길이 끊기지 않았습니다. 계책도 그것을 지킬 칼이 있어야 완성됩니다.',
      '관우: 다음 땅을 얻는 싸움에서도, 계책이 가리키는 곳을 제 칼로 지키겠습니다.'
    ],
    choices: [
      {
        id: 'guard-next-crossing',
        label: '다음 도하 지점을 맡긴다',
        response: '제갈량은 관우에게 다음 강가 전열을 맡겼고, 관우는 묵묵히 청룡언월도를 세워 답했다.',
        rewardExp: 10
      },
      {
        id: 'honor-steady-line',
        label: '흔들리지 않은 전열을 칭찬한다',
        response: '관우는 큰 공을 말하지 않았지만, 병사들이 그의 등 뒤에서 안정을 찾았다는 말에는 조용히 고개를 숙였다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-mi-after-red-cliffs-fire',
    title: '불길 속 보급로',
    availableAfterBattleIds: [campBattleIds.twentySecond],
    unitIds: ['liu-bei', 'mi-zhu'],
    bondId: 'liu-bei__mi-zhu',
    rewardExp: 24,
    lines: [
      '미축: 불길이 번지는 전장은 칼보다 물과 약이 먼저 필요했습니다. 젖은 약품을 버리고 마른 꾸러미부터 살렸습니다.',
      '유비: 전공이 커도 병사가 쓰러지면 다음 걸음은 없소. 그대가 붙잡은 보급로가 곧 우리의 근본이오.',
      '미축: 다음부터는 빠른 말과 마른 약품을 한 묶음으로 준비하겠습니다. 전장도 이제 더 넓어질 테니까요.'
    ],
    choices: [
      {
        id: 'bind-fast-supply-line',
        label: '기동 보급로를 정한다',
        response: '미축은 기동 부대가 바로 가져갈 수 있는 보급 꾸러미를 따로 묶었고, 유비는 다음 전장의 장기전을 대비했다.',
        rewardExp: 10
      },
      {
        id: 'reserve-dry-medicine',
        label: '젖지 않는 약품을 챙긴다',
        response: '미축은 젖은 강가에서도 쓸 수 있는 약품을 따로 묶었고, 유비는 병사들이 승리 뒤에도 무너지지 않게 했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-ma-after-jingzhou-south-entry',
    title: '백미를 맞이하다',
    availableAfterBattleIds: [campBattleIds.twentyThird],
    unitIds: ['liu-bei', 'ma-liang'],
    bondId: 'liu-bei__ma-liang',
    rewardExp: 28,
    lines: [
      '마량: 주공께서 형주 남부의 마을을 먼저 살피셨다는 말을 들었습니다. 그래서 저도 붓을 들고 찾아왔습니다.',
      '유비: 땅을 얻는 일보다 사람을 잃지 않는 일이 먼저요. 선생이 남부의 길과 민심을 알려 준다면 큰 힘이 되겠소.',
      '마량: 칼로 열 수 없는 문은 말과 문서로 열겠습니다. 다음 군현부터는 제가 앞서 백성의 사정을 살피겠습니다.'
    ],
    choices: [
      {
        id: 'ask-ma-liang-local-voices',
        label: '군현의 민심을 묻는다',
        response: '마량은 영릉과 계양의 토호, 마을 장로, 상인 길목을 차분히 짚어 주었고 유비군의 다음 행군 명분이 선명해졌다.',
        rewardExp: 12
      },
      {
        id: 'entrust-scholar-letters',
        label: '선비들에게 보낼 글을 맡긴다',
        response: '유비는 형주 선비들에게 보낼 첫 문장을 마량에게 맡겼고, 백미의 이름은 군영 안에서 조용히 힘을 얻기 시작했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhuge-ma-after-jingzhou-south-entry',
    title: '형주의 지맥',
    availableAfterBattleIds: [campBattleIds.twentyThird],
    unitIds: ['zhuge-liang', 'ma-liang'],
    bondId: 'zhuge-liang__ma-liang',
    rewardExp: 26,
    lines: [
      '제갈량: 큰 계책은 지도를 보지만, 실제 길은 그곳에 사는 사람의 입에서 나옵니다. 마량 선생의 기록이 필요합니다.',
      '마량: 형주 남부는 길 하나가 성 하나와 같습니다. 어디를 먼저 설득하고 어디를 피해야 할지 적어 두겠습니다.',
      '제갈량: 좋습니다. 이번부터 출전 명단을 고를 때도 전열만이 아니라 문서와 설득을 함께 보아야 합니다.'
    ],
    choices: [
      {
        id: 'map-commandery-roads',
        label: '남부 군현의 길을 표시한다',
        response: '마량이 좁은 산길과 마을 우회로를 표시하자, 제갈량의 다음 전장 배치안이 훨씬 촘촘해졌다.',
        rewardExp: 11
      },
      {
        id: 'compare-scholar-networks',
        label: '선비들의 관계망을 맞춰 본다',
        response: '두 사람은 선비층의 관계를 병력 배치처럼 정리했고, 싸우지 않고 열 수 있는 문이 몇 곳 보이기 시작했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'sun-ma-after-jingzhou-south-entry',
    title: '문서와 백미',
    availableAfterBattleIds: [campBattleIds.twentyThird],
    unitIds: ['sun-qian', 'ma-liang'],
    bondId: 'sun-qian__ma-liang',
    rewardExp: 24,
    lines: [
      '손건: 외교 문서는 말이 곧 병사입니다. 마량 선생이 남부의 이름과 체면을 바로잡아 주면 설득이 쉬워질 겁니다.',
      '마량: 손건 선생의 문장은 군영 밖에서도 곧게 들립니다. 거기에 현지의 호칭과 예법을 붙이면 문이 열릴 것입니다.',
      '손건: 다음 출전부터는 칼을 드는 장수뿐 아니라, 문서를 들고 갈 장수도 생각해야겠군요.'
    ],
    choices: [
      {
        id: 'draft-commandery-letters',
        label: '군현에 보낼 서신을 쓴다',
        response: '손건과 마량은 군현별 서신 초안을 만들었고, 전투 사이 선택지가 더 넓어질 준비가 갖춰졌다.',
        rewardExp: 10
      },
      {
        id: 'sort-local-titles',
        label: '토호들의 호칭을 정리한다',
        response: '사소한 호칭 하나가 문을 닫기도 연다. 두 사람은 남부 토호들의 이름과 예를 새로 정리했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-yi-after-guiyang',
    title: '예로 열린 계양',
    availableAfterBattleIds: [campBattleIds.twentyFourth],
    unitIds: ['liu-bei', 'yi-ji'],
    bondId: 'liu-bei__yi-ji',
    rewardExp: 28,
    lines: [
      '이적: 계양의 문이 칼끝이 아니라 군율과 서신 앞에서 열렸습니다. 그런 군이라면 저도 따를 이유가 있습니다.',
      '유비: 형주의 사람을 얻으려면 형주의 예를 알아야 하오. 선생이 길을 알려 준다면 다음 군현도 덜 흔들릴 것이오.',
      '이적: 다음부터는 싸우기 전에 먼저 물어야 할 이름과 예가 있습니다. 제가 그 문장을 준비하겠습니다.'
    ],
    choices: [
      {
        id: 'entrust-guiyang-etiquette',
        label: '계양의 예법을 맡긴다',
        response: '이적은 계양의 토호와 문사 이름을 정리했고, 유비군의 다음 설득 문서는 한층 단정해졌다.',
        rewardExp: 12
      },
      {
        id: 'share-liu-bei-cause-with-yi-ji',
        label: '유비군의 뜻을 설명한다',
        response: '유비는 백성을 먼저 세우려는 뜻을 말했고, 이적은 그 뜻을 형주 사람들이 읽을 수 있는 문장으로 다듬었다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'ma-yi-after-guiyang',
    title: '백미와 문사',
    availableAfterBattleIds: [campBattleIds.twentyFourth],
    unitIds: ['ma-liang', 'yi-ji'],
    bondId: 'ma-liang__yi-ji',
    rewardExp: 26,
    lines: [
      '마량: 계양은 열렸지만 무릉은 산길이 험합니다. 길을 아는 사람과 마음을 아는 사람이 함께 움직여야 합니다.',
      '이적: 마량 선생의 기록은 정확하고, 제 일은 그 기록을 상대가 받아들일 말로 바꾸는 것입니다.',
      '마량: 그렇다면 다음 전장부터는 길과 문장이 함께 가겠군요. 무릉으로 가기 전에 마을별 이름을 맞춰 봅시다.'
    ],
    choices: [
      {
        id: 'align-commandery-records',
        label: '군현 기록을 맞춘다',
        response: '마량과 이적은 마을별 호칭과 길목 기록을 하나로 맞추었고, 무릉 준비의 빈칸이 줄어들었다.',
        rewardExp: 11
      },
      {
        id: 'prepare-mountain-letters',
        label: '산길 마을 서신을 준비한다',
        response: '두 문사는 무릉 산길 마을에 보낼 짧은 서신을 써 두었고, 다음 행군의 설득력이 깊어졌다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'sun-yi-after-guiyang',
    title: '외교 문안',
    availableAfterBattleIds: [campBattleIds.twentyFourth],
    unitIds: ['sun-qian', 'yi-ji'],
    bondId: 'sun-qian__yi-ji',
    rewardExp: 24,
    lines: [
      '손건: 저는 명분을 곧게 세우는 편이고, 이적 선생은 상대의 체면을 세우는 법을 아는군요.',
      '이적: 둘 중 하나만 있으면 문이 반쯤만 열립니다. 곧은 뜻과 부드러운 예가 함께 있어야 합니다.',
      '손건: 다음 군현으로 가는 서신은 둘이 함께 씁시다. 칼보다 먼저 도착해야 할 글입니다.'
    ],
    choices: [
      {
        id: 'write-joint-diplomacy-note',
        label: '공동 서신을 쓴다',
        response: '손건과 이적은 유비군의 뜻과 형주의 예를 함께 담은 서신을 만들었고, 군영의 외교 축이 넓어졌다.',
        rewardExp: 10
      },
      {
        id: 'sort-commandery-audience',
        label: '면담 순서를 정한다',
        response: '두 사람은 누구를 먼저 만나야 성문이 열릴지 순서를 세웠고, 다음 군현 접근이 조금 더 현실적인 계획이 되었다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-gong-after-wuling',
    title: '산길의 귀순',
    availableAfterBattleIds: [campBattleIds.twentyFifth],
    unitIds: ['liu-bei', 'gong-zhi'],
    bondId: 'liu-bei__gong-zhi',
    rewardExp: 28,
    lines: [
      '공지: 무릉의 길은 험하지만, 백성이 숨을 곳은 더 좁습니다. 주공께서는 그 길을 밟지 않고 열어 주셨습니다.',
      '유비: 그대가 산길과 마을을 아는 만큼, 백성의 두려움도 알고 있겠지. 앞으로 그 길을 함께 살피고 싶소.',
      '공지: 제가 아는 길을 바치겠습니다. 다만 무릉 사람들의 이름을 먼저 불러 주십시오.'
    ],
    choices: [
      {
        id: 'entrust-wuling-roads',
        label: '무릉 길 안내를 맡긴다',
        response: '공지는 무릉 산길의 갈림길과 창고 위치를 정리했고, 유비군의 행군 명단에 새 안내선이 생겼다.',
        rewardExp: 12
      },
      {
        id: 'promise-wuling-discipline',
        label: '군율을 다시 약속한다',
        response: '유비는 무릉 마을을 함부로 징발하지 않겠다고 말했고, 공지는 그 약속을 고을 사람들에게 전하겠다고 답했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'ma-gong-after-wuling',
    title: '무릉 기록',
    availableAfterBattleIds: [campBattleIds.twentyFifth],
    unitIds: ['ma-liang', 'gong-zhi'],
    bondId: 'ma-liang__gong-zhi',
    rewardExp: 26,
    lines: [
      '마량: 기록에는 길이 하나로 적혀도, 실제 산에는 세 갈래가 숨어 있더군요.',
      '공지: 맞습니다. 비가 오면 마을 사람도 다른 길을 씁니다. 무릉은 지도가 아니라 사람에게 길을 물어야 합니다.',
      '마량: 그렇다면 제 기록은 그 사람들의 이름까지 품어야겠습니다.'
    ],
    choices: [
      {
        id: 'revise-wuling-map',
        label: '무릉 지도를 고친다',
        response: '마량과 공지는 기존 지도에 숨은 우회로와 위험한 골짜기를 더했고, 다음 행군의 판단 근거가 두꺼워졌다.',
        rewardExp: 11
      },
      {
        id: 'record-mountain-clans',
        label: '산길 마을 이름을 적는다',
        response: '두 사람은 마을별 장로와 창고 위치를 기록했고, 무릉의 길은 점차 유비군의 말 안으로 들어왔다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'yi-gong-after-wuling',
    title: '예와 귀순',
    availableAfterBattleIds: [campBattleIds.twentyFifth],
    unitIds: ['yi-ji', 'gong-zhi'],
    bondId: 'yi-ji__gong-zhi',
    rewardExp: 24,
    lines: [
      '이적: 귀순은 무릎을 꿇는 일이 아니라, 다음날 마을이 문을 닫지 않게 하는 약속입니다.',
      '공지: 무릉 사람들은 말보다 행동을 봅니다. 약속을 글로 남기고, 창고를 지켜야 믿을 것입니다.',
      '이적: 그렇다면 공지 장군의 길과 제 문장이 함께 가야겠군요.'
    ],
    choices: [
      {
        id: 'write-wuling-pledge',
        label: '무릉 서약문을 쓴다',
        response: '이적은 공지의 말을 서약문으로 다듬었고, 무릉의 귀순은 전투 결과가 아니라 군영의 질서로 굳어졌다.',
        rewardExp: 10
      },
      {
        id: 'prepare-changsha-envoy',
        label: '장사 사절 예를 논한다',
        response: '두 사람은 장사로 보낼 예물과 인사 순서를 논했고, 황충을 함부로 대하지 않을 준비가 시작됐다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-huang-after-changsha',
    title: '노장을 예우하다',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    unitIds: ['liu-bei', 'huang-zhong'],
    bondId: 'liu-bei__huang-zhong',
    rewardExp: 30,
    lines: [
      '황충: 늙은 장수의 체면을 지켜 주신 일은 전장의 승리보다 오래 남을 것입니다.',
      '유비: 활은 나이를 묻지 않고 뜻을 묻는다고 들었소. 장군의 활이 백성을 지키는 데 쓰인다면 더 바랄 것이 없소.',
      '황충: 그 뜻이라면 제 활과 남은 세월을 맡기겠습니다.'
    ],
    choices: [
      {
        id: 'honor-veteran-bow',
        label: '노장의 활을 예우한다',
        response: '유비는 황충의 활과 전공을 병사들 앞에서 높였고, 노장은 조용히 고개를 숙였다.',
        rewardExp: 12
      },
      {
        id: 'ask-changsha-people',
        label: '장사 백성을 묻는다',
        response: '황충은 장사 성 안팎의 창고와 마을 사정을 차분히 들려주었고, 유비는 먼저 살필 곳을 정했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'guan-huang-after-changsha',
    title: '무인의 존중',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    unitIds: ['guan-yu', 'huang-zhong'],
    bondId: 'guan-yu__huang-zhong',
    rewardExp: 28,
    lines: [
      '관우: 장군의 활은 성루 위에서도 흔들리지 않았소. 칼을 든 몸으로도 그 기개를 알 수 있었소.',
      '황충: 운장 장군의 칼 역시 병사를 물러서게 하지 않았습니다. 나이는 달라도 무인의 길은 통하는군요.',
      '관우: 다음 전장에서는 서로의 거리와 호흡을 맞춰 봅시다.'
    ],
    choices: [
      {
        id: 'compare-front-and-bow',
        label: '전열과 활의 거리를 맞춘다',
        response: '관우와 황충은 전열이 버티는 거리와 활이 닿는 거리를 함께 맞추며 장사 병사들의 시선을 모았다.',
        rewardExp: 11
      },
      {
        id: 'share-warrior-courtesy',
        label: '무인의 예를 나눈다',
        response: '두 장수는 말보다 짧은 예로 서로를 인정했고, 군영의 새 긴장감은 단단한 신뢰로 바뀌기 시작했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-wei-after-changsha',
    title: '닫힌 문을 여는 창',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    unitIds: ['liu-bei', 'wei-yan'],
    bondId: 'liu-bei__wei-yan',
    rewardExp: 26,
    lines: [
      '위연: 저는 성문 안의 답답함을 참지 못했습니다. 주공의 군이라면 닫힌 문을 여는 데 제 창을 쓰겠습니다.',
      '유비: 거친 창도 향할 곳이 바르면 백성을 살리는 길이 되오. 다만 급한 마음이 먼저 나서지 않도록 함께 다듬읍시다.',
      '위연: 명령이 분명하다면, 제 창도 분명히 따르겠습니다.'
    ],
    choices: [
      {
        id: 'set-wei-yan-vanguard-rule',
        label: '돌파 규칙을 정한다',
        response: '유비는 위연에게 돌파 전에 반드시 백성의 길을 살피라 명했고, 위연은 거칠게나마 고개를 끄덕였다.',
        rewardExp: 10
      },
      {
        id: 'temper-wei-yan-ambition',
        label: '뜻을 먼저 묻는다',
        response: '위연은 공을 세우고 싶다는 마음을 숨기지 않았지만, 그 공이 누구를 위한 것인지 다시 생각하게 되었다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'huang-wei-after-changsha',
    title: '장사의 전환',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    unitIds: ['huang-zhong', 'wei-yan'],
    bondId: 'huang-zhong__wei-yan',
    rewardExp: 24,
    lines: [
      '황충: 성문이 열릴 때의 결단은 빠르더군. 다만 빠른 결단은 때로 먼저 상처를 남긴다.',
      '위연: 장군처럼 오래 참는 법은 아직 모릅니다. 하지만 닫힌 성문을 그대로 두는 것도 참기 어렵습니다.',
      '황충: 그러니 다음에는 내가 거리를 보고, 그대가 길을 열면 되겠군.'
    ],
    choices: [
      {
        id: 'pair-bow-and-breakthrough',
        label: '활과 돌파를 맞춘다',
        response: '황충은 위연이 뛰어들 거리의 위험을 짚었고, 위연은 노장의 활이 닿는 길을 익혔다.',
        rewardExp: 10
      },
      {
        id: 'settle-changsha-memory',
        label: '장사의 기억을 정리한다',
        response: '두 사람은 한현 아래에서 서로 달랐던 선택을 말했고, 이제 같은 군에서 다른 역할을 맡기로 했다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-pang-after-yizhou',
    title: '봉추가 보는 인의',
    availableAfterBattleIds: [campBattleIds.twentySeventh],
    unitIds: ['liu-bei', 'pang-tong'],
    bondId: 'liu-bei__pang-tong',
    rewardExp: 30,
    lines: [
      '방통: 원군로를 열면서도 마을 창고를 지킨 일은 예상보다 인상적이었습니다.',
      '유비: 큰 뜻이 백성을 짓밟고 지나가면, 그 뜻은 이미 비어 있는 것이오.',
      '방통: 그렇다면 그 빈틈 없는 뜻에 제가 조금 날카로운 길을 보태 보겠습니다.'
    ],
    choices: [
      {
        id: 'ask-pang-tong-yizhou-map',
        label: '익주 지세를 묻는다',
        response: '방통은 산문과 관문의 이름을 빠르게 짚으며, 유비군이 서두를 곳과 멈출 곳을 나누었다.',
        rewardExp: 12
      },
      {
        id: 'trust-pang-tong-boldness',
        label: '과감한 계책을 맡긴다',
        response: '유비가 방통의 과감함을 인정하자, 봉추의 눈빛은 한층 밝아졌다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'zhuge-pang-after-yizhou',
    title: '와룡과 봉추',
    availableAfterBattleIds: [campBattleIds.twentySeventh],
    unitIds: ['zhuge-liang', 'pang-tong'],
    bondId: 'zhuge-liang__pang-tong',
    rewardExp: 30,
    lines: [
      '제갈량: 익주는 산이 많아 큰 판만으로는 늦을 때가 있습니다.',
      '방통: 그래서 때로는 바른 길보다 빠른 틈을 먼저 보아야 하지요.',
      '제갈량: 큰 판과 빠른 틈이 함께 있으면, 군은 길을 잃지 않을 것입니다.'
    ],
    choices: [
      {
        id: 'divide-dragon-phoenix-plans',
        label: '큰 판과 기책을 나눈다',
        response: '제갈량은 전체 진군로를 맡고, 방통은 관문과 협곡을 흔드는 기책을 맡기로 했다.',
        rewardExp: 12
      },
      {
        id: 'compare-yizhou-people',
        label: '익주 사람을 논한다',
        response: '두 책사는 지도보다 먼저 사람의 마음을 살피기로 했고, 익주 공략의 색이 조금 더 깊어졌다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'huang-pang-after-yizhou',
    title: '노궁과 기책',
    availableAfterBattleIds: [campBattleIds.twentySeventh],
    unitIds: ['huang-zhong', 'pang-tong'],
    bondId: 'huang-zhong__pang-tong',
    rewardExp: 26,
    lines: [
      '방통: 노장군의 활은 멀리서도 전열의 숨을 끊을 수 있더군요.',
      '황충: 책사의 계책은 화살보다 먼저 날아간다 들었습니다. 어디를 겨눌지 알려 주시오.',
      '방통: 다음 관문에서는 활이 닿는 곳부터 적의 마음을 흔들어 보겠습니다.'
    ],
    choices: [
      {
        id: 'mark-bow-kill-zone',
        label: '활의 사거리를 표시한다',
        response: '방통은 산길의 사각을 표시했고, 황충은 그 표시를 따라 활이 닿을 거리를 익혔다.',
        rewardExp: 10
      },
      {
        id: 'prepare-feigned-retreat',
        label: '유인책을 준비한다',
        response: '황충은 물러서는 척하며 적을 끌어낼 거리와, 방통의 매복 신호를 함께 맞췄다.',
        rewardExp: 9
      }
    ]
  },
  {
    id: 'liu-fa-after-fupass',
    title: '익주 내부의 길',
    availableAfterBattleIds: [campBattleIds.twentyEighth],
    unitIds: ['liu-bei', 'fa-zheng'],
    bondId: 'liu-bei__fa-zheng',
    rewardExp: 30,
    lines: [
      '법정: 익주 사람들은 강한 군보다 약속을 지키는 군을 더 오래 기억합니다.',
      '유비: 그래서 관문 창고를 봉했소. 길을 열어도 백성의 밥그릇을 건드리면 마음은 닫히오.',
      '법정: 그렇다면 저는 그 마음이 어디서 열리고 닫히는지 알려 드리겠습니다.'
    ],
    choices: [
      {
        id: 'ask-fa-zheng-people',
        label: '익주 민심을 묻는다',
        response: '법정은 관문보다 먼저 설득해야 할 마을과 사람의 이름을 적었다.',
        rewardExp: 12
      },
      {
        id: 'promise-yizhou-discipline',
        label: '군율을 약속한다',
        response: '유비가 군율을 다시 못박자, 법정은 안쪽 길을 밝히겠다고 답했다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'pang-fa-after-fupass',
    title: '봉추와 내부책',
    availableAfterBattleIds: [campBattleIds.twentyEighth],
    unitIds: ['pang-tong', 'fa-zheng'],
    bondId: 'pang-tong__fa-zheng',
    rewardExp: 28,
    lines: [
      '방통: 안쪽 사정을 아는 사람이 있으니, 이제 문을 두드릴지 흔들지 결정하기 쉬워졌군요.',
      '법정: 흔들어야 할 문도 있고, 조용히 열어야 할 문도 있습니다. 순서를 틀리면 익주가 피로 답할 것입니다.',
      '방통: 그러면 그 순서를 당신이, 그 틈을 제가 보겠습니다.'
    ],
    choices: [
      {
        id: 'mark-hidden-gates',
        label: '숨은 문을 표시한다',
        response: '법정은 익주 내부의 숨은 길목을 표시했고, 방통은 그 길목을 흔드는 계책을 덧붙였다.',
        rewardExp: 11
      },
      {
        id: 'set-bold-and-soft-plan',
        label: '강온책을 나눈다',
        response: '두 사람은 강하게 압박할 곳과 조용히 설득할 곳을 분리했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'zhuge-fa-after-fupass',
    title: '큰 판과 속내',
    availableAfterBattleIds: [campBattleIds.twentyEighth],
    unitIds: ['zhuge-liang', 'fa-zheng'],
    bondId: 'zhuge-liang__fa-zheng',
    rewardExp: 28,
    lines: [
      '제갈량: 익주는 지세가 험하니 길을 얻어도 마음을 잃으면 오래 지키기 어렵습니다.',
      '법정: 그래서 저는 안쪽 사람들의 두려움과 불만을 함께 보려 합니다.',
      '제갈량: 큰 판에 그 속내를 더하면, 피를 줄이는 길도 보일 것입니다.'
    ],
    choices: [
      {
        id: 'combine-map-and-sentiment',
        label: '지도와 민심을 합친다',
        response: '제갈량의 지도 위에 법정의 사람 이름이 더해지자, 익주 공략의 다음 순서가 또렷해졌다.',
        rewardExp: 11
      },
      {
        id: 'prepare-luo-castle-letter',
        label: '낙성 서찰을 준비한다',
        response: '두 책사는 다음 성으로 보낼 글과 병력 배치의 순서를 함께 정했다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-wu-after-luo',
    title: '낙성의 새 길',
    availableAfterBattleIds: [campBattleIds.twentyNinth],
    unitIds: ['liu-bei', 'wu-yi'],
    bondId: 'liu-bei__wu-yi',
    rewardExp: 30,
    lines: [
      '오의: 성문을 지킨다는 명분으로 백성을 몰아넣는 일은 장수의 길이 아니었습니다.',
      '유비: 성문은 닫혀 있어도 사람의 마음은 열 수 있소. 그대가 길을 보여준다면 피를 덜 흘릴 수 있겠지.',
      '오의: 그렇다면 낙성의 길목과 성 안의 동요를 제가 살피겠습니다.'
    ],
    choices: [
      {
        id: 'promise-luo-discipline',
        label: '낙성 군율을 약속한다',
        response: '유비가 약탈 금지를 다시 못박자 오의는 익주 장수들을 설득할 명분이 생겼다고 고개를 숙였다.',
        rewardExp: 12
      },
      {
        id: 'ask-wu-yi-gate',
        label: '성문 방비를 묻는다',
        response: '오의는 낙성 외곽의 문루와 보급로를 짚으며 다음 출전에서 자신이 맡을 역할을 분명히 했다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'fa-wu-after-luo',
    title: '익주 내부 설득',
    availableAfterBattleIds: [campBattleIds.twentyNinth],
    unitIds: ['fa-zheng', 'wu-yi'],
    bondId: 'fa-zheng__wu-yi',
    rewardExp: 28,
    lines: [
      '법정: 익주의 장수들은 강한 군대보다 오래 지켜질 약속을 보고 움직입니다.',
      '오의: 저도 그 약속을 확인했으니, 이제 흔들리는 장수들에게 말할 수 있습니다.',
      '법정: 다음 전장에서는 누가 백성을 버렸고 누가 지켰는지부터 말합시다.'
    ],
    choices: [
      {
        id: 'write-yizhou-names',
        label: '설득할 장수를 적는다',
        response: '두 사람은 낙성 주변 장수들의 이름을 정리했고, 말이 통할 사람과 경계할 사람을 나누었다.',
        rewardExp: 10
      },
      {
        id: 'prepare-luo-message',
        label: '성 안에 보낼 말을 고른다',
        response: '법정은 문장을 다듬고 오의는 받을 사람을 정했다. 낙성의 문은 칼보다 먼저 말에 흔들리기 시작했다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'pang-wu-after-luo',
    title: '봉추와 성문 장수',
    availableAfterBattleIds: [campBattleIds.twentyNinth],
    unitIds: ['pang-tong', 'wu-yi'],
    bondId: 'pang-tong__wu-yi',
    rewardExp: 28,
    lines: [
      '방통: 낙성의 길은 곧아 보일수록 위험합니다. 사람을 믿되 길은 의심해야 하지요.',
      '오의: 장임은 좁은 길에서 기다리는 법을 압니다. 성 밖보다 성 안쪽 길이 더 날카롭습니다.',
      '방통: 그렇다면 다음 전장은 빠른 칼보다 빠른 눈이 먼저 필요하겠군요.'
    ],
    choices: [
      {
        id: 'mark-luofeng-risk',
        label: '낙봉파 위험을 표시한다',
        response: '방통은 오의의 설명을 지도에 겹쳐 놓고, 지나치게 좋은 길목에 붉은 표시를 남겼다.',
        rewardExp: 11
      },
      {
        id: 'prepare-decoy-column',
        label: '유인 부대를 준비한다',
        response: '오의는 성문 병력의 시선을 끌 수 있는 깃발과 행군 순서를 알려 주었고, 방통은 미소를 지었다.',
        rewardExp: 10
      }
    ]
  },
  {
    id: 'liu-pang-after-luofeng',
    title: '살아 돌아온 봉추',
    availableAfterBattleIds: [campBattleIds.thirtieth],
    unitIds: ['liu-bei', 'pang-tong'],
    bondId: 'liu-bei__pang-tong',
    rewardExp: 34,
    lines: [
      '유비: 낙봉파의 화살이 그대에게 닿을 뻔했소. 오늘 그대를 잃었다면 익주 길은 물론 내 마음도 크게 꺾였을 것이오.',
      '방통: 주공께서 길을 의심해 주셨기에 제가 길 위에서 목숨을 붙였습니다. 이제는 빠른 계책보다 오래 버틸 계책을 먼저 보겠습니다.',
      '유비: 그대의 말이 오늘 군영의 경계가 되었소. 다음 낙성 본성 앞에서도 그 눈을 믿겠소.'
    ],
    choices: [
      {
        id: 'thank-pang-tong-survival-luofeng',
        label: '방통의 생환을 기뻐한다',
        response: '유비가 직접 술잔을 낮추자 방통도 장난스러운 미소를 거두고 다음 전장의 방비도를 다시 펼쳤습니다.',
        rewardExp: 13
      },
      {
        id: 'ask-pang-tong-next-risk-luofeng',
        label: '다음 위험을 묻는다',
        response: '방통은 낙성 본성의 성문, 물길, 민심을 한 줄로 엮어 보이며 무리한 돌파를 피해야 한다고 답했습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'pang-wu-after-luofeng',
    title: '매복을 읽은 두 사람',
    availableAfterBattleIds: [campBattleIds.thirtieth],
    unitIds: ['pang-tong', 'wu-yi'],
    bondId: 'pang-tong__wu-yi',
    rewardExp: 32,
    lines: [
      '오의: 장임이 숨어들 만한 길을 말씀드렸을 뿐인데, 선생께서는 적의 숨소리까지 짚어 내셨습니다.',
      '방통: 길을 아는 사람과 마음을 읽는 사람이 같이 보면, 매복도 길 표지처럼 보이는 법이지요.',
      '오의: 다음 성문 앞에서도 제가 아는 익주의 길을 숨기지 않겠습니다.'
    ],
    choices: [
      {
        id: 'share-luofeng-slope-map',
        label: '낙봉파 지도를 보완한다',
        response: '오의가 놓친 샛길을 더하고 방통이 그 길마다 적의 시야를 적어 넣자, 낙성 진입로가 더 분명해졌습니다.',
        rewardExp: 12
      },
      {
        id: 'honor-wu-yi-guide',
        label: '오의의 안내를 치하한다',
        response: '방통이 오의의 공을 군영 앞에서 분명히 말하자, 새로 합류한 장수의 어깨에서 긴장이 조금 풀렸습니다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'zhuge-fa-after-luofeng',
    title: '낙성 본성의 문',
    availableAfterBattleIds: [campBattleIds.thirtieth],
    unitIds: ['zhuge-liang', 'fa-zheng'],
    bondId: 'zhuge-liang__fa-zheng',
    rewardExp: 32,
    lines: [
      '제갈량: 낙봉파의 매복을 깨뜨렸으니 장임은 성문 뒤에서 더 단단히 버틸 것입니다.',
      '법정: 성문은 군사만으로 열리지 않습니다. 익주의 장수들이 유비공을 두려워하지 않게 만드는 말도 함께 준비해야 합니다.',
      '제갈량: 그렇다면 다음 싸움은 성벽을 치는 전투이자 마음을 여는 전투가 되겠군요.'
    ],
    choices: [
      {
        id: 'prepare-luo-castle-parley',
        label: '항복 권고문을 준비한다',
        response: '두 책사가 문장을 다듬자 낙성 안쪽으로 보낼 글에는 위협보다 약속이 먼저 놓였습니다.',
        rewardExp: 12
      },
      {
        id: 'mark-main-gate-weakness',
        label: '성문 약점을 표시한다',
        response: '법정의 내부 정보와 제갈량의 배치도가 겹쳐지며, 다음 전장의 첫 돌파 지점이 좁혀졌습니다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'liu-yan-after-luomain',
    title: '노장의 항복',
    availableAfterBattleIds: [campBattleIds.thirtyFirst],
    unitIds: ['liu-bei', 'yan-yan'],
    bondId: 'liu-bei__yan-yan',
    rewardExp: 34,
    lines: [
      '엄안: 성문을 잃었으나 백성을 잃지 않았습니다. 패장은 목숨을 구걸하지 않지만, 그 뜻만은 보았습니다.',
      '유비: 노장의 절개를 꺾으려 온 것이 아니오. 익주의 백성과 장수들이 함께 살 길을 열고자 왔소.',
      '엄안: 그렇다면 이 활은 성벽 위에서가 아니라 유비공의 진영에서 백성을 지키겠습니다.'
    ],
    choices: [
      {
        id: 'honor-yan-yan-resolve',
        label: '엄안의 절개를 예우한다',
        response: '유비가 포박을 풀게 하자 엄안은 고개를 숙이고도 눈빛을 잃지 않았습니다.',
        rewardExp: 13
      },
      {
        id: 'ask-yan-yan-yizhou-road',
        label: '익주 안쪽 길을 묻는다',
        response: '엄안은 성도까지 이어지는 창고와 물길을 짚어 주며, 무리한 약탈을 피해야 한다고 조언했습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'huang-yan-after-luomain',
    title: '노장 쌍궁',
    availableAfterBattleIds: [campBattleIds.thirtyFirst],
    unitIds: ['huang-zhong', 'yan-yan'],
    bondId: 'huang-zhong__yan-yan',
    rewardExp: 32,
    lines: [
      '황충: 성벽 위 활이 흔들리지 않더군. 적으로 만났으나 노장의 손맛은 분명했습니다.',
      '엄안: 황 장군의 활도 고지의 바람을 거슬렀습니다. 이제 같은 편이라면 서로의 사각을 메울 수 있겠지요.',
      '황충: 다음 성벽은 함께 바라봅시다. 젊은 장수들에게도 노장의 활이 아직 살아 있음을 보여 줘야겠소.'
    ],
    choices: [
      {
        id: 'compare-old-bow-stance',
        label: '활 자세를 비교한다',
        response: '두 노장이 말없이 활시위를 당기자 군영 뒤편의 표적이 거의 동시에 흔들렸습니다.',
        rewardExp: 12
      },
      {
        id: 'plan-wall-archery-luomain',
        label: '성벽 사격을 연구한다',
        response: '황충과 엄안은 다음 전장의 고지와 성벽을 나눠 맡는 방법을 병사들에게 가르쳤습니다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'fa-wu-after-luomain',
    title: '성도 압박의 정보',
    availableAfterBattleIds: [campBattleIds.thirtyFirst],
    unitIds: ['fa-zheng', 'wu-yi'],
    bondId: 'fa-zheng__wu-yi',
    rewardExp: 32,
    lines: [
      '법정: 낙성이 열렸으니 유장의 마음도 흔들릴 것입니다. 하지만 성도는 겁만으로 열리지 않습니다.',
      '오의: 익주 장수들은 유비공이 약속을 지키는지 보고 있습니다. 엄안의 합류가 그 증거가 될 수 있습니다.',
      '법정: 그렇다면 다음 길은 성문보다 말문을 먼저 열어야겠군요.'
    ],
    choices: [
      {
        id: 'write-chengdu-pressure-letter',
        label: '성도에 보낼 글을 쓴다',
        response: '법정은 위협 대신 약속을 앞세우고, 오의는 익주 장수들이 믿을 만한 이름들을 덧붙였습니다.',
        rewardExp: 12
      },
      {
        id: 'mark-inner-yizhou-route',
        label: '익주 안쪽 길을 표시한다',
        response: '오의가 군량 길을 긋고 법정이 사람의 마음길을 덧대자, 성도 압박의 첫 윤곽이 잡혔습니다.',
        rewardExp: 11
      }
    ]
  },
  {
    id: 'liu-li-after-mianzhu',
    title: '성도 앞의 군율',
    availableAfterBattleIds: [campBattleIds.thirtySecond],
    unitIds: ['liu-bei', 'li-yan'],
    bondId: 'liu-bei__li-yan',
    rewardExp: 36,
    lines: [
      '이엄: 면죽관을 내주었으나, 성도 앞의 군율까지 무너지면 익주는 오래 앙금을 품을 것입니다.',
      '유비: 항복한 장수의 말이라도 백성을 살리는 말이면 곧 군령이오. 그대가 익주 병사의 마음을 잡아 주시오.',
      '이엄: 그렇다면 창고와 관문을 맡아, 항복한 병사도 약탈을 두려워하지 않게 하겠습니다.'
    ],
    choices: [
      {
        id: 'trust-li-yan-discipline',
        label: '군율 정비를 맡긴다',
        response: '이엄은 면죽관 창고 명부를 다시 정리하고, 항복 병사에게도 같은 배급 규칙을 적용했습니다.',
        rewardExp: 14
      },
      {
        id: 'ask-li-yan-chengdu-order',
        label: '성도 질서를 묻는다',
        response: '이엄은 성도 안쪽 창고, 문지기, 장수들의 이해관계를 짚어 다음 권고문의 근거를 보탰습니다.',
        rewardExp: 13
      }
    ]
  },
  {
    id: 'zhuge-li-after-mianzhu',
    title: '내정과 책략',
    availableAfterBattleIds: [campBattleIds.thirtySecond],
    unitIds: ['zhuge-liang', 'li-yan'],
    bondId: 'zhuge-liang__li-yan',
    rewardExp: 34,
    lines: [
      '제갈량: 성을 얻는 일보다 얻은 뒤의 질서가 더 어렵습니다. 이엄 장군은 그 무게를 알고 있군요.',
      '이엄: 저는 관문을 막는 법은 알지만, 사람 마음을 여는 법은 아직 배워야 합니다.',
      '제갈량: 그렇다면 관문의 숫자와 사람의 사정을 함께 보지요. 다음 싸움은 이미 싸움 뒤의 정치와 맞닿아 있습니다.'
    ],
    choices: [
      {
        id: 'map-chengdu-storehouses',
        label: '성도 창고도를 맞춘다',
        response: '제갈량의 큰 지도 위에 이엄의 창고 명부가 겹치며, 성도 항복 뒤 혼란을 줄일 길이 보였습니다.',
        rewardExp: 13
      },
      {
        id: 'write-surrender-terms',
        label: '항복 조건을 다듬는다',
        response: '두 사람은 유장에게 보낼 문장에 처벌보다 안정을 먼저 적었습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'yan-li-after-mianzhu',
    title: '익주 장수의 결단',
    availableAfterBattleIds: [campBattleIds.thirtySecond],
    unitIds: ['yan-yan', 'li-yan'],
    bondId: 'yan-yan__li-yan',
    rewardExp: 34,
    lines: [
      '엄안: 나도 성문 위에서 끝까지 버티려 했소. 그러나 백성이 살아야 장수의 절개도 남는 법이오.',
      '이엄: 노장께서 먼저 길을 여셨기에 저도 검을 내려놓을 수 있었습니다.',
      '엄안: 이제는 우리가 익주 장수들에게 말해야 하오. 항복이 부끄러움이 아니라 백성을 살리는 선택이 될 수 있다고.'
    ],
    choices: [
      {
        id: 'share-yizhou-officer-names',
        label: '익주 장수 명단을 맞춘다',
        response: '엄안과 이엄은 끝까지 버틸 장수와 설득 가능한 장수를 나누어, 다음 전장의 말길을 열었습니다.',
        rewardExp: 13
      },
      {
        id: 'honor-yizhou-soldiers',
        label: '항복 병사를 예우한다',
        response: '두 장수가 함께 병사들을 달래자, 익주 병사들은 유비군 군영을 조금 덜 두려워하게 되었습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'liu-huang-after-chengdu',
    title: '항복과 보전',
    availableAfterBattleIds: [campBattleIds.thirtyThird],
    unitIds: ['liu-bei', 'huang-quan'],
    bondId: 'liu-bei__huang-quan',
    rewardExp: 38,
    lines: [
      '황권: 패배한 장수에게 가장 두려운 것은 처벌보다, 백성이 새 질서 아래에서 버려지는 일입니다.',
      '유비: 그 두려움을 알기에 그대를 맞이하오. 익주의 사람을 익주의 길로 안정시키는 일에 힘을 빌려 주시오.',
      '황권: 그렇다면 저는 성도 안의 창고와 관직 명단을 정리해, 항복이 혼란으로 번지지 않게 하겠습니다.'
    ],
    choices: [
      {
        id: 'entrust-chengdu-order',
        label: '성도 질서를 맡긴다',
        response: '황권은 항복한 관료와 병사들의 이름을 정리하며, 유비군의 명령이 보복이 아니라 질서임을 보였습니다.',
        rewardExp: 14
      },
      {
        id: 'promise-yizhou-protection',
        label: '익주 보전을 약속한다',
        response: '유비의 약속이 군영 안에 전해지자 황권은 처음으로 긴 숨을 내쉬고 고개를 숙였습니다.',
        rewardExp: 13
      }
    ]
  },
  {
    id: 'zhuge-huang-after-chengdu',
    title: '성도 수습책',
    availableAfterBattleIds: [campBattleIds.thirtyThird],
    unitIds: ['zhuge-liang', 'huang-quan'],
    bondId: 'zhuge-liang__huang-quan',
    rewardExp: 36,
    lines: [
      '제갈량: 성도는 얻었으나 아직 다스린 것은 아닙니다. 관문보다 장부가 더 오래 남는 법이지요.',
      '황권: 익주의 장부에는 사람의 두려움도 함께 적혀 있습니다. 급히 고치면 반발만 커질 것입니다.',
      '제갈량: 그래서 그대의 신중함이 필요합니다. 새 나라는 칼보다 제도 위에 오래 서야 합니다.'
    ],
    choices: [
      {
        id: 'sort-yizhou-ledgers',
        label: '익주 장부를 정리한다',
        response: '제갈량과 황권은 군량, 관직, 백성 명부를 나누어 보며 다음 장의 내정 과제를 분명히 했습니다.',
        rewardExp: 13
      },
      {
        id: 'draft-shu-foundation-policy',
        label: '건국 준비안을 쓴다',
        response: '두 사람은 아직 나라 이름을 말하지 않았지만, 이미 나라가 버틸 기둥을 하나씩 적기 시작했습니다.',
        rewardExp: 13
      }
    ]
  },
  {
    id: 'fa-huang-after-chengdu',
    title: '익주 책사들의 균형',
    availableAfterBattleIds: [campBattleIds.thirtyThird],
    unitIds: ['fa-zheng', 'huang-quan'],
    bondId: 'fa-zheng__huang-quan',
    rewardExp: 35,
    lines: [
      '법정: 성도가 열렸으니 이제 빠르게 마음을 잡아야 합니다. 머뭇거리면 오래된 의심이 다시 살아납니다.',
      '황권: 빠름이 항상 안정은 아닙니다. 익주 사람은 강제로 바뀌는 것을 오래 기억합니다.',
      '법정: 좋습니다. 저는 흔들 곳을 짚고, 그대는 무너지지 않게 받치십시오.'
    ],
    choices: [
      {
        id: 'divide-policy-tempo',
        label: '정책 속도를 나눈다',
        response: '법정은 급히 처리할 군사 문제를, 황권은 천천히 설득할 내정 문제를 맡아 균형을 잡았습니다.',
        rewardExp: 13
      },
      {
        id: 'appease-yizhou-officers',
        label: '익주 장수를 달랜다',
        response: '두 책사는 항복 장수들의 자리를 함부로 흔들지 않는 대신, 군율 위반은 단호히 다루기로 했습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'liu-ma-after-jiameng',
    title: '떠도는 창과 새 깃발',
    availableAfterBattleIds: [campBattleIds.thirtyFourth],
    unitIds: ['liu-bei', 'ma-chao'],
    bondId: 'liu-bei__ma-chao',
    rewardExp: 40,
    lines: [
      '마초: 저는 조조를 미워했고, 장로에게 기대었으나 마음 둘 곳을 찾지 못했습니다.',
      '유비: 떠돌던 창도 지킬 백성과 뜻을 만나면 깃발 아래 설 수 있소. 그대의 용맹을 사람을 살리는 데 쓰시오.',
      '마초: 그렇다면 서량의 창은 더는 원한만 좇지 않겠습니다. 유비공의 깃발 아래에서 북쪽 길을 열겠습니다.'
    ],
    choices: [
      {
        id: 'accept-ma-chao-oath',
        label: '마초의 맹세를 받아들인다',
        response: '유비가 직접 마초의 창을 들어 올리자, 서량 기병들은 새 깃발 앞에서 고개를 숙였습니다.',
        rewardExp: 15
      },
      {
        id: 'ask-ma-chao-hanzhong-road',
        label: '한중 길을 묻는다',
        response: '마초는 장로군의 관문, 기병이 달릴 수 있는 능선, 조조군의 감시 지점을 지도에 표시했습니다.',
        rewardExp: 14
      }
    ]
  },
  {
    id: 'zhao-ma-after-jiameng',
    title: '백마와 금마초',
    availableAfterBattleIds: [campBattleIds.thirtyFourth],
    unitIds: ['zhao-yun', 'ma-chao'],
    bondId: 'zhao-yun__ma-chao',
    rewardExp: 36,
    lines: [
      '조운: 서량 기병의 돌격은 빠르나, 빠른 창일수록 돌아올 길도 보아야 합니다.',
      '마초: 백마 장군의 말은 조용하지만 빈틈이 없군. 내 창은 때로 너무 멀리 나갔습니다.',
      '조운: 다음 전장에서는 서로의 말발굽이 너무 멀어지지 않게 합시다.'
    ],
    choices: [
      {
        id: 'practice-cavalry-turn',
        label: '기병 회전을 연습한다',
        response: '조운의 절제된 말놀림과 마초의 폭발적인 돌격이 이어지며, 기병대의 움직임이 한층 깊어졌습니다.',
        rewardExp: 13
      },
      {
        id: 'share-scouting-pace',
        label: '정찰 속도를 맞춘다',
        response: '두 장수는 빠른 돌파와 안전한 귀환의 간격을 맞추며 한중 전선의 정찰 기준을 세웠습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'zhang-ma-after-jiameng',
    title: '호통과 돌격',
    availableAfterBattleIds: [campBattleIds.thirtyFourth],
    unitIds: ['zhang-fei', 'ma-chao'],
    bondId: 'zhang-fei__ma-chao',
    rewardExp: 36,
    lines: [
      '장비: 하하! 네놈 창끝이 제법 매섭더군. 그래도 내 앞에서는 한 번 멈췄지 않느냐!',
      '마초: 장비 장군의 호통도 말발굽을 흔들 만큼 거칠었습니다. 다음에는 같은 방향으로 흔들어 보지요.',
      '장비: 좋다! 누가 먼저 적진을 갈라 놓는지 겨뤄 보자.'
    ],
    choices: [
      {
        id: 'promise-front-duel',
        label: '전열 돌파를 겨룬다',
        response: '장비와 마초가 웃으며 창을 맞대자, 병사들은 다음 전열 돌파가 조금 두려우면서도 기대된다고 수군거렸습니다.',
        rewardExp: 13
      },
      {
        id: 'split-shock-roles',
        label: '돌격 역할을 나눈다',
        response: '장비는 좁은 길을 부수고, 마초는 열린 측면을 파고드는 방식으로 서로의 돌격을 나누었습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'ma-chao-dai-after-yangping',
    title: '서량의 두 창',
    availableAfterBattleIds: [campBattleIds.thirtyFifth],
    unitIds: ['ma-chao', 'ma-dai'],
    bondId: 'ma-chao__ma-dai',
    rewardExp: 40,
    lines: [
      '마대: 형님, 흩어진 서량 기병을 데려왔습니다. 이제는 원한이 아니라 새 깃발을 보고 달리게 하겠습니다.',
      '마초: 잘 왔다. 내가 너무 앞서 달리면 네가 뒤를 거두어라. 서량의 말발굽이 이번에는 흩어지지 않아야 한다.',
      '마대: 명심하겠습니다. 형님이 길을 열면 저는 그 길이 무너지지 않게 지키겠습니다.'
    ],
    choices: [
      {
        id: 'bind-western-cavalry',
        label: '서량 기병을 묶는다',
        response: '마초와 마대가 함께 기병 명단을 정리하자, 흩어진 서량 병사들이 하나의 대오로 서기 시작했습니다.',
        rewardExp: 15
      },
      {
        id: 'divide-cavalry-charge',
        label: '돌격과 회수를 나눈다',
        response: '마초는 전열 돌파를, 마대는 후속 회수를 맡기로 하며 서량 기병의 움직임이 한층 안정되었습니다.',
        rewardExp: 14
      }
    ]
  },
  {
    id: 'liu-ma-dai-after-yangping',
    title: '새 깃발의 기병',
    availableAfterBattleIds: [campBattleIds.thirtyFifth],
    unitIds: ['liu-bei', 'ma-dai'],
    bondId: 'liu-bei__ma-dai',
    rewardExp: 34,
    lines: [
      '유비: 그대는 마초를 따라왔으나, 이제는 백성과 병사를 지키는 길도 함께 보아야 하오.',
      '마대: 서량의 병사는 빠르게 달리는 법은 알지만, 오래 머무는 법은 서툽니다. 유비공의 군율을 배우겠습니다.',
      '유비: 빠른 말도 돌아올 곳이 있어야 힘을 쓰는 법이오. 우리 깃발 아래에서 그 자리를 찾으시오.'
    ],
    choices: [
      {
        id: 'accept-ma-dai-oath',
        label: '마대의 맹세를 받는다',
        response: '마대가 창을 세워 예를 올리자, 유비군의 장수들은 새 기병장의 이름을 출전 명단에 적었습니다.',
        rewardExp: 13
      },
      {
        id: 'teach-camp-discipline',
        label: '군영 군율을 일러준다',
        response: '마대는 서량 기병에게 약탈 금지와 보급 질서를 먼저 전하며, 새 군영에 맞춰 움직이기 시작했습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'zhao-ma-dai-after-yangping',
    title: '침착한 말발굽',
    availableAfterBattleIds: [campBattleIds.thirtyFifth],
    unitIds: ['zhao-yun', 'ma-dai'],
    bondId: 'zhao-yun__ma-dai',
    rewardExp: 32,
    lines: [
      '조운: 마초 장군의 창이 앞을 열면, 그 뒤의 길은 누군가 차분히 지켜야 합니다.',
      '마대: 제가 그 일을 맡겠습니다. 형님의 기세를 살리되, 병사가 흩어지지 않도록 하겠습니다.',
      '조운: 좋습니다. 빠른 말발굽에도 조용한 박자가 있어야 오래 싸울 수 있습니다.'
    ],
    choices: [
      {
        id: 'practice-follow-up-line',
        label: '후속 대열을 연습한다',
        response: '조운과 마대는 돌격 뒤 흩어진 병사를 다시 모으는 훈련을 반복하며 기병대의 안정감을 높였습니다.',
        rewardExp: 12
      },
      {
        id: 'mark-safe-return',
        label: '귀환로를 표시한다',
        response: '두 장수는 양평관 지도에 돌파로와 귀환로를 함께 표시해 다음 한중 전투의 위험을 줄였습니다.',
        rewardExp: 12
      }
    ]
  },
  {
    id: 'huang-wang-after-dingjun',
    title: '노장과 산길',
    availableAfterBattleIds: [campBattleIds.thirtySixth],
    unitIds: ['huang-zhong', 'wang-ping'],
    bondId: 'huang-zhong__wang-ping',
    rewardExp: 38,
    lines: [
      '왕평: 정군산의 길은 곧게 오르는 길보다 돌아가는 길이 더 빠를 때가 많습니다.',
      '황충: 흥, 늙은 다리는 돌아가는 길을 싫어하지만 전장은 고집으로 이기는 곳이 아니지.',
      '왕평: 장군의 칼이 닿을 순간을 만들 수 있다면, 산길은 제게 맡겨 주십시오.'
    ],
    choices: [
      {
        id: 'trust-mountain-route',
        label: '산길을 맡긴다',
        response: '황충이 왕평의 지형 판단을 받아들이자, 노장의 돌파는 무모함이 아니라 계산된 일격으로 바뀌었습니다.',
        rewardExp: 14
      },
      {
        id: 'prepare-ridge-ambush',
        label: '능선 매복을 준비한다',
        response: '왕평이 숲길과 절벽 사이의 매복 지점을 표시했고, 황충은 그곳에서 칼을 뽑을 순간을 기다리기로 했습니다.',
        rewardExp: 13
      }
    ]
  },
  {
    id: 'zhuge-wang-after-dingjun',
    title: '한중 지형책',
    availableAfterBattleIds: [campBattleIds.thirtySixth],
    unitIds: ['zhuge-liang', 'wang-ping'],
    bondId: 'zhuge-liang__wang-ping',
    rewardExp: 36,
    lines: [
      '제갈량: 지도에는 산이 선으로 그려지지만, 병사는 그 선 사이에서 지치고 헤맵니다.',
      '왕평: 그래서 지도만 보는 장수는 한중을 오래 품지 못합니다. 물길, 숲길, 겨울 길이 모두 다릅니다.',
      '제갈량: 그대의 눈이 필요합니다. 한중을 얻는 것보다 지키는 일이 더 길 테니까요.'
    ],
    choices: [
      {
        id: 'write-hanzhong-terrain-book',
        label: '한중 지형책을 쓴다',
        response: '제갈량과 왕평은 정군산, 양평관, 가맹관의 길을 하나의 지형책으로 묶기 시작했습니다.',
        rewardExp: 13
      },
      {
        id: 'mark-winter-roads',
        label: '겨울 행군로를 표시한다',
        response: '왕평은 눈이 오면 막히는 길과 오히려 단단해지는 강변 길을 지도에 따로 표시했습니다.',
        rewardExp: 13
      }
    ]
  },
  {
    id: 'ma-dai-wang-after-dingjun',
    title: '기병과 산길잡이',
    availableAfterBattleIds: [campBattleIds.thirtySixth],
    unitIds: ['ma-dai', 'wang-ping'],
    bondId: 'ma-dai__wang-ping',
    rewardExp: 34,
    lines: [
      '마대: 말이 달릴 수 없는 길에서도 기병은 쓸모가 있습니까?',
      '왕평: 달리지 못하면 지키면 됩니다. 좁은 능선에서는 빠른 돌격보다 정확한 퇴로가 더 중요합니다.',
      '마대: 그럼 제가 지킬 길을 알려 주십시오. 형님의 돌격이 돌아올 길을 잃지 않게 하겠습니다.'
    ],
    choices: [
      {
        id: 'map-cavalry-return',
        label: '기병 귀환로를 그린다',
        response: '왕평과 마대가 능선의 귀환로를 정리하자, 서량 기병은 좁은 산길에서도 무리하게 흩어지지 않게 되었습니다.',
        rewardExp: 12
      },
      {
        id: 'train-dismount-guard',
        label: '하마 방어를 훈련한다',
        response: '마대는 말에서 내려 창진을 세우는 훈련을 받아, 산악 전투에서 기병의 빈틈을 줄였습니다.',
        rewardExp: 12
      }
    ]
  }
];

const campVisits: CampVisitDefinition[] = [
  {
    id: 'jingzhou-scholar-rumors',
    title: '형주 선비들의 모임',
    location: '신야 객사',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    bondId: 'liu-bei__sun-qian',
    description: '형주의 선비들이 모이는 객사에서 와룡과 봉추에 관한 소문을 모읍니다.',
    lines: [
      '손건: 형주의 선비들은 겉으로는 술과 시를 말하지만, 속으로는 천하의 흐름을 재고 있습니다.',
      '유비: 그들의 말을 듣되 우리 뜻을 팔지는 말아야 하오. 와룡이라는 이름이 참이라면, 예로 찾아가야 하겠지.',
      '객사의 선비들은 유비군이 오래 머물 손님인지, 천하를 논할 사람인지 조심스럽게 살핍니다.'
    ],
    choices: [
      {
        id: 'ask-about-wolong',
        label: '와룡의 거처를 묻는다',
        response: '손건은 무리하게 캐묻지 않고 융중 근처의 초가와 제갈씨 집안에 관한 소문을 얻어 냈다.',
        bondExp: 18,
        itemRewards: ['와룡 소문 1']
      },
      {
        id: 'share-liu-bei-intent',
        label: '유비의 뜻을 조용히 전한다',
        response: '손건은 유비가 형주의 땅보다 사람을 먼저 찾는다는 뜻을 전했고, 선비들의 경계가 조금 누그러졌다.',
        bondExp: 20,
        gold: 80
      }
    ]
  },
  {
    id: 'xinye-market-supplies',
    title: '신야 장터 정비',
    location: '신야 장터',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    bondId: 'liu-bei__mi-zhu',
    description: '피난민과 상인이 뒤섞인 장터에서 다음 여정을 위한 군량과 약을 챙깁니다.',
    lines: [
      '미축: 형주의 장터는 물자가 많지만 값도 빠르게 오릅니다. 지금 무엇을 먼저 챙길지 정해야 합니다.',
      '유비: 백성의 몫을 빼앗지 않는 선에서 군을 살릴 물자를 구하시오. 다음 길은 길어질 것이오.',
      '상인들은 유비군의 명분을 믿고 값을 낮출지, 낯선 객군이라며 셈을 올릴지 눈치를 봅니다.'
    ],
    choices: [
      {
        id: 'buy-field-medicine',
        label: '상처약을 먼저 구한다',
        response: '미축은 좋은 약재를 골라 상처약을 더 확보했고, 병사들은 긴 행군에 조금 안심했다.',
        bondExp: 12,
        itemRewards: ['상처약 2']
      },
      {
        id: 'stock-camp-grain',
        label: '군량을 넉넉히 산다',
        response: '미축은 콩과 마른 양식을 챙기며 군자금을 아꼈고, 남은 돈으로 다음 협상 여지도 마련했다.',
        bondExp: 10,
        gold: 120,
        itemRewards: ['콩 2']
      }
    ]
  },
  {
    id: 'refugee-village-patrol',
    title: '피난민 마을 순행',
    location: '형주 북쪽 마을',
    availableAfterBattleIds: [campBattleIds.sixteenth],
    bondId: 'liu-bei__zhao-yun',
    description: '조운과 함께 형주 북쪽 마을을 돌아보며 민심과 길목의 위험을 살핍니다.',
    lines: [
      '조운: 조조군을 피해 내려온 백성들이 마을 바깥에 모여 있습니다. 관문은 열렸지만 마음은 아직 닫혀 있습니다.',
      '유비: 의탁한 몸이라도 백성을 외면할 수는 없소. 우리가 머무는 동안 폐가 아니라 힘이 되게 합시다.',
      '피난민들은 유비의 이름을 듣고도 쉽게 다가오지 못하지만, 조운의 차분한 호위에 조금씩 안정을 찾습니다.'
    ],
    choices: [
      {
        id: 'guard-refugee-road',
        label: '피난길을 호위한다',
        response: '조운은 피난민 행렬을 안전한 길로 이끌었고, 유비군의 깃발은 형주 북쪽 마을에 좋은 소문으로 남았다.',
        bondExp: 22,
        itemRewards: ['민심 기록 1']
      },
      {
        id: 'listen-to-village-elders',
        label: '마을 어른들의 말을 듣는다',
        response: '유비는 마을 어른들의 걱정을 오래 들었고, 조운은 다음 행군에서 피해야 할 샛길을 지도에 표시했다.',
        bondExp: 18,
        itemRewards: ['탁주 1']
      }
    ]
  },
  {
    id: 'longzhong-cottage-records',
    title: '융중 초가 정리',
    location: '융중 초가',
    availableAfterBattleIds: [campBattleIds.seventeenth],
    bondId: 'liu-bei__zhuge-liang',
    description: '제갈량과 함께 초가의 지도와 서책을 정리하며 다음 큰 흐름을 준비합니다.',
    lines: [
      '제갈량: 이 지도들은 아직 군영의 전장 지도가 아니라 천하의 형세입니다. 그러나 전장은 늘 여기서 시작됩니다.',
      '유비: 우리 군이 지금 가진 것은 많지 않소. 그래서 더더욱 무엇을 먼저 지킬지 알아야 하오.',
      '초가의 낮은 책상 위에는 형주와 강동, 북방의 길이 하나씩 펼쳐집니다.'
    ],
    choices: [
      {
        id: 'study-red-cliff-winds',
        label: '강동의 바람을 살핀다',
        response: '제갈량은 강동과 조조군의 충돌 가능성을 짚었고, 유비군은 다음 큰 전장의 이름을 조심스럽게 떠올렸다.',
        bondExp: 24,
        itemRewards: ['강동 풍문 1']
      },
      {
        id: 'organize-wolong-scrolls',
        label: '와룡의 서책을 정리한다',
        response: '손상되기 쉬운 서책을 군영으로 옮기며 제갈량의 계책을 병사들도 이해할 수 있는 말로 옮기기 시작했다.',
        bondExp: 20,
        itemRewards: ['와룡의 서찰 1']
      }
    ]
  },
  {
    id: 'xinye-merchant-after-wolong',
    title: '신야 상인 접견',
    location: '신야 장터',
    availableAfterBattleIds: [campBattleIds.seventeenth],
    bondId: 'liu-bei__mi-zhu',
    description: '제갈량 합류 소문을 듣고 찾아온 상인들과 다음 전투를 위한 보급 조건을 조율합니다.',
    lines: [
      '미축: 와룡이 주공의 군영에 들었다는 말이 퍼지자 상인들의 셈도 달라졌습니다. 지금 보급선을 고정할 기회입니다.',
      '유비: 값이 싸다고 무리하게 사들이지는 마시오. 백성의 삶을 해치지 않는 선에서 군을 세워야 하오.',
      '상인들은 유비군의 앞날에 걸지, 당장의 이익만 챙길지 서로 눈치를 봅니다.'
    ],
    choices: [
      {
        id: 'secure-medicine-contract',
        label: '약재 계약을 맺는다',
        response: '미축은 오래 보관할 수 있는 약재 계약을 맺었고, 군영의 치료 물자가 안정되었다.',
        bondExp: 14,
        itemRewards: ['상처약 2']
      },
      {
        id: 'preserve-war-funds',
        label: '군자금을 아낀다',
        response: '미축은 급하지 않은 거래를 미루고 군자금을 보존했다. 병사들은 검소한 군영의 기준을 이해했다.',
        bondExp: 12,
        gold: 160,
        itemRewards: ['콩 2']
      }
    ]
  },
  {
    id: 'bowang-battlefield-inspection',
    title: '박망파 전장 점검',
    location: '박망파 숲길',
    availableAfterBattleIds: [campBattleIds.eighteenth],
    bondId: 'liu-bei__zhuge-liang',
    description: '박망파의 불길이 지나간 숲길을 살피며 다음 조조군 남하에 대비합니다.',
    lines: [
      '제갈량: 불길은 적의 발을 묶었지만, 다음에는 더 큰 군세가 올 것입니다. 길목마다 피난로와 매복지를 함께 살펴야 합니다.',
      '유비: 백성이 지나갈 길과 군이 버틸 길을 따로 표시해 두시오. 승리보다 중요한 것은 사람을 잃지 않는 것이오.',
      '병사들은 탄 흔적을 지우며, 이 작은 승리가 더 큰 전장의 시작임을 느낍니다.'
    ],
    choices: [
      {
        id: 'mark-refugee-routes',
        label: '피난로를 표시한다',
        response: '제갈량은 백성이 지나갈 길을 군의 이동로와 분리해 지도에 적었고, 유비군의 다음 퇴각 준비가 빨라졌다.',
        bondExp: 22,
        itemRewards: ['피난로 지도 1']
      },
      {
        id: 'collect-ambush-lessons',
        label: '매복 기록을 정리한다',
        response: '박망파의 매복 순서를 다시 기록하자, 다음 전장에서 책략 명령을 더 빨리 전달할 근거가 마련되었다.',
        bondExp: 20,
        itemRewards: ['와룡의 서찰 1']
      }
    ]
  },
  {
    id: 'jiangdong-letter-prep',
    title: '강동 서신 준비',
    location: '신야 객사',
    availableAfterBattleIds: [campBattleIds.eighteenth],
    bondId: 'sun-qian__zhuge-liang',
    description: '강동에 보낼 첫 서신과 조조군 남하 정보를 정리합니다.',
    lines: [
      '손건: 강동은 말만으로 움직이지 않을 것입니다. 조조군이 남하한다는 증거와 주공이 버티는 이유를 함께 담아야 합니다.',
      '제갈량: 맞습니다. 손권에게 필요한 것은 두려움만이 아니라 싸울 수 있다는 계산입니다.',
      '서신의 문장은 짧아지고, 지도 위에는 조조군의 길과 강동의 바람이 함께 표시됩니다.'
    ],
    choices: [
      {
        id: 'send-cao-threat-report',
        label: '조조 남하 보고를 쓴다',
        response: '손건은 박망파의 전황과 조조군의 움직임을 정리했고, 강동 사절의 첫 명분이 분명해졌다.',
        bondExp: 18,
        itemRewards: ['강동 풍문 1']
      },
      {
        id: 'reserve-envoy-funds',
        label: '사절 군자금을 남긴다',
        response: '불필요한 지출을 줄여 강동으로 보낼 사절 비용을 따로 묶어 두었다.',
        bondExp: 14,
        gold: 180,
        itemRewards: ['탁주 1']
      }
    ]
  },
  {
    id: 'changban-refugee-camp',
    title: '장판파 피난민 수습',
    location: '남쪽 피난 진영',
    availableAfterBattleIds: [campBattleIds.nineteenth],
    bondId: 'liu-bei__zhao-yun',
    description: '장판파를 벗어난 피난민을 돌보며 흩어진 병사와 민심을 다시 모읍니다.',
    lines: [
      '조운: 피난민 중에는 가족을 잃고 주저앉은 사람도 많습니다. 군이 먼저 손을 내밀어야 길이 이어집니다.',
      '유비: 우리가 지킨 길이 사람을 버리는 길이 되어서는 안 되오. 군량을 나누되, 다음 행군도 버틸 수 있게 살피시오.',
      '남쪽 진영에는 먼지와 상처가 가득하지만, 유비군의 깃발 아래 다시 줄이 만들어집니다.'
    ],
    choices: [
      {
        id: 'share-field-medicine',
        label: '상처 입은 피난민을 돌본다',
        response: '조운은 약을 나누며 질서를 세웠고, 피난민들은 유비군이 자신들을 버리지 않았음을 기억했다.',
        bondExp: 22,
        itemRewards: ['민심 기록 1']
      },
      {
        id: 'gather-scattered-soldiers',
        label: '흩어진 병사를 모은다',
        response: '장판파에서 흩어진 병사들이 하나둘 돌아왔고, 군영은 다음 이동을 버틸 힘을 되찾았다.',
        bondExp: 18,
        gold: 140,
        itemRewards: ['콩 2']
      }
    ]
  },
  {
    id: 'jiangdong-envoy-after-changban',
    title: '강동 사절 문서',
    location: '피난 진영 막사',
    availableAfterBattleIds: [campBattleIds.nineteenth],
    bondId: 'sun-qian__zhuge-liang',
    description: '제갈량과 손건이 장판파 전황을 강동에 보낼 문서로 정리합니다.',
    lines: [
      '손건: 장판파의 일은 처참하지만, 강동이 조조의 남하를 실감할 증거이기도 합니다.',
      '제갈량: 두려움만 적으면 사람은 굳고, 계산만 적으면 마음이 움직이지 않습니다. 주공이 지킨 길을 함께 써야 합니다.',
      '작은 막사 안에서 피난로 지도와 조조군의 진격 보고가 하나의 문서로 묶입니다.'
    ],
    choices: [
      {
        id: 'write-sun-quan-brief',
        label: '손권에게 보낼 요지를 쓴다',
        response: '손건은 제갈량의 형세 판단을 짧은 문장으로 정리했고, 강동 사절의 첫 말이 뚜렷해졌다.',
        bondExp: 20,
        itemRewards: ['강동 풍문 1']
      },
      {
        id: 'prepare-envoy-supplies',
        label: '사절 보급을 챙긴다',
        response: '강동까지 갈 사절단의 노자와 약품을 따로 묶어 두었다. 긴 길을 위한 준비가 조금 더 단단해졌다.',
        bondExp: 16,
        gold: 180,
        itemRewards: ['상처약 1', '탁주 1']
      }
    ]
  },
  {
    id: 'jiangdong-parley-records',
    title: '강동 회담 문서 정리',
    location: '강나루 막사',
    availableAfterBattleIds: [campBattleIds.twentieth],
    bondId: 'sun-qian__zhuge-liang',
    description: '손권과 주유를 설득하기 위해 조조군 남하 정보와 유비군의 피난 기록을 회담 문서로 정리합니다.',
    lines: [
      '손건: 강동은 싸울 명분만이 아니라 승산을 볼 것입니다. 숫자와 길, 사람의 마음을 모두 적어야 합니다.',
      '제갈량: 문서는 짧아야 하지만 빈틈은 없어야 합니다. 손권이 물을 질문을 먼저 문서 안에 넣겠습니다.',
      '강나루 막사의 등불 아래, 장판파의 먼지와 강동의 바람이 같은 종이에 적힙니다.'
    ],
    choices: [
      {
        id: 'compile-cao-army-scale',
        label: '조조군 규모를 정리한다',
        response: '손건은 추격대의 규모와 조조군의 남하 속도를 정리했고, 강동 회담의 위기감이 분명해졌다.',
        bondExp: 20,
        itemRewards: ['강동 사절 문서 1']
      },
      {
        id: 'write-liu-bei-cause',
        label: '유비의 명분을 적는다',
        response: '제갈량은 유비가 피난민을 버리지 않았다는 기록을 회담의 첫 문장에 두었다.',
        bondExp: 18,
        gold: 160,
        itemRewards: ['민심 기록 1']
      }
    ]
  },
  {
    id: 'river-port-firecraft',
    title: '강나루 화공 논의',
    location: '강나루 선착장',
    availableAfterBattleIds: [campBattleIds.twentieth],
    bondId: 'liu-bei__zhuge-liang',
    description: '장강 전장을 대비해 배, 바람, 불길의 가능성을 조심스럽게 검토합니다.',
    lines: [
      '제갈량: 큰 군세는 넓은 땅에서 강하지만, 강 위에서는 바람과 배가 전장의 절반을 차지합니다.',
      '유비: 불을 쓰더라도 백성이 피할 길은 먼저 열어야 하오. 이 싸움 역시 사람을 살리기 위한 길이어야 하오.',
      '선착장에는 작은 배와 젖은 밧줄이 놓여 있고, 병사들은 처음으로 강 위의 전장을 상상합니다.'
    ],
    choices: [
      {
        id: 'inspect-boats-and-wind',
        label: '배와 바람을 살핀다',
        response: '제갈량은 바람의 방향과 배의 간격을 기록했고, 훗날 화공을 논할 작은 단서가 마련되었다.',
        bondExp: 22,
        itemRewards: ['화공 논의 1']
      },
      {
        id: 'secure-river-medicine',
        label: '강가 보급을 챙긴다',
        response: '유비는 강가 병사들의 상처와 추위를 살폈고, 미축은 젖지 않는 약품 꾸러미를 따로 묶었다.',
        bondExp: 16,
        gold: 140,
        itemRewards: ['상처약 2']
      }
    ]
  },
  {
    id: 'red-cliffs-firecraft-prep',
    title: '적벽 화공 예행',
    location: '강동 선착장',
    availableAfterBattleIds: [campBattleIds.twentyFirst],
    bondId: 'liu-bei__zhuge-liang',
    description: '첫 강안 전선을 확보한 뒤, 배와 바람을 살피며 화공을 실제 전장 계획으로 다듬습니다.',
    lines: [
      '제갈량: 전초전으로 포구를 얻었으니 이제 불길이 어디서 시작되어 어디로 번질지 따져 볼 수 있습니다.',
      '유비: 불은 무서운 계책이오. 다만 조조의 대군을 멈추지 못하면 더 많은 백성이 떠밀릴 것이오.',
      '선착장에는 젖은 짚단과 빈 배가 놓였고, 병사들은 처음으로 불을 전장의 언어로 배우기 시작합니다.'
    ],
    choices: [
      {
        id: 'test-fire-boat-spacing',
        label: '화선 간격을 시험한다',
        response: '제갈량은 배 사이 간격과 바람의 흐름을 기록했고, 적벽 화공의 첫 실행안이 더 또렷해졌다.',
        bondExp: 24,
        itemRewards: ['화공 논의 1']
      },
      {
        id: 'prepare-evacuation-line',
        label: '퇴로와 피난로를 먼저 정한다',
        response: '유비는 불길을 쓰기 전 아군과 백성이 빠질 길부터 표시하게 했고, 계책은 더 조심스러운 형태를 갖추었다.',
        bondExp: 20,
        gold: 160,
        itemRewards: ['상처약 2']
      }
    ]
  },
  {
    id: 'alliance-war-council-records',
    title: '동맹 군의 회의록',
    location: '강동 군막',
    availableAfterBattleIds: [campBattleIds.twentyFirst],
    bondId: 'sun-qian__zhuge-liang',
    description: '손건과 제갈량이 주유에게 보낼 전초전 기록과 다음 화공 논의를 정리합니다.',
    lines: [
      '손건: 동맹은 말로 맺었지만, 계속 이어 가려면 기록과 전공이 서로 맞아야 합니다.',
      '제갈량: 오늘 유비군이 세운 전선과 강동군이 준비할 화공을 한 장의 흐름으로 묶겠습니다.',
      '군막 안에는 조조 수군의 배치도와 포구 전초전 기록이 펼쳐지고, 다음 싸움의 책임이 문장 위에 놓입니다.'
    ],
    choices: [
      {
        id: 'write-joint-command-note',
        label: '합동 지휘 요지를 적는다',
        response: '손건은 유비군의 전선 유지와 강동군의 화공 준비를 함께 적어 동맹의 균형을 분명히 했다.',
        bondExp: 22,
        itemRewards: ['동맹 군의 기록 1']
      },
      {
        id: 'clarify-zhou-yu-requests',
        label: '주유에게 요청할 내용을 정한다',
        response: '제갈량은 주유가 요구할 병력, 배, 시점을 예상했고 손건은 그 답을 짧은 회의록으로 정리했다.',
        bondExp: 18,
        gold: 180,
        itemRewards: ['탁주 1']
      }
    ]
  },
  {
    id: 'red-cliffs-aftermath-relief',
    title: '적벽 전후 수습',
    location: '강안 임시 진영',
    availableAfterBattleIds: [campBattleIds.twentySecond],
    bondId: 'liu-bei__mi-zhu',
    description: '불길 뒤의 강안에서 부상자와 흩어진 병사를 수습하며 다음 행군의 보급을 정리합니다.',
    lines: [
      '미축: 승리는 컸지만 젖은 군량과 다친 병사가 많습니다. 지금 정리하지 않으면 형주로 향할 때 다시 흔들릴 것입니다.',
      '유비: 불길 뒤에 남은 사람을 먼저 살펴야 하오. 승리의 이름보다 살아 움직일 군이 먼저요.',
      '강안 임시 진영에는 젖은 깃발과 그을린 갑옷이 널려 있고, 병사들은 비로소 숨을 고릅니다.'
    ],
    choices: [
      {
        id: 'treat-burned-soldiers',
        label: '화상 입은 병사를 돌본다',
        response: '미축은 약품을 아끼지 않았고, 유비는 전공보다 병사를 먼저 살핀다는 군영의 기준을 다시 세웠다.',
        bondExp: 22,
        itemRewards: ['상처약 2']
      },
      {
        id: 'restore-river-supplies',
        label: '강안 보급을 다시 묶는다',
        response: '젖은 군량을 버리고 쓸 수 있는 물자를 다시 묶자, 다음 형주 진입을 버틸 보급이 마련되었다.',
        bondExp: 18,
        gold: 180,
        itemRewards: ['콩 3']
      }
    ]
  },
  {
    id: 'jingzhou-entry-council',
    title: '형주 진입 논의',
    location: '전후 군막',
    availableAfterBattleIds: [campBattleIds.twentySecond],
    bondId: 'liu-bei__zhuge-liang',
    description: '제갈량과 함께 적벽 이후의 형세를 정리하고 형주 남부로 향할 첫 명분을 준비합니다.',
    lines: [
      '제갈량: 조조가 물러난 지금, 빈틈은 오래 머물지 않습니다. 형주 남부의 군현은 두려움과 기대 사이에 있습니다.',
      '유비: 힘으로 빼앗는다면 조조와 무엇이 다르겠소. 백성이 기대어 설 명분을 먼저 세워야 하오.',
      '군막의 지도 위에는 적벽의 붉은 표시가 지워지고, 형주 남부의 길목이 새로 동그라미 쳐집니다.'
    ],
    choices: [
      {
        id: 'draft-jingzhou-cause',
        label: '형주 진입 명분을 쓴다',
        response: '제갈량은 조조를 막은 공과 백성 보호의 뜻을 함께 적어, 형주 남부로 향할 첫 문장을 만들었다.',
        bondExp: 24,
        itemRewards: ['형주 진입 문서 1']
      },
      {
        id: 'survey-southern-commandery',
        label: '남부 군현 정보를 모은다',
        response: '손건과 간옹이 들은 풍문까지 지도에 더해지자, 다음 행군에서 설득해야 할 성과 피해야 할 길이 분명해졌다.',
        bondExp: 20,
        gold: 200,
        itemRewards: ['민심 기록 1']
      }
    ]
  },
  {
    id: 'lingling-village-relief',
    title: '영릉 마을 구호',
    location: '영릉 외곽 마을',
    availableAfterBattleIds: [campBattleIds.twentyThird],
    bondId: 'liu-bei__ma-liang',
    description: '형도영의 가병이 물러난 뒤, 유비와 마량이 마을을 돌며 남은 상처와 민심을 살핍니다.',
    lines: [
      '마량: 이 마을은 길목이라 어느 군대가 지나가도 먼저 흔들립니다. 오늘은 곡식보다 안심할 말이 필요합니다.',
      '유비: 우리가 지나간 뒤에도 마을이 버틸 수 있어야 하오. 남은 약과 곡식을 나누고, 군율을 다시 세우시오.',
      '마을 장로들은 처음에는 문을 반쯤만 열었지만, 약과 곡식이 먼저 들어오자 조심스레 군영의 이름을 물었습니다.'
    ],
    choices: [
      {
        id: 'leave-village-medicine',
        label: '상처약을 남긴다',
        response: '마량은 약을 받은 집마다 이름을 적었고, 유비군이 약탈군이 아니라는 소문이 영릉 주변으로 퍼졌다.',
        bondExp: 22,
        itemRewards: ['상처약 2']
      },
      {
        id: 'write-relief-record',
        label: '구호 기록을 남긴다',
        response: '유비와 마량은 마을별 필요 물자를 문서로 남겼고, 다음 보급 계획이 한결 정확해졌다.',
        bondExp: 18,
        gold: 160,
        itemRewards: ['형주 민심 기록 1']
      }
    ]
  },
  {
    id: 'southern-commandery-ledger',
    title: '남부 군현 문서',
    location: '임시 군막',
    availableAfterBattleIds: [campBattleIds.twentyThird],
    bondId: 'zhuge-liang__ma-liang',
    description: '제갈량과 마량이 영릉 이후 차례로 열어야 할 군현의 길, 토호, 마을 사정을 정리합니다.',
    lines: [
      '제갈량: 전투로 이긴 곳은 하루면 흔들리고, 문서로 묶은 곳은 다음 계절까지 버팁니다.',
      '마량: 계양은 먼저 설득할 사람이 있고, 무릉은 길이 험합니다. 장사는 장수의 이름부터 살펴야 합니다.',
      '두 사람의 지도가 펼쳐지자 형주 남부는 막연한 땅이 아니라, 차례로 준비해야 할 선택지로 보이기 시작했습니다.'
    ],
    choices: [
      {
        id: 'mark-guiyang-route',
        label: '계양으로 가는 길을 표시한다',
        response: '마량이 계양 길목과 설득 대상을 적자, 제갈량은 다음 군현 확보 순서를 새로 정리했다.',
        bondExp: 20,
        itemRewards: ['남부 군현 지도 1']
      },
      {
        id: 'estimate-wuling-risk',
        label: '무릉의 위험을 계산한다',
        response: '험한 길과 분산된 마을이 지도에 표시되며, 다음 전장에는 기동 장수와 보급 장수의 선택이 더 중요해졌다.',
        bondExp: 18,
        gold: 180,
        itemRewards: ['군현 문서 1']
      }
    ]
  },
  {
    id: 'guiyang-civic-pledge',
    title: '계양 민심 서약',
    location: '계양 관청',
    availableAfterBattleIds: [campBattleIds.twentyFourth],
    bondId: 'liu-bei__yi-ji',
    description: '유비와 이적이 계양 관청에서 군율과 세곡 처리 원칙을 밝히며 새로 열린 군현의 불안을 다독입니다.',
    lines: [
      '이적: 계양 사람들은 항복보다 그 뒤의 세금과 군율을 두려워합니다. 먼저 약속을 글로 남겨야 합니다.',
      '유비: 우리가 이곳에 머무는 동안 백성의 창고를 함부로 열지 않겠소. 필요한 것은 값을 치르고, 군율을 어기면 벌하겠소.',
      '관청 앞에 모인 사람들은 아직 조심스러웠지만, 글로 남긴 약속을 보고 처음으로 긴 숨을 내쉬었습니다.'
    ],
    choices: [
      {
        id: 'publish-guiyang-discipline',
        label: '군율 서약을 내건다',
        response: '이적은 유비의 약속을 관청 문에 걸었고, 계양의 불안은 조금씩 거래와 보급으로 바뀌기 시작했다.',
        bondExp: 22,
        itemRewards: ['계양 민심 기록 1']
      },
      {
        id: 'settle-storehouse-rules',
        label: '창고 사용 원칙을 정한다',
        response: '계양 창고의 곡식 출납 원칙이 정해지자, 미축의 보급 계획도 다음 전장까지 이어질 수 있게 되었다.',
        bondExp: 18,
        gold: 180,
        itemRewards: ['콩 3']
      }
    ]
  },
  {
    id: 'wuling-mountain-scouting',
    title: '무릉 산길 정찰',
    location: '무릉 길목',
    availableAfterBattleIds: [campBattleIds.twentyFourth],
    bondId: 'ma-liang__yi-ji',
    description: '마량과 이적이 무릉으로 이어지는 산길과 마을 이름을 대조하며 다음 전투의 위험을 미리 살핍니다.',
    lines: [
      '마량: 이 길은 좁고 마을은 흩어져 있습니다. 기병만 앞세우면 빠르지만, 뒤의 보급이 끊길 수 있습니다.',
      '이적: 산길 마을은 말보다 신뢰를 먼저 봅니다. 먼저 이름을 부르고, 그 다음에 도움을 청해야 합니다.',
      '두 사람은 길목마다 작은 표식을 남기고, 다음 전투에서 놓치기 쉬운 마을 이름을 지도 옆에 적었습니다.'
    ],
    choices: [
      {
        id: 'mark-wuling-villages',
        label: '산길 마을을 표시한다',
        response: '무릉의 작은 마을들이 지도에 표시되자, 다음 전장에서는 이동과 보급의 선택이 더 분명해졌다.',
        bondExp: 20,
        itemRewards: ['무릉 산길 지도 1']
      },
      {
        id: 'prepare-mountain-supplies',
        label: '산길 보급을 준비한다',
        response: '이적은 마을별 필요한 물자를 적고, 마량은 안전한 우회로를 더했다. 무릉 진입 준비가 조금 더 단단해졌다.',
        bondExp: 18,
        gold: 160,
        itemRewards: ['상처약 2']
      }
    ]
  },
  {
    id: 'wuling-road-repair',
    title: '무릉 산길 정비',
    location: '무릉 서쪽 고개',
    availableAfterBattleIds: [campBattleIds.twentyFifth],
    bondId: 'liu-bei__gong-zhi',
    description: '유비와 공지가 전투로 망가진 산길을 정비하며, 마을 사람들이 다시 오갈 수 있는 길을 먼저 회복합니다.',
    lines: [
      '공지: 이 고개가 막히면 무릉 사람들은 곡식을 팔러 갈 수도, 약을 구하러 갈 수도 없습니다.',
      '유비: 군이 지나간 자리라면 군이 먼저 고쳐야 하오. 병사들을 나누어 길을 다듬게 하시오.',
      '마을 사람들은 처음엔 멀리서 지켜보다가, 길이 열리자 조심스럽게 삽과 바구니를 들고 나왔습니다.'
    ],
    choices: [
      {
        id: 'repair-wuling-pass',
        label: '고개를 먼저 고친다',
        response: '공지가 길목을 지휘하고 유비가 병사들을 나누자, 무릉 서쪽 고개가 다시 짐수레를 받을 수 있게 되었다.',
        bondExp: 22,
        itemRewards: ['무릉 보급 기록 1']
      },
      {
        id: 'protect-returning-villagers',
        label: '귀환민을 호위한다',
        response: '돌아오는 마을 사람들을 호위하자, 무릉의 불안한 소문은 유비군이 길을 지킨다는 말로 바뀌었다.',
        bondExp: 18,
        gold: 180,
        itemRewards: ['콩 3']
      }
    ]
  },
  {
    id: 'changsha-veteran-rumor',
    title: '장사 노장 소문',
    location: '무릉 군막',
    availableAfterBattleIds: [campBattleIds.twentyFifth],
    bondId: 'yi-ji__gong-zhi',
    description: '이적과 공지가 장사로 향하기 전에 황충과 한현의 성격, 장사 성내 분위기를 정리합니다.',
    lines: [
      '이적: 장사에는 황충이라는 노장이 있습니다. 예우 없이 대하면 성문보다 마음이 먼저 닫힐 것입니다.',
      '공지: 한현은 의심이 많고, 황충은 무인의 체면을 중히 여깁니다. 싸움보다 먼저 사람을 보아야 합니다.',
      '두 사람은 장사로 보낼 사절 명단과 예물을 차례로 적었습니다.'
    ],
    choices: [
      {
        id: 'prepare-huang-zhong-courtesy',
        label: '황충 예우를 준비한다',
        response: '이적은 노장을 대하는 예문을 쓰고, 공지는 장사 내부 소문을 덧붙였다. 다음 장의 첫 대면이 조금 더 조심스러워졌다.',
        bondExp: 20,
        itemRewards: ['장사 사절 문서 1']
      },
      {
        id: 'map-changsha-approach',
        label: '장사 접근로를 표시한다',
        response: '공지의 무릉 길 정보와 이적의 문서가 합쳐져, 장사로 향하는 길과 만날 사람의 순서가 정리되었다.',
        bondExp: 18,
        gold: 200,
        itemRewards: ['남부 군현 지도 1']
      }
    ]
  },
  {
    id: 'changsha-veteran-honor',
    title: '장사 노장 예우',
    location: '장사 성루',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    bondId: 'liu-bei__huang-zhong',
    description: '유비와 황충이 장사 성루에 올라 전투 뒤 흔들린 병사들을 달래고, 노장의 명예를 새 군율 안에 세웁니다.',
    lines: [
      '황충: 병사들은 제가 패한 것이 아니라 버림받은 것이라 여길까 두려워합니다.',
      '유비: 장군을 예로 맞는다면 병사들도 자기 자리를 잃지 않을 것이오. 성루에서 함께 군율을 밝힙시다.',
      '장사 성루 위에는 아직 화살 자국이 남아 있었지만, 병사들의 시선은 조금씩 아래로 내려왔습니다.'
    ],
    choices: [
      {
        id: 'honor-changsha-soldiers',
        label: '장사 병사를 예우한다',
        response: '황충의 이름으로 장사 병사들의 항복 절차가 정리되자, 성 안의 불안은 빠르게 가라앉았다.',
        bondExp: 22,
        itemRewards: ['장사 병적 기록 1']
      },
      {
        id: 'open-castle-storehouse',
        label: '성내 창고를 점검한다',
        response: '창고를 함부로 열지 않고 장부부터 확인하자, 장사 상인들이 다시 거래를 시작할 수 있게 되었다.',
        bondExp: 18,
        gold: 220,
        itemRewards: ['콩 4']
      }
    ]
  },
  {
    id: 'yi-province-road-council',
    title: '익주 길 논의',
    location: '장사 군막',
    availableAfterBattleIds: [campBattleIds.twentySixth],
    bondId: 'liu-bei__zhuge-liang',
    description: '제갈량과 새로 합류한 장수들의 정보를 모아, 형주 남부 이후 익주로 향할 큰 방향을 처음 정리합니다.',
    lines: [
      '제갈량: 형주 남부는 뿌리가 되었지만, 뿌리만으로 천하를 받칠 수는 없습니다. 익주로 향할 명분과 길이 필요합니다.',
      '유비: 길이 멀수록 함께 갈 사람을 더 살펴야 하오. 새로 온 장수들의 뜻도 함께 묶읍시다.',
      '군막의 지도에는 형주 남부 표시 옆으로 익주로 향하는 긴 선이 처음 그어졌습니다.'
    ],
    choices: [
      {
        id: 'draft-yi-province-cause',
        label: '익주 명분을 적는다',
        response: '제갈량은 형주 남부 안정과 백성 보호의 기록을 바탕으로, 익주로 향할 첫 명분을 정리했다.',
        bondExp: 24,
        itemRewards: ['익주 진입 초안 1']
      },
      {
        id: 'assign-new-officers',
        label: '새 장수 역할을 나눈다',
        response: '황충, 위연, 공지의 역할이 정리되자 다음 대장정에서 누가 전열과 후방을 맡을지 윤곽이 생겼다.',
        bondExp: 20,
        gold: 220,
        itemRewards: ['군영 편성 기록 1']
      }
    ]
  },
  {
    id: 'yizhou-entry-council',
    title: '익주 원군로 회의',
    location: '익주 길목 군막',
    availableAfterBattleIds: [campBattleIds.twentySeventh],
    bondId: 'liu-bei__pang-tong',
    description: '방통이 합류한 뒤, 유비와 새 책사가 익주로 들어가는 명분과 속도를 처음 조율합니다.',
    lines: [
      '방통: 익주는 문이 많은 땅입니다. 모든 문을 두드리면 늦고, 모든 문을 부수면 민심을 잃습니다.',
      '유비: 그러니 열어야 할 문과 기다려야 할 문을 구분해야 하오.',
      '군막의 촛불 아래 익주의 관문 이름과 마을 이름이 하나씩 적히기 시작했습니다.'
    ],
    choices: [
      {
        id: 'choose-yizhou-cause-first',
        label: '명분을 먼저 세운다',
        response: '방통은 유비의 뜻에 맞춰 원군 요청 문서를 앞세우고, 군의 이동 속도는 조금 늦추되 마을의 신뢰를 얻도록 조정했다.',
        bondExp: 24,
        itemRewards: ['익주 원군 문서 1']
      },
      {
        id: 'choose-yizhou-speed-first',
        label: '관문 속도를 높인다',
        response: '방통은 빠르게 흔들 수 있는 관문과 우회로를 짚었다. 유비는 백성이 다치지 않는 선을 분명히 못박았다.',
        bondExp: 22,
        gold: 240,
        itemRewards: ['산문 정찰 기록 1']
      }
    ]
  },
  {
    id: 'fu-pass-road-survey',
    title: '부수관 길 정찰',
    location: '익주 산길',
    availableAfterBattleIds: [campBattleIds.twentySeventh],
    bondId: 'zhuge-liang__pang-tong',
    description: '제갈량과 방통이 다음 관문으로 이어지는 산길을 살피며, 출전 후보와 병과 조합을 논합니다.',
    lines: [
      '제갈량: 이 길은 한 번 막히면 보급이 끊깁니다. 전열보다 후원이 무너질 수 있습니다.',
      '방통: 그렇다면 적이 막기 전에 우리가 먼저 막힌 척해야겠군요. 적을 끌어내면 관문은 비게 됩니다.',
      '두 책사는 산의 그림자와 강의 굽이를 지도 위에 겹쳐 보았습니다.'
    ],
    choices: [
      {
        id: 'prepare-fu-pass-supply',
        label: '보급선을 먼저 살핀다',
        response: '제갈량은 보급선을, 방통은 유인 지점을 표시했다. 다음 전투의 출전 선택이 한층 중요해졌다.',
        bondExp: 22,
        itemRewards: ['부수관 보급표 1']
      },
      {
        id: 'prepare-feint-on-pass',
        label: '유인책을 세운다',
        response: '방통의 기책과 제갈량의 큰 판이 합쳐져, 적을 관문 밖으로 끌어내는 초안이 마련되었다.',
        bondExp: 24,
        gold: 260,
        itemRewards: ['관문 유인 초안 1']
      }
    ]
  },
  {
    id: 'fu-pass-storehouse-seal',
    title: '부수관 창고 봉인',
    location: '부수관 창고',
    availableAfterBattleIds: [campBattleIds.twentyEighth],
    bondId: 'liu-bei__fa-zheng',
    description: '법정의 조언에 따라 부수관 창고를 함부로 열지 않고 장부와 민심을 먼저 정리합니다.',
    lines: [
      '법정: 익주 사람들은 창고 문이 열리는 순간을 보고 새 주인을 판단할 것입니다.',
      '유비: 병사들이 배고파도 백성의 곡식을 빼앗아서는 안 되오. 장부를 먼저 세우겠소.',
      '창고 앞에 모인 사람들은 병사들이 물러서고 장부가 펼쳐지는 모습을 조용히 지켜보았습니다.'
    ],
    choices: [
      {
        id: 'seal-storehouse-ledger',
        label: '장부를 먼저 세운다',
        response: '법정은 창고 장부를 정리했고, 유비군은 필요한 보급만 표시해 주민의 불안을 줄였다.',
        bondExp: 24,
        itemRewards: ['부수관 창고 장부 1']
      },
      {
        id: 'guard-storehouse-night',
        label: '야간 경계를 세운다',
        response: '창고를 노리는 잔병을 막아 내자, 부수관 주민들은 유비군이 약속을 지킨다는 말을 전하기 시작했다.',
        bondExp: 20,
        gold: 260,
        itemRewards: ['콩 4']
      }
    ]
  },
  {
    id: 'luo-castle-entry-council',
    title: '낙성 진입 회의',
    location: '부수관 군막',
    availableAfterBattleIds: [campBattleIds.twentyEighth],
    bondId: 'pang-tong__fa-zheng',
    description: '방통과 법정이 낙성으로 이어지는 길을 두고 빠른 기책과 내부 설득의 균형을 조율합니다.',
    lines: [
      '방통: 빠르게 움직이면 낙성 앞의 적이 미처 모이지 못합니다.',
      '법정: 그러나 빠른 길일수록 뒤에 남는 사람의 마음이 상합니다. 누가 남고 누가 따를지 먼저 보아야 합니다.',
      '두 사람의 말이 부딪히자, 군막 안의 지도 위에는 빠른 길과 조심스러운 길이 동시에 그어졌습니다.'
    ],
    choices: [
      {
        id: 'prepare-fast-luo-entry',
        label: '빠른 진입로를 잡는다',
        response: '방통은 협곡을 통과하는 빠른 진입로를 표시했고, 법정은 그 길의 반발을 줄일 연락책을 붙였다.',
        bondExp: 24,
        itemRewards: ['낙성 진입 초안 1']
      },
      {
        id: 'prepare-soft-luo-entry',
        label: '내부 설득을 우선한다',
        response: '법정은 설득해야 할 호족과 관리의 이름을 적었고, 방통은 그 사이를 흔들 유인책을 더했다.',
        bondExp: 22,
        gold: 280,
        itemRewards: ['익주 연락 명단 1']
      }
    ]
  },
  {
    id: 'luo-village-reassurance',
    title: '낙성 외곽 마을 위무',
    location: '낙성 외곽 마을',
    availableAfterBattleIds: [campBattleIds.twentyNinth],
    bondId: 'liu-bei__wu-yi',
    description: '오의가 앞장서서 마을 사람들을 안심시키고, 유비군이 낙성 백성을 해치지 않는다는 약속을 확인합니다.',
    lines: [
      '오의: 이 마을 사람들이 마음을 열면 낙성 안쪽도 곧 소문을 듣게 됩니다.',
      '유비: 군량과 말은 백성에게서 빼앗지 않는다. 이 약속을 먼저 전하시오.',
      '마을의 불안이 조금 누그러지고 낙성으로 이어지는 작은 길들이 지도에 더해졌습니다.'
    ],
    choices: [
      {
        id: 'guard-luo-village',
        label: '마을 경계를 세운다',
        response: '오의가 아는 길목에 경계를 세우자 백성들은 유비군의 군율을 조금 더 믿게 되었다.',
        bondExp: 24,
        itemRewards: ['콩 4']
      },
      {
        id: 'share-grain-ledger',
        label: '군량 명부를 공개한다',
        response: '유비군이 쓸 군량과 남길 군량을 나누어 적자, 오의는 설득할 말이 생겼다고 말했다.',
        bondExp: 22,
        gold: 300,
        itemRewards: ['낙성 마을 명부 1']
      }
    ]
  },
  {
    id: 'luofeng-shadow-council',
    title: '낙봉파 그림자 회의',
    location: '낙성 진군로',
    availableAfterBattleIds: [campBattleIds.twentyNinth],
    bondId: 'pang-tong__wu-yi',
    description: '방통과 오의가 낙성 안쪽 길을 살피며, 다음 전장으로 이어질 위험한 좁은 길을 표시합니다.',
    lines: [
      '방통: 이름부터 좋지 않군요. 봉황이 떨어지는 비탈이라니.',
      '오의: 그 길은 빠르지만 너무 조용합니다. 장임이라면 그 조용함을 그냥 두지 않을 겁니다.',
      '두 사람은 다음 출전에서 정찰과 호위를 어떻게 나눌지 군영 지도 위에 표시했습니다.'
    ],
    choices: [
      {
        id: 'send-forward-scouts',
        label: '선행 정찰을 보낸다',
        response: '오의가 길을 아는 사람을 붙이고 방통이 정찰 순서를 정했다. 다음 전장의 불안이 조금 줄었다.',
        bondExp: 24,
        itemRewards: ['낙봉파 정찰표 1']
      },
      {
        id: 'prepare-ambush-counter',
        label: '매복 대응책을 세운다',
        response: '방통은 일부러 느슨해 보이는 행군을 제안했고, 오의는 적이 물어뜯을 만한 길목을 짚었다.',
        bondExp: 22,
        gold: 320,
        itemRewards: ['매복 대응 초안 1']
      }
    ]
  },
  {
    id: 'luofeng-slope-shrine',
    title: '낙봉파 길가의 제단',
    location: '낙봉파 비탈',
    availableAfterBattleIds: [campBattleIds.thirtieth],
    bondId: 'liu-bei__pang-tong',
    description: '유비와 방통이 매복을 넘긴 비탈의 작은 제단에 들러, 살아 남은 병사들과 다음 길의 마음을 다집니다.',
    lines: [
      '방통: 이곳에서 한 걸음만 늦었다면 제 이름은 길가의 이야기가 되었을지도 모릅니다.',
      '유비: 그러니 살아 남은 오늘을 가볍게 쓰지 맙시다. 다음 성문 앞에서도 백성을 먼저 보겠소.',
      '제단 앞에 놓인 낡은 향이 꺼지고, 병사들은 낙봉파를 넘었다는 사실을 뒤늦게 실감했습니다.'
    ],
    choices: [
      {
        id: 'comfort-luofeng-wounded',
        label: '부상병을 위문한다',
        response: '유비가 부상병의 손을 잡자 방통도 다음 행군표에서 무리한 구간을 줄였습니다.',
        bondExp: 26,
        itemRewards: ['상처약 3']
      },
      {
        id: 'offer-luofeng-prayer',
        label: '전몰자를 기린다',
        response: '군영의 말소리가 낮아지고, 방통은 다음 계책에 병사들이 돌아올 길을 먼저 적었습니다.',
        bondExp: 24,
        gold: 340,
        itemRewards: ['맑은 향 1']
      }
    ]
  },
  {
    id: 'luo-castle-main-council',
    title: '낙성 본성 공략 회의',
    location: '낙성 앞 군영',
    availableAfterBattleIds: [campBattleIds.thirtieth],
    bondId: 'fa-zheng__wu-yi',
    description: '법정과 오의가 낙성 본성의 문, 물길, 수비 장수들의 성향을 대조하며 다음 공략의 밑그림을 만듭니다.',
    lines: [
      '법정: 장임은 강하지만, 낙성 안의 모든 장수가 같은 마음으로 버티는 것은 아닙니다.',
      '오의: 제가 아는 이름들을 적겠습니다. 문을 여는 사람과 끝까지 막을 사람을 나누어 보지요.',
      '두 사람이 성문 지도를 다시 그리자, 다음 전장의 목표가 단순한 격파가 아니라 설득을 포함한 공략으로 바뀌었습니다.'
    ],
    choices: [
      {
        id: 'sort-luo-officer-names',
        label: '장수 명단을 분류한다',
        response: '익주 장수들의 이름 옆에 설득, 견제, 격파라는 표시가 붙으며 다음 전장의 말길이 열렸습니다.',
        bondExp: 26,
        itemRewards: ['낙성 장수 명단 1']
      },
      {
        id: 'trace-luo-supply-water',
        label: '보급 수로를 추적한다',
        response: '오의가 작은 물길을 짚자 법정은 그 길이 성 안의 불안을 흔들 수 있다고 판단했습니다.',
        bondExp: 24,
        gold: 360,
        itemRewards: ['낙성 수로도 1']
      }
    ]
  },
  {
    id: 'luo-main-gate-relief',
    title: '낙성 성문 구호',
    location: '낙성 본성 성문',
    availableAfterBattleIds: [campBattleIds.thirtyFirst],
    bondId: 'liu-bei__yan-yan',
    description: '유비와 엄안이 열린 성문 앞의 백성을 달래고, 전투 뒤 무너진 성문과 창고를 정리합니다.',
    lines: [
      '엄안: 성문을 지키던 병사들도 결국 익주의 백성입니다. 이들을 함부로 다루면 다음 성도 길은 닫힙니다.',
      '유비: 부상자는 먼저 치료하고, 창고의 곡식은 명단을 적어 돌려주시오.',
      '낙성 백성들은 아직 두려움 속에 있었지만, 군영의 약탈 금지 명령을 들으며 문틈으로 고개를 내밀기 시작했습니다.'
    ],
    choices: [
      {
        id: 'treat-luo-gate-wounded',
        label: '성문 부상자를 치료한다',
        response: '상처약이 성문 양쪽에 나뉘어 쓰이자 엄안은 말없이 유비에게 한 번 더 예를 표했습니다.',
        bondExp: 26,
        itemRewards: ['상처약 4']
      },
      {
        id: 'publish-no-looting-order',
        label: '약탈 금지 명령을 붙인다',
        response: '성문 앞에 붙은 명령문은 낙성 안쪽으로 빠르게 퍼졌고, 다음 항복 권고의 근거가 되었습니다.',
        bondExp: 24,
        gold: 380,
        itemRewards: ['낙성 안정문 1']
      }
    ]
  },
  {
    id: 'chengdu-pressure-council',
    title: '성도 압박 회의',
    location: '낙성 군영 본막',
    availableAfterBattleIds: [campBattleIds.thirtyFirst],
    bondId: 'fa-zheng__wu-yi',
    description: '법정, 오의, 엄안이 성도까지 이어지는 길과 유장의 흔들리는 장수들을 정리합니다.',
    lines: [
      '법정: 낙성이 열렸다는 소식은 성도까지 하루면 닿을 것입니다. 그 소식이 공포가 아니라 약속으로 들려야 합니다.',
      '오의: 익주의 길을 아는 장수들이 이제 우리 군영에 있습니다. 길은 좁아도 말은 넓게 보내야 합니다.',
      '엄안: 노장 하나가 항복했다고 모든 성이 열리지는 않습니다. 하지만 백성을 살렸다는 말은 오래 갑니다.'
    ],
    choices: [
      {
        id: 'send-chengdu-parley-envoy',
        label: '성도에 사자를 보낸다',
        response: '사자는 항복 권고문과 낙성 안정문을 함께 들고 떠났고, 군영에는 다음 전장 후보지가 표시되었습니다.',
        bondExp: 26,
        itemRewards: ['성도 권고문 초안 1']
      },
      {
        id: 'survey-inner-yizhou-storehouses',
        label: '익주 창고를 조사한다',
        response: '창고와 물길을 먼저 장악하자는 의견이 모이며, 다음 전투의 보급 목표가 분명해졌습니다.',
        bondExp: 24,
        gold: 400,
        itemRewards: ['익주 창고 장부 1']
      }
    ]
  },
  {
    id: 'mianzhu-storehouse-relief',
    title: '면죽관 창고 구호',
    location: '면죽관 창고',
    availableAfterBattleIds: [campBattleIds.thirtySecond],
    bondId: 'liu-bei__li-yan',
    description: '유비와 이엄이 면죽관 창고를 열어 항복 병사와 백성에게 공평하게 배급할 방법을 정합니다.',
    lines: [
      '이엄: 창고를 빼앗긴 병사들은 패배보다 굶주림을 더 두려워합니다.',
      '유비: 그렇다면 먼저 명부를 세우고, 우리 병사와 항복 병사를 같은 줄에 세웁시다.',
      '창고 앞의 긴장된 줄은 천천히 낮아졌고, 면죽관의 문은 전리품이 아니라 구호소처럼 쓰이기 시작했습니다.'
    ],
    choices: [
      {
        id: 'equal-storehouse-ration',
        label: '배급 줄을 하나로 합친다',
        response: '유비군과 항복 병사가 같은 줄에서 배급을 받자, 성도까지 보낼 소문이 한층 부드러워졌습니다.',
        bondExp: 28,
        itemRewards: ['콩 6', '상처약 3']
      },
      {
        id: 'record-mianzhu-ledger',
        label: '창고 장부를 남긴다',
        response: '이엄은 창고 장부에 손댄 곡식과 남긴 곡식을 모두 적어, 다음 성도 권고의 증거를 만들었습니다.',
        bondExp: 26,
        gold: 420,
        itemRewards: ['면죽관 창고 장부 1']
      }
    ]
  },
  {
    id: 'chengdu-surrender-council',
    title: '성도 항복 권고 회의',
    location: '면죽관 본막',
    availableAfterBattleIds: [campBattleIds.thirtySecond],
    bondId: 'zhuge-liang__li-yan',
    description: '제갈량과 이엄이 성도에 보낼 항복 조건을 정리하고, 유장이 받아들일 만한 명분을 다듬습니다.',
    lines: [
      '제갈량: 권고문은 이기는 군대의 칼이 아니라, 성 안의 사람이 붙잡을 난간이어야 합니다.',
      '이엄: 유장공이 체면을 잃지 않는 문장이라면, 문을 여는 사람도 배신자가 아니라 중재자가 될 수 있습니다.',
      '두 사람은 항복 뒤 관직과 창고, 병사의 처우를 조목조목 적어 다음 장의 실마리를 만들었습니다.'
    ],
    choices: [
      {
        id: 'soften-surrender-terms',
        label: '항복 조건을 부드럽게 쓴다',
        response: '권고문은 위협보다 보전과 질서를 앞세웠고, 성도 안쪽으로 들어갈 여지가 넓어졌습니다.',
        bondExp: 28,
        itemRewards: ['성도 항복 권고문 1']
      },
      {
        id: 'prepare-city-officer-list',
        label: '성도 장수 명단을 정리한다',
        response: '이엄의 기억과 제갈량의 판단이 겹쳐지며, 다음 설득전에서 먼저 만날 장수들이 정해졌습니다.',
        bondExp: 26,
        gold: 440,
        itemRewards: ['성도 장수 명단 1']
      }
    ]
  },
  {
    id: 'chengdu-storehouse-order',
    title: '성도 창고 정리',
    location: '성도 남창',
    availableAfterBattleIds: [campBattleIds.thirtyThird],
    bondId: 'liu-bei__huang-quan',
    description: '유비와 황권이 성도 창고를 열어 항복 병사와 백성의 배급 질서를 바로잡습니다.',
    lines: [
      '황권: 성도 창고는 익주의 배를 붙잡는 곳입니다. 여기서 불공평하다는 말이 나오면 항복은 금세 원망이 됩니다.',
      '유비: 그렇다면 전리품보다 장부를 먼저 세우시오. 우리 병사도 그 질서를 따르게 하겠소.',
      '창고 문 앞에는 칼보다 붓이 먼저 놓였고, 성도 백성은 새 군령이 약탈이 아님을 확인하기 시작했습니다.'
    ],
    choices: [
      {
        id: 'share-chengdu-rations',
        label: '배급 기준을 공개한다',
        response: '배급 기준이 성도 남창에 붙자 항복 병사와 백성의 불안이 조금씩 가라앉았습니다.',
        bondExp: 30,
        itemRewards: ['콩 8', '상처약 4']
      },
      {
        id: 'preserve-chengdu-ledger',
        label: '창고 장부를 보전한다',
        response: '황권은 기존 장부를 태우지 않고 그대로 보전해, 유비군이 익주를 빼앗기만 한 것이 아님을 보였습니다.',
        bondExp: 28,
        gold: 480,
        itemRewards: ['성도 창고 장부 1']
      }
    ]
  },
  {
    id: 'shu-foundation-council',
    title: '촉한 건국 준비 회의',
    location: '성도 임시 관청',
    availableAfterBattleIds: [campBattleIds.thirtyThird],
    bondId: 'zhuge-liang__huang-quan',
    description: '제갈량과 황권이 익주 수습 뒤 유비군이 국가의 틀로 나아가기 위한 첫 행정 과제를 정합니다.',
    lines: [
      '제갈량: 형주와 익주가 이어졌으니 이제 군영의 규칙만으로는 부족합니다. 법, 창고, 장수의 자리를 세워야 합니다.',
      '황권: 익주 사람은 새 이름보다 오늘의 질서를 먼저 봅니다. 세금을 낮추고 병사의 약탈을 막는 것이 첫걸음입니다.',
      '임시 관청의 등불 아래에서 다음 장의 목적은 전장 하나가 아니라 나라의 틀로 넓어졌습니다.'
    ],
    choices: [
      {
        id: 'draft-yizhou-stabilization',
        label: '익주 안정책을 쓴다',
        response: '제갈량과 황권은 세금, 군량, 항복 장수의 배치를 나누어 적으며 촉한 건국의 첫 초안을 만들었습니다.',
        bondExp: 30,
        itemRewards: ['익주 안정책 1']
      },
      {
        id: 'prepare-hanzhong-road',
        label: '한중 길을 검토한다',
        response: '아직 이른 이야기였지만, 북쪽 관문과 한중으로 향하는 길이 지도 가장자리에 조용히 표시되었습니다.',
        bondExp: 28,
        gold: 500,
        itemRewards: ['한중 길 초안 1']
      }
    ]
  },
  {
    id: 'jiameng-stable-mustering',
    title: '가맹관 마구간 정비',
    location: '가맹관 마구간',
    availableAfterBattleIds: [campBattleIds.thirtyFourth],
    bondId: 'liu-bei__ma-chao',
    description: '마초가 합류한 뒤 서량 기병의 말과 유비군의 보급 체계를 함께 맞춥니다.',
    lines: [
      '마초: 서량 말은 성질이 급합니다. 배불리 먹이고 달릴 길을 먼저 보여 주어야 힘을 냅니다.',
      '유비: 우리 군영의 말과 사람도 그대의 길에 맞추어 배워야 하오. 북쪽 길은 이제 함께 열 길이오.',
      '마구간에는 새로 들어온 서량 말의 숨소리가 가득했고, 병사들은 다음 출전 명단을 떠올리며 고삐를 고쳤습니다.'
    ],
    choices: [
      {
        id: 'align-cavalry-feed',
        label: '말먹이 기준을 맞춘다',
        response: '서량 말의 먹이와 휴식 기준이 정리되자, 마초의 기병대가 유비군의 행군 속도에 맞춰 움직이기 시작했습니다.',
        bondExp: 30,
        itemRewards: ['콩 8', '탁주 3']
      },
      {
        id: 'mark-cavalry-routes',
        label: '기병 돌파로를 표시한다',
        response: '마초가 지도 위에 기병이 달릴 수 있는 능선과 피해야 할 늪길을 표시했습니다.',
        bondExp: 28,
        gold: 520,
        itemRewards: ['서량 말먹이 1']
      }
    ]
  },
  {
    id: 'hanzhong-front-council',
    title: '한중 전선 회의',
    location: '가맹관 본막',
    availableAfterBattleIds: [campBattleIds.thirtyFourth],
    bondId: 'zhao-yun__ma-chao',
    description: '조운과 마초가 한중으로 이어지는 관문, 산길, 기병 운용을 놓고 다음 출전 구성을 논의합니다.',
    lines: [
      '조운: 가맹관을 넘었다고 길이 열린 것은 아닙니다. 좁은 산길에서는 빠른 말도 한 번 멈춰야 합니다.',
      '마초: 멈출 곳을 알면 더 세게 달릴 수 있지요. 장로의 군세와 조조의 눈이 모두 북쪽 길에 걸려 있습니다.',
      '본막의 지도 위에는 한중 전선과 촉한 건국의 다음 숙제가 한 줄로 이어졌습니다.'
    ],
    choices: [
      {
        id: 'plan-hanzhong-scouting',
        label: '한중 정찰을 준비한다',
        response: '조운의 정찰 기준과 마초의 기병 감각이 합쳐져, 다음 장의 출전 후보가 더 분명해졌습니다.',
        bondExp: 30,
        itemRewards: ['한중 정찰도 1']
      },
      {
        id: 'divide-pass-roles',
        label: '관문 돌파 역할을 나눈다',
        response: '조운은 후방을 지키고 마초는 전열을 찌르는 방식으로, 두 기병장의 역할이 정리되었습니다.',
        bondExp: 28,
        gold: 540,
        itemRewards: ['가맹관 전술 기록 1']
      }
    ]
  },
  {
    id: 'yangping-storehouse-survey',
    title: '양평관 창고 조사',
    location: '양평관 외곽 창고',
    availableAfterBattleIds: [campBattleIds.thirtyFifth],
    bondId: 'liu-bei__ma-dai',
    description: '유비와 마대가 양평관 창고의 말먹이, 군량, 통행로를 확인해 한중 본전을 준비합니다.',
    lines: [
      '마대: 말은 창보다 먼저 먹이를 찾습니다. 이 창고를 잡으면 서량 기병도 한중 길에서 오래 버틸 수 있습니다.',
      '유비: 군량이 있어야 백성의 것을 빼앗지 않소. 창고를 얻었다면 먼저 장부를 세워야 하오.',
      '창고 안에는 말먹이와 마른 군량이 쌓여 있었고, 한중 본전을 위한 첫 보급 기준이 마련되었습니다.'
    ],
    choices: [
      {
        id: 'sort-horse-feed',
        label: '말먹이를 분류한다',
        response: '마대가 말먹이와 기병 장비를 따로 정리하자 서량 기병의 다음 출전 준비가 빨라졌습니다.',
        bondExp: 30,
        itemRewards: ['서량 말먹이 2', '콩 8']
      },
      {
        id: 'record-storehouse-ledger',
        label: '창고 장부를 남긴다',
        response: '유비는 창고 장부를 군영에 공개해 양평관에서 얻은 물자가 약탈이 아니라 군율 아래 쓰이게 했습니다.',
        bondExp: 28,
        gold: 560,
        itemRewards: ['양평관 창고 장부 1']
      }
    ]
  },
  {
    id: 'yangping-ridge-drill',
    title: '양평관 능선 훈련',
    location: '양평관 서쪽 능선',
    availableAfterBattleIds: [campBattleIds.thirtyFifth],
    bondId: 'ma-chao__ma-dai',
    description: '마초와 마대가 좁은 능선에서 기병 돌격과 회수 훈련을 맞추며 다음 한중 전투의 기동 축을 만듭니다.',
    lines: [
      '마초: 넓은 벌판이 아니라도 말은 달릴 수 있다. 대신 멈출 때를 알아야 한다.',
      '마대: 형님이 뚫으면 제가 뒤를 묶겠습니다. 능선에서는 한 걸음 늦은 창이 대오를 살립니다.',
      '서쪽 능선에는 짧은 돌격과 빠른 회수가 반복되며, 서량 기병의 새 박자가 자리 잡았습니다.'
    ],
    choices: [
      {
        id: 'drill-ridge-charge',
        label: '능선 돌격을 연습한다',
        response: '마초의 선봉과 마대의 후속 대열이 맞물리며 좁은 능선에서도 기병이 흔들리지 않게 되었습니다.',
        bondExp: 32,
        itemRewards: ['기병 훈련 기록 1']
      },
      {
        id: 'set-recovery-signal',
        label: '회수 신호를 정한다',
        response: '깃발과 북소리로 회수 신호를 정하자, 서량 기병은 돌격 후 돌아오는 박자를 배웠습니다.',
        bondExp: 30,
        gold: 580,
        itemRewards: ['탁주 4']
      }
    ]
  },
  {
    id: 'dingjun-ridge-survey',
    title: '정군산 능선 조사',
    location: '정군산 서쪽 능선',
    availableAfterBattleIds: [campBattleIds.thirtySixth],
    bondId: 'zhuge-liang__wang-ping',
    description: '제갈량과 왕평이 정군산의 능선, 절벽, 보급로를 다시 확인해 한중 결전을 준비합니다.',
    lines: [
      '왕평: 이 능선은 보기보다 바람이 셉니다. 깃발이 흔들리는 방향만 봐도 적의 시야를 피할 수 있습니다.',
      '제갈량: 바람과 길을 함께 적어 두면 다음 전투의 한 수가 됩니다.',
      '두 사람은 산길을 하나씩 짚으며 한중 본전의 진형 후보를 정리했습니다.'
    ],
    choices: [
      {
        id: 'record-ridge-winds',
        label: '능선 바람을 기록한다',
        response: '제갈량은 왕평의 설명을 바탕으로 정군산 능선의 바람과 시야를 지형책에 보탰습니다.',
        bondExp: 32,
        itemRewards: ['산악 지형도 1']
      },
      {
        id: 'secure-ridge-store',
        label: '능선 보급소를 확보한다',
        response: '왕평이 작은 보급소의 위치를 알려 주며 한중 결전에서 버틸 군량을 조금 더 확보했습니다.',
        bondExp: 30,
        gold: 620,
        itemRewards: ['콩 10', '상처약 5']
      }
    ]
  },
  {
    id: 'dingjun-veteran-drill',
    title: '정군산 노장 훈련',
    location: '정군산 중턱',
    availableAfterBattleIds: [campBattleIds.thirtySixth],
    bondId: 'huang-zhong__wang-ping',
    description: '황충과 왕평이 고지 돌파와 산길 매복을 맞춰 보며 다음 한중 결전의 선봉 감각을 다듬습니다.',
    lines: [
      '황충: 젊은 장수들은 빠르게 오른다지만, 오래 버티는 법은 아직 모를 때가 많지.',
      '왕평: 이 산은 오래 버티는 장수를 좋아합니다. 급한 발보다 끊기지 않는 숨이 중요합니다.',
      '정군산 중턱에는 노장의 숨과 산길잡이의 눈이 맞물리며 새로운 훈련 길이 생겼습니다.'
    ],
    choices: [
      {
        id: 'drill-high-ground-strike',
        label: '고지 일격을 연습한다',
        response: '황충은 왕평이 잡아 준 짧은 고지 길을 따라 치고 빠지는 훈련을 반복했습니다.',
        bondExp: 32,
        itemRewards: ['기병 훈련 기록 1']
      },
      {
        id: 'set-old-guard-pace',
        label: '노장 진군 속도를 맞춘다',
        response: '왕평은 황충의 호흡에 맞춰 병사들의 오르막 속도를 정했고, 무리한 추격을 줄였습니다.',
        bondExp: 30,
        gold: 600,
        itemRewards: ['탁주 4']
      }
    ]
  }
];

export class CampScene extends Phaser.Scene {
  private campaign?: CampaignState;
  private report?: FirstBattleReport;
  private selectedUnitId = 'liu-bei';
  private activeTab: CampTab = 'status';
  private selectedDialogueId = campDialogues[0].id;
  private selectedVisitId = campVisits[0].id;
  private contentObjects: Phaser.GameObjects.GameObject[] = [];
  private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
  private sortieObjects: Phaser.GameObjects.GameObject[] = [];
  private tabButtons: CampTabButtonView[] = [];
  private visitedTabs = new Set<CampTab>();
  private selectedSortieUnitIds: string[] = [];
  private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();

  constructor() {
    super('CampScene');
  }

  create() {
    this.contentObjects = [];
    this.dialogueObjects = [];
    this.sortieObjects = [];
    this.tabButtons = [];
    this.visitedTabs = new Set(['status']);
    this.campaign = getCampaignState();
    this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
    this.ensureCurrentCampRecruitment();
    this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
    this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
    this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
    this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
    soundDirector.playMusic('militia-theme');

    const { width, height } = this.scale;
    this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62);
    this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0);
    this.add.rectangle(0, 0, width, 84, 0x06090d, 0.52).setOrigin(0);

    this.add.text(42, 28, this.currentCampTitle(), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '30px',
      color: '#f2e3bf',
      fontStyle: '700',
      stroke: '#05070a',
      strokeThickness: 4
    });
    this.add.text(42, 64, this.reportSummary(), {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '14px',
      color: '#d4dce6'
    });

    this.renderStaticButtons();
    this.render();
  }

  private createFallbackReport(): FirstBattleReport {
    const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
    return {
      battleId: defaultBattleScenario.id,
      battleTitle: defaultBattleScenario.title,
      outcome: 'victory',
      turnNumber: 1,
      rewardGold: 300,
      defeatedEnemies: 0,
      totalEnemies: firstBattleUnits.filter((unit) => unit.faction === 'enemy').length,
      objectives: [],
      units: allies.map((unit) => JSON.parse(JSON.stringify(unit)) as UnitData),
      bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })),
      itemRewards: [],
      completedCampDialogues: [],
      completedCampVisits: [],
      createdAt: new Date().toISOString()
    };
  }

  private ensureCurrentCampRecruitment() {
    const battleId = this.currentCampBattleId();
    if (!this.campaign) {
      return;
    }

    if (battleId === campBattleIds.thirtySixth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits,
          ...chengduPressureRecruitUnits,
          ...chengduSurrenderRecruitUnits,
          ...hanzhongOpeningRecruitUnits,
          ...hanzhongScoutRecruitUnits,
          ...hanzhongMainRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds,
          ...chengduPressureRecruitBonds,
          ...chengduSurrenderRecruitBonds,
          ...hanzhongOpeningRecruitBonds,
          ...hanzhongScoutRecruitBonds,
          ...hanzhongMainRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtyFifth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits,
          ...chengduPressureRecruitUnits,
          ...chengduSurrenderRecruitUnits,
          ...hanzhongOpeningRecruitUnits,
          ...hanzhongScoutRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds,
          ...chengduPressureRecruitBonds,
          ...chengduSurrenderRecruitBonds,
          ...hanzhongOpeningRecruitBonds,
          ...hanzhongScoutRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtyFourth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits,
          ...chengduPressureRecruitUnits,
          ...chengduSurrenderRecruitUnits,
          ...hanzhongOpeningRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds,
          ...chengduPressureRecruitBonds,
          ...chengduSurrenderRecruitBonds,
          ...hanzhongOpeningRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtyThird) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits,
          ...chengduPressureRecruitUnits,
          ...chengduSurrenderRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds,
          ...chengduPressureRecruitBonds,
          ...chengduSurrenderRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtySecond) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits,
          ...chengduPressureRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds,
          ...chengduPressureRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtyFirst) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits,
          ...luoCastleProperRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds,
          ...luoCastleProperRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.thirtieth || battleId === campBattleIds.twentyNinth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits,
          ...luoCastleRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds,
          ...luoCastleRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentyEighth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits,
          ...fuPassRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds,
          ...fuPassRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentySeventh) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits,
          ...yizhouRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds,
          ...yizhouRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentySixth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits,
          ...changshaRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds,
          ...changshaRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentyFifth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits,
          ...wulingRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds,
          ...wulingRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentyFourth) {
      this.campaign = ensureCampaignRosterUnits(
        [
          ...xuzhouRecruitUnits,
          ...caoBreakRecruitUnits,
          ...liuBiaoRecruitUnits,
          ...zhugeRecruitUnits,
          ...jingzhouRecruitUnits,
          ...guiyangRecruitUnits
        ],
        [
          ...xuzhouRecruitBonds,
          ...caoBreakRecruitBonds,
          ...liuBiaoRecruitBonds,
          ...zhugeRecruitBonds,
          ...jingzhouRecruitBonds,
          ...guiyangRecruitBonds
        ]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.twentyThird) {
      this.campaign = ensureCampaignRosterUnits(
        [...xuzhouRecruitUnits, ...caoBreakRecruitUnits, ...liuBiaoRecruitUnits, ...zhugeRecruitUnits, ...jingzhouRecruitUnits],
        [...xuzhouRecruitBonds, ...caoBreakRecruitBonds, ...liuBiaoRecruitBonds, ...zhugeRecruitBonds, ...jingzhouRecruitBonds]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (
      battleId === campBattleIds.seventeenth ||
      battleId === campBattleIds.eighteenth ||
      battleId === campBattleIds.nineteenth ||
      battleId === campBattleIds.twentieth ||
      battleId === campBattleIds.twentyFirst ||
      battleId === campBattleIds.twentySecond
    ) {
      this.campaign = ensureCampaignRosterUnits(
        [...xuzhouRecruitUnits, ...caoBreakRecruitUnits, ...liuBiaoRecruitUnits, ...zhugeRecruitUnits],
        [...xuzhouRecruitBonds, ...caoBreakRecruitBonds, ...liuBiaoRecruitBonds, ...zhugeRecruitBonds]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (battleId === campBattleIds.fifteenth || battleId === campBattleIds.sixteenth) {
      this.campaign = ensureCampaignRosterUnits(
        [...xuzhouRecruitUnits, ...caoBreakRecruitUnits, ...liuBiaoRecruitUnits],
        [...xuzhouRecruitBonds, ...caoBreakRecruitBonds, ...liuBiaoRecruitBonds]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (
      battleId === campBattleIds.thirteenth ||
      battleId === campBattleIds.fourteenth
    ) {
      this.campaign = ensureCampaignRosterUnits(
        [...xuzhouRecruitUnits, ...caoBreakRecruitUnits],
        [...xuzhouRecruitBonds, ...caoBreakRecruitBonds]
      );
      this.report = this.campaign.firstBattleReport ?? this.report;
      return;
    }

    if (
      (battleId !== campBattleIds.seventh &&
        battleId !== campBattleIds.eighth &&
        battleId !== campBattleIds.ninth &&
        battleId !== campBattleIds.tenth &&
        battleId !== campBattleIds.eleventh &&
        battleId !== campBattleIds.twelfth)
    ) {
      return;
    }

    this.campaign = ensureCampaignRosterUnits(xuzhouRecruitUnits, xuzhouRecruitBonds);
    this.report = this.campaign.firstBattleReport ?? this.report;
  }

  private reportSummary() {
    if (!this.report) {
      return '';
    }

    const achieved = this.report.objectives.filter((objective) => objective.achieved).length;
    const gold = this.campaign?.gold ?? this.report.rewardGold;
    return `${this.report.battleTitle} ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
  }

  private currentCampTitle() {
    const battleId = this.currentCampBattleId();
    if (battleId === campBattleIds.thirtySixth) {
      return '정군산 전초전 후 군영';
    }
    if (battleId === campBattleIds.thirtyFifth) {
      return '양평관 정찰 후 군영';
    }
    if (battleId === campBattleIds.thirtyFourth) {
      return '가맹관 대면전 후 군영';
    }
    if (battleId === campBattleIds.thirtyThird) {
      return '성도 항복 후 군영';
    }
    if (battleId === campBattleIds.thirtySecond) {
      return '면죽관 압박전 후 군영';
    }
    if (battleId === campBattleIds.thirtyFirst) {
      return '낙성 본성 공략 후 군영';
    }
    if (battleId === campBattleIds.thirtieth) {
      return '낙봉파 매복전 후 군영';
    }
    if (battleId === campBattleIds.twentyNinth) {
      return '낙성 외곽전 후 군영';
    }
    if (battleId === campBattleIds.twentyEighth) {
      return '부수관 진입 후 군영';
    }
    if (battleId === campBattleIds.twentySeventh) {
      return '익주 원군로 후 군영';
    }
    if (battleId === campBattleIds.twentySixth) {
      return '장사 노장 대면 후 군영';
    }
    if (battleId === campBattleIds.twentyFifth) {
      return '무릉 산길 확보 후 군영';
    }
    if (battleId === campBattleIds.twentyFourth) {
      return '계양 설득전 후 군영';
    }
    if (battleId === campBattleIds.twentyThird) {
      return '형주 남부 진입 후 군영';
    }
    if (battleId === campBattleIds.twentySecond) {
      return '적벽 화공전 후 군영';
    }
    if (battleId === campBattleIds.twentyFirst) {
      return '적벽 전초전 후 군영';
    }
    if (battleId === campBattleIds.twentieth) {
      return '강동 사절 준비 군영';
    }
    if (battleId === campBattleIds.nineteenth) {
      return '장판파 돌파 후 군영';
    }
    if (battleId === campBattleIds.eighteenth) {
      return '박망파 승전 후 군영';
    }
    if (battleId === campBattleIds.seventeenth) {
      return '와룡 출려 후 군영';
    }
    if (battleId === campBattleIds.sixteenth) {
      return '형주 의탁 후 군영';
    }
    if (battleId === campBattleIds.fifteenth) {
      return '원소 의탁 후 군영';
    }
    if (battleId === campBattleIds.fourteenth) {
      return '서주 재기 후 군영';
    }
    if (battleId === campBattleIds.thirteenth) {
      return '하비 결전 후 군영';
    }
    if (battleId === campBattleIds.twelfth) {
      return '하비 포위 군영';
    }
    if (battleId === campBattleIds.eleventh) {
      return '허도 의탁 군영';
    }
    if (battleId === campBattleIds.tenth) {
      return '서주 상실 후 군영';
    }
    if (battleId === campBattleIds.ninth) {
      return '서주 성문 군영';
    }
    if (battleId === campBattleIds.eighth) {
      return '소패 방위 군영';
    }
    if (battleId === campBattleIds.seventh) {
      return '서주 인수 준비 군영';
    }
    if (battleId === campBattleIds.fifth) {
      return '공손찬 진영 군영';
    }
    if (battleId === campBattleIds.sixth) {
      return '서주 원군 준비 군영';
    }
    if (battleId === campBattleIds.fourth) {
      return '광종 토벌 후 군영';
    }
    if (battleId === campBattleIds.third) {
      return '광종 구원로 군영';
    }
    if (battleId === campBattleIds.second) {
      return '황건 잔당 추격 군영';
    }
    return '탁현 의용군 군영';
  }

  private currentCampBattleId() {
    return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id;
  }

  private availableCampDialogues() {
    const battleId = this.currentCampBattleId();
    const dialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
    return dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
  }

  private completedAvailableDialogues() {
    const completed = this.completedCampDialogues();
    return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
  }

  private availableCampVisits() {
    const battleId = this.currentCampBattleId();
    return campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId));
  }

  private completedAvailableVisits() {
    const completed = this.completedCampVisits();
    return this.availableCampVisits().filter((visit) => completed.includes(visit.id));
  }

  private renderStaticButtons() {
    this.addTabButton('장수', 'status', 654, 38);
    this.addTabButton('대화', 'dialogue', 732, 38);
    this.addTabButton('방문', 'visit', 810, 38);
    this.addTabButton('정비', 'supplies', 888, 38);
    this.addTabButton('연표', 'progress', 966, 38);
    this.addCommandButton('저장', 1044, 38, 76, () => {
      soundDirector.playSelect();
      this.campaign = saveCampaignState(getCampaignState());
      this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
      this.render();
    });
    this.addCommandButton('다음 이야기', 1160, 38, 142, () => {
      soundDirector.playSelect();
      this.showSortiePrep();
    });
  }

  private addTabButton(label: string, tab: CampTab, x: number, y: number) {
    const bg = this.add.rectangle(x, y, 76, 34, 0x182431, 0.94);
    bg.setStrokeStyle(1, palette.blue, 0.62);
    bg.setInteractive({ useHandCursor: true });
    bg.on('pointerdown', () => {
      soundDirector.playSelect();
      this.activeTab = tab;
      this.render();
    });

    const text = this.add.text(x, y, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f2e3bf',
      fontStyle: '700'
    });
    text.setOrigin(0.5);
    text.setInteractive({ useHandCursor: true });
    text.on('pointerdown', () => {
      soundDirector.playSelect();
      this.activeTab = tab;
      this.render();
    });
    this.tabButtons.push({ tab, bg, text });
    this.updateTabButtons();
  }

  private updateTabButtons() {
    this.tabButtons.forEach(({ tab, bg, text }) => {
      const active = this.activeTab === tab;
      bg.setFillStyle(active ? 0x25384a : 0x182431, active ? 0.98 : 0.94);
      bg.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62);
      text.setColor(active ? '#fff2b8' : '#f2e3bf');
    });
  }

  private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) {
    const bg = this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96);
    bg.setStrokeStyle(1, palette.gold, 0.8);
    bg.setInteractive({ useHandCursor: true });
    bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
    bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
    bg.on('pointerdown', action);

    const text = this.add.text(x, y, label, {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: '16px',
      color: '#f2e3bf',
      fontStyle: '700'
    });
    text.setOrigin(0.5);
    text.setInteractive({ useHandCursor: true });
    text.on('pointerdown', action);
  }

  private render() {
    this.clearContent();
    this.visitedTabs.add(this.activeTab);
    this.updateTabButtons();
    this.renderUnitColumn();
    this.renderReportPanel();

    if (this.activeTab === 'dialogue') {
      this.renderDialoguePanel();
      return;
    }

    if (this.activeTab === 'visit') {
      this.renderVisitPanel();
      return;
    }

    if (this.activeTab === 'supplies') {
      this.renderSuppliesPanel();
      return;
    }

    if (this.activeTab === 'progress') {
      this.renderProgressPanel();
      return;
    }

    this.renderStatusPanel();
  }

  private showSortiePrep() {
    this.hideSortiePrep();
    this.campaign = getCampaignState();
    this.report = this.campaign.firstBattleReport ?? this.report;
    this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);

    const depth = 40;
    const width = 1188;
    const height = 676;
    const x = Math.floor((this.scale.width - width) / 2);
    const y = Math.floor((this.scale.height - height) / 2);

    const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
    shade.setOrigin(0);
    shade.setDepth(depth);
    shade.setInteractive();

    const panel = this.trackSortie(this.add.rectangle(x, y, width, height, 0x101820, 0.98));
    panel.setOrigin(0);
    panel.setDepth(depth + 1);
    panel.setStrokeStyle(3, palette.gold, 0.92);

    const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true)));
    title.setDepth(depth + 2);
    const subtitle = this.trackSortie(
      this.add.text(x + 34, y + 70, '대화, 보급, 상인 정비를 마친 뒤 함께 출전할 무장을 선택합니다.', this.textStyle(15, '#d4dce6'))
    );
    subtitle.setDepth(depth + 2);

    this.renderSortieBriefing(x + 34, y + 104, 520, 168, depth + 2);
    this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2);
    this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2);
    this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2);
    this.renderSortieRewardHint(x + 34, y + 596, 808, 52, depth + 2);

    this.addSortieButton('군영으로', x + width - 298, y + height - 42, 124, () => {
      soundDirector.playSelect();
      this.hideSortiePrep();
    }, depth + 3);
    this.addSortieButton('출진', x + width - 152, y + height - 42, 124, () => {
      soundDirector.playSelect();
      this.startVictoryStory();
    }, depth + 3);
  }

  private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.blue, 0.48);

    const briefing = this.nextSortieBriefing();
    this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
    this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
    this.trackSortie(
      this.add.text(x + 18, y + 84, briefing.description, {
        ...this.textStyle(14, '#d4dce6'),
        wordWrap: { width: width - 36, useAdvancedWrap: true },
        lineSpacing: 4
      })
    ).setDepth(depth + 1);
    this.trackSortie(
      this.add.text(x + 18, y + height - 30, `편성 주안점: ${this.nextSortieRule().note}`, {
        ...this.textStyle(12, '#d8b15f', true),
        wordWrap: { width: width - 36, useAdvancedWrap: true }
      })
    ).setDepth(depth + 1);
  }

  private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.gold, 0.5);
    this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);

    this.sortieChecklist().forEach((item, index) => {
      const rowY = y + 42 + index * 27;
      const color = item.complete ? '#a8ffd0' : '#ffdf7b';
      const status = item.complete ? '완료' : '주의';
      this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1);
      this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1);
      const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f')));
      detail.setDepth(depth + 1);
    });
  }

  private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.blue, 0.48);
    const allies = this.sortieAllies();
    const selectedCount = this.selectedSortieUnitIds.length;
    const maxUnits = this.sortieMaxUnits();
    const denseRoster = allies.length > 12;
    const rowGap = denseRoster ? 17 : allies.length > 7 ? 30 : allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48;
    const rowHeight = denseRoster ? 15 : allies.length > 7 ? 24 : rowGap - 6;
    this.trackSortie(
      this.add.text(x + 18, y + 14, `출전 무장 선택  ${selectedCount}/${maxUnits}`, this.textStyle(18, '#f2e3bf', true))
    ).setDepth(depth + 1);
    this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);

    allies.forEach((unit, index) => {
      const rowY = y + (denseRoster ? 48 : 50) + index * rowGap;
      const selected = this.isSortieSelected(unit.id);
      const required = requiredSortieUnitIds.has(unit.id);
      const recruited = !foundingSortieUnitIds.has(unit.id);
      const recommendation = this.sortieRecommendation(unit.id);
      const summary = this.sortieUnitTacticalSummary(unit);
      const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
      row.setOrigin(0);
      row.setDepth(depth + 1);
      row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38);
      row.setInteractive({ useHandCursor: !required });
      row.on('pointerdown', () => this.toggleSortieUnit(unit.id));

      const marker = selected ? '출전' : '대기';
      const markerLabel = required ? '필수' : recommendation ? '추천' : recruited ? '합류' : marker;
      this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(denseRoster ? 10 : 12, required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
      this.trackSortie(this.add.text(x + (denseRoster ? 78 : 84), rowY, `${unit.name}  Lv ${unit.level}`, this.textStyle(denseRoster ? 12 : 14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
      this.trackSortie(this.add.text(x + (denseRoster ? 194 : 214), rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, denseRoster ? 17 : 19), this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
      this.trackSortie(this.add.text(x + 424, rowY, denseRoster ? this.compactText(summary.statLine, 23) : `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
      if (!denseRoster) {
        this.drawSortieBar(x + 514, rowY + 6, 74, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
      }
      this.trackSortie(this.add.text(x + 610, rowY, this.compactText(denseRoster ? summary.bondLine : summary.terrainLine, denseRoster ? 15 : 13), this.textStyle(11, selected ? (denseRoster ? '#a8ffd0' : '#d8b15f') : '#77818c', true))).setDepth(depth + 2);

      if (!denseRoster) {
        this.renderSortieEquipmentIcons(unit, x + 84, rowY + 19, selected, depth + 2);
        this.trackSortie(this.add.text(x + 162, rowY + 18, this.compactText(summary.equipmentLine, 34), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
        this.trackSortie(this.add.text(x + 424, rowY + 18, this.compactText(summary.statLine, 23), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
        this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(summary.bondLine, 15), this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2);
      }
    });
  }

  private renderSortieFormationPlan(x: number, y: number, width: number, height: number, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.gold, 0.5);

    const plan = this.sortiePlanSummary();
    this.trackSortie(this.add.text(x + 18, y + 14, '전술 편성', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
    this.trackSortie(
      this.add.text(x + width - 18, y + 17, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))
    ).setOrigin(1, 0).setDepth(depth + 1);

    this.renderSortieDeploymentMap(x + 18, y + 46, width - 36, 92, depth + 1);

    const summaryLines = [
      plan.deploymentLine,
      `${plan.recommendationLine} · 공명 ${plan.activeBondCount}개`,
      plan.recruitedLine,
      plan.objectiveLine
    ];
    summaryLines.forEach((line, index) => {
      this.trackSortie(this.add.text(x + 18, y + 146 + index * 16, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
    });

    plan.formationSlots.forEach((slot, index) => {
      const column = index % 2;
      const row = Math.floor(index / 2);
      const rowX = x + 18 + column * 176;
      const rowY = y + 208 + row * 24;
      const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치';
      const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf';
      this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1);
      this.trackSortie(this.add.text(rowX + 42, rowY, this.compactText(names, 13), this.textStyle(11, '#c8d2dd'))).setDepth(depth + 1);
      this.trackSortie(this.add.text(rowX + 42, rowY + 13, this.compactText(slot.description, 14), this.textStyle(10, '#87919c'))).setDepth(depth + 1);
    });

    const warning = plan.warnings[0] ?? plan.reserveLine;
    this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1);
  }

  private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) {
    equipmentSlots.forEach((slot, index) => {
      const state = unit.equipment[slot];
      const item = getItem(state.itemId);
      const iconFrame = this.trackSortie(this.add.rectangle(x + index * 23, y, 18, 18, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92));
      iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34);
      iconFrame.setDepth(depth);
      const icon = this.trackSortie(this.add.image(x + index * 23, y, `item-${item.id}`));
      icon.setDisplaySize(14, 14);
      icon.setAlpha(selected ? 1 : 0.52);
      icon.setDepth(depth + 1);
    });
  }

  private sortieUnitTacticalSummary(unit: UnitData): SortieUnitTacticalSummary {
    return {
      statLine: `무 ${unit.stats.might} 지 ${unit.stats.intelligence} 통 ${unit.stats.leadership} 민 ${unit.stats.agility} 운 ${unit.stats.luck}`,
      equipmentLine: this.sortieEquipmentLine(unit),
      bondLine: this.sortieBondLine(unit),
      terrainLine: this.sortieTerrainLine(unit)
    };
  }

  private sortieEquipmentLine(unit: UnitData) {
    return equipmentSlots
      .map((slot) => {
        const state = unit.equipment[slot];
        const item = getItem(state.itemId);
        return `${item.name} Lv${state.level}`;
      })
      .join(' · ');
  }

  private sortieBondLine(unit: UnitData) {
    const selected = new Set(this.selectedSortieUnitIds);
    const activeBonds = this.currentBonds()
      .filter((bond) => bond.unitIds.includes(unit.id))
      .map((bond) => {
        const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0];
        return {
          bond,
          partnerId,
          partnerName: this.unitName(partnerId),
          partnerSelected: selected.has(partnerId)
        };
      })
      .sort((a, b) => Number(b.partnerSelected) - Number(a.partnerSelected) || b.bond.level - a.bond.level || b.bond.exp - a.bond.exp);

    const deployedBond = activeBonds.find((entry) => entry.partnerSelected);
    if (deployedBond) {
      return `공명 ${deployedBond.partnerName} Lv${deployedBond.bond.level}`;
    }

    const strongestBond = activeBonds[0];
    if (strongestBond) {
      return `대기 공명 ${strongestBond.partnerName} Lv${strongestBond.bond.level}`;
    }

    return '공명 관계 없음';
  }

  private sortieTerrainLine(unit: UnitData) {
    const scenario = this.nextSortieScenario();
    if (!scenario) {
      return `${getUnitClass(unit.classKey).role}`;
    }

    const terrainCounts = this.passableTerrainCounts(scenario);
    const unitClass = getUnitClass(unit.classKey);
    const entries = Object.entries(terrainCounts) as [TerrainType, number][];
    const total = entries.reduce((sum, [, count]) => sum + count, 0);
    if (total <= 0) {
      return `${unitClass.role}`;
    }

    const weightedScore =
      entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total;
    const grade = weightedScore >= 110 ? '최적' : weightedScore >= 102 ? '유리' : weightedScore >= 95 ? '보통' : '주의';
    const bestTerrains = entries
      .sort((a, b) => (unitClass.terrainRatings[b[0]] ?? 0) - (unitClass.terrainRatings[a[0]] ?? 0) || b[1] - a[1])
      .slice(0, 2)
      .map(([terrain]) => terrainRules[terrain].label)
      .join('/');

    return `지형 ${grade}${bestTerrains ? ` · ${bestTerrains}` : ''}`;
  }

  private passableTerrainCounts(scenario: BattleScenarioDefinition): SortieTerrainCounts {
    const cached = this.terrainCountCache.get(scenario.id);
    if (cached) {
      return cached;
    }

    const counts: SortieTerrainCounts = {};
    scenario.map.terrain.forEach((row) => {
      row.forEach((terrain) => {
        if (terrainRules[terrain].passable === false) {
          return;
        }
        counts[terrain] = (counts[terrain] ?? 0) + 1;
      });
    });
    this.terrainCountCache.set(scenario.id, counts);
    return counts;
  }

  private nextSortieScenario() {
    const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId;
    return nextBattleId ? getBattleScenario(nextBattleId) : undefined;
  }

  private nextSortieRule(scenario = this.nextSortieScenario()) {
    if (!scenario) {
      return defaultSortieRule;
    }
    return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
  }

  private sortieMaxUnits(scenario = this.nextSortieScenario()) {
    return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length);
  }

  private sortieRecommendation(unitId: string, scenario = this.nextSortieScenario()) {
    return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId);
  }

  private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number) {
    const scenario = this.nextSortieScenario();
    const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.9));
    frame.setOrigin(0);
    frame.setDepth(depth);
    frame.setStrokeStyle(1, palette.blue, 0.48);

    if (!scenario) {
      this.trackSortie(this.add.text(x + 14, y + 34, '다음 전투 배치 정보가 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
      return;
    }

    const mapWidth = scenario.map.width;
    const mapHeight = scenario.map.height;
    const cell = Math.min((width - 16) / mapWidth, (height - 16) / mapHeight);
    const originX = x + Math.floor((width - mapWidth * cell) / 2);
    const originY = y + Math.floor((height - mapHeight * cell) / 2);
    const graphics = this.trackSortie(this.add.graphics());
    graphics.setDepth(depth + 1);

    scenario.map.terrain.forEach((row, rowIndex) => {
      row.forEach((terrain, columnIndex) => {
        const rule = terrainRules[terrain];
        graphics.fillStyle(rule.color, terrain === 'river' || terrain === 'cliff' ? 0.82 : 0.58);
        graphics.fillRect(originX + columnIndex * cell, originY + rowIndex * cell, Math.ceil(cell), Math.ceil(cell));
      });
    });

    const selected = new Set(this.selectedSortieUnitIds);
    scenario.units.forEach((unit) => {
      if (unit.faction !== 'enemy' && !selected.has(unit.id)) {
        return;
      }
      const dotX = originX + (unit.x + 0.5) * cell;
      const dotY = originY + (unit.y + 0.5) * cell;
      const color = unit.faction === 'enemy' ? 0xd35f4a : unit.id === 'liu-bei' ? palette.gold : palette.green;
      const alpha = unit.faction === 'enemy' ? 0.72 : 0.96;
      const dot = this.trackSortie(this.add.circle(dotX, dotY, unit.faction === 'enemy' ? 2.2 : 3.7, color, alpha));
      dot.setDepth(depth + 2);
      if (unit.faction !== 'enemy') {
        dot.setStrokeStyle(1, 0x05070a, 0.8);
      }
    });

    this.trackSortie(this.add.text(x + 10, y + height - 16, '녹색: 출전 아군 · 붉은색: 적 배치', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2);
  }

  private sortiePlanSummary(): SortiePlanSummary {
    const allAllies = this.sortieAllies();
    const selectedUnits = this.selectedSortieUnits();
    const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
    const scenario = this.nextSortieScenario();
    const rule = this.nextSortieRule(scenario);
    const availableIds = new Set(allAllies.map((unit) => unit.id));
    const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
    const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId));
    const recruitedUnits = allAllies.filter((unit) => !foundingSortieUnitIds.has(unit.id));
    const selectedRecruitedCount = recruitedUnits.filter((unit) => selectedIds.has(unit.id)).length;
    const reserveUnits = allAllies.filter((unit) => !selectedIds.has(unit.id));
    const reserveNames = reserveUnits.map((unit) => unit.name).join(', ');
    const terrainScores = selectedUnits.map((unit) => this.sortieTerrainScore(unit, scenario));
    const terrainScore = terrainScores.length
      ? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length)
      : 0;
    const terrainGrade = this.sortieTerrainGrade(terrainScore);
    const activeBondCount = this.currentBonds().filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length;
    const recommendedSelectedCount = recommended.filter((entry) => selectedIds.has(entry.unitId)).length;
    const roleCounts = selectedUnits.reduce(
      (counts, unit) => {
        counts[this.sortieFormationRole(unit)] += 1;
        return counts;
      },
      { front: 0, flank: 0, support: 0, reserve: 0 } as Record<SortieFormationRole, number>
    );
    const formationSlots = sortieFormationSlotDefinitions.map((slot) => ({
      ...slot,
      unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name)
    }));
    const warnings: string[] = [];
    const maxCount = this.sortieMaxUnits(scenario);

    if (selectedUnits.length < maxCount) {
      warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`);
    }
    if (missingRecommended.length > 0) {
      warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')} 대기 중입니다.`);
    }
    if (roleCounts.front <= 0) {
      warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.');
    }
    if (roleCounts.flank <= 0) {
      warnings.push('돌파 병과가 없어 추격과 측면 압박이 느릴 수 있습니다.');
    }
    if (roleCounts.support <= 0) {
      warnings.push('후원 병과가 없어 회복, 보급, 원거리 견제가 약합니다.');
    }
    if (activeBondCount <= 0 && selectedUnits.length > 1) {
      warnings.push('함께 출전하는 공명 조합이 없습니다.');
    }
    if (terrainScore > 0 && terrainScore < 98) {
      warnings.push('선택 무장의 평균 지형 적성이 낮습니다.');
    }

    return {
      selectedCount: selectedUnits.length,
      maxCount,
      terrainScore,
      terrainGrade,
      activeBondCount,
      recruitedCount: recruitedUnits.length,
      selectedRecruitedCount,
      reserveCount: reserveUnits.length,
      recommendedSelectedCount,
      recommendedTotal: recommended.length,
      deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
      recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음',
      recruitedLine:
        recruitedUnits.length > 0
          ? `합류 무장 ${selectedRecruitedCount}/${recruitedUnits.length} · 대기 ${reserveUnits.length}`
          : '합류 무장 없음',
      reserveLine: reserveUnits.length > 0 ? `대기 무장: ${reserveNames}` : '전원 출전 중입니다.',
      objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
      formationSlots,
      warnings
    };
  }

  private sortieFormationRole(unit: UnitData): SortieFormationRole {
    if (unit.classKey === 'cavalry') {
      return 'flank';
    }
    if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') {
      return 'support';
    }
    if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') {
      return 'front';
    }
    return 'reserve';
  }

  private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) {
    if (!scenario) {
      return 100;
    }
    const terrainCounts = this.passableTerrainCounts(scenario);
    const entries = Object.entries(terrainCounts) as [TerrainType, number][];
    const total = entries.reduce((sum, [, count]) => sum + count, 0);
    if (total <= 0) {
      return 100;
    }
    const unitClass = getUnitClass(unit.classKey);
    return Math.round(entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total);
  }

  private sortieTerrainGrade(score: number) {
    if (score >= 110) {
      return '최적';
    }
    if (score >= 102) {
      return '유리';
    }
    if (score >= 95) {
      return '보통';
    }
    return score > 0 ? '주의' : '미정';
  }

  private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9));
    bg.setOrigin(0);
    bg.setDepth(depth);
    bg.setStrokeStyle(1, palette.gold, 0.4);
    const availableDialogues = this.availableCampDialogues();
    const completedDialogues = this.completedAvailableDialogues().length;
    const availableVisits = this.availableCampVisits();
    const completedVisits = this.completedAvailableVisits().length;
    const inventoryLabels = this.inventoryLabels();
    const selectedUnits = this.selectedSortieUnits();
    const selectedPreview = selectedUnits.slice(0, 3).map((unit) => unit.name).join(', ');
    const selectedSummary = selectedUnits.length > 0 ? `${selectedUnits.length}명 (${selectedPreview}${selectedUnits.length > 3 ? ' 외' : ''})` : '없음';
    const inventorySummary =
      inventoryLabels.length > 0 ? `${inventoryLabels.slice(0, 4).join(', ')}${inventoryLabels.length > 4 ? ' 외' : ''}` : '없음';
    const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
    this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
    this.trackSortie(
      this.add.text(
        x + 16,
        y + 30,
        `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedSummary} · 보유 ${inventorySummary}`,
        this.textStyle(12, '#d4dce6')
      )
    ).setDepth(depth + 1);
  }

  private nextSortieBriefing() {
    const flow = getSortieFlow(this.campaign?.latestBattleId);
    return {
      eyebrow: flow.eyebrow,
      title: flow.title,
      description: flow.description
    };
  }

  private sortieChecklist(): SortieChecklistItem[] {
    const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
    const liuBei = units.find((unit) => unit.id === 'liu-bei');
    const injured = units.filter((unit) => unit.hp < unit.maxHp);
    const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
    const availableDialogues = this.availableCampDialogues();
    const completedDialogues = this.completedAvailableDialogues().length;
    const availableVisits = this.availableCampVisits();
    const completedVisits = this.completedAvailableVisits().length;
    const selected = this.selectedSortieUnits();
    return [
      {
        label: '유비 생존',
        complete: Boolean(liuBei && liuBei.hp > 0),
        detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음'
      },
      {
        label: '부상 장수 확인',
        complete: injured.length === 0,
        detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장`
      },
      {
        label: '출전 구성',
        complete: selected.some((unit) => unit.id === 'liu-bei') && selected.length > 0,
        detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : '출전 무장 선택 필요'
      },
      {
        label: '소모품 보유',
        complete: supplyCount > 0,
        detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인'
      },
      {
        label: '공명 대화',
        complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
        detail: `${completedDialogues}/${availableDialogues.length} 완료`
      },
      {
        label: '현지 방문',
        complete: availableVisits.length === 0 || completedVisits > 0,
        detail: availableVisits.length === 0 ? '방문 없음' : `${completedVisits}/${availableVisits.length} 완료`
      },
      {
        label: '장비 상태',
        complete: this.visitedTabs.has('status'),
        detail: this.visitedTabs.has('status') ? '장수 탭 확인됨' : '장수 탭 확인 권장'
      }
    ];
  }

  private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
    const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96));
    bg.setDepth(depth);
    bg.setStrokeStyle(1, label === '출진' ? palette.gold : palette.blue, 0.78);
    bg.setInteractive({ useHandCursor: true });
    bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
    bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
    bg.on('pointerdown', action);

    const text = this.trackSortie(this.add.text(x, y, label, this.textStyle(15, '#f2e3bf', true)));
    text.setOrigin(0.5);
    text.setDepth(depth + 1);
    text.setInteractive({ useHandCursor: true });
    text.on('pointerdown', action);
  }

  private startVictoryStory() {
    const flow = getSortieFlow(this.campaign?.latestBattleId);
    if (!this.ensureSortieSelectionSaved()) {
      this.showCampNotice('출전할 무장을 선택하세요. 유비는 반드시 출전해야 합니다.');
      return;
    }

    if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) {
      this.hideSortiePrep();
      if (flow.pages.length > 0) {
        this.scene.start('StoryScene', {
          pages: flow.pages,
          nextScene: 'CampScene'
        });
        return;
      }
      this.showCampNotice(flow.unavailableNotice ?? '다음 장은 아직 준비 중입니다. 군영에서 성장 상태를 정비하세요.');
      return;
    }

    if (flow.nextBattleId === campBattleIds.seventeenth && this.wolongClueCount() <= 0) {
      this.hideSortiePrep();
      this.activeTab = 'visit';
      this.render();
      this.showCampNotice('와룡의 단서를 먼저 모으십시오. 방문 탭에서 형주 선비들의 말을 들어 보세요.');
      return;
    }

    markCampaignStep(flow.campaignStep);
    this.scene.start('StoryScene', {
      pages: flow.pages,
      nextScene: 'BattleScene',
      nextSceneData: { battleId: flow.nextBattleId }
    });
  }

  private hideSortiePrep() {
    this.sortieObjects.forEach((object) => object.destroy());
    this.sortieObjects = [];
  }

  private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
    this.sortieObjects.push(object);
    return object;
  }

  private drawSortieBar(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) {
    const track = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
    track.setOrigin(0);
    track.setDepth(depth);
    const fill = this.trackSortie(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
    fill.setOrigin(0);
    fill.setDepth(depth + 1);
  }

  private renderUnitColumn() {
    const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
    const availableHeight = 462;
    const rowGap = allies.length > 1 ? Math.min(132, Math.floor(availableHeight / allies.length)) : 132;
    const cardHeight = Math.min(118, Math.max(48, rowGap - 8));
    const compact = cardHeight < 88;
    this.renderRosterColumnSummary(42, 86, 318, allies);
    allies.forEach((unit, index) => {
      const x = 42;
      const y = 120 + index * rowGap;
      const active = this.selectedUnitId === unit.id;
      const rosterStatus = this.unitRosterStatus(unit);
      const training = this.latestReserveTraining(unit.id);
      const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
      bg.setOrigin(0);
      bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46);
      bg.setInteractive({ useHandCursor: true });
      bg.on('pointerdown', () => {
        soundDirector.playSelect();
        this.selectedUnitId = unit.id;
        this.render();
      });

      const portraitKey = portraitByUnitId[unit.id];
      const portraitCenterY = y + cardHeight / 2;
      const portraitSize = compact ? 40 : 84;
      if (portraitKey) {
        const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey]));
        portrait.setDisplaySize(portraitSize, portraitSize);
      } else {
        this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit);
      }

      const textX = compact ? x + 102 : x + 116;
      this.track(this.add.text(textX, y + (compact ? 7 : 17), `${unit.name}  Lv ${unit.level}`, this.textStyle(compact ? 14 : 20, '#f2e3bf', true)));
      this.track(this.add.text(textX, y + (compact ? 27 : 47), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 10 : 13, '#d4dce6')));
      const expLine = training ? `경험 ${unit.exp}/100 · 대기 +${training.expGained}` : `경험 ${unit.exp}/100`;
      this.track(this.add.text(textX, y + (compact ? 40 : 72), expLine, this.textStyle(compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true)));
      this.drawBar(textX, y + cardHeight - (compact ? 10 : 17), compact ? 142 : 176, compact ? 5 : 8, unit.exp / 100, training ? palette.green : palette.gold);
      const badgeColor = rosterStatus.tone === 'selected' ? '#a8ffd0' : rosterStatus.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
      const badge = this.track(this.add.text(x + 302, y + (compact ? 7 : 17), rosterStatus.label, this.textStyle(compact ? 10 : 11, badgeColor, true)));
      badge.setOrigin(1, 0);
    });
  }

  private renderRosterColumnSummary(x: number, y: number, width: number, allies: UnitData[]) {
    const selected = this.selectedSortieUnits();
    const reserveCount = Math.max(0, allies.length - selected.length);
    const recentTrainingCount = this.latestReserveTrainingAwards().length;
    const bg = this.track(this.add.rectangle(x, y, width, 26, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.38);
    this.track(this.add.text(x + 12, y + 6, `전군 ${allies.length} · 출전 ${selected.length} · 대기 ${reserveCount}`, this.textStyle(11, '#f2e3bf', true)));
    const trainingText = this.track(
      this.add.text(
        x + width - 12,
        y + 6,
        recentTrainingCount > 0 ? `최근 훈련 ${recentTrainingCount}` : '훈련 대기',
        this.textStyle(11, recentTrainingCount > 0 ? '#a8ffd0' : '#9fb0bf', true)
      )
    );
    trainingText.setOrigin(1, 0);
  }

  private unitRosterStatus(unit: UnitData) {
    const selected = this.isSortieSelected(unit.id);
    const recommendation = this.sortieRecommendation(unit.id);
    if (requiredSortieUnitIds.has(unit.id)) {
      return { label: '필수', tone: 'recommended' as const };
    }
    if (selected) {
      return { label: recommendation ? '추천 출전' : '출전', tone: 'selected' as const };
    }
    if (recommendation) {
      return { label: '추천 대기', tone: 'recommended' as const };
    }
    if (this.latestReserveTraining(unit.id)) {
      return { label: '훈련', tone: 'selected' as const };
    }
    return { label: '대기', tone: 'reserve' as const };
  }

  private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) {
    const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96));
    bg.setStrokeStyle(2, palette.gold, 0.58);
    const initial = this.track(this.add.text(x, y - radius * 0.34, unit.name.slice(0, 1), this.textStyle(Math.max(17, Math.floor(radius * 0.86)), '#f2e3bf', true)));
    initial.setOrigin(0.5);
    const label = this.track(this.add.text(x, y + radius * 0.34, unit.className, this.textStyle(Math.max(9, Math.floor(radius * 0.3)), '#d4dce6', true)));
    label.setOrigin(0.5);
  }

  private renderReportPanel() {
    const report = this.report;
    if (!report) {
      return;
    }

    const x = 42;
    const y = 590;
    const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.48);
    this.track(this.add.text(x + 18, y + 14, '전투 보고', this.textStyle(18, '#f2e3bf', true)));
    this.track(
      this.add.text(
        x + 18,
        y + 43,
        `MVP ${report.mvp?.name ?? '-'} · 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`,
        this.textStyle(15, '#d4dce6')
      )
    );
  }

  private renderProgressPanel() {
    const x = 394;
    const y = 120;
    const width = 828;
    const height = 444;
    const progress = this.campaignTimelineProgress();
    const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.56);

    this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true)));
    this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6')));

    const statY = y + 82;
    this.renderProgressStatCard('완료 전투', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176);
    this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198);
    this.renderProgressStatCard('최근 전투', progress.latestBattleTitle, x + 422, statY, 176);
    this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190);

    const completedIds = this.completedBattleIds();
    const listX = x + 24;
    const listY = y + 142;
    this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true)));
    campaignTimelineChapters.forEach((chapter, index) => {
      const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds);
      const rowY = listY + 30 + index * 31;
      const active = index === progress.activeChapterIndex;
      const complete = status === '완료';
      const row = this.track(this.add.rectangle(listX, rowY, 360, 27, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84));
      row.setOrigin(0);
      row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34);
      const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf';
      this.track(this.add.text(listX + 12, rowY + 5, status, this.textStyle(11, markerColor, true)));
      this.track(this.add.text(listX + 64, rowY + 4, chapter.title, this.textStyle(13, active ? '#f2e3bf' : '#d4dce6', active)));
      const countText = chapter.battleIds.length > 0
        ? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}`
        : '예정';
      const count = this.track(this.add.text(listX + 342, rowY + 5, countText, this.textStyle(11, markerColor, true)));
      count.setOrigin(1, 0);
    });

    this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds);
  }

  private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) {
    const bg = this.track(this.add.rectangle(x, y, width, 44, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.blue, 0.4);
    this.track(this.add.text(x + 12, y + 8, label, this.textStyle(11, '#9fb0bf', true)));
    const valueText = this.track(this.add.text(x + width - 12, y + 21, this.compactText(value, width > 190 ? 14 : 11), this.textStyle(14, '#f2e3bf', true)));
    valueText.setOrigin(1, 0);
  }

  private renderProgressChapterDetail(
    chapter: CampaignTimelineChapter,
    chapterIndex: number,
    x: number,
    y: number,
    width: number,
    height: number,
    completedIds: Set<string>
  ) {
    const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.48);
    const status = this.timelineChapterStatus(chapter, chapterIndex, this.campaignTimelineProgress().activeChapterIndex, completedIds);
    this.track(this.add.text(x + 18, y + 16, chapter.title, this.textStyle(21, '#f2e3bf', true)));
    const statusText = this.track(this.add.text(x + width - 18, y + 19, status, this.textStyle(13, this.timelineStatusColor(status), true)));
    statusText.setOrigin(1, 0);
    this.track(this.add.text(x + 18, y + 48, chapter.period, this.textStyle(13, '#d8b15f', true)));
    this.track(
      this.add.text(x + 18, y + 74, chapter.description, {
        ...this.textStyle(13, '#d4dce6'),
        wordWrap: { width: width - 36, useAdvancedWrap: true },
        lineSpacing: 2
      })
    );

    const completedCount = chapter.battleIds.filter((id) => completedIds.has(id)).length;
    const total = chapter.battleIds.length;
    const ratio = total > 0 ? completedCount / total : status === '다음' ? 0.08 : 0;
    this.track(this.add.text(x + 18, y + 136, total > 0 ? `전투 진행 ${completedCount}/${total}` : '아직 전투가 편성되지 않은 장입니다.', this.textStyle(13, '#f2e3bf', true)));
    this.drawBar(x + 18, y + 162, width - 36, 8, ratio, status === '완료' ? palette.green : palette.gold);

    if (chapter.battleIds.length > 0) {
      chapter.battleIds.forEach((battleId, index) => {
        const battle = getBattleScenario(battleId);
        const complete = completedIds.has(battleId);
        const rowY = y + 190 + index * 28;
        const row = this.track(this.add.rectangle(x + 18, rowY, width - 36, 24, complete ? 0x17231d : 0x151f2a, 0.86));
        row.setOrigin(0);
        row.setStrokeStyle(1, complete ? palette.green : palette.blue, complete ? 0.48 : 0.32);
        this.track(this.add.text(x + 30, rowY + 4, complete ? '완료' : '대기', this.textStyle(10, complete ? '#a8ffd0' : '#9fb0bf', true)));
        this.track(this.add.text(x + 78, rowY + 3, battle.title, this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete)));
        const objective = this.track(this.add.text(x + width - 30, rowY + 4, battle.victoryConditionLabel, this.textStyle(10, '#9fb0bf', true)));
        objective.setOrigin(1, 0);
      });
      return;
    }

    chapter.nextHints.forEach((hint, index) => {
      const rowY = y + 190 + index * 30;
      const dot = this.track(this.add.circle(x + 28, rowY + 11, 4, status === '다음' ? palette.gold : palette.blue, status === '다음' ? 0.92 : 0.48));
      dot.setStrokeStyle(1, 0x05070a, 0.6);
      this.track(this.add.text(x + 44, rowY + 3, hint, this.textStyle(13, status === '다음' ? '#f2e3bf' : '#9fb0bf', status === '다음')));
    });
  }

  private campaignTimelineProgress() {
    const completedIds = this.completedBattleIds();
    const totalKnown = campaignTimelineChapters.reduce((sum, chapter) => sum + chapter.battleIds.length, 0);
    const completedKnown = campaignTimelineChapters.reduce(
      (sum, chapter) => sum + chapter.battleIds.filter((id) => completedIds.has(id)).length,
      0
    );
    const activeChapterIndex = this.activeTimelineChapterIndex(completedIds);
    const activeChapter = campaignTimelineChapters[activeChapterIndex];
    const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId;
    const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId;
    return {
      completedKnown,
      totalKnown,
      activeChapterIndex,
      activeChapter,
      latestBattleTitle: latestBattleId ? getBattleScenario(latestBattleId).title : '없음',
      nextBattleTitle: nextBattleId ? getBattleScenario(nextBattleId).title : '준비 중'
    };
  }

  private activeTimelineChapterIndex(completedIds = this.completedBattleIds()) {
    const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId;
    const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId;
    if (!nextBattleId && latestBattleId) {
      const latestChapterIndex = campaignTimelineChapters.findIndex((chapter) =>
        chapter.battleIds.includes(latestBattleId as BattleScenarioId)
      );
      if (latestChapterIndex >= 0) {
        return latestChapterIndex;
      }
    }

    const index = campaignTimelineChapters.findIndex((chapter) => {
      if (chapter.battleIds.length === 0) {
        return true;
      }
      return chapter.battleIds.some((battleId) => !completedIds.has(battleId));
    });
    return index >= 0 ? index : campaignTimelineChapters.length - 1;
  }

  private timelineChapterStatus(chapter: CampaignTimelineChapter, index: number, activeIndex: number, completedIds = this.completedBattleIds()) {
    if (index < activeIndex) {
      return '완료';
    }
    if (index > activeIndex) {
      return '예정';
    }
    if (chapter.battleIds.length === 0) {
      return '다음';
    }
    return '진행';
  }

  private timelineStatusColor(status: string) {
    if (status === '완료') {
      return '#a8ffd0';
    }
    if (status === '다음') {
      return '#fff2b8';
    }
    if (status === '진행') {
      return '#d8b15f';
    }
    return '#9fb0bf';
  }

  private completedBattleIds() {
    return new Set(Object.keys(this.campaign?.battleHistory ?? {}));
  }

  private latestReserveTrainingAwards() {
    const latestBattleId = this.campaign?.latestBattleId;
    if (!latestBattleId) {
      return [];
    }
    return this.campaign?.battleHistory[latestBattleId]?.reserveTraining ?? [];
  }

  private latestReserveTraining(unitId: string) {
    return this.latestReserveTrainingAwards().find((entry) => entry.unitId === unitId);
  }

  private rosterCollectionSummary() {
    const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
    const selectedIds = new Set(this.selectedSortieUnitIds);
    const selectedCount = allies.filter((unit) => selectedIds.has(unit.id)).length;
    const recruitedCount = allies.filter((unit) => !foundingSortieUnitIds.has(unit.id)).length;
    const reserveTrainingAwards = this.latestReserveTrainingAwards();
    const averageLevel = allies.length > 0 ? Math.round(allies.reduce((sum, unit) => sum + unit.level, 0) / allies.length) : 0;
    return {
      total: allies.length,
      selectedCount,
      reserveCount: Math.max(0, allies.length - selectedCount),
      recruitedCount,
      averageLevel,
      reserveTrainingCount: reserveTrainingAwards.length,
      reserveTrainingExp: reserveTrainingAwards.reduce((sum, entry) => sum + entry.expGained, 0)
    };
  }

  private renderStatusPanel() {
    const unit = this.selectedUnit();
    if (!unit) {
      return;
    }

    const x = 394;
    const y = 120;
    const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.blue, 0.56);

    const unitClass = getUnitClass(unit.classKey);
    const portraitKey = portraitByUnitId[unit.id];
    const portraitFrame = this.track(this.add.rectangle(x + 84, y + 90, 122, 122, 0x0b1219, 0.92));
    portraitFrame.setStrokeStyle(2, palette.gold, 0.62);
    if (portraitKey) {
      const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey]));
      portrait.setDisplaySize(112, 112);
    } else {
      this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit);
    }

    this.track(this.add.text(x + 166, y + 22, `${unit.name}  Lv ${unit.level}`, this.textStyle(27, '#f2e3bf', true)));
    this.track(this.add.text(x + 166, y + 60, `${unit.className} · ${unitClass.family} · ${unitClass.role}`, this.textStyle(15, '#d8b15f', true)));
    this.track(
      this.add.text(x + 166, y + 91, unitClass.description, {
        ...this.textStyle(14, '#d4dce6'),
        wordWrap: { width: 324, useAdvancedWrap: true },
        lineSpacing: 2
      })
    );
    this.renderDetailGauge('병력', `${unit.hp}/${unit.maxHp}`, unit.hp / unit.maxHp, x + 166, y + 142, 300, palette.green);
    this.renderDetailGauge('경험', `${unit.exp}/100`, unit.exp / 100, x + 166, y + 168, 300, palette.gold);

    const statX = x + 524;
    const stats = [
      ['공격', unit.attack],
      ['이동', unit.move],
      ['무력', unit.stats.might],
      ['지력', unit.stats.intelligence],
      ['통솔', unit.stats.leadership],
      ['민첩', unit.stats.agility],
      ['운', unit.stats.luck]
    ] as const;
    stats.forEach(([label, value], index) => {
      this.renderStatCard(label, `${value}`, statX + (index % 2) * 128, y + 22 + Math.floor(index / 2) * 42, 116);
    });

    this.renderSelectedUnitRosterPlan(unit, x + 24, y + 196, 780);

    this.track(this.add.text(x + 24, y + 224, '장비', this.textStyle(20, '#f2e3bf', true)));
    equipmentSlots.forEach((slot, index) => {
      this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 256, 244, 98);
    });

    this.renderSelectedUnitBondPanel(unit, x + 24, y + 366, 780);
  }

  private renderDialoguePanel() {
    const x = 394;
    const y = 120;
    const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.58);
    this.track(this.add.text(x + 24, y + 22, '장수 대화', this.textStyle(24, '#f2e3bf', true)));
    this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));

    const dialogues = this.availableCampDialogues();
    const compactDialogueList = dialogues.length > 3;
    const dialogueRowGap = compactDialogueList ? 44 : 64;
    const dialogueRowHeight = compactDialogueList ? 36 : 48;
    if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
      this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
    }

    dialogues.forEach((dialogue, index) => {
      const completed = this.completedCampDialogues().includes(dialogue.id);
      const rowY = y + 96 + index * dialogueRowGap;
      const selected = this.selectedDialogueId === dialogue.id;
      const row = this.track(this.add.rectangle(x + 24, rowY, 320, dialogueRowHeight, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
      row.setOrigin(0);
      row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
      row.setInteractive({ useHandCursor: true });
      row.on('pointerdown', () => {
        soundDirector.playSelect();
        this.selectedDialogueId = dialogue.id;
        this.render();
      });
      this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
      const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
      this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])}  공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf')));
    });

    this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
    this.renderBondList(x + 24, y + 308, 320);
  }

  private renderSelectedDialogue(x: number, y: number, width: number, height: number) {
    const dialogues = this.availableCampDialogues();
    const dialogue = dialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? dialogues[0];
    if (!dialogue) {
      const empty = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
      empty.setOrigin(0);
      empty.setStrokeStyle(1, palette.blue, 0.42);
      this.track(this.add.text(x + 18, y + 16, '대화 없음', this.textStyle(20, '#f2e3bf', true)));
      this.track(this.add.text(x + 18, y + 54, '현재 군영에서 가능한 장수 대화가 없습니다.', this.textStyle(14, '#9fb0bf')));
      return;
    }
    const completed = this.completedCampDialogues().includes(dialogue.id);
    const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
    this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true)));
    this.track(
      this.add.text(x + 18, y + 52, dialogue.lines.join('\n\n'), {
        ...this.textStyle(15, '#d4dce6'),
        wordWrap: { width: width - 36, useAdvancedWrap: true },
        lineSpacing: 5
      })
    );

    if (completed) {
      const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
      done.setStrokeStyle(1, palette.green, 0.76);
      const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true)));
      text.setOrigin(0.5);
      return;
    }

    dialogue.choices.forEach((choice, index) => {
      const choiceY = y + height - 80 + index * 42;
      const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
      button.setStrokeStyle(1, palette.gold, 0.76);
      button.setInteractive({ useHandCursor: true });
      button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
      button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
      button.on('pointerdown', () => this.completeDialogue(dialogue, choice));

      const label = `${choice.label}  공명 +${dialogue.rewardExp + choice.rewardExp}`;
      const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(14, '#f2e3bf', true)));
      text.setOrigin(0.5);
      text.setInteractive({ useHandCursor: true });
      text.on('pointerdown', () => this.completeDialogue(dialogue, choice));
    });
  }

  private renderBondList(x: number, y: number, width: number) {
    const bonds = this.currentBonds();
    const compact = bonds.length > 3;
    const rowGap = compact ? 25 : 36;
    this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
    bonds.forEach((bond, index) => {
      const rowY = y + (compact ? 28 : 32) + index * rowGap;
      const first = this.unitName(bond.unitIds[0]);
      const second = this.unitName(bond.unitIds[1]);
      this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(compact ? 11 : 13, '#d4dce6')));
      this.drawBar(x + 136, rowY + 6, width - 136, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
    });
  }

  private renderVisitPanel() {
    const x = 394;
    const y = 120;
    const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.58);
    this.track(this.add.text(x + 24, y + 22, '형주 방문', this.textStyle(24, '#f2e3bf', true)));
    this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));

    const visits = this.availableCampVisits();
    if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
      this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
    }

    if (visits.length === 0) {
      const empty = this.track(this.add.rectangle(x + 24, y + 104, 780, 250, 0x0d141c, 0.92));
      empty.setOrigin(0);
      empty.setStrokeStyle(1, palette.blue, 0.42);
      this.track(this.add.text(x + 48, y + 130, '방문 가능한 장소 없음', this.textStyle(20, '#f2e3bf', true)));
      this.track(this.add.text(x + 48, y + 170, '현재 군영에서는 따로 방문할 장소가 없습니다. 장수 대화와 정비를 진행하십시오.', this.textStyle(14, '#9fb0bf')));
      return;
    }

    visits.forEach((visit, index) => {
      const completed = this.completedCampVisits().includes(visit.id);
      const rowY = y + 96 + index * 66;
      const selected = this.selectedVisitId === visit.id;
      const row = this.track(this.add.rectangle(x + 24, rowY, 320, 50, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
      row.setOrigin(0);
      row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
      row.setInteractive({ useHandCursor: true });
      row.on('pointerdown', () => {
        soundDirector.playSelect();
        this.selectedVisitId = visit.id;
        this.render();
      });
      this.track(this.add.text(x + 38, rowY + 8, visit.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
      this.track(this.add.text(x + 38, rowY + 29, visit.location, this.textStyle(12, '#9fb0bf')));
    });

    this.renderSelectedVisit(x + 372, y + 96, 416, 300);
    this.renderVisitPreparationSummary(x + 24, y + 322, 320);
  }

  private renderSelectedVisit(x: number, y: number, width: number, height: number) {
    const visits = this.availableCampVisits();
    const visit = visits.find((candidate) => candidate.id === this.selectedVisitId) ?? visits[0];
    if (!visit) {
      return;
    }

    const completed = this.completedCampVisits().includes(visit.id);
    const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
    this.track(this.add.text(x + 18, y + 14, visit.title, this.textStyle(20, '#f2e3bf', true)));
    this.track(this.add.text(x + 18, y + 42, visit.description, {
      ...this.textStyle(13, '#d8b15f', true),
      wordWrap: { width: width - 36, useAdvancedWrap: true },
      lineSpacing: 2
    }));
    this.track(
      this.add.text(x + 18, y + 88, visit.lines.join('\n\n'), {
        ...this.textStyle(14, '#d4dce6'),
        wordWrap: { width: width - 36, useAdvancedWrap: true },
        lineSpacing: 4
      })
    );

    if (completed) {
      const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
      done.setStrokeStyle(1, palette.green, 0.76);
      const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료', this.textStyle(15, '#a8ffd0', true)));
      text.setOrigin(0.5);
      return;
    }

    visit.choices.forEach((choice, index) => {
      const choiceY = y + height - 80 + index * 42;
      const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
      button.setStrokeStyle(1, palette.gold, 0.76);
      button.setInteractive({ useHandCursor: true });
      button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
      button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
      button.on('pointerdown', () => this.completeVisit(visit, choice));

      const label = `${choice.label}  ${this.visitRewardText(choice)}`;
      const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(13, '#f2e3bf', true)));
      text.setOrigin(0.5);
      text.setInteractive({ useHandCursor: true });
      text.on('pointerdown', () => this.completeVisit(visit, choice));
    });
  }

  private renderVisitPreparationSummary(x: number, y: number, width: number) {
    const visits = this.availableCampVisits();
    const completed = this.completedAvailableVisits();
    const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.blue, 0.42);
    this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true)));
    this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
    const insightCount = this.wolongClueCount();
    this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
  }

  private renderSuppliesPanel() {
    const x = 394;
    const y = 120;
    const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.blue, 0.56);
    this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true)));
    this.track(this.add.text(x + 24, y + 58, '소모품은 왼쪽에서 선택한 장수에게 사용됩니다.', this.textStyle(14, '#d4dce6')));

    const unit = this.selectedUnit();
    if (unit) {
      this.renderSupplyTargetCard(unit, x + 24, y + 94, 342, 86);
    }

    campSupplies.forEach((supply, index) => {
      this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390);
    });

    this.renderMerchantPanel(x + 24, y + 202, 342);

    this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
    const trophies = this.nonConsumableInventoryLabels();
    if (trophies.length === 0) {
      this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
    } else {
      trophies.forEach((reward, index) => {
        this.renderSupplyBox(reward, x + 390 + index * 128, y + 360);
      });
    }
  }

  private renderMerchantPanel(x: number, y: number, width: number) {
    const campaign = this.campaign ?? getCampaignState();
    const bg = this.track(this.add.rectangle(x, y, width, 210, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.48);
    this.track(this.add.text(x + 16, y + 10, `군영 상인 · 군자금 ${campaign.gold}`, this.textStyle(17, '#f2e3bf', true)));

    merchantItems.forEach((item, index) => {
      this.renderMerchantItem(item, x + 16, y + 44 + index * 52, width - 32);
    });
  }

  private renderMerchantItem(item: MerchantItemDefinition, x: number, y: number, width: number) {
    const campaign = this.campaign ?? getCampaignState();
    const canBuy = campaign.gold >= item.price;
    const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36);
    this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true)));
    this.track(this.add.text(x + 10, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true)));

    const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72));
    button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4);
    const text = this.track(this.add.text(x + width - 42, y + 20, '구매', this.textStyle(12, canBuy ? '#f2e3bf' : '#7f8994', true)));
    text.setOrigin(0.5);

    if (canBuy) {
      const action = () => this.buyMerchantItem(item);
      button.setInteractive({ useHandCursor: true });
      button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
      button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
      button.on('pointerdown', action);
      text.setInteractive({ useHandCursor: true });
      text.on('pointerdown', action);
    }
  }

  private buyMerchantItem(item: MerchantItemDefinition) {
    const campaign = this.campaign ?? getCampaignState();
    if (campaign.gold < item.price) {
      this.showCampNotice('군자금이 부족합니다.');
      return;
    }

    campaign.gold -= item.price;
    campaign.inventory[item.label] = (campaign.inventory[item.label] ?? 0) + 1;
    this.campaign = saveCampaignState(campaign);
    this.report = this.campaign.firstBattleReport ?? this.report;
    soundDirector.playSelect();
    this.showCampNotice(`${item.title} 구입 · 군자금 -${item.price}`);
    this.render();
  }

  private renderSupplyBox(label: string, x: number, y: number) {
    const bg = this.track(this.add.rectangle(x, y, 138, 50, 0x151f2a, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.52);
    this.track(this.add.text(x + 14, y + 15, label, this.textStyle(16, '#f2e3bf', true)));
  }

  private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) {
    const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.48);
    this.track(this.add.text(x + 16, y + 12, '사용 대상', this.textStyle(15, '#f2e3bf', true)));
    this.track(this.add.text(x + 16, y + 38, `${unit.name}  ${unit.className}  Lv ${unit.level}`, this.textStyle(16, '#d4dce6', true)));
    this.track(this.add.text(x + 16, y + 62, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#9fb0bf', true)));
    this.drawBar(x + 118, y + 68, width - 138, 8, unit.hp / unit.maxHp, palette.green);
  }

  private renderSupplyUseRow(supply: CampSupplyDefinition, x: number, y: number, width: number) {
    const unit = this.selectedUnit();
    const amount = this.inventoryAmount(supply.label);
    const enabled = Boolean(unit && this.canUseSupply(supply, unit));
    const bg = this.track(this.add.rectangle(x, y, width, 62, enabled ? 0x151f2a : 0x111820, enabled ? 0.92 : 0.76));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.62 : 0.38);

    this.track(this.add.text(x + 14, y + 10, `${supply.title}  x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true)));
    this.track(
      this.add.text(x + 14, y + 34, supply.description, {
        ...this.textStyle(12, enabled ? '#c8d2dd' : '#77818c'),
        wordWrap: { width: width - 128, useAdvancedWrap: true }
      })
    );

    const button = this.track(this.add.rectangle(x + width - 54, y + 31, 82, 30, enabled ? 0x1a2630 : 0x121922, enabled ? 0.96 : 0.72));
    button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.76 : 0.42);
    const buttonText = this.track(this.add.text(x + width - 54, y + 31, enabled ? '사용' : '불가', this.textStyle(13, enabled ? '#f2e3bf' : '#7f8994', true)));
    buttonText.setOrigin(0.5);

    if (enabled) {
      const action = () => this.useSupply(supply);
      button.setInteractive({ useHandCursor: true });
      button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
      button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
      button.on('pointerdown', action);
      buttonText.setInteractive({ useHandCursor: true });
      buttonText.on('pointerdown', action);
    }
  }

  private canUseSupply(supply: CampSupplyDefinition, unit: UnitData) {
    if (this.inventoryAmount(supply.label) <= 0) {
      return false;
    }
    if (supply.healHp > 0 && unit.hp < unit.maxHp) {
      return true;
    }
    return supply.bondExp > 0 && this.currentBonds().some((bond) => bond.unitIds.includes(unit.id));
  }

  private useSupply(supply: CampSupplyDefinition) {
    const campaign = this.campaign ?? getCampaignState();
    const target = campaign.roster.find((unit) => unit.id === this.selectedUnitId);
    if (!target) {
      this.showCampNotice('사용할 장수를 선택하세요.');
      return;
    }

    if ((campaign.inventory[supply.label] ?? 0) <= 0) {
      this.showCampNotice(`${supply.title}이 없습니다.`);
      return;
    }

    const previousHp = target.hp;
    target.hp = Math.min(target.maxHp, target.hp + supply.healHp);
    const hpGain = target.hp - previousHp;
    const bondGain = this.applySupplyBondExp(campaign, target.id, supply.bondExp);

    if (hpGain <= 0 && bondGain <= 0) {
      this.showCampNotice(`${target.name}에게 지금은 사용할 필요가 없습니다.`);
      return;
    }

    campaign.inventory[supply.label] -= 1;
    if (campaign.inventory[supply.label] <= 0) {
      delete campaign.inventory[supply.label];
    }
    this.syncReportUnitHp(campaign, target.id, target.hp);

    this.campaign = saveCampaignState(campaign);
    this.report = this.campaign.firstBattleReport ?? this.report;
    soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });

    const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
    const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : '';
    this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`);
    this.render();
  }

  private applySupplyBondExp(campaign: CampaignState, unitId: string, amount: number) {
    if (amount <= 0) {
      return 0;
    }

    let affected = 0;
    campaign.bonds
      .filter((bond) => bond.unitIds.includes(unitId))
      .forEach((bond) => {
        this.advanceCampBond(bond, amount);
        affected += 1;
        const reportBond = campaign.firstBattleReport?.bonds.find((candidate) => candidate.id === bond.id);
        if (reportBond) {
          reportBond.level = bond.level;
          reportBond.exp = bond.exp;
          reportBond.battleExp = bond.battleExp;
        }
      });

    return affected * amount;
  }

  private advanceCampBond(bond: CampaignState['bonds'][number], amount: number) {
    bond.battleExp += amount;
    bond.exp += amount;
    while (bond.exp >= 100) {
      bond.exp -= 100;
      bond.level = Math.min(100, bond.level + 1);
    }
  }

  private syncReportUnitHp(campaign: CampaignState, unitId: string, hp: number) {
    const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId);
    if (reportUnit) {
      reportUnit.hp = hp;
    }
  }

  private renderDetailGauge(label: string, value: string, ratio: number, x: number, y: number, width: number, color: number) {
    this.track(this.add.text(x, y - 2, label, this.textStyle(13, '#9fb0bf', true)));
    this.drawBar(x + 54, y + 6, width - 134, 8, ratio, color);
    const valueText = this.track(this.add.text(x + width, y - 3, value, this.textStyle(13, '#f2e3bf', true)));
    valueText.setOrigin(1, 0);
  }

  private renderStatCard(label: string, value: string, x: number, y: number, width: number) {
    const bg = this.track(this.add.rectangle(x, y, width, 34, 0x151f2a, 0.82));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, 0x53606c, 0.38);
    this.track(this.add.text(x + 10, y + 8, label, this.textStyle(12, '#9fb0bf', true)));
    const valueText = this.track(this.add.text(x + width - 10, y + 5, value, this.textStyle(17, '#f2e3bf', true)));
    valueText.setOrigin(1, 0);
  }

  private renderEquipmentCard(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number, height: number) {
    const state = unit.equipment[slot];
    const item = getItem(state.itemId);
    const next = equipmentExpToNext(state.level);
    const isTreasure = item.rank === 'treasure';
    const bg = this.track(this.add.rectangle(x, y, width, height, isTreasure ? 0x1f2430 : 0x151f2a, 0.92));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, isTreasure ? palette.gold : palette.blue, isTreasure ? 0.68 : 0.42);

    const iconFrame = this.track(this.add.rectangle(x + 28, y + 29, 42, 42, 0x0a1017, 0.92));
    iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48);
    const icon = this.track(this.add.image(x + 28, y + 29, `item-${item.id}`));
    icon.setDisplaySize(30, 30);

    this.track(this.add.text(x + 58, y + 12, `${equipmentSlotLabels[slot]} · ${item.name}`, this.textStyle(14, isTreasure ? '#f4dfad' : '#d4dce6', true)));
    this.track(this.add.text(x + 58, y + 34, `${isTreasure ? '보물' : '일반'}  Lv ${state.level}`, this.textStyle(12, isTreasure ? '#d8b15f' : '#9fb0bf', true)));
    const valueText = this.track(this.add.text(x + width - 12, y + 34, `${state.exp}/${next}`, this.textStyle(12, '#f0e4c8', true)));
    valueText.setOrigin(1, 0);
    this.drawBar(x + 58, y + 57, width - 74, 7, state.exp / next, isTreasure ? palette.gold : palette.blue);

    this.track(
      this.add.text(x + 14, y + 72, `${this.itemBonusText(item)} · ${item.effects[0] ?? item.description}`, {
        ...this.textStyle(11, '#c8d2dd'),
        wordWrap: { width: width - 28, useAdvancedWrap: true }
      })
    );
  }

  private renderSelectedUnitBondPanel(unit: UnitData, x: number, y: number, width: number) {
    const bonds = this.currentBonds().filter((bond) => bond.unitIds.includes(unit.id));
    const bg = this.track(this.add.rectangle(x, y, width, 62, 0x0d141c, 0.9));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, palette.gold, 0.42);
    this.track(this.add.text(x + 14, y + 9, '공명 관계', this.textStyle(16, '#f2e3bf', true)));

    if (bonds.length === 0) {
      this.track(this.add.text(x + 120, y + 22, '아직 연결된 공명 관계가 없습니다.', this.textStyle(13, '#9fb0bf')));
      return;
    }

    bonds.forEach((bond, index) => {
      const cardX = x + 116 + index * 322;
      const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0];
      const partnerName = this.unitName(partnerId);
      this.track(this.add.text(cardX, y + 10, `${partnerName} · ${bond.title}`, this.textStyle(13, '#d4dce6', true)));
      this.track(this.add.text(cardX, y + 32, `Lv ${bond.level}  ${bond.exp}/100  전투 +${bond.battleExp}`, this.textStyle(12, bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf', true)));
      this.drawBar(cardX + 176, y + 38, 120, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
    });
  }

  private renderSelectedUnitRosterPlan(unit: UnitData, x: number, y: number, width: number) {
    const status = this.unitRosterStatus(unit);
    const training = this.latestReserveTraining(unit.id);
    const role = this.sortieFormationRole(unit);
    const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === role)?.label ?? '예비';
    const recommendation = this.sortieRecommendation(unit.id);
    const bg = this.track(this.add.rectangle(x, y, width, 22, 0x151f2a, 0.82));
    bg.setOrigin(0);
    bg.setStrokeStyle(1, training ? palette.green : palette.blue, training ? 0.5 : 0.34);
    const planText = recommendation ? `추천: ${recommendation.reason}` : `${roleLabel} 역할로 편성 가능`;
    const trainingText = training ? `최근 대기 훈련 경험 +${training.expGained}, 장비 +${training.equipmentExpGained}` : '대기 중에도 전투 후 소량의 훈련 성장이 반영됩니다.';
    this.track(this.add.text(x + 12, y + 5, `${status.label} · ${roleLabel}`, this.textStyle(11, status.tone === 'reserve' ? '#9fb0bf' : '#f2e3bf', true)));
    this.track(this.add.text(x + 112, y + 5, this.compactText(planText, 48), this.textStyle(11, '#d4dce6')));
    const trainingLabel = this.track(this.add.text(x + width - 12, y + 5, this.compactText(trainingText, 28), this.textStyle(11, training ? '#a8ffd0' : '#9fb0bf', true)));
    trainingLabel.setOrigin(1, 0);
  }

  private itemBonusText(item: ReturnType<typeof getItem>) {
    const bonuses = [
      item.attackBonus ? `공격 +${item.attackBonus}` : '',
      item.defenseBonus ? `방어 +${item.defenseBonus}` : '',
      item.strategyBonus ? `책략 +${item.strategyBonus}` : ''
    ].filter(Boolean);
    return bonuses.join(' / ') || '특수 보정 없음';
  }

  private completeVisit(visit: CampVisitDefinition, choice: CampVisitChoice) {
    if (this.completedCampVisits().includes(visit.id)) {
      this.showCampNotice('이미 완료한 방문입니다.');
      return;
    }

    const updated = applyCampVisitReward(visit.id, {
      bondId: visit.bondId,
      bondExp: choice.bondExp,
      gold: choice.gold,
      itemRewards: choice.itemRewards
    });

    if (updated) {
      this.campaign = updated;
      this.report = this.campaign.firstBattleReport ?? this.report;
    }

    soundDirector.playEffect('exp-gain', { volume: 0.26, stopAfterMs: 700 });
    this.showVisitReward(choice);
    this.render();
  }

  private showVisitReward(choice: CampVisitChoice) {
    this.showCampNotice(`${choice.label} · ${this.visitRewardText(choice)}`);
    this.time.delayedCall(120, () => this.showCampNotice(choice.response));
  }

  private visitRewardText(choice: CampVisitChoice) {
    const rewards = [
      choice.bondExp ? `공명 +${choice.bondExp}` : '',
      choice.gold ? `군자금 +${choice.gold}` : '',
      ...(choice.itemRewards ?? []).map((reward) => `${reward}`)
    ].filter(Boolean);
    return rewards.join(' / ') || '정보 획득';
  }

  private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
    const rewardExp = dialogue.rewardExp + choice.rewardExp;
    const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
    if (updated) {
      this.report = updated;
      this.campaign = getCampaignState();
    } else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) {
      this.report.completedCampDialogues.push(dialogue.id);
      const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
      if (bond) {
        bond.battleExp += rewardExp;
        bond.exp += rewardExp;
      }
    }

    soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
    this.showDialogueReward(dialogue, choice, rewardExp);
    this.render();
  }

  private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
    this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`);
    this.time.delayedCall(120, () => this.showCampNotice(choice.response));
  }

  private showCampNotice(message: string) {
    this.dialogueObjects.forEach((object) => object.destroy());
    this.dialogueObjects = [];
    const box = this.add.rectangle(this.scale.width / 2, 104, 720, 58, 0x101820, 0.96);
    box.setStrokeStyle(2, palette.gold, 0.84);
    box.setDepth(30);
    const text = this.add.text(this.scale.width / 2, 104, message, {
      ...this.textStyle(16, '#f2e3bf', true),
      align: 'center',
      wordWrap: { width: 670, useAdvancedWrap: true }
    });
    text.setOrigin(0.5);
    text.setDepth(31);
    this.dialogueObjects.push(box, text);
    this.tweens.add({
      targets: this.dialogueObjects,
      alpha: 0,
      delay: 1300,
      duration: 320,
      onComplete: () => {
        this.dialogueObjects.forEach((object) => object.destroy());
        this.dialogueObjects = [];
      }
    });
  }

  private selectedUnit() {
    return this.currentUnits().find((unit) => unit.id === this.selectedUnitId);
  }

  private unitName(unitId: string) {
    return this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId;
  }

  private currentUnits() {
    if (this.campaign?.roster.length) {
      return this.campaign.roster;
    }
    return this.report?.units ?? [];
  }

  private currentBonds() {
    if (this.campaign?.bonds.length) {
      return this.campaign.bonds;
    }
    return this.report?.bonds ?? [];
  }

  private sortieAllies() {
    return this.currentUnits().filter((unit) => unit.faction === 'ally' && unit.hp > 0);
  }

  private selectedSortieUnits() {
    const selected = new Set(this.selectedSortieUnitIds);
    return this.sortieAllies().filter((unit) => selected.has(unit.id));
  }

  private normalizedSortieUnitIds(candidateIds?: string[]) {
    const allies = this.sortieAllies();
    const allyIds = new Set(allies.map((unit) => unit.id));
    const maxUnits = this.sortieMaxUnits();
    const useDefaultSelection = !candidateIds || candidateIds.length === 0;
    const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id)));
    requiredSortieUnitIds.forEach((id) => {
      if (allyIds.has(id)) {
        candidateSet.add(id);
      }
    });

    if (useDefaultSelection) {
      allies.slice(0, maxUnits).forEach((unit) => candidateSet.add(unit.id));
    }

    const required = allies.filter((unit) => requiredSortieUnitIds.has(unit.id)).map((unit) => unit.id);
    const optional = allies
      .filter((unit) => !requiredSortieUnitIds.has(unit.id) && candidateSet.has(unit.id))
      .slice(0, Math.max(0, maxUnits - required.length))
      .map((unit) => unit.id);

    return [...new Set([...required, ...optional])].slice(0, maxUnits);
  }

  private isSortieSelected(unitId: string) {
    return this.selectedSortieUnitIds.includes(unitId);
  }

  private toggleSortieUnit(unitId: string) {
    const unit = this.sortieAllies().find((candidate) => candidate.id === unitId);
    if (!unit) {
      return;
    }
    if (requiredSortieUnitIds.has(unitId)) {
      this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`);
      return;
    }

    const selected = new Set(this.selectedSortieUnitIds);
    if (selected.has(unitId)) {
      selected.delete(unitId);
    } else if (selected.size >= this.sortieMaxUnits()) {
      this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`);
      return;
    } else {
      selected.add(unitId);
    }

    this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected));
    this.persistSortieSelection();
    soundDirector.playSelect();
    this.showSortiePrep();
  }

  private ensureSortieSelectionSaved() {
    this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds);
    if (!this.selectedSortieUnitIds.some((id) => requiredSortieUnitIds.has(id)) || this.selectedSortieUnitIds.length === 0) {
      return false;
    }
    this.persistSortieSelection();
    return true;
  }

  private persistSortieSelection() {
    const campaign = this.campaign ?? getCampaignState();
    campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds];
    this.campaign = saveCampaignState(campaign);
  }

  private completedCampDialogues() {
    if (this.campaign) {
      return this.campaign.completedCampDialogues;
    }
    return this.report?.completedCampDialogues ?? [];
  }

  private completedCampVisits() {
    if (this.campaign) {
      return this.campaign.completedCampVisits;
    }
    return this.report?.completedCampVisits ?? [];
  }

  private inventoryLabels() {
    const inventory = this.campaign?.inventory ?? {};
    const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
    if (entries.length > 0) {
      return entries.map(([label, amount]) => `${label} ${amount}`);
    }
    return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1'];
  }

  private nonConsumableInventoryLabels() {
    const entries = Object.entries(this.campaign?.inventory ?? {}).filter(([label, amount]) => {
      return amount > 0 && !campSupplyByLabel.has(label);
    });
    if (entries.length > 0) {
      return entries.map(([label, amount]) => `${label} ${amount}`);
    }
    return this.inventoryLabels().filter((label) => {
      const normalized = label.replace(/\s+\d+$/, '').trim();
      return !campSupplyByLabel.has(normalized);
    });
  }

  private inventoryAmount(label: string) {
    return this.campaign?.inventory[label] ?? 0;
  }

  private wolongClueCount() {
    return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
  }

  private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
    const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
    track.setOrigin(0);
    const fill = this.track(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
    fill.setOrigin(0);
  }

  private textStyle(fontSize: number, color: string, bold = false): Phaser.Types.GameObjects.Text.TextStyle {
    return {
      fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
      fontSize: `${fontSize}px`,
      color,
      fontStyle: bold ? '700' : '400'
    };
  }

  private track<T extends Phaser.GameObjects.GameObject>(object: T) {
    this.contentObjects.push(object);
    return object;
  }

  private compactText(value: string, maxLength: number) {
    if (value.length <= maxLength) {
      return value;
    }
    return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
  }

  private clearContent() {
    this.contentObjects.forEach((object) => object.destroy());
    this.contentObjects = [];
  }

  getDebugState() {
    return {
      scene: this.scene.key,
      activeTab: this.activeTab,
      sortieVisible: this.sortieObjects.length > 0,
      selectedUnitId: this.selectedUnitId,
      selectedDialogueId: this.selectedDialogueId,
      selectedVisitId: this.selectedVisitId,
      selectedSortieUnitIds: [...this.selectedSortieUnitIds],
      sortieRoster: this.sortieAllies().map((unit) => {
        const summary = this.sortieUnitTacticalSummary(unit);
        return {
          id: unit.id,
          name: unit.name,
          selected: this.isSortieSelected(unit.id),
          recruited: !foundingSortieUnitIds.has(unit.id),
          required: requiredSortieUnitIds.has(unit.id),
          className: unit.className,
          classRole: getUnitClass(unit.classKey).role,
          formationRole: this.sortieFormationRole(unit),
          recommended: Boolean(this.sortieRecommendation(unit.id)),
          recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null,
          statLine: summary.statLine,
          equipmentLine: summary.equipmentLine,
          bondLine: summary.bondLine,
          terrainLine: summary.terrainLine
        };
      }),
      sortiePlan: this.sortiePlanSummary(),
      rosterCollection: this.rosterCollectionSummary(),
      reserveTrainingAwards: this.latestReserveTrainingAwards(),
      campaignProgress: this.campaignTimelineProgress(),
      campBattleId: this.currentCampBattleId(),
      campTitle: this.currentCampTitle(),
      availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
      completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
      availableVisitIds: this.availableCampVisits().map((visit) => visit.id),
      completedAvailableVisits: this.completedAvailableVisits().map((visit) => visit.id),
      campaign: this.campaign
        ? {
          step: this.campaign.step,
          gold: this.campaign.gold,
          inventory: this.campaign.inventory,
          selectedSortieUnitIds: this.campaign.selectedSortieUnitIds,
          roster: this.campaign.roster.map((unit) => ({
            id: unit.id,
            name: unit.name,
            hp: unit.hp,
            maxHp: unit.maxHp
          })),
          completedCampDialogues: this.campaign.completedCampDialogues,
          completedCampVisits: this.campaign.completedCampVisits
        }
        : null,
      report: this.report
        ? {
            rewardGold: this.report.rewardGold,
            objectives: this.report.objectives,
            completedCampDialogues: this.report.completedCampDialogues,
            completedCampVisits: this.report.completedCampVisits,
            bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))
          }
        : null
    };
  }
}
