feat: port pre-start general deletion lifecycle
This commit is contained in:
@@ -88,6 +88,13 @@ const zTroopRename = z.object({
|
||||
|
||||
const zDieOnPrestart = z.object({
|
||||
type: z.literal('dieOnPrestart'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zEnsureDieOnPrestartStatus = z.object({
|
||||
type: z.literal('ensureDieOnPrestartStatus'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
@@ -415,6 +422,14 @@ const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) =>
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeEnsureDieOnPrestartStatus: CommandNormalizer<'ensureDieOnPrestartStatus'> = (envelope) => {
|
||||
const command = parseWith(zEnsureDieOnPrestartStatus, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeBuildNationCandidate: CommandNormalizer<'buildNationCandidate'> = (envelope) => {
|
||||
const command = parseWith(zBuildNationCandidate, envelope.command);
|
||||
if (!command) {
|
||||
@@ -629,6 +644,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
troopKick: normalizeTroopKick,
|
||||
troopRename: normalizeTroopRename,
|
||||
dieOnPrestart: normalizeDieOnPrestart,
|
||||
ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus,
|
||||
buildNationCandidate: normalizeBuildNationCandidate,
|
||||
instantRetreat: normalizeInstantRetreat,
|
||||
vacation: normalizeVacation,
|
||||
|
||||
@@ -74,13 +74,13 @@ const settleInheritance = async (
|
||||
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 randomUniqueRefund = meta.inheritRandomUnique
|
||||
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
|
||||
: 0;
|
||||
const specificSpecialRefund = meta.inheritSpecificSpecialWar
|
||||
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
|
||||
: 0;
|
||||
const refund = randomUniqueRefund + specificSpecialRefund;
|
||||
const lived = readNumber(meta, 'inherit_lived_month');
|
||||
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10;
|
||||
const maxDomestic = readNumber(meta, 'max_domestic_critical');
|
||||
@@ -129,6 +129,19 @@ const settleInheritance = async (
|
||||
}),
|
||||
},
|
||||
});
|
||||
for (const text of [
|
||||
...(randomUniqueRefund > 0 ? [`사망으로 랜덤 유니크 구입 ${randomUniqueRefund} 포인트 반환`] : []),
|
||||
...(specificSpecialRefund > 0 ? [`사망으로 전투 특기 지정 ${specificSpecialRefund} 포인트 반환`] : []),
|
||||
]) {
|
||||
await prisma.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year: event.year,
|
||||
month: event.month,
|
||||
text,
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -139,8 +152,7 @@ const settleInheritance = async (
|
||||
});
|
||||
};
|
||||
|
||||
const computeRate = (numerator: number, denominator: number): number =>
|
||||
denominator > 0 ? numerator / denominator : 0;
|
||||
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
|
||||
|
||||
const settleHall = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
@@ -186,7 +198,9 @@ const settleHall = async (
|
||||
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) : '';
|
||||
typeof asRecord(worldMeta.scenarioMeta).title === 'string'
|
||||
? String(asRecord(worldMeta.scenarioMeta).title)
|
||||
: '';
|
||||
const aux = {
|
||||
name: event.before.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
@@ -270,8 +284,12 @@ const archiveDeletedGeneral = async (
|
||||
orderBy: { id: 'desc' },
|
||||
select: { text: true },
|
||||
});
|
||||
const archivedMeta = { ...asRecord(event.before.meta) };
|
||||
delete archivedMeta.inheritRandomUnique;
|
||||
delete archivedMeta.inheritSpecificSpecialWar;
|
||||
const data = {
|
||||
...event.before,
|
||||
meta: archivedMeta,
|
||||
turnTime: event.before.turnTime.toISOString(),
|
||||
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
||||
history: history.map((entry) => entry.text),
|
||||
|
||||
@@ -699,6 +699,21 @@ export class InMemoryTurnWorld {
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteGeneralWithLifecycle(id: number, year: number, month: number): boolean {
|
||||
const general = this.generals.get(id);
|
||||
if (!general) {
|
||||
return false;
|
||||
}
|
||||
this.lifecycleEvents.push({
|
||||
generalId: id,
|
||||
outcome: 'deleted',
|
||||
before: structuredClone(general),
|
||||
year,
|
||||
month,
|
||||
});
|
||||
return this.removeGeneral(id);
|
||||
}
|
||||
|
||||
updateCity(id: number, patch: Partial<City>): City | null {
|
||||
const target = this.cities.get(id);
|
||||
if (!target) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { WarTraitKey } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||
@@ -702,6 +703,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config);
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId: input.userId,
|
||||
@@ -778,6 +780,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
tournament: 0,
|
||||
newvote: 0,
|
||||
inherit_spent_dyn: inheritRequiredPoint,
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
},
|
||||
};
|
||||
if (!world.addGeneral(general)) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
|
||||
const DEFAULT_MIN_TURNS = 2;
|
||||
|
||||
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
|
||||
const value = meta.prestart_delete_after;
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
export const buildPrestartDeleteAfter = (base: Date, tickSeconds: number, config: Record<string, unknown>): Date => {
|
||||
const configConst = asRecord(config.const);
|
||||
const minTurns = Math.max(0, Math.floor(asNumber(configConst.minTurnDieOnPrestart, DEFAULT_MIN_TURNS)));
|
||||
return new Date(base.getTime() + Math.max(1, Math.floor(tickSeconds)) * minTurns * 1_000);
|
||||
};
|
||||
|
||||
export const formatPrestartDeleteAfter = (value: Date): string => {
|
||||
const seoul = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
|
||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
||||
return [
|
||||
`${seoul.getUTCFullYear()}-${pad(seoul.getUTCMonth() + 1)}-${pad(seoul.getUTCDate())}`,
|
||||
`${pad(seoul.getUTCHours())}:${pad(seoul.getUTCMinutes())}:${pad(seoul.getUTCSeconds())}`,
|
||||
].join(' ');
|
||||
};
|
||||
@@ -1,12 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
JosaUtil,
|
||||
LiteHashDRBG,
|
||||
RandUtil,
|
||||
} from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
import {
|
||||
EventDomesticTraitLoader,
|
||||
@@ -18,15 +12,12 @@ import {
|
||||
|
||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||
|
||||
export type SelectPoolErrorCode =
|
||||
| 'BAD_REQUEST'
|
||||
| 'PRECONDITION_FAILED'
|
||||
| 'CONFLICT'
|
||||
| 'INTERNAL_SERVER_ERROR';
|
||||
export type SelectPoolErrorCode = 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
|
||||
export class SelectPoolError extends Error {
|
||||
constructor(
|
||||
@@ -98,10 +89,7 @@ export interface SelectPoolReservationDto {
|
||||
candidates: SelectPoolCandidateDto[];
|
||||
}
|
||||
|
||||
const fail = (
|
||||
code: SelectPoolErrorCode,
|
||||
message: string
|
||||
): never => {
|
||||
const fail = (code: SelectPoolErrorCode, message: string): never => {
|
||||
throw new SelectPoolError(code, message);
|
||||
};
|
||||
|
||||
@@ -135,12 +123,7 @@ export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number =>
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
asNumber(
|
||||
config.maxGeneral ??
|
||||
configConst.defaultMaxGeneral ??
|
||||
configConst.maxGeneral,
|
||||
DEFAULT_MAX_GENERAL
|
||||
)
|
||||
asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL)
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -174,9 +157,7 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
|
||||
|
||||
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
|
||||
|
||||
const toCandidateDto = async (
|
||||
candidate: SelectPoolCandidateInfo
|
||||
): Promise<SelectPoolCandidateDto> => {
|
||||
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
|
||||
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
|
||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||
: null;
|
||||
@@ -187,8 +168,7 @@ const toCandidateDto = async (
|
||||
strength: candidate.strength,
|
||||
intel: candidate.intel,
|
||||
specialDomestic: candidate.specialDomestic,
|
||||
specialDomesticName:
|
||||
trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
||||
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
||||
specialDomesticInfo: trait?.info ?? '',
|
||||
specialWar: candidate.specialWar ?? null,
|
||||
ego: candidate.ego ?? null,
|
||||
@@ -204,18 +184,12 @@ const toReservationDto = (
|
||||
): Promise<SelectPoolReservationDto> => {
|
||||
const validUntil = rows[0]?.reservedUntil;
|
||||
if (!validUntil) {
|
||||
throw new SelectPoolError(
|
||||
'INTERNAL_SERVER_ERROR',
|
||||
'장수 선택 후보의 유효기간이 없습니다.'
|
||||
);
|
||||
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||
}
|
||||
const expiresAt = validUntil;
|
||||
const sorted = rows
|
||||
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id
|
||||
);
|
||||
.sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id);
|
||||
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
|
||||
poolName: SUPPORTED_POOL,
|
||||
hasGeneral,
|
||||
@@ -229,16 +203,11 @@ const formatLegacySeedTime = (value: Date): string => {
|
||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||
koreaTime.getUTCDate()
|
||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
|
||||
koreaTime.getUTCSeconds()
|
||||
)}`;
|
||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
||||
};
|
||||
|
||||
export const buildSelectPoolSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
now: Date
|
||||
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
|
||||
export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string =>
|
||||
simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
|
||||
|
||||
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
|
||||
weighted: [T, number][];
|
||||
@@ -365,13 +334,7 @@ export const reserveSelectionPool = async (options: {
|
||||
}
|
||||
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
buildSelectPoolSeed(
|
||||
getWorldHiddenSeed(worldState),
|
||||
options.seedOwnerIdentity ?? userId,
|
||||
now
|
||||
)
|
||||
)
|
||||
new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now))
|
||||
);
|
||||
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
|
||||
const reservedUntil = new Date(
|
||||
@@ -398,10 +361,10 @@ export const reserveSelectionPool = async (options: {
|
||||
},
|
||||
});
|
||||
const reserved = selected.map((candidate) => ({
|
||||
...candidate,
|
||||
ownerUserId: userId,
|
||||
reservedUntil,
|
||||
}));
|
||||
...candidate,
|
||||
ownerUserId: userId,
|
||||
reservedUntil,
|
||||
}));
|
||||
if (reserved.length !== RESERVATION_COUNT) {
|
||||
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
@@ -413,10 +376,7 @@ const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> =>
|
||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
|
||||
};
|
||||
|
||||
const assertGeneralIdSnapshotMatches = async (
|
||||
db: DatabaseClient,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<void> => {
|
||||
const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise<void> => {
|
||||
const persistedIds = (
|
||||
await db.general.findMany({
|
||||
select: { id: true },
|
||||
@@ -427,13 +387,8 @@ const assertGeneralIdSnapshotMatches = async (
|
||||
.listGenerals()
|
||||
.map(({ id }) => id)
|
||||
.sort((left, right) => left - right);
|
||||
if (
|
||||
persistedIds.length !== runtimeIds.length ||
|
||||
persistedIds.some((id, index) => id !== runtimeIds[index])
|
||||
) {
|
||||
throw new Error(
|
||||
'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'
|
||||
);
|
||||
if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) {
|
||||
throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -468,12 +423,7 @@ const resolveRandomPersonality = (
|
||||
): string =>
|
||||
new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(
|
||||
getWorldHiddenSeed(worldState),
|
||||
'selectPickedGeneralPersonality',
|
||||
ownerIdentity,
|
||||
uniqueName
|
||||
)
|
||||
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneralPersonality', ownerIdentity, uniqueName)
|
||||
)
|
||||
).choice([...PERSONALITY_TRAIT_KEYS]);
|
||||
|
||||
@@ -495,19 +445,10 @@ const resolveSelectedPersonality = (
|
||||
return requested;
|
||||
};
|
||||
|
||||
const resolvePoolRng = (
|
||||
worldState: WorldStateRow,
|
||||
ownerIdentity: string | number,
|
||||
uniqueName: string
|
||||
): RandUtil =>
|
||||
const resolvePoolRng = (worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string): RandUtil =>
|
||||
new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(
|
||||
getWorldHiddenSeed(worldState),
|
||||
'selectPickedGeneral',
|
||||
ownerIdentity,
|
||||
uniqueName
|
||||
)
|
||||
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneral', ownerIdentity, uniqueName)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -612,16 +553,12 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const nextChangeAt = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
||||
const showImgLevel = asNumber(config.showImgLevel, 0);
|
||||
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
|
||||
const defaultSpecialWar =
|
||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
const personality = resolveSelectedPersonality(
|
||||
worldState,
|
||||
seedOwnerIdentity,
|
||||
uniqueName,
|
||||
options.personality
|
||||
);
|
||||
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
|
||||
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
|
||||
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
|
||||
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
|
||||
@@ -692,6 +629,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
dex5: info.dex[4],
|
||||
next_change: nextChangeAt.toISOString(),
|
||||
nextChangeAt: nextChangeAt.toISOString(),
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
npc_org: 0,
|
||||
},
|
||||
};
|
||||
@@ -884,6 +822,6 @@ export const getSelectionPoolStatus = async (
|
||||
poolName,
|
||||
allowOptions: resolvePoolAllowOptions(worldState),
|
||||
hasGeneral: Boolean(general),
|
||||
nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null,
|
||||
nextChangeAt: general ? (readNextChangeAt(general.meta)?.toISOString() ?? null) : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from './selectPoolService.js';
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -141,7 +142,15 @@ const resolveCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Extract<
|
||||
TurnDaemonCommand,
|
||||
{ type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }
|
||||
{
|
||||
type:
|
||||
| 'dieOnPrestart'
|
||||
| 'ensureDieOnPrestartStatus'
|
||||
| 'joinCreateGeneral'
|
||||
| 'npcPossessGeneral'
|
||||
| 'selectPoolCreate'
|
||||
| 'selectPoolReselect';
|
||||
}
|
||||
>
|
||||
): Promise<Date> => {
|
||||
if (!command.requestId) {
|
||||
@@ -1029,31 +1038,136 @@ async function handleTroopRename(
|
||||
};
|
||||
}
|
||||
|
||||
const ensurePrestartDeleteAfter = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' | 'ensureDieOnPrestartStatus' }>,
|
||||
general: TurnGeneral,
|
||||
acceptedAt: Date
|
||||
): Promise<Date> => {
|
||||
const current = readPrestartDeleteAfter(asRecord(general.meta));
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const access = await db.generalAccessLog.findUnique({
|
||||
where: { generalId: general.id },
|
||||
select: { lastRefresh: true },
|
||||
});
|
||||
const deleteAfter = buildPrestartDeleteAfter(
|
||||
access?.lastRefresh ?? acceptedAt,
|
||||
ctx.world.getState().tickSeconds,
|
||||
asRecord(ctx.world.getScenarioConfig())
|
||||
);
|
||||
const updated = ctx.world.updateGeneral(general.id, {
|
||||
meta: {
|
||||
...general.meta,
|
||||
prestart_delete_after: deleteAfter.toISOString(),
|
||||
},
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error(`${command.type} general disappeared while fixing the pre-start deletion time.`);
|
||||
}
|
||||
return deleteAfter;
|
||||
};
|
||||
|
||||
async function handleEnsureDieOnPrestartStatus(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const general = ctx.world.getGeneralById(command.generalId);
|
||||
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
|
||||
return {
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
generalId: command.generalId,
|
||||
show: false,
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const worldState = ctx.world.getState();
|
||||
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
|
||||
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
|
||||
return {
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
generalId: command.generalId,
|
||||
show: false,
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
const availableAt = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
|
||||
return {
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
generalId: command.generalId,
|
||||
show: true,
|
||||
available: availableAt.getTime() <= acceptedAt.getTime(),
|
||||
availableAt: availableAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDieOnPrestart(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
|
||||
return {
|
||||
type: 'dieOnPrestart',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '장수 정보를 찾을 수 없습니다.',
|
||||
reason: '장수가 없습니다',
|
||||
};
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const worldState = world.getState();
|
||||
const opentime = worldState.meta.opentime as string | undefined;
|
||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
|
||||
return {
|
||||
type: 'dieOnPrestart',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '게임이 시작되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (general.npcState !== 0 || general.nationId !== 0) {
|
||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' };
|
||||
if (general.nationId !== 0) {
|
||||
return {
|
||||
type: 'dieOnPrestart',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '이미 국가에 소속되어있습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
world.removeGeneral(command.generalId);
|
||||
const deleteAfter = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
|
||||
if (deleteAfter.getTime() > acceptedAt.getTime()) {
|
||||
return {
|
||||
type: 'dieOnPrestart',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: `아직 삭제할 수 없습니다. ${formatPrestartDeleteAfter(deleteAfter)} 부터 가능합니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (general.troopId === general.id) {
|
||||
for (const member of world.listGenerals()) {
|
||||
if (member.troopId === general.id) {
|
||||
world.updateGeneral(member.id, { troopId: 0 });
|
||||
}
|
||||
}
|
||||
world.removeTroop(general.id);
|
||||
}
|
||||
const josaYi = JosaUtil.pick(general.name, '이');
|
||||
world.pushLog({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: `<Y>${general.name}</>${josaYi} 홀연히 모습을 <R>감추었습니다</>`,
|
||||
meta: {},
|
||||
});
|
||||
world.deleteGeneralWithLifecycle(command.generalId, worldState.currentYear, worldState.currentMonth);
|
||||
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -2135,6 +2249,11 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
|
||||
troopRename: (command) =>
|
||||
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
|
||||
ensureDieOnPrestartStatus: (command) =>
|
||||
handleEnsureDieOnPrestartStatus(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
|
||||
),
|
||||
dieOnPrestart: (command) =>
|
||||
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||
buildNationCandidate: (command) =>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const acceptedAt = new Date('2026-07-31T00:00:00.000Z');
|
||||
|
||||
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id: 7,
|
||||
userId: 'owner-7',
|
||||
name: '테스트장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
turnTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
penalty: {},
|
||||
officerLevel: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
...overrides,
|
||||
meta: { killturn: 6, ...overrides.meta },
|
||||
});
|
||||
|
||||
const buildFixture = (options: {
|
||||
generals?: TurnGeneral[];
|
||||
minTurns?: number;
|
||||
lastTurnTime?: Date;
|
||||
troops?: TurnWorldSnapshot['troops'];
|
||||
lastRefresh?: Date | null;
|
||||
eventActor?: string;
|
||||
}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
|
||||
meta: { opentime: '2026-08-01T00:00:00.000Z' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: options.generals ?? [buildGeneral()],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: options.troops ?? [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
...(options.minTurns === undefined ? {} : { minTurnDieOnPrestart: options.minTurns }),
|
||||
},
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const inputEvent = {
|
||||
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
|
||||
createdAt: acceptedAt,
|
||||
actorUserId: options.eventActor ?? 'owner-7',
|
||||
target: 'ENGINE',
|
||||
eventType: where.requestId.startsWith('status-') ? 'ensureDieOnPrestartStatus' : 'dieOnPrestart',
|
||||
})),
|
||||
};
|
||||
const generalAccessLog = {
|
||||
findUnique: vi.fn(async () =>
|
||||
options.lastRefresh === null ? null : { lastRefresh: options.lastRefresh ?? acceptedAt }
|
||||
),
|
||||
};
|
||||
const db = { inputEvent, generalAccessLog } as unknown as GamePrisma.TransactionClient;
|
||||
return {
|
||||
world,
|
||||
db,
|
||||
handler: createTurnDaemonCommandHandler({ world }),
|
||||
inputEvent,
|
||||
generalAccessLog,
|
||||
};
|
||||
};
|
||||
|
||||
describe('pre-start general deletion', () => {
|
||||
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
|
||||
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
|
||||
expect(
|
||||
buildPrestartDeleteAfter(acceptedAt, 600, {
|
||||
const: { minTurnDieOnPrestart: 1 },
|
||||
}).toISOString()
|
||||
).toBe('2026-07-31T00:10:00.000Z');
|
||||
expect(formatPrestartDeleteAfter(new Date('2026-07-31T00:20:00.000Z'))).toBe('2026-07-31 09:20:00');
|
||||
});
|
||||
|
||||
it('fixes a legacy missing cutoff once from lastRefresh and returns it on later status requests', async () => {
|
||||
const lastRefresh = new Date('2026-07-30T23:55:00.000Z');
|
||||
const fixture = buildFixture({ lastRefresh });
|
||||
const first = await fixture.handler.handle(
|
||||
{
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
requestId: 'status-first',
|
||||
userId: 'owner-7',
|
||||
generalId: 7,
|
||||
},
|
||||
{ db: fixture.db }
|
||||
);
|
||||
expect(first).toEqual({
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
generalId: 7,
|
||||
show: true,
|
||||
available: false,
|
||||
availableAt: '2026-07-31T00:15:00.000Z',
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:15:00.000Z');
|
||||
|
||||
const second = await fixture.handler.handle(
|
||||
{
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
requestId: 'status-second',
|
||||
userId: 'owner-7',
|
||||
generalId: 7,
|
||||
},
|
||||
{ db: fixture.db }
|
||||
);
|
||||
expect(second).toEqual(first);
|
||||
expect(fixture.generalAccessLog.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves the Ref error order and validates the durable event actor', async () => {
|
||||
const started = buildFixture({
|
||||
generals: [buildGeneral({ nationId: 1 })],
|
||||
lastTurnTime: new Date('2026-08-02T00:00:00.000Z'),
|
||||
});
|
||||
await expect(
|
||||
started.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-started', userId: 'owner-7', generalId: 7 },
|
||||
{ db: started.db }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '게임이 시작되었습니다.' });
|
||||
|
||||
const nation = buildFixture({ generals: [buildGeneral({ nationId: 1 })] });
|
||||
await expect(
|
||||
nation.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-nation', userId: 'owner-7', generalId: 7 },
|
||||
{ db: nation.db }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '이미 국가에 소속되어있습니다.' });
|
||||
|
||||
const actorMismatch = buildFixture({ eventActor: 'foreign-user' });
|
||||
await expect(
|
||||
actorMismatch.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-actor', userId: 'owner-7', generalId: 7 },
|
||||
{ db: actorMismatch.db }
|
||||
)
|
||||
).rejects.toThrow('input event actor does not match dieOnPrestart user');
|
||||
|
||||
const ownerMismatch = buildFixture({});
|
||||
await expect(
|
||||
ownerMismatch.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-owner', userId: 'foreign-user', generalId: 7 },
|
||||
{ db: ownerMismatch.db }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '장수가 없습니다' });
|
||||
});
|
||||
|
||||
it('allows the equality boundary and queues troop cleanup, lifecycle, and exact global log together', async () => {
|
||||
const leader = buildGeneral({
|
||||
troopId: 7,
|
||||
meta: { killturn: 3, prestart_delete_after: acceptedAt.toISOString() },
|
||||
});
|
||||
const member = buildGeneral({ id: 8, userId: 'owner-8', name: '부대원', troopId: 7 });
|
||||
const fixture = buildFixture({
|
||||
generals: [leader, member],
|
||||
troops: [{ id: 7, nationId: 0, name: '테스트부대' }],
|
||||
});
|
||||
await expect(
|
||||
fixture.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-success', userId: 'owner-7', generalId: 7 },
|
||||
{ db: fixture.db }
|
||||
)
|
||||
).resolves.toEqual({ type: 'dieOnPrestart', ok: true, generalId: 7 });
|
||||
|
||||
expect(fixture.world.getGeneralById(7)).toBeNull();
|
||||
expect(fixture.world.getGeneralById(8)?.troopId).toBe(0);
|
||||
const changes = fixture.world.peekDirtyState();
|
||||
expect(changes.deletedTroops).toEqual([7]);
|
||||
expect(changes.deletedGenerals).toEqual([7]);
|
||||
expect(changes.lifecycleEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
generalId: 7,
|
||||
outcome: 'deleted',
|
||||
before: expect.objectContaining({ troopId: 0 }),
|
||||
year: 185,
|
||||
month: 1,
|
||||
}),
|
||||
]);
|
||||
expect(changes.logs).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: 'SYSTEM',
|
||||
category: 'SUMMARY',
|
||||
text: '<Y>테스트장수</>가 홀연히 모습을 <R>감추었습니다</>',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists a legacy cutoff on early failure without queuing deletion or a log', async () => {
|
||||
const fixture = buildFixture({ lastRefresh: acceptedAt, minTurns: 1 });
|
||||
await expect(
|
||||
fixture.handler.handle(
|
||||
{ type: 'dieOnPrestart', requestId: 'die-early', userId: 'owner-7', generalId: 7 },
|
||||
{ db: fixture.db }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '아직 삭제할 수 없습니다. 2026-07-31 09:10:00 부터 가능합니다.',
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:10:00.000Z');
|
||||
expect(fixture.world.peekDirtyState()).toMatchObject({
|
||||
deletedGenerals: [],
|
||||
lifecycleEvents: [],
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,8 @@ 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 userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
|
||||
const serverId = 'lifecycle-int';
|
||||
|
||||
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id,
|
||||
@@ -57,10 +53,7 @@ const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {
|
||||
...patch,
|
||||
});
|
||||
|
||||
const event = (
|
||||
general: TurnGeneral,
|
||||
outcome: GeneralLifecycleEvent['outcome']
|
||||
): GeneralLifecycleEvent => ({
|
||||
const event = (general: TurnGeneral, outcome: GeneralLifecycleEvent['outcome']): GeneralLifecycleEvent => ({
|
||||
generalId: general.id,
|
||||
outcome,
|
||||
before: general,
|
||||
@@ -153,12 +146,24 @@ integration('general turn lifecycle persistence', () => {
|
||||
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||
where: { by_no: { serverId, generalNo: general.id } },
|
||||
});
|
||||
expect(asRecord(archived.data).history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
||||
const archivedData = asRecord(archived.data);
|
||||
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
||||
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
|
||||
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
expect(
|
||||
await db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 3_147 });
|
||||
expect(
|
||||
(
|
||||
await db.inheritanceLog.findMany({
|
||||
where: { userId: general.userId! },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { text: true },
|
||||
})
|
||||
).map(({ text }) => text)
|
||||
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,147 포인트']);
|
||||
});
|
||||
|
||||
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
|
||||
@@ -185,8 +190,9 @@ integration('general turn lifecycle persistence', () => {
|
||||
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.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })
|
||||
).toMatchObject({ value: 0 });
|
||||
expect(
|
||||
await db.hallOfFame.findUnique({
|
||||
where: {
|
||||
@@ -220,12 +226,7 @@ integration('general turn lifecycle persistence', () => {
|
||||
});
|
||||
|
||||
await db.$transaction((tx) =>
|
||||
persistGeneralLifecycleEvents(
|
||||
tx,
|
||||
[event(general, 'deleted')],
|
||||
{ serverId, startYear: 180 },
|
||||
{}
|
||||
)
|
||||
persistGeneralLifecycleEvents(tx, [event(general, 'deleted')], { serverId, startYear: 180 }, {})
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -37,7 +37,11 @@ const archivedGeneral = (): TurnGeneral => ({
|
||||
deadYear: 240,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 0 },
|
||||
meta: {
|
||||
killturn: 0,
|
||||
inheritRandomUnique: true,
|
||||
inheritSpecificSpecialWar: true,
|
||||
},
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
@@ -79,6 +83,7 @@ describe('general lifecycle archive history', () => {
|
||||
create: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
||||
meta: { killturn: 0 },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user