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 { 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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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('만료');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user