Merge branch 'codex/p0-general-turn-lifecycle'

# Conflicts:
#	app/game-engine/src/turn/databaseHooks.ts
#	app/game-engine/src/turn/inMemoryWorld.ts
#	app/game-engine/src/turn/reservedTurnHandler.ts
#	packages/infra/src/turnEngineDb.ts
This commit is contained in:
2026-07-25 08:48:10 +00:00
13 changed files with 1325 additions and 68 deletions
+13
View File
@@ -28,6 +28,7 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -119,6 +120,7 @@ const buildRankRows = (
const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): TurnEngineGeneralUpdateInput => ({
userId: general.userId ?? null,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
@@ -239,6 +241,7 @@ const buildNationUpdate = (
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
level: nation.level,
@@ -340,6 +343,7 @@ export const createDatabaseTurnHooks = async (
createdTroops,
createdDiplomacy,
deletedEvents,
lifecycleEvents,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -358,6 +362,12 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
await persistGeneralLifecycleEvents(
prisma,
lifecycleEvents,
meta,
asRecord(world.getScenarioConfig().const)
);
if (deletedNationSnapshots.length > 0) {
const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id);
@@ -470,6 +480,9 @@ export const createDatabaseTurnHooks = async (
}
if (deletedGenerals.length > 0) {
await prisma.generalTurn.deleteMany({
where: { generalId: { in: deletedGenerals } },
});
await prisma.general.deleteMany({
where: { id: { in: deletedGenerals } },
});
@@ -0,0 +1,320 @@
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const readNumber = (record: Record<string, unknown>, key: string): number => {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
const readWorldNumber = (record: Record<string, unknown>, key: string, fallback: number): number => {
const value = readNumber(record, key);
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
};
const computeDexPoint = (meta: Record<string, unknown>): number => {
let total = 0;
for (let dex = 1; dex <= 5; dex += 1) {
total += readNumber(meta, `dex${dex}`);
}
return total * 0.001;
};
const settleInheritance = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
isRebirth: boolean,
configConst: Record<string, unknown>
): Promise<void> => {
const userId = event.before.userId;
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
return;
}
const meta = asRecord(event.before.meta);
if (event.before.npcState === 1) {
const pickYearMonth = readNumber(meta, 'pickYearMonth');
if (pickYearMonth === 0 && meta.pickYearMonth === undefined) {
return;
}
const pickYear = Math.floor(pickYearMonth / 12);
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
const startYear = readWorldNumber(
worldMeta,
'startYear',
readWorldNumber(worldMeta, 'startyear', readWorldNumber(scenarioMeta, 'startYear', event.year))
);
if ((event.year - pickYear) * 2 <= event.year - startYear) {
return;
}
}
const [rows, rankRows] = await Promise.all([
prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const points = new Map(rows.map((row) => [row.key, row.value]));
const ranks = new Map(rankRows.map((row) => [row.type, row.value]));
const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`);
const previous = points.get('previous') ?? 0;
const refund =
(meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0) +
(meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0);
const lived = readNumber(meta, 'inherit_lived_month');
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10;
const maxDomestic = readNumber(meta, 'max_domestic_critical');
const active = readNumber(meta, 'inherit_active_action') * 3;
const combat = rank('warnum') * 5;
const sabotage = (ranks.get('firenum') ?? readNumber(meta, 'firenum')) * 20;
const dex = computeDexPoint(meta);
const unifier = points.get('unifier') ?? 0;
const earned = isRebirth
? lived + active + combat + sabotage + dex * 0.5
: lived + maxBelong + maxDomestic + active + combat + sabotage + dex + unifier;
const total = Math.trunc(previous + refund + earned);
await prisma.inheritancePoint.upsert({
where: { userId_key: { userId, key: 'previous' } },
update: { value: total },
create: { userId, key: 'previous', value: total },
});
await prisma.inheritancePoint.deleteMany({
where: {
userId,
key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' },
},
});
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
await prisma.inheritanceResult.create({
data: {
serverId,
owner: userId,
generalId: event.generalId,
year: event.year,
month: event.month,
value: asJson({
previous,
refund,
lived_month: lived,
max_belong: maxBelong,
max_domestic_critical: maxDomestic,
active_action: active,
combat,
sabotage,
dex: isRebirth ? dex * 0.5 : dex,
unifier: isRebirth ? 0 : unifier,
rebirth: isRebirth,
}),
},
});
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`,
},
});
};
const computeRate = (numerator: number, denominator: number): number =>
denominator > 0 ? numerator / denominator : 0;
const settleHall = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
): Promise<void> => {
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
if (isUnited !== 0) {
return;
}
const [ranks, nation, historyCount] = await Promise.all([
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
event.before.nationId > 0
? prisma.nation.findUnique({
where: { id: event.before.nationId },
select: { name: true, color: true },
})
: null,
prisma.gameHistory.count(),
]);
const rank = new Map(ranks.map((row) => [row.type, row.value]));
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
const warnum = value('warnum');
const tt = value('ttw') + value('ttd') + value('ttl');
const tl = value('tlw') + value('tld') + value('tll');
const ts = value('tsw') + value('tsd') + value('tsl');
const ti = value('tiw') + value('tid') + value('til');
const calc: Record<string, number> = {
winrate: computeRate(value('killnum'), warnum),
killrate: computeRate(value('killcrew'), Math.max(1, value('deathcrew'))),
killrate_person: computeRate(value('killcrew_person'), Math.max(1, value('deathcrew_person'))),
ttrate: computeRate(value('ttw'), Math.max(1, tt)),
tlrate: computeRate(value('tlw'), Math.max(1, tl)),
tsrate: computeRate(value('tsw'), Math.max(1, ts)),
tirate: computeRate(value('tiw'), Math.max(1, ti)),
betrate: computeRate(value('betwingold'), Math.max(1, value('betgold'))),
};
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const season = readWorldNumber(worldMeta, 'season', 1);
const scenario = readWorldNumber(worldMeta, 'scenarioId', 0);
const scenarioName =
typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : '';
const aux = {
name: event.before.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? '#000000',
fgColor: nation?.color ?? '#000000',
startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null,
unitedTime: new Date().toISOString(),
ownerName: event.before.userId ?? null,
serverID: serverId,
serverIdx: historyCount,
scenarioName,
};
for (const type of HALL_OF_FAME_TYPES) {
let hallValue =
type === 'experience'
? event.before.experience
: type === 'dedication'
? event.before.dedication
: type.endsWith('rate')
? (calc[type] ?? 0)
: value(type);
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
if (type === 'ttrate' && tt < 50) continue;
if (type === 'tlrate' && tl < 50) continue;
if (type === 'tsrate' && ts < 50) continue;
if (type === 'tirate' && ti < 50) continue;
if (type === 'betrate' && value('betgold') < 1000) continue;
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
hallValue = Number(hallValue);
const existing = await prisma.hallOfFame.findUnique({
where: {
serverId_type_generalNo: {
serverId,
type: type as HallOfFameType,
generalNo: event.generalId,
},
},
});
if (existing) {
if (hallValue > existing.value) {
await prisma.hallOfFame.update({
where: { id: existing.id },
data: { value: hallValue, aux: asJson(aux) },
});
}
continue;
}
await prisma.hallOfFame.createMany({
data: [
{
serverId,
season,
scenario,
generalNo: event.generalId,
type,
value: hallValue,
owner: event.before.userId ?? null,
aux: asJson(aux),
},
],
skipDuplicates: true,
});
}
};
const archiveDeletedGeneral = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
): Promise<void> => {
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const data = {
...event.before,
turnTime: event.before.turnTime.toISOString(),
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
};
await prisma.oldGeneral.upsert({
where: { by_no: { serverId, generalNo: event.generalId } },
update: {
owner: event.before.userId ?? null,
name: event.before.name,
lastYearMonth: event.year * 100 + event.month,
turnTime: event.before.turnTime,
data: asJson(data),
},
create: {
serverId,
generalNo: event.generalId,
owner: event.before.userId ?? null,
name: event.before.name,
lastYearMonth: event.year * 100 + event.month,
turnTime: event.before.turnTime,
data: asJson(data),
},
});
};
export const persistGeneralLifecycleEvents = async (
prisma: GamePrisma.TransactionClient,
events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>
): Promise<void> => {
if (events.length === 0) {
return;
}
await prisma.generalAccessLog.updateMany({
where: { generalId: { in: events.map((event) => event.generalId) } },
data: { refreshScore: 0 },
});
for (const event of events) {
if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
}
if (event.outcome === 'deleted') {
await archiveDeletedGeneral(prisma, event, worldMeta);
await settleInheritance(prisma, event, worldMeta, false, configConst);
}
if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta);
await settleInheritance(prisma, event, worldMeta, true, configConst);
await prisma.rankData.updateMany({
where: { generalId: event.generalId },
data: { value: 0 },
});
}
}
};
+38 -6
View File
@@ -42,6 +42,20 @@ export interface GeneralTurnResult {
nations?: Nation[];
troops?: Troop[];
};
deleted?: {
general: boolean;
troopIds?: number[];
};
lifecycleEvent?: GeneralLifecycleEvent;
}
export interface GeneralLifecycleEvent {
generalId: number;
outcome: 'active' | 'detached' | 'deleted' | 'retired';
before: TurnGeneral;
after?: TurnGeneral;
year: number;
month: number;
}
export interface GeneralTurnHandler {
@@ -87,6 +101,7 @@ export interface TurnWorldChanges {
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -254,6 +269,7 @@ export class InMemoryTurnWorld {
}> = [];
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -594,12 +610,14 @@ export class InMemoryTurnWorld {
});
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
const nextGeneral = {
...(result.general ?? currentGeneral),
turnTime: nextTurnAt,
};
this.generals.set(nextGeneral.id, nextGeneral);
this.dirtyGeneralIds.add(nextGeneral.id);
if (!result.deleted?.general) {
const nextGeneral = {
...(result.general ?? currentGeneral),
turnTime: nextTurnAt,
};
this.generals.set(nextGeneral.id, nextGeneral);
this.dirtyGeneralIds.add(nextGeneral.id);
}
if (result.city) {
this.cities.set(result.city.id, result.city);
@@ -697,6 +715,17 @@ export class InMemoryTurnWorld {
}
}
}
if (result.deleted?.troopIds) {
for (const troopId of result.deleted.troopIds) {
this.removeTroop(troopId);
}
}
if (result.deleted?.general) {
this.removeGeneral(currentGeneral.id);
}
if (result.lifecycleEvent) {
this.lifecycleEvents.push(result.lifecycleEvent);
}
this.removeCollapsedNations();
@@ -776,6 +805,7 @@ export class InMemoryTurnWorld {
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
const messages = this.messages.slice();
const lifecycleEvents = this.lifecycleEvents.slice();
return {
generals,
@@ -794,6 +824,7 @@ export class InMemoryTurnWorld {
createdTroops,
createdDiplomacy,
deletedEvents,
lifecycleEvents,
};
}
@@ -818,6 +849,7 @@ export class InMemoryTurnWorld {
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
}
consumeDirtyState(): TurnWorldChanges {
+315 -45
View File
@@ -30,6 +30,7 @@ import {
loadItemModules,
resolveUniqueConfig,
rollUniqueLottery,
getNextTurnAt,
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
@@ -103,6 +104,90 @@ const serializeSeed = (...values: Array<string | number>): string =>
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number): number => {
const value = asRecord(config.const)[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
...general,
stats: { ...general.stats },
role: {
...general.role,
items: { ...general.role.items },
},
meta: { ...general.meta },
triggerState: {
...general.triggerState,
flags: { ...general.triggerState.flags },
counters: { ...general.triggerState.counters },
modifiers: { ...general.triggerState.modifiers },
meta: { ...general.triggerState.meta },
},
});
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
const meta = { ...general.meta };
for (const key of [
'firenum',
'rank_warnum',
'rank_killnum',
'rank_deathnum',
'rank_occupied',
'rank_killcrew',
'rank_deathcrew',
'rank_killcrew_person',
'rank_deathcrew_person',
'rank_ttw',
'rank_ttd',
'rank_ttl',
'rank_ttg',
'rank_ttp',
'rank_tlw',
'rank_tld',
'rank_tll',
'rank_tlg',
'rank_tlp',
'rank_tsw',
'rank_tsd',
'rank_tsl',
'rank_tsg',
'rank_tsp',
'rank_tiw',
'rank_tid',
'rank_til',
'rank_tig',
'rank_tip',
'rank_betgold',
'rank_betwin',
'rank_betwingold',
'specage',
'specage2',
]) {
meta[key] = 0;
}
for (let dex = 1; dex <= 5; dex += 1) {
const key = `dex${dex}`;
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
}
meta.inherit_lived_month = 0;
meta.inherit_active_action = 0;
return {
...general,
stats: {
leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)),
strength: Math.max(10, Math.round(general.stats.strength * 0.85)),
intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)),
},
injury: 0,
experience: Math.round(general.experience * 0.5),
dedication: Math.round(general.dedication * 0.5),
age: 20,
meta,
};
};
type LegacyLastTurn = {
command: string;
arg?: Record<string, unknown>;
@@ -1056,6 +1141,12 @@ export const createReservedTurnHandler = async (options: {
};
};
const lifecycleBefore = cloneTurnGeneral(currentGeneral);
currentGeneral = cloneTurnGeneral(currentGeneral);
if (currentGeneral.npcState < 2) {
currentGeneral.meta.inherit_lived_month =
readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1;
}
const preprocessRng = new RandUtil(
new LiteHashDRBG(
serializeSeed(
@@ -1067,37 +1158,27 @@ export const createReservedTurnHandler = async (options: {
)
)
);
currentGeneral = {
...currentGeneral,
role: {
...currentGeneral.role,
items: { ...currentGeneral.role.items },
},
meta: { ...currentGeneral.meta },
triggerState: {
...currentGeneral.triggerState,
flags: { ...currentGeneral.triggerState.flags },
counters: { ...currentGeneral.triggerState.counters },
modifiers: { ...currentGeneral.triggerState.modifiers },
meta: { ...currentGeneral.triggerState.meta },
},
};
if (currentGeneral.npcState < 2) {
const lived =
typeof currentGeneral.meta.inherit_lived_month === 'number'
? currentGeneral.meta.inherit_lived_month
: 0;
currentGeneral.meta.inherit_lived_month = lived + 1;
}
currentCity = currentCity ? { ...currentCity, meta: { ...currentCity.meta } } : currentCity;
const cityGeneralCopies = new Map<number, TurnGeneral>();
for (const general of worldView?.listGenerals() ?? []) {
cityGeneralCopies.set(
general.id,
general.id === currentGeneral.id ? currentGeneral : cloneTurnGeneral(general)
);
}
cityGeneralCopies.set(currentGeneral.id, currentGeneral);
const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
const preTurnContext = createGeneralTriggerContext({
general: currentGeneral,
nation: currentNation,
worldView: worldView ?? undefined,
worldView: {
listGenerals: () => Array.from(cityGeneralCopies.values()),
listGeneralsByCity: (cityId) =>
Array.from(cityGeneralCopies.values()).filter((general) => general.cityId === cityId),
},
rng: preprocessRng,
log: {
push: (message: string) => logs.push(createActionLog(message)),
push: (message) => logs.push(createActionLog(message)),
},
});
preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv);
@@ -1110,14 +1191,16 @@ export const createReservedTurnHandler = async (options: {
if (consumeRice <= currentGeneral.rice) {
currentGeneral.rice -= consumeRice;
} else {
const releasedCrew = preTurnPipeline.onCalcDomestic(
preTurnContext,
'징집인구',
'score',
currentGeneral.crew
const releasedCrew = Math.trunc(
preTurnPipeline.onCalcDomestic(preTurnContext, '징집인구', 'score', currentGeneral.crew)
);
if (currentCity) {
currentCity.population += releasedCrew;
currentCity = {
...currentCity,
population: currentCity.population + releasedCrew,
meta: { ...currentCity.meta },
};
worldOverlay?.syncCity(currentCity);
}
currentGeneral.crew = 0;
currentGeneral.rice = 0;
@@ -1126,18 +1209,23 @@ export const createReservedTurnHandler = async (options: {
}
preTurnContext.skill.activate('pre.병력군량소모');
}
worldOverlay?.syncGeneral(currentGeneral);
if (currentCity) {
worldOverlay?.syncCity(currentCity);
for (const [generalId, next] of cityGeneralCopies) {
if (generalId === currentGeneral.id) {
continue;
}
const previous = worldView?.getGeneralById(generalId);
if (!previous || previous.injury === next.injury) {
continue;
}
patches.generals.push({ id: generalId, patch: { injury: next.injury } });
worldOverlay?.applyGeneralPatch(generalId, { injury: next.injury });
}
worldOverlay?.syncGeneral(currentGeneral);
const blockCode = typeof currentGeneral.meta.block === 'number' ? Math.trunc(currentGeneral.meta.block) : 0;
const blockCode = readMetaNumber(currentGeneral.meta, 'block', 0);
const isBlocked = blockCode === 2 || blockCode === 3;
if (isBlocked) {
currentGeneral.meta.killturn = Math.max(
0,
typeof currentGeneral.meta.killturn === 'number' ? currentGeneral.meta.killturn - 1 : 0
);
currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1);
logs.push(
createActionLog(
blockCode === 2
@@ -1147,12 +1235,16 @@ export const createReservedTurnHandler = async (options: {
);
}
let hasReservedTurn = false;
if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
let nationCommand = options.reservedTurns.getNationTurn(
currentNation.id,
currentGeneral.officerLevel,
0
);
if (nationCommand.action !== DEFAULT_ACTION) {
hasReservedTurn = true;
}
let nationAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
if (worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
@@ -1196,9 +1288,12 @@ export const createReservedTurnHandler = async (options: {
}
let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
if (!isBlocked && generalCommand.action !== DEFAULT_ACTION) {
hasReservedTurn = true;
}
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
let generalAutorunMode = false;
if (worldView && shouldUseAi(currentGeneral, context.world)) {
if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
general: currentGeneral,
city: currentCity,
@@ -1242,14 +1337,14 @@ export const createReservedTurnHandler = async (options: {
...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}),
...(generalAiState ? { aiState: generalAiState } : {}),
});
const nextTurnAt = generalResult.nextTurnAt;
let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined;
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
const worldMeta = asRecord(context.world.meta);
if (!isBlocked) {
const meta = { ...currentGeneral.meta };
const currentKillturn =
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
const worldKillturn = readMetaNumber(asRecord(context.world.meta), 'killturn', currentKillturn);
const currentKillturn = readMetaNumber(meta, 'killturn', 0);
const worldKillturn = readMetaNumber(worldMeta, 'killturn', currentKillturn);
const requestedRest = generalCommand.action === DEFAULT_ACTION;
if (
currentGeneral.npcState >= 2 ||
@@ -1261,19 +1356,178 @@ export const createReservedTurnHandler = async (options: {
} else {
meta.killturn = worldKillturn;
}
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
if (generalResult.actionKey !== DEFAULT_ACTION) {
meta.inherit_active_action = active + 1;
} else {
meta.inherit_active_action = active;
}
currentGeneral = { ...currentGeneral, meta };
worldOverlay?.syncGeneral(currentGeneral);
}
const incDefSettingChange = readConfigNumber(options.scenarioConfig, 'incDefSettingChange', 3);
const maxDefSettingChange = readConfigNumber(options.scenarioConfig, 'maxDefSettingChange', 9);
currentGeneral = {
...currentGeneral,
meta: {
...currentGeneral.meta,
myset: Math.min(
9,
(typeof currentGeneral.meta.myset === 'number' ? currentGeneral.meta.myset : 0) + 3
maxDefSettingChange,
readMetaNumber(currentGeneral.meta, 'myset', 0) + incDefSettingChange
),
},
};
const autorunUser = asRecord(worldMeta.autorun_user);
const autorunLimitMinutes = readMetaNumber(autorunUser, 'limit_minutes', 0);
if (hasReservedTurn && currentGeneral.npcState < 2 && autorunLimitMinutes > 0) {
const turnMinutes = Math.max(1, Math.round(context.world.tickSeconds / 60));
currentGeneral.meta.autorun_limit =
joinYearMonth(context.world.currentYear, context.world.currentMonth) +
Math.trunc(autorunLimitMinutes / turnMinutes);
}
const nextTurnTimeBase = readMetaNumber(currentGeneral.meta, 'nextTurnTimeBase', -1);
if (nextTurnTimeBase >= 0) {
const alignedNextTurn = nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, context.schedule);
nextTurnAt = new Date(alignedNextTurn.getTime() + nextTurnTimeBase * 1000);
delete currentGeneral.meta.nextTurnTimeBase;
}
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
let deleteGeneral = false;
const deletedTroopIds: number[] = [];
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
if (currentGeneral.meta.killturn <= 0) {
if (
currentGeneral.npcState === 1 &&
typeof currentGeneral.deadYear === 'number' &&
currentGeneral.deadYear > context.world.currentYear
) {
const npcOrg = readMetaNumber(currentGeneral.meta, 'npc_org', 2);
const ownerName =
typeof currentGeneral.meta.owner_name === 'string'
? currentGeneral.meta.owner_name
: currentGeneral.userId;
logs.push(
createActionLog(
`${ownerName ?? '사용자'}이 <Y>${currentGeneral.name}</>의 육체에서 <S>유체이탈</>합니다!`
)
);
currentGeneral = {
...currentGeneral,
userId: null,
npcState: npcOrg,
meta: {
...currentGeneral.meta,
killturn: (currentGeneral.deadYear - context.world.currentYear) * 12,
defence_train: 80,
owner_name: '',
},
};
lifecycleOutcome = 'detached';
} else {
if (currentGeneral.officerLevel === 12 && currentNation && worldView) {
const candidates = worldView
.listGenerals()
.filter(
(candidate) =>
candidate.id !== currentGeneral.id &&
candidate.nationId === currentGeneral.nationId &&
candidate.officerLevel !== 12 &&
candidate.npcState !== 5
);
let successor: TurnGeneral | undefined;
const fiction = readMetaNumber(worldMeta, 'fiction', 0);
if (
fiction === 0 &&
currentGeneral.npcState > 0 &&
typeof currentGeneral.affinity === 'number'
) {
const npcCandidates = candidates.filter(
(candidate) =>
candidate.npcState >= 1 &&
candidate.npcState <= 3 &&
typeof candidate.affinity === 'number'
);
const affinityDistance = (candidate: TurnGeneral): number => {
const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0));
return distance > 75 ? 150 - distance : distance;
};
const minDistance = Math.min(...npcCandidates.map(affinityDistance));
const nearest = npcCandidates.filter(
(candidate) => affinityDistance(candidate) === minDistance
);
if (nearest.length > 0) {
const rng = new RandUtil(
new LiteHashDRBG(
serializeSeed(
buildSeedBase(context.world),
'NextNPCRuler',
context.world.currentYear,
context.world.currentMonth,
currentGeneral.id
)
)
);
successor = rng.choice(nearest);
}
}
successor ??= candidates
.filter((candidate) => candidate.officerLevel >= 9)
.sort((left, right) => right.officerLevel - left.officerLevel || left.id - right.id)[0];
successor ??= candidates.sort(
(left, right) => right.dedication - left.dedication || left.id - right.id
)[0];
if (successor) {
patches.generals.push({
id: successor.id,
patch: { officerLevel: 12 },
});
currentNation = {
...currentNation,
chiefGeneralId: successor.id,
};
logs.push(
createActionLog(
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
)
);
}
}
if (currentGeneral.troopId === currentGeneral.id) {
deletedTroopIds.push(currentGeneral.id);
for (const member of worldView?.listGenerals() ?? []) {
if (member.id !== currentGeneral.id && member.troopId === currentGeneral.id) {
patches.generals.push({ id: member.id, patch: { troopId: 0 } });
}
}
}
if (currentNation) {
const gennum = readMetaNumber(asRecord(currentNation.meta), 'gennum', 0);
currentNation = {
...currentNation,
meta: {
...currentNation.meta,
gennum: Math.max(0, gennum - 1),
},
};
}
deleteGeneral = true;
lifecycleOutcome = 'deleted';
}
}
const retirementYear = readConfigNumber(options.scenarioConfig, 'retirementYear', 80);
if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) {
currentGeneral = resetRetiredGeneral(currentGeneral);
lifecycleOutcome = 'retired';
logs.push(
createActionLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.')
);
}
currentGeneral = {
...currentGeneral,
triggerState: {
@@ -1298,6 +1552,22 @@ export const createReservedTurnHandler = async (options: {
...(createdNations.length > 0 ? { nations: createdNations } : {}),
}
: undefined,
...(deleteGeneral
? {
deleted: {
general: true,
...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}),
},
}
: undefined),
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: lifecycleOutcome,
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot,
...(deleteGeneral ? {} : { after: currentGeneral }),
year: context.world.currentYear,
month: context.world.currentMonth,
},
};
return result;
+4
View File
@@ -21,6 +21,10 @@ export interface TurnWorldState {
}
export interface TurnGeneral extends General {
userId?: string | null;
bornYear?: number;
deadYear?: number;
affinity?: number | null;
turnTime: Date;
recentWarTime?: Date | null;
lastTurn?: GeneralLastTurn;
+4
View File
@@ -173,6 +173,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
return { meta: { ...meta, killturn } as TurnGeneral['meta'] };
})(),
id: row.id,
userId: row.userId,
name: row.name,
nationId: row.nationId,
cityId: row.cityId,
@@ -200,6 +201,9 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
bornYear: row.bornYear,
deadYear: row.deadYear,
affinity: row.affinity,
triggerState: {
flags: {},
counters: {},