feat: improve first-session combat and visual assets
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user