fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -130,17 +130,25 @@ export type TurnDaemonCommand =
|
||||
deltaMinutes: number;
|
||||
}
|
||||
| { type: 'getStatus'; requestId?: string }
|
||||
| { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string }
|
||||
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
|
||||
| { type: 'troopExit'; requestId?: string; generalId: number }
|
||||
| { type: 'troopCreate'; requestId?: string; userId: string; generalId: number; troopName: string }
|
||||
| { type: 'troopJoin'; requestId?: string; userId: string; generalId: number; troopId: number }
|
||||
| { type: 'troopExit'; requestId?: string; userId: string; generalId: number }
|
||||
| {
|
||||
type: 'troopKick';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
troopId: number;
|
||||
targetGeneralId: number;
|
||||
}
|
||||
| { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string }
|
||||
| {
|
||||
type: 'troopRename';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
troopId: number;
|
||||
troopName: string;
|
||||
}
|
||||
| { type: 'ensureDieOnPrestartStatus'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number }
|
||||
@@ -153,10 +161,11 @@ export type TurnDaemonCommand =
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
}
|
||||
| { type: 'vacation'; requestId?: string; generalId: number }
|
||||
| { type: 'vacation'; requestId?: string; userId: string; generalId: number }
|
||||
| {
|
||||
type: 'setMySetting';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
settings: {
|
||||
tnmt?: number;
|
||||
@@ -170,7 +179,13 @@ export type TurnDaemonCommand =
|
||||
use_auto_nation_capital?: number;
|
||||
};
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| {
|
||||
type: 'dropItem';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
itemType: 'horse' | 'weapon' | 'book' | 'item';
|
||||
}
|
||||
| {
|
||||
type: 'auctionFinalize';
|
||||
requestId?: string;
|
||||
@@ -181,6 +196,7 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||
amount: number;
|
||||
@@ -192,14 +208,16 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'changePermission';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
isAmbassador: boolean;
|
||||
targetGeneralIds: number[];
|
||||
}
|
||||
| { type: 'kick'; requestId?: string; generalId: number; destGeneralId: number }
|
||||
| { type: 'kick'; requestId?: string; userId: string; generalId: number; destGeneralId: number }
|
||||
| {
|
||||
type: 'appoint';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
destGeneralId: number;
|
||||
destCityId: number;
|
||||
@@ -239,17 +257,38 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'voteReward';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
voteId: number;
|
||||
generalId: number;
|
||||
selection: number[];
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNationSetting';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
nationId: number;
|
||||
updates: Record<string, unknown>;
|
||||
expectedUpdatedAt?: string;
|
||||
mutation:
|
||||
| { kind: 'notice'; message: string }
|
||||
| { kind: 'scoutMessage'; message: string }
|
||||
| { kind: 'rate'; amount: number }
|
||||
| { kind: 'bill'; amount: number }
|
||||
| { kind: 'secretLimit'; amount: number }
|
||||
| { kind: 'blockWar'; value: boolean }
|
||||
| { kind: 'blockScout'; value: boolean };
|
||||
}
|
||||
| {
|
||||
type: 'setNpcPolicy';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
nationId: number;
|
||||
expectedUpdatedAt: string | null;
|
||||
mutation:
|
||||
| { kind: 'nationPolicy'; values: Record<string, unknown> }
|
||||
| { kind: 'nationPriority'; priority: string[] }
|
||||
| { kind: 'generalPriority'; priority: string[] };
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
@@ -377,6 +416,7 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
@@ -605,17 +645,33 @@ export type TurnDaemonCommandResult =
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNationSetting';
|
||||
ok: true;
|
||||
nationId: number;
|
||||
updatedAt: string;
|
||||
availableCnt?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationSetting';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED';
|
||||
nationId?: number;
|
||||
reason: string;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'setNpcPolicy';
|
||||
ok: true;
|
||||
nationId: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNpcPolicy';
|
||||
ok: false;
|
||||
nationId: number;
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT';
|
||||
reason: string;
|
||||
currentUpdatedAt?: string;
|
||||
nationId?: number;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
|
||||
@@ -46,10 +46,10 @@ export interface AcceptScoutResolveContext<
|
||||
|
||||
const differentDestGeneral = (destGeneralId: number): Constraint => ({
|
||||
name: 'differentDestGeneral',
|
||||
requires: (ctx) => [
|
||||
{ kind: 'general', id: ctx.actorId },
|
||||
{ kind: 'destGeneral', id: destGeneralId },
|
||||
],
|
||||
// Ref keeps only the recruiter id/nation snapshot in the letter. The
|
||||
// recruiter can be deleted before the receiver accepts it, so this
|
||||
// self-check must not turn the missing live recruiter into a dependency.
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx) =>
|
||||
ctx.actorId === destGeneralId
|
||||
? { kind: 'deny', reason: '본인의 등용장을 수락할 수 없습니다.' }
|
||||
@@ -74,7 +74,6 @@ export class ActionResolver<
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!destNation) throw new Error('Target nation not found.');
|
||||
if (!destGeneral) throw new Error('Recruiter not found.');
|
||||
|
||||
// 1. Logs
|
||||
const destNationName = destNation.name;
|
||||
@@ -96,79 +95,102 @@ export class ActionResolver<
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// 2. Recruiter Rewards
|
||||
const recruiterExperience = destGeneral.experience + 100;
|
||||
const recruiterDedication = destGeneral.dedication + 100;
|
||||
// 2. Recruiter Rewards. Ref resolves a deleted recruiter as a dummy
|
||||
// general: joining still succeeds, while recruiter-only rewards and
|
||||
// logs are discarded.
|
||||
const belong = readMetaNumberFromUnknown(general.meta, 'belong') ?? 0;
|
||||
const maxBelong = readMetaNumberFromUnknown(general.meta, 'max_belong') ?? 0;
|
||||
const recruiterExpLevel = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
|
||||
recruiterExperience < 1_000
|
||||
? Math.trunc(recruiterExperience / 100)
|
||||
: Math.trunc(Math.sqrt(recruiterExperience / 10))
|
||||
)
|
||||
);
|
||||
const recruiterDedicationLevel = Math.max(
|
||||
0,
|
||||
Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10))
|
||||
);
|
||||
const previousRecruiterExpLevel = typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0;
|
||||
const previousRecruiterDedicationLevel =
|
||||
typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0;
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
experience: recruiterExperience,
|
||||
dedication: recruiterDedication,
|
||||
meta: {
|
||||
...destGeneral.meta,
|
||||
explevel: recruiterExpLevel,
|
||||
dedlevel: recruiterDedicationLevel,
|
||||
},
|
||||
},
|
||||
destGeneral.id
|
||||
)
|
||||
);
|
||||
if (recruiterExpLevel !== previousRecruiterExpLevel) {
|
||||
const josaRo = JosaUtil.pick(String(recruiterExpLevel), '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterExpLevel > previousRecruiterExpLevel
|
||||
? `<C>Lv ${recruiterExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${recruiterExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
if (destGeneral) {
|
||||
const recruiterExperience = destGeneral.experience + 100;
|
||||
const recruiterDedication = destGeneral.dedication + 100;
|
||||
const recruiterExpLevel = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
|
||||
recruiterExperience < 1_000
|
||||
? Math.trunc(recruiterExperience / 100)
|
||||
: Math.trunc(Math.sqrt(recruiterExperience / 10))
|
||||
)
|
||||
);
|
||||
}
|
||||
if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) {
|
||||
const maxDedicationLevel = this.env.maxDedicationLevel ?? 30;
|
||||
const dedicationLevelText =
|
||||
recruiterDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`;
|
||||
const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400);
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
const recruiterDedicationLevel = Math.max(
|
||||
0,
|
||||
Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10))
|
||||
);
|
||||
const previousRecruiterExpLevel =
|
||||
typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0;
|
||||
const previousRecruiterDedicationLevel =
|
||||
typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0;
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterDedicationLevel > previousRecruiterDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
experience: recruiterExperience,
|
||||
dedication: recruiterDedication,
|
||||
meta: {
|
||||
...destGeneral.meta,
|
||||
explevel: recruiterExpLevel,
|
||||
dedlevel: recruiterDedicationLevel,
|
||||
},
|
||||
},
|
||||
destGeneral.id
|
||||
)
|
||||
);
|
||||
if (recruiterExpLevel !== previousRecruiterExpLevel) {
|
||||
const recruiterLevelJosaRo = JosaUtil.pick(String(recruiterExpLevel), '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterExpLevel > previousRecruiterExpLevel
|
||||
? `<C>Lv ${recruiterExpLevel}</>${recruiterLevelJosaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${recruiterExpLevel}</>${recruiterLevelJosaRo} <R>레벨다운</>!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) {
|
||||
const maxDedicationLevel = this.env.maxDedicationLevel ?? 30;
|
||||
const dedicationLevelText =
|
||||
recruiterDedicationLevel === 0
|
||||
? '무품관'
|
||||
: `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`;
|
||||
const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400);
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterDedicationLevel > previousRecruiterDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
effects.push(
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Betrayal Logic
|
||||
@@ -285,22 +307,6 @@ export class ActionResolver<
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
effects.push(
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
|
||||
const deletedTroopIds: number[] = [];
|
||||
if (general.troopId === general.id) {
|
||||
|
||||
@@ -81,6 +81,14 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport)
|
||||
|
||||
const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0);
|
||||
|
||||
const isAssignedToOfficerCity = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
cityId: number
|
||||
): boolean =>
|
||||
['officerCity', 'officer_city', 'officerCityId'].some(
|
||||
(key) => getMetaNumber(general.meta, key, Number.NaN) === cityId
|
||||
);
|
||||
|
||||
// REF-COMPAT:BEGIN ref-dead-split-int-binding
|
||||
const increaseDeadCounter = (city: City, delta: number): void => {
|
||||
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
|
||||
@@ -452,6 +460,21 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
affectedNations.add(attackerNation);
|
||||
}
|
||||
|
||||
if (!nationCollapsed) {
|
||||
// Ref updates every row assigned to the captured city without filtering
|
||||
// by nation, current city, or existing officer level.
|
||||
for (const general of generals) {
|
||||
if (!isAssignedToOfficerCity(general, defenderCity.id)) {
|
||||
continue;
|
||||
}
|
||||
general.officerLevel = 1;
|
||||
general.meta.officerCity = 0;
|
||||
general.meta.officer_city = 0;
|
||||
general.meta.officerCityId = 0;
|
||||
affectedGenerals.add(general);
|
||||
}
|
||||
}
|
||||
|
||||
// 수도 함락 시 수도 이전 및 내부 사기/자원 페널티.
|
||||
if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) {
|
||||
const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, input.map);
|
||||
@@ -528,6 +551,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
defenderCity.supplyState = 1;
|
||||
defenderCity.frontState = 0;
|
||||
defenderCity.meta.term = 0;
|
||||
defenderCity.meta.officer_set = 0;
|
||||
defenderCity.agriculture = round(defenderCity.agriculture * 0.7);
|
||||
defenderCity.commerce = round(defenderCity.commerce * 0.7);
|
||||
defenderCity.security = round(defenderCity.security * 0.7);
|
||||
|
||||
@@ -323,6 +323,76 @@ describe('war aftermath', () => {
|
||||
expect(defenderCity.meta.dead).toBe(111);
|
||||
});
|
||||
|
||||
it('clears every captured-city officer assignment when the defending nation survives', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
const foreignNation = buildNation(3);
|
||||
defenderNation.capitalCityId = 3;
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const defenderCapital = buildCity(3, 2);
|
||||
const foreignCity = buildCity(4, 3);
|
||||
defenderCity.meta.officer_set = 7;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
const camelAssigned = buildGeneral(2, 2, 3);
|
||||
camelAssigned.officerLevel = 4;
|
||||
camelAssigned.meta.officerCity = defenderCity.id;
|
||||
const snakeAssigned = buildGeneral(3, 1, 1);
|
||||
snakeAssigned.officerLevel = 10;
|
||||
snakeAssigned.meta.officer_city = defenderCity.id;
|
||||
const idAssigned = buildGeneral(4, 3, 4);
|
||||
idAssigned.officerLevel = 0;
|
||||
idAssigned.meta.officerCityId = defenderCity.id;
|
||||
const unrelated = buildGeneral(5, 2, 2);
|
||||
unrelated.officerLevel = 3;
|
||||
unrelated.meta.officerCity = defenderCapital.id;
|
||||
unrelated.meta.officer_city = defenderCapital.id;
|
||||
unrelated.meta.officerCityId = defenderCapital.id;
|
||||
|
||||
const outcome = resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: true,
|
||||
reports: [],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation, foreignNation],
|
||||
cities: [attackerCity, defenderCity, defenderCapital, foreignCity],
|
||||
generals: [attacker, camelAssigned, snakeAssigned, idAssigned, unrelated],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(outcome.conquest?.nationCollapsed).toBe(false);
|
||||
expect(defenderCity.meta.officer_set).toBe(0);
|
||||
for (const assigned of [camelAssigned, snakeAssigned, idAssigned]) {
|
||||
expect(assigned).toMatchObject({
|
||||
officerLevel: 1,
|
||||
meta: { officerCity: 0, officer_city: 0, officerCityId: 0 },
|
||||
});
|
||||
}
|
||||
expect(unrelated).toMatchObject({
|
||||
officerLevel: 3,
|
||||
meta: {
|
||||
officerCity: defenderCapital.id,
|
||||
officer_city: defenderCapital.id,
|
||||
officerCityId: defenderCapital.id,
|
||||
},
|
||||
});
|
||||
expect(outcome.generals.map((general) => general.id)).toEqual(
|
||||
expect.arrayContaining([attacker.id, camelAssigned.id, snakeAssigned.id, idAssigned.id])
|
||||
);
|
||||
});
|
||||
|
||||
it('logs emergency relocation when a surviving nation loses its capital', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
|
||||
Reference in New Issue
Block a user