건국 커맨드 구현 및 테스트 추가

This commit is contained in:
2026-01-08 16:21:15 +00:00
parent 2c541199da
commit 77185f1d49
12 changed files with 686 additions and 81 deletions
+25 -6
View File
@@ -244,6 +244,7 @@ export const createDatabaseTurnHooks = async (
diplomacy,
logs,
createdGenerals,
createdNations,
createdTroops,
createdDiplomacy,
} = world.consumeDirtyState();
@@ -260,6 +261,7 @@ export const createDatabaseTurnHooks = async (
});
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
const createdDiplomacyKeys = new Set(
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
@@ -270,6 +272,21 @@ export const createDatabaseTurnHooks = async (
data: createdGenerals.map(buildGeneralCreate),
});
}
if (createdNations.length > 0) {
await prisma.nation.createMany({
data: createdNations.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
gold: nation.gold,
rice: nation.rice,
level: nation.level,
typeCode: nation.typeCode,
meta: asJson(nation.meta),
})),
});
}
if (createdTroops.length > 0) {
await prisma.troop.createMany({
data: createdTroops.map(buildTroopCreate),
@@ -307,12 +324,14 @@ export const createDatabaseTurnHooks = async (
data: buildCityUpdate(city),
})
),
...nations.map((nation) =>
prisma.nation.update({
where: { id: nation.id },
data: buildNationUpdate(nation),
})
),
...nations
.filter((nation) => !createdNationIds.has(nation.id))
.map((nation) =>
prisma.nation.update({
where: { id: nation.id },
data: buildNationUpdate(nation),
})
),
...troops
.filter((troop) => !createdTroopIds.has(troop.id))
.map((troop) =>
+56
View File
@@ -38,6 +38,7 @@ export interface GeneralTurnResult {
}>;
created?: {
generals: TurnGeneral[];
nations?: Nation[];
troops?: Troop[];
};
}
@@ -162,6 +163,7 @@ export class InMemoryTurnWorld {
private readonly dirtyTroopIds = new Set<number>();
private readonly dirtyDiplomacyKeys = new Set<string>();
private readonly createdGeneralIds = new Set<number>();
private readonly createdNationIds = new Set<number>();
private readonly createdTroopIds = new Set<number>();
private readonly createdDiplomacyKeys = new Set<string>();
private readonly deletedTroopIds = new Set<number>();
@@ -358,6 +360,44 @@ export class InMemoryTurnWorld {
};
}
getNextNationId(): number {
const meta = this.state.meta as Record<string, unknown>;
let lastId = (meta.lastNationId as number | undefined) ?? 0;
if (lastId === 0) {
const currentIds = Array.from(this.nations.keys());
lastId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
}
const nextId = lastId + 1;
this.state = {
...this.state,
meta: {
...this.state.meta,
lastNationId: nextId,
},
};
return nextId;
}
getNextGeneralId(): number {
const meta = this.state.meta as Record<string, unknown>;
let lastId = (meta.lastGeneralId as number | undefined) ?? 0;
if (lastId === 0) {
const currentIds = Array.from(this.generals.keys());
lastId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
}
const nextId = lastId + 1;
this.state = {
...this.state,
meta: {
...this.state.meta,
lastGeneralId: nextId,
},
};
return nextId;
}
setCheckpoint(checkpoint?: TurnCheckpoint): void {
this.checkpoint = checkpoint;
}
@@ -474,6 +514,16 @@ export class InMemoryTurnWorld {
this.dirtyGeneralIds.add(createdGeneral.id);
this.createdGeneralIds.add(createdGeneral.id);
}
if (result.created.nations) {
for (const createdNation of result.created.nations) {
if (this.nations.has(createdNation.id)) {
continue;
}
this.nations.set(createdNation.id, { ...createdNation });
this.dirtyNationIds.add(createdNation.id);
this.createdNationIds.add(createdNation.id);
}
}
if (result.created.troops) {
for (const createdTroop of result.created.troops) {
if (this.troops.has(createdTroop.id)) {
@@ -535,6 +585,7 @@ export class InMemoryTurnWorld {
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
} {
@@ -544,6 +595,9 @@ export class InMemoryTurnWorld {
const createdGenerals = Array.from(this.createdGeneralIds)
.map((id) => this.generals.get(id))
.filter((general): general is TurnGeneral => Boolean(general));
const createdNations = Array.from(this.createdNationIds)
.map((id) => this.nations.get(id))
.filter((nation): nation is Nation => Boolean(nation));
const cities = Array.from(this.dirtyCityIds)
.map((id) => this.cities.get(id))
.filter((city): city is City => Boolean(city));
@@ -572,6 +626,7 @@ export class InMemoryTurnWorld {
this.dirtyTroopIds.clear();
this.dirtyDiplomacyKeys.clear();
this.createdGeneralIds.clear();
this.createdNationIds.clear();
this.createdTroopIds.clear();
this.createdDiplomacyKeys.clear();
this.deletedTroopIds.clear();
@@ -587,6 +642,7 @@ export class InMemoryTurnWorld {
diplomacy,
logs,
createdGenerals,
createdNations,
createdTroops,
createdDiplomacy,
};
@@ -305,6 +305,8 @@ class WorldStateView implements StateView {
return this.overrides.nation;
}
return this.world.getNationById(req.id);
case 'nationList':
return this.world.listNations();
case 'destNation':
return this.world.getNationById(req.id);
case 'diplomacy':
@@ -402,16 +404,34 @@ export const createReservedTurnHandler = async (options: {
let nextGeneralId: number | null = null;
const createGeneralId = (): number => {
const world = options.getWorld();
if (world) {
return world.getNextGeneralId();
}
if (nextGeneralId === null) {
const world = options.getWorld();
const ids = world ? world.listGenerals().map((general) => general.id) : [];
nextGeneralId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
nextGeneralId = 1;
}
const result = nextGeneralId;
nextGeneralId += 1;
return result;
};
let nextNationId: number | null = null;
const createNationId = (): number => {
const world = options.getWorld();
if (world) {
return world.getNextNationId();
}
if (nextNationId === null) {
nextNationId = 1;
}
const result = nextNationId;
nextNationId += 1;
return result;
};
return {
execute(context): GeneralTurnResult {
const worldRef = options.getWorld();
@@ -434,7 +454,8 @@ export const createReservedTurnHandler = async (options: {
destNationId: number;
patch: DiplomacyPatch;
}> = [];
const created: TurnGeneral[] = [];
const createdGenerals: TurnGeneral[] = [];
const createdNations: Nation[] = [];
let currentGeneral = context.general;
let currentCity = context.city;
@@ -518,6 +539,7 @@ export const createReservedTurnHandler = async (options: {
worldRef: worldView,
actionArgs: actionArgsRecord,
createGeneralId,
createNationId,
seedBase,
},
actionContextBuilders
@@ -550,6 +572,13 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
currentNation = resolution.nation ?? currentNation;
if (!currentNation && resolution.created?.nations) {
currentNation =
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
currentNation;
}
logs.push(...resolution.logs);
if (worldOverlay) {
worldOverlay.syncGeneral(currentGeneral);
@@ -608,14 +637,23 @@ export const createReservedTurnHandler = async (options: {
}
if (resolution.created?.generals) {
const createdGenerals = resolution.created.generals as TurnGeneral[];
created.push(...createdGenerals);
const newGenerals = resolution.created.generals as TurnGeneral[];
createdGenerals.push(...newGenerals);
if (worldOverlay) {
for (const general of createdGenerals) {
for (const general of newGenerals) {
worldOverlay.syncGeneral(general);
}
}
}
if (resolution.created?.nations) {
const newNations = resolution.created.nations as Nation[];
createdNations.push(...newNations);
if (worldOverlay) {
for (const nation of newNations) {
worldOverlay.syncNation(nation);
}
}
}
return applyNextTurnAt ? resolution.nextTurnAt : undefined;
};
@@ -642,7 +680,13 @@ export const createReservedTurnHandler = async (options: {
logs,
patches,
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
created: created.length > 0 ? { generals: created } : undefined,
created:
createdGenerals.length > 0 || createdNations.length > 0
? {
generals: createdGenerals,
...(createdNations.length > 0 ? { nations: createdNations } : {}),
}
: undefined,
};
return result;
@@ -4,6 +4,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
// Inline MINIMAL_MAP to avoid cross-package relative import issues
const MINIMAL_MAP = {
id: 'minimal_map',
@@ -320,4 +321,306 @@ describe('Reserved Turn Execution Integration', () => {
// Turn 2 exec -> Shift -1
// Turns should indeed be empty/default now.
});
describe('Uprising and Founding Execution', () => {
it('should execute uprising, fail founding in wrong city, fail domestic in wandering nation, then succeed founding and domestic', async () => {
const mockDate = new Date('0189-01-01T00:00:00Z');
// 1. Setup World Data
const generals: TurnGeneral[] = [
{
id: 1,
name: 'General_Leader',
nationId: 0,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
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: 5,
experience: 0,
dedication: 0,
injury: 0,
gold: 2000,
rice: 2000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
},
{
id: 2,
name: 'General_Sub',
nationId: 0,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
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: 5,
experience: 0,
dedication: 0,
injury: 0,
gold: 2000,
rice: 2000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
},
];
const cities = [
{
id: 1,
name: 'Small_City',
nationId: 0,
viewName: 'Small_City',
agriculture: 100,
agricultureMax: 2000,
commerce: 100,
commerceMax: 2000,
security: 100,
securityMax: 100,
def: 100,
defMax: 100,
wall: 100,
wallMax: 100,
pop: 10000,
popMax: 50000,
trust: 50,
supplyState: 1,
frontState: 0,
tradepoint: 0,
level: 1, // Invalid for founding
meta: {},
},
{
id: 2,
name: 'Big_City',
nationId: 0,
viewName: 'Big_City',
agriculture: 100,
agricultureMax: 3000,
commerce: 100,
commerceMax: 3000,
security: 100,
securityMax: 100,
def: 100,
defMax: 100,
wall: 100,
wallMax: 100,
pop: 20000,
popMax: 80000,
trust: 50,
supplyState: 1,
frontState: 0,
tradepoint: 0,
level: 5, // Valid for founding
meta: {},
},
];
const localMap = {
id: 'local_map',
name: 'TestMap',
cities: [
{ id: 1, connections: [2] },
{ id: 2, connections: [1] },
] as any,
};
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: cities as any,
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: localMap,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { openingPartYear: 9999 },
environment: { mapName: 'local', unitSet: 'default' },
},
scenarioMeta: {
startYear: 189,
} as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: {},
};
const initialRows = [
// Gen 1: Uprising -> Founding (Fail) -> Move -> Founding (Success)
{ generalId: 1, turnIdx: 0, actionCode: 'che_거병', arg: {} },
{
generalId: 1,
turnIdx: 1,
actionCode: 'che_건국',
arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 },
},
{ generalId: 1, turnIdx: 2, actionCode: 'che_이동', arg: { destCityId: 2 } },
{
generalId: 1,
turnIdx: 3,
actionCode: 'che_건국',
arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 },
},
// Gen 2: Rest -> Domestic (Fail) -> Rest -> Rest -> Domestic (Success)
{ generalId: 2, turnIdx: 0, actionCode: '휴식', arg: {} },
{ generalId: 2, turnIdx: 1, actionCode: 'che_농지개간', arg: {} },
{ generalId: 2, turnIdx: 2, actionCode: '휴식', arg: {} },
{ generalId: 2, turnIdx: 3, actionCode: '휴식', arg: {} },
{ generalId: 2, turnIdx: 4, actionCode: 'che_농지개간', arg: {} },
];
const mockPrisma = createMockPrisma(initialRows);
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
maxGeneralTurns: 10,
maxNationTurns: 10,
});
await reservedTurnStore.loadAll();
const wrapper = { world: null as InMemoryTurnWorld | null };
const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: localMap,
unitSet: snapshot.unitSet,
getWorld: () => wrapper.world,
});
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
wrapper.world = world;
const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 });
// Execution Loop
// Turn 0: Uprising (Gen 1)
await processor.run(new Date(mockDate.getTime() + 10 * 60 * 1000), {
budgetMs: 1000,
maxGenerals: 100,
catchUpCap: 10,
});
await reservedTurnStore.flushChanges();
// Verify Uprising
const gen1 = world.getGeneralById(1)!;
expect(gen1.nationId).toBeGreaterThan(0);
const nation = world.getNationById(gen1.nationId)!;
expect(nation.level).toBe(0); // Wandering
expect(gen1.officerLevel).toBe(12); // Monarch
// Cheat: Join Gen 2 to Gen 1's nation
// We can treat this as "Gen 2 was convinced by Gen 1" or manually updated
world.updateGeneral(2, { nationId: gen1.nationId });
// Turn 1: Founding (Fail) and Domestic (Fail)
await processor.run(new Date(mockDate.getTime() + 2 * 10 * 60 * 1000), {
// Skip enough time for turn
budgetMs: 1000,
maxGenerals: 100,
catchUpCap: 10,
});
await reservedTurnStore.flushChanges();
// Verify Failures
// Founding should fail (Wrong City), so Nation Level should still be 0
const nationAfterFail = world.getNationById(gen1.nationId)!;
expect(nationAfterFail.level).toBe(0);
// Domestic should fail (Wandering Nation)
// Domestic effect is increasing agriculture. Gen 2 in City 1.
const city1 = world.getCityById(1)!;
expect(city1.agriculture).toBe(100); // No change
// Turn 2: Move (Gen 1)
await processor.run(new Date(mockDate.getTime() + 3 * 10 * 60 * 1000), {
budgetMs: 1000,
maxGenerals: 100,
catchUpCap: 10,
});
await reservedTurnStore.flushChanges();
const gen1AfterMove = world.getGeneralById(1)!;
expect(gen1AfterMove.cityId).toBe(2);
// Turn 3: Founding (Success in City 2)
await processor.run(new Date(mockDate.getTime() + 4 * 10 * 60 * 1000), {
budgetMs: 1000,
maxGenerals: 100,
catchUpCap: 10,
});
await reservedTurnStore.flushChanges();
// Verify Founding
const nationFinal = world.getNationById(gen1.nationId)!;
expect(nationFinal.level).toBe(1); // Normal Nation
expect(nationFinal.name).toBe('NewEmpire');
expect(nationFinal.capitalCityId).toBe(2);
// Turn 4: Domestic (Success, Gen 2 in City 1)
// Wait, Gen 2 is in City 1. City 1 belongs to nation?
// Founding sets Capital City 2 to belongs to nation.
// City 1 is still Neutral?
// If City 1 is neutral, Domestic (Agriculture) might still fail due to "OccupiedCity" or ownership?
// NotWanderingNation passes.
// occupiedCity(): requires city.nationId === general.nationId?
// Let's check occupiedCity constraint.
// If occupiedCity fails, then Domestic fails.
// To make Domestic SUCCESS, Gen 2 must be in a city owned by the nation.
// But Gen 2 is in City 1.
// Gen 1 founded in City 2.
// So Gen 2 should move to City 2 OR we own City 1.
// Let's assume Gen 2 moves to City 2.
// But I didn't schedule Move for Gen 2.
// I'll manually move Gen 2 to City 2 before Turn 4.
world.updateGeneral(2, { cityId: 2 });
await processor.run(new Date(mockDate.getTime() + 5 * 10 * 60 * 1000), {
budgetMs: 1000,
maxGenerals: 100,
catchUpCap: 10,
});
await reservedTurnStore.flushChanges();
const city2 = world.getCityById(2)!;
expect(city2.agriculture).toBeGreaterThan(100); // Success
});
});
});