feat: 국가 멸망 처리 로직 추가 및 관련 테스트 케이스 작성
This commit is contained in:
@@ -241,6 +241,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
troops,
|
troops,
|
||||||
deletedTroops,
|
deletedTroops,
|
||||||
deletedGenerals,
|
deletedGenerals,
|
||||||
|
deletedNations,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
@@ -309,6 +310,23 @@ export const createDatabaseTurnHooks = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deletedNations.length > 0) {
|
||||||
|
await prisma.diplomacy.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ srcNationId: { in: deletedNations } },
|
||||||
|
{ destNationId: { in: deletedNations } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.nationTurn.deleteMany({
|
||||||
|
where: { nationId: { in: deletedNations } },
|
||||||
|
});
|
||||||
|
await prisma.nation.deleteMany({
|
||||||
|
where: { id: { in: deletedNations } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...generals
|
...generals
|
||||||
.filter((general) => !createdIds.has(general.id))
|
.filter((general) => !createdIds.has(general.id))
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly createdDiplomacyKeys = new Set<string>();
|
private readonly createdDiplomacyKeys = new Set<string>();
|
||||||
private readonly deletedTroopIds = new Set<number>();
|
private readonly deletedTroopIds = new Set<number>();
|
||||||
private readonly deletedGeneralIds = new Set<number>();
|
private readonly deletedGeneralIds = new Set<number>();
|
||||||
|
private readonly deletedNationIds = new Set<number>();
|
||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private readonly scenarioConfig: ScenarioConfig;
|
private readonly scenarioConfig: ScenarioConfig;
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
@@ -376,6 +377,24 @@ export class InMemoryTurnWorld {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeNation(id: number): boolean {
|
||||||
|
if (!this.nations.has(id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.nations.delete(id);
|
||||||
|
this.dirtyNationIds.delete(id);
|
||||||
|
this.createdNationIds.delete(id);
|
||||||
|
this.deletedNationIds.add(id);
|
||||||
|
for (const [key, entry] of this.diplomacy) {
|
||||||
|
if (entry.fromNationId === id || entry.toNationId === id) {
|
||||||
|
this.diplomacy.delete(key);
|
||||||
|
this.dirtyDiplomacyKeys.delete(key);
|
||||||
|
this.createdDiplomacyKeys.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
applyDiplomacyPatch(input: { srcNationId: number; destNationId: number; patch: DiplomacyPatch }): void {
|
applyDiplomacyPatch(input: { srcNationId: number; destNationId: number; patch: DiplomacyPatch }): void {
|
||||||
const key = buildDiplomacyKey(input.srcNationId, input.destNationId);
|
const key = buildDiplomacyKey(input.srcNationId, input.destNationId);
|
||||||
const existed = this.diplomacy.has(key);
|
const existed = this.diplomacy.has(key);
|
||||||
@@ -586,6 +605,8 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.removeCollapsedNations();
|
||||||
|
|
||||||
return nextTurnAt;
|
return nextTurnAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +653,7 @@ export class InMemoryTurnWorld {
|
|||||||
troops: Troop[];
|
troops: Troop[];
|
||||||
deletedTroops: number[];
|
deletedTroops: number[];
|
||||||
deletedGenerals: number[];
|
deletedGenerals: number[];
|
||||||
|
deletedNations: number[];
|
||||||
diplomacy: TurnDiplomacy[];
|
diplomacy: TurnDiplomacy[];
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
createdGenerals: TurnGeneral[];
|
createdGenerals: TurnGeneral[];
|
||||||
@@ -668,6 +690,7 @@ export class InMemoryTurnWorld {
|
|||||||
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
|
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
|
||||||
const deletedTroops = Array.from(this.deletedTroopIds);
|
const deletedTroops = Array.from(this.deletedTroopIds);
|
||||||
const deletedGenerals = Array.from(this.deletedGeneralIds);
|
const deletedGenerals = Array.from(this.deletedGeneralIds);
|
||||||
|
const deletedNations = Array.from(this.deletedNationIds);
|
||||||
const logs = this.logs.splice(0, this.logs.length);
|
const logs = this.logs.splice(0, this.logs.length);
|
||||||
|
|
||||||
this.dirtyGeneralIds.clear();
|
this.dirtyGeneralIds.clear();
|
||||||
@@ -681,6 +704,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.createdDiplomacyKeys.clear();
|
this.createdDiplomacyKeys.clear();
|
||||||
this.deletedTroopIds.clear();
|
this.deletedTroopIds.clear();
|
||||||
this.deletedGeneralIds.clear();
|
this.deletedGeneralIds.clear();
|
||||||
|
this.deletedNationIds.clear();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generals,
|
generals,
|
||||||
@@ -689,6 +713,7 @@ export class InMemoryTurnWorld {
|
|||||||
troops,
|
troops,
|
||||||
deletedTroops,
|
deletedTroops,
|
||||||
deletedGenerals,
|
deletedGenerals,
|
||||||
|
deletedNations,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
@@ -698,6 +723,45 @@ export class InMemoryTurnWorld {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeCollapsedNations(): void {
|
||||||
|
const collapsedNationIds: number[] = [];
|
||||||
|
for (const nation of this.nations.values()) {
|
||||||
|
if (nation.id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const meta = nation.meta as Record<string, unknown>;
|
||||||
|
if (meta.collapsed !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cityCount = Array.from(this.cities.values()).filter((city) => city.nationId === nation.id).length;
|
||||||
|
if (cityCount > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collapsedNationIds.push(nation.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nationId of collapsedNationIds) {
|
||||||
|
for (const general of this.generals.values()) {
|
||||||
|
if (general.nationId !== nationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = applyGeneralPatch(general, {
|
||||||
|
nationId: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
troopId: 0,
|
||||||
|
});
|
||||||
|
this.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime));
|
||||||
|
this.dirtyGeneralIds.add(general.id);
|
||||||
|
}
|
||||||
|
for (const troop of Array.from(this.troops.values())) {
|
||||||
|
if (troop.nationId === nationId) {
|
||||||
|
this.removeTroop(troop.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.removeNation(nationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureDiplomacyMatrix(): void {
|
private ensureDiplomacyMatrix(): void {
|
||||||
const nationIds = Array.from(this.nations.keys());
|
const nationIds = Array.from(this.nations.keys());
|
||||||
for (const srcNationId of nationIds) {
|
for (const srcNationId of nationIds) {
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
|
||||||
|
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
||||||
|
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
|
|
||||||
|
const mockDate = new Date('0185-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
const createNpcGeneral = (
|
||||||
|
id: number,
|
||||||
|
cityId: number,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
stats: { leadership: number; strength: number; intelligence: number },
|
||||||
|
overrides: Partial<TurnGeneral> = {}
|
||||||
|
): TurnGeneral => ({
|
||||||
|
id,
|
||||||
|
name: `NPC_${id}`,
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
troopId: 0,
|
||||||
|
stats,
|
||||||
|
turnTime: mockDate,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
officerLevel,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 2,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
|
||||||
|
...city,
|
||||||
|
population: city.populationMax,
|
||||||
|
agriculture: city.agricultureMax,
|
||||||
|
commerce: city.commerceMax,
|
||||||
|
security: city.securityMax,
|
||||||
|
defence: city.defenceMax,
|
||||||
|
wall: city.wallMax,
|
||||||
|
meta: { ...(city.meta ?? {}), trust: 100, trade: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const weakCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
|
||||||
|
...city,
|
||||||
|
population: 1000,
|
||||||
|
agriculture: 1,
|
||||||
|
commerce: 1,
|
||||||
|
security: 1,
|
||||||
|
defence: 1,
|
||||||
|
wall: 1,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 1,
|
||||||
|
meta: { ...(city.meta ?? {}), trust: 5, trade: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('도시 점령 시 국가 멸망 처리', () => {
|
||||||
|
it('마지막 도시를 점령하면 약소국이 삭제되어야 한다', async () => {
|
||||||
|
const cities = buildLargeTestCities().map(maxCityStats);
|
||||||
|
const weakCityId = 1;
|
||||||
|
const strongFrontCityId = 2;
|
||||||
|
|
||||||
|
for (const city of cities) {
|
||||||
|
city.nationId = 1;
|
||||||
|
}
|
||||||
|
const weakCity = cities.find((city) => city.id === weakCityId)!;
|
||||||
|
weakCity.nationId = 2;
|
||||||
|
Object.assign(weakCity, weakCityStats(weakCity));
|
||||||
|
|
||||||
|
const strongFrontCity = cities.find((city) => city.id === strongFrontCityId)!;
|
||||||
|
strongFrontCity.frontState = 1;
|
||||||
|
strongFrontCity.supplyState = 1;
|
||||||
|
|
||||||
|
const unitSet: UnitSetDefinition = {
|
||||||
|
id: 'test_unit_set',
|
||||||
|
name: 'TestUnitSet',
|
||||||
|
defaultCrewTypeId: 1100,
|
||||||
|
crewTypes: [
|
||||||
|
{
|
||||||
|
id: 1100,
|
||||||
|
armType: 1,
|
||||||
|
name: '보병',
|
||||||
|
attack: 150,
|
||||||
|
defence: 150,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 10,
|
||||||
|
rice: 10,
|
||||||
|
requirements: [],
|
||||||
|
attackCoef: {},
|
||||||
|
defenceCoef: {},
|
||||||
|
info: [],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const delayedTurnTime = new Date(mockDate.getTime() + 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const strongLeader = createNpcGeneral(
|
||||||
|
1,
|
||||||
|
strongFrontCityId,
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
{ leadership: 90, strength: 90, intelligence: 70 },
|
||||||
|
{
|
||||||
|
crew: 9000,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
gold: 500000,
|
||||||
|
rice: 500000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const generals: TurnGeneral[] = [strongLeader];
|
||||||
|
for (let i = 2; i <= 6; i += 1) {
|
||||||
|
generals.push(
|
||||||
|
createNpcGeneral(i, strongFrontCityId, 1, 2, { leadership: 80, strength: 80, intelligence: 50 }, {
|
||||||
|
crew: 8000,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
gold: 100000,
|
||||||
|
rice: 100000,
|
||||||
|
turnTime: delayedTurnTime,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const weakGeneral = createNpcGeneral(
|
||||||
|
100,
|
||||||
|
weakCityId,
|
||||||
|
2,
|
||||||
|
12,
|
||||||
|
{ leadership: 10, strength: 10, intelligence: 10 },
|
||||||
|
{
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
turnTime: delayedTurnTime,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
generals.push(weakGeneral);
|
||||||
|
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: generals as any,
|
||||||
|
cities: cities as any,
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '강국',
|
||||||
|
color: '#AA0000',
|
||||||
|
capitalCityId: strongFrontCityId,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 800000,
|
||||||
|
rice: 800000,
|
||||||
|
power: 10000,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'npc',
|
||||||
|
meta: { tech: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '약소국',
|
||||||
|
color: '#0000AA',
|
||||||
|
capitalCityId: weakCityId,
|
||||||
|
chiefGeneralId: 100,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'npc',
|
||||||
|
meta: { tech: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [
|
||||||
|
{ fromNationId: 1, toNationId: 2, state: DIPLOMACY_STATE.WAR, term: 12, dead: 0, meta: {} },
|
||||||
|
{ fromNationId: 2, toNationId: 1, state: DIPLOMACY_STATE.WAR, term: 12, dead: 0, meta: {} },
|
||||||
|
],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: LARGE_TEST_MAP as any,
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {
|
||||||
|
openingPartYear: 3,
|
||||||
|
develCost: 10,
|
||||||
|
baseGold: 1000,
|
||||||
|
baseRice: 1000,
|
||||||
|
maxResourceActionAmount: 10000,
|
||||||
|
minAvailableRecruitPop: 0,
|
||||||
|
},
|
||||||
|
environment: { mapName: 'large_test_map', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
scenarioMeta: { startYear: 180 } as any,
|
||||||
|
unitSet: unitSet as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: mockDate,
|
||||||
|
meta: { seed: 1, initYear: 185, initMonth: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule: TurnSchedule = {
|
||||||
|
entries: [{ startMinute: 0, tickMinutes: 10 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const worldRef = { current: null as InMemoryTurnWorld | null };
|
||||||
|
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
|
||||||
|
snapshot,
|
||||||
|
state,
|
||||||
|
schedule,
|
||||||
|
map: LARGE_TEST_MAP,
|
||||||
|
worldRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const turns = reservedTurnStore.getGeneralTurns(strongLeader.id);
|
||||||
|
turns[0] = {
|
||||||
|
action: 'che_출병',
|
||||||
|
args: { destCityId: weakCityId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await runOneTick();
|
||||||
|
|
||||||
|
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
|
||||||
|
expect(world.getNationById(2)).toBeNull();
|
||||||
|
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false);
|
||||||
|
|
||||||
|
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
|
||||||
|
expect(updatedWeakGeneral?.nationId).toBe(0);
|
||||||
|
expect(updatedWeakGeneral?.officerLevel).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user