merge: register delayed scenario NPCs
This commit is contained in:
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -52,6 +52,7 @@ import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js';
|
|||||||
import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js';
|
import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js';
|
||||||
import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js';
|
import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js';
|
||||||
import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js';
|
import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js';
|
||||||
|
import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js';
|
||||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||||
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
@@ -155,7 +156,11 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
Array.isArray(event.action) &&
|
Array.isArray(event.action) &&
|
||||||
event.action.some((action) => Array.isArray(action) && action[0] === name)
|
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 =
|
const reservedTurnStoreHandle =
|
||||||
options.generalTurnHandler && !eventRequiresReservedTurns
|
options.generalTurnHandler && !eventRequiresReservedTurns
|
||||||
? null
|
? null
|
||||||
@@ -232,6 +237,19 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
env: monthlyCommandEnv,
|
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(
|
eventActions.set(
|
||||||
'UpdateNationLevel',
|
'UpdateNationLevel',
|
||||||
createUpdateNationLevelHandler({
|
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 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,6 +161,64 @@ const resolveAge = (startYear: number | null, birthYear: number): number => {
|
|||||||
return Math.max(startYear - birthYear, 0);
|
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 => {
|
const resolveOfficerLevel = (officerLevel: number, nationId: number): number => {
|
||||||
if (officerLevel > 0) {
|
if (officerLevel > 0) {
|
||||||
return officerLevel;
|
return officerLevel;
|
||||||
@@ -590,9 +648,33 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
let nextGeneralId = 1;
|
let nextGeneralId = 1;
|
||||||
const allGeneralSeeds: GeneralSeed[] = [];
|
const allGeneralSeeds: GeneralSeed[] = [];
|
||||||
const allGenerals: General[] = [];
|
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(
|
const generalResult = buildGeneralSeeds(
|
||||||
scenario.generals,
|
activeGenerals,
|
||||||
2,
|
2,
|
||||||
nextGeneralId,
|
nextGeneralId,
|
||||||
'general',
|
'general',
|
||||||
@@ -608,7 +690,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
nextGeneralId = generalResult.nextId;
|
nextGeneralId = generalResult.nextId;
|
||||||
|
|
||||||
const generalExResult = buildGeneralSeeds(
|
const generalExResult = buildGeneralSeeds(
|
||||||
scenario.generalsEx,
|
activeGeneralsEx,
|
||||||
2,
|
2,
|
||||||
nextGeneralId,
|
nextGeneralId,
|
||||||
'general_ex',
|
'general_ex',
|
||||||
@@ -624,7 +706,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
nextGeneralId = generalExResult.nextId;
|
nextGeneralId = generalExResult.nextId;
|
||||||
|
|
||||||
const generalNeutralResult = buildGeneralSeeds(
|
const generalNeutralResult = buildGeneralSeeds(
|
||||||
scenario.generalsNeutral,
|
activeGeneralsNeutral,
|
||||||
6,
|
6,
|
||||||
nextGeneralId,
|
nextGeneralId,
|
||||||
'general_neutral',
|
'general_neutral',
|
||||||
@@ -638,6 +720,17 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
allGeneralSeeds.push(...generalNeutralResult.seeds);
|
allGeneralSeeds.push(...generalNeutralResult.seeds);
|
||||||
allGenerals.push(...generalNeutralResult.generals);
|
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 = {
|
const seed: WorldSeedPayload = {
|
||||||
scenarioConfig: scenario.config,
|
scenarioConfig: scenario.config,
|
||||||
scenarioMeta,
|
scenarioMeta,
|
||||||
@@ -648,7 +741,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
generals: allGeneralSeeds,
|
generals: allGeneralSeeds,
|
||||||
troops: [],
|
troops: [],
|
||||||
diplomacy: scenario.diplomacy,
|
diplomacy: scenario.diplomacy,
|
||||||
events: scenario.events,
|
events,
|
||||||
initialEvents: scenario.initialEvents,
|
initialEvents: scenario.initialEvents,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -662,7 +755,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
generals: allGenerals,
|
generals: allGenerals,
|
||||||
troops: [],
|
troops: [],
|
||||||
diplomacy: scenario.diplomacy,
|
diplomacy: scenario.diplomacy,
|
||||||
events: scenario.events,
|
events,
|
||||||
initialEvents: scenario.initialEvents,
|
initialEvents: scenario.initialEvents,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,4 +122,123 @@ describe('scenario bootstrap', () => {
|
|||||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
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('만료');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user