From 8f083a6ca40cbe9aad72250666406fa6694f7eef Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 05:59:14 +0900 Subject: [PATCH] Add officer roster reserve growth --- docs/roadmap.md | 7 +- scripts/verify-flow.mjs | 25 ++++++- src/game/scenes/CampScene.ts | 119 ++++++++++++++++++++++++++++---- src/game/state/campaignState.ts | 63 ++++++++++++++++- 4 files changed, 195 insertions(+), 19 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 259bb2d..b064415 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,14 +42,15 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Eighteenth battle Bowang ambush as the first Red Cliffs preparation battle, with a new large battlefield, Xiahou Dun's vanguard, mixed enemy AI, Zhuge Liang sortie recommendation, and post-battle camp events - Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter - Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer +- Officer collection support in camp, including full roster status, selected/reserve/recommended markers, and post-battle reserve training growth for benched officers - Flow verification script from title through the eighteenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang camp state, campaign timeline state, and camp save state ## Next Steps 1. Continue the Red Cliffs preparation chapter into Changban and the Jiangdong envoy route -2. Add a richer officer collection and reserve growth view so benched recruitable officers still feel valuable -3. Apply treasure equipment effects to damage, defense, recovery, and command rules -4. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han +2. Apply treasure equipment effects to damage, defense, recovery, and command rules +3. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han +4. Expand reserve training into explicit drill choices, class practice, and bond-focused camp assignments 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending ## Content Direction diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 8b7fa21..5bb0cf4 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1848,12 +1848,33 @@ try { !eighteenthCampState.availableDialogueIds.every((id) => id.endsWith('bowang-ambush')) || eighteenthCampState.availableVisitIds?.length !== 2 || !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'zhang-fei__zhuge-liang') || - !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') + !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') || + eighteenthCampState.rosterCollection?.total < 8 || + eighteenthCampState.rosterCollection?.reserveTrainingCount < 2 || + eighteenthCampState.rosterCollection?.reserveTrainingExp < 12 || + !eighteenthCampState.reserveTrainingAwards?.some((entry) => entry.unitId === 'jian-yong' && entry.expGained === 6) || + !eighteenthCampState.reserveTrainingAwards?.every((entry) => entry.expGained === 6 && entry.equipmentExpGained === 2) || + !eighteenthCampState.reserveTrainingAwards?.some((entry) => ['mi-zhu', 'sun-qian'].includes(entry.unitId)) ) { - throw new Error(`Expected eighteenth camp to expose Bowang dialogue/visit sets and Zhuge bond expansion: ${JSON.stringify(eighteenthCampState)}`); + throw new Error(`Expected eighteenth camp to expose Bowang dialogue/visit sets, Zhuge bonds, and reserve training growth: ${JSON.stringify(eighteenthCampState)}`); } await page.screenshot({ path: 'dist/verification-bowang-camp.png', fullPage: true }); + await page.mouse.click(654, 38); + await page.waitForTimeout(160); + await page.mouse.click(180, 315); + await page.waitForTimeout(160); + const bowangRosterState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + bowangRosterState?.activeTab !== 'status' || + bowangRosterState.selectedUnitId !== 'jian-yong' || + bowangRosterState.rosterCollection?.reserveCount < 2 || + !bowangRosterState.reserveTrainingAwards?.some((entry) => entry.unitId === 'jian-yong' && entry.expGained === 6) + ) { + throw new Error(`Expected status tab to expose officer roster reserve growth details: ${JSON.stringify(bowangRosterState)}`); + } + await page.screenshot({ path: 'dist/verification-bowang-roster.png', fullPage: true }); + await page.mouse.click(966, 38); await page.waitForTimeout(180); const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ba0e3ec..9d59c7c 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -3177,13 +3177,16 @@ export class CampScene extends Phaser.Scene { private renderUnitColumn() { const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); const availableHeight = 462; - const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150; - const cardHeight = Math.min(126, Math.max(70, rowGap - 10)); - const compact = cardHeight < 104; + 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); @@ -3196,22 +3199,63 @@ export class CampScene extends Phaser.Scene { const portraitKey = portraitByUnitId[unit.id]; const portraitCenterY = y + cardHeight / 2; - const portraitSize = compact ? 62 : 92; + 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 ? 31 : 44, unit); + this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit); } const textX = compact ? x + 102 : x + 116; - this.track(this.add.text(textX, y + (compact ? 13 : 20), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 17 : 21, '#f2e3bf', true))); - this.track(this.add.text(textX, y + (compact ? 38 : 51), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 12 : 14, '#d4dce6'))); - this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true))); - this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold); + 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); @@ -3423,6 +3467,36 @@ export class CampScene extends Phaser.Scene { 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) { @@ -3472,12 +3546,14 @@ export class CampScene extends Phaser.Scene { this.renderStatCard(label, `${value}`, statX + (index % 2) * 128, y + 22 + Math.floor(index / 2) * 42, 116); }); - this.track(this.add.text(x + 24, y + 210, '장비', this.textStyle(20, '#f2e3bf', true))); + 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 + 244, 244, 102); + this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 256, 244, 98); }); - this.renderSelectedUnitBondPanel(unit, x + 24, y + 362, 780); + this.renderSelectedUnitBondPanel(unit, x + 24, y + 366, 780); } private renderDialoguePanel() { @@ -3974,6 +4050,23 @@ export class CampScene extends Phaser.Scene { }); } + 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) { const bonuses = [ item.attackBonus ? `공격 +${item.attackBonus}` : '', @@ -4278,6 +4371,8 @@ export class CampScene extends Phaser.Scene { }; }), sortiePlan: this.sortiePlanSummary(), + rosterCollection: this.rosterCollectionSummary(), + reserveTrainingAwards: this.latestReserveTrainingAwards(), campaignProgress: this.campaignTimelineProgress(), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 600bf55..7c12ecb 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1,3 +1,4 @@ +import { equipmentExpToNext, equipmentSlots } from '../data/battleItems'; import type { BattleBond, UnitData } from '../data/scenario'; export type BattleObjectiveSnapshot = { @@ -96,6 +97,15 @@ export type CampaignBondProgressSnapshot = { battleExp: number; }; +export type CampaignReserveTrainingSnapshot = { + unitId: string; + name: string; + expGained: number; + equipmentExpGained: number; + level: number; + exp: number; +}; + export type CampaignBattleSettlement = { battleId: string; battleTitle: string; @@ -105,6 +115,7 @@ export type CampaignBattleSettlement = { objectives: BattleObjectiveSnapshot[]; units: CampaignUnitProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[]; + reserveTraining?: CampaignReserveTrainingSnapshot[]; completedAt: string; }; @@ -265,12 +276,15 @@ export function setFirstBattleReport(report: FirstBattleReport) { state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); + const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' + ? previousSettlement?.reserveTraining ?? [] + : applyReserveTrainingProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); state.bonds = reportClone.bonds.map(cloneBondSnapshot); state.completedCampDialogues = completedCampDialogues; state.completedCampVisits = completedCampVisits; state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); - state.battleHistory[battleId] = createBattleSettlement(reportClone); + state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining); state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); @@ -492,7 +506,7 @@ function advanceBond(bond: CampBondSnapshot, amount: number) { } } -function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettlement { +function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement { return { battleId: report.battleId, battleTitle: report.battleTitle, @@ -518,6 +532,7 @@ function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettle exp: bond.exp, battleExp: bond.battleExp })), + reserveTraining: reserveTraining.map((entry) => ({ ...entry })), completedAt: report.createdAt }; } @@ -540,6 +555,50 @@ function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[ return Array.from(merged.values()); } +function applyReserveTrainingProgress(roster: UnitData[], deployedUnits: UnitData[]): CampaignReserveTrainingSnapshot[] { + const deployedIds = new Set(deployedUnits.map((unit) => unit.id)); + return roster + .filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id)) + .map((unit) => { + const expGained = 6; + const equipmentExpGained = 2; + advanceUnitExp(unit, expGained); + advanceReserveEquipment(unit, equipmentExpGained); + return { + unitId: unit.id, + name: unit.name, + expGained, + equipmentExpGained, + level: unit.level, + exp: unit.exp + }; + }); +} + +function advanceUnitExp(unit: UnitData, amount: number) { + let exp = unit.exp + amount; + while (unit.level < 99 && exp >= 100) { + exp -= 100; + unit.level += 1; + unit.maxHp += 2; + unit.hp = Math.min(unit.maxHp, unit.hp + 2); + unit.attack += 1; + } + unit.exp = unit.level >= 99 ? Math.min(exp, 100) : exp; +} + +function advanceReserveEquipment(unit: UnitData, amount: number) { + equipmentSlots.forEach((slot) => { + const state = unit.equipment[slot]; + let exp = state.exp + amount; + while (state.level < 9 && exp >= equipmentExpToNext(state.level)) { + exp -= equipmentExpToNext(state.level); + state.level += 1; + } + state.exp = state.level >= 9 ? Math.min(exp, equipmentExpToNext(state.level)) : exp; + }); +} + function parseRewardLabel(reward: string) { const normalized = reward.replace(/\s+/g, ' ').trim(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);