feat: improve first-session combat and visual assets

This commit is contained in:
2026-07-18 22:11:50 +09:00
parent 4a2a0dfb19
commit f29956b90e
82 changed files with 3640 additions and 839 deletions

View File

@@ -1,7 +1,13 @@
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
import { unitClasses } from '../data/battleRules';
import { battleScenarios, type BattleScenarioId } from '../data/battles';
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
import {
campaignRecruitUnits,
jianYongRecruitBond,
jianYongRecruitUnit,
type BattleBond,
type UnitData
} from '../data/scenario';
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
import {
@@ -438,6 +444,10 @@ export type CampaignSortieOrderHistory = Partial<
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
>;
export const campaignTutorialIds = ['first-battle-basic-controls'] as const;
export type CampaignTutorialId = (typeof campaignTutorialIds)[number];
const campaignTutorialIdSet = new Set<string>(campaignTutorialIds);
export type CampaignState = {
version: 1;
updatedAt: string;
@@ -458,6 +468,7 @@ export type CampaignState = {
claimedSortieOrderRewardIds: string[];
reserveTrainingFocus: CampaignReserveTrainingFocusId;
reserveTrainingAssignments: CampaignReserveTrainingAssignments;
completedTutorialIds: CampaignTutorialId[];
completedCampDialogues: string[];
completedCampVisits: string[];
acknowledgedVictoryRewardBattleIds: string[];
@@ -663,6 +674,7 @@ export function createInitialCampaignState(): CampaignState {
claimedSortieOrderRewardIds: [],
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
reserveTrainingAssignments: {},
completedTutorialIds: [],
completedCampDialogues: [],
completedCampVisits: [],
acknowledgedVictoryRewardBattleIds: [],
@@ -749,6 +761,20 @@ export function markCampaignStep(step: CampaignStep) {
return cloneCampaignState(state);
}
export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) {
return state.completedTutorialIds.includes(id);
}
export function completeCampaignTutorial(id: CampaignTutorialId) {
const state = ensureCampaignState();
if (!state.completedTutorialIds.includes(id)) {
state.completedTutorialIds.push(id);
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
return cloneCampaignState(state);
}
export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) {
const state = ensureCampaignState();
if (!(battleId in battleScenarios)) {
@@ -957,6 +983,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
.filter((unit): unit is UnitData => Boolean(unit));
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
backfillEarlyJianYongRecruit(state);
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
: applyReserveTrainingProgress(
@@ -1207,6 +1234,89 @@ export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[]
return cloneCampaignState(state);
}
const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery';
/**
* 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다.
* 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다.
*/
function backfillEarlyJianYongRecruit(state: CampaignState) {
const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId];
const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId
? state.firstBattleReport
: undefined;
const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory';
if (!firstVictoryRecorded) {
return;
}
if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) {
state.roster.push(cloneUnit(jianYongRecruitUnit));
}
if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
// 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다.
// 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다.
state.bonds.push(createCampBondSnapshot(jianYongRecruitBond));
}
if (firstReport?.outcome === 'victory') {
if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries);
}
firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards);
}
if (firstSettlement?.outcome === 'victory') {
if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
appendRequiredLimitedMigrationEntry(firstSettlement.bonds, {
id: jianYongRecruitBond.id,
title: jianYongRecruitBond.title,
level: jianYongRecruitBond.level,
exp: jianYongRecruitBond.exp,
battleExp: 0
}, maxCampaignBattleBondEntries);
}
firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards);
}
}
function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot {
const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward;
const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? {
supplies: [...(rewardDefinition?.supplies ?? [])],
equipment: [...(rewardDefinition?.equipment ?? [])],
reputation: [...(rewardDefinition?.reputation ?? [])],
recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({
unitId,
name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId
})),
unlocks: rewardDefinition?.unlockBattleId
? [{
battleId: rewardDefinition.unlockBattleId,
title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title
}]
: [],
...(rewardDefinition?.note ? { note: rewardDefinition.note } : {})
};
if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) {
appendRequiredLimitedMigrationEntry(
snapshot.recruits,
{ unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name },
maxCampaignStringListEntries
);
}
return snapshot;
}
function appendRequiredLimitedMigrationEntry<T>(entries: T[], entry: T, limit: number) {
if (entries.length >= limit) {
entries.splice(limit - 1);
}
entries.push(entry);
}
function ensureCampaignState() {
if (!campaignState) {
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
@@ -1232,9 +1342,14 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
.filter((bond): bond is CampBondSnapshot => Boolean(bond));
normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds)
.filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
.filter((battleId) => battleId in battleScenarios);
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
backfillEarlyJianYongRecruit(normalized);
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
if (sortieUnitFilter) {
@@ -1279,7 +1394,6 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
);
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
if (sortieUnitFilter) {
normalized.battleHistory = filterBattleHistoryRosterReferences(
normalized.battleHistory,
@@ -1288,7 +1402,6 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
);
}
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
if (normalized.firstBattleReport && sortieUnitFilter) {
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
}