Add officer roster reserve growth

This commit is contained in:
2026-06-23 05:59:14 +09:00
parent 44009b3542
commit 8f083a6ca4
4 changed files with 195 additions and 19 deletions

View File

@@ -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 - 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 - 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 - 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 - 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 ## Next Steps
1. Continue the Red Cliffs preparation chapter into Changban and the Jiangdong envoy route 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 2. Apply treasure equipment effects to damage, defense, recovery, and command rules
3. 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. 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 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction ## Content Direction

View File

@@ -1848,12 +1848,33 @@ try {
!eighteenthCampState.availableDialogueIds.every((id) => id.endsWith('bowang-ambush')) || !eighteenthCampState.availableDialogueIds.every((id) => id.endsWith('bowang-ambush')) ||
eighteenthCampState.availableVisitIds?.length !== 2 || eighteenthCampState.availableVisitIds?.length !== 2 ||
!eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'zhang-fei__zhuge-liang') || !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.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.mouse.click(966, 38);
await page.waitForTimeout(180); await page.waitForTimeout(180);
const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());

View File

@@ -3177,13 +3177,16 @@ export class CampScene extends Phaser.Scene {
private renderUnitColumn() { private renderUnitColumn() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const availableHeight = 462; const availableHeight = 462;
const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150; const rowGap = allies.length > 1 ? Math.min(132, Math.floor(availableHeight / allies.length)) : 132;
const cardHeight = Math.min(126, Math.max(70, rowGap - 10)); const cardHeight = Math.min(118, Math.max(48, rowGap - 8));
const compact = cardHeight < 104; const compact = cardHeight < 88;
this.renderRosterColumnSummary(42, 86, 318, allies);
allies.forEach((unit, index) => { allies.forEach((unit, index) => {
const x = 42; const x = 42;
const y = 120 + index * rowGap; const y = 120 + index * rowGap;
const active = this.selectedUnitId === unit.id; 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)); const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
bg.setOrigin(0); bg.setOrigin(0);
bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46); 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 portraitKey = portraitByUnitId[unit.id];
const portraitCenterY = y + cardHeight / 2; const portraitCenterY = y + cardHeight / 2;
const portraitSize = compact ? 62 : 92; const portraitSize = compact ? 40 : 84;
if (portraitKey) { if (portraitKey) {
const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey])); const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey]));
portrait.setDisplaySize(portraitSize, portraitSize); portrait.setDisplaySize(portraitSize, portraitSize);
} else { } 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; 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 ? 7 : 17), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 14 : 20, '#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 ? 27 : 47), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 10 : 13, '#d4dce6')));
this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true))); const expLine = training ? `경험 ${unit.exp}/100 · 대기 +${training.expGained}` : `경험 ${unit.exp}/100`;
this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold); 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) { private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) {
const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96)); const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96));
bg.setStrokeStyle(2, palette.gold, 0.58); 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 ?? {})); 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() { private renderStatusPanel() {
const unit = this.selectedUnit(); const unit = this.selectedUnit();
if (!unit) { 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.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) => { 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() { 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<typeof getItem>) { private itemBonusText(item: ReturnType<typeof getItem>) {
const bonuses = [ const bonuses = [
item.attackBonus ? `공격 +${item.attackBonus}` : '', item.attackBonus ? `공격 +${item.attackBonus}` : '',
@@ -4278,6 +4371,8 @@ export class CampScene extends Phaser.Scene {
}; };
}), }),
sortiePlan: this.sortiePlanSummary(), sortiePlan: this.sortiePlanSummary(),
rosterCollection: this.rosterCollectionSummary(),
reserveTrainingAwards: this.latestReserveTrainingAwards(),
campaignProgress: this.campaignTimelineProgress(), campaignProgress: this.campaignTimelineProgress(),
campBattleId: this.currentCampBattleId(), campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(), campTitle: this.currentCampTitle(),

View File

@@ -1,3 +1,4 @@
import { equipmentExpToNext, equipmentSlots } from '../data/battleItems';
import type { BattleBond, UnitData } from '../data/scenario'; import type { BattleBond, UnitData } from '../data/scenario';
export type BattleObjectiveSnapshot = { export type BattleObjectiveSnapshot = {
@@ -96,6 +97,15 @@ export type CampaignBondProgressSnapshot = {
battleExp: number; battleExp: number;
}; };
export type CampaignReserveTrainingSnapshot = {
unitId: string;
name: string;
expGained: number;
equipmentExpGained: number;
level: number;
exp: number;
};
export type CampaignBattleSettlement = { export type CampaignBattleSettlement = {
battleId: string; battleId: string;
battleTitle: string; battleTitle: string;
@@ -105,6 +115,7 @@ export type CampaignBattleSettlement = {
objectives: BattleObjectiveSnapshot[]; objectives: BattleObjectiveSnapshot[];
units: CampaignUnitProgressSnapshot[]; units: CampaignUnitProgressSnapshot[];
bonds: CampaignBondProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[];
reserveTraining?: CampaignReserveTrainingSnapshot[];
completedAt: string; completedAt: string;
}; };
@@ -265,12 +276,15 @@ export function setFirstBattleReport(report: FirstBattleReport) {
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); 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.bonds = reportClone.bonds.map(cloneBondSnapshot);
state.completedCampDialogues = completedCampDialogues; state.completedCampDialogues = completedCampDialogues;
state.completedCampVisits = completedCampVisits; state.completedCampVisits = completedCampVisits;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.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.latestBattleId = battleId;
state.updatedAt = new Date().toISOString(); 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 { return {
battleId: report.battleId, battleId: report.battleId,
battleTitle: report.battleTitle, battleTitle: report.battleTitle,
@@ -518,6 +532,7 @@ function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettle
exp: bond.exp, exp: bond.exp,
battleExp: bond.battleExp battleExp: bond.battleExp
})), })),
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
completedAt: report.createdAt completedAt: report.createdAt
}; };
} }
@@ -540,6 +555,50 @@ function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[
return Array.from(merged.values()); 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) { function parseRewardLabel(reward: string) {
const normalized = reward.replace(/\s+/g, ' ').trim(); const normalized = reward.replace(/\s+/g, ' ').trim();
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);