feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -77,6 +77,8 @@ export const campaignVictoryRewardCategoryIds = [
export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number];
export type CampaignVictoryRewardAcknowledgements = Partial<Record<string, CampaignVictoryRewardCategoryId[]>>;
export type CampaignCampChoiceHistory = Record<string, string>;
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
export type CampaignSortieUnitPerformanceSnapshot = {
@@ -373,6 +375,7 @@ const campaignInventoryCaps: Record<string, number> = {
const maxCampaignStringListEntries = 128;
const maxCampaignStringListLength = 96;
const maxCampaignCampHistoryEntries = 256;
const maxCampaignUnknownInventoryAmount = 999;
const maxCampaignRewardNoteLength = 180;
const maxCampaignBattleObjectiveEntries = 24;
@@ -485,6 +488,8 @@ export type CampaignState = {
completedTutorialIds: CampaignTutorialId[];
completedCampDialogues: string[];
completedCampVisits: string[];
campDialogueChoiceIds: CampaignCampChoiceHistory;
campVisitChoiceIds: CampaignCampChoiceHistory;
acknowledgedVictoryRewardBattleIds: string[];
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
dismissedVictoryRewardNoticeBattleIds: string[];
@@ -704,6 +709,8 @@ export function createInitialCampaignState(): CampaignState {
completedTutorialIds: [],
completedCampDialogues: [],
completedCampVisits: [],
campDialogueChoiceIds: {},
campVisitChoiceIds: {},
acknowledgedVictoryRewardBattleIds: [],
acknowledgedVictoryRewardCategories: {},
dismissedVictoryRewardNoticeBattleIds: [],
@@ -918,10 +925,16 @@ export function setFirstBattleReport(report: FirstBattleReport) {
delete reportClone.sortieRecommendation;
}
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
const completedCampVisits =
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
const completedCampDialogues = mergeCampCompletionIds(
state.completedCampDialogues,
reportClone.completedCampDialogues,
Object.keys(state.campDialogueChoiceIds)
);
const completedCampVisits = mergeCampCompletionIds(
state.completedCampVisits,
reportClone.completedCampVisits ?? [],
Object.keys(state.campVisitChoiceIds)
);
reportClone.completedCampDialogues = completedCampDialogues;
reportClone.completedCampVisits = completedCampVisits;
@@ -1313,14 +1326,27 @@ function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCat
return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category));
}
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number, choiceId?: string) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
return undefined;
}
if (hasCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) {
if (syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) {
if (
hasCampCompletionFlag(
state.completedCampDialogues,
state.firstBattleReport.completedCampDialogues,
state.campDialogueChoiceIds,
dialogueId
)
) {
const completionSynced = syncCompletionFlag(
state.completedCampDialogues,
state.firstBattleReport.completedCampDialogues,
dialogueId
);
const choiceRemembered = rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId);
if (completionSynced || choiceRemembered) {
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
@@ -1334,6 +1360,7 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
}
syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId);
rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId);
advanceBond(campaignBond, amount);
reportBond.level = campaignBond.level;
reportBond.exp = campaignBond.exp;
@@ -1346,15 +1373,29 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
export function applyCampVisitReward(
visitId: string,
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] },
choiceId?: string
) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
return undefined;
}
if (hasCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) {
if (syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) {
if (
hasCampCompletionFlag(
state.completedCampVisits,
state.firstBattleReport.completedCampVisits,
state.campVisitChoiceIds,
visitId
)
) {
const completionSynced = syncCompletionFlag(
state.completedCampVisits,
state.firstBattleReport.completedCampVisits,
visitId
);
const choiceRemembered = rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId);
if (completionSynced || choiceRemembered) {
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
@@ -1362,6 +1403,7 @@ export function applyCampVisitReward(
}
syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId);
rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId);
if (reward.bondId && reward.bondExp && reward.bondExp > 0) {
const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId);
@@ -1389,8 +1431,31 @@ export function applyCampVisitReward(
return cloneCampaignState(state);
}
function hasCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) {
return stateFlags.includes(flagId) || reportFlags.includes(flagId);
function hasCampCompletionFlag(
stateFlags: string[],
reportFlags: string[],
choiceHistory: CampaignCampChoiceHistory,
flagId: string
) {
const normalizedFlagId = normalizeKeyString(flagId);
return (
stateFlags.includes(flagId) ||
reportFlags.includes(flagId) ||
Boolean(normalizedFlagId && choiceHistory[normalizedFlagId])
);
}
function rememberCampChoice(history: CampaignCampChoiceHistory, entryId: string, choiceId?: string) {
const normalizedEntryId = normalizeKeyString(entryId);
const normalizedChoiceId = normalizeKeyString(choiceId);
if (!normalizedEntryId || !normalizedChoiceId || history[normalizedEntryId]) {
return false;
}
if (Object.keys(history).length >= maxCampaignCampHistoryEntries) {
return false;
}
history[normalizedEntryId] = normalizedChoiceId;
return true;
}
function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) {
@@ -1566,8 +1631,10 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalized.bonds = arrayOrEmpty<unknown>(normalized.bonds)
.map(normalizeCampBondSnapshot)
.filter((bond): bond is CampBondSnapshot => Boolean(bond));
normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
normalized.completedCampDialogues = uniqueCampHistoryIds(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueCampHistoryIds(normalized.completedCampVisits);
normalized.campDialogueChoiceIds = normalizeCampChoiceHistory(normalized.campDialogueChoiceIds);
normalized.campVisitChoiceIds = normalizeCampChoiceHistory(normalized.campVisitChoiceIds);
normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds)
.filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
@@ -1580,6 +1647,20 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
normalized.completedCampDialogues = mergeCampCompletionIds(
normalized.completedCampDialogues,
normalized.firstBattleReport?.completedCampDialogues ?? [],
Object.keys(normalized.campDialogueChoiceIds)
);
normalized.completedCampVisits = mergeCampCompletionIds(
normalized.completedCampVisits,
normalized.firstBattleReport?.completedCampVisits ?? [],
Object.keys(normalized.campVisitChoiceIds)
);
if (normalized.firstBattleReport) {
normalized.firstBattleReport.completedCampDialogues = [...normalized.completedCampDialogues];
normalized.firstBattleReport.completedCampVisits = [...normalized.completedCampVisits];
}
backfillEarlyJianYongRecruit(normalized);
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
@@ -1734,6 +1815,40 @@ function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): Campaig
}, {});
}
function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory {
const history: CampaignCampChoiceHistory = {};
let acceptedEntryCount = 0;
for (const [entryId, choiceId] of Object.entries(recordOrEmpty(value))) {
if (acceptedEntryCount >= maxCampaignCampHistoryEntries) {
break;
}
const normalizedEntryId = normalizeKeyString(entryId);
const normalizedChoiceId = normalizeKeyString(choiceId);
if (normalizedEntryId && normalizedChoiceId) {
if (!history[normalizedEntryId]) {
acceptedEntryCount += 1;
}
history[normalizedEntryId] = normalizedChoiceId;
}
}
return history;
}
function uniqueCampHistoryIds(value: unknown): string[] {
return [
...new Set(
arrayOrEmpty<unknown>(value)
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength)
)
].slice(0, maxCampaignCampHistoryEntries);
}
function mergeCampCompletionIds(...completionIdGroups: string[][]) {
return uniqueCampHistoryIds(completionIdGroups.flat());
}
function uniqueStrings(value: unknown): string[] {
return [
...new Set(
@@ -2704,8 +2819,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {}),
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
completedCampVisits: uniqueStrings(report.completedCampVisits),
completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues),
completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits),
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
};
}
@@ -3102,8 +3217,8 @@ function cloneCampaignState(state: CampaignState): CampaignState {
function cloneReport(report: FirstBattleReport): FirstBattleReport {
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards);
cloned.completedCampDialogues = uniqueStrings(cloned.completedCampDialogues);
cloned.completedCampVisits = uniqueStrings(cloned.completedCampVisits);
cloned.completedCampDialogues = uniqueCampHistoryIds(cloned.completedCampDialogues);
cloned.completedCampVisits = uniqueCampHistoryIds(cloned.completedCampVisits);
if (cloned.campaignRewards) {
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId);
}