건국 커맨드 구현 및 테스트 추가
This commit is contained in:
@@ -244,6 +244,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
|
createdNations,
|
||||||
createdTroops,
|
createdTroops,
|
||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
} = world.consumeDirtyState();
|
} = world.consumeDirtyState();
|
||||||
@@ -260,6 +261,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createdIds = new Set(createdGenerals.map((general) => general.id));
|
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 createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
||||||
const createdDiplomacyKeys = new Set(
|
const createdDiplomacyKeys = new Set(
|
||||||
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
|
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
|
||||||
@@ -270,6 +272,21 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: createdGenerals.map(buildGeneralCreate),
|
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) {
|
if (createdTroops.length > 0) {
|
||||||
await prisma.troop.createMany({
|
await prisma.troop.createMany({
|
||||||
data: createdTroops.map(buildTroopCreate),
|
data: createdTroops.map(buildTroopCreate),
|
||||||
@@ -307,12 +324,14 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: buildCityUpdate(city),
|
data: buildCityUpdate(city),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
...nations.map((nation) =>
|
...nations
|
||||||
prisma.nation.update({
|
.filter((nation) => !createdNationIds.has(nation.id))
|
||||||
where: { id: nation.id },
|
.map((nation) =>
|
||||||
data: buildNationUpdate(nation),
|
prisma.nation.update({
|
||||||
})
|
where: { id: nation.id },
|
||||||
),
|
data: buildNationUpdate(nation),
|
||||||
|
})
|
||||||
|
),
|
||||||
...troops
|
...troops
|
||||||
.filter((troop) => !createdTroopIds.has(troop.id))
|
.filter((troop) => !createdTroopIds.has(troop.id))
|
||||||
.map((troop) =>
|
.map((troop) =>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface GeneralTurnResult {
|
|||||||
}>;
|
}>;
|
||||||
created?: {
|
created?: {
|
||||||
generals: TurnGeneral[];
|
generals: TurnGeneral[];
|
||||||
|
nations?: Nation[];
|
||||||
troops?: Troop[];
|
troops?: Troop[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -162,6 +163,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly dirtyTroopIds = new Set<number>();
|
private readonly dirtyTroopIds = new Set<number>();
|
||||||
private readonly dirtyDiplomacyKeys = new Set<string>();
|
private readonly dirtyDiplomacyKeys = new Set<string>();
|
||||||
private readonly createdGeneralIds = new Set<number>();
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
|
private readonly createdNationIds = new Set<number>();
|
||||||
private readonly createdTroopIds = new Set<number>();
|
private readonly createdTroopIds = new Set<number>();
|
||||||
private readonly createdDiplomacyKeys = new Set<string>();
|
private readonly createdDiplomacyKeys = new Set<string>();
|
||||||
private readonly deletedTroopIds = new Set<number>();
|
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 {
|
setCheckpoint(checkpoint?: TurnCheckpoint): void {
|
||||||
this.checkpoint = checkpoint;
|
this.checkpoint = checkpoint;
|
||||||
}
|
}
|
||||||
@@ -474,6 +514,16 @@ export class InMemoryTurnWorld {
|
|||||||
this.dirtyGeneralIds.add(createdGeneral.id);
|
this.dirtyGeneralIds.add(createdGeneral.id);
|
||||||
this.createdGeneralIds.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) {
|
if (result.created.troops) {
|
||||||
for (const createdTroop of result.created.troops) {
|
for (const createdTroop of result.created.troops) {
|
||||||
if (this.troops.has(createdTroop.id)) {
|
if (this.troops.has(createdTroop.id)) {
|
||||||
@@ -535,6 +585,7 @@ export class InMemoryTurnWorld {
|
|||||||
diplomacy: TurnDiplomacy[];
|
diplomacy: TurnDiplomacy[];
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
createdGenerals: TurnGeneral[];
|
createdGenerals: TurnGeneral[];
|
||||||
|
createdNations: Nation[];
|
||||||
createdTroops: Troop[];
|
createdTroops: Troop[];
|
||||||
createdDiplomacy: TurnDiplomacy[];
|
createdDiplomacy: TurnDiplomacy[];
|
||||||
} {
|
} {
|
||||||
@@ -544,6 +595,9 @@ export class InMemoryTurnWorld {
|
|||||||
const createdGenerals = Array.from(this.createdGeneralIds)
|
const createdGenerals = Array.from(this.createdGeneralIds)
|
||||||
.map((id) => this.generals.get(id))
|
.map((id) => this.generals.get(id))
|
||||||
.filter((general): general is TurnGeneral => Boolean(general));
|
.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)
|
const cities = Array.from(this.dirtyCityIds)
|
||||||
.map((id) => this.cities.get(id))
|
.map((id) => this.cities.get(id))
|
||||||
.filter((city): city is City => Boolean(city));
|
.filter((city): city is City => Boolean(city));
|
||||||
@@ -572,6 +626,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.dirtyTroopIds.clear();
|
this.dirtyTroopIds.clear();
|
||||||
this.dirtyDiplomacyKeys.clear();
|
this.dirtyDiplomacyKeys.clear();
|
||||||
this.createdGeneralIds.clear();
|
this.createdGeneralIds.clear();
|
||||||
|
this.createdNationIds.clear();
|
||||||
this.createdTroopIds.clear();
|
this.createdTroopIds.clear();
|
||||||
this.createdDiplomacyKeys.clear();
|
this.createdDiplomacyKeys.clear();
|
||||||
this.deletedTroopIds.clear();
|
this.deletedTroopIds.clear();
|
||||||
@@ -587,6 +642,7 @@ export class InMemoryTurnWorld {
|
|||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
|
createdNations,
|
||||||
createdTroops,
|
createdTroops,
|
||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ class WorldStateView implements StateView {
|
|||||||
return this.overrides.nation;
|
return this.overrides.nation;
|
||||||
}
|
}
|
||||||
return this.world.getNationById(req.id);
|
return this.world.getNationById(req.id);
|
||||||
|
case 'nationList':
|
||||||
|
return this.world.listNations();
|
||||||
case 'destNation':
|
case 'destNation':
|
||||||
return this.world.getNationById(req.id);
|
return this.world.getNationById(req.id);
|
||||||
case 'diplomacy':
|
case 'diplomacy':
|
||||||
@@ -402,16 +404,34 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
|
|
||||||
let nextGeneralId: number | null = null;
|
let nextGeneralId: number | null = null;
|
||||||
const createGeneralId = (): number => {
|
const createGeneralId = (): number => {
|
||||||
|
const world = options.getWorld();
|
||||||
|
if (world) {
|
||||||
|
return world.getNextGeneralId();
|
||||||
|
}
|
||||||
|
|
||||||
if (nextGeneralId === null) {
|
if (nextGeneralId === null) {
|
||||||
const world = options.getWorld();
|
nextGeneralId = 1;
|
||||||
const ids = world ? world.listGenerals().map((general) => general.id) : [];
|
|
||||||
nextGeneralId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
||||||
}
|
}
|
||||||
const result = nextGeneralId;
|
const result = nextGeneralId;
|
||||||
nextGeneralId += 1;
|
nextGeneralId += 1;
|
||||||
return result;
|
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 {
|
return {
|
||||||
execute(context): GeneralTurnResult {
|
execute(context): GeneralTurnResult {
|
||||||
const worldRef = options.getWorld();
|
const worldRef = options.getWorld();
|
||||||
@@ -434,7 +454,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
destNationId: number;
|
destNationId: number;
|
||||||
patch: DiplomacyPatch;
|
patch: DiplomacyPatch;
|
||||||
}> = [];
|
}> = [];
|
||||||
const created: TurnGeneral[] = [];
|
const createdGenerals: TurnGeneral[] = [];
|
||||||
|
const createdNations: Nation[] = [];
|
||||||
|
|
||||||
let currentGeneral = context.general;
|
let currentGeneral = context.general;
|
||||||
let currentCity = context.city;
|
let currentCity = context.city;
|
||||||
@@ -518,6 +539,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
worldRef: worldView,
|
worldRef: worldView,
|
||||||
actionArgs: actionArgsRecord,
|
actionArgs: actionArgsRecord,
|
||||||
createGeneralId,
|
createGeneralId,
|
||||||
|
createNationId,
|
||||||
seedBase,
|
seedBase,
|
||||||
},
|
},
|
||||||
actionContextBuilders
|
actionContextBuilders
|
||||||
@@ -550,6 +572,13 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
currentGeneral = resolution.general as TurnGeneral;
|
currentGeneral = resolution.general as TurnGeneral;
|
||||||
currentCity = resolution.city ?? currentCity;
|
currentCity = resolution.city ?? currentCity;
|
||||||
currentNation = resolution.nation ?? currentNation;
|
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);
|
logs.push(...resolution.logs);
|
||||||
if (worldOverlay) {
|
if (worldOverlay) {
|
||||||
worldOverlay.syncGeneral(currentGeneral);
|
worldOverlay.syncGeneral(currentGeneral);
|
||||||
@@ -608,14 +637,23 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolution.created?.generals) {
|
if (resolution.created?.generals) {
|
||||||
const createdGenerals = resolution.created.generals as TurnGeneral[];
|
const newGenerals = resolution.created.generals as TurnGeneral[];
|
||||||
created.push(...createdGenerals);
|
createdGenerals.push(...newGenerals);
|
||||||
if (worldOverlay) {
|
if (worldOverlay) {
|
||||||
for (const general of createdGenerals) {
|
for (const general of newGenerals) {
|
||||||
worldOverlay.syncGeneral(general);
|
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;
|
return applyNextTurnAt ? resolution.nextTurnAt : undefined;
|
||||||
};
|
};
|
||||||
@@ -642,7 +680,13 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
logs,
|
logs,
|
||||||
patches,
|
patches,
|
||||||
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
|
...(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;
|
return result;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn
|
|||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||||
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.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
|
// Inline MINIMAL_MAP to avoid cross-package relative import issues
|
||||||
const MINIMAL_MAP = {
|
const MINIMAL_MAP = {
|
||||||
id: 'minimal_map',
|
id: 'minimal_map',
|
||||||
@@ -320,4 +321,306 @@ describe('Reserved Turn Execution Integration', () => {
|
|||||||
// Turn 2 exec -> Shift -1
|
// Turn 2 exec -> Shift -1
|
||||||
// Turns should indeed be empty/default now.
|
// 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
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
|
|||||||
| GeneralAddEffect<TriggerState>
|
| GeneralAddEffect<TriggerState>
|
||||||
| CityPatchEffect
|
| CityPatchEffect
|
||||||
| NationPatchEffect
|
| NationPatchEffect
|
||||||
|
| NationAddEffect
|
||||||
| DiplomacyPatchEffect
|
| DiplomacyPatchEffect
|
||||||
| LogEffect
|
| LogEffect
|
||||||
| NextTurnOverrideEffect;
|
| NextTurnOverrideEffect;
|
||||||
@@ -117,6 +118,7 @@ export interface GeneralActionResolution {
|
|||||||
effects: GeneralActionEffect[];
|
effects: GeneralActionEffect[];
|
||||||
created?: {
|
created?: {
|
||||||
generals: General[];
|
generals: General[];
|
||||||
|
nations?: Nation[];
|
||||||
};
|
};
|
||||||
patches?: {
|
patches?: {
|
||||||
generals: Array<{ id: GeneralId; patch: Partial<General> }>;
|
generals: Array<{ id: GeneralId; patch: Partial<General> }>;
|
||||||
@@ -165,6 +167,16 @@ export const createNationPatchEffect = (patch: Partial<Nation>, targetId?: Natio
|
|||||||
...(targetId !== undefined ? { targetId } : {}),
|
...(targetId !== undefined ? { targetId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface NationAddEffect {
|
||||||
|
type: 'nation:add';
|
||||||
|
nation: Nation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNationAddEffect = (nation: Nation): NationAddEffect => ({
|
||||||
|
type: 'nation:add',
|
||||||
|
nation,
|
||||||
|
});
|
||||||
|
|
||||||
export const createDiplomacyPatchEffect = (
|
export const createDiplomacyPatchEffect = (
|
||||||
srcNationId: NationId,
|
srcNationId: NationId,
|
||||||
destNationId: NationId,
|
destNationId: NationId,
|
||||||
@@ -206,6 +218,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
let nextTurnAtOverride: Date | null = null;
|
let nextTurnAtOverride: Date | null = null;
|
||||||
const createdGenerals: General[] = [];
|
const createdGenerals: General[] = [];
|
||||||
|
const createdNations: Nation[] = [];
|
||||||
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
||||||
generals: [],
|
generals: [],
|
||||||
cities: [],
|
cities: [],
|
||||||
@@ -284,6 +297,9 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
case 'general:add':
|
case 'general:add':
|
||||||
createdGenerals.push(effect.general as General);
|
createdGenerals.push(effect.general as General);
|
||||||
break;
|
break;
|
||||||
|
case 'nation:add':
|
||||||
|
createdNations.push(effect.nation as Nation);
|
||||||
|
break;
|
||||||
case 'diplomacy:patch':
|
case 'diplomacy:patch':
|
||||||
pendingEffects.push(effect);
|
pendingEffects.push(effect);
|
||||||
break;
|
break;
|
||||||
@@ -366,9 +382,10 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
if (patches.generals.length > 0 || patches.cities.length > 0 || patches.nations.length > 0) {
|
if (patches.generals.length > 0 || patches.cities.length > 0 || patches.nations.length > 0) {
|
||||||
resolution.patches = patches;
|
resolution.patches = patches;
|
||||||
}
|
}
|
||||||
if (createdGenerals.length > 0) {
|
if (createdGenerals.length > 0 || createdNations.length > 0) {
|
||||||
resolution.created = {
|
resolution.created = {
|
||||||
generals: createdGenerals,
|
generals: createdGenerals,
|
||||||
|
...(createdNations.length > 0 ? { nations: createdNations } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface ActionContextOptions {
|
|||||||
worldRef: ActionContextWorldRef | null;
|
worldRef: ActionContextWorldRef | null;
|
||||||
actionArgs: Record<string, unknown>;
|
actionArgs: Record<string, unknown>;
|
||||||
createGeneralId: () => number;
|
createGeneralId: () => number;
|
||||||
|
createNationId: () => number;
|
||||||
seedBase: string;
|
seedBase: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import type { GeneralTriggerState, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import { beNeutral } from '@sammo-ts/logic/constraints/presets.js';
|
import { beNeutral } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { createGeneralPatchEffect, createNationAddEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export interface UprisingArgs {}
|
export interface UprisingArgs {}
|
||||||
|
|
||||||
|
export interface UprisingContext extends ActionContextBase {
|
||||||
|
createNationId: () => number;
|
||||||
|
listNations?: () => Nation[];
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '거병';
|
const ACTION_NAME = '거병';
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
@@ -19,7 +25,6 @@ export class ActionDefinition<
|
|||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
parseArgs(_raw: unknown): UprisingArgs | null {
|
parseArgs(_raw: unknown): UprisingArgs | null {
|
||||||
void _raw;
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,24 +37,92 @@ export class ActionDefinition<
|
|||||||
_args: UprisingArgs
|
_args: UprisingArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
const uprisingCtx = context as unknown as UprisingContext;
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
if (!uprisingCtx.createNationId) {
|
||||||
general.meta = {
|
throw new Error('createNationId is not defined in context');
|
||||||
...(general.meta as object),
|
}
|
||||||
uprising: true as TriggerValue,
|
|
||||||
|
const newNationId = uprisingCtx.createNationId();
|
||||||
|
const josaYi = '이'; // Mock JodaUtil.pick
|
||||||
|
|
||||||
|
let nationName = general.name;
|
||||||
|
const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : [];
|
||||||
|
|
||||||
|
if (nations.some((n) => n.name === nationName)) {
|
||||||
|
nationName = '㉥' + nationName;
|
||||||
|
if (nationName.length > 18) nationName = nationName.substring(0, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nations.some((n) => n.name === nationName)) {
|
||||||
|
nationName = '㉥' + nationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNation: Nation = {
|
||||||
|
id: newNationId,
|
||||||
|
name: nationName,
|
||||||
|
color: '#330000',
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
level: 0,
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: general.id,
|
||||||
|
gold: 0,
|
||||||
|
rice: 2000,
|
||||||
|
power: 0,
|
||||||
|
meta: {
|
||||||
|
rate: 20,
|
||||||
|
bill: 100,
|
||||||
|
strategic_cmd_limit: 12,
|
||||||
|
surlimit: 72,
|
||||||
|
secretlimit: 3,
|
||||||
|
gennum: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
context.addLog(`${ACTION_NAME}을 준비했습니다.`, {
|
const cityName = context.city?.name ?? '??';
|
||||||
|
|
||||||
|
context.addLog(`거병에 성공하였습니다.`, {
|
||||||
|
category: LogCategory.USER,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${general.name}${josaYi} ${cityName}에 거병하였습니다.`, {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
context.addLog(`【거병】${general.name}${josaYi} 세력을 결성하였습니다.`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${cityName}에서 거병`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${general.name}${josaYi} ${cityName}에서 거병`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
|
||||||
return { effects: [] };
|
const effects = [
|
||||||
|
createNationAddEffect(newNation),
|
||||||
|
createGeneralPatchEffect<TriggerState>({
|
||||||
|
nationId: newNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
experience: (general.experience || 0) + 100,
|
||||||
|
dedication: (general.dedication || 0) + 100,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { effects };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
return {
|
||||||
|
...base,
|
||||||
|
createNationId: options.createNationId,
|
||||||
|
listNations: () => options.worldRef?.listNations() ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_거병',
|
key: 'che_거병',
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { GeneralTriggerState, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
beMonarch,
|
beMonarch,
|
||||||
beWanderingNation,
|
beWanderingNation,
|
||||||
reqNationGeneralCount,
|
|
||||||
beOpeningPart,
|
|
||||||
beNeutralCity,
|
beNeutralCity,
|
||||||
reqCityLevel,
|
reqCityLevel,
|
||||||
|
reqNationGeneralCount,
|
||||||
checkNationNameDuplicate,
|
checkNationNameDuplicate,
|
||||||
|
beOpeningPart,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import {
|
||||||
|
createCityPatchEffect,
|
||||||
|
createGeneralPatchEffect,
|
||||||
|
createNationPatchEffect,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
@@ -24,6 +29,42 @@ export interface FoundingArgs {
|
|||||||
|
|
||||||
const ACTION_NAME = '건국';
|
const ACTION_NAME = '건국';
|
||||||
|
|
||||||
|
const NATION_COLORS = [
|
||||||
|
'#FF0000',
|
||||||
|
'#800000',
|
||||||
|
'#A0522D',
|
||||||
|
'#FF6347',
|
||||||
|
'#FFA500',
|
||||||
|
'#FFDAB9',
|
||||||
|
'#FFD700',
|
||||||
|
'#FFFF00',
|
||||||
|
'#7CFC00',
|
||||||
|
'#00FF00',
|
||||||
|
'#808000',
|
||||||
|
'#008000',
|
||||||
|
'#2E8B57',
|
||||||
|
'#008080',
|
||||||
|
'#20B2AA',
|
||||||
|
'#6495ED',
|
||||||
|
'#7FFFD4',
|
||||||
|
'#AFEEEE',
|
||||||
|
'#87CEEB',
|
||||||
|
'#00FFFF',
|
||||||
|
'#00BFFF',
|
||||||
|
'#0000FF',
|
||||||
|
'#000080',
|
||||||
|
'#483D8B',
|
||||||
|
'#7B68EE',
|
||||||
|
'#BA55D3',
|
||||||
|
'#800080',
|
||||||
|
'#FF00FF',
|
||||||
|
'#FFC0CB',
|
||||||
|
'#F5F5DC',
|
||||||
|
'#E0FFFF',
|
||||||
|
'#FFFFFF',
|
||||||
|
'#A9A9A9',
|
||||||
|
];
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
||||||
@@ -57,20 +98,67 @@ export class ActionDefinition<
|
|||||||
args: FoundingArgs
|
args: FoundingArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
const nation = context.nation!;
|
||||||
|
const cityId = general.cityId!;
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
|
||||||
general.meta = {
|
throw new Error('Invalid color type');
|
||||||
...(general.meta as object),
|
}
|
||||||
founding: true as TriggerValue,
|
const color = NATION_COLORS[args.colorType];
|
||||||
foundingArgs: args as unknown as TriggerValue, // Cast to TriggerValue to solve type mismatch
|
|
||||||
};
|
|
||||||
|
|
||||||
context.addLog(`${args.nationName} 건국을 준비했습니다.`, {
|
const josaUl = '을'; // Mock JosaUtil.pick
|
||||||
|
const josaYi = '이';
|
||||||
|
const city = context.city;
|
||||||
|
|
||||||
|
context.addLog(`${args.nationName}${josaUl} 건국하였습니다.`, {
|
||||||
|
category: LogCategory.USER,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${general.name}${josaYi} ${city?.name}에 국가를 건설하였습니다.`, {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
context.addLog(`【건국】${args.nationType} ${args.nationName}${josaYi} 새로이 등장하였습니다.`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${args.nationName}${josaUl} 건국`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`${general.name}${josaYi} ${args.nationName}${josaUl} 건국`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
|
||||||
return { effects: [] };
|
const effects = [
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
name: args.nationName,
|
||||||
|
typeCode: args.nationType,
|
||||||
|
color: color!,
|
||||||
|
level: 1, // Normal Nation
|
||||||
|
capitalCityId: cityId,
|
||||||
|
meta: {
|
||||||
|
...nation.meta,
|
||||||
|
can_국기변경: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
),
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
nationId: nation.id,
|
||||||
|
},
|
||||||
|
cityId
|
||||||
|
),
|
||||||
|
createGeneralPatchEffect<TriggerState>({
|
||||||
|
experience: (general.experience || 0) + 1000,
|
||||||
|
dedication: (general.dedication || 0) + 1000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { effects };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,22 +21,19 @@ export const notOpeningPart = (relYear: number, openingPartYear: number): Constr
|
|||||||
export const beOpeningPart = (): Constraint => ({
|
export const beOpeningPart = (): Constraint => ({
|
||||||
name: 'BeOpeningPart',
|
name: 'BeOpeningPart',
|
||||||
requires: () => [
|
requires: () => [
|
||||||
{ kind: 'env', key: 'world' },
|
{ kind: 'env', key: 'year' },
|
||||||
{ kind: 'env', key: 'openingPartYear' },
|
{ kind: 'env', key: 'openingPartYear' },
|
||||||
],
|
],
|
||||||
test: (_ctx, view) => {
|
test: (_ctx, view) => {
|
||||||
const world = view.get({ kind: 'env', key: 'world' }) as { currentYear: number } | null;
|
const year = view.get({ kind: 'env', key: 'year' }) as number | undefined;
|
||||||
const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | null;
|
const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | undefined;
|
||||||
if (!world || openingPartYear === null) {
|
|
||||||
return {
|
if (year === undefined || openingPartYear === undefined) {
|
||||||
kind: 'unknown',
|
// 정보가 없으면 제약을 무시하거나 알림
|
||||||
missing: [
|
return allow();
|
||||||
{ kind: 'env', key: 'world' },
|
|
||||||
{ kind: 'env', key: 'openingPartYear' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (world.currentYear < openingPartYear) {
|
|
||||||
|
if (year <= openingPartYear) {
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
return { kind: 'deny', reason: '초반이 지났습니다.' };
|
return { kind: 'deny', reason: '초반이 지났습니다.' };
|
||||||
|
|||||||
@@ -261,12 +261,12 @@ export const reqNationGeneralCount = (min: number): Constraint => ({
|
|||||||
export const checkNationNameDuplicate = (name: string): Constraint => ({
|
export const checkNationNameDuplicate = (name: string): Constraint => ({
|
||||||
name: 'CheckNationNameDuplicate',
|
name: 'CheckNationNameDuplicate',
|
||||||
requires: () => [{ kind: 'nationList' }],
|
requires: () => [{ kind: 'nationList' }],
|
||||||
test: (_ctx, view) => {
|
test: (ctx, view) => {
|
||||||
const nations = view.get({ kind: 'nationList' }) as Nation[] | null;
|
const nations = view.get({ kind: 'nationList' }) as Nation[] | null;
|
||||||
if (!nations) {
|
if (!nations) {
|
||||||
return { kind: 'unknown', missing: [{ kind: 'nationList' }] };
|
return { kind: 'unknown', missing: [{ kind: 'nationList' }] };
|
||||||
}
|
}
|
||||||
if (nations.some((n) => n.name === name)) {
|
if (nations.some((n) => n.name === name && n.id !== ctx.nationId)) {
|
||||||
return { kind: 'deny', reason: '이미 존재하는 국가 이름입니다.' };
|
return { kind: 'deny', reason: '이미 존재하는 국가 이름입니다.' };
|
||||||
}
|
}
|
||||||
return allow();
|
return allow();
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ describe('Blank Start Scenario', () => {
|
|||||||
if (req.kind === 'env') {
|
if (req.kind === 'env') {
|
||||||
if (req.key === 'world') return { currentYear: year };
|
if (req.key === 'world') return { currentYear: year };
|
||||||
if (req.key === 'openingPartYear') return systemEnv.openingPartYear;
|
if (req.key === 'openingPartYear') return systemEnv.openingPartYear;
|
||||||
|
if (req.key === 'relYear') return year - 189;
|
||||||
|
if (req.key === 'year') return year;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -181,33 +183,13 @@ describe('Blank Start Scenario', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const gen0AfterUprising = world.getGeneral(gen0.id)!;
|
const gen0AfterUprising = world.getGeneral(gen0.id)!;
|
||||||
expect(gen0AfterUprising.meta.uprising).toBe(true);
|
expect(gen0AfterUprising.nationId).toBeGreaterThan(0);
|
||||||
|
expect(gen0AfterUprising.officerLevel).toBe(12);
|
||||||
|
|
||||||
// Simulate Uprising Daemon
|
const newNationId = gen0AfterUprising.nationId;
|
||||||
const newNationId = 1;
|
const newNation = world.getNation(newNationId)!;
|
||||||
const newNation: Nation = {
|
expect(newNation.chiefGeneralId).toBe(gen0.id);
|
||||||
id: newNationId,
|
expect(newNation.level).toBe(0); // Wandering Nation
|
||||||
name: 'Gen0Nation',
|
|
||||||
color: '#FF0000',
|
|
||||||
capitalCityId: gen0AfterUprising.cityId,
|
|
||||||
chiefGeneralId: gen0AfterUprising.id,
|
|
||||||
gold: 10000,
|
|
||||||
rice: 10000,
|
|
||||||
power: 0,
|
|
||||||
level: 0, // Wandering Nation
|
|
||||||
typeCode: 'che_def',
|
|
||||||
meta: {},
|
|
||||||
};
|
|
||||||
world.snapshot.nations.push(newNation);
|
|
||||||
const cityIdx = world.snapshot.cities.findIndex((c) => c.id === gen0AfterUprising.cityId);
|
|
||||||
world.snapshot.cities[cityIdx] = { ...world.snapshot.cities[cityIdx], nationId: newNationId, level: 5 } as City; // level 5 (소)
|
|
||||||
const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0AfterUprising.id);
|
|
||||||
world.snapshot.generals[gen0Idx] = {
|
|
||||||
...world.snapshot.generals[gen0Idx],
|
|
||||||
nationId: newNationId,
|
|
||||||
officerLevel: 12, // Monarch
|
|
||||||
meta: { ...gen0AfterUprising.meta, uprising: false },
|
|
||||||
} as General;
|
|
||||||
|
|
||||||
// --- Step 2: Gen 1 performs Appointment ---
|
// --- Step 2: Gen 1 performs Appointment ---
|
||||||
// Before appointment, Gen 0 should FAIL Founding because general count = 1
|
// Before appointment, Gen 0 should FAIL Founding because general count = 1
|
||||||
@@ -280,8 +262,10 @@ describe('Blank Start Scenario', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(world.getGeneral(gen0.id)?.meta.founding).toBe(true);
|
const nationAfterFounding = world.getNation(newNationId)!;
|
||||||
expect(world.getGeneral(gen0.id)?.meta.foundingArgs).toEqual(FOUNDING_ARGS);
|
expect(nationAfterFounding.level).toBe(1);
|
||||||
|
expect(nationAfterFounding.name).toBe(FOUNDING_ARGS.nationName);
|
||||||
|
expect(nationAfterFounding.capitalCityId).toBe(gen0.cityId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail founding if city is not level 5 or 6', async () => {
|
it('should fail founding if city is not level 5 or 6', async () => {
|
||||||
|
|||||||
@@ -84,8 +84,13 @@ export class InMemoryWorld {
|
|||||||
this.updateNation(resolution.nation);
|
this.updateNation(resolution.nation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolution.created && resolution.created.generals) {
|
if (resolution.created) {
|
||||||
this.snapshot.generals.push(...resolution.created.generals);
|
if (resolution.created.generals) {
|
||||||
|
this.snapshot.generals.push(...resolution.created.generals);
|
||||||
|
}
|
||||||
|
if (resolution.created.nations) {
|
||||||
|
this.snapshot.nations.push(...resolution.created.nations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolution.effects) {
|
if (resolution.effects) {
|
||||||
@@ -177,6 +182,9 @@ export class TestGameRunner {
|
|||||||
this.currentDate = new Date(startYear, startMonth - 1);
|
this.currentDate = new Date(startYear, startMonth - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextGeneralId = 1;
|
||||||
|
nextNationId = 1;
|
||||||
|
|
||||||
async runTurn(commands: TestCommand[]) {
|
async runTurn(commands: TestCommand[]) {
|
||||||
const schedule: TurnSchedule = {
|
const schedule: TurnSchedule = {
|
||||||
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
||||||
@@ -212,6 +220,21 @@ export class TestGameRunner {
|
|||||||
map: this.world.snapshot.map,
|
map: this.world.snapshot.map,
|
||||||
unitSet: this.world.snapshot.unitSet,
|
unitSet: this.world.snapshot.unitSet,
|
||||||
cities: this.world.snapshot.cities,
|
cities: this.world.snapshot.cities,
|
||||||
|
nations: this.world.snapshot.nations,
|
||||||
|
createGeneralId: () => {
|
||||||
|
if (this.nextGeneralId === 1) {
|
||||||
|
const ids = this.world.getAllGenerals().map((g) => g.id);
|
||||||
|
this.nextGeneralId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
||||||
|
}
|
||||||
|
return this.nextGeneralId++;
|
||||||
|
},
|
||||||
|
createNationId: () => {
|
||||||
|
if (this.nextNationId === 1) {
|
||||||
|
const ids = this.world.getAllNations().map((n) => n.id);
|
||||||
|
this.nextNationId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
||||||
|
}
|
||||||
|
return this.nextNationId++;
|
||||||
|
},
|
||||||
...cmd.context,
|
...cmd.context,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user