diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 21d8a4f..f4888c3 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -394,6 +394,7 @@ export const createDatabaseTurnHooks = async ( createdNations, createdTroops, createdDiplomacy, + createdEvents, deletedEvents, lifecycleEvents, pendingNeutralAuctions, @@ -592,6 +593,24 @@ export const createDatabaseTurnHooks = async ( data: createdDiplomacy.map(buildDiplomacyCreate), }); } + if (createdEvents.length > 0) { + await prisma.event.createMany({ + data: createdEvents.map((event) => ({ + id: event.id, + targetCode: event.targetCode, + priority: event.priority, + condition: asJson(event.condition), + action: asJson(event.action), + meta: asJson(event.meta), + })), + }); + await prisma.$queryRaw` + SELECT setval( + pg_get_serial_sequence('event', 'id'), + GREATEST((SELECT COALESCE(MAX(id), 1) FROM event), 1) + ) + `; + } if (deletedTroops.length > 0) { await prisma.troop.deleteMany({ where: { troopLeaderId: { in: deletedTroops } }, diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 76ad2e2..b6bbc77 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -107,6 +107,7 @@ export interface TurnWorldChanges { createdNations: Nation[]; createdTroops: Troop[]; createdDiplomacy: TurnDiplomacy[]; + createdEvents: TurnEvent[]; deletedEvents: number[]; lifecycleEvents: GeneralLifecycleEvent[]; pendingNeutralAuctions: PendingNeutralAuction[]; @@ -249,7 +250,7 @@ const ensureGeneralKillturn = (general: TurnGeneral, worldKillturn: number | nul export class InMemoryTurnWorld { // DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다. - private readonly schedule: TurnSchedule; + private schedule: TurnSchedule; private readonly generalTurnHandler: GeneralTurnHandler; private readonly calendarHandler?: TurnCalendarHandler; private readonly generals = new Map(); @@ -267,6 +268,7 @@ export class InMemoryTurnWorld { private readonly createdNationIds = new Set(); private readonly createdTroopIds = new Set(); private readonly createdDiplomacyKeys = new Set(); + private readonly createdEventIds = new Set(); private readonly deletedTroopIds = new Set(); private readonly deletedGeneralIds = new Set(); private readonly deletedNationIds = new Set(); @@ -338,6 +340,28 @@ export class InMemoryTurnWorld { }; } + changeTurnTerm(tickMinutes: number): void { + if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) { + throw new Error('Turn term must be a positive integer.'); + } + const previousTickSeconds = this.state.tickSeconds; + const nextTickSeconds = tickMinutes * 60; + if (previousTickSeconds === nextTickSeconds) { + return; + } + const ratio = nextTickSeconds / previousTickSeconds; + const baseTime = this.state.lastTurnTime.getTime(); + for (const general of this.generals.values()) { + const nextTurnTime = new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio); + this.updateGeneral(general.id, { turnTime: nextTurnTime }); + } + this.schedule = { entries: [{ startMinute: 0, tickMinutes }] }; + this.state = { + ...this.state, + tickSeconds: nextTickSeconds, + }; + } + pushLog(entry: LogEntryDraft): void { this.logs.push(entry); } @@ -410,10 +434,22 @@ export class InMemoryTurnWorld { if (!this.events.delete(id)) { return false; } + if (this.createdEventIds.delete(id)) { + return true; + } this.deletedEventIds.add(id); return true; } + addEvent(event: TurnEvent): boolean { + if (this.events.has(event.id)) { + return false; + } + this.events.set(event.id, { ...event, meta: { ...event.meta } }); + this.createdEventIds.add(event.id); + return true; + } + getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null { if (srcNationId === destNationId) { return null; @@ -606,6 +642,11 @@ export class InMemoryTurnWorld { return nextId; } + getNextEventId(): number { + const currentIds = Array.from(this.events.keys()); + return (currentIds.length > 0 ? Math.max(...currentIds) : 0) + 1; + } + getNextGeneralId(): number { const meta = this.state.meta as Record; let lastId = (meta.lastGeneralId as number | undefined) ?? 0; @@ -860,6 +901,9 @@ export class InMemoryTurnWorld { const createdDiplomacy = Array.from(this.createdDiplomacyKeys) .map((key) => this.diplomacy.get(key)) .filter((entry): entry is TurnDiplomacy => Boolean(entry)); + const createdEvents = Array.from(this.createdEventIds) + .map((id) => this.events.get(id)) + .filter((event): event is TurnEvent => Boolean(event)); const deletedTroops = Array.from(this.deletedTroopIds); const deletedGenerals = Array.from(this.deletedGeneralIds); const deletedNations = Array.from(this.deletedNationIds); @@ -891,6 +935,7 @@ export class InMemoryTurnWorld { createdNations, createdTroops, createdDiplomacy, + createdEvents, deletedEvents, lifecycleEvents, pendingNeutralAuctions, @@ -912,6 +957,7 @@ export class InMemoryTurnWorld { for (const entry of changes.createdDiplomacy) { this.createdDiplomacyKeys.delete(buildDiplomacyKey(entry.fromNationId, entry.toNationId)); } + for (const event of changes.createdEvents) this.createdEventIds.delete(event.id); for (const id of changes.deletedTroops) this.deletedTroopIds.delete(id); for (const id of changes.deletedGenerals) this.deletedGeneralIds.delete(id); for (const id of changes.deletedNations) this.deletedNationIds.delete(id); diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts new file mode 100644 index 0000000..0a7d915 --- /dev/null +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -0,0 +1,538 @@ +import { LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { LogCategory, LogFormat, LogScope, type Nation, type TurnCommandEnv } from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler, MonthlyEventEnvironment } from './monthlyEventHandler.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnEvent, TurnGeneral } from './types.js'; + +const INVADER_NPC_TYPE = 9; +const INVADER_PREFIX = 'ⓞ'; +const TURN_TERM_CANDIDATES = [1, 2, 5, 10, 20, 30, 60, 120] as const; + +const toInteger = (value: number): number => (Number.isFinite(value) ? Math.trunc(value) : 0); + +const readNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +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 resolveServerId = (world: InMemoryTurnWorld): string | null => { + const value = world.getState().meta.serverId; + return typeof value === 'string' && value !== '' ? value : null; +}; + +const shuffleLegacy = (values: T[]): T[] => { + const result = [...values]; + // ref의 follower 병종 숙련 배열은 action DRBG가 아니라 PHP process-global + // shuffle()로 섞인다. + for (let index = result.length - 1; index > 0; index -= 1) { + const target = Math.floor(Math.random() * (index + 1)); + [result[index], result[target]] = [result[target]!, result[index]!]; + } + return result; +}; + +const createTurnTime = (rng: RandUtil, environment: MonthlyEventEnvironment, tickSeconds: number): Date => { + const turnMinutes = tickSeconds / 60; + if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { + throw new Error('RaiseInvader requires a positive integer turn term.'); + } + const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1); + const fraction = rng.nextRangeInt(0, 999_999); + return new Date(environment.turnTime.getTime() + seconds * 1_000 + Math.floor(fraction / 1_000)); +}; + +const createInvaderGeneral = (options: { + world: InMemoryTurnWorld; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; + rng: RandUtil; + environment: MonthlyEventEnvironment; + rawName: string; + nationId: number; + cityId: number; + stats: { leadership: number; strength: number; intelligence: number }; + experience: number; + dex: readonly [number, number, number, number, number]; +}): TurnGeneral => { + const age = 20; + const deadYear = options.environment.year + 20; + const experience = options.experience || age * 100; + const id = options.world.getNextGeneralId(); + const general: TurnGeneral = { + id, + userId: null, + name: `${INVADER_PREFIX}${options.rawName}`, + nationId: options.nationId, + cityId: options.cityId, + troopId: 0, + stats: options.stats, + experience, + dedication: age * 100, + officerLevel: 1, + role: { + personality: 'che_패권', + specialDomestic: 'che_인덕', + specialWar: 'che_척사', + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 99_999, + rice: 99_999, + crew: 0, + crewTypeId: options.env.defaultCrewTypeId, + train: 0, + atmos: 0, + age, + npcState: INVADER_NPC_TYPE, + bornYear: options.environment.year - 20, + deadYear, + affinity: 999, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: createTurnTime(options.rng, options.environment, options.world.getState().tickSeconds), + recentWarTime: null, + meta: { + killturn: + (deadYear - options.environment.year) * 12 + + options.rng.nextRangeInt(0, 11) + + options.environment.month - + 1, + npcType: INVADER_NPC_TYPE, + npc_org: INVADER_NPC_TYPE, + belong: 0, + dedlevel: 1, + dex1: options.dex[0], + dex2: options.dex[1], + dex3: options.dex[2], + dex4: options.dex[3], + dex5: options.dex[4], + }, + }; + if (!options.world.addGeneral(general)) { + throw new Error(`RaiseInvader generated duplicate general id ${id}.`); + } + options.reservedTurns.ensureGeneralTurns(id); + return general; +}; + +const addMonthlyEvent = (world: InMemoryTurnWorld, action: readonly unknown[]): TurnEvent => { + const event: TurnEvent = { + id: world.getNextEventId(), + targetCode: 'month', + priority: 1_000, + condition: true, + action: [action], + meta: {}, + }; + if (!world.addEvent(event)) { + throw new Error(`RaiseInvader generated duplicate event id ${event.id}.`); + } + return event; +}; + +export const createRaiseInvaderHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; + loadArchivedNationMaxId?: (serverId: string) => Promise; + maxGeneralsPerMinute?: number; +}): MonthlyEventActionHandler => { + return async (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const invaderCities = world.listCities().filter((city) => city.level === 4); + if (invaderCities.length === 0) { + return; + } + + const npcEachArgument = readNumber(args[0], -3); + const specAverageArgument = readNumber(args[1], -1.2); + const techArgument = readNumber(args[2], -1.2); + const dexArgument = readNumber(args[3], -1); + const ordinaryGenerals = world.listGenerals().filter((general) => general.npcState < 4); + let npcEachCount = npcEachArgument; + if (npcEachCount < 0) { + npcEachCount = (ordinaryGenerals.length / invaderCities.length) * -npcEachCount; + } + npcEachCount = Math.max(10, toInteger(npcEachCount)); + + world.updateWorldMeta({ isunited: 1 }); + const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length; + const maxGeneralsPerMinute = + options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000); + const currentTurnMinutes = world.getState().tickSeconds / 60; + if (totalGeneralCount > maxGeneralsPerMinute * currentTurnMinutes) { + const nextTerm = TURN_TERM_CANDIDATES.find( + (candidate) => totalGeneralCount <= maxGeneralsPerMinute * candidate + ); + if (nextTerm !== undefined) { + world.changeTurnTerm(nextTerm); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `★턴시간이 ${nextTerm}분으로 변경됩니다.`, + }); + } + } + + let specAverage = specAverageArgument; + if (specAverage < 0) { + const sum = ordinaryGenerals.reduce( + (total, general) => + total + general.stats.leadership + general.stats.strength + general.stats.intelligence, + 0 + ); + specAverage = (ordinaryGenerals.length === 0 ? 0 : sum / ordinaryGenerals.length) * -specAverage; + } + specAverage = toInteger(specAverage / 3); + + const activeNations = world.listNations().filter((nation) => nation.level > 0); + let tech = techArgument; + if (tech < 0) { + const averageTech = + activeNations.length === 0 + ? 0 + : activeNations.reduce((sum, nation) => sum + readNumber(nation.meta.tech), 0) / + activeNations.length; + tech = averageTech * -tech; + } + // Nation::__construct(int $tech)의 weak scalar coercion을 보존한다. + tech = toInteger(tech); + + let dex = dexArgument; + if (dex < 0) { + const averageDex = + ordinaryGenerals.length === 0 + ? 0 + : ordinaryGenerals.reduce((sum, general) => { + const meta = asRecord(general.meta); + return ( + sum + + (readNumber(meta.dex1) + + readNumber(meta.dex2) + + readNumber(meta.dex3) + + readNumber(meta.dex4) + + readNumber(meta.dex5)) / + 5 + ); + }, 0) / ordinaryGenerals.length; + dex = averageDex * -dex; + } + dex = toInteger(dex); + + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'RaiseInvader', environment.year, environment.month) + ) + ); + + for (const nation of world.listNations()) { + world.updateNation(nation.id, { + meta: { ...nation.meta, war: 0, scout: 0 }, + }); + } + + const invaderCityIds = new Set(invaderCities.map((city) => city.id)); + const disabledCityIds = new Set(); + for (const nation of world.listNations()) { + const oldCapitalId = nation.capitalCityId; + if (oldCapitalId === null || !invaderCityIds.has(oldCapitalId)) { + continue; + } + const candidates = world + .listCities() + .filter( + (city) => city.nationId === nation.id && city.id !== oldCapitalId && !invaderCityIds.has(city.id) + ); + if (candidates.length === 0) { + disabledCityIds.add(oldCapitalId); + continue; + } + const nextCapital = rng.choice(candidates); + world.updateNation(nation.id, { capitalCityId: nextCapital.id }); + for (const general of world + .listGenerals() + .filter((general) => general.nationId === nation.id && general.cityId === oldCapitalId)) { + world.updateGeneral(general.id, { cityId: nextCapital.id }); + } + } + + for (const general of world.listGenerals()) { + const meta = asRecord(general.meta); + const officerCityId = readNumber(meta.officer_city ?? meta.officerCity); + if (!invaderCityIds.has(officerCityId)) { + continue; + } + world.updateGeneral(general.id, { + officerLevel: 1, + meta: { ...general.meta, officer_city: 0, officerCity: 0 }, + }); + } + for (const city of invaderCities) { + world.updateCity(city.id, { nationId: 0, frontState: 0, supplyState: 1 }); + } + + const existingNationIds = world.listNations().map((nation) => nation.id); + const currentLastNationId = readNumber(world.getState().meta.lastNationId); + const liveNationMaxId = existingNationIds.reduce((maxId, id) => Math.max(maxId, id), 0); + let lastNationId = Math.max(currentLastNationId, liveNationMaxId); + const serverId = resolveServerId(world); + if (serverId && options.loadArchivedNationMaxId) { + lastNationId = Math.max(lastNationId, await options.loadArchivedNationMaxId(serverId)); + } + if (lastNationId !== currentLastNationId) { + world.updateWorldMeta({ lastNationId }); + } + + const experiencePool = world.listGenerals().filter((general) => general.npcState < 6); + const averageExperience = + experiencePool.length === 0 + ? 0 + : experiencePool.reduce((sum, general) => sum + general.experience, 0) / experiencePool.length; + for (const general of world.listGenerals()) { + world.updateGeneral(general.id, { gold: 999_999, rice: 999_999 }); + } + + const invaderNationIds: number[] = []; + for (const city of invaderCities) { + if (disabledCityIds.has(city.id)) { + continue; + } + const nationId = world.getNextNationId(); + invaderNationIds.push(nationId); + const nation: Nation = { + id: nationId, + name: `${INVADER_PREFIX}${city.name}족`, + color: '#800080', + capitalCityId: city.id, + chiefGeneralId: null, + gold: 9_999_999, + rice: 9_999_999, + power: 0, + level: 2, + typeCode: 'che_병가', + meta: { + tech, + infoText: '중원의 부패를 물리쳐라! 이민족 침범!', + bill: 100, + rate: 15, + scout: 0, + war: 0, + strategicCommandLimit: 24, + surrenderLimit: 72, + can_국기변경: 1, + }, + }; + if (!world.addNation(nation)) { + throw new Error(`RaiseInvader generated duplicate nation id ${nationId}.`); + } + world.updateCity(city.id, { nationId }); + + const ruler = createInvaderGeneral({ + world, + reservedTurns: options.reservedTurns, + env: options.env, + rng, + environment, + rawName: `${city.name}대왕`, + nationId, + cityId: city.id, + stats: { + leadership: toInteger(specAverage * 1.8), + strength: toInteger(specAverage * 1.8), + intelligence: toInteger(specAverage * 1.2), + }, + experience: toInteger(averageExperience * 1.2), + dex: [0, 0, 0, 0, 0], + }); + world.updateGeneral(ruler.id, { officerLevel: 12 }); + + // ref Util::range(1, $npcEachCount)는 끝 값을 포함하지 않는다. + for (let index = 1; index < npcEachCount; index += 1) { + const leadership = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4)); + const mainStat = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4)); + const subStat = specAverage * 3 - leadership - mainStat; + const isWarrior = rng.nextBit(); + const martialDex = isWarrior ? shuffleLegacy([dex * 2, dex, dex]) : [dex, dex, dex]; + createInvaderGeneral({ + world, + reservedTurns: options.reservedTurns, + env: options.env, + rng, + environment, + rawName: `${city.name}장수${index}`, + nationId, + cityId: city.id, + stats: isWarrior + ? { leadership, strength: mainStat, intelligence: subStat } + : { leadership, strength: subStat, intelligence: mainStat }, + experience: toInteger(averageExperience), + dex: isWarrior + ? [martialDex[0]!, martialDex[1]!, martialDex[2]!, dex, 0] + : [dex, dex, dex, dex * 2, 0], + }); + } + world.updateNation(nationId, { + chiefGeneralId: ruler.id, + meta: { ...nation.meta, gennum: npcEachCount }, + }); + for (const officerLevel of [12, 11, 10, 9]) { + options.reservedTurns.ensureNationTurns(nationId, officerLevel); + } + addMonthlyEvent(world, ['AutoDeleteInvader', nationId]); + } + addMonthlyEvent(world, ['InvaderEnding']); + + for (const existingNationId of existingNationIds) { + for (const invaderNationId of invaderNationIds) { + world.applyDiplomacyPatch({ + srcNationId: existingNationId, + destNationId: invaderNationId, + patch: { state: 1, term: 24 }, + }); + world.applyDiplomacyPatch({ + srcNationId: invaderNationId, + destNationId: existingNationId, + patch: { state: 1, term: 24 }, + }); + } + } + for (const fromNationId of invaderNationIds) { + for (const toNationId of invaderNationIds) { + if (fromNationId === toNationId) { + continue; + } + world.applyDiplomacyPatch({ + srcNationId: fromNationId, + destNationId: toNationId, + patch: { state: 7, term: 480 }, + }); + } + } + + const cityPopulationMax = specAverage * npcEachCount * 100 * 4; + for (const city of world.listCities()) { + const isInvaderCity = invaderNationIds.includes(city.nationId); + world.updateCity(city.id, { + populationMax: isInvaderCity ? cityPopulationMax : city.populationMax, + defenceMax: isInvaderCity ? 100_000 : city.defenceMax, + wallMax: isInvaderCity ? 10_000 : city.wallMax, + population: isInvaderCity ? cityPopulationMax : city.populationMax, + agriculture: city.agricultureMax, + commerce: city.commerceMax, + security: city.securityMax, + }); + } + + for (const text of [ + '【이벤트】각지의 이민족들이 궐기합니다!', + '【이벤트】중원의 전 국가에 선전포고 합니다!', + '【이벤트】이민족의 기세는 그 누구도 막을 수 없을듯 합니다!', + ]) { + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text, + format: LogFormat.NOTICE_YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + } + world.updateWorldMeta({ block_change_scout: false }); + }; +}; + +export const createAutoDeleteInvaderHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; +}): MonthlyEventActionHandler => { + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const nationId = readNumber(args[0], -1); + const nation = world.getNationById(nationId); + if (!nation) { + world.removeEvent(environment.currentEventID); + return; + } + const onWar = world + .listDiplomacy() + .some((entry) => entry.fromNationId === nationId && (entry.state === 0 || entry.state === 1)); + if (onWar) { + return; + } + const ruler = world + .listGenerals() + .find((general) => general.nationId === nationId && general.officerLevel === 12); + if (ruler) { + options.reservedTurns.replaceGeneralTurns(ruler.id, { + action: 'che_방랑', + args: {}, + }); + } + world.removeEvent(environment.currentEventID); + }; +}; + +export const createInvaderEndingHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const meta = world.getState().meta; + const isUnited = readNumber(meta.isunited ?? meta.isUnited); + if (isUnited === 0 || isUnited === 2) { + return; + } + const nations = world.listNations(); + if (nations.length >= 2) { + return; + } + const neutralCityCount = world.listCities().filter((city) => city.nationId === 0).length; + let userWin = false; + if (neutralCityCount === 0) { + userWin = nations.length === 1 && !nations[0]!.name.startsWith(INVADER_PREFIX); + } else if (neutralCityCount !== world.listCities().length) { + return; + } + const texts = userWin + ? [ + '【이벤트】이민족을 모두 소탕했습니다!', + '【이벤트】중원은 당분간 태평성대를 누릴 것입니다.', + ] + : [ + '【이벤트】중원은 이민족에 의해 혼란에 빠졌습니다.', + '【이벤트】백성은 언젠가 영웅이 나타나길 기다립니다.', + ]; + for (const text of texts) { + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text, + format: LogFormat.NOTICE_YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + } + world.updateWorldMeta({ + isunited: 3, + refreshLimit: readNumber(meta.refreshLimit) * 100, + }); + world.removeEvent(environment.currentEventID); + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index c383328..96de3e3 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -214,6 +214,18 @@ export class InMemoryReservedTurnStore { this.pendingGeneralInitializationIds.add(generalId); } + replaceGeneralTurns(generalId: number, entry: ReservedTurnEntry): void { + this.generalTurns.set( + generalId, + Array.from({ length: this.maxGeneralTurns }, () => ({ + action: normalizeAction(entry.action), + args: normalizeArgs(entry.args), + })) + ); + this.pendingGeneralInitializationIds.delete(generalId); + this.dirtyGeneralIds.add(generalId); + } + ensureNationTurns(nationId: number, officerLevel: number): void { const key = buildNationKey(nationId, officerLevel); this.getNationTurns(nationId, officerLevel); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 93c6f9f..e2aaa8e 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -54,6 +54,11 @@ import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js'; import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js'; import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js'; import { createRaiseNpcNationHandler } from './monthlyRaiseNpcNationAction.js'; +import { + createAutoDeleteInvaderHandler, + createInvaderEndingHandler, + createRaiseInvaderHandler, +} from './monthlyInvaderAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -176,7 +181,9 @@ const createTurnDaemonRuntimeWithLease = async ( hasEventAction('CreateManyNPC') || hasEventAction('RegNPC') || hasEventAction('RegNeutralNPC') || - hasEventAction('RaiseNPCNation'); + hasEventAction('RaiseNPCNation') || + hasEventAction('RaiseInvader') || + hasEventAction('AutoDeleteInvader'); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null @@ -273,8 +280,23 @@ const createTurnDaemonRuntimeWithLease = async ( reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, map: snapshot.map, - loadArchivedNationMaxId: (serverId) => - loadArchivedNationMaxId(options.databaseUrl, serverId), + loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), + }) + ); + eventActions.set( + 'RaiseInvader', + createRaiseInvaderHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + env: monthlyCommandEnv, + loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), + }) + ); + eventActions.set( + 'AutoDeleteInvader', + createAutoDeleteInvaderHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, }) ); eventActions.set( @@ -287,6 +309,12 @@ const createTurnDaemonRuntimeWithLease = async ( }) ); } + eventActions.set( + 'InvaderEnding', + createInvaderEndingHandler({ + getWorld: () => worldRef, + }) + ); eventActions.set('ProcessIncome', async (_args, environment) => { await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts new file mode 100644 index 0000000..edc4098 --- /dev/null +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createAutoDeleteInvaderHandler, + createInvaderEndingHandler, + createRaiseInvaderHandler, +} from '../src/turn/monthlyInvaderAction.js'; +import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number, nationId: number, level: number): City => ({ + id, + name: `도시${id}`, + nationId, + level, + state: 0, + population: 1_000, + populationMax: 10_000, + agriculture: 2_000, + agricultureMax: 20_000, + commerce: 3_000, + commerceMax: 30_000, + security: 4_000, + securityMax: 40_000, + supplyState: 0, + frontState: 1, + defence: 5_000, + defenceMax: 50_000, + wall: 6_000, + wallMax: 60_000, + meta: { trust: 50 }, +}); + +const buildNation = (id: number, capitalCityId = 1, name = `국가${id}`): Nation => ({ + id, + name, + color: '#777777', + capitalCityId, + chiefGeneralId: 1, + gold: 1_000, + rice: 1_000, + power: 0, + level: 2, + typeCode: 'che_유가', + meta: { tech: 100, war: 1, scout: 1 }, +}); + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 1, + userId: null, + name: '군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 1_000, + dedication: 1_000, + officerLevel: 12, + role: { + personality: 'che_안전', + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:05:00.000Z'), + recentWarTime: null, + meta: { killturn: 1_000, dex1: 10, dex2: 20, dex3: 30, dex4: 40, dex5: 50 }, + ...overrides, +}); + +const event: TurnEvent = { + id: 7, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RaiseInvader', 10, 150, 100, 20]], + meta: {}, +}; + +const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, +}; + +const buildHarness = (options?: { + cities?: City[]; + nations?: Nation[]; + generals?: TurnGeneral[]; + events?: TurnEvent[]; + meta?: Record; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { + hiddenSeed: 'raise-invader-fixture', + serverId: 'fixture-server', + refreshLimit: 3, + ...options?.meta, + }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + map: { + id: 'test', + name: 'test', + cities: [], + }, + generals: options?.generals ?? [buildGeneral()], + cities: options?.cities ?? [buildCity(1, 1, 3), buildCity(2, 0, 4)], + nations: options?.nations ?? [buildNation(1)], + troops: [], + diplomacy: [], + events: options?.events ?? [event], + 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, + }); + return { + state, + snapshot, + world, + reservedTurns, + environment: { + year: 200, + month: 1, + startyear: 190, + currentEventID: 7, + turnTime: state.lastTurnTime, + }, + }; +}; + +describe('invader monthly actions', () => { + it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => { + const harness = buildHarness(); + const handler = createRaiseInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + env: buildCommandEnv(scenarioConfig), + loadArchivedNationMaxId: vi.fn().mockResolvedValue(9), + }); + + await handler([10, 150, 100, 20], harness.environment, event); + + const dirty = harness.world.peekDirtyState(); + expect(dirty.createdNations).toHaveLength(1); + expect(dirty.createdNations[0]).toMatchObject({ + id: 10, + name: 'ⓞ도시2족', + color: '#800080', + capitalCityId: 2, + chiefGeneralId: 2, + gold: 9_999_999, + rice: 9_999_999, + level: 2, + typeCode: 'che_병가', + meta: { tech: 100, gennum: 10 }, + }); + expect(dirty.createdGenerals).toHaveLength(10); + expect(dirty.createdGenerals[0]).toMatchObject({ + id: 2, + name: 'ⓞ도시2대왕', + nationId: 10, + cityId: 2, + stats: { leadership: 90, strength: 90, intelligence: 60 }, + experience: 1_200, + dedication: 2_000, + officerLevel: 12, + gold: 99_999, + rice: 99_999, + npcState: 9, + affinity: 999, + }); + expect(harness.world.getGeneralById(1)).toMatchObject({ gold: 999_999, rice: 999_999 }); + expect(harness.world.getCityById(2)).toMatchObject({ + nationId: 10, + populationMax: 200_000, + population: 200_000, + agriculture: 20_000, + commerce: 30_000, + security: 40_000, + defenceMax: 100_000, + wallMax: 10_000, + supplyState: 1, + frontState: 0, + }); + expect(harness.world.getCityById(1)).toMatchObject({ + population: 10_000, + agriculture: 20_000, + commerce: 30_000, + security: 40_000, + }); + expect(dirty.createdEvents.map((created) => created.action)).toEqual([ + [['AutoDeleteInvader', 10]], + [['InvaderEnding']], + ]); + expect( + harness.world + .listDiplomacy() + .filter((entry) => entry.fromNationId !== entry.toNationId) + .map((entry) => [entry.fromNationId, entry.toNationId, entry.state, entry.term]) + ).toEqual([ + [1, 10, 1, 24], + [10, 1, 1, 24], + ]); + expect(dirty.logs.map((log) => log.text)).toEqual([ + '【이벤트】각지의 이민족들이 궐기합니다!', + '【이벤트】중원의 전 국가에 선전포고 합니다!', + '【이벤트】이민족의 기세는 그 누구도 막을 수 없을듯 합니다!', + ]); + expect(harness.world.getState().meta).toMatchObject({ + isunited: 1, + block_change_scout: false, + lastNationId: 10, + }); + expect(harness.reservedTurns.peekDirtyState()).toMatchObject({ + generalInitializationIds: expect.arrayContaining(Array.from({ length: 10 }, (_, index) => index + 2)), + nationInitializationKeys: ['10:12', '10:11', '10:10', '10:9'], + }); + }); + + it('moves a level-4 capital and scales turn times when the general limit requires a longer term', async () => { + const cities = [buildCity(1, 1, 4), buildCity(2, 1, 3), buildCity(3, 0, 4)]; + const harness = buildHarness({ cities }); + const handler = createRaiseInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + env: buildCommandEnv(scenarioConfig), + maxGeneralsPerMinute: 1, + }); + + await handler([10, 150, 100, 20], harness.environment, event); + + expect(harness.world.getNationById(1)?.capitalCityId).toBe(2); + expect(harness.world.getGeneralById(1)?.cityId).toBe(2); + expect(harness.world.getState().tickSeconds).toBe(30 * 60); + expect(harness.world.getGeneralById(1)?.turnTime.toISOString()).toBe('0200-01-01T00:15:00.000Z'); + expect(harness.world.peekDirtyState().logs[0]?.text).toBe('★턴시간이 30분으로 변경됩니다.'); + }); + + it('keeps an invader event while at war, then overwrites the ruler turns and deletes it at peace', async () => { + const autoDeleteEvent: TurnEvent = { + id: 8, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['AutoDeleteInvader', 2]], + meta: {}, + }; + const harness = buildHarness({ + cities: [buildCity(1, 1, 3), buildCity(2, 2, 4)], + nations: [buildNation(1), buildNation(2, 2, 'ⓞ도시2족')], + generals: [buildGeneral(), buildGeneral({ id: 2, name: 'ⓞ도시2대왕', nationId: 2, cityId: 2 })], + events: [autoDeleteEvent], + }); + harness.world.applyDiplomacyPatch({ + srcNationId: 2, + destNationId: 1, + patch: { state: 1, term: 24 }, + }); + const handler = createAutoDeleteInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + }); + const environment = { ...harness.environment, currentEventID: 8 }; + + await handler([2], environment, autoDeleteEvent); + expect(harness.world.listEvents().map((entry) => entry.id)).toContain(8); + + harness.world.applyDiplomacyPatch({ + srcNationId: 2, + destNationId: 1, + patch: { state: 2, term: 0 }, + }); + await handler([2], environment, autoDeleteEvent); + + expect(harness.world.listEvents().map((entry) => entry.id)).not.toContain(8); + expect(harness.reservedTurns.getGeneralTurns(2)).toEqual( + Array.from({ length: 30 }, () => ({ action: 'che_방랑', args: {} })) + ); + expect(harness.reservedTurns.peekDirtyState().generalIds).toEqual([2]); + }); + + it('finishes with the legacy user-win logs and refresh multiplier', async () => { + const endingEvent: TurnEvent = { + id: 9, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['InvaderEnding']], + meta: {}, + }; + const harness = buildHarness({ + cities: [buildCity(1, 1, 3)], + events: [endingEvent], + meta: { isunited: 1, refreshLimit: 3 }, + }); + const handler = createInvaderEndingHandler({ getWorld: () => harness.world }); + const environment = { ...harness.environment, currentEventID: 9 }; + + await handler([], environment, endingEvent); + + expect(harness.world.getState().meta).toMatchObject({ isunited: 3, refreshLimit: 300 }); + expect(harness.world.listEvents()).toHaveLength(0); + expect(harness.world.peekDirtyState().logs.map((log) => log.text)).toEqual([ + '【이벤트】이민족을 모두 소탕했습니다!', + '【이벤트】중원은 당분간 태평성대를 누릴 것입니다.', + ]); + }); + + it('does not execute dynamically-added events in the same monthly dispatch', async () => { + const harness = buildHarness(); + const actions = new Map([ + [ + 'RaiseInvader', + createRaiseInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + env: buildCommandEnv(scenarioConfig), + }), + ], + [ + 'AutoDeleteInvader', + createAutoDeleteInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + }), + ], + ['InvaderEnding', createInvaderEndingHandler({ getWorld: () => harness.world })], + ]); + const calendar = createMonthlyEventHandler({ + getWorld: () => harness.world, + startYear: 190, + actions, + }); + + await calendar.onMonthChanged?.({ + previousYear: 199, + previousMonth: 12, + currentYear: 200, + currentMonth: 1, + turnTime: harness.state.lastTurnTime, + }); + + expect(harness.world.peekDirtyState().createdEvents).toHaveLength(2); + expect(harness.reservedTurns.peekDirtyState().generalIds).toEqual([]); + expect(harness.world.getState().meta.isunited).toBe(1); + }); + + it.skipIf(!process.env.REF_HIDDEN_SEED)( + 'matches the fixed-seed legacy ruler and first follower RNG fields', + async () => { + const invaderNames = ['남만', '산월', '오환', '강', '왜', '흉노', '저']; + const cities = [ + buildCity(1, 1, 3), + ...invaderNames.map((name, index) => ({ + ...buildCity(69 + index, 0, 4), + name, + })), + ]; + const harness = buildHarness({ + cities, + meta: { hiddenSeed: process.env.REF_HIDDEN_SEED }, + }); + const handler = createRaiseInvaderHandler({ + getWorld: () => harness.world, + reservedTurns: harness.reservedTurns, + env: buildCommandEnv(scenarioConfig), + loadArchivedNationMaxId: vi.fn().mockResolvedValue(10), + }); + + await handler([10, 150, 100, 20], harness.environment, event); + + const dirty = harness.world.peekDirtyState(); + expect(dirty.createdNations).toHaveLength(7); + expect(dirty.createdGenerals).toHaveLength(70); + expect( + dirty.createdGenerals.slice(0, 3).map((general) => ({ + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + stats: general.stats, + turnTime: general.turnTime.toISOString(), + killturn: general.meta.killturn, + dex: [ + general.meta.dex1, + general.meta.dex2, + general.meta.dex3, + general.meta.dex4, + general.meta.dex5, + ], + })) + ).toEqual([ + { + name: 'ⓞ남만대왕', + nationId: 11, + cityId: 69, + stats: { leadership: 90, strength: 90, intelligence: 60 }, + turnTime: '0200-01-01T00:02:40.239Z', + killturn: 247, + dex: [0, 0, 0, 0, 0], + }, + { + name: 'ⓞ남만장수1', + nationId: 11, + cityId: 69, + stats: { leadership: 70, strength: 13, intelligence: 67 }, + turnTime: '0200-01-01T00:03:19.647Z', + killturn: 242, + dex: [20, 20, 20, 40, 0], + }, + { + name: 'ⓞ남만장수2', + nationId: 11, + cityId: 69, + stats: { leadership: 62, strength: 70, intelligence: 18 }, + turnTime: '0200-01-01T00:02:09.634Z', + killturn: 250, + dex: expect.arrayContaining([40, 20, 20, 20, 0]), + }, + ]); + } + ); +}); diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts new file mode 100644 index 0000000..4a6ac03 --- /dev/null +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -0,0 +1,391 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRaiseInvaderHandler } from '../src/turn/monthlyInvaderAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const existingNationId = 991_100; +const createdNationId = 991_101; +const existingGeneralId = 991_100; +const firstCreatedGeneralId = 991_101; +const ordinaryCityId = 991_100; +const invaderCityId = 991_101; +const sourceEventId = 991_100; +const createdEventIds = [991_101, 991_102]; + +const buildCity = (id: number, nationId: number, level: number, name: string): City => ({ + id, + name, + nationId, + level, + state: 0, + population: 1_000, + populationMax: 10_000, + agriculture: 2_000, + agricultureMax: 20_000, + commerce: 3_000, + commerceMax: 30_000, + security: 4_000, + securityMax: 40_000, + supplyState: 1, + frontState: 0, + defence: 5_000, + defenceMax: 50_000, + wall: 6_000, + wallMax: 60_000, + meta: { trust: 50, region: 1 }, +}); + +const ordinaryCity = buildCity(ordinaryCityId, existingNationId, 3, '기준도시'); +const invaderCity = buildCity(invaderCityId, 0, 4, '남만'); +const existingNation: Nation = { + id: existingNationId, + name: '기준국', + color: '#777777', + capitalCityId: ordinaryCityId, + chiefGeneralId: existingGeneralId, + gold: 1_000, + rice: 1_000, + power: 0, + level: 2, + typeCode: 'che_유가', + meta: { tech: 100, war: 1, scout: 1 }, +}; +const existingGeneral: TurnGeneral = { + id: existingGeneralId, + userId: null, + name: '군주', + nationId: existingNationId, + cityId: ordinaryCityId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 1_000, + dedication: 1_000, + officerLevel: 12, + role: { + personality: 'che_안전', + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:05:00.000Z'), + recentWarTime: null, + meta: { killturn: 1_000, dex1: 10, dex2: 20, dex3: 30, dex4: 40, dex5: 50 }, +}; +const sourceEvent: TurnEvent = { + id: sourceEventId, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RaiseInvader', 10, 150, 100, 20]], + meta: {}, +}; + +integration('RaiseInvader database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const clean = async () => { + const createdGeneralIds = Array.from({ length: 10 }, (_, index) => firstCreatedGeneralId + index); + await db.logEntry.deleteMany({ + where: { year: 200, month: 1, text: { contains: '【이벤트】' } }, + }); + await db.generalTurn.deleteMany({ + where: { generalId: { in: [existingGeneralId, ...createdGeneralIds] } }, + }); + await db.rankData.deleteMany({ + where: { generalId: { in: [existingGeneralId, ...createdGeneralIds] } }, + }); + await db.general.deleteMany({ + where: { id: { in: [existingGeneralId, ...createdGeneralIds] } }, + }); + await db.nationTurn.deleteMany({ where: { nationId: createdNationId } }); + await db.diplomacy.deleteMany({ + where: { + OR: [ + { srcNationId: { in: [existingNationId, createdNationId] } }, + { destNationId: { in: [existingNationId, createdNationId] } }, + ], + }, + }); + await db.nation.deleteMany({ where: { id: { in: [existingNationId, createdNationId] } } }); + await db.city.deleteMany({ where: { id: { in: [ordinaryCityId, invaderCityId] } } }); + await db.event.deleteMany({ where: { id: { in: [sourceEventId, ...createdEventIds] } } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-invader-persistence' } }); + }; + + 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 invader entities, dynamic events, diplomacy, turns, ranks, city state, and logs atomically', async () => { + const createCity = (city: City) => + 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: {}, + }, + }); + await createCity(ordinaryCity); + await createCity(invaderCity); + await db.nation.create({ + data: { + id: existingNationId, + name: existingNation.name, + color: existingNation.color, + capitalCityId: ordinaryCityId, + chiefGeneralId: existingGeneralId, + gold: existingNation.gold, + rice: existingNation.rice, + tech: 100, + level: 2, + typeCode: existingNation.typeCode, + meta: existingNation.meta, + }, + }); + await db.general.create({ + data: { + id: existingGeneralId, + name: existingGeneral.name, + nationId: existingNationId, + cityId: ordinaryCityId, + leadership: 50, + strength: 50, + intel: 50, + experience: 1_000, + dedication: 1_000, + officerLevel: 12, + gold: 1_000, + rice: 1_000, + crewTypeId: 1100, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + personalCode: 'che_안전', + lastTurn: { command: '휴식' }, + meta: existingGeneral.meta, + turnTime: existingGeneral.turnTime, + }, + }); + await db.event.create({ + data: { + id: sourceEventId, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RaiseInvader', 10, 150, 100, 20]], + meta: {}, + }, + }); + const stateRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-invader-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { + hiddenSeed: 'raise-invader-persistence', + lastGeneralId: firstCreatedGeneralId - 1, + lastNationId: createdNationId - 1, + serverId: 'raise-invader-persistence', + }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { + hiddenSeed: 'raise-invader-persistence', + lastGeneralId: firstCreatedGeneralId - 1, + lastNationId: createdNationId - 1, + serverId: 'raise-invader-persistence', + }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + generals: [existingGeneral], + cities: [ordinaryCity, invaderCity], + nations: [existingNation], + troops: [], + diplomacy: [], + events: [sourceEvent], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 }); + const handler = createRaiseInvaderHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + loadArchivedNationMaxId: async () => 0, + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + await handler( + [10, 150, 100, 20], + { + year: 200, + month: 1, + startyear: 190, + currentEventID: sourceEventId, + turnTime: state.lastTurnTime, + }, + sourceEvent + ); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.nation.findUniqueOrThrow({ where: { id: createdNationId } })).toMatchObject({ + name: 'ⓞ남만족', + capitalCityId: invaderCityId, + chiefGeneralId: firstCreatedGeneralId, + gold: 9_999_999, + rice: 9_999_999, + tech: 100, + level: 2, + typeCode: 'che_병가', + }); + expect(await db.general.count({ where: { nationId: createdNationId } })).toBe(10); + expect(await db.generalTurn.count({ where: { generalId: { gte: firstCreatedGeneralId } } })).toBe(300); + expect(await db.rankData.count({ where: { generalId: { gte: firstCreatedGeneralId } } })).toBe(410); + expect(await db.nationTurn.count({ where: { nationId: createdNationId } })).toBe(48); + expect( + await db.diplomacy.count({ + where: { + OR: [{ srcNationId: createdNationId }, { destNationId: createdNationId }], + }, + }) + ).toBe(2); + expect( + await db.event.findMany({ + where: { id: { in: createdEventIds } }, + orderBy: { id: 'asc' }, + select: { id: true, targetCode: true, priority: true, condition: true, action: true }, + }) + ).toEqual([ + { + id: createdEventIds[0], + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['AutoDeleteInvader', createdNationId]], + }, + { + id: createdEventIds[1], + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['InvaderEnding']], + }, + ]); + expect(await db.city.findUniqueOrThrow({ where: { id: invaderCityId } })).toMatchObject({ + nationId: createdNationId, + population: 200_000, + populationMax: 200_000, + agriculture: 20_000, + commerce: 30_000, + security: 40_000, + defenceMax: 100_000, + wallMax: 10_000, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: existingGeneralId } })).toMatchObject({ + gold: 999_999, + rice: 999_999, + }); + expect( + await db.logEntry.count({ + where: { year: 200, month: 1, text: { contains: '【이벤트】' } }, + }) + ).toBe(3); + expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ + meta: expect.objectContaining({ isunited: 1, block_change_scout: false }), + }); + const sequenceProbe = await db.event.create({ + data: { + targetCode: 'month', + priority: 0, + condition: false, + action: [], + meta: {}, + }, + }); + expect(sequenceProbe.id).toBeGreaterThan(createdEventIds[1]!); + await db.event.delete({ where: { id: sequenceProbe.id } }); + } finally { + await dbHooks.close(); + } + }); +});