feat: make Xuzhou choices shape battle eight

This commit is contained in:
2026-07-27 17:39:13 +09:00
parent 2737354e30
commit eec4896002
13 changed files with 4231 additions and 84 deletions

View File

@@ -24,6 +24,7 @@ export type CityResonanceDialogueChoice = {
label: string;
response: string;
rewardExp: number;
battleEffect?: string;
};
export type CityResonanceDialogue = {
@@ -70,8 +71,8 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [
location: '서주 관청',
description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.',
briefingLines: [
'소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.',
'습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.'
'창고 보급은 서쪽 길목으로 들어오며, 장부의 빈틈을 노린 여포군 기병은 동쪽 길에서 창고를 우회 압박합니다.',
'마을 어귀와 창고 습격대의 움직임을 배치 전에 확인해 먼저 대응하십시오.'
],
itemReward: '소패 보급로 장부 1'
},
@@ -90,13 +91,15 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [
id: 'city-xuzhou-share-storehouse-duty',
label: '창고와 백성의 몫을 함께 지킨다',
response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.',
rewardExp: 10
rewardExp: 12,
battleEffect: '8차 전투 3턴까지 미축의 첫 ‘응급’ 실제 회복량 +4'
},
{
id: 'city-xuzhou-trust-mi-zhu-ledger',
label: '보급 판단을 미축에게 맡긴다',
response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.',
rewardExp: 12
rewardExp: 12,
battleEffect: '8차 전투 3턴까지 미축의 첫 ‘격려’ 지속 +1턴'
}
]
},

View File

