merge: 최신 main을 턴 실패 개인 기록 작업에 반영

# Conflicts:
#	app/game-engine/src/turn/reservedTurnHandler.ts
This commit is contained in:
2026-08-20 16:25:00 +00:00
44 changed files with 2619 additions and 276 deletions
+72
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
@@ -23,6 +24,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
interface AuctionRow {
id: number;
type: AuctionType;
targetCode: string | null;
hostGeneralId: number;
detail: unknown;
status: AuctionStatus;
@@ -99,6 +101,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
GamePrisma.sql`
SELECT id,
type,
target_code as "targetCode",
host_general_id as "hostGeneralId",
detail,
status,
@@ -178,6 +181,7 @@ export const createAuctionBidder = async (options: {
await connector.connect();
const prisma = connector.prisma;
const world = options.world;
const itemLoader = new ItemLoader();
return {
bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
@@ -290,6 +294,74 @@ export const createAuctionBidder = async (options: {
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (auction.type === 'UNIQUE_ITEM') {
const itemKey = auction.targetCode;
if (!itemKey || !isItemKey(itemKey)) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '아이템이 올바르지 않습니다.',
};
}
const item = await itemLoader.load(itemKey).catch(() => null);
if (!item || item.buyable) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: item ? '구매할 수 있는 아이템입니다.' : '아이템 정보를 불러올 수 없습니다.',
};
}
const currentSlotItem = general.role.items[item.slot];
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
if (currentItem && !currentItem.buyable) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason:
currentSlotItem === itemKey
? '이미 그 유니크를 가지고 있습니다.'
: '이미 다른 유니크를 가지고 있습니다.',
};
}
}
const otherHighestBids = await db.$queryRaw<Array<{ auctionId: number; targetCode: string | null }>>(
GamePrisma.sql`
SELECT candidate.id as "auctionId", candidate.target_code as "targetCode"
FROM auction candidate
INNER JOIN LATERAL (
SELECT bid.general_id
FROM auction_bid bid
WHERE bid.auction_id = candidate.id
ORDER BY bid.amount DESC, bid.id ASC
LIMIT 1
) highest ON true
WHERE candidate.type = 'UNIQUE_ITEM'
AND candidate.status IN ('OPEN', 'FINALIZING')
AND candidate.id <> ${auction.id}
AND highest.general_id = ${command.generalId}
`
);
for (const other of otherHighestBids) {
if (!other.targetCode || !isItemKey(other.targetCode)) {
continue;
}
const otherItem = await itemLoader.load(other.targetCode).catch(() => null);
if (otherItem?.slot === item.slot) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
};
}
}
}
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
return {
type: 'auctionBid',
+13 -3
View File
@@ -1,5 +1,6 @@
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
import { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -18,6 +19,8 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
const MIN_EXTENSION_MINUTES_PER_BID = 1;
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
const COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT = 24;
const MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY = 5;
interface AuctionRow {
id: number;
@@ -363,7 +366,10 @@ export const createAuctionFinalizer = async (options: {
}
const state = world.getState();
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
const config = await resolveLegacyCompatibleUniqueConfig(
asRecord(world.getScenarioConfig().const),
itemLoader
);
const scenarioMeta = asRecord(state.meta.scenarioMeta);
const startYear =
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
@@ -392,7 +398,11 @@ export const createAuctionFinalizer = async (options: {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
Math.max(
MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY,
turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT
) *
60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
+6 -11
View File
@@ -2,14 +2,8 @@ import { randomUUID } from 'node:crypto';
import { asRecord, JosaUtil } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
import {
ActionLogger,
ItemLoader,
LogFormat,
buildAuctionAlias,
isItemKey,
resolveUniqueConfig,
} from '@sammo-ts/logic';
import { ActionLogger, ItemLoader, LogFormat, buildAuctionAlias, isItemKey } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
@@ -147,7 +141,8 @@ const openUniqueAuction = async (
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
}
const item = await new ItemLoader().load(itemKey).catch(() => null);
const itemLoader = new ItemLoader();
const item = await itemLoader.load(itemKey).catch(() => null);
if (!item) {
return fail('아이템 정보를 불러올 수 없습니다.');
}
@@ -156,7 +151,7 @@ const openUniqueAuction = async (
}
const currentSlotItem = general.role.items[item.slot];
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null);
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
if (currentItem && !currentItem.buyable) {
return fail('이미 가진 아이템이 있습니다.');
}
@@ -189,7 +184,7 @@ const openUniqueAuction = async (
return fail('아직 경매가 끝나지 않았습니다.');
}
const uniqueConfig = resolveUniqueConfig(configConst);
const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst, itemLoader);
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
const occupiedAmount = world
.listGenerals()
+14 -3
View File
@@ -323,9 +323,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
options: install.autorunUser.options,
};
}
const archivedWorldMeta = { ...worldMeta };
delete archivedWorldMeta.hiddenSeed;
await connector.connect();
try {
const result: ScenarioSeedResult = { seed, warnings, applied: true };
@@ -383,6 +380,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
await prisma.worldState.deleteMany();
}
const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId : undefined;
const completedGameCount = await prisma.gameHistory.count({
where: {
status: 'COMPLETED',
...(serverId ? { serverId: { not: serverId } } : {}),
},
});
// Ref fixes server_cnt once during ResetHelper initialization. Keep the
// frequently rendered game index in the same persisted read model and
// exclude abandoned or unfinished rows from the official sequence.
worldMeta.gameIdx = completedGameCount + 1;
const archivedWorldMeta = { ...worldMeta };
delete archivedWorldMeta.hiddenSeed;
await prisma.worldState.create({
data: {
scenarioCode: String(options.scenarioId),
+11 -2
View File
@@ -814,7 +814,11 @@ export class InMemoryTurnWorld {
};
}
pushLog(entry: LogEntryDraft): void {
pushLog(entry: LogEntryDraft, occurredAt?: Date): void {
if (occurredAt && !entry.occurredAt) {
this.logs.push({ ...entry, occurredAt: new Date(occurredAt.getTime()) });
return;
}
this.logs.push(entry);
}
@@ -1382,7 +1386,12 @@ export class InMemoryTurnWorld {
this.dirtyNationIds.add(result.nation.id);
}
if (result.logs && result.logs.length > 0) {
this.logs.push(...result.logs);
// Ref command logs use the executing general's pre-advance turntime.
// Preserve that per-entry occurrence time instead of replacing every
// log in the transaction with the shared completion cursor at flush.
for (const log of result.logs) {
this.pushLog(log, currentGeneral.turnTime);
}
}
if (result.messages && result.messages.length > 0) {
this.messages.push(...result.messages);
@@ -2273,9 +2273,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
`${reason} ${definition.name} 실패.`;
if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') {
options.world.pushLog({
...createGeneralActionLog(general.id, failureText),
});
options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime);
}
return { ok: false, reason: failureText };
}
@@ -2352,7 +2350,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
for (const log of resolution.logs) {
options.world.pushLog(log);
options.world.pushLog(log, general.turnTime);
}
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
}
@@ -2436,7 +2434,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
options.world.removeTroop(troopId);
}
for (const log of [...resolution.logs, ...progressionLogs]) {
options.world.pushLog(log);
options.world.pushLog(log, general.turnTime);
}
options.world.updateGeneral(input.generalId, nextGeneral);
return { ok: true };
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
@@ -15,6 +16,7 @@ integration('general access score reset persistence', () => {
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId } });
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
@@ -33,8 +35,9 @@ integration('general access score reset persistence', () => {
await closeDb?.();
});
it('commits the own-turn reset marker in the same world flush', async () => {
it('commits the own-turn reset marker and per-entry log occurrence time in the same world flush', async () => {
const turnTime = new Date('2026-08-15T00:10:00.000Z');
const occurredAt = new Date('2026-08-15T00:07:43.000Z');
await db.general.create({
data: {
id: generalId,
@@ -95,6 +98,13 @@ integration('general access score reset persistence', () => {
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
world.markGeneralAccessScoreReset(generalId);
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
text: '<C>●</>1월:아무것도 실행하지 않았습니다.',
generalId,
occurredAt,
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
@@ -113,6 +123,12 @@ integration('general access score reset persistence', () => {
refreshScoreTotal: 999,
});
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]);
expect(
await db.logEntry.findFirstOrThrow({
where: { generalId, category: LogCategory.ACTION },
select: { createdAt: true },
})
).toEqual({ createdAt: occurredAt });
} finally {
await hooks.close();
}
@@ -159,6 +159,25 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
});
describe('legacy general turn lifecycle', () => {
it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => {
const flushCursor = new Date('0200-01-01T00:35:00.000Z');
const generalTurnTime = new Date('0200-01-01T00:37:43.000Z');
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([makeGeneral({ turnTime: generalTurnTime })]),
state: { ...makeState(), lastTurnTime: flushCursor },
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: '휴식', args: {} };
await harness.runOneTick();
const actionLog = harness.world
.peekDirtyState()
.logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.'));
expect(actionLog?.occurredAt).toEqual(generalTurnTime);
});
it('emits legacy plain logs when command gains cross experience and dedication levels', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
@@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => {
},
]);
});
it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => {
const nation = {
id: 1,
meta: {
rate: 20,
spy: { 2: 3 },
},
};
const handler = createNationTurnMonthlyHandler({
getWorld: () =>
({
listNations: () => [nation],
updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => {
if (patch.meta) nation.meta = patch.meta;
},
}) as never,
});
expect(nation.meta.spy).toEqual({ 2: 3 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({ 2: 2 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({ 2: 1 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({});
});
});
@@ -128,6 +128,58 @@ describeDb('scenario database seed', () => {
}
});
test('persists the next official game index without counting cancelled or unfinished games', async () => {
const marker = `scenario-seeder-game-index-${Date.now()}`;
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
await connector.prisma.gameHistory.createMany({
data: [
{
serverId: `${marker}-completed`,
date: new Date('2026-08-01T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '정상 종료 fixture',
status: 'COMPLETED',
},
{
serverId: `${marker}-abandoned`,
date: new Date('2026-08-02T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '취소 fixture',
status: 'ABANDONED',
},
{
serverId: `${marker}-open`,
date: new Date('2026-08-03T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '미완료 fixture',
status: 'OPEN',
},
],
});
await seedScenarioToDatabase({
scenarioId: 1010,
databaseUrl,
installOptions: { serverId: marker },
});
const worldState = await connector.prisma.worldState.findFirstOrThrow();
expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 });
await expect(
connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } })
).resolves.toMatchObject({ status: 'OPEN' });
} finally {
await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } });
await connector.disconnect();
}
});
test('writes scenario data into tables', async () => {
const { seed } = await seedScenarioToDatabase({
scenarioId,