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

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
+18 -1
View File
@@ -91,6 +91,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
| GeneralAddEffect<TriggerState>
| 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<General> }>;
@@ -165,6 +167,16 @@ export const createNationPatchEffect = (patch: Partial<Nation>, 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 = <TriggerState extends GeneralTriggerState =
const logs: LogEntryDraft[] = [];
let nextTurnAtOverride: Date | null = null;
const createdGenerals: General[] = [];
const createdNations: Nation[] = [];
const patches: NonNullable<GeneralActionResolution['patches']> = {
generals: [],
cities: [],
@@ -284,6 +297,9 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
case 'general:add':
createdGenerals.push(effect.general as General);
break;
case 'nation:add':
createdNations.push(effect.nation as Nation);
break;
case 'diplomacy:patch':
pendingEffects.push(effect);
break;
@@ -366,9 +382,10 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
if (patches.generals.length > 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 } : {}),
};
}
@@ -64,6 +64,7 @@ export interface ActionContextOptions {
worldRef: ActionContextWorldRef | null;
actionArgs: Record<string, unknown>;
createGeneralId: () => number;
createNationId: () => number;
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 { 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<TriggerState> {
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<TriggerState>({
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_거병',
@@ -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<TriggerState, FoundingArgs> {
@@ -57,20 +98,67 @@ export class ActionDefinition<
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
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<TriggerState>({
experience: (general.experience || 0) + 1000,
dedication: (general.dedication || 0) + 1000,
}),
];
return { effects };
}
}
+9 -12
View File
@@ -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: '초반이 지났습니다.' };
+2 -2
View File
@@ -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();
@@ -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 () => {
+25 -2
View File
@@ -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,
};