diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index e38bc41e..4e2736d7 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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) => diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index f267ac67..b208beb4 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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(); private readonly dirtyDiplomacyKeys = new Set(); private readonly createdGeneralIds = new Set(); + private readonly createdNationIds = new Set(); private readonly createdTroopIds = new Set(); private readonly createdDiplomacyKeys = new Set(); private readonly deletedTroopIds = new Set(); @@ -358,6 +360,44 @@ export class InMemoryTurnWorld { }; } + getNextNationId(): number { + const meta = this.state.meta as Record; + 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; + 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, }; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index eae1eb0f..27dd6c94 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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; diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index c87c8790..c8b3fee8 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -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 + }); + }); }); diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index fbd97a85..5feda1aa 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -91,6 +91,7 @@ export type GeneralActionEffect | CityPatchEffect | NationPatchEffect + | NationAddEffect | DiplomacyPatchEffect | LogEffect | NextTurnOverrideEffect; @@ -117,6 +118,7 @@ export interface GeneralActionResolution { effects: GeneralActionEffect[]; created?: { generals: General[]; + nations?: Nation[]; }; patches?: { generals: Array<{ id: GeneralId; patch: Partial }>; @@ -165,6 +167,16 @@ export const createNationPatchEffect = (patch: Partial, targetId?: Natio ...(targetId !== undefined ? { targetId } : {}), }); +export interface NationAddEffect { + type: 'nation:add'; + nation: Nation; +} + +export const createNationAddEffect = (nation: Nation): NationAddEffect => ({ + type: 'nation:add', + nation, +}); + export const createDiplomacyPatchEffect = ( srcNationId: NationId, destNationId: NationId, @@ -206,6 +218,7 @@ export const resolveGeneralAction = = { generals: [], cities: [], @@ -284,6 +297,9 @@ export const resolveGeneralAction = 0 || patches.cities.length > 0 || patches.nations.length > 0) { resolution.patches = patches; } - if (createdGenerals.length > 0) { + if (createdGenerals.length > 0 || createdNations.length > 0) { resolution.created = { generals: createdGenerals, + ...(createdNations.length > 0 ? { nations: createdNations } : {}), }; } diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 99e96bbc..3584f0f3 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -64,6 +64,7 @@ export interface ActionContextOptions { worldRef: ActionContextWorldRef | null; actionArgs: Record; createGeneralId: () => number; + createNationId: () => number; seedBase: string; } diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index dc47cfa3..5a300f05 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -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 { beNeutral } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.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 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'; export interface UprisingArgs {} +export interface UprisingContext extends ActionContextBase { + createNationId: () => number; + listNations?: () => Nation[]; +} + const ACTION_NAME = '거병'; export class ActionDefinition< @@ -19,7 +25,6 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(_raw: unknown): UprisingArgs | null { - void _raw; return {}; } @@ -32,24 +37,92 @@ export class ActionDefinition< _args: UprisingArgs ): GeneralActionOutcome { const general = context.general; + const uprisingCtx = context as unknown as UprisingContext; - // 직접 수정 (Immer Draft) - general.meta = { - ...(general.meta as object), - uprising: true as TriggerValue, + if (!uprisingCtx.createNationId) { + throw new Error('createNationId is not defined in context'); + } + + 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, 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({ + nationId: newNationId, + officerLevel: 12, + experience: (general.experience || 0) + 100, + dedication: (general.dedication || 0) + 100, + }), + ]; + + return { effects }; } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + return { + ...base, + createNationId: options.createNationId, + listNations: () => options.worldRef?.listNations() ?? [], + }; +}; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_거병', diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index 9ecf0433..28536e87 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -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 { beMonarch, beWanderingNation, - reqNationGeneralCount, - beOpeningPart, beNeutralCity, reqCityLevel, + reqNationGeneralCount, checkNationNameDuplicate, + beOpeningPart, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -24,6 +29,42 @@ export interface FoundingArgs { 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< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { @@ -57,20 +98,67 @@ export class ActionDefinition< args: FoundingArgs ): GeneralActionOutcome { const general = context.general; + const nation = context.nation!; + const cityId = general.cityId!; - // 직접 수정 (Immer Draft) - general.meta = { - ...(general.meta as object), - founding: true as TriggerValue, - foundingArgs: args as unknown as TriggerValue, // Cast to TriggerValue to solve type mismatch - }; + if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { + throw new Error('Invalid color type'); + } + const color = NATION_COLORS[args.colorType]; - 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, 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({ + experience: (general.experience || 0) + 1000, + dedication: (general.dedication || 0) + 1000, + }), + ]; + + return { effects }; } } diff --git a/packages/logic/src/constraints/misc.ts b/packages/logic/src/constraints/misc.ts index fd1cea17..30298aae 100644 --- a/packages/logic/src/constraints/misc.ts +++ b/packages/logic/src/constraints/misc.ts @@ -21,22 +21,19 @@ export const notOpeningPart = (relYear: number, openingPartYear: number): Constr export const beOpeningPart = (): Constraint => ({ name: 'BeOpeningPart', requires: () => [ - { kind: 'env', key: 'world' }, + { kind: 'env', key: 'year' }, { kind: 'env', key: 'openingPartYear' }, ], test: (_ctx, view) => { - const world = view.get({ kind: 'env', key: 'world' }) as { currentYear: number } | null; - const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | null; - if (!world || openingPartYear === null) { - return { - kind: 'unknown', - missing: [ - { kind: 'env', key: 'world' }, - { kind: 'env', key: 'openingPartYear' }, - ], - }; + const year = view.get({ kind: 'env', key: 'year' }) as number | undefined; + const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | undefined; + + if (year === undefined || openingPartYear === undefined) { + // 정보가 없으면 제약을 무시하거나 알림 + return allow(); } - if (world.currentYear < openingPartYear) { + + if (year <= openingPartYear) { return allow(); } return { kind: 'deny', reason: '초반이 지났습니다.' }; diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index 7c2716a8..d4bda8a0 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -261,12 +261,12 @@ export const reqNationGeneralCount = (min: number): Constraint => ({ export const checkNationNameDuplicate = (name: string): Constraint => ({ name: 'CheckNationNameDuplicate', requires: () => [{ kind: 'nationList' }], - test: (_ctx, view) => { + test: (ctx, view) => { const nations = view.get({ kind: 'nationList' }) as Nation[] | null; if (!nations) { 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 allow(); diff --git a/packages/logic/test/scenarios/blankStart.test.ts b/packages/logic/test/scenarios/blankStart.test.ts index 2f2680ab..b6484ffd 100644 --- a/packages/logic/test/scenarios/blankStart.test.ts +++ b/packages/logic/test/scenarios/blankStart.test.ts @@ -133,6 +133,8 @@ describe('Blank Start Scenario', () => { if (req.kind === 'env') { if (req.key === 'world') return { currentYear: year }; if (req.key === 'openingPartYear') return systemEnv.openingPartYear; + if (req.key === 'relYear') return year - 189; + if (req.key === 'year') return year; } return null; }, @@ -181,33 +183,13 @@ describe('Blank Start Scenario', () => { ]); 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 = 1; - const newNation: Nation = { - id: newNationId, - 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; + const newNationId = gen0AfterUprising.nationId; + const newNation = world.getNation(newNationId)!; + expect(newNation.chiefGeneralId).toBe(gen0.id); + expect(newNation.level).toBe(0); // Wandering Nation // --- Step 2: Gen 1 performs Appointment --- // 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); - expect(world.getGeneral(gen0.id)?.meta.foundingArgs).toEqual(FOUNDING_ARGS); + const nationAfterFounding = world.getNation(newNationId)!; + 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 () => { diff --git a/packages/logic/test/testEnv.ts b/packages/logic/test/testEnv.ts index b91b91fe..59be9a07 100644 --- a/packages/logic/test/testEnv.ts +++ b/packages/logic/test/testEnv.ts @@ -84,8 +84,13 @@ export class InMemoryWorld { this.updateNation(resolution.nation); } - if (resolution.created && resolution.created.generals) { - this.snapshot.generals.push(...resolution.created.generals); + if (resolution.created) { + 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) { @@ -177,6 +182,9 @@ export class TestGameRunner { this.currentDate = new Date(startYear, startMonth - 1); } + nextGeneralId = 1; + nextNationId = 1; + async runTurn(commands: TestCommand[]) { const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }], @@ -212,6 +220,21 @@ export class TestGameRunner { map: this.world.snapshot.map, unitSet: this.world.snapshot.unitSet, 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, };