@@ -0,0 +1,511 @@
export const xuzhouStayBattlePayoffVersion = 1 as const;
export const xuzhouStayBattlePayoffRecordId =
'xuzhou-stay-battle-payoff' as const;
export const xuzhouStayId = 'xuzhou' as const;
export const xuzhouStayBattlePayoffSourceBattleId =
'seventh-battle-xuzhou-rescue' as const;
export const xuzhouStayBattlePayoffTargetBattleId =
'eighth-battle-xiaopei-supply-road' as const;
export const xuzhouStayInformationVisitId =
'city-xuzhou-xiaopei-ledger' as const;
export const xuzhouStayResonanceDialogueId =
'city-xuzhou-liu-mi-supply-trust' as const;
export const xuzhouStayShareStorehouseDutyChoiceId =
'city-xuzhou-share-storehouse-duty' as const;
export const xuzhouStayTrustMiZhuLedgerChoiceId =
'city-xuzhou-trust-mi-zhu-ledger' as const;
export const xuzhouStayMiZhuUnitId = 'mi-zhu' as const;
export const xuzhouStayBattlePayoffActiveThroughTurn = 3 as const;
export const xuzhouStayInformationEnemyUnitIds = [
'xiaopei-guard-a',
'xiaopei-guard-b'
] as const;
export const xuzhouStayBattlePayoffChoiceIds = [
xuzhouStayShareStorehouseDutyChoiceId,
xuzhouStayTrustMiZhuLedgerChoiceId
] as const;
export type XuzhouStayBattlePayoffChoiceId =
(typeof xuzhouStayBattlePayoffChoiceIds)[number];
export type XuzhouStayInformationEffect = {
kind: 'deployment-intent-preview';
enemyUnitIds: readonly string[];
previewCount: 2;
};
export type XuzhouStayAidEffect = {
kind: 'mi-zhu-first-aid-healing';
actorUnitId: typeof xuzhouStayMiZhuUnitId;
usableId: 'aid';
activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn;
bonusHealing: 4;
maxBattleUses: 1;
};
export type XuzhouStayEncourageEffect = {
kind: 'mi-zhu-first-encourage-duration';
actorUnitId: typeof xuzhouStayMiZhuUnitId;
usableId: 'encourage';
activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn;
bonusBuffTurns: 1;
maxBattleUses: 1;
};
export type XuzhouStayResonanceEffect =
| XuzhouStayAidEffect
| XuzhouStayEncourageEffect;
export const xuzhouStayBattlePayoffDefinition = {
version: xuzhouStayBattlePayoffVersion,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
information: {
visitId: xuzhouStayInformationVisitId,
effect: {
kind: 'deployment-intent-preview',
enemyUnitIds: xuzhouStayInformationEnemyUnitIds,
previewCount: 2
} satisfies XuzhouStayInformationEffect
},
dialogue: {
dialogueId: xuzhouStayResonanceDialogueId,
choices: {
[xuzhouStayShareStorehouseDutyChoiceId]: {
kind: 'mi-zhu-first-aid-healing',
actorUnitId: xuzhouStayMiZhuUnitId,
usableId: 'aid',
activeThroughTurn:
xuzhouStayBattlePayoffActiveThroughTurn,
bonusHealing: 4,
maxBattleUses: 1
} satisfies XuzhouStayAidEffect,
[xuzhouStayTrustMiZhuLedgerChoiceId]: {
kind: 'mi-zhu-first-encourage-duration',
actorUnitId: xuzhouStayMiZhuUnitId,
usableId: 'encourage',
activeThroughTurn:
xuzhouStayBattlePayoffActiveThroughTurn,
bonusBuffTurns: 1,
maxBattleUses: 1
} satisfies XuzhouStayEncourageEffect
}
}
} as const;
export type XuzhouStayBattleRecord = {
battleId?: unknown;
outcome?: unknown;
};
export type XuzhouStayBattlePayoffCampaignState = {
battleHistory?: Readonly<
Record<string, XuzhouStayBattleRecord | null | undefined>
>;
firstBattleReport?: XuzhouStayBattleRecord | null;
completedCampVisits?: readonly unknown[];
completedCampDialogues?: readonly unknown[];
campDialogueChoiceIds?: Readonly<Record<string, unknown>> | null;
};
export type XuzhouStayBattlePayoffRecordSource =
| 'battle-history'
| 'legacy-report';
export type ResolvedXuzhouStayBattlePayoff = {
recordId: typeof xuzhouStayBattlePayoffRecordId;
cityStayId: typeof xuzhouStayId;
sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId;
targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId;
informationVisitId: typeof xuzhouStayInformationVisitId;
dialogueId: typeof xuzhouStayResonanceDialogueId;
recordSource: XuzhouStayBattlePayoffRecordSource;
informationCompleted: boolean;
informationEffect?: XuzhouStayInformationEffect;
choiceId?: XuzhouStayBattlePayoffChoiceId;
resonanceEffect?: XuzhouStayResonanceEffect;
};
export type XuzhouStayBattlePayoffSnapshot = {
version: typeof xuzhouStayBattlePayoffVersion;
recordId: typeof xuzhouStayBattlePayoffRecordId;
cityStayId: typeof xuzhouStayId;
sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId;
targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId;
informationVisitId: typeof xuzhouStayInformationVisitId;
dialogueId: typeof xuzhouStayResonanceDialogueId;
informationCompleted: boolean;
choiceId?: XuzhouStayBattlePayoffChoiceId;
informationCounterplays: number;
miZhuDeployed: boolean;
supportAttempts: number;
supportUses: number;
bonusHealing: number;
bonusBuffTurns: number;
};
export type XuzhouStayBattlePayoffRuntime =
XuzhouStayBattlePayoffSnapshot;
export type XuzhouStayBattlePayoffRuntimeState =
XuzhouStayBattlePayoffRuntime;
export type XuzhouStayBattlePayoffNormalizationOptions = {
expectedBattleId?: unknown;
expectedRosterUnitIds?: readonly unknown[] | ReadonlySet<string>;
expectedMemory?: ResolvedXuzhouStayBattlePayoff | null;
};
export function isXuzhouStayBattlePayoffChoiceId(
value: unknown
): value is XuzhouStayBattlePayoffChoiceId {
return (
typeof value === 'string' &&
(xuzhouStayBattlePayoffChoiceIds as readonly string[]).includes(
value
)
);
}
export function getXuzhouStayResonanceEffect(
choiceId: unknown
): XuzhouStayResonanceEffect | undefined {
if (!isXuzhouStayBattlePayoffChoiceId(choiceId)) {
return undefined;
}
return {
...xuzhouStayBattlePayoffDefinition.dialogue.choices[choiceId]
};
}
export function isXuzhouStayBattlePayoffActiveTurn(
turnNumber: unknown
) {
return (
typeof turnNumber === 'number' &&
Number.isInteger(turnNumber) &&
turnNumber >= 1 &&
turnNumber <= xuzhouStayBattlePayoffActiveThroughTurn
);
}
export function resolveXuzhouStayBattlePayoff(
campaign?: XuzhouStayBattlePayoffCampaignState | null,
battleId: unknown = xuzhouStayBattlePayoffTargetBattleId
): ResolvedXuzhouStayBattlePayoff | undefined {
if (battleId !== xuzhouStayBattlePayoffTargetBattleId) {
return undefined;
}
const recordSource = selectSourceVictoryRecord(campaign);
if (!recordSource) {
return undefined;
}
const informationCompleted = arrayValue(
campaign?.completedCampVisits
).includes(xuzhouStayInformationVisitId);
const dialogueCompleted = arrayValue(
campaign?.completedCampDialogues
).includes(xuzhouStayResonanceDialogueId);
const rawChoiceId = isRecord(campaign?.campDialogueChoiceIds)
? campaign.campDialogueChoiceIds[
xuzhouStayResonanceDialogueId
]
: undefined;
const choiceId =
dialogueCompleted &&
isXuzhouStayBattlePayoffChoiceId(rawChoiceId)
? rawChoiceId
: undefined;
if (!informationCompleted && !choiceId) {
return undefined;
}
return {
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId,
recordSource,
informationCompleted,
...(informationCompleted
? {
informationEffect: {
...xuzhouStayBattlePayoffDefinition.information.effect,
enemyUnitIds: [
...xuzhouStayInformationEnemyUnitIds
]
}
}
: {}),
...(choiceId
? {
choiceId,
resonanceEffect:
getXuzhouStayResonanceEffect(choiceId)!
}
: {})
};
}
export function createXuzhouStayBattlePayoffRuntime(
memory: ResolvedXuzhouStayBattlePayoff,
options: {
miZhuDeployed: unknown;
expectedRosterUnitIds?: readonly unknown[] | ReadonlySet<string>;
}
): XuzhouStayBattlePayoffRuntime | undefined {
return normalizeXuzhouStayBattlePayoffRuntime(
{
version: xuzhouStayBattlePayoffVersion,
recordId: memory.recordId,
cityStayId: memory.cityStayId,
sourceBattleId: memory.sourceBattleId,
targetBattleId: memory.targetBattleId,
informationVisitId: memory.informationVisitId,
dialogueId: memory.dialogueId,
informationCompleted: memory.informationCompleted,
...(memory.choiceId ? { choiceId: memory.choiceId } : {}),
informationCounterplays: 0,
miZhuDeployed: options.miZhuDeployed,
supportAttempts: 0,
supportUses: 0,
bonusHealing: 0,
bonusBuffTurns: 0
},
{
expectedBattleId: memory.targetBattleId,
expectedRosterUnitIds: options.expectedRosterUnitIds,
expectedMemory: memory
}
);
}
export function normalizeXuzhouStayBattlePayoffRuntime(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffRuntime | undefined {
return normalizePayoffState(value, options);
}
export function cloneXuzhouStayBattlePayoffRuntime(
runtime: XuzhouStayBattlePayoffRuntime
): XuzhouStayBattlePayoffRuntime {
return { ...runtime };
}
export function createXuzhouStayBattlePayoffSnapshot(
runtime: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffSnapshot | undefined {
return normalizeXuzhouStayBattlePayoffSnapshot(runtime, options);
}
export function normalizeXuzhouStayBattlePayoffSnapshot(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffSnapshot | undefined {
return normalizePayoffState(value, options);
}
export function cloneXuzhouStayBattlePayoffSnapshot(
snapshot: XuzhouStayBattlePayoffSnapshot
): XuzhouStayBattlePayoffSnapshot {
return { ...snapshot };
}
function normalizePayoffState(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions
): XuzhouStayBattlePayoffSnapshot | undefined {
if (
!isRecord(value) ||
value.version !== xuzhouStayBattlePayoffVersion ||
value.recordId !== xuzhouStayBattlePayoffRecordId ||
value.cityStayId !== xuzhouStayId ||
value.sourceBattleId !==
xuzhouStayBattlePayoffSourceBattleId ||
value.targetBattleId !==
xuzhouStayBattlePayoffTargetBattleId ||
value.informationVisitId !==
xuzhouStayInformationVisitId ||
value.dialogueId !== xuzhouStayResonanceDialogueId ||
typeof value.informationCompleted !== 'boolean' ||
typeof value.miZhuDeployed !== 'boolean' ||
(
options.expectedBattleId !== undefined &&
options.expectedBattleId !==
xuzhouStayBattlePayoffTargetBattleId
)
) {
return undefined;
}
const choiceId =
value.choiceId === undefined
? undefined
: isXuzhouStayBattlePayoffChoiceId(value.choiceId)
? value.choiceId
: undefined;
if (value.choiceId !== undefined && !choiceId) {
return undefined;
}
if (options.expectedMemory === null) {
return undefined;
}
const expectedMemory = options.expectedMemory;
if (
expectedMemory &&
(
expectedMemory.recordId !== xuzhouStayBattlePayoffRecordId ||
expectedMemory.sourceBattleId !==
xuzhouStayBattlePayoffSourceBattleId ||
expectedMemory.targetBattleId !==
xuzhouStayBattlePayoffTargetBattleId ||
expectedMemory.informationCompleted !==
value.informationCompleted ||
expectedMemory.choiceId !== choiceId
)
) {
return undefined;
}
const rosterDeployment = expectedMiZhuDeployment(
options.expectedRosterUnitIds
);
const miZhuDeployed =
rosterDeployment ?? value.miZhuDeployed;
const informationCounterplays = value.informationCompleted
? cappedNonNegativeInteger(
value.informationCounterplays,
xuzhouStayInformationEnemyUnitIds.length
)
: 0;
const supportEnabled = Boolean(choiceId && miZhuDeployed);
const supportAttempts = supportEnabled
? cappedNonNegativeInteger(
value.supportAttempts,
maxXuzhouStayBattlePayoffCounter
)
: 0;
const requestedSupportUses = supportEnabled
? Math.min(
cappedNonNegativeInteger(value.supportUses, 1),
supportAttempts
)
: 0;
const bonusHealing =
choiceId === xuzhouStayShareStorehouseDutyChoiceId &&
requestedSupportUses > 0
? cappedNonNegativeInteger(
value.bonusHealing,
xuzhouStayBattlePayoffDefinition.dialogue.choices[
xuzhouStayShareStorehouseDutyChoiceId
].bonusHealing
)
: 0;
const supportUses =
choiceId === xuzhouStayShareStorehouseDutyChoiceId
? bonusHealing > 0
? requestedSupportUses
: 0
: requestedSupportUses;
const bonusBuffTurns =
choiceId === xuzhouStayTrustMiZhuLedgerChoiceId &&
supportUses > 0
? xuzhouStayBattlePayoffDefinition.dialogue.choices[
xuzhouStayTrustMiZhuLedgerChoiceId
].bonusBuffTurns
: 0;
return {
version: xuzhouStayBattlePayoffVersion,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId,
informationCompleted: value.informationCompleted,
...(choiceId ? { choiceId } : {}),
informationCounterplays,
miZhuDeployed,
supportAttempts,
supportUses,
bonusHealing,
bonusBuffTurns
};
}
function selectSourceVictoryRecord(
campaign?: XuzhouStayBattlePayoffCampaignState | null
): XuzhouStayBattlePayoffRecordSource | undefined {
const history = campaign?.battleHistory;
if (
history &&
Object.prototype.hasOwnProperty.call(
history,
xuzhouStayBattlePayoffSourceBattleId
)
) {
return isSourceVictoryRecord(
history[xuzhouStayBattlePayoffSourceBattleId]
)
? 'battle-history'
: undefined;
}
return isSourceVictoryRecord(campaign?.firstBattleReport)
? 'legacy-report'
: undefined;
}
function isSourceVictoryRecord(
value?: XuzhouStayBattleRecord | null
) {
return Boolean(
value &&
value.battleId ===
xuzhouStayBattlePayoffSourceBattleId &&
value.outcome === 'victory'
);
}
function expectedMiZhuDeployment(
value?: readonly unknown[] | ReadonlySet<string>
) {
if (value === undefined) {
return undefined;
}
if (value instanceof Set) {
return value.has(xuzhouStayMiZhuUnitId);
}
if (!Array.isArray(value)) {
return false;
}
return value.some((unitId) => unitId === xuzhouStayMiZhuUnitId);
}
function cappedNonNegativeInteger(value: unknown, max: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return 0;
}
return Math.min(max, Math.floor(numeric));
}
function arrayValue(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
const maxXuzhouStayBattlePayoffCounter = 999999;

File diff suppressed because it is too large Load Diff

View File

@@ -381,6 +381,7 @@ export class CityStayScene extends Phaser.Scene {
id: choice.id,
label: choice.label,
rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp,
battleEffect: choice.battleEffect ?? null,
interactive: Boolean(view?.interactive),
bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
};
@@ -1289,7 +1290,7 @@ export class CityStayScene extends Phaser.Scene {
color: '#f1d691',
fontStyle: 'bold'
});
const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', {
const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명과 다음 전투의 지원 방식이 달라집니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#aeb8c4'
@@ -1318,7 +1319,7 @@ export class CityStayScene extends Phaser.Scene {
});
const reward = this.add.text(
x + 76,
y + 98,
y + 126,
`공명 +${dialogue.rewardExp + choice.rewardExp}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -1327,10 +1328,21 @@ export class CityStayScene extends Phaser.Scene {
fontStyle: 'bold'
}
);
const battleEffect = this.add.text(
x + 76,
y + 82,
choice.battleEffect ?? '선택한 방침은 이후 대화와 공명에 반영됩니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#9fd7ef',
wordWrap: { width: 670, useAdvancedWrap: true }
}
);
button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98));
button.on('pointerdown', () => this.chooseDialogue(choice));
objects.push(button, number, label, reward);
objects.push(button, number, label, battleEffect, reward);
return { choiceId: choice.id, button, interactive: true };
});
const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', {

View File

@@ -21,6 +21,10 @@ import {
normalizeCityEquipmentContributionSnapshot,
type CityEquipmentContributionSnapshot
} from '../data/cityEquipmentPreparation';
import {
normalizeXuzhouStayBattlePayoffRuntime,
type XuzhouStayBattlePayoffRuntime
} from '../data/xuzhouStayBattlePayoff';
export type BattleSaveFaction = 'ally' | 'enemy';
export type BattleSaveRosterTab = 'ally' | 'enemy';
@@ -118,6 +122,8 @@ export type BattleSaveThirdCampPreparationState = {
export type BattleSaveCityEquipmentContributionState =
CityEquipmentContributionSnapshot;
export type BattleSaveXuzhouStayBattlePayoffState =
XuzhouStayBattlePayoffRuntime;
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
@@ -141,6 +147,7 @@ export type BattleSaveState = {
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
thirdCampPreparation?: BattleSaveThirdCampPreparationState;
cityEquipmentContribution?: BattleSaveCityEquipmentContributionState;
xuzhouStayBattlePayoff?: BattleSaveXuzhouStayBattlePayoffState;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
@@ -239,6 +246,7 @@ export function normalizeBattleSaveState(
normalizeCooperationSaveFields(cloned, options);
normalizeThirdCampPreparationSaveField(cloned);
normalizeCityEquipmentContributionSaveField(cloned, options);
normalizeXuzhouStayBattlePayoffSaveField(cloned, options);
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
allowedUnitIds: options.validAllyUnitIds
@@ -296,6 +304,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false;
}
if (!isOptionalXuzhouStayBattlePayoffState(state.xuzhouStayBattlePayoff, state, options)) {
return false;
}
const attackIntents = state.attackIntents;
const units = state.units;
@@ -803,6 +815,100 @@ function canonicalCityEquipmentContributionSaveState(
};
}
function normalizeXuzhouStayBattlePayoffSaveField(
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
const normalized = canonicalXuzhouStayBattlePayoffSaveState(
state.xuzhouStayBattlePayoff,
state,
options
);
if (normalized) {
state.xuzhouStayBattlePayoff = normalized;
} else {
delete state.xuzhouStayBattlePayoff;
}
}
function isOptionalXuzhouStayBattlePayoffState(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
if (value === undefined) {
return true;
}
const normalized = canonicalXuzhouStayBattlePayoffSaveState(
value,
state,
options
);
if (!normalized || !isRecord(value)) {
return false;
}
const fields: Array<
keyof BattleSaveXuzhouStayBattlePayoffState
> = [
'version',
'recordId',
'cityStayId',
'sourceBattleId',
'targetBattleId',
'informationVisitId',
'dialogueId',
'informationCompleted',
'informationCounterplays',
'miZhuDeployed',
'supportAttempts',
'supportUses',
'bonusHealing',
'bonusBuffTurns'
];
if (normalized.choiceId) {
fields.push('choiceId');
}
return (
Object.keys(value).length === fields.length &&
fields.every((field) => value[field] === normalized[field])
);
}
function canonicalXuzhouStayBattlePayoffSaveState(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
): BattleSaveXuzhouStayBattlePayoffState | undefined {
const battleId =
typeof state.battleId === 'string' ? state.battleId : undefined;
if (!battleId || !Array.isArray(state.units)) {
return undefined;
}
const deployedUnitIds = state.units
.filter(isRecord)
.map((unit) => unit.id)
.filter(
(unitId): unitId is string =>
typeof unitId === 'string' &&
(!options.validAllyUnitIds ||
options.validAllyUnitIds.has(unitId))
);
const normalized = normalizeXuzhouStayBattlePayoffRuntime(
value,
{
expectedBattleId: battleId,
expectedRosterUnitIds: deployedUnitIds
}
);
if (
!normalized ||
(!normalized.informationCompleted && !normalized.choiceId)
) {
return undefined;
}
return normalized;
}
function isOptionalCoreResonanceState(
state: Record<string, unknown>,
options: BattleSaveValidationOptions

View File

@@ -13,6 +13,12 @@ import {
type CityEquipmentPreparationSnapshot,
type CityEquipmentPurchaseCounts
} from '../data/cityEquipmentPreparation';
import {
cloneXuzhouStayBattlePayoffSnapshot,
normalizeXuzhouStayBattlePayoffSnapshot,
resolveXuzhouStayBattlePayoff,
type XuzhouStayBattlePayoffSnapshot
} from '../data/xuzhouStayBattlePayoff';
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
import {
isThirdCampPreparationPriorityId,
@@ -190,6 +196,7 @@ export type FirstBattleReport = {
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot;
cityEquipmentContribution?: CityEquipmentContributionSnapshot;
xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot;
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
@@ -475,6 +482,7 @@ export type CampaignBattleSettlement = {
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot;
cityEquipmentContribution?: CityEquipmentContributionSnapshot;
xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot;
completedAt: string;
};
@@ -1027,6 +1035,24 @@ export function setFirstBattleReport(report: FirstBattleReport) {
} else {
delete reportClone.cityEquipmentContribution;
}
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
reportClone.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: reportClone.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id),
expectedMemory:
resolveXuzhouStayBattlePayoff(state, battleId) ?? null
}
);
if (xuzhouStayBattlePayoff) {
reportClone.xuzhouStayBattlePayoff =
xuzhouStayBattlePayoff;
} else {
delete reportClone.xuzhouStayBattlePayoff;
}
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues = mergeCampCompletionIds(
state.completedCampDialogues,
@@ -1899,6 +1925,7 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalizeStoredCityEquipmentContributionsForContainers(
normalized
);
normalizeStoredXuzhouStayBattlePayoffsForContainers(normalized);
const recordedVictoryBattleIds = new Set(
Object.entries(normalized.battleHistory)
.filter(([, settlement]) => settlement.outcome === 'victory')
@@ -2628,6 +2655,44 @@ function normalizeStoredCityEquipmentContributionsForContainers(
});
}
function normalizeStoredXuzhouStayBattlePayoffsForContainers(
state: CampaignState
) {
if (state.firstBattleReport) {
const payoff = normalizeXuzhouStayBattlePayoffSnapshot(
state.firstBattleReport.xuzhouStayBattlePayoff,
{
expectedBattleId: state.firstBattleReport.battleId,
expectedRosterUnitIds: state.firstBattleReport.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id)
}
);
if (payoff) {
state.firstBattleReport.xuzhouStayBattlePayoff = payoff;
} else {
delete state.firstBattleReport.xuzhouStayBattlePayoff;
}
}
Object.values(state.battleHistory).forEach((settlement) => {
const payoff = normalizeXuzhouStayBattlePayoffSnapshot(
settlement.xuzhouStayBattlePayoff,
{
expectedBattleId: settlement.battleId,
expectedRosterUnitIds: settlement.units.map(
(unit) => unit.unitId
)
}
);
if (payoff) {
settlement.xuzhouStayBattlePayoff = payoff;
} else {
delete settlement.xuzhouStayBattlePayoff;
}
});
}
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds);
const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance);
@@ -2793,6 +2858,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
battleId,
campaignSettlementContributionRoster(units)
);
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
settlement.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: units.map((unit) => unit.unitId)
}
);
return {
battleId,
@@ -2814,6 +2887,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
...(cityEquipmentContribution
? { cityEquipmentContribution }
: {}),
...(xuzhouStayBattlePayoff
? { xuzhouStayBattlePayoff }
: {}),
completedAt
};
}
@@ -3171,6 +3247,16 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
battleId,
units
);
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
report.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id)
}
);
return {
battleId,
@@ -3197,6 +3283,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
...(cityEquipmentContribution
? { cityEquipmentContribution }
: {}),
...(xuzhouStayBattlePayoff
? { xuzhouStayBattlePayoff }
: {}),
completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues),
completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits),
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
@@ -3472,6 +3561,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
)
}
: {}),
...(report.xuzhouStayBattlePayoff
? {
xuzhouStayBattlePayoff:
cloneXuzhouStayBattlePayoffSnapshot(
report.xuzhouStayBattlePayoff
)
}
: {}),
completedAt: report.createdAt
};
}
@@ -3641,6 +3738,12 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport {
cloned.cityEquipmentContribution
);
}
if (cloned.xuzhouStayBattlePayoff) {
cloned.xuzhouStayBattlePayoff =
cloneXuzhouStayBattlePayoffSnapshot(
cloned.xuzhouStayBattlePayoff
);
}
return cloned;
}