feat: carry first-battle camaraderie into sortie prep

This commit is contained in:
2026-07-27 06:57:33 +09:00
parent b963ef34cb
commit 17486efa8b
10 changed files with 2207 additions and 89 deletions

View File

@@ -0,0 +1,341 @@
import type {
CampaignBattleSettlement,
CampaignSortieCooperationSnapshot
} from '../state/campaignState';
export const firstBattleCamaraderieSourceBattleId =
'first-battle-zhuo-commandery';
export const firstBattleCamaraderieTargetBattleId =
'second-battle-yellow-turban-pursuit';
export const firstBattleCamaraderieDialogueId =
'first-battle-camaraderie-memory';
export const firstBattleCamaraderieCoreRateBonus = 5;
export type SortieCooperationStats = {
attempts: number;
successes: number;
additionalDamage: number;
};
export type FirstBattleCamaraderieDialogueChoice = {
id: string;
label: string;
response: string;
rewardExp: number;
};
export type FirstBattleCamaraderieDialogue = {
id: typeof firstBattleCamaraderieDialogueId;
title: string;
unitIds: [string, string];
bondId: string;
rewardExp: number;
lines: [string, string, string];
choices: [
FirstBattleCamaraderieDialogueChoice,
FirstBattleCamaraderieDialogueChoice
];
};
export type FirstBattleCamaraderieMemory = {
sourceBattleId: typeof firstBattleCamaraderieSourceBattleId;
targetBattleId: typeof firstBattleCamaraderieTargetBattleId;
bondId: string;
unitIds: [string, string];
unitNames: [string, string];
attempts: number;
successes: number;
additionalDamage: number;
rememberedOutcome: 'success' | 'attempted';
completed: boolean;
bonusRate: typeof firstBattleCamaraderieCoreRateBonus;
summary: string;
dialogue: FirstBattleCamaraderieDialogue;
};
export type FirstBattleCamaraderieCampaignState = {
battleHistory?: Partial<Record<string, CampaignBattleSettlement | undefined>>;
completedCampDialogues?: readonly string[];
roster?: readonly {
id: string;
name: string;
}[];
};
type CooperationBond = {
id: string;
unitIds: readonly string[];
};
export function selectDominantSortieCooperationSnapshot(options: {
statsByBond?: Readonly<
Record<string, SortieCooperationStats | undefined>
>;
bonds: readonly CooperationBond[];
}): CampaignSortieCooperationSnapshot | undefined {
const bondById = new Map(
options.bonds
.filter((bond) => (
validKey(bond.id) &&
bond.unitIds.length === 2 &&
validKey(bond.unitIds[0]) &&
validKey(bond.unitIds[1]) &&
bond.unitIds[0] !== bond.unitIds[1]
))
.map((bond) => [bond.id, bond])
);
const candidates = Object.entries(options.statsByBond ?? {})
.flatMap<CampaignSortieCooperationSnapshot>(([bondId, stats]) => {
const bond = bondById.get(bondId);
const normalized = normalizeStats(stats);
if (!bond || !normalized || normalized.attempts <= 0) {
return [];
}
return [{
version: 1,
bondId,
unitIds: [bond.unitIds[0], bond.unitIds[1]],
...normalized
}];
})
.sort(compareCooperationSnapshots);
return candidates[0]
? cloneCooperationSnapshot(candidates[0])
: undefined;
}
export function resolveFirstBattleCamaraderieMemory(
campaign?: FirstBattleCamaraderieCampaignState | null
): FirstBattleCamaraderieMemory | undefined {
const settlement =
campaign?.battleHistory?.[firstBattleCamaraderieSourceBattleId];
if (
!settlement ||
settlement.battleId !== firstBattleCamaraderieSourceBattleId ||
settlement.outcome !== 'victory'
) {
return undefined;
}
const snapshot = validCampaignSnapshot(settlement.sortieCooperation);
if (!snapshot) {
return undefined;
}
const unitNamesById = new Map<string, string>();
campaign?.roster?.forEach((unit) => {
if (validKey(unit.id) && validDisplayName(unit.name)) {
unitNamesById.set(unit.id, unit.name.trim());
}
});
(settlement.units ?? []).forEach((unit) => {
if (validKey(unit.unitId) && validDisplayName(unit.name)) {
unitNamesById.set(unit.unitId, unit.name.trim());
}
});
const unitNames: [string, string] = [
unitNamesById.get(snapshot.unitIds[0]) ?? snapshot.unitIds[0],
unitNamesById.get(snapshot.unitIds[1]) ?? snapshot.unitIds[1]
];
const rememberedOutcome =
snapshot.successes > 0 ? 'success' : 'attempted';
const dialogue = createCamaraderieDialogue(
snapshot,
unitNames,
rememberedOutcome
);
return {
sourceBattleId: firstBattleCamaraderieSourceBattleId,
targetBattleId: firstBattleCamaraderieTargetBattleId,
bondId: snapshot.bondId,
unitIds: [...snapshot.unitIds],
unitNames,
attempts: snapshot.attempts,
successes: snapshot.successes,
additionalDamage: snapshot.additionalDamage,
rememberedOutcome,
completed: Boolean(
campaign?.completedCampDialogues?.includes(
firstBattleCamaraderieDialogueId
)
),
bonusRate: firstBattleCamaraderieCoreRateBonus,
summary: rememberedOutcome === 'success'
? `${unitNames.join(' · ')} 협공 ${snapshot.successes}/${snapshot.attempts}회 · 추가 피해 ${snapshot.additionalDamage}`
: `${unitNames.join(' · ')} 협공 시도 ${snapshot.attempts}회 · 다음 출전에서 호흡 보완`,
dialogue
};
}
export function firstBattleCamaraderieBonusFor(options: {
campaign?: FirstBattleCamaraderieCampaignState | null;
battleId?: string;
coreBondId?: string;
}) {
const memory = resolveFirstBattleCamaraderieMemory(options.campaign);
return (
memory?.completed &&
options.battleId === firstBattleCamaraderieTargetBattleId &&
options.coreBondId === memory.bondId
)
? firstBattleCamaraderieCoreRateBonus
: 0;
}
function compareCooperationSnapshots(
left: CampaignSortieCooperationSnapshot,
right: CampaignSortieCooperationSnapshot
) {
return (
Number(right.successes > 0) - Number(left.successes > 0) ||
right.successes - left.successes ||
right.additionalDamage - left.additionalDamage ||
right.attempts - left.attempts ||
stableTextCompare(left.bondId, right.bondId)
);
}
function stableTextCompare(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function normalizeStats(
stats?: SortieCooperationStats
): SortieCooperationStats | undefined {
if (!stats || typeof stats !== 'object') {
return undefined;
}
const attempts = safeNonNegativeInteger(stats.attempts);
if (attempts <= 0) {
return undefined;
}
const successes = Math.min(
attempts,
safeNonNegativeInteger(stats.successes)
);
return {
attempts,
successes,
additionalDamage: successes > 0
? safeNonNegativeInteger(stats.additionalDamage)
: 0
};
}
function validCampaignSnapshot(
snapshot?: CampaignSortieCooperationSnapshot
): CampaignSortieCooperationSnapshot | undefined {
if (
!snapshot ||
snapshot.version !== 1 ||
!validKey(snapshot.bondId) ||
!Array.isArray(snapshot.unitIds) ||
snapshot.unitIds.length !== 2 ||
!validKey(snapshot.unitIds[0]) ||
!validKey(snapshot.unitIds[1]) ||
snapshot.unitIds[0] === snapshot.unitIds[1]
) {
return undefined;
}
const stats = normalizeStats(snapshot);
if (!stats) {
return undefined;
}
return {
version: 1,
bondId: snapshot.bondId,
unitIds: [...snapshot.unitIds],
...stats
};
}
function cloneCooperationSnapshot(
snapshot: CampaignSortieCooperationSnapshot
): CampaignSortieCooperationSnapshot {
return {
...snapshot,
unitIds: [...snapshot.unitIds]
};
}
function createCamaraderieDialogue(
snapshot: CampaignSortieCooperationSnapshot,
unitNames: [string, string],
rememberedOutcome: FirstBattleCamaraderieMemory['rememberedOutcome']
): FirstBattleCamaraderieDialogue {
const [firstName, secondName] = unitNames;
const lines: [string, string, string] = rememberedOutcome === 'success'
? [
`${firstName}: 지난 싸움에서 네가 틈을 이어 준 덕에 적진을 흔들 수 있었어.`,
`${secondName}: 서로의 신호를 알아본 순간이었습니다. 다음에도 같은 호흡을 잇겠습니다.`,
`${firstName}: 좋아. 황건 추격에서도 우리가 직접 핵심 공명조를 정해 이 합을 시험하자.`
]
: [
`${firstName}: 지난 싸움에서는 신호가 한 박자 어긋나 합을 끝까지 잇지 못했어.`,
`${secondName}: 그래도 서로가 움직이려던 순간은 보았습니다. 다음에는 놓치지 않겠습니다.`,
`${firstName}: 황건 추격 전에 신호를 맞추자. 핵심 공명조로 정한다면 이번에는 끝까지 잇는다.`
];
const choices: FirstBattleCamaraderieDialogue['choices'] =
rememberedOutcome === 'success'
? [
{
id: 'trust-remembered-rhythm',
label: '그 호흡을 다시 믿는다',
response: `두 사람은 첫 승리에서 맞춘 신호를 다시 확인했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`,
rewardExp: 4
},
{
id: 'review-successful-signals',
label: '성공한 신호를 되짚는다',
response: `두 사람은 ${snapshot.successes}번 이어진 협공을 복기했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`,
rewardExp: 2
}
]
: [
{
id: 'correct-missed-timing',
label: '엇갈린 순간을 바로잡는다',
response: `두 사람은 ${snapshot.attempts}번의 시도를 되짚었다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`,
rewardExp: 4
},
{
id: 'agree-on-clear-signal',
label: '서로의 신호를 정한다',
response: `두 사람은 추격전의 신호를 정했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`,
rewardExp: 2
}
];
return {
id: firstBattleCamaraderieDialogueId,
title: rememberedOutcome === 'success'
? `${firstName} · ${secondName}, 이어진 합`
: `${firstName} · ${secondName}, 엇갈린 신호`,
unitIds: [...snapshot.unitIds],
bondId: snapshot.bondId,
rewardExp: 8,
lines,
choices
};
}
function safeNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.min(999999, Math.max(0, Math.floor(value)));
}
function validKey(value: unknown): value is string {
return (
typeof value === 'string' &&
value.trim() === value &&
value.length > 0 &&
value.length <= 96
);
}
function validDisplayName(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -120,6 +120,10 @@ import {
firstBattleVolunteerPromiseEventKey,
volunteerPromiseLineForBattle
} from '../data/firstBattleNarrativeMemory';
import {
firstBattleCamaraderieBonusFor,
selectDominantSortieCooperationSnapshot
} from '../data/firstBattleCamaraderieMemory';
import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
@@ -154,6 +158,7 @@ import {
type CampaignTutorialId,
type CampaignSortieItemAssignments,
type CampaignSortieOrderResultSnapshot,
type CampaignSortieCooperationSnapshot,
type CampaignSortiePerformanceSnapshot,
type CampaignSortiePresetId,
type CampaignSortieRecommendationSnapshot,
@@ -1697,6 +1702,12 @@ type BondState = BattleBond & {
battleExp: number;
};
type SortieCooperationStats = {
attempts: number;
successes: number;
additionalDamage: number;
};
type BondCombatBonus = {
damageBonus: number;
chainRate: number;
@@ -3887,6 +3898,9 @@ export class BattleScene extends Phaser.Scene {
private coreResonancePursuitSuccesses = 0;
private coreResonancePursuitFailures = 0;
private coreResonancePursuitDamage = 0;
private cooperationStatsByBond = new Map<string, SortieCooperationStats>();
private launchCamaraderieMemoryBondId?: string;
private launchCamaraderieMemoryBonus = 0;
constructor() {
super('BattleScene');
@@ -4017,6 +4031,9 @@ export class BattleScene extends Phaser.Scene {
data?.sortieRecommendation ?? campaign.sortieRecommendationSelection
);
this.resetCoreResonancePursuitStats();
this.resetSortieCooperationStats();
this.launchCamaraderieMemoryBondId = undefined;
this.launchCamaraderieMemoryBonus = 0;
}
create() {
@@ -4076,7 +4093,9 @@ export class BattleScene extends Phaser.Scene {
this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId(
this.launchSortieResonanceBondId
);
this.refreshLaunchCamaraderieMemoryBonus(campaign);
this.resetCoreResonancePursuitStats();
this.resetSortieCooperationStats();
this.itemStocks = this.createItemStocks(campaign);
this.battleBuffs.clear();
this.battleStatuses.clear();
@@ -4838,6 +4857,17 @@ export class BattleScene extends Phaser.Scene {
return bond.unitIds.every((unitId) => deployedAllies.has(unitId)) ? bond.id : undefined;
}
private refreshLaunchCamaraderieMemoryBonus(campaign: CampaignState) {
const coreBondId = this.launchSortieResonanceBondId;
const bonus = firstBattleCamaraderieBonusFor({
campaign,
battleId: battleScenario.id,
coreBondId
});
this.launchCamaraderieMemoryBondId = bonus > 0 ? coreBondId : undefined;
this.launchCamaraderieMemoryBonus = bonus;
}
private resetCoreResonancePursuitStats() {
this.coreResonancePursuitAttempts = 0;
this.coreResonancePursuitSuccesses = 0;
@@ -4865,6 +4895,70 @@ export class BattleScene extends Phaser.Scene {
this.coreResonancePursuitDamage = stats.additionalDamage;
}
private resetSortieCooperationStats() {
this.cooperationStatsByBond.clear();
}
private sortieCooperationStatsRecord() {
return Object.fromEntries(
Array.from(this.cooperationStatsByBond.entries())
.filter(([bondId]) => this.bondStates.has(bondId))
.sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId))
.map(([bondId, stats]) => [bondId, { ...stats }])
);
}
private dominantSortieCooperationSnapshot(): CampaignSortieCooperationSnapshot | undefined {
return selectDominantSortieCooperationSnapshot({
statsByBond: this.sortieCooperationStatsRecord(),
bonds: Array.from(this.bondStates.values()).map((bond) => ({
id: bond.id,
unitIds: [...bond.unitIds] as [string, string]
}))
});
}
private restoreSortieCooperationStats(
statsByBond?: BattleSaveState['cooperationStatsByBond']
) {
this.resetSortieCooperationStats();
Object.entries(statsByBond ?? {})
.filter(([bondId]) => this.bondStates.has(bondId))
.sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId))
.forEach(([bondId, stats]) => {
this.cooperationStatsByBond.set(bondId, { ...stats });
});
}
private recordSortieCooperationAttempt(bondId: string) {
const normalizedBondId = bondId.trim();
if (!normalizedBondId || !this.bondStates.has(normalizedBondId)) {
return;
}
const current = this.cooperationStatsByBond.get(normalizedBondId) ?? {
attempts: 0,
successes: 0,
additionalDamage: 0
};
this.cooperationStatsByBond.set(normalizedBondId, {
...current,
attempts: current.attempts + 1
});
}
private recordSortieCooperationSuccess(bondId: string, additionalDamage: number) {
const normalizedBondId = bondId.trim();
const current = this.cooperationStatsByBond.get(normalizedBondId);
if (!normalizedBondId || !current) {
return;
}
this.cooperationStatsByBond.set(normalizedBondId, {
attempts: current.attempts,
successes: current.successes + 1,
additionalDamage: current.additionalDamage + Math.max(0, additionalDamage)
});
}
private effectiveSortieUnitIds(campaign?: CampaignState) {
const configured = this.launchSortieUnitIds.length > 0
? this.launchSortieUnitIds
@@ -13492,10 +13586,17 @@ export class BattleScene extends Phaser.Scene {
}
if (this.launchSortieResonanceBondId) {
const selectedCoreBond = this.bondStates.get(this.launchSortieResonanceBondId);
const selectedCoreRate = selectedCoreBond
? this.bondChainRateBreakdown(selectedCoreBond)
: { baseRate: 0, memoryBonus: 0, effectiveRate: 0 };
const memoryRateSummary = selectedCoreRate.memoryBonus > 0
? ` · 판정 ${selectedCoreRate.effectiveRate}% (전우 기억 +${selectedCoreRate.memoryBonus}%p)`
: '';
const coreResonanceSummary = this.trackResultObject(this.add.text(
left + panelWidth - 44,
top + 372,
`핵심 공명 · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`,
`핵심 공명${memoryRateSummary} · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
@@ -14048,6 +14149,7 @@ export class BattleScene extends Phaser.Scene {
sortieRecommendation: this.launchSortieRecommendation
? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation)
: undefined,
sortieCooperation: this.dominantSortieCooperationSnapshot(),
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
@@ -16483,6 +16585,7 @@ export class BattleScene extends Phaser.Scene {
succeeded,
coreResonance: preview.bondChainCoreResonance
};
this.recordSortieCooperationAttempt(attempt.bondId);
if (attempt.coreResonance) {
this.coreResonancePursuitAttempts += 1;
}
@@ -16503,6 +16606,7 @@ export class BattleScene extends Phaser.Scene {
this.coreResonancePursuitSuccesses += 1;
this.coreResonancePursuitDamage += damage;
}
this.recordSortieCooperationSuccess(attempt.bondId, damage);
this.statsFor(preview.attacker.id).sortieExtendedBondTriggers += 1;
return {
@@ -19140,6 +19244,7 @@ export class BattleScene extends Phaser.Scene {
coreResonanceStats: this.launchSortieResonanceBondId
? { ...this.coreResonancePursuitStatsSnapshot() }
: undefined,
cooperationStatsByBond: this.sortieCooperationStatsRecord(),
savedAt: new Date().toISOString(),
turnNumber: this.turnNumber,
activeFaction: this.activeFaction,
@@ -19218,6 +19323,7 @@ export class BattleScene extends Phaser.Scene {
);
this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation);
this.resetCoreResonancePursuitStats();
this.resetSortieCooperationStats();
this.turnNumber = Math.max(1, state.turnNumber || 1);
this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally';
this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally';
@@ -19280,9 +19386,11 @@ export class BattleScene extends Phaser.Scene {
this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId(
this.launchSortieResonanceBondId
);
this.refreshLaunchCamaraderieMemoryBonus(getCampaignState());
if (this.launchSortieResonanceBondId) {
this.restoreCoreResonancePursuitStats(state.coreResonanceStats);
}
this.restoreSortieCooperationStats(state.cooperationStatsByBond);
const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0];
if (cameraFocus) {
@@ -24058,12 +24166,36 @@ export class BattleScene extends Phaser.Scene {
return bond.id === this.launchSortieResonanceBondId && bond.level >= coreSortieResonanceMinLevel;
}
private bondChainRateForBond(bond: Pick<BondState, 'id' | 'level'>) {
private baseBondChainRateForBond(bond: Pick<BondState, 'id' | 'level'>) {
return this.isCoreSortieResonanceBond(bond)
? coreSortieResonanceChainRateForLevel(bond.level)
: sortieBondBonusForLevel(bond.level).chainRate;
}
private camaraderieMemoryBonusForBond(bond: Pick<BondState, 'id' | 'level'>) {
if (
!this.isCoreSortieResonanceBond(bond) ||
bond.id !== this.launchCamaraderieMemoryBondId
) {
return 0;
}
return this.launchCamaraderieMemoryBonus;
}
private bondChainRateBreakdown(bond: Pick<BondState, 'id' | 'level'>) {
const baseRate = this.baseBondChainRateForBond(bond);
const memoryBonus = this.camaraderieMemoryBonusForBond(bond);
return {
baseRate,
memoryBonus,
effectiveRate: Phaser.Math.Clamp(baseRate + memoryBonus, 0, 100)
};
}
private bondChainRateForBond(bond: Pick<BondState, 'id' | 'level'>) {
return this.bondChainRateBreakdown(bond).effectiveRate;
}
private bondChainPartnerEligible(
attacker: UnitData,
partner: UnitData,
@@ -28387,6 +28519,11 @@ export class BattleScene extends Phaser.Scene {
private coreSortieResonanceDebugState() {
const selectedBondId = this.launchSortieResonanceBondId;
const bond = selectedBondId ? this.bondStates.get(selectedBondId) : undefined;
const rate = bond
? this.bondChainRateBreakdown(bond)
: { baseRate: 0, memoryBonus: 0, effectiveRate: 0 };
const cooperationStatsByBond = this.sortieCooperationStatsRecord();
const cooperationSnapshot = this.dominantSortieCooperationSnapshot();
const active = Boolean(
bond &&
this.isCoreSortieResonanceBond(bond) &&
@@ -28404,13 +28541,25 @@ export class BattleScene extends Phaser.Scene {
unitIds: bond ? [...bond.unitIds] : [],
unitNames: bond ? bond.unitIds.map((unitId) => this.unitName(unitId)) : [],
level: bond?.level ?? null,
rate: bond ? this.bondChainRateForBond(bond) : 0,
rate: rate.effectiveRate,
baseRate: rate.baseRate,
memoryBonus: rate.memoryBonus,
effectiveRate: rate.effectiveRate,
stats: {
attempts: this.coreResonancePursuitAttempts,
successes: this.coreResonancePursuitSuccesses,
failures: this.coreResonancePursuitFailures,
additionalDamage: this.coreResonancePursuitDamage
},
cooperation: {
statsByBond: cooperationStatsByBond,
dominantSnapshot: cooperationSnapshot
? {
...cooperationSnapshot,
unitIds: [...cooperationSnapshot.unitIds]
}
: null
},
resultSummary: this.resultCoreResonanceSummaryText?.active
? {
visible: this.resultCoreResonanceSummaryText.visible,

View File

@@ -28,6 +28,12 @@ import {
resolveFirstBattleCampFollowup,
type FirstBattleCampFollowup
} from '../data/firstBattleCampFollowup';
import {
firstBattleCamaraderieBonusFor,
firstBattleCamaraderieCoreRateBonus,
resolveFirstBattleCamaraderieMemory,
type FirstBattleCamaraderieMemory
} from '../data/firstBattleCamaraderieMemory';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
@@ -605,6 +611,10 @@ type SortiePursuitPanelView = {
rows: {
bondId: string;
level: number;
baseChainRate: number;
bonusChainRate: number;
effectiveChainRate: number;
camaraderieRemembered: boolean;
chainRate: number;
selected: boolean;
state: 'selected' | 'candidate';
@@ -615,7 +625,10 @@ type SortiePursuitPanelView = {
};
type SortieCoreResonanceCandidate = SortieSynergyActiveBond & {
baseCoreChainRate: number;
camaraderieBonusRate: number;
coreChainRate: number;
camaraderieRemembered: boolean;
selected: boolean;
};
@@ -12807,6 +12820,24 @@ export class CampScene extends Phaser.Scene {
return resolution.source === 'campaign' ? resolution : undefined;
}
private firstBattleCamaraderieMemory(): FirstBattleCamaraderieMemory | undefined {
return resolveFirstBattleCamaraderieMemory(this.campaign);
}
private firstBattleCamaraderieDialogue(): CampDialogue | undefined {
if (!this.isFirstSortiePrepSlice()) {
return undefined;
}
const memory = this.firstBattleCamaraderieMemory();
if (!memory) {
return undefined;
}
return {
...memory.dialogue,
availableAfterBattleIds: [memory.sourceBattleId]
};
}
private campDialogueWithFirstBattleFollowup(dialogue: CampDialogue) {
const followup = this.firstBattleCampFollowup();
if (!followup || dialogue.id !== followup.targetDialogueId) {
@@ -12827,14 +12858,26 @@ export class CampScene extends Phaser.Scene {
);
}
private availableCampDialogues() {
private hasPendingFirstBattleCamaraderieMemory() {
const dialogue = this.firstBattleCamaraderieDialogue();
return Boolean(
dialogue &&
!this.completedCampDialogues().includes(dialogue.id)
);
}
private availableCampDialogues(): CampDialogue[] {
const battleId = this.currentCampBattleId();
const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
const dialogues = this.filterCampEventsByStep(battleDialogues);
const available = dialogues.length > 0
? dialogues
: campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
return available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue));
const resolved = available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue));
const camaraderieDialogue = this.firstBattleCamaraderieDialogue();
return camaraderieDialogue && !resolved.some((dialogue) => dialogue.id === camaraderieDialogue.id)
? [...resolved, camaraderieDialogue]
: resolved;
}
private completedAvailableDialogues() {
@@ -13824,15 +13867,20 @@ export class CampScene extends Phaser.Scene {
const pendingCategories = new Set(getPendingCampaignVictoryRewardCategories());
this.tabButtons.forEach(({ tab, bg, text, indicator, newBadge, hovered }) => {
const active = this.activeTab === tab;
const pendingFirstBattleFollowup = tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup();
const pendingCamaraderieMemory = tab === 'dialogue' && this.hasPendingFirstBattleCamaraderieMemory();
bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94);
bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62);
text.setColor(active || hovered ? '#fff2b8' : '#f2e3bf');
indicator.setFillStyle(accentColor, 1);
indicator.setVisible(active || hovered);
indicator.setAlpha(active ? 1 : 0.66);
if (tab === 'dialogue' && newBadge) {
newBadge.setText(pendingFirstBattleFollowup ? '회고' : pendingCamaraderieMemory ? '전우' : '회고');
}
newBadge?.setVisible(Boolean(
(tab === 'city' && this.hasPendingCityStayActions()) ||
(tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup()) ||
(tab === 'dialogue' && (pendingFirstBattleFollowup || pendingCamaraderieMemory)) ||
(tab === 'supplies' && pendingCategories.has('supplies')) ||
(tab === 'equipment' && pendingCategories.has('equipment'))
));
@@ -15194,11 +15242,15 @@ export class CampScene extends Phaser.Scene {
this.setCampaignSortieCoreSelectorVisible(true);
view.background.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62);
view.title.setText('핵심 공명조');
view.summary.setText(selectedCore ? `지정 ${selectedCore.coreChainRate}% · 후보 ${coreCandidates.length}` : `미지정 · 후보 ${coreCandidates.length}`);
view.summary.setText(
selectedCore
? `지정 ${selectedCore.coreChainRate}%${selectedCore.camaraderieRemembered ? ` · 기억 +${selectedCore.camaraderieBonusRate}%p` : ''} · 후보 ${coreCandidates.length}`
: `미지정 · 후보 ${coreCandidates.length}`
);
view.headline
.setText(
selectedCore
? `${selectedCore.unitNames[0]}${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 준비`
? `${selectedCore.unitNames[0]}${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 ${selectedCore.baseCoreChainRate}%${selectedCore.camaraderieRemembered ? ` + 전우 기억 ${selectedCore.camaraderieBonusRate}%p` : ''}`
: coreCandidates.length > 0
? '아래 인연 칩을 눌러 이번 전투의 핵심 한 조를 정하십시오.'
: `Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하면 후보가 열립니다.`
@@ -15208,7 +15260,11 @@ export class CampScene extends Phaser.Scene {
.setText(this.compactText(this.sortiePlanFeedback || (selectedCore ? '다시 누르면 지정을 해제합니다.' : '정확히 한 조만 지정되며 다른 조를 누르면 즉시 교체됩니다.'), 68))
.setColor(this.sortiePlanFeedback || selectedCore ? '#a8ffd0' : '#d4dce6');
view.detail
.setText(`Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지`)
.setText(
coreCandidates.some((candidate) => candidate.camaraderieRemembered)
? `전우 기억한 같은 조 직접 지정 시 +${firstBattleCamaraderieCoreRateBonus}%p · 재클릭 해제 · 일반 추격 유지`
: `Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지`
)
.setColor('#9fb0bf');
return;
}
@@ -15865,10 +15921,16 @@ export class CampScene extends Phaser.Scene {
const rows: SortiePursuitPanelView['rows'] = visibleCandidates.map((candidate, index) => {
const chipX = x + index * (chipWidth + chipGap);
const selected = candidate.selected;
const chipText = `${selected ? '핵심' : '지정'} ${candidate.coreChainRate}% ${candidate.unitNames[0]}${candidate.unitNames[1]}`;
const rateText = candidate.camaraderieRemembered
? `${candidate.baseCoreChainRate}${candidate.coreChainRate}%`
: `${candidate.coreChainRate}%`;
const chipText = `${selected ? '핵심' : '지정'} ${rateText} · ${candidate.unitNames[0]}${candidate.unitNames[1]}`;
const chipLabel = candidate.camaraderieRemembered
? `${this.compactText(chipText, 20)}\n전우 기억 +${candidate.camaraderieBonusRate}%p`
: this.compactText(chipText, chipWidth >= 140 ? 18 : 15);
const fill = selected ? 0x493719 : 0x173329;
const stroke = selected ? palette.gold : palette.green;
const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 22, fill, 0.98));
const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 32, fill, 0.98));
background.setOrigin(0);
background.setDepth(depth);
background.setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68);
@@ -15883,9 +15945,13 @@ export class CampScene extends Phaser.Scene {
const label = this.trackSortie(
this.add.text(
chipX + chipWidth / 2,
y + 11,
this.compactText(chipText, chipWidth >= 140 ? 18 : 15),
this.textStyle(9, selected ? '#ffdf7b' : '#a8ffd0', true)
y + 16,
chipLabel,
{
...this.textStyle(candidate.camaraderieRemembered ? 8 : 9, selected ? '#ffdf7b' : '#a8ffd0', true),
align: 'center',
lineSpacing: 0
}
)
);
label.setOrigin(0.5);
@@ -15893,6 +15959,10 @@ export class CampScene extends Phaser.Scene {
return {
bondId: candidate.id,
level: candidate.level,
baseChainRate: candidate.baseCoreChainRate,
bonusChainRate: candidate.camaraderieBonusRate,
effectiveChainRate: candidate.coreChainRate,
camaraderieRemembered: candidate.camaraderieRemembered,
chainRate: candidate.coreChainRate,
selected,
state: selected ? 'selected' as const : 'candidate' as const,
@@ -15955,7 +16025,7 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 18,
y + 9,
`핵심 공명조 · ${selectedCore ? '지정' : '미지정'} · 후보 ${coreCandidates.length}`,
`핵심 공명조 · ${selectedCore ? `${selectedCore.coreChainRate}% 지정` : '미지정'} · 후보 ${coreCandidates.length}`,
this.textStyle(14, '#f2e3bf', true)
)
);
@@ -15971,7 +16041,7 @@ export class CampScene extends Phaser.Scene {
width - 36,
depth + 1,
x + width - 84,
y + 60
y + 72
);
if (pursuitPageView.rows.length === 0) {
this.trackSortie(
@@ -15979,7 +16049,14 @@ export class CampScene extends Phaser.Scene {
).setDepth(depth + 1);
}
const ruleText = this.trackSortie(
this.add.text(x + 18, y + 62, `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`, this.textStyle(9, '#d4dce6', true))
this.add.text(
x + 18,
y + 72,
coreCandidates.some((candidate) => candidate.camaraderieRemembered)
? `전우 기억한 조 직접 지정 · 핵심 추격 +${firstBattleCamaraderieCoreRateBonus}%p · 재클릭 해제`
: `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`,
this.textStyle(9, '#d4dce6', true)
)
);
ruleText.setDepth(depth + 1);
const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = {
@@ -15994,7 +16071,7 @@ export class CampScene extends Phaser.Scene {
: '삼재진 대기 · 세 형제의 역할을 나누십시오.';
const bottomLine = this.sortiePlanFeedback ? `${formationLine} · ${this.sortiePlanFeedback}` : formationLine;
this.trackSortie(
this.add.text(x + 18, y + 88, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true))
this.add.text(x + 18, y + 96, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true))
).setDepth(depth + 1);
this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, ...pursuitPageView };
}
@@ -17252,13 +17329,27 @@ export class CampScene extends Phaser.Scene {
const seenPairs = new Set<string>();
return snapshot.activeBonds
.filter((bond) => bond.level >= coreSortieResonanceMinLevel)
.map((bond) => ({
...bond,
coreChainRate: coreSortieResonanceChainRateForLevel(bond.level),
selected: bond.id === selectedBondId
}))
.map((bond) => {
const baseCoreChainRate = coreSortieResonanceChainRateForLevel(bond.level);
const camaraderieBonusRate = scenario
? firstBattleCamaraderieBonusFor({
campaign: this.campaign,
battleId: scenario.id,
coreBondId: bond.id
})
: 0;
return {
...bond,
baseCoreChainRate,
camaraderieBonusRate,
coreChainRate: baseCoreChainRate + camaraderieBonusRate,
camaraderieRemembered: camaraderieBonusRate > 0,
selected: bond.id === selectedBondId
};
})
.sort((left, right) =>
Number(right.selected) - Number(left.selected) ||
Number(right.camaraderieRemembered) - Number(left.camaraderieRemembered) ||
right.coreChainRate - left.coreChainRate ||
right.level - left.level ||
left.title.localeCompare(right.title) ||
@@ -17304,7 +17395,9 @@ export class CampScene extends Phaser.Scene {
const pairLabel = `${candidate.unitNames[0]}${candidate.unitNames[1]}`;
this.sortiePlanFeedback = clearing
? `핵심 공명조 해제 · ${pairLabel}`
: `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`;
: candidate.camaraderieRemembered
? `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.baseCoreChainRate}% + 전우 기억 ${candidate.camaraderieBonusRate}%p = ${candidate.coreChainRate}%`
: `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`;
if (guidedRecalculated) {
this.sortiePlanFeedback += ' · 보완안 재계산';
}
@@ -17326,7 +17419,8 @@ export class CampScene extends Phaser.Scene {
const rule = this.nextSortieRule(scenario);
const members = this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available);
const availableIds = new Set(members.map((unit) => unit.id));
return evaluateSortiePursuitPairs({
const coreBondId = this.currentSortieResonanceBondId(scenario);
const pursuit = evaluateSortiePursuitPairs({
members: members.map((unit) => ({
id: unit.id,
name: unit.name,
@@ -17337,8 +17431,28 @@ export class CampScene extends Phaser.Scene {
selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)),
selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)),
battleId: scenario?.id,
coreBondId: this.currentSortieResonanceBondId(scenario)
coreBondId
});
const camaraderieBonusRate = firstBattleCamaraderieBonusFor({
campaign: this.campaign,
battleId: scenario.id,
coreBondId
});
if (camaraderieBonusRate <= 0 || !coreBondId) {
return pursuit;
}
return {
activePairs: pursuit.activePairs.map((pair) =>
pair.core && pair.id === coreBondId
? { ...pair, chainRate: pair.chainRate + camaraderieBonusRate }
: pair
),
selectableOpportunities: pursuit.selectableOpportunities.map((opportunity) =>
opportunity.core && opportunity.id === coreBondId
? { ...opportunity, chainRate: opportunity.chainRate + camaraderieBonusRate }
: opportunity
)
};
}
private sortiePursuitPairKey(pair: Pick<SortieSynergyActiveBond, 'unitIds'>) {
@@ -17347,14 +17461,23 @@ export class CampScene extends Phaser.Scene {
private activeSortiePursuitPairs(snapshot: SortieSynergySnapshot) {
const seenPairs = new Set<string>();
const coreBondId = this.currentSortieResonanceBondId();
const scenario = this.nextSortieScenario();
const coreBondId = this.currentSortieResonanceBondId(scenario);
const camaraderieBonusRate = scenario
? firstBattleCamaraderieBonusFor({
campaign: this.campaign,
battleId: scenario.id,
coreBondId
})
: 0;
return snapshot.activeBonds
.map((bond) => {
const core = bond.id === coreBondId && bond.level >= coreSortieResonanceMinLevel;
const baseCoreChainRate = coreSortieResonanceChainRateForLevel(bond.level);
return {
...bond,
baseChainRate: bond.chainRate,
chainRate: core ? coreSortieResonanceChainRateForLevel(bond.level) : bond.chainRate,
chainRate: core ? baseCoreChainRate + camaraderieBonusRate : bond.chainRate,
core
};
})
@@ -22532,6 +22655,7 @@ export class CampScene extends Phaser.Scene {
const dialogues = this.availableCampDialogues();
const firstBattleFollowup = this.firstBattleCampFollowup();
const camaraderieDialogue = this.firstBattleCamaraderieDialogue();
const compactDialogueList = dialogues.length > 3;
const dialogueRowGap = compactDialogueList ? 35 : 64;
const dialogueRowHeight = compactDialogueList ? 30 : 48;
@@ -22550,23 +22674,26 @@ export class CampScene extends Phaser.Scene {
this.campDialogueRowButtons[dialogue.id] = row;
row.on('pointerdown', () => {
soundDirector.playSelect();
this.clearCampNotice();
this.selectedDialogueId = dialogue.id;
this.render();
});
const isFirstBattleFollowup = firstBattleFollowup?.targetDialogueId === dialogue.id;
const title = isFirstBattleFollowup
const isCamaraderieMemory = camaraderieDialogue?.id === dialogue.id;
const title = isFirstBattleFollowup || isCamaraderieMemory
? this.compactText(dialogue.title, compactDialogueList ? 20 : 14)
: dialogue.title;
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
if (isFirstBattleFollowup) {
if (isFirstBattleFollowup || isCamaraderieMemory) {
const badgeLabel = completed ? '완료' : isCamaraderieMemory ? '전우 기억' : '회고';
this.track(
this.add.text(
x + 320,
rowY + (compactDialogueList ? 4 : 9),
completed ? '완료' : '회고',
badgeLabel,
{
...this.textStyle(compactDialogueList ? 8 : 9, completed ? '#a8ffd0' : '#fff2b8', true),
backgroundColor: completed ? '#21402d' : '#8a3f25',
backgroundColor: completed ? '#21402d' : isCamaraderieMemory ? '#36507a' : '#8a3f25',
padding: { x: 4, y: 2 }
}
)
@@ -23876,16 +24003,17 @@ export class CampScene extends Phaser.Scene {
private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
const followup = this.firstBattleCampFollowup();
const preparationLine = followup?.targetDialogueId === dialogue.id
? `\n다음 준비 · ${followup.recommendationTitle}`
: '';
const camaraderieMemory = this.firstBattleCamaraderieMemory();
const preparationLine = camaraderieMemory?.dialogue.id === dialogue.id
? `\n다음 준비 · ${camaraderieMemory.unitNames.join('')}를 직접 핵심 공명조로 지정하면 추격 +${camaraderieMemory.bonusRate}%p · 자동 지정 없음`
: followup?.targetDialogueId === dialogue.id
? `\n다음 준비 · ${followup.recommendationTitle}`
: '';
this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}${preparationLine}`);
}
private showCampNotice(message: string) {
this.tweens.killTweensOf(this.dialogueObjects);
this.dialogueObjects.forEach((object) => object.active && object.destroy());
this.dialogueObjects = [];
this.clearCampNotice();
const depth = this.sortieObjects.length > 0 ? 72 : 30;
const longestLineLength = Math.max(...message.split('\n').map((line) => line.length));
const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13));
@@ -23917,6 +24045,12 @@ export class CampScene extends Phaser.Scene {
});
}
private clearCampNotice() {
this.tweens.killTweensOf(this.dialogueObjects);
this.dialogueObjects.forEach((object) => object.active && object.destroy());
this.dialogueObjects = [];
}
private campNoticeHoldDuration(message: string) {
const readableCharacterCount = message.replace(/\s/g, '').length;
const paragraphCount = Math.max(1, message.split('\n').filter((line) => line.trim().length > 0).length);
@@ -24869,10 +25003,18 @@ export class CampScene extends Phaser.Scene {
? this.completedCampDialogues().includes(cityStay.dialogue.id)
: false;
const firstBattleCampFollowup = this.firstBattleCampFollowup();
const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory();
const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue();
const availableCampDialogues = this.availableCampDialogues();
const selectedCampDialogue = availableCampDialogues.find(
(dialogue) => dialogue.id === this.selectedDialogueId
) ?? availableCampDialogues[0];
const firstBattleCamaraderieCandidate = firstBattleCamaraderieMemory
? sortieCoreResonanceCandidates.find((candidate) => candidate.id === firstBattleCamaraderieMemory.bondId)
: undefined;
const firstBattleCamaraderieCandidateRow = firstBattleCamaraderieMemory
? this.sortiePursuitPanelView?.rows.find((row) => row.bondId === firstBattleCamaraderieMemory.bondId)
: undefined;
return {
scene: this.scene.key,
victoryRewardAcknowledgement: {
@@ -24925,6 +25067,7 @@ export class CampScene extends Phaser.Scene {
interactive: Boolean(button.bg.input?.enabled && button.text.input?.enabled),
indicatorVisible: button.indicator.visible,
newBadgeVisible: Boolean(button.newBadge?.visible),
newBadgeText: button.newBadge?.text ?? null,
fillColor: button.bg.fillColor,
strokeColor: button.bg.strokeColor,
lineWidth: button.bg.lineWidth
@@ -24962,6 +25105,65 @@ export class CampScene extends Phaser.Scene {
)
}
: null,
firstBattleCamaraderieMemory: firstBattleCamaraderieMemory
? {
available: Boolean(firstBattleCamaraderieDialogue),
sourceBattleId: firstBattleCamaraderieMemory.sourceBattleId,
targetBattleId: firstBattleCamaraderieMemory.targetBattleId,
bondId: firstBattleCamaraderieMemory.bondId,
unitIds: [...firstBattleCamaraderieMemory.unitIds],
unitNames: [...firstBattleCamaraderieMemory.unitNames],
attempts: firstBattleCamaraderieMemory.attempts,
successes: firstBattleCamaraderieMemory.successes,
additionalDamage: firstBattleCamaraderieMemory.additionalDamage,
rememberedOutcome: firstBattleCamaraderieMemory.rememberedOutcome,
summary: firstBattleCamaraderieMemory.summary,
completed: firstBattleCamaraderieMemory.completed,
dialogue: {
id: firstBattleCamaraderieMemory.dialogue.id,
title: firstBattleCamaraderieMemory.dialogue.title,
lines: [...firstBattleCamaraderieMemory.dialogue.lines],
selected: this.selectedDialogueId === firstBattleCamaraderieMemory.dialogue.id,
completed: this.completedCampDialogues().includes(firstBattleCamaraderieMemory.dialogue.id),
choiceId: this.campaign?.campDialogueChoiceIds[firstBattleCamaraderieMemory.dialogue.id] ?? null,
rowBounds: this.sortieObjectBoundsDebug(
this.campDialogueRowButtons[firstBattleCamaraderieMemory.dialogue.id]
),
bodyBounds: this.selectedDialogueId === firstBattleCamaraderieMemory.dialogue.id
? this.sortieTextBoundsDebug(this.campDialogueBodyText)
: null,
choices: firstBattleCamaraderieMemory.dialogue.choices.map((choice) => ({
id: choice.id,
label: choice.label,
response: choice.response,
rewardExp: firstBattleCamaraderieMemory.dialogue.rewardExp + choice.rewardExp,
bounds: this.sortieInteractiveObjectBoundsDebug(
this.campDialogueChoiceButtons[`${firstBattleCamaraderieMemory.dialogue.id}:${choice.id}`]
)
}))
},
perk: {
requiresManualSelection: true,
autoSelected: false,
exactPairOnly: true,
unlocked: firstBattleCamaraderieMemory.completed,
expectedBonusRate: firstBattleCamaraderieMemory.bonusRate,
candidateAvailable: Boolean(firstBattleCamaraderieCandidate),
selected: Boolean(firstBattleCamaraderieCandidate?.selected),
active: Boolean(
firstBattleCamaraderieCandidate?.selected &&
firstBattleCamaraderieCandidate.camaraderieRemembered
),
baseChainRate: firstBattleCamaraderieCandidate?.baseCoreChainRate ?? null,
bonusChainRate: firstBattleCamaraderieCandidate?.camaraderieBonusRate ?? 0,
effectiveChainRate: firstBattleCamaraderieCandidate?.coreChainRate ?? null,
cardText: firstBattleCamaraderieCandidateRow?.text ?? null,
cardBounds: this.sortieInteractiveObjectBoundsDebug(
firstBattleCamaraderieCandidateRow?.background
)
}
}
: null,
selectedDialogue: selectedCampDialogue
? {
id: selectedCampDialogue.id,
@@ -24970,6 +25172,7 @@ export class CampScene extends Phaser.Scene {
lines: [...selectedCampDialogue.lines],
completed: this.completedCampDialogues().includes(selectedCampDialogue.id),
firstVictoryFollowup: firstBattleCampFollowup?.targetDialogueId === selectedCampDialogue.id,
camaraderieMemory: firstBattleCamaraderieMemory?.dialogue.id === selectedCampDialogue.id,
rowBounds: this.sortieObjectBoundsDebug(this.campDialogueRowButtons[selectedCampDialogue.id]),
bodyBounds: this.sortieTextBoundsDebug(this.campDialogueBodyText),
choices: selectedCampDialogue.choices.map((choice) => ({
@@ -25814,6 +26017,10 @@ export class CampScene extends Phaser.Scene {
title: candidate.title,
level: candidate.level,
baseChainRate: candidate.chainRate,
baseCoreChainRate: candidate.baseCoreChainRate,
bonusChainRate: candidate.camaraderieBonusRate,
effectiveCoreChainRate: candidate.coreChainRate,
camaraderieRemembered: candidate.camaraderieRemembered,
coreChainRate: candidate.coreChainRate,
selected: candidate.selected
})),
@@ -25860,6 +26067,10 @@ export class CampScene extends Phaser.Scene {
rows: this.sortiePursuitPanelView?.rows.map((row) => ({
bondId: row.bondId,
level: row.level,
baseChainRate: row.baseChainRate,
bonusChainRate: row.bonusChainRate,
effectiveChainRate: row.effectiveChainRate,
camaraderieRemembered: row.camaraderieRemembered,
chainRate: row.chainRate,
selected: row.selected,
state: row.state,

View File

@@ -85,6 +85,12 @@ export type BattleSaveCoreResonanceStats = {
additionalDamage: number;
};
export type BattleSaveCooperationStats = {
attempts: number;
successes: number;
additionalDamage: number;
};
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
export type BattleSavePendingEvent = {
@@ -104,6 +110,7 @@ export type BattleSaveState = {
sortieResonanceBondId?: string;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
coreResonanceStats?: BattleSaveCoreResonanceStats;
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
@@ -199,6 +206,7 @@ export function normalizeBattleSaveState(
}
normalizeCoreResonanceSaveFields(cloned, options);
normalizeCooperationSaveFields(cloned, options);
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
allowedUnitIds: options.validAllyUnitIds
@@ -244,6 +252,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false;
}
if (!isOptionalCooperationStatsByBond(state.cooperationStatsByBond, state, options)) {
return false;
}
const attackIntents = state.attackIntents;
const units = state.units;
@@ -368,6 +380,131 @@ function normalizeCoreResonanceStatValue(value: unknown) {
return Math.min(maxBattleUnitStatValue, Math.max(0, Math.floor(value)));
}
function normalizeCooperationSaveFields(
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
const validBondIds = validDeployedCooperationBondIds(state, options);
const normalizedStats: Record<string, BattleSaveCooperationStats> = {};
for (const [bondId, rawValue] of Object.entries(
isRecord(state.cooperationStatsByBond) ? state.cooperationStatsByBond : {}
).sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId))) {
if (Object.keys(normalizedStats).length >= maxBattleBondEntries) {
break;
}
const normalizedBondId = bondId.trim();
if (
normalizedBondId !== bondId ||
!validBondIds.has(normalizedBondId) ||
!isRecord(rawValue)
) {
continue;
}
const attempts = normalizeCoreResonanceStatValue(rawValue.attempts);
if (attempts <= 0) {
continue;
}
const successes = Math.min(
attempts,
normalizeCoreResonanceStatValue(rawValue.successes)
);
normalizedStats[normalizedBondId] = {
attempts,
successes,
additionalDamage: successes > 0
? normalizeCoreResonanceStatValue(rawValue.additionalDamage)
: 0
};
}
if (
Object.keys(normalizedStats).length === 0 &&
typeof state.sortieResonanceBondId === 'string' &&
validBondIds.has(state.sortieResonanceBondId) &&
isRecord(state.coreResonanceStats)
) {
const attempts = normalizeCoreResonanceStatValue(state.coreResonanceStats.attempts);
if (attempts > 0) {
const successes = Math.min(
attempts,
normalizeCoreResonanceStatValue(state.coreResonanceStats.successes)
);
normalizedStats[state.sortieResonanceBondId] = {
attempts,
successes,
additionalDamage: successes > 0
? normalizeCoreResonanceStatValue(state.coreResonanceStats.additionalDamage)
: 0
};
}
}
if (Object.keys(normalizedStats).length > 0) {
state.cooperationStatsByBond = normalizedStats;
} else {
delete state.cooperationStatsByBond;
}
}
function validDeployedCooperationBondIds(
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
const deployedUnitIds = new Set(
(Array.isArray(state.units) ? state.units : [])
.filter(isRecord)
.map((unit) => unit.id)
.filter((unitId): unitId is string => typeof unitId === 'string')
);
return new Set(
(Array.isArray(state.bonds) ? state.bonds : [])
.filter(isRecord)
.filter((bond) => (
typeof bond.id === 'string' &&
bond.id.trim() === bond.id &&
bond.id.length > 0 &&
Array.isArray(bond.unitIds) &&
bond.unitIds.length === 2 &&
bond.unitIds[0] !== bond.unitIds[1] &&
bond.unitIds.every((unitId) => (
typeof unitId === 'string' &&
deployedUnitIds.has(unitId) &&
(!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId))
))
))
.map((bond) => bond.id as string)
);
}
function isOptionalCooperationStatsByBond(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
if (value === undefined) {
return true;
}
if (!isRecord(value)) {
return false;
}
const entries = Object.entries(value);
if (entries.length > maxBattleBondEntries) {
return false;
}
const validBondIds = validDeployedCooperationBondIds(state, options);
return entries.every(([bondId, stats]) => (
bondId.trim() === bondId &&
validBondIds.has(bondId) &&
isRecord(stats) &&
isPositiveInteger(stats.attempts) &&
isBattleUnitStatValue(stats.attempts) &&
isBattleUnitStatValue(stats.successes) &&
isBattleUnitStatValue(stats.additionalDamage) &&
Number(stats.successes) <= Number(stats.attempts) &&
(Number(stats.successes) > 0 || Number(stats.additionalDamage) === 0)
));
}
function isOptionalCoreResonanceState(
state: Record<string, unknown>,
options: BattleSaveValidationOptions

View File

@@ -133,6 +133,15 @@ export type CampaignSortieRecommendationSnapshot = {
unitReasons: Record<string, string>;
};
export type CampaignSortieCooperationSnapshot = {
version: 1;
bondId: string;
unitIds: [string, string];
attempts: number;
successes: number;
additionalDamage: number;
};
export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot;
export type FirstBattleReport = {
@@ -153,6 +162,7 @@ export type FirstBattleReport = {
sortieReview?: CampaignSortieReviewSnapshot;
sortieOrder?: CampaignSortieOrderResultSnapshot;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot;
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
@@ -436,6 +446,7 @@ export type CampaignBattleSettlement = {
sortieReview?: CampaignSortieReviewSnapshot;
sortieOrder?: CampaignSortieOrderResultSnapshot;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot;
completedAt: string;
};
@@ -1759,7 +1770,7 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalized.battleHistory = filterBattleHistoryRosterReferences(
normalized.battleHistory,
sortieUnitFilter,
new Set(normalized.bonds.map((bond) => bond.id))
normalized.bonds
);
}
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
@@ -2131,6 +2142,72 @@ export function normalizeCampaignSortieRecommendationSnapshot(
};
}
type CampaignSortieCooperationNormalizationOptions = {
allowedUnitIds?: ReadonlySet<string>;
allowedBondIds?: ReadonlySet<string>;
bonds?: readonly {
id: string;
unitIds: readonly string[];
}[];
};
export function normalizeCampaignSortieCooperationSnapshot(
value: unknown,
options: CampaignSortieCooperationNormalizationOptions = {}
): CampaignSortieCooperationSnapshot | undefined {
if (!isPlainObject(value) || value.version !== 1) {
return undefined;
}
const bondId = normalizeKeyString(value.bondId);
const rawUnitIds = arrayOrEmpty<unknown>(value.unitIds);
if (!bondId || rawUnitIds.length !== 2) {
return undefined;
}
const unitIds = rawUnitIds.map(normalizeKeyString);
if (
!unitIds[0] ||
!unitIds[1] ||
unitIds[0] === unitIds[1] ||
(options.allowedBondIds && !options.allowedBondIds.has(bondId)) ||
unitIds.some((unitId) => options.allowedUnitIds && !options.allowedUnitIds.has(unitId))
) {
return undefined;
}
const matchingBond = options.bonds?.find((bond) => bond.id === bondId);
if (options.bonds && !matchingBond) {
return undefined;
}
if (
matchingBond &&
(
matchingBond.unitIds.length !== 2 ||
!matchingBond.unitIds.every((unitId) => unitIds.includes(unitId))
)
) {
return undefined;
}
const attempts = normalizeNonNegativeInteger(value.attempts);
if (attempts <= 0) {
return undefined;
}
const normalizedUnitIds = matchingBond
? [matchingBond.unitIds[0], matchingBond.unitIds[1]] as [string, string]
: [unitIds[0], unitIds[1]] as [string, string];
const successes = Math.min(attempts, normalizeNonNegativeInteger(value.successes));
return {
version: 1,
bondId,
unitIds: normalizedUnitIds,
attempts,
successes,
additionalDamage: successes > 0
? normalizeNonNegativeInteger(value.additionalDamage)
: 0
};
}
function resolveCampaignSortieResonanceBond(
battleId: string,
bondId: string,
@@ -2398,14 +2475,21 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
allowedUnitIds: rosterUnitIds
})
: undefined;
const filteredBonds = report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId)));
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, {
allowedUnitIds: rosterUnitIds,
allowedBondIds: new Set(filteredBonds.map((bond) => bond.id)),
bonds: filteredBonds
});
const filtered = {
...report,
units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)),
bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))),
bonds: filteredBonds,
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {})
...(sortieRecommendation ? { sortieRecommendation } : {}),
...(sortieCooperation ? { sortieCooperation } : {})
};
if (!sortiePerformance) {
delete filtered.sortiePerformance;
@@ -2419,6 +2503,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
if (!sortieRecommendation) {
delete filtered.sortieRecommendation;
}
if (!sortieCooperation) {
delete filtered.sortieCooperation;
}
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
delete filtered.mvp;
}
@@ -2428,8 +2515,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
function filterBattleHistoryRosterReferences(
history: Record<string, CampaignBattleSettlement>,
rosterUnitIds: Set<string>,
bondIds: Set<string>
campaignBonds: readonly CampBondSnapshot[]
): Record<string, CampaignBattleSettlement> {
const bondIds = new Set(campaignBonds.map((bond) => bond.id));
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((filteredHistory, [battleId, settlement]) => {
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds);
const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(settlement.sortiePerformance, sortiePerformance);
@@ -2448,6 +2536,11 @@ function filterBattleHistoryRosterReferences(
allowedUnitIds: rosterUnitIds
})
: undefined;
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, {
allowedUnitIds: rosterUnitIds,
allowedBondIds: bondIds,
bonds: campaignBonds
});
filteredHistory[battleId] = {
...settlement,
units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)),
@@ -2456,7 +2549,8 @@ function filterBattleHistoryRosterReferences(
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {})
...(sortieRecommendation ? { sortieRecommendation } : {}),
...(sortieCooperation ? { sortieCooperation } : {})
};
if (!sortiePerformance) {
delete filteredHistory[battleId].sortiePerformance;
@@ -2470,6 +2564,9 @@ function filterBattleHistoryRosterReferences(
if (!sortieRecommendation) {
delete filteredHistory[battleId].sortieRecommendation;
}
if (!sortieCooperation) {
delete filteredHistory[battleId].sortieCooperation;
}
return filteredHistory;
}, {});
}
@@ -2505,6 +2602,20 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
turnNumber
})
: undefined;
const units = normalizeLimitedArray(
settlement.units,
normalizeCampaignUnitProgressSnapshot,
maxCampaignBattleUnitEntries
);
const bonds = normalizeLimitedArray(
settlement.bonds,
normalizeCampaignBondProgressSnapshot,
maxCampaignBattleBondEntries
);
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, {
allowedUnitIds: new Set(units.map((unit) => unit.unitId)),
allowedBondIds: new Set(bonds.map((bond) => bond.id))
});
return {
battleId,
@@ -2515,13 +2626,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
itemRewards: normalizeRewardStrings(settlement.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId),
objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries),
bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries),
units,
bonds,
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {}),
...(sortieCooperation ? { sortieCooperation } : {}),
completedAt
};
}
@@ -2856,6 +2968,23 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
turnNumber
})
: undefined;
const units = normalizeLimitedArray(
report.units,
normalizeUnitDataSnapshot,
maxCampaignBattleUnitEntries
);
const bonds = normalizeLimitedArray(
report.bonds,
normalizeCampBondSnapshot,
maxCampaignBattleBondEntries
);
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, {
allowedUnitIds: new Set(
units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)
),
allowedBondIds: new Set(bonds.map((bond) => bond.id)),
bonds
});
return {
battleId,
@@ -2866,8 +2995,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies),
totalEnemies: normalizeNonNegativeInteger(report.totalEnemies),
objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
units: normalizeLimitedArray(report.units, normalizeUnitDataSnapshot, maxCampaignBattleUnitEntries),
bonds: normalizeLimitedArray(report.bonds, normalizeCampBondSnapshot, maxCampaignBattleBondEntries),
units,
bonds,
mvp: normalizeCampMvpSnapshot(report.mvp),
itemRewards: normalizeRewardStrings(report.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(
@@ -2878,6 +3007,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {}),
...(sortieCooperation ? { sortieCooperation } : {}),
completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues),
completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits),
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
@@ -3142,6 +3272,9 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
...(report.sortieRecommendation
? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) }
: {}),
...(report.sortieCooperation
? { sortieCooperation: cloneCampaignSortieCooperationSnapshot(report.sortieCooperation) }
: {}),
completedAt: report.createdAt
};
}
@@ -3302,6 +3435,9 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport {
if (cloned.campaignRewards) {
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId);
}
if (cloned.sortieCooperation) {
cloned.sortieCooperation = cloneCampaignSortieCooperationSnapshot(cloned.sortieCooperation);
}
return cloned;
}
@@ -3314,6 +3450,13 @@ export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSort
} satisfies CampaignSortieRecommendationSnapshot;
}
export function cloneCampaignSortieCooperationSnapshot(snapshot: CampaignSortieCooperationSnapshot) {
return {
...snapshot,
unitIds: [...snapshot.unitIds] as [string, string]
} satisfies CampaignSortieCooperationSnapshot;
}
function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined {
if (!rewards) {
return undefined;