merge: register delayed scenario NPCs

This commit is contained in:
2026-07-25 18:40:17 +00:00
6 changed files with 1218 additions and 6 deletions
@@ -0,0 +1,383 @@
import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import {
DOMESTIC_TRAIT_KEYS,
LogCategory,
LogFormat,
LogScope,
PERSONALITY_TRAIT_KEYS,
WAR_TRAIT_KEYS,
type TurnCommandEnv,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { MonthlyEventActionHandler } from './monthlyEventHandler.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import type { TurnGeneral } from './types.js';
type RegisterNpcActionName = 'RegNPC' | 'RegNeutralNPC';
interface RegisterNpcArguments {
affinity: number;
name: string;
picture: number | string | null;
nationId: number;
city: number | string | null;
leadership: number;
strength: number;
intelligence: number;
officerLevel: number | null;
birthYear: number;
deathYear: number;
personality: string | null;
special: string | null;
text: string;
}
const ADULT_AGE = 14;
const readInteger = (value: unknown, label: string, fallback?: number): number => {
if (value === undefined && fallback !== undefined) {
return fallback;
}
const parsed =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: Number.NaN;
if (!Number.isInteger(parsed)) {
throw new Error(`${label} must be an integer.`);
}
return parsed;
};
const readName = (value: unknown): string => {
if (typeof value !== 'string') {
throw new Error('NPC name must be a string.');
}
return value;
};
const readNullableString = (value: unknown, label: string): string | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string or null.`);
}
return value;
};
const readPicture = (value: unknown): number | string | null => {
if (value === null || value === undefined || typeof value === 'number' || typeof value === 'string') {
return value ?? null;
}
throw new Error('NPC picture must be a number, string, or null.');
};
const readCity = (value: unknown): number | string | null => {
if (value === null || value === undefined || typeof value === 'number' || typeof value === 'string') {
return value ?? null;
}
throw new Error('NPC city must be a number, string, or null.');
};
const parseArguments = (actionName: RegisterNpcActionName, args: readonly unknown[]): RegisterNpcArguments => {
if (actionName === 'RegNPC') {
return {
affinity: readInteger(args[0], 'RegNPC affinity'),
name: readName(args[1]),
picture: readPicture(args[2]),
nationId: readInteger(args[3], 'RegNPC nationId'),
city: readCity(args[4]),
leadership: readInteger(args[5], 'RegNPC leadership'),
strength: readInteger(args[6], 'RegNPC strength'),
intelligence: readInteger(args[7], 'RegNPC intelligence'),
officerLevel: readInteger(args[8], 'RegNPC officerLevel'),
birthYear: readInteger(args[9], 'RegNPC birthYear', 160),
deathYear: readInteger(args[10], 'RegNPC deathYear', 300),
personality: readNullableString(args[11], 'RegNPC personality'),
special: readNullableString(args[12], 'RegNPC special'),
text: readNullableString(args[13], 'RegNPC text') || '',
};
}
return {
affinity: readInteger(args[0], 'RegNeutralNPC affinity'),
name: readName(args[1]),
picture: readPicture(args[2]),
nationId: readInteger(args[3], 'RegNeutralNPC nationId'),
city: readCity(args[4]),
leadership: readInteger(args[5], 'RegNeutralNPC leadership'),
strength: readInteger(args[6], 'RegNeutralNPC strength'),
intelligence: readInteger(args[7], 'RegNeutralNPC intelligence'),
officerLevel: null,
birthYear: readInteger(args[8], 'RegNeutralNPC birthYear', 160),
deathYear: readInteger(args[9], 'RegNeutralNPC deathYear', 300),
personality: readNullableString(args[10], 'RegNeutralNPC personality'),
special: readNullableString(args[11], 'RegNeutralNPC special'),
text: readNullableString(args[12], 'RegNeutralNPC text') || '',
};
};
const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
const state = world.getState();
const value = state.meta.hiddenSeed ?? state.meta.seed ?? state.id;
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
};
const resolvePersonality = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const key = raw.startsWith('che_') ? raw : `che_${raw}`;
if (!PERSONALITY_TRAIT_KEYS.includes(key as (typeof PERSONALITY_TRAIT_KEYS)[number])) {
throw new Error(`Unknown NPC personality: ${raw}`);
}
return key;
};
const resolveSpecial = (
raw: string | null,
env: TurnCommandEnv
): { specialDomestic: string | null; specialWar: string | null } => {
if (raw === null || raw === '' || raw === 'None') {
return {
specialDomestic: env.defaultSpecialDomestic,
specialWar: env.defaultSpecialWar,
};
}
const key = raw.startsWith('che_') ? raw : `che_${raw}`;
if (DOMESTIC_TRAIT_KEYS.includes(key as (typeof DOMESTIC_TRAIT_KEYS)[number])) {
return { specialDomestic: key, specialWar: env.defaultSpecialWar };
}
if (WAR_TRAIT_KEYS.includes(key as (typeof WAR_TRAIT_KEYS)[number])) {
return { specialDomestic: env.defaultSpecialDomestic, specialWar: key };
}
throw new Error(`Unknown NPC speciality: ${raw}`);
};
const resolveCityId = (world: InMemoryTurnWorld, raw: number | string | null): number | null => {
if (raw === null) {
return null;
}
const city =
typeof raw === 'number'
? world.getCityById(raw)
: world.listCities().find((candidate) => candidate.name === raw) ?? null;
if (!city) {
throw new Error(`Unknown NPC city: ${String(raw)}`);
}
return city.id;
};
const resolvePicture = (
raw: number | string | null,
showImgLevel: number,
iconPath: string
): string => {
if (showImgLevel < 3 || raw === null || (typeof raw === 'number' && raw < 0)) {
return 'default.jpg';
}
if (typeof raw === 'number') {
return `${raw}.jpg`;
}
if (iconPath !== '' && iconPath !== '.' && !raw.includes('/')) {
return `${iconPath}/${raw}`;
}
return raw;
};
const buildSpecialityAge = (
retirementYear: number,
age: number,
relativeYear: number,
divisor: number
): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
const resolveRuntimeNumber = (
source: Record<string, unknown> | undefined,
key: string,
fallback: number
): number => {
const value = source?.[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
export const createRegisterNpcHandler = (options: {
actionName: RegisterNpcActionName;
getWorld: () => InMemoryTurnWorld | null;
reservedTurns: InMemoryReservedTurnStore;
env: TurnCommandEnv;
worldConfig?: Record<string, unknown>;
scenarioFiction?: number | null;
}): MonthlyEventActionHandler => {
const npcType = options.actionName === 'RegNPC' ? 2 : 6;
const namePrefix = npcType === 2 ? 'ⓝ' : 'ⓤ';
return (args, environment) => {
const world = options.getWorld();
if (!world) {
return;
}
const parsed = parseArguments(options.actionName, args);
const explicitCityId = resolveCityId(world, parsed.city);
const explicitPersonality = resolvePersonality(parsed.personality);
const special = resolveSpecial(parsed.special, options.env);
const rng = new RandUtil(
new LiteHashDRBG(
simpleSerialize(
resolveHiddenSeed(world),
options.actionName,
parsed.name,
parsed.nationId,
parsed.leadership,
parsed.strength,
parsed.intelligence
)
)
);
const affinity =
parsed.affinity < 1
? rng.nextRangeInt(1, 150)
: parsed.affinity >= 900
? 999
: parsed.affinity <= 150
? parsed.affinity
: null;
if (affinity === null) {
throw new Error(`Invalid NPC affinity: ${parsed.affinity}`);
}
const configValues = asRecord(world.getScenarioConfig().const);
const retirementYear = resolveRuntimeNumber(configValues, 'retirementYear', 80);
const age = environment.year - parsed.birthYear;
const relativeYear = Math.max(environment.year - environment.startyear, 0);
const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12);
const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6);
const personality =
explicitPersonality ?? rng.choice(options.env.availablePersonalities ?? ['che_안전']);
if (parsed.deathYear <= environment.year || age < ADULT_AGE) {
return;
}
const isNewGeneral = age === ADULT_AGE;
const fiction = resolveRuntimeNumber(
options.worldConfig,
'fiction',
options.scenarioFiction ?? 0
);
let nationId = parsed.nationId;
if ((fiction !== 0 && isNewGeneral) || !world.getNationById(nationId)) {
nationId = 0;
}
const initialOfficerLevel =
parsed.officerLevel === null ? (parsed.nationId !== 0 ? 1 : 0) : parsed.officerLevel;
const officerLevel = !initialOfficerLevel || isNewGeneral ? (nationId !== 0 ? 1 : 0) : initialOfficerLevel;
const name = `${namePrefix}${parsed.name}`;
if (isNewGeneral) {
const josaYi = JosaUtil.pick(name, '이');
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
text: `<Y>${name}</>${josaYi} 성인이 되어 <S>등장</>했습니다.`,
format: LogFormat.MONTH,
year: environment.year,
month: environment.month,
});
}
let cityId = explicitCityId;
if (cityId === null) {
const nationCities =
nationId === 0 ? [] : world.listCities().filter((city) => city.nationId === nationId);
const candidates = nationCities.length > 0 ? nationCities : world.listCities();
if (candidates.length === 0) {
throw new Error(`${options.actionName} requires at least one city.`);
}
cityId = rng.choice(candidates).id;
}
const turnMinutes = world.getState().tickSeconds / 60;
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
throw new Error(`${options.actionName} requires a positive integer turn term.`);
}
const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1);
const turnFraction = rng.nextRangeInt(0, 999_999);
const turnTime = new Date(
environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
const killturn =
(parsed.deathYear - environment.year) * 12 +
rng.nextRangeInt(0, 11) +
environment.month -
1;
const id = world.getNextGeneralId();
const showImgLevel = resolveRuntimeNumber(options.worldConfig, 'showImgLevel', 3);
const general: TurnGeneral = {
id,
userId: null,
name,
nationId,
cityId,
troopId: 0,
stats: {
leadership: parsed.leadership,
strength: parsed.strength,
intelligence: parsed.intelligence,
},
experience: age * 100,
dedication: age * 100,
officerLevel,
role: {
personality,
...special,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: options.env.defaultCrewTypeId,
train: 0,
atmos: 0,
age,
npcState: npcType,
bornYear: parsed.birthYear,
deadYear: parsed.deathYear,
affinity,
picture: resolvePicture(parsed.picture, showImgLevel, world.getScenarioConfig().iconPath),
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
lastTurn: { command: '휴식' },
turnTime,
recentWarTime: null,
meta: {
killturn,
npcType,
npc_org: npcType,
belong: 0,
dedlevel: 1,
specage: specAge,
specage2: specAge2,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
text: parsed.text,
},
};
if (!world.addGeneral(general)) {
throw new Error(`${options.actionName} generated a duplicate general id: ${id}`);
}
options.reservedTurns.ensureGeneralTurns(id);
};
};
+19 -1
View File
@@ -52,6 +52,7 @@ import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js';
import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js';
import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js';
import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js';
import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
@@ -155,7 +156,11 @@ const createTurnDaemonRuntimeWithLease = async (
Array.isArray(event.action) &&
event.action.some((action) => Array.isArray(action) && action[0] === name)
);
const eventRequiresReservedTurns = hasEventAction('UpdateNationLevel') || hasEventAction('CreateManyNPC');
const eventRequiresReservedTurns =
hasEventAction('UpdateNationLevel') ||
hasEventAction('CreateManyNPC') ||
hasEventAction('RegNPC') ||
hasEventAction('RegNeutralNPC');
const reservedTurnStoreHandle =
options.generalTurnHandler && !eventRequiresReservedTurns
? null
@@ -232,6 +237,19 @@ const createTurnDaemonRuntimeWithLease = async (
env: monthlyCommandEnv,
})
);
for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) {
eventActions.set(
actionName,
createRegisterNpcHandler({
actionName,
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
worldConfig: snapshot.worldConfig,
scenarioFiction: snapshot.scenarioMeta?.fiction,
})
);
}
eventActions.set(
'UpdateNationLevel',
createUpdateNationLevelHandler({
@@ -0,0 +1,374 @@
import { describe, expect, it, vi } from 'vitest';
import { PERSONALITY_TRAIT_KEYS, type City, type Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createRegisterNpcHandler } from '../src/turn/monthlyRegisterNpcAction.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildCity = (id: number, nationId: number): City => ({
id,
name: `도시${id}`,
nationId,
level: 4,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
});
const buildNation = (id: number): Nation => ({
id,
name: `국가${id}`,
color: '#777777',
capitalCityId: id,
chiefGeneralId: null,
gold: 1_000,
rice: 1_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
});
const event: TurnEvent = {
id: 1,
targetCode: 'month',
priority: 1_000,
condition: true,
action: [],
meta: {},
};
const buildHarness = (options: { fiction?: number; seed?: string; fixtureCity?: boolean } = {}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { hiddenSeed: options.seed ?? 'register-npc-fixture' },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
retirementYear: 80,
availablePersonality: ['che_안전', 'che_유지'],
},
environment: { mapName: 'test', unitSet: 'default' },
},
scenarioMeta: {
title: 'test',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
worldConfig: { fiction: options.fiction ?? 0, showImgLevel: 3 },
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [],
cities: [
buildCity(1, 1),
buildCity(2, 1),
buildCity(3, 0),
...(options.fixtureCity ? [buildCity(33, 1)] : []),
],
nations: [buildNation(1)],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const prisma = {
generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
nationTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
};
const reservedTurns = new InMemoryReservedTurnStore(prisma as never, {
maxGeneralTurns: 30,
maxNationTurns: 12,
});
const env = buildCommandEnv(snapshot.scenarioConfig);
const environment = {
year: 200,
month: 1,
startyear: 190,
currentEventID: 1,
turnTime: state.lastTurnTime,
};
return { world, reservedTurns, env, environment, snapshot };
};
describe('RegNPC and RegNeutralNPC monthly actions', () => {
it('registers a 14-year-old NPC with the legacy prefix, officer override, log, and reserved turns', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness();
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler(
[77, '등장장수', null, 1, '도시2', 60, 50, 40, 7, 186, 240, '유지', '인덕', '대사'],
environment,
event
);
const created = world.peekDirtyState().createdGenerals[0]!;
expect(created).toMatchObject({
name: 'ⓝ등장장수',
nationId: 1,
cityId: 2,
stats: { leadership: 60, strength: 50, intelligence: 40 },
officerLevel: 1,
experience: 1_400,
dedication: 1_400,
age: 14,
npcState: 2,
bornYear: 186,
deadYear: 240,
affinity: 77,
picture: 'default.jpg',
role: {
personality: 'che_유지',
specialDomestic: 'che_인덕',
specialWar: null,
},
meta: {
npcType: 2,
npc_org: 2,
text: '대사',
},
});
expect(reservedTurns.getGeneralTurns(created.id)).toHaveLength(30);
expect(world.peekDirtyState().logs).toEqual([
expect.objectContaining({
text: '<Y>ⓝ등장장수</>가 성인이 되어 <S>등장</>했습니다.',
year: 200,
month: 1,
}),
]);
});
it('uses the neutral prefix/type and the legacy fixed RNG order for implicit fields', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness();
const handler = createRegisterNpcHandler({
actionName: 'RegNeutralNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler([0, '재야장수', null, 0, null, 45, 55, 65, 180, 245, null, '무쌍', ''], environment, event);
const created = world.peekDirtyState().createdGenerals[0]!;
expect({
name: created.name,
nationId: created.nationId,
cityId: created.cityId,
officerLevel: created.officerLevel,
affinity: created.affinity,
personality: created.role.personality,
specialDomestic: created.role.specialDomestic,
specialWar: created.role.specialWar,
turnTime: created.turnTime.toISOString(),
killturn: created.meta.killturn,
npcType: created.npcState,
npcOrg: created.meta.npc_org,
}).toMatchInlineSnapshot(`
{
"affinity": 47,
"cityId": 1,
"killturn": 547,
"name": "ⓤ재야장수",
"nationId": 0,
"npcOrg": 6,
"npcType": 6,
"officerLevel": 0,
"personality": "che_안전",
"specialDomestic": null,
"specialWar": "che_무쌍",
"turnTime": "0200-01-01T00:04:36.545Z",
}
`);
});
it('forces a newly adult general to neutral in fiction mode', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness({ fiction: 1 });
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler([10, '가상장수', null, 1, null, 50, 50, 50, 5, 186, 240, '안전', '', ''], environment, event);
expect(world.peekDirtyState().createdGenerals[0]).toMatchObject({
name: 'ⓝ가상장수',
nationId: 0,
officerLevel: 0,
});
});
it('projects legacy numeric icon indexes to shared jpg paths', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness();
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler([10, '도상', 1001, 0, 1, 50, 50, 50, 0, 180, 240, '안전', '', ''], environment, event);
expect(world.peekDirtyState().createdGenerals[0]?.picture).toBe('1001.jpg');
});
it('consumes fill-time random values but does not insert a dead or underage general', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness();
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler([0, '사망', null, 1, null, 50, 50, 50, 1, 170, 200, null, '', ''], environment, event);
await handler([0, '미성년', null, 1, null, 50, 50, 50, 1, 190, 250, null, '', ''], environment, event);
expect(world.peekDirtyState().createdGenerals).toEqual([]);
expect(world.peekDirtyState().logs).toEqual([]);
expect(reservedTurns.peekDirtyState().generalInitializationIds).toEqual([]);
});
it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the fixed-seed legacy RegNPC fixture', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness({
seed: process.env.REF_HIDDEN_SEED,
fixtureCity: true,
});
env.availablePersonalities = [...PERSONALITY_TRAIT_KEYS];
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler(
[0, '등록장수', null, 0, 33, 60, 50, 40, 7, 186, 240, null, '인덕', '등록 대사'],
environment,
event
);
const created = world.peekDirtyState().createdGenerals[0]!;
expect({
name: created.name,
nationId: created.nationId,
cityId: created.cityId,
officerLevel: created.officerLevel,
affinity: created.affinity,
personality: created.role.personality,
specialDomestic: created.role.specialDomestic,
specialWar: created.role.specialWar,
specAge: created.meta.specage,
specAge2: created.meta.specage2,
turnTime: created.turnTime.toISOString(),
killturn: created.meta.killturn,
}).toEqual({
name: 'ⓝ등록장수',
nationId: 0,
cityId: 33,
officerLevel: 0,
affinity: 126,
personality: 'che_출세',
specialDomestic: 'che_인덕',
specialWar: null,
specAge: 17,
specAge2: 20,
turnTime: '0200-01-01T00:09:00.782Z',
killturn: 489,
});
});
it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the fixed-seed legacy RegNeutralNPC fixture', async () => {
const { world, reservedTurns, env, environment, snapshot } = buildHarness({
seed: process.env.REF_HIDDEN_SEED,
fixtureCity: true,
});
env.availablePersonalities = [...PERSONALITY_TRAIT_KEYS];
const handler = createRegisterNpcHandler({
actionName: 'RegNeutralNPC',
getWorld: () => world,
reservedTurns,
env,
worldConfig: snapshot.worldConfig,
});
await handler(
[0, '등록재야', null, 0, 33, 45, 55, 65, 180, 245, null, '무쌍', ''],
environment,
event
);
const created = world.peekDirtyState().createdGenerals[0]!;
expect({
name: created.name,
nationId: created.nationId,
cityId: created.cityId,
officerLevel: created.officerLevel,
affinity: created.affinity,
personality: created.role.personality,
specialDomestic: created.role.specialDomestic,
specialWar: created.role.specialWar,
specAge: created.meta.specage,
specAge2: created.meta.specage2,
turnTime: created.turnTime.toISOString(),
killturn: created.meta.killturn,
}).toEqual({
name: 'ⓤ등록재야',
nationId: 0,
cityId: 33,
officerLevel: 0,
affinity: 103,
personality: 'che_출세',
specialDomestic: null,
specialWar: 'che_무쌍',
specAge: 23,
specAge2: 25,
turnTime: '0200-01-01T00:09:50.378Z',
killturn: 540,
});
});
});
@@ -0,0 +1,225 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import type { City } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createRegisterNpcHandler } from '../src/turn/monthlyRegisterNpcAction.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const cityId = 990_082;
const createdGeneralId = 990_082;
const city: City = {
id: cityId,
name: '등록NPC저장도시',
nationId: 0,
level: 4,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
};
const event: TurnEvent = {
id: 1,
targetCode: 'month',
priority: 1_000,
condition: true,
action: [['RegNPC']],
meta: {},
};
integration('RegNPC database persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const clean = async () => {
await db.logEntry.deleteMany({
where: {
OR: [
{ generalId: createdGeneralId },
{ year: 200, month: 1, text: { contains: 'ⓝ저장장수' } },
],
},
});
await db.generalTurn.deleteMany({ where: { generalId: createdGeneralId } });
await db.rankData.deleteMany({ where: { generalId: createdGeneralId } });
await db.general.deleteMany({ where: { id: createdGeneralId } });
await db.city.deleteMany({ where: { id: cityId } });
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await clean();
});
afterAll(async () => {
await clean();
await closeDb?.();
});
it('commits the registered general, resting turns, ranks, and adult log atomically', async () => {
await db.city.create({
data: {
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
supplyState: city.supplyState,
frontState: city.frontState,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
trust: 50,
trade: 100,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
region: 1,
conflict: {},
meta: {},
},
});
const stateRow = await db.worldState.create({
data: {
scenarioCode: 'monthly-register-npc-persistence',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: { hiddenSeed: 'register-npc-persistence', lastGeneralId: createdGeneralId - 1 },
},
});
const state: TurnWorldState = {
id: stateRow.id,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { hiddenSeed: 'register-npc-persistence', lastGeneralId: createdGeneralId - 1 },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
iconPath: '.',
map: {},
const: { retirementYear: 80, availablePersonality: ['che_안전'] },
environment: { mapName: 'test', unitSet: 'default' },
},
scenarioMeta: {
title: 'test',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
worldConfig: { fiction: 0, showImgLevel: 3 },
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [],
cities: [city],
nations: [],
troops: [],
diplomacy: [],
events: [event],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 });
const handler = createRegisterNpcHandler({
actionName: 'RegNPC',
getWorld: () => world,
reservedTurns,
env: buildCommandEnv(snapshot.scenarioConfig),
worldConfig: snapshot.worldConfig,
});
const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
await handler(
[77, '저장장수', 1001, 0, cityId, 60, 50, 40, 7, 186, 240, '유지', '인덕', '대사'],
{
year: 200,
month: 1,
startyear: 190,
currentEventID: 1,
turnTime: state.lastTurnTime,
},
event
);
await dbHooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
});
expect(await db.general.findUniqueOrThrow({ where: { id: createdGeneralId } })).toMatchObject({
name: 'ⓝ저장장수',
nationId: 0,
cityId,
officerLevel: 0,
npcState: 2,
affinity: 77,
bornYear: 186,
deadYear: 240,
picture: '1001.jpg',
experience: 1_400,
dedication: 1_400,
personalCode: 'che_유지',
specialCode: 'che_인덕',
});
const turns = await db.generalTurn.findMany({ where: { generalId: createdGeneralId } });
expect(turns).toHaveLength(30);
expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['휴식']));
const ranks = await db.rankData.findMany({ where: { generalId: createdGeneralId } });
expect(ranks).toHaveLength(41);
expect(ranks.every((rank) => rank.nationId === 0 && rank.value === 0)).toBe(true);
expect(
await db.logEntry.findFirst({
where: { year: 200, month: 1, text: { contains: 'ⓝ저장장수' } },
})
).toMatchObject({
category: 'ACTION',
text: expect.stringContaining('성인이 되어'),
});
} finally {
await dbHooks.close();
await db.worldState.delete({ where: { id: stateRow.id } });
}
});
});
+98 -5
View File
@@ -161,6 +161,64 @@ const resolveAge = (startYear: number | null, birthYear: number): number => {
return Math.max(startYear - birthYear, 0);
};
const ADULT_GENERAL_AGE = 14;
type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired';
const resolveGeneralBootstrapDisposition = (
general: ScenarioGeneral,
startYear: number | null
): GeneralBootstrapDisposition => {
if (startYear === null) {
return 'active';
}
if (general.deathYear <= startYear) {
return 'expired';
}
if (startYear - general.birthYear < ADULT_GENERAL_AGE) {
return 'delayed';
}
return 'active';
};
const buildDelayedGeneralAction = (
general: ScenarioGeneral,
nationId: number,
npcType: 2 | 6
): unknown[] => {
const common = [
general.affinity ?? 0,
general.name,
general.picture,
nationId,
general.city,
general.leadership,
general.strength,
general.intelligence,
];
if (npcType === 2) {
return [
'RegNPC',
...common,
general.officerLevel,
general.birthYear,
general.deathYear,
general.personality,
general.special,
general.text ?? '',
];
}
return [
'RegNeutralNPC',
...common,
general.birthYear,
general.deathYear,
general.personality,
general.special,
general.text ?? '',
];
};
const resolveOfficerLevel = (officerLevel: number, nationId: number): number => {
if (officerLevel > 0) {
return officerLevel;
@@ -590,9 +648,33 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
let nextGeneralId = 1;
const allGeneralSeeds: GeneralSeed[] = [];
const allGenerals: General[] = [];
const delayedActionsByBirthYear = new Map<number, unknown[][]>();
const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => {
const active: ScenarioGeneral[] = [];
for (const general of rows) {
const disposition = resolveGeneralBootstrapDisposition(general, scenario.startYear);
if (disposition === 'active') {
active.push(general);
continue;
}
if (disposition === 'expired') {
continue;
}
const nationId = resolveNationId(general.nation, nationNameToId, warnings, general.name);
const actions = delayedActionsByBirthYear.get(general.birthYear) ?? [];
actions.push(buildDelayedGeneralAction(general, nationId, npcType));
delayedActionsByBirthYear.set(general.birthYear, actions);
}
return active;
};
const activeGenerals = partitionGenerals(scenario.generals, 2);
const activeGeneralsEx = partitionGenerals(scenario.generalsEx, 2);
const activeGeneralsNeutral = partitionGenerals(scenario.generalsNeutral, 6);
const generalResult = buildGeneralSeeds(
scenario.generals,
activeGenerals,
2,
nextGeneralId,
'general',
@@ -608,7 +690,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
nextGeneralId = generalResult.nextId;
const generalExResult = buildGeneralSeeds(
scenario.generalsEx,
activeGeneralsEx,
2,
nextGeneralId,
'general_ex',
@@ -624,7 +706,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
nextGeneralId = generalExResult.nextId;
const generalNeutralResult = buildGeneralSeeds(
scenario.generalsNeutral,
activeGeneralsNeutral,
6,
nextGeneralId,
'general_neutral',
@@ -638,6 +720,17 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
allGeneralSeeds.push(...generalNeutralResult.seeds);
allGenerals.push(...generalNeutralResult.generals);
const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map(
([birthYear, actions]) => [
'Month',
1_000,
['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1],
...actions,
['DeleteEvent'],
]
);
const events = [...scenario.events, ...delayedGeneralEvents];
const seed: WorldSeedPayload = {
scenarioConfig: scenario.config,
scenarioMeta,
@@ -648,7 +741,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
generals: allGeneralSeeds,
troops: [],
diplomacy: scenario.diplomacy,
events: scenario.events,
events,
initialEvents: scenario.initialEvents,
};
@@ -662,7 +755,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
generals: allGenerals,
troops: [],
diplomacy: scenario.diplomacy,
events: scenario.events,
events,
initialEvents: scenario.initialEvents,
};
+119
View File
@@ -122,4 +122,123 @@ describe('scenario bootstrap', () => {
expect(result.seed.generals[0]?.npcType).toBe(2);
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
});
it('defers future generals into birth-year registration events and omits expired rows', () => {
const general = (
name: string,
birthYear: number,
deathYear: number,
nation: number | string | null = 1
): ScenarioDefinition['generals'][number] => ({
affinity: 0,
name,
picture: null,
nation,
city: null,
leadership: 50,
strength: 60,
intelligence: 40,
officerLevel: 3,
birthYear,
deathYear,
personality: null,
special: '',
text: '',
});
const scenario: ScenarioDefinition = {
title: 'Delayed generals',
startYear: 200,
life: null,
fiction: null,
history: [],
config: {
stat: { total: 100, min: 10, max: 70, npcTotal: 80, npcMax: 60, npcMin: 5, chiefMin: 50 },
iconPath: '.',
map: {},
const: {},
environment: { mapName: 'test-map', unitSet: 'test-unit' },
},
nations: [
{
id: 1,
name: 'TestNation',
color: '#123456',
gold: 5_000,
rice: 3_000,
infoText: '',
tech: 100,
type: 'Test',
level: 3,
cities: ['Alpha'],
},
],
diplomacy: [],
generals: [
general('현재', 180, 240),
general('미래1', 190, 250, 'TestNation'),
general('만료', 170, 200),
],
generalsEx: [general('미래확장', 190, 260)],
generalsNeutral: [general('미래재야', 191, 260, 0)],
cities: [],
events: [['Month', 500, ['Date', '>=', 200, 1], ['Existing']]],
initialEvents: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = {
id: 'test-map',
name: 'test-map',
cities: [
{
id: 1,
name: 'Alpha',
level: 3,
region: 1,
position: { x: 0, y: 0 },
connections: [],
max: {
population: 100_000,
agriculture: 20_000,
commerce: 20_000,
security: 15_000,
defence: 5_000,
wall: 3_000,
},
initial: {
population: 50_000,
agriculture: 10_000,
commerce: 10_000,
security: 8_000,
defence: 2_500,
wall: 1_500,
},
},
],
};
const result = buildScenarioBootstrap({ scenario, map });
expect(result.seed.generals.map((row) => row.name)).toEqual(['현재']);
expect(result.snapshot.generals.map((row) => row.name)).toEqual(['현재']);
expect(result.seed.events).toEqual([
['Month', 500, ['Date', '>=', 200, 1], ['Existing']],
[
'Month',
1_000,
['Date', '>=', 204, 1],
['RegNPC', 0, '미래1', null, 1, null, 50, 60, 40, 3, 190, 250, null, '', ''],
['RegNPC', 0, '미래확장', null, 1, null, 50, 60, 40, 3, 190, 260, null, '', ''],
['DeleteEvent'],
],
[
'Month',
1_000,
['Date', '>=', 205, 1],
['RegNeutralNPC', 0, '미래재야', null, 0, null, 50, 60, 40, 191, 260, null, '', ''],
['DeleteEvent'],
],
]);
expect(result.snapshot.events).toEqual(result.seed.events);
expect(result.seed.events.flat(3)).not.toContain('만료');
});
});