feat: complete general turn lifecycle
This commit is contained in:
@@ -517,6 +517,18 @@ export const joinRouter = router({
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId: general.id },
|
||||
update: {
|
||||
userId,
|
||||
lastRefresh: new Date(),
|
||||
},
|
||||
create: {
|
||||
generalId: general.id,
|
||||
userId,
|
||||
lastRefresh: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (inheritRequiredPoint > 0) {
|
||||
await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint);
|
||||
@@ -618,25 +630,67 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await ctx.db.general.updateMany({
|
||||
where: {
|
||||
id: input.generalId,
|
||||
userId: null,
|
||||
npcState: { gte: 2 },
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
npcState: 1,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await ctx.db.$transaction!(async (db) => {
|
||||
const [candidate, worldState] = await Promise.all([
|
||||
db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
select: { npcState: true, meta: true },
|
||||
}),
|
||||
db.worldState.findFirst({
|
||||
select: { currentYear: true, currentMonth: true },
|
||||
}),
|
||||
]);
|
||||
if (!candidate || candidate.npcState < 2 || !worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
||||
const now = new Date();
|
||||
const updated = await db.general.updateMany({
|
||||
where: {
|
||||
id: input.generalId,
|
||||
userId: null,
|
||||
npcState: candidate.npcState,
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
npcState: 1,
|
||||
meta: {
|
||||
...asRecord(candidate.meta),
|
||||
npc_org: candidate.npcState,
|
||||
owner_name: ctx.auth?.user.displayName ?? '',
|
||||
pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1,
|
||||
killturn: 6,
|
||||
defence_train: 80,
|
||||
},
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (updated.count === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId: input.generalId },
|
||||
update: {
|
||||
userId,
|
||||
lastRefresh: now,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
},
|
||||
create: {
|
||||
generalId: input.generalId,
|
||||
userId,
|
||||
lastRefresh: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
@@ -236,6 +238,7 @@ const buildNationUpdate = (
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
capitalCityId: nation.capitalCityId,
|
||||
chiefGeneralId: nation.chiefGeneralId,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
level: nation.level,
|
||||
@@ -336,6 +339,7 @@ export const createDatabaseTurnHooks = async (
|
||||
createdNations,
|
||||
createdTroops,
|
||||
createdDiplomacy,
|
||||
lifecycleEvents,
|
||||
} = changes;
|
||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||
|
||||
@@ -354,6 +358,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);
|
||||
@@ -466,6 +476,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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -85,6 +99,7 @@ export interface TurnWorldChanges {
|
||||
createdNations: Nation[];
|
||||
createdTroops: Troop[];
|
||||
createdDiplomacy: TurnDiplomacy[];
|
||||
lifecycleEvents: GeneralLifecycleEvent[];
|
||||
}
|
||||
|
||||
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||
@@ -250,6 +265,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;
|
||||
@@ -572,12 +588,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);
|
||||
@@ -675,6 +693,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();
|
||||
|
||||
@@ -751,6 +780,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,
|
||||
@@ -768,6 +798,7 @@ export class InMemoryTurnWorld {
|
||||
createdNations,
|
||||
createdTroops,
|
||||
createdDiplomacy,
|
||||
lifecycleEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -791,6 +822,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 {
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_TURN_COMMAND_PROFILE,
|
||||
GeneralTurnCommandLoader,
|
||||
GeneralActionPipeline,
|
||||
NationTurnCommandLoader,
|
||||
createGeneralTriggerContext,
|
||||
defaultActionContextBuilder,
|
||||
evaluateConstraints,
|
||||
resolveGeneralAction,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
loadItemModules,
|
||||
resolveUniqueConfig,
|
||||
rollUniqueLottery,
|
||||
getNextTurnAt,
|
||||
type ItemModule,
|
||||
type UniqueLotteryRunner,
|
||||
} from '@sammo-ts/logic';
|
||||
@@ -101,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 NationLastTurn = {
|
||||
command: string;
|
||||
arg?: Record<string, unknown>;
|
||||
@@ -959,12 +1046,108 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
};
|
||||
|
||||
if (currentNation && currentGeneral.officerLevel >= 5) {
|
||||
const lifecycleBefore = cloneTurnGeneral(currentGeneral);
|
||||
currentGeneral = cloneTurnGeneral(currentGeneral);
|
||||
currentGeneral.meta.inherit_lived_month =
|
||||
readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1;
|
||||
|
||||
const preprocessRng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
serializeSeed(
|
||||
buildSeedBase(context.world),
|
||||
'preprocess',
|
||||
context.world.currentYear,
|
||||
context.world.currentMonth,
|
||||
currentGeneral.id
|
||||
)
|
||||
)
|
||||
);
|
||||
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: {
|
||||
listGenerals: () => Array.from(cityGeneralCopies.values()),
|
||||
listGeneralsByCity: (cityId) =>
|
||||
Array.from(cityGeneralCopies.values()).filter((general) => general.cityId === cityId),
|
||||
},
|
||||
rng: preprocessRng,
|
||||
log: {
|
||||
push: (message) => logs.push(createActionLog(message)),
|
||||
},
|
||||
});
|
||||
preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv);
|
||||
if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) {
|
||||
currentGeneral.injury = Math.max(0, currentGeneral.injury - 10);
|
||||
preTurnContext.skill.activate('pre.부상경감');
|
||||
}
|
||||
if (currentGeneral.crew >= 100) {
|
||||
const consumeRice = Math.trunc(currentGeneral.crew / 100);
|
||||
if (consumeRice <= currentGeneral.rice) {
|
||||
currentGeneral.rice -= consumeRice;
|
||||
} else {
|
||||
const releasedCrew = Math.trunc(
|
||||
preTurnPipeline.onCalcDomestic(preTurnContext, '징집인구', 'score', currentGeneral.crew)
|
||||
);
|
||||
if (currentCity) {
|
||||
currentCity = {
|
||||
...currentCity,
|
||||
population: currentCity.population + releasedCrew,
|
||||
meta: { ...currentCity.meta },
|
||||
};
|
||||
worldOverlay?.syncCity(currentCity);
|
||||
}
|
||||
currentGeneral.crew = 0;
|
||||
currentGeneral.rice = 0;
|
||||
logs.push(createActionLog('군량이 모자라 병사들이 <R>소집해제</>되었습니다!'));
|
||||
preTurnContext.skill.activate('pre.소집해제');
|
||||
}
|
||||
preTurnContext.skill.activate('pre.병력군량소모');
|
||||
}
|
||||
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 = readMetaNumber(currentGeneral.meta, 'block', 0);
|
||||
const isBlocked = blockCode === 2 || blockCode === 3;
|
||||
if (isBlocked) {
|
||||
currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1);
|
||||
logs.push(
|
||||
createActionLog(
|
||||
blockCode === 2
|
||||
? '현재 멀티, 또는 비매너로 인한<R>블럭</> 대상자입니다.'
|
||||
: '현재 악성유저로 분류되어 <R>블럭</> 대상자입니다.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -1003,10 +1186,17 @@ export const createReservedTurnHandler = async (options: {
|
||||
});
|
||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
||||
}
|
||||
if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
|
||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
||||
}
|
||||
|
||||
let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
|
||||
if (!isBlocked && generalCommand.action !== DEFAULT_ACTION) {
|
||||
hasReservedTurn = true;
|
||||
}
|
||||
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
let generalAutorunMode = false;
|
||||
if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
@@ -1026,11 +1216,20 @@ export const createReservedTurnHandler = async (options: {
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
if (candidate) {
|
||||
generalAutorunMode =
|
||||
candidate.action !== generalCommand.action ||
|
||||
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
|
||||
generalCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
generalAiState = ai.getDebugState();
|
||||
}
|
||||
const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
const generalResult = isBlocked
|
||||
? {
|
||||
actionKey: DEFAULT_ACTION,
|
||||
usedFallback: true,
|
||||
blockedReason: '블럭 대상자입니다.',
|
||||
}
|
||||
: runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
options.onActionResolved?.({
|
||||
kind: 'general',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -1041,15 +1240,25 @@ 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 (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) {
|
||||
if (!isBlocked) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0;
|
||||
const currentKillturn = readMetaNumber(meta, 'killturn', 0);
|
||||
const worldKillturn = readMetaNumber(worldMeta, 'killturn', currentKillturn);
|
||||
if (
|
||||
currentGeneral.npcState >= 2 ||
|
||||
currentKillturn > worldKillturn ||
|
||||
generalAutorunMode ||
|
||||
generalCommand.action === DEFAULT_ACTION
|
||||
) {
|
||||
meta.killturn = Math.max(0, currentKillturn - 1);
|
||||
} else {
|
||||
meta.killturn = worldKillturn;
|
||||
}
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_lived_month = lived + 1;
|
||||
if (generalResult.actionKey !== DEFAULT_ACTION) {
|
||||
meta.inherit_active_action = active + 1;
|
||||
} else {
|
||||
@@ -1059,6 +1268,176 @@ export const createReservedTurnHandler = async (options: {
|
||||
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(
|
||||
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: {
|
||||
...currentGeneral.triggerState,
|
||||
flags: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result: GeneralTurnResult = {
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
@@ -1075,6 +1454,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;
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface TurnWorldState {
|
||||
}
|
||||
|
||||
export interface TurnGeneral extends General {
|
||||
userId?: string | null;
|
||||
bornYear?: number;
|
||||
deadYear?: number;
|
||||
affinity?: number | null;
|
||||
turnTime: Date;
|
||||
recentWarTime?: Date | null;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,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,
|
||||
@@ -188,6 +189,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: {},
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
|
||||
const start = new Date('0200-01-01T00:00:00.000Z');
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const map = {
|
||||
id: 'general-lifecycle',
|
||||
name: '장수 lifecycle',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [],
|
||||
max: {
|
||||
population: 50_000,
|
||||
agriculture: 1_000,
|
||||
commerce: 1_000,
|
||||
security: 1_000,
|
||||
defence: 1_000,
|
||||
wall: 1_000,
|
||||
},
|
||||
initial: {
|
||||
population: 10_000,
|
||||
agriculture: 500,
|
||||
commerce: 500,
|
||||
security: 500,
|
||||
defence: 500,
|
||||
wall: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const makeGeneral = (patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '테스트장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 100,
|
||||
dedication: 80,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 40,
|
||||
atmos: 40,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
bornYear: 170,
|
||||
deadYear: 260,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime: start,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const makeSnapshot = (generals: TurnGeneral[]): TurnWorldSnapshot => ({
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
trainDelta: 30,
|
||||
atmosDelta: 30,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
initialNationGenLimit: 10,
|
||||
incDefSettingChange: 3,
|
||||
maxDefSettingChange: 9,
|
||||
retirementYear: 80,
|
||||
},
|
||||
environment: { mapName: map.id, unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '장수 lifecycle',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#000000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_중립',
|
||||
meta: { gennum: generals.length, tech: 0 },
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 50_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 500,
|
||||
defenceMax: 1_000,
|
||||
wall: 500,
|
||||
wallMax: 1_000,
|
||||
meta: { trust: 50, trade: 100, region: 1 },
|
||||
},
|
||||
],
|
||||
generals,
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
});
|
||||
|
||||
const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: start,
|
||||
meta: { killturn: 24, ...meta },
|
||||
});
|
||||
|
||||
describe('legacy general turn lifecycle', () => {
|
||||
it('runs preprocess before commands and restores crew population when rice is insufficient', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const general = harness.world.getGeneralById(1)!;
|
||||
expect(general.injury).toBe(15);
|
||||
expect(general.crew).toBe(0);
|
||||
expect(general.rice).toBe(0);
|
||||
expect(general.meta.inherit_lived_month).toBe(1);
|
||||
expect(harness.world.getCityById(1)!.population).toBe(10_200);
|
||||
});
|
||||
|
||||
it('skips blocked commands, decreases killturn once, and advances the queue', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([makeGeneral({ injury: 20, meta: { killturn: 5, block: 3 } })]),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const general = harness.world.getGeneralById(1)!;
|
||||
expect(general.injury).toBe(10);
|
||||
expect(general.experience).toBe(100);
|
||||
expect(general.meta.killturn).toBe(4);
|
||||
expect(general.meta.myset).toBe(3);
|
||||
expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식');
|
||||
});
|
||||
|
||||
it('updates killturn, autorun_limit, myset, and nextTurnTimeBase with legacy branches', async () => {
|
||||
const general = makeGeneral({
|
||||
injury: 20,
|
||||
meta: { killturn: 3, myset: 8, nextTurnTimeBase: 30 },
|
||||
});
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([general]),
|
||||
state: makeState({ autorun_user: { limit_minutes: 60, options: {} } }),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.meta.killturn).toBe(24);
|
||||
expect(updated.meta.myset).toBe(9);
|
||||
expect(updated.meta.autorun_limit).toBe(2406);
|
||||
expect(updated.meta.nextTurnTimeBase).toBeUndefined();
|
||||
expect(updated.turnTime.toISOString()).toBe('0200-01-01T00:10:30.000Z');
|
||||
});
|
||||
|
||||
it('detaches an expired possessed NPC instead of deleting its body', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([
|
||||
makeGeneral({
|
||||
npcState: 1,
|
||||
deadYear: 205,
|
||||
meta: { killturn: 1, npc_org: 3, owner_name: '소유자' },
|
||||
}),
|
||||
]),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.userId).toBeNull();
|
||||
expect(updated.npcState).toBe(3);
|
||||
expect(updated.meta.killturn).toBe(60);
|
||||
expect(updated.meta.defence_train).toBe(80);
|
||||
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('detached');
|
||||
});
|
||||
|
||||
it('deletes a zero-killturn general, clears its troop, and appoints a successor', async () => {
|
||||
const leader = makeGeneral({
|
||||
troopId: 1,
|
||||
officerLevel: 12,
|
||||
meta: { killturn: 1 },
|
||||
});
|
||||
const successor = makeGeneral({
|
||||
id: 2,
|
||||
userId: 'user-2',
|
||||
name: '후계자',
|
||||
troopId: 1,
|
||||
officerLevel: 9,
|
||||
dedication: 200,
|
||||
meta: { killturn: 24 },
|
||||
});
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([leader, successor]),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
expect(harness.world.getGeneralById(1)).toBeNull();
|
||||
expect(harness.world.getGeneralById(2)!.troopId).toBe(0);
|
||||
expect(harness.world.getGeneralById(2)!.officerLevel).toBe(12);
|
||||
expect(harness.world.getNationById(1)!.chiefGeneralId).toBe(2);
|
||||
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
|
||||
});
|
||||
|
||||
it('retires a player general and resets inherited stats and rank state', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot([
|
||||
makeGeneral({
|
||||
age: 80,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 101,
|
||||
dedication: 81,
|
||||
meta: {
|
||||
killturn: 24,
|
||||
dex1: 101,
|
||||
inherit_lived_month: 10,
|
||||
inherit_active_action: 4,
|
||||
rank_warnum: 12,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.age).toBe(20);
|
||||
expect(updated.stats).toEqual({ leadership: 68, strength: 60, intelligence: 51 });
|
||||
expect(updated.experience).toBe(51);
|
||||
expect(updated.dedication).toBe(41);
|
||||
expect(updated.meta.dex1).toBe(51);
|
||||
expect(updated.meta.inherit_lived_month).toBe(0);
|
||||
expect(updated.meta.inherit_active_action).toBe(0);
|
||||
expect(updated.meta.rank_warnum).toBe(0);
|
||||
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalIds = [990_001, 990_002, 990_003];
|
||||
const userIds = [
|
||||
'integration-lifecycle-dead',
|
||||
'integration-lifecycle-retired',
|
||||
'integration-lifecycle-possessed',
|
||||
];
|
||||
const serverId = 'integration-lifecycle';
|
||||
|
||||
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id,
|
||||
userId,
|
||||
name: `lifecycle-${id}`,
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 1_000,
|
||||
dedication: 800,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 80,
|
||||
npcState: 0,
|
||||
bornYear: 170,
|
||||
deadYear: 260,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {
|
||||
killturn: 0,
|
||||
inherit_lived_month: 10,
|
||||
inherit_active_action: 2,
|
||||
dex1: 1_000,
|
||||
},
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
...patch,
|
||||
});
|
||||
|
||||
const event = (
|
||||
general: TurnGeneral,
|
||||
outcome: GeneralLifecycleEvent['outcome']
|
||||
): GeneralLifecycleEvent => ({
|
||||
generalId: general.id,
|
||||
outcome,
|
||||
before: general,
|
||||
...(outcome === 'deleted' ? {} : { after: general }),
|
||||
year: 200,
|
||||
month: 1,
|
||||
});
|
||||
|
||||
integration('general turn lifecycle persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
|
||||
const cleanup = async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
||||
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
||||
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
close = () => connector.disconnect();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await close?.();
|
||||
});
|
||||
|
||||
it('archives deletion, removes access state, and settles inheritance', async () => {
|
||||
const general = makeGeneral(generalIds[0]!, userIds[0]!, {
|
||||
meta: {
|
||||
killturn: 0,
|
||||
inherit_lived_month: 10,
|
||||
inherit_active_action: 2,
|
||||
inheritRandomUnique: true,
|
||||
dex1: 1_000,
|
||||
},
|
||||
});
|
||||
await db.generalAccessLog.create({
|
||||
data: { generalId: general.id, userId: general.userId, refreshScore: 99 },
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId: general.userId!, key: 'previous', value: 100 },
|
||||
});
|
||||
await db.rankData.createMany({
|
||||
data: [
|
||||
{ generalId: general.id, nationId: 0, type: 'warnum', value: 2 },
|
||||
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
await db.$transaction((tx) =>
|
||||
persistGeneralLifecycleEvents(
|
||||
tx,
|
||||
[event(general, 'deleted')],
|
||||
{ serverId },
|
||||
{ inheritItemRandomPoint: 3_000, inheritSpecificSpecialPoint: 4_000 }
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull();
|
||||
expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull();
|
||||
expect(
|
||||
await db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 3_147 });
|
||||
});
|
||||
|
||||
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
|
||||
const general = makeGeneral(generalIds[1]!, userIds[1]!);
|
||||
await db.generalAccessLog.create({
|
||||
data: { generalId: general.id, userId: general.userId, refreshScore: 77 },
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId: general.userId!, key: 'previous', value: 50 },
|
||||
});
|
||||
await db.rankData.create({
|
||||
data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 },
|
||||
});
|
||||
|
||||
await db.$transaction((tx) =>
|
||||
persistGeneralLifecycleEvents(
|
||||
tx,
|
||||
[event(general, 'retired')],
|
||||
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({
|
||||
refreshScore: 0,
|
||||
});
|
||||
expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } }))
|
||||
.toMatchObject({ value: 0 });
|
||||
expect(
|
||||
await db.hallOfFame.findUnique({
|
||||
where: {
|
||||
serverId_type_generalNo: {
|
||||
serverId,
|
||||
type: 'experience',
|
||||
generalNo: general.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({ value: 1_000 });
|
||||
expect(
|
||||
await db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 116 });
|
||||
});
|
||||
|
||||
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
|
||||
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
|
||||
npcState: 1,
|
||||
meta: {
|
||||
killturn: 0,
|
||||
inherit_lived_month: 10,
|
||||
inherit_active_action: 2,
|
||||
pickYearMonth: 190 * 12,
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId: general.userId!, key: 'previous', value: 100 },
|
||||
});
|
||||
|
||||
await db.$transaction((tx) =>
|
||||
persistGeneralLifecycleEvents(
|
||||
tx,
|
||||
[event(general, 'deleted')],
|
||||
{ serverId, startYear: 180 },
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
await db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 100 });
|
||||
expect(
|
||||
await db.inheritanceResult.count({
|
||||
where: { serverId, owner: general.userId! },
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,20 @@ model General {
|
||||
@@map("general")
|
||||
}
|
||||
|
||||
model GeneralAccessLog {
|
||||
id Int @id @default(autoincrement())
|
||||
generalId Int @unique @map("general_id")
|
||||
userId String? @map("user_id")
|
||||
lastRefresh DateTime? @map("last_refresh")
|
||||
refresh Int @default(0)
|
||||
refreshTotal Int @default(0) @map("refresh_total")
|
||||
refreshScore Int @default(0) @map("refresh_score")
|
||||
refreshScoreTotal Int @default(0) @map("refresh_score_total")
|
||||
|
||||
@@index([userId])
|
||||
@@map("general_access_log")
|
||||
}
|
||||
|
||||
model RankData {
|
||||
id Int @id @default(autoincrement())
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "general_access_log" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"general_id" INTEGER NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"last_refresh" TIMESTAMP(3),
|
||||
"refresh" INTEGER NOT NULL DEFAULT 0,
|
||||
"refresh_total" INTEGER NOT NULL DEFAULT 0,
|
||||
"refresh_score" INTEGER NOT NULL DEFAULT 0,
|
||||
"refresh_score_total" INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "general_access_log_general_id_key" ON "general_access_log"("general_id");
|
||||
CREATE INDEX "general_access_log_user_id_idx" ON "general_access_log"("user_id");
|
||||
@@ -6,6 +6,7 @@ export interface DatabaseClient {
|
||||
$executeRaw: GamePrismaClient['$executeRaw'];
|
||||
worldState: GamePrisma.WorldStateDelegate;
|
||||
general: GamePrisma.GeneralDelegate;
|
||||
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
||||
city: GamePrisma.CityDelegate;
|
||||
nation: GamePrisma.NationDelegate;
|
||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface TurnEngineWorldStateRow {
|
||||
|
||||
export interface TurnEngineGeneralRow {
|
||||
id: number;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
@@ -42,6 +43,9 @@ export interface TurnEngineGeneralRow {
|
||||
atmos: number;
|
||||
age: number;
|
||||
npcState: number;
|
||||
bornYear: number;
|
||||
deadYear: number;
|
||||
affinity: number | null;
|
||||
meta: JsonValue;
|
||||
turnTime: Date;
|
||||
recentWarTime: Date | null;
|
||||
@@ -140,6 +144,7 @@ export interface TurnEngineWorldStateCreateInput {
|
||||
}
|
||||
|
||||
export interface TurnEngineGeneralUpdateInput {
|
||||
userId: string | null;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
@@ -264,6 +269,7 @@ export interface TurnEngineNationUpdateInput {
|
||||
name: string;
|
||||
color: string;
|
||||
capitalCityId: number | null;
|
||||
chiefGeneralId: number | null;
|
||||
gold: number;
|
||||
rice: number;
|
||||
level: number;
|
||||
|
||||
Reference in New Issue
Block a